Compare commits

..

2 Commits
dev ... review

Author SHA1 Message Date
Dax Raad
7f2dada012 refactor: clean up dialog-model.tsx per code review
- Extract duplicated favorite/recent option builder into toOptions helper
- Simplify showExtra memo to single boolean expression
- Simplify title memo with optional chaining
- Remove dead ref signal and unused DialogSelectRef import
- Inline intermediate variables (q, value aliases)
- Use implicit returns in map callbacks
2026-02-10 09:44:02 -05:00
Dax Raad
0ba88ac5a1 review 2026-02-10 09:34:52 -05:00
635 changed files with 23516 additions and 11111 deletions

1
.github/VOUCHED.td vendored
View File

@@ -15,5 +15,4 @@ kitlangton
kommander
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr

View File

@@ -1,6 +1,6 @@
### What does this PR do?
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the pr.
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**

View File

@@ -1,883 +0,0 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3-pro
---
You are a professional translator and localization specialist.
Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE).
Requirements:
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
- Also preserve every term listed in the Do-Not-Translate glossary below.
- Do not modify fenced code blocks.
- Output ONLY the translation (no commentary).
If the target locale is missing, ask the user to provide it.
---
# Do-Not-Translate Terms (OpenCode Docs)
Generated from: `packages/web/src/content/docs/*.mdx` (default English docs)
Generated on: 2026-02-10
Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation).
General rules (verbatim, even if not listed below):
- Anything inside inline code (single backticks) or fenced code blocks (triple backticks)
- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers
- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars
## Proper nouns and product names
Additional (not reliably captured via link text):
```text
Astro
Bun
Chocolatey
Cursor
Docker
Git
GitHub Actions
GitLab CI
GNOME Terminal
Homebrew
Mise
Neovim
Node.js
npm
Obsidian
opencode
opencode-ai
Paru
pnpm
ripgrep
Scoop
SST
Starlight
Visual Studio Code
VS Code
VSCodium
Windsurf
Windows Terminal
Yarn
Zellij
Zed
anomalyco
```
Extracted from link labels in the English docs (review and prune as desired):
```text
@openspoon/subtask2
302.AI console
ACP progress report
Agent Client Protocol
Agent Skills
Agentic
AGENTS.md
AI SDK
Alacritty
Anthropic
Anthropic's Data Policies
Atom One
Avante.nvim
Ayu
Azure AI Foundry
Azure portal
Baseten
built-in GITHUB_TOKEN
Bun.$
Catppuccin
Cerebras console
ChatGPT Plus or Pro
Cloudflare dashboard
CodeCompanion.nvim
CodeNomad
Configuring Adapters: Environment Variables
Context7 MCP server
Cortecs console
Deep Infra dashboard
DeepSeek console
Duo Agent Platform
Everforest
Fireworks AI console
Firmware dashboard
Ghostty
GitLab CLI agents docs
GitLab docs
GitLab User Settings > Access Tokens
Granular Rules (Object Syntax)
Grep by Vercel
Groq console
Gruvbox
Helicone
Helicone documentation
Helicone Header Directory
Helicone's Model Directory
Hugging Face Inference Providers
Hugging Face settings
install WSL
IO.NET console
JetBrains IDE
Kanagawa
Kitty
MiniMax API Console
Models.dev
Moonshot AI console
Nebius Token Factory console
Nord
OAuth
Ollama integration docs
OpenAI's Data Policies
OpenChamber
OpenCode
OpenCode config
OpenCode Config
OpenCode TUI with the opencode theme
OpenCode Web - Active Session
OpenCode Web - New Session
OpenCode Web - See Servers
OpenCode Zen
OpenCode-Obsidian
OpenRouter dashboard
OpenWork
OVHcloud panel
Pro+ subscription
SAP BTP Cockpit
Scaleway Console IAM settings
Scaleway Generative APIs
SDK documentation
Sentry MCP server
shell API
Together AI console
Tokyonight
Unified Billing
Venice AI console
Vercel dashboard
WezTerm
Windows Subsystem for Linux (WSL)
WSL
WSL (Windows Subsystem for Linux)
WSL extension
xAI console
Z.AI API console
Zed
ZenMux dashboard
Zod
```
## Acronyms and initialisms
```text
ACP
AGENTS
AI
AI21
ANSI
API
AST
AWS
BTP
CD
CDN
CI
CLI
CMD
CORS
DEBUG
EKS
ERROR
FAQ
GLM
GNOME
GPT
HTML
HTTP
HTTPS
IAM
ID
IDE
INFO
IO
IP
IRSA
JS
JSON
JSONC
K2
LLM
LM
LSP
M2
MCP
MR
NET
NPM
NTLM
OIDC
OS
PAT
PATH
PHP
PR
PTY
README
RFC
RPC
SAP
SDK
SKILL
SSE
SSO
TS
TTY
TUI
UI
URL
US
UX
VCS
VPC
VPN
VS
WARN
WSL
X11
YAML
```
## Code identifiers used in prose (CamelCase, mixedCase)
```text
apiKey
AppleScript
AssistantMessage
baseURL
BurntSushi
ChatGPT
ClangFormat
CodeCompanion
CodeNomad
DeepSeek
DefaultV2
FileContent
FileDiff
FileNode
fineGrained
FormatterStatus
GitHub
GitLab
iTerm2
JavaScript
JetBrains
macOS
mDNS
MiniMax
NeuralNomadsAI
NickvanDyke
NoeFabris
OpenAI
OpenAPI
OpenChamber
OpenCode
OpenRouter
OpenTUI
OpenWork
ownUserPermissions
PowerShell
ProviderAuthAuthorization
ProviderAuthMethod
ProviderInitError
SessionStatus
TabItem
tokenType
ToolIDs
ToolList
TypeScript
typesUrl
UserMessage
VcsInfo
WebView2
WezTerm
xAI
ZenMux
```
## OpenCode CLI commands (as shown in docs)
```text
opencode
opencode [project]
opencode /path/to/project
opencode acp
opencode agent [command]
opencode agent create
opencode agent list
opencode attach [url]
opencode attach http://10.20.30.40:4096
opencode attach http://localhost:4096
opencode auth [command]
opencode auth list
opencode auth login
opencode auth logout
opencode auth ls
opencode export [sessionID]
opencode github [command]
opencode github install
opencode github run
opencode import <file>
opencode import https://opncd.ai/s/abc123
opencode import session.json
opencode mcp [command]
opencode mcp add
opencode mcp auth [name]
opencode mcp auth list
opencode mcp auth ls
opencode mcp auth my-oauth-server
opencode mcp auth sentry
opencode mcp debug <name>
opencode mcp debug my-oauth-server
opencode mcp list
opencode mcp logout [name]
opencode mcp logout my-oauth-server
opencode mcp ls
opencode models --refresh
opencode models [provider]
opencode models anthropic
opencode run [message..]
opencode run Explain the use of context in Go
opencode serve
opencode serve --cors http://localhost:5173 --cors https://app.example.com
opencode serve --hostname 0.0.0.0 --port 4096
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode session [command]
opencode session list
opencode stats
opencode uninstall
opencode upgrade
opencode upgrade [target]
opencode upgrade v0.1.48
opencode web
opencode web --cors https://example.com
opencode web --hostname 0.0.0.0
opencode web --mdns
opencode web --mdns --mdns-domain myproject.local
opencode web --port 4096
opencode web --port 4096 --hostname 0.0.0.0
opencode.server.close()
```
## Slash commands and routes
```text
/agent
/auth/:id
/clear
/command
/config
/config/providers
/connect
/continue
/doc
/editor
/event
/experimental/tool?provider=<p>&model=<m>
/experimental/tool/ids
/export
/file?path=<path>
/file/content?path=<p>
/file/status
/find?pattern=<pat>
/find/file
/find/file?query=<q>
/find/symbol?query=<q>
/formatter
/global/event
/global/health
/help
/init
/instance/dispose
/log
/lsp
/mcp
/mnt/
/mnt/c/
/mnt/d/
/models
/oc
/opencode
/path
/project
/project/current
/provider
/provider/{id}/oauth/authorize
/provider/{id}/oauth/callback
/provider/auth
/q
/quit
/redo
/resume
/session
/session/:id
/session/:id/abort
/session/:id/children
/session/:id/command
/session/:id/diff
/session/:id/fork
/session/:id/init
/session/:id/message
/session/:id/message/:messageID
/session/:id/permissions/:permissionID
/session/:id/prompt_async
/session/:id/revert
/session/:id/share
/session/:id/shell
/session/:id/summarize
/session/:id/todo
/session/:id/unrevert
/session/status
/share
/summarize
/theme
/tui
/tui/append-prompt
/tui/clear-prompt
/tui/control/next
/tui/control/response
/tui/execute-command
/tui/open-help
/tui/open-models
/tui/open-sessions
/tui/open-themes
/tui/show-toast
/tui/submit-prompt
/undo
/Users/username
/Users/username/projects/*
/vcs
```
## CLI flags and short options
```text
--agent
--attach
--command
--continue
--cors
--cwd
--days
--dir
--dry-run
--event
--file
--force
--fork
--format
--help
--hostname
--hostname 0.0.0.0
--keep-config
--keep-data
--log-level
--max-count
--mdns
--mdns-domain
--method
--model
--models
--port
--print-logs
--project
--prompt
--refresh
--session
--share
--title
--token
--tools
--verbose
--version
--wait
-c
-d
-f
-h
-m
-n
-s
-v
```
## Environment variables
```text
AI_API_URL
AI_FLOW_CONTEXT
AI_FLOW_EVENT
AI_FLOW_INPUT
AICORE_DEPLOYMENT_ID
AICORE_RESOURCE_GROUP
AICORE_SERVICE_KEY
ANTHROPIC_API_KEY
AWS_ACCESS_KEY_ID
AWS_BEARER_TOKEN_BEDROCK
AWS_PROFILE
AWS_REGION
AWS_ROLE_ARN
AWS_SECRET_ACCESS_KEY
AWS_WEB_IDENTITY_TOKEN_FILE
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
AZURE_RESOURCE_NAME
CI_PROJECT_DIR
CI_SERVER_FQDN
CI_WORKLOAD_REF
CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_GATEWAY_ID
CONTEXT7_API_KEY
GITHUB_TOKEN
GITLAB_AI_GATEWAY_URL
GITLAB_HOST
GITLAB_INSTANCE_URL
GITLAB_OAUTH_CLIENT_ID
GITLAB_TOKEN
GITLAB_TOKEN_OPENCODE
GOOGLE_APPLICATION_CREDENTIALS
GOOGLE_CLOUD_PROJECT
HTTP_PROXY
HTTPS_PROXY
K2_
MY_API_KEY
MY_ENV_VAR
MY_MCP_CLIENT_ID
MY_MCP_CLIENT_SECRET
NO_PROXY
NODE_ENV
NODE_EXTRA_CA_CERTS
NPM_AUTH_TOKEN
OC_ALLOW_WAYLAND
OPENCODE_API_KEY
OPENCODE_AUTH_JSON
OPENCODE_AUTO_SHARE
OPENCODE_CLIENT
OPENCODE_CONFIG
OPENCODE_CONFIG_CONTENT
OPENCODE_CONFIG_DIR
OPENCODE_DISABLE_AUTOCOMPACT
OPENCODE_DISABLE_AUTOUPDATE
OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS
OPENCODE_DISABLE_FILETIME_CHECK
OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_PRUNE
OPENCODE_DISABLE_TERMINAL_TITLE
OPENCODE_ENABLE_EXA
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
OPENCODE_EXPERIMENTAL
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
OPENCODE_EXPERIMENTAL_EXA
OPENCODE_EXPERIMENTAL_FILEWATCHER
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
OPENCODE_EXPERIMENTAL_LSP_TOOL
OPENCODE_EXPERIMENTAL_LSP_TY
OPENCODE_EXPERIMENTAL_MARKDOWN
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
OPENCODE_EXPERIMENTAL_OXFMT
OPENCODE_EXPERIMENTAL_PLAN_MODE
OPENCODE_FAKE_VCS
OPENCODE_GIT_BASH_PATH
OPENCODE_MODEL
OPENCODE_MODELS_URL
OPENCODE_PERMISSION
OPENCODE_PORT
OPENCODE_SERVER_PASSWORD
OPENCODE_SERVER_USERNAME
PROJECT_ROOT
RESOURCE_NAME
RUST_LOG
VARIABLE_NAME
VERTEX_LOCATION
XDG_CONFIG_HOME
```
## Package/module identifiers
```text
../../../config.mjs
@astrojs/starlight/components
@opencode-ai/plugin
@opencode-ai/sdk
path
shescape
zod
@
@ai-sdk/anthropic
@ai-sdk/cerebras
@ai-sdk/google
@ai-sdk/openai
@ai-sdk/openai-compatible
@File#L37-42
@modelcontextprotocol/server-everything
@opencode
```
## GitHub owner/repo slugs referenced in docs
```text
24601/opencode-zellij-namer
angristan/opencode-wakatime
anomalyco/opencode
apps/opencode-agent
athal7/opencode-devcontainers
awesome-opencode/awesome-opencode
backnotprop/plannotator
ben-vargas/ai-sdk-provider-opencode-sdk
btriapitsyn/openchamber
BurntSushi/ripgrep
Cluster444/agentic
code-yeongyu/oh-my-opencode
darrenhinde/opencode-agents
different-ai/opencode-scheduler
different-ai/openwork
features/copilot
folke/tokyonight.nvim
franlol/opencode-md-table-formatter
ggml-org/llama.cpp
ghoulr/opencode-websearch-cited.git
H2Shami/opencode-helicone-session
hosenur/portal
jamesmurdza/daytona
jenslys/opencode-gemini-auth
JRedeker/opencode-morph-fast-apply
JRedeker/opencode-shell-strategy
kdcokenny/ocx
kdcokenny/opencode-background-agents
kdcokenny/opencode-notify
kdcokenny/opencode-workspace
kdcokenny/opencode-worktree
login/device
mohak34/opencode-notifier
morhetz/gruvbox
mtymek/opencode-obsidian
NeuralNomadsAI/CodeNomad
nick-vi/opencode-type-inject
NickvanDyke/opencode.nvim
NoeFabris/opencode-antigravity-auth
nordtheme/nord
numman-ali/opencode-openai-codex-auth
olimorris/codecompanion.nvim
panta82/opencode-notificator
rebelot/kanagawa.nvim
remorses/kimaki
sainnhe/everforest
shekohex/opencode-google-antigravity-auth
shekohex/opencode-pty.git
spoons-and-mirrors/subtask2
sudo-tee/opencode.nvim
supermemoryai/opencode-supermemory
Tarquinen/opencode-dynamic-context-pruning
Th3Whit3Wolf/one-nvim
upstash/context7
vtemian/micode
vtemian/octto
yetone/avante.nvim
zenobi-us/opencode-plugin-template
zenobi-us/opencode-skillful
```
## Paths, filenames, globs, and URLs
```text
./.opencode/themes/*.json
./<project-slug>/storage/
./config/#custom-directory
./global/storage/
.agents/skills/*/SKILL.md
.agents/skills/<name>/SKILL.md
.clang-format
.claude
.claude/skills
.claude/skills/*/SKILL.md
.claude/skills/<name>/SKILL.md
.env
.github/workflows/opencode.yml
.gitignore
.gitlab-ci.yml
.ignore
.NET SDK
.npmrc
.ocamlformat
.opencode
.opencode/
.opencode/agents/
.opencode/commands/
.opencode/commands/test.md
.opencode/modes/
.opencode/plans/*.md
.opencode/plugins/
.opencode/skills/<name>/SKILL.md
.opencode/skills/git-release/SKILL.md
.opencode/tools/
.well-known/opencode
{ type: "raw" \| "patch", content: string }
{file:path/to/file}
**/*.js
%USERPROFILE%/intelephense/license.txt
%USERPROFILE%\.cache\opencode
%USERPROFILE%\.config\opencode\opencode.jsonc
%USERPROFILE%\.config\opencode\plugins
%USERPROFILE%\.local\share\opencode
%USERPROFILE%\.local\share\opencode\log
<project-root>/.opencode/themes/*.json
<providerId>/<modelId>
<your-project>/.opencode/plugins/
~
~/...
~/.agents/skills/*/SKILL.md
~/.agents/skills/<name>/SKILL.md
~/.aws/credentials
~/.bashrc
~/.cache/opencode
~/.cache/opencode/node_modules/
~/.claude/CLAUDE.md
~/.claude/skills/
~/.claude/skills/*/SKILL.md
~/.claude/skills/<name>/SKILL.md
~/.config/opencode
~/.config/opencode/AGENTS.md
~/.config/opencode/agents/
~/.config/opencode/commands/
~/.config/opencode/modes/
~/.config/opencode/opencode.json
~/.config/opencode/opencode.jsonc
~/.config/opencode/plugins/
~/.config/opencode/skills/*/SKILL.md
~/.config/opencode/skills/<name>/SKILL.md
~/.config/opencode/themes/*.json
~/.config/opencode/tools/
~/.config/zed/settings.json
~/.local/share
~/.local/share/opencode/
~/.local/share/opencode/auth.json
~/.local/share/opencode/log/
~/.local/share/opencode/mcp-auth.json
~/.local/share/opencode/opencode.jsonc
~/.npmrc
~/.zshrc
~/code/
~/Library/Application Support
~/projects/*
~/projects/personal/
${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts
$HOME/intelephense/license.txt
$HOME/projects/*
$XDG_CONFIG_HOME/opencode/themes/*.json
agent/
agents/
build/
commands/
dist/
http://<wsl-ip>:4096
http://127.0.0.1:8080/callback
http://localhost:<port>
http://localhost:4096
http://localhost:4096/doc
https://app.example.com
https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/
https://opencode.ai/zen/v1/chat/completions
https://opencode.ai/zen/v1/messages
https://opencode.ai/zen/v1/models/gemini-3-flash
https://opencode.ai/zen/v1/models/gemini-3-pro
https://opencode.ai/zen/v1/responses
https://RESOURCE_NAME.openai.azure.com/
laravel/pint
log/
model: "anthropic/claude-sonnet-4-5"
modes/
node_modules/
openai/gpt-4.1
opencode.ai/config.json
opencode/<model-id>
opencode/gpt-5.1-codex
opencode/gpt-5.2-codex
opencode/kimi-k2
openrouter/google/gemini-2.5-flash
opncd.ai/s/<share-id>
packages/*/AGENTS.md
plugins/
project/
provider_id/model_id
provider/model
provider/model-id
rm -rf ~/.cache/opencode
skills/
skills/*/SKILL.md
src/**/*.ts
themes/
tools/
```
## Keybind strings
```text
alt+b
Alt+Ctrl+K
alt+d
alt+f
Cmd+Esc
Cmd+Option+K
Cmd+Shift+Esc
Cmd+Shift+G
Cmd+Shift+P
ctrl+a
ctrl+b
ctrl+d
ctrl+e
Ctrl+Esc
ctrl+f
ctrl+g
ctrl+k
Ctrl+Shift+Esc
Ctrl+Shift+P
ctrl+t
ctrl+u
ctrl+w
ctrl+x
DELETE
Shift+Enter
WIN+R
```
## Model ID strings referenced
```text
{env:OPENCODE_MODEL}
anthropic/claude-3-5-sonnet-20241022
anthropic/claude-haiku-4-20250514
anthropic/claude-haiku-4-5
anthropic/claude-sonnet-4-20250514
anthropic/claude-sonnet-4-5
gitlab/duo-chat-haiku-4-5
lmstudio/google/gemma-3n-e4b
openai/gpt-4.1
openai/gpt-5
opencode/gpt-5.1-codex
opencode/gpt-5.2-codex
opencode/kimi-k2
openrouter/google/gemini-2.5-flash
```

View File

@@ -1,4 +1,4 @@
Use this tool to assign and/or label a GitHub issue.
Use this tool to assign and/or label a Github issue.
You can assign the following users:
- thdxr

View File

@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.56",
"version": "1.1.53",
"bin": {
"opencode": "./bin/opencode",
},
@@ -366,7 +366,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -386,7 +386,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.56",
"version": "1.1.53",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -397,7 +397,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -410,7 +410,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -452,7 +452,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"zod": "catalog:",
},
@@ -463,7 +463,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -275,7 +275,7 @@ async function assertOpencodeConnected() {
body: {
service: "github-workflow",
level: "info",
message: "Prepare to react to GitHub Workflow event",
message: "Prepare to react to Github Workflow event",
},
})
connected = true

View File

@@ -166,10 +166,14 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
})
let logProcessor
if ($app.stage === "production" || $app.stage === "frank") {
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [HONEYCOMB_API_KEY],
})
}
new sst.cloudflare.x.SolidStart("Console", {
domain,
@@ -207,7 +211,7 @@ new sst.cloudflare.x.SolidStart("Console", {
transform: {
worker: {
placement: { mode: "smart" },
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
},
},
},

View File

@@ -1,3 +1,2 @@
[test]
root = "./src"
preload = ["./happydom.ts"]

View File

@@ -1,140 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (slug === root) return ""
if (seen.includes(slug)) return ""
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
const slug = slugFromUrl(page.url())
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await page.keyboard.type(text)
await page.keyboard.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return sessionID
}
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return ""
return info.directory
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root }) => {
const workspaces = [] as { slug: string; directory: string }[]
const sessions = [] as string[]
try {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
workspaces.push(first)
await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
workspaces.push(second)
await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
sessions.push(firstSession)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
sessions.push(secondSession)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
sessions.push(thirdSession)
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
} finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all(
sessions.map((sessionID) =>
Promise.all(
dirs.map((dir) =>
createSdk(dir)
.session.delete({ sessionID })
.catch(() => undefined),
),
),
),
)
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
}
})
})

View File

@@ -48,9 +48,6 @@ export const workspaceItemSelector = (slug: string) =>
export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
export const workspaceNewSessionSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`

View File

@@ -1,126 +0,0 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { createSdk, modKey } from "../utils"
import { promptSelector } from "../selectors"
async function seedConversation(input: {
page: Page
sdk: ReturnType<typeof createSdk>
sessionID: string
token: string
}) {
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
await input.page.keyboard.press("Enter")
let userMessageID: string | undefined
await expect
.poll(
async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 50 })
.then((r) => r.data ?? [])
const users = messages.filter((m) => m.info.role === "user")
if (users.length === 0) return false
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
userMessageID = user.info.id
const assistantText = messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
return assistantText.includes(input.token)
},
{ timeout: 90_000 },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
return { prompt, userMessageID }
}
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
})
})
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
})
})
})

View File

@@ -1,36 +0,0 @@
import { test, expect } from "../fixtures"
import { closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await closeSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
.getByRole("button", { name: /archive/i })
.first()
.click()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
} finally {
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.56",
"version": "1.1.53",
"description": "",
"type": "module",
"exports": {

View File

@@ -89,6 +89,7 @@ let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
let internalError = false
const cleanup = async () => {
if (cleaned) return
@@ -114,8 +115,9 @@ const shutdown = (code: number, reason: string) => {
}
const reportInternalError = (reason: string, error: unknown) => {
console.warn(`e2e-local ignored server error: ${reason}`)
console.warn(error)
internalError = true
console.error(`e2e-local internal error: ${reason}`)
console.error(error)
}
process.once("SIGINT", () => shutdown(130, "SIGINT"))
@@ -175,4 +177,6 @@ try {
await cleanup()
}
if (code === 0 && internalError) code = 1
process.exit(code)

View File

@@ -6,7 +6,6 @@ let dirsToExpand: typeof import("./file-tree").dirsToExpand
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@/context/file", () => ({

View File

@@ -1,5 +1,4 @@
import { useFile } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
@@ -21,7 +20,11 @@ import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}`
const encodedPath = filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
return `file://${encodedPath}`
}
type Kind = "add" | "del" | "mix"
@@ -220,14 +223,12 @@ export default function FileTree(props: {
seen.add(item)
}
out.sort((a, b) => {
return out.toSorted((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return out
})
const Node = (

View File

@@ -787,7 +787,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree,
newSessionWorktree: props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
onSubmit: props.onSubmit,
})

View File

@@ -112,7 +112,7 @@ describe("buildRequestParts", () => {
// Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/)
}
})
@@ -210,7 +210,7 @@ describe("buildRequestParts", () => {
if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D:/other/project/file.ts")
expect(filePart.url).toContain("/D%3A/other/project/file.ts")
}
})

View File

@@ -1,7 +1,6 @@
import { getFilename } from "@opencode-ai/util/path"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
@@ -28,11 +27,23 @@ type BuildRequestPartsInput = {
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
const absolute = (directory: string, path: string) =>
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
const encodeFilePath = (filepath: string): string => {
// Normalize Windows paths: convert backslashes to forward slashes
let normalized = filepath.replace(/\\/g, "/")
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
if (/^[A-Za-z]:/.test(normalized)) {
normalized = "/" + normalized
}
// Encode each path segment (preserving forward slashes as path separators)
return normalized
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
}
const fileQuery = (selection: FileSelection | undefined) =>

View File

@@ -1,175 +0,0 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => ({
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
})
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
mock.module("@opencode-ai/ui/toast", () => ({
showToast: () => 0,
}))
mock.module("@opencode-ai/util/encode", () => ({
base64Encode: (value: string) => value,
}))
mock.module("@/context/local", () => ({
useLocal: () => ({
model: {
current: () => ({ id: "model", provider: { id: "provider" } }),
variant: { current: () => undefined },
},
agent: {
current: () => ({ name: "agent" }),
},
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
items: () => [],
},
}),
}))
mock.module("@/context/layout", () => ({
useLayout: () => ({
handoff: {
setTabs: () => undefined,
},
}),
}))
mock.module("@/context/sdk", () => ({
useSDK: () => ({
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
}),
}))
mock.module("@/context/sync", () => ({
useSync: () => ({
data: { command: [] },
session: {
optimistic: {
add: () => undefined,
remove: () => undefined,
},
},
set: () => undefined,
}),
}))
mock.module("@/context/global-sync", () => ({
useGlobalSync: () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
return [{}, () => undefined]
},
}),
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({
fetch: fetch,
}),
}))
mock.module("@/context/language", () => ({
useLanguage: () => ({
t: (key: string) => key,
}),
}))
const mod = await import("./submit")
createPromptSubmit = mod.createPromptSubmit
})
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
})
describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
})
})

View File

@@ -37,7 +37,7 @@ type PromptSubmitInput = {
resetHistoryNavigation: () => void
setMode: (mode: "normal" | "shell") => void
setPopover: (popover: "at" | "slash" | null) => void
newSessionWorktree?: Accessor<string | undefined>
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
onSubmit?: () => void
}
@@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk.directory
const isNewSession = !params.id
const worktreeSelection = input.newSessionWorktree?.() || "main"
const worktreeSelection = input.newSessionWorktree || "main"
let sessionDirectory = projectDirectory
let client = sdk.client

View File

@@ -166,7 +166,6 @@ export function SessionHeader() {
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
@@ -356,12 +355,7 @@ export function SessionHeader() {
<span class="text-12-regular text-text-strong">Open</span>
</Button>
<div class="self-stretch w-px bg-border-base/70" />
<DropdownMenu
gutter={6}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu gutter={6} placement="bottom-end">
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
@@ -381,13 +375,7 @@ export function SessionHeader() {
}}
>
{options().map((o) => (
<DropdownMenu.RadioItem
value={o.id}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={size(o.icon)} />
</div>
@@ -400,12 +388,7 @@ export function SessionHeader() {
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<DropdownMenu.Item onSelect={copyPath}>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>

View File

@@ -74,9 +74,7 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaBlur: () => void
let disposed = false
const cleanups: VoidFunction[] = []
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
let tail = local.pty.tail ?? ""
const cleanup = () => {
if (!cleanups.length) return
@@ -166,16 +164,13 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
@@ -190,7 +185,7 @@ export const Terminal = (props: TerminalProps) => {
cursorStyle: "bar",
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false,
allowTransparency: true,
convertEol: true,
theme: terminalColors(),
scrollback: 10_000,
@@ -294,6 +289,26 @@ export const Terminal = (props: TerminalProps) => {
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
const limit = 16_384
const min = 32
const windowMs = 750
const seed = tail.length > limit ? tail.slice(-limit) : tail
let sync = seed.length >= min
let syncUntil = 0
const stopSync = () => {
sync = false
syncUntil = 0
}
const overlap = (data: string) => {
if (!seed) return 0
const max = Math.min(seed.length, data.length)
if (max < min) return 0
for (let i = max; i >= min; i--) {
if (seed.slice(-i) === data.slice(0, i)) return i
}
return 0
}
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
@@ -310,6 +325,7 @@ export const Terminal = (props: TerminalProps) => {
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (data) stopSync()
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
@@ -327,6 +343,7 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => {
local.onConnect?.()
if (sync) syncUntil = Date.now() + windowMs
sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -340,31 +357,31 @@ export const Terminal = (props: TerminalProps) => {
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (event.data instanceof ArrayBuffer) {
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch {
// ignore
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
t.write(data)
cursor += data.length
const next = (() => {
if (!sync) return data
if (syncUntil && Date.now() > syncUntil) {
stopSync()
return data
}
const n = overlap(data)
if (!n) {
stopSync()
return data
}
const trimmed = data.slice(n)
if (trimmed) stopSync()
return trimmed
})()
if (!next) return
t.write(next)
tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
}
socket.addEventListener("message", handleMessage)
cleanups.push(() => socket.removeEventListener("message", handleMessage))
@@ -418,7 +435,7 @@ export const Terminal = (props: TerminalProps) => {
props.onCleanup({
...local.pty,
buffer,
cursor,
tail,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),

View File

@@ -6,7 +6,6 @@ let createCommentSessionForTest: typeof import("./comments").createCommentSessio
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({

View File

@@ -108,7 +108,7 @@ describe("encodeFilePath", () => {
const url = new URL(fileUrl)
expect(url.protocol).toBe("file:")
expect(url.pathname).toContain("README.bs.md")
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
})
test("should handle mixed separator path (Windows + Unix)", () => {
@@ -118,7 +118,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
})
test("should handle Windows path with spaces", () => {
@@ -146,7 +146,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C:/")
expect(result).toBe("/C%3A/")
})
test("should handle Windows relative path with backslashes", () => {
@@ -177,7 +177,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/c:/users/test/file.txt")
expect(result).toBe("/c%3A/users/test/file.txt")
})
})
@@ -193,7 +193,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(windowsPath)
// Should convert to forward slashes and add leading /
expect(result).not.toContain("\\")
expect(result).toMatch(/^\/[A-Za-z]:\//)
expect(result).toMatch(/^\/[A-Za-z]%3A\//)
})
test("should handle relative paths the same on all platforms", () => {
@@ -237,7 +237,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(alreadyNormalized)
// Should not add another leading slash
expect(result).toBe("/D:/path/file.txt")
expect(result).toBe("/D%3A/path/file.txt")
expect(result).not.toContain("//D")
})
@@ -246,7 +246,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(justDrive)
const fileUrl = `file://${result}`
expect(result).toBe("/D:")
expect(result).toBe("/D%3A")
expect(() => new URL(fileUrl)).not.toThrow()
})
@@ -256,7 +256,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C:/Users/test/")
expect(result).toBe("/C%3A/Users/test/")
})
test("should handle very long paths", () => {

View File

@@ -90,14 +90,9 @@ export function encodeFilePath(filepath: string): string {
}
// Encode each path segment (preserving forward slashes as path separators)
// Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers
// can reliably detect drives.
return normalized
.split("/")
.map((segment, index) => {
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
return encodeURIComponent(segment)
})
.map((segment) => encodeURIComponent(segment))
.join("/")
}

View File

@@ -12,19 +12,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
const auth = (() => {
if (typeof window === "undefined") return
const password = window.__OPENCODE__?.serverPassword
if (!password) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
headers: auth,
fetch: platform.fetch,
})
const emitter = createGlobalEmitter<{
[key: string]: Event

View File

@@ -6,7 +6,6 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
export type ModelKey = { providerID: string; modelID: string }
@@ -185,27 +184,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
models.setVisibility(model, visible)
},
variant: {
configured() {
const a = agent.current()
const m = current()
if (!a || !m) return undefined
return getConfiguredAgentVariant({
agent: { model: a.model, variant: a.variant },
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
})
},
selected() {
current() {
const m = current()
if (!m) return undefined
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
},
current() {
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
},
list() {
const m = current()
if (!m) return []
@@ -220,13 +203,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
const currentVariant = this.current()
if (!currentVariant) {
this.set(variants[0])
return
}
const index = variants.indexOf(currentVariant)
if (index === -1 || index === variants.length - 1) {
this.set(undefined)
return
}
this.set(variants[index + 1])
},
},
}

View File

@@ -1,66 +0,0 @@
import { describe, expect, test } from "bun:test"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
describe("model variant", () => {
test("resolves configured agent variant when model matches", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "openai",
modelID: "gpt-5.2",
variants: { low: {}, high: {}, xhigh: {} },
},
})
expect(value).toBe("xhigh")
})
test("ignores configured variant when model does not match", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4",
variants: { low: {}, high: {}, xhigh: {} },
},
})
expect(value).toBeUndefined()
})
test("prefers selected variant over configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: "high",
configured: "xhigh",
})
expect(value).toBe("high")
})
test("cycles from configured variant to next", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "high",
})
expect(value).toBe("xhigh")
})
test("wraps from configured last variant to first", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBe("low")
})
})

View File

@@ -1,50 +0,0 @@
type AgentModel = {
providerID: string
modelID: string
}
type Agent = {
model?: AgentModel
variant?: string
}
type Model = AgentModel & {
variants?: Record<string, unknown>
}
type VariantInput = {
variants: string[]
selected: string | undefined
configured: string | undefined
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
if (!input.agent?.variant) return undefined
if (!input.agent.model) return undefined
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
}
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
}

View File

@@ -5,7 +5,6 @@ let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => str
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({

View File

@@ -13,7 +13,7 @@ export type LocalPTY = {
cols?: number
buffer?: string
scrollY?: number
cursor?: number
tail?: string
}
const WORKSPACE_KEY = "__workspace__"

View File

@@ -54,13 +54,6 @@ export default function Layout(props: ParentProps) {
navigate(`/${params.dir}/session/${sessionID}`)
}
const sessionHref = (sessionID: string) => {
if (params.dir) return `/${params.dir}/session/${sessionID}`
return `/session/${sessionID}`
}
const syncSession = (sessionID: string) => sync.session.sync(sessionID)
return (
<DataProvider
data={sync.data}
@@ -69,8 +62,6 @@ export default function Layout(props: ParentProps) {
onQuestionReply={replyToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
onSessionHref={sessionHref}
onSyncSession={syncSession}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@@ -25,8 +25,7 @@ export default function Home() {
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
return sync.data.project
.slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})

View File

@@ -181,6 +181,20 @@ export default function Layout(props: ParentProps) {
aim.reset()
})
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => {
if (layout.sidebar.opened()) return
if (!state.hoverProject) return
aim.reset()
setState("hoverSession", undefined)
setState("hoverProject", undefined)
},
{ defer: true },
),
)
const autoselecting = createMemo(() => {
if (params.dir) return false
if (!state.autoselect) return false
@@ -1258,6 +1272,8 @@ export default function Layout(props: ParentProps) {
),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
setBusy(directory, false)
dismiss()
@@ -1922,7 +1938,7 @@ export default function Layout(props: ParentProps) {
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
max={window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}

View File

@@ -26,7 +26,7 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
export const childMapByParent = (sessions: Session[]) => {
const map = new Map<string, string[]>()

View File

@@ -144,7 +144,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const item = (
<A
href={`/${props.slug}/session/${props.session.id}`}
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerEnter={scheduleHoverPrefetch}
onPointerLeave={cancelHoverPrefetch}
@@ -285,7 +285,7 @@ export const NewSessionItem = (props: {
const tooltip = () => props.mobile || !props.sidebarExpanded()
const item = (
<A
href={`/${props.slug}/session`}
href={`${props.slug}/session`}
end
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => {

View File

@@ -118,7 +118,7 @@ export const SortableWorkspace = (props: {
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
setWorkspaceStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.directory)
}
@@ -368,7 +368,7 @@ export const LocalWorkspace = (props: {
const loading = createMemo(() => !booted() && sessions().length === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
workspace().setStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
}

View File

@@ -1026,31 +1026,10 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
</div>
</Match>
</Switch>
)
@@ -1062,7 +1041,7 @@ export default function Page() {
diffStyle: layout.review.diffStyle(),
onDiffStyleChange: layout.review.setDiffStyle,
loadingClass: "px-6 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</div>
@@ -1590,7 +1569,7 @@ export default function Page() {
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -1704,7 +1683,7 @@ export default function Page() {
direction="horizontal"
size={layout.session.width()}
min={450}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
max={window.innerWidth * 0.45}
onResize={layout.session.resize}
/>
</Show>

View File

@@ -228,7 +228,6 @@ export const createScrollSpy = (input: Input) => {
node.delete(key)
visible.delete(key)
dirty = true
schedule()
}
const markDirty = () => {

View File

@@ -41,7 +41,7 @@ export function TerminalPanel(props: {
direction="vertical"
size={props.height}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
max={window.innerHeight * 0.6}
collapseThreshold={50}
onResize={props.resize}
onCollapse={props.close}

View File

@@ -365,81 +365,48 @@ export const useSessionCommands = (input: {
return [
{
id: "session.share",
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
title: input.info()?.share?.url ? "Copy share link" : input.language.t("command.session.share"),
description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
? "Copy share URL to clipboard"
: input.language.t("command.session.share.description"),
category: input.language.t("command.category.session"),
slash: "share",
disabled: !input.params.id,
onSelect: async () => {
if (!input.params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return Promise.resolve(true)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false)
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
const copy = (url: string, existing: boolean) =>
navigator.clipboard
.writeText(url)
.then(() =>
showToast({
title: existing
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
}),
)
const url = input.info()?.share?.url
if (url) {
await copy(url, true)
return
}
const url = await input.sdk.client.session
await input.sdk.client.session
.share({ sessionID: input.params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
await copy(url, false)
.then((res) => copy(res.data!.share!.url, false))
.catch(() =>
showToast({
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error",
}),
)
},
},
{

View File

@@ -99,9 +99,4 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem("direct-value")).toBe('{"value":5}')
})
test("normalizer rejects malformed JSON payloads", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
})

View File

@@ -195,14 +195,6 @@ function parse(value: string) {
}
}
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
const parsed = parse(raw)
if (parsed === undefined) return
const migrated = migrate ? migrate(parsed) : parsed
const merged = merge(defaults, migrated)
return JSON.stringify(merged)
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const sum = checksum(dir) ?? "0"
@@ -299,7 +291,6 @@ function localStorageDirect(): SyncStorage {
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
}
export const Persist = {
@@ -367,11 +358,12 @@ export function persisted<T>(
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
current.removeItem(key)
return null
}
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) current.setItem(key, next)
return next
}
@@ -380,13 +372,16 @@ export function persisted<T>(
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
legacyStore.removeItem(legacyKey)
continue
}
current.setItem(key, next)
current.setItem(key, legacyRaw)
legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) current.setItem(key, next)
return next
}
@@ -410,11 +405,12 @@ export function persisted<T>(
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
await current.removeItem(key).catch(() => undefined)
return null
}
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) await current.setItem(key, next)
return next
}
@@ -425,13 +421,16 @@ export function persisted<T>(
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
await legacyStore.removeItem(legacyKey).catch(() => undefined)
continue
}
await current.setItem(key, next)
await current.setItem(key, legacyRaw)
await legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) await current.setItem(key, next)
return next
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.56",
"version": "1.1.53",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "100K",
full: "100,000",
compact: "95K",
full: "95,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "700",
commits: "9,000",
contributors: "650",
commits: "8,500",
monthlyUsers: "2.5M",
},
} as const

View File

@@ -394,7 +394,7 @@ export const dict = {
"workspace.settings.edit": "Edit",
"workspace.billing.title": "Billing",
"workspace.billing.subtitle.beforeLink": "Manage payment methods.",
"workspace.billing.subtitle.beforeLink": "Manage payments methods.",
"workspace.billing.contactUs": "Contact us",
"workspace.billing.subtitle.afterLink": "if you have any questions.",
"workspace.billing.currentBalance": "Current Balance",

View File

@@ -203,7 +203,7 @@ export const dict = {
"zen.how.step2.link": "betale per forespørsel",
"zen.how.step2.afterLink": "med null markeringer",
"zen.how.step3.title": "Automatisk påfylling",
"zen.how.step3.body": "når saldoen din når $5, fyller vi automatisk $20",
"zen.how.step3.body": "når saldoen din når $5, legger vi automatisk til $20",
"zen.privacy.title": "Personvernet ditt er viktig for oss",
"zen.privacy.beforeExceptions":
"Alle Zen-modeller er vert i USA. Leverandører følger en nulloppbevaringspolicy og bruker ikke dataene dine til modelltrening, med",
@@ -283,7 +283,7 @@ export const dict = {
"changelog.empty": "Ingen endringsloggoppforinger funnet.",
"changelog.viewJson": "Vis JSON",
"workspace.nav.zen": "Zen",
"workspace.nav.apiKeys": "API Nøkler",
"workspace.nav.apiKeys": "API Taster",
"workspace.nav.members": "Medlemmer",
"workspace.nav.billing": "Fakturering",
"workspace.nav.settings": "Innstillinger",
@@ -320,7 +320,7 @@ export const dict = {
"workspace.providers.edit": "Redigere",
"workspace.providers.delete": "Slett",
"workspace.providers.saving": "Lagrer...",
"workspace.providers.save": "Lagre",
"workspace.providers.save": "Spare",
"workspace.providers.table.provider": "Leverandør",
"workspace.providers.table.apiKey": "API nøkkel",
"workspace.usage.title": "Brukshistorikk",
@@ -330,21 +330,21 @@ export const dict = {
"workspace.usage.table.model": "Modell",
"workspace.usage.table.input": "Inndata",
"workspace.usage.table.output": "Produksjon",
"workspace.usage.table.cost": "Kostnad",
"workspace.usage.table.cost": "Koste",
"workspace.usage.breakdown.input": "Inndata",
"workspace.usage.breakdown.cacheRead": "Cache lest",
"workspace.usage.breakdown.cacheWrite": "Cache-skriving",
"workspace.usage.breakdown.output": "Produksjon",
"workspace.usage.breakdown.reasoning": "Argumentasjon",
"workspace.usage.subscription": "abonnement (${{amount}})",
"workspace.cost.title": "Kostnad",
"workspace.cost.title": "Koste",
"workspace.cost.subtitle": "Brukskostnader fordelt på modell.",
"workspace.cost.allModels": "Alle modeller",
"workspace.cost.allKeys": "Alle nøkler",
"workspace.cost.deletedSuffix": "(slettet)",
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
"workspace.cost.subscriptionShort": "sub",
"workspace.keys.title": "API Nøkler",
"workspace.keys.title": "API Taster",
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
"workspace.keys.create": "Opprett API-nøkkel",
"workspace.keys.placeholder": "Skriv inn nøkkelnavn",
@@ -370,7 +370,7 @@ export const dict = {
"workspace.members.edit": "Redigere",
"workspace.members.delete": "Slett",
"workspace.members.saving": "Lagrer...",
"workspace.members.save": "Lagre",
"workspace.members.save": "Spare",
"workspace.members.table.email": "E-post",
"workspace.members.table.role": "Rolle",
"workspace.members.table.monthLimit": "Månedsgrense",
@@ -383,7 +383,7 @@ export const dict = {
"workspace.settings.workspaceName": "Navn på arbeidsområde",
"workspace.settings.defaultName": "Misligholde",
"workspace.settings.updating": "Oppdaterer...",
"workspace.settings.save": "Lagre",
"workspace.settings.save": "Spare",
"workspace.settings.edit": "Redigere",
"workspace.billing.title": "Fakturering",
"workspace.billing.subtitle.beforeLink": "Administrer betalingsmåter.",
@@ -407,22 +407,22 @@ export const dict = {
"workspace.monthlyLimit.noLimit": "Ingen bruksgrense satt.",
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende bruk for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
"workspace.reload.title": "Automatisk påfylling",
"workspace.reload.disabled.before": "Automatisk påfylling er",
"workspace.reload.disabled.state": "deaktivert",
"workspace.reload.disabled.after": "Aktiver for å automatisk påfylle på nytt når saldoen er lav.",
"workspace.reload.enabled.before": "Automatisk påfylling er",
"workspace.reload.title": "Last inn automatisk",
"workspace.reload.disabled.before": "Automatisk reload er",
"workspace.reload.disabled.state": "funksjonshemmet",
"workspace.reload.disabled.after": "Aktiver for å laste automatisk på nytt når balansen er lav.",
"workspace.reload.enabled.before": "Automatisk reload er",
"workspace.reload.enabled.state": "aktivert",
"workspace.reload.enabled.middle": "Vi fyller på",
"workspace.reload.enabled.middle": "Vi laster på nytt",
"workspace.reload.processingFee": "behandlingsgebyr",
"workspace.reload.enabled.after": "når saldoen når",
"workspace.reload.enabled.after": "når balansen når",
"workspace.reload.edit": "Redigere",
"workspace.reload.enable": "Aktiver",
"workspace.reload.enableAutoReload": "Aktiver automatisk påfylling",
"workspace.reload.enableAutoReload": "Aktiver automatisk reload",
"workspace.reload.reloadAmount": "Last inn $",
"workspace.reload.whenBalanceReaches": "Når saldoen når $",
"workspace.reload.saving": "Lagrer...",
"workspace.reload.save": "Lagre",
"workspace.reload.save": "Spare",
"workspace.reload.failedAt": "Omlasting mislyktes kl",
"workspace.reload.reason": "Grunn:",
"workspace.reload.updatePaymentMethod": "Oppdater betalingsmåten og prøv på nytt.",
@@ -436,7 +436,7 @@ export const dict = {
"workspace.payments.table.receipt": "Kvittering",
"workspace.payments.type.credit": "kreditt",
"workspace.payments.type.subscription": "abonnement",
"workspace.payments.view": "Vis",
"workspace.payments.view": "Utsikt",
"workspace.black.loading": "Laster inn...",
"workspace.black.time.day": "dag",
"workspace.black.time.days": "dager",

View File

@@ -1,15 +1,13 @@
export class AuthError extends Error {}
export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
class LimitError extends Error {
export class SubscriptionError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class FreeUsageLimitError extends LimitError {}
export class SubscriptionUsageLimitError extends LimitError {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RateLimitError extends Error {}

View File

@@ -18,10 +18,10 @@ import {
AuthError,
CreditsError,
MonthlyLimitError,
SubscriptionError,
UserLimitError,
ModelError,
FreeUsageLimitError,
SubscriptionUsageLimitError,
RateLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
@@ -52,8 +52,7 @@ export async function handler(
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
const MAX_FAILOVER_RETRIES = 3
const MAX_429_RETRIES = 3
const MAX_RETRIES = 3
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
@@ -112,7 +111,7 @@ export async function handler(
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
const res = await fetchWith429Retry(reqUrl, {
const res = await fetch(reqUrl, {
method: "POST",
headers: (() => {
const headers = new Headers(input.request.headers)
@@ -134,26 +133,20 @@ export async function handler(
body: reqBody,
})
if (res.status !== 200) {
logger.metric({
"llm.error.code": res.status,
"llm.error.message": res.statusText,
// Try another provider => stop retrying if using fallback provider
if (
res.status !== 200 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider
) {
return retriableRequest({
excludeProviders: [...retry.excludeProviders, providerInfo.id],
retryCount: retry.retryCount + 1,
})
// Try another provider => stop retrying if using fallback provider
if (
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider
) {
return retriableRequest({
excludeProviders: [...retry.excludeProviders, providerInfo.id],
retryCount: retry.retryCount + 1,
})
}
}
return { providerInfo, reqBody, res, startTimestamp }
@@ -311,9 +304,9 @@ export async function handler(
{ status: 401 },
)
if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) {
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
const headers = new Headers()
if (error.retryAfter) {
if (error instanceof SubscriptionError && error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response(
@@ -376,7 +369,7 @@ export async function handler(
if (provider) return provider
}
if (retry.retryCount === MAX_FAILOVER_RETRIES) {
if (retry.retryCount === MAX_RETRIES) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
}
@@ -527,7 +520,7 @@ export async function handler(
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionUsageLimitError(
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
@@ -541,7 +534,7 @@ export async function handler(
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionUsageLimitError(
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
@@ -604,15 +597,6 @@ export async function handler(
providerInfo.apiKey = authInfo.provider.credentials
}
async function fetchWith429Retry(url: string, options: RequestInit, retry = { count: 0 }) {
const res = await fetch(url, options)
if (res.status === 429 && retry.count < MAX_429_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, retry.count) * 500))
return fetchWith429Retry(url, options, { count: retry.count + 1 })
}
return res
}
async function trackUsage(
authInfo: AuthInfo,
modelInfo: ModelInfo,

View File

@@ -1,6 +1,6 @@
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { FreeUsageLimitError } from "./error"
import { RateLimitError } from "./error"
import { logger } from "./logger"
import { ZenData } from "@opencode-ai/console-core/model.js"
@@ -28,46 +28,17 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
check: async () => {
const rows = await Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.select({ count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limitValue)
throw new FreeUsageLimitError(
`Rate limit exceeded. Please try again later.`,
limit.period === "day" ? getRetryAfterDay(now) : getRetryAfterHour(rows, intervals, limitValue, now),
)
if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
},
}
}
export function getRetryAfterDay(now: number) {
return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
}
export function getRetryAfterHour(
rows: { interval: string; count: number }[],
intervals: string[],
limit: number,
now: number,
) {
const counts = new Map(rows.map((r) => [r.interval, r.count]))
// intervals are ordered newest to oldest: [current, -1h, -2h]
// simulate dropping oldest intervals one at a time
let running = intervals.reduce((sum, i) => sum + (counts.get(i) ?? 0), 0)
for (let i = intervals.length - 1; i >= 0; i--) {
running -= counts.get(intervals[i]) ?? 0
if (running < limit) {
// interval at index i rolls out of the window (intervals.length - i) hours from the current hour start
const hours = intervals.length - i
return Math.ceil((hours * 3_600_000 - (now % 3_600_000)) / 1000)
}
}
return Math.ceil((3_600_000 - (now % 3_600_000)) / 1000)
}
function buildYYYYMMDD(timestamp: number) {
return new Date(timestamp)
.toISOString()

View File

@@ -1,92 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getRetryAfterDay, getRetryAfterHour } from "../src/routes/zen/util/rateLimiter"
describe("getRetryAfterDay", () => {
test("returns full day at midnight UTC", () => {
const midnight = Date.UTC(2026, 0, 15, 0, 0, 0, 0)
expect(getRetryAfterDay(midnight)).toBe(86_400)
})
test("returns remaining seconds until next UTC day", () => {
const noon = Date.UTC(2026, 0, 15, 12, 0, 0, 0)
expect(getRetryAfterDay(noon)).toBe(43_200)
})
test("rounds up to nearest second", () => {
const almost = Date.UTC(2026, 0, 15, 23, 59, 59, 500)
expect(getRetryAfterDay(almost)).toBe(1)
})
})
describe("getRetryAfterHour", () => {
// 14:30:00 UTC — 30 minutes into the current hour
const now = Date.UTC(2026, 0, 15, 14, 30, 0, 0)
const intervals = ["2026011514", "2026011513", "2026011512"]
test("waits 3 hours when all usage is in current hour", () => {
const rows = [{ interval: "2026011514", count: 10 }]
// only current hour has usage — it won't leave the window for 3 hours from hour start
// 3 * 3600 - 1800 = 9000s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(9000)
})
test("waits 1 hour when dropping oldest interval is sufficient", () => {
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop oldest (-2h, count=10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1 * 3600 - 1800 = 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when usage spans oldest two intervals", () => {
const rows = [
{ interval: "2026011513", count: 8 },
{ interval: "2026011512", count: 5 },
]
// total=13, drop -2h (5) -> 8, 8 >= 8, drop -1h (8) -> 0 < 8
// hours = 3 - 1 = 2 -> 2 * 3600 - 1800 = 5400s
expect(getRetryAfterHour(rows, intervals, 8, now)).toBe(5400)
})
test("waits 1 hour when oldest interval alone pushes over limit", () => {
const rows = [
{ interval: "2026011514", count: 1 },
{ interval: "2026011513", count: 1 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop -2h (10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when middle interval keeps total over limit", () => {
const rows = [
{ interval: "2026011514", count: 4 },
{ interval: "2026011513", count: 4 },
{ interval: "2026011512", count: 4 },
]
// total=12, drop -2h (4) -> 8, 8 >= 5, drop -1h (4) -> 4 < 5
// hours = 3 - 1 = 2 -> 5400s
expect(getRetryAfterHour(rows, intervals, 5, now)).toBe(5400)
})
test("rounds up to nearest second", () => {
const offset = Date.UTC(2026, 0, 15, 14, 30, 0, 500)
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// hours=1 -> 3_600_000 - 1_800_500 = 1_799_500ms -> ceil(1799.5) = 1800
expect(getRetryAfterHour(rows, intervals, 10, offset)).toBe(1800)
})
test("fallback returns time until next hour when rows are empty", () => {
// edge case: rows empty but function called (shouldn't happen in practice)
// loop drops all zeros, running stays 0 which is < any positive limit on first iteration
const rows: { interval: string; count: number }[] = []
// drop -2h (0) -> 0 < 1 -> hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 1, now)).toBe(1800)
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.56",
"version": "1.1.53",
"private": true,
"type": "module",
"license": "MIT",
@@ -19,7 +19,6 @@
"zod": "catalog:"
},
"exports": {
"./*.js": "./src/*.ts",
"./*": "./src/*"
},
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.56",
"version": "1.1.53",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -17,7 +17,8 @@ export default {
)
return
let data = {
let metrics = {
event_type: "completions",
"cf.continent": event.event.request.cf?.continent,
"cf.country": event.event.request.cf?.country,
"cf.city": event.event.request.cf?.city,
@@ -30,28 +31,22 @@ export default {
status: event.event.response?.status ?? 0,
ip: event.event.request.headers["x-real-ip"],
}
const time = new Date(event.eventTimestamp ?? Date.now()).toISOString()
const events = []
for (const log of event.logs) {
for (const message of log.message) {
if (!message.startsWith("_metric:")) continue
const json = JSON.parse(message.slice(8))
data = { ...data, ...json }
if ("llm.error.code" in json) {
events.push({ time, data: { ...data, event_type: "llm.error" } })
}
metrics = { ...metrics, ...JSON.parse(message.slice(8)) }
}
}
events.push({ time, data: { ...data, event_type: "completions" } })
console.log(JSON.stringify(data, null, 2))
console.log(JSON.stringify(metrics, null, 2))
const ret = await fetch("https://api.honeycomb.io/1/batch/zen", {
const ret = await fetch("https://api.honeycomb.io/1/events/zen", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Honeycomb-Event-Time": (event.eventTimestamp ?? Date.now()).toString(),
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
},
body: JSON.stringify(events),
body: JSON.stringify(metrics),
})
console.log(ret.status)
console.log(await ret.text())

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.56",
"version": "1.1.53",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.56",
"version": "1.1.53",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -20,9 +20,9 @@ use std::{
env,
net::TcpListener,
path::PathBuf,
process::Command,
sync::{Arc, Mutex},
time::Duration,
process::Command,
};
use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel};
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
@@ -152,12 +152,12 @@ fn check_app_exists(app_name: &str) -> bool {
{
check_windows_app(app_name)
}
#[cfg(target_os = "macos")]
{
check_macos_app(app_name)
}
#[cfg(target_os = "linux")]
{
check_linux_app(app_name)
@@ -165,165 +165,11 @@ fn check_app_exists(app_name: &str) -> bool {
}
#[cfg(target_os = "windows")]
fn check_windows_app(_app_name: &str) -> bool {
fn check_windows_app(app_name: &str) -> bool {
// Check if command exists in PATH, including .exe
return true;
}
#[cfg(target_os = "windows")]
fn resolve_windows_app_path(app_name: &str) -> Option<String> {
use std::path::{Path, PathBuf};
// Try to find the command using 'where'
let output = Command::new("where").arg(app_name).output().ok()?;
if !output.status.success() {
return None;
}
let paths = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect::<Vec<_>>();
let has_ext = |path: &Path, ext: &str| {
path.extension()
.and_then(|v| v.to_str())
.map(|v| v.eq_ignore_ascii_case(ext))
.unwrap_or(false)
};
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
return Some(path.to_string_lossy().to_string());
}
let resolve_cmd = |path: &Path| -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for token in content.split('"') {
let lower = token.to_ascii_lowercase();
if !lower.contains(".exe") {
continue;
}
if let Some(index) = lower.find("%~dp0") {
let base = path.parent()?;
let suffix = &token[index + 5..];
let mut resolved = PathBuf::from(base);
for part in suffix.replace('/', "\\").split('\\') {
if part.is_empty() || part == "." {
continue;
}
if part == ".." {
let _ = resolved.pop();
continue;
}
resolved.push(part);
}
if resolved.exists() {
return Some(resolved.to_string_lossy().to_string());
}
}
let resolved = PathBuf::from(token);
if resolved.exists() {
return Some(resolved.to_string_lossy().to_string());
}
}
None
};
for path in &paths {
if has_ext(path, "cmd") || has_ext(path, "bat") {
if let Some(resolved) = resolve_cmd(path) {
return Some(resolved);
}
}
if path.extension().is_none() {
let cmd = path.with_extension("cmd");
if cmd.exists() {
if let Some(resolved) = resolve_cmd(&cmd) {
return Some(resolved);
}
}
let bat = path.with_extension("bat");
if bat.exists() {
if let Some(resolved) = resolve_cmd(&bat) {
return Some(resolved);
}
}
}
}
let key = app_name
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
if !key.is_empty() {
for path in &paths {
let dirs = [
path.parent(),
path.parent().and_then(|dir| dir.parent()),
path.parent()
.and_then(|dir| dir.parent())
.and_then(|dir| dir.parent()),
];
for dir in dirs.into_iter().flatten() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let candidate = entry.path();
if !has_ext(&candidate, "exe") {
continue;
}
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
continue;
};
let name = stem
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
if name.contains(&key) || key.contains(&name) {
return Some(candidate.to_string_lossy().to_string());
}
}
}
}
}
}
paths.first().map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
#[specta::specta]
fn resolve_app_path(app_name: &str) -> Option<String> {
#[cfg(target_os = "windows")]
{
resolve_windows_app_path(app_name)
}
#[cfg(not(target_os = "windows"))]
{
// On macOS/Linux, just return the app_name as-is since
// the opener plugin handles them correctly
Some(app_name.to_string())
}
}
#[cfg(target_os = "macos")]
fn check_macos_app(app_name: &str) -> bool {
// Check common installation locations
@@ -335,13 +181,13 @@ fn check_macos_app(app_name: &str) -> bool {
if let Ok(home) = std::env::var("HOME") {
app_locations.push(format!("{}/Applications/{}.app", home, app_name));
}
for location in app_locations {
if std::path::Path::new(&location).exists() {
return true;
}
}
// Also check if command exists in PATH
Command::new("which")
.arg(app_name)
@@ -405,8 +251,7 @@ pub fn run() {
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists,
resolve_app_path
check_app_exists
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);

View File

@@ -14,7 +14,6 @@ export const commands = {
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
};
/** Events */

View File

@@ -116,15 +116,6 @@ function parseRecord(value: unknown) {
return value as Record<string, unknown>
}
function parseStored(value: unknown) {
if (typeof value !== "string") return value
try {
return JSON.parse(value) as unknown
} catch {
return value
}
}
function pickLocale(value: unknown): Locale | null {
const direct = parseLocale(value)
if (direct) return direct
@@ -178,7 +169,7 @@ export function initI18n(): Promise<Locale> {
if (!store) return state.locale
const raw = await store.get("language").catch(() => null)
const value = parseStored(raw)
const value = typeof raw === "string" ? JSON.parse(raw) : raw
const next = pickLocale(value) ?? state.locale
state.locale = next

View File

@@ -98,12 +98,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
void shellOpen(url).catch(() => undefined)
},
async openPath(path: string, app?: string) {
const os = ostype()
if (os === "windows" && app) {
const resolvedApp = await commands.resolveAppPath(app)
return openerOpenPath(path, resolvedApp || app)
}
openPath(path: string, app?: string) {
return openerOpenPath(path, app)
},

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.56",
"version": "1.1.53",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.56"
version = "1.1.53"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.56",
"version": "1.1.53",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -2,4 +2,4 @@ preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]
timeout = 30000 # 30 seconds - allow time for package installation
timeout = 10000 # 10 seconds (default is 5000ms)

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.56",
"version": "1.1.53",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
You are a helpful AI assistant tasked with summarizing conversations.
When asked to summarize, provide a detailed but concise summary of the conversation.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
@@ -10,5 +10,3 @@ Focus on information that would be helpful for continuing the conversation, incl
- Important technical decisions and why they were made
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
Do not respond to any questions in the conversation, only output the summary.

View File

@@ -0,0 +1,584 @@
# Code Review: `packages/opencode/src/cli/cmd/tui/app.tsx`
## Summary
The file is a top-level TUI application entry point. It's structurally sound but has a number of style guide violations and readability issues scattered throughout. The main concerns are: unnecessary destructuring, `let` where `const` or different patterns would work, unnecessary type annotations, redundant variables, an `else if` chain that could be simplified, and a stale `Show` import. The `App` function is large but is the root wiring point for the TUI so that's acceptable by the "keep things in one function unless composable or reusable" principle.
---
## Issues
### 1. Unnecessary destructuring of `useTheme()` (line 197)
Destructuring pulls three values out and loses context about where they come from. Use dot notation per style guide.
**Before (line 197):**
```tsx
const { theme, mode, setMode } = useTheme()
```
**After:**
```tsx
const theme = useTheme()
```
Then replace all usages:
- `theme.background` -> `theme.theme.background` (line 694)
- `mode()` -> `theme.mode()` (line 496)
- `setMode(...)` -> `theme.setMode(...)` (line 496)
**Why:** Avoids unnecessary destructuring. The `theme` variable name collides with the destructured `theme` property, which is confusing -- the current code has `theme.background` which looks like it's accessing the theme context, but it's actually the nested `theme` property. Dot notation makes the nesting explicit.
---
### 2. `let` used for `r`, `g`, `b` with reassignment (lines 61-63)
These use `let` with default values and then get reassigned inside branches.
**Before (lines 61-79):**
```tsx
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8
g = parseInt(parts[1], 16) >> 8
b = parseInt(parts[2], 16) >> 8
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
} else if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
r = parseInt(parts[0])
g = parseInt(parts[1])
b = parseInt(parts[2])
}
```
**After:**
```tsx
const rgb = (() => {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return [parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8]
}
if (color.startsWith("#")) {
return [
parseInt(color.substring(1, 3), 16),
parseInt(color.substring(3, 5), 16),
parseInt(color.substring(5, 7), 16),
]
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return [parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2])]
}
return [0, 0, 0]
})()
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255
```
**Why:** Replaces three `let` variables and an `else if` chain with `const` and early returns. The `else if` chain is replaced by sequential `if` + `return` which is the preferred style.
---
### 3. `let` used for `continued` and `forked` flags (lines 263, 289)
These are boolean flags mutated inside reactive effects. This is a common SolidJS pattern for "run once" guards, so `let` is somewhat justified, but it's still worth noting.
**Before (lines 263-264):**
```tsx
let continued = false
createEffect(() => {
if (continued || sync.status === "loading" || !args.continue) return
...
continued = true
```
**After:**
```tsx
const continued = { current: false }
createEffect(() => {
if (continued.current || sync.status === "loading" || !args.continue) return
...
continued.current = true
```
**Why:** This is a minor stylistic point. Using a ref object lets you use `const` while still mutating state. However, the `let` pattern is idiomatic in SolidJS effects and is arguably clearer here. **This one is borderline -- keep as-is if the team prefers the SolidJS idiom.**
---
### 4. Unnecessary type annotation on `handler` parameter (line 53)
**Before (line 53):**
```tsx
const handler = (data: Buffer) => {
```
**After:**
```tsx
const handler = (data: Buffer) => {
```
This one is actually needed because `process.stdin.on("data", handler)` needs the signature. **No change needed.** Noting it for completeness.
---
### 5. Unnecessary type annotation on return type (line 40)
**Before (line 40):**
```tsx
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
```
**After:**
```tsx
async function getTerminalBackgroundColor() {
```
**Why:** The return type can be inferred from the `resolve()` calls that pass `"dark"` or `"light"`. Removing the explicit annotation reduces noise. However, since this is an exported-level utility and the inference relies on the `resolve` calls inside a `new Promise` constructor, the explicit annotation provides safety. **Borderline -- keep if you prefer explicit contracts on standalone functions.**
---
### 6. Unnecessary `onExit` wrapper variable (lines 114-117)
**Before (lines 114-117):**
```tsx
const onExit = async () => {
await input.onExit?.()
resolve()
}
```
This is used twice (passed to `ErrorBoundary` and `ExitProvider`), so the variable is justified. **No change needed.**
---
### 7. Unnecessary destructuring of `Provider.parseModel()` (line 244)
**Before (lines 244-251):**
```tsx
const { providerID, modelID } = Provider.parseModel(args.model)
if (!providerID || !modelID)
return toast.show({
variant: "warning",
message: `Invalid model format: ${args.model}`,
duration: 3000,
})
local.model.set({ providerID, modelID }, { recent: true })
```
**After:**
```tsx
const parsed = Provider.parseModel(args.model)
if (!parsed.providerID || !parsed.modelID)
return toast.show({
variant: "warning",
message: `Invalid model format: ${args.model}`,
duration: 3000,
})
local.model.set({ providerID: parsed.providerID, modelID: parsed.modelID }, { recent: true })
```
**Why:** Avoids destructuring per style guide. Uses dot notation to preserve the context that these values came from `parseModel`. However, in this case the destructured names are immediately passed into an object with the same keys, so destructuring is arguably cleaner. **Borderline -- the destructuring here is compact and the repacked object would be more verbose. Could go either way.**
---
### 8. Redundant `text.length === 0` check (line 204)
**Before (line 204):**
```tsx
if (!text || text.length === 0) return
```
**After:**
```tsx
if (!text) return
```
**Why:** If `text` is an empty string, `!text` is already `true`. The `text.length === 0` check is redundant with the falsy check.
---
### 9. Same redundancy on line 701
**Before (line 701):**
```tsx
if (text && text.length > 0) {
```
**After:**
```tsx
if (text) {
```
**Why:** Same reason. A non-empty string is truthy; an empty string is falsy. `text.length > 0` is redundant.
---
### 10. Unnecessary `const` for `color` (line 58)
**Before (lines 58-59):**
```tsx
const color = match[1]
// Parse RGB values from color string
```
`color` is used several times in the block, so this is fine. **No change needed.**
---
### 11. Unused import: `Show` (line 5)
**Before (line 5):**
```tsx
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
```
`Show` is imported but never used in the file.
**After:**
```tsx
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, on } from "solid-js"
```
**Why:** Dead imports add noise and can confuse readers about what's actually used.
---
### 12. Stray `import type` after function definition (line 100)
**Before (line 100):**
```tsx
import type { EventSource } from "./context/sdk"
```
This import is placed between `getTerminalBackgroundColor` and `tui`, breaking the convention that all imports are at the top of the file.
**After:** Move to the top of the file with the other imports (after line 38).
**Why:** Import statements should be grouped at the top of the file. A stray import in the middle is surprising and easy to miss.
---
### 13. Duplicated fork logic (lines 273-279 and 293-298)
The fork-and-navigate pattern is repeated in two effects:
**Lines 273-279:**
```tsx
sdk.client.session.fork({ sessionID: match }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
```
**Lines 293-298:**
```tsx
sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
```
**After:** Extract a helper:
```tsx
const fork = (sessionID: string) => {
sdk.client.session.fork({ sessionID }).then((result) => {
if (result.data?.id) return route.navigate({ type: "session", sessionID: result.data.id })
toast.show({ message: "Failed to fork session", variant: "error" })
})
}
```
Then use `fork(match)` and `fork(args.sessionID)` respectively.
**Why:** Eliminates duplicated code and makes the intent clearer.
---
### 14. `else` in fork result handling (lines 274-278)
Inside the duplicated fork logic:
**Before:**
```tsx
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
```
**After:**
```tsx
if (result.data?.id) return route.navigate({ type: "session", sessionID: result.data.id })
toast.show({ message: "Failed to fork session", variant: "error" })
```
**Why:** Avoids `else` per style guide. Early return is cleaner.
---
### 15. Unnecessary intermediate variable `message` in error handler (lines 662-672)
The IIFE pattern for `message` is fine but could be simplified.
**Before (lines 659-678):**
```tsx
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {
if (!error) return "An error occurred"
if (typeof error === "object") {
const data = error.data
if ("message" in data && typeof data.message === "string") {
return data.message
}
}
return String(error)
})()
toast.show({
variant: "error",
message,
duration: 5000,
})
})
```
The intermediate `const data = error.data` on line 667 can be inlined:
**After:**
```tsx
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {
if (!error) return "An error occurred"
if (typeof error === "object" && "message" in error.data && typeof error.data.message === "string") {
return error.data.message
}
return String(error)
})()
toast.show({
variant: "error",
message,
duration: 5000,
})
})
```
**Why:** Inlines `data` (used once), removes a nesting level, and collapses the condition into a single `if`. Follows "reduce variable count by inlining when value is only used once."
---
### 16. Unnecessary `let timeout` declaration style (line 45)
**Before (lines 45, 93-96):**
```tsx
let timeout: NodeJS.Timeout
...
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
```
**After:**
```tsx
const timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
```
But this requires reordering -- `cleanup` references `timeout` via `clearTimeout(timeout)`. The current structure declares `timeout` first so `cleanup` can close over it, then assigns later. This is a necessary pattern due to the circular reference between `cleanup` and `timeout`.
**Alternative -- move setTimeout before the stdin listener setup and move cleanup inline:**
Actually, the cleanest fix is to just accept the `let` here since it's a necessary consequence of the circular dependency. **No change -- the `let` is justified.**
---
### 17. `isLight` intermediate variable (line 746)
**Before (lines 746-752):**
```tsx
const isLight = props.mode === "light"
const colors = {
bg: isLight ? "#ffffff" : "#0a0a0a",
text: isLight ? "#1a1a1a" : "#eeeeee",
muted: isLight ? "#8a8a8a" : "#808080",
primary: isLight ? "#3b7dd8" : "#fab283",
}
```
`isLight` is used four times, so the variable is justified. **No change needed.**
---
### 18. Debug `console.log` left in (lines 213-215)
**Before (lines 213-215):**
```tsx
createEffect(() => {
console.log(JSON.stringify(route.data))
})
```
This logs route data on every navigation. Looks like a leftover debug statement.
**After:** Remove entirely.
**Why:** Debug logging should not be left in production code. It pollutes stdout and is clearly a development artifact.
---
### 19. `currentPrompt` intermediate variable used once (lines 342-343)
**Before (lines 340-346):**
```tsx
onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
})
```
**After:**
```tsx
onSelect: () => {
// Don't require focus - if there's any text, preserve it
route.navigate({
type: "home",
initialPrompt: promptRef.current?.current?.input ? promptRef.current.current : undefined,
})
```
**Why:** Both `current` and `currentPrompt` are used once. Inlining reduces variable count per style guide. The comment still explains the intent.
---
### 20. `async` on `new Promise` executor (line 112)
**Before (line 112):**
```tsx
return new Promise<void>(async (resolve) => {
```
Passing an `async` function as a Promise executor is an antipattern. If the `await` on line 113 throws, the error is silently swallowed because the Promise constructor can't catch rejections from async executors.
**After:**
```tsx
const mode = await getTerminalBackgroundColor()
const onExit = async () => {
await input.onExit?.()
}
render(
() => { ... },
{ ... },
)
// Return a promise that never resolves to keep the process alive,
// resolved via onExit
return new Promise<void>((resolve) => {
// Expose resolve to onExit
})
```
Actually, the simplest fix while keeping the current structure:
```tsx
export async function tui(input: { ... }) {
const mode = await getTerminalBackgroundColor()
return new Promise<void>((resolve) => {
const onExit = async () => {
await input.onExit?.()
resolve()
}
render(
() => { ... },
{ ... },
)
})
}
```
**Why:** `async` executor is a well-known antipattern. Moving the `await` before `new Promise` eliminates the issue and is just as readable.
---
## Summary of Actionable Changes (by priority)
| Priority | Issue | Lines |
| -------- | ---------------------------------------------------- | ---------------- |
| High | Remove debug `console.log` | 213-215 |
| High | Remove unused `Show` import | 5 |
| High | Move stray `import type` to top | 100 |
| High | Fix async Promise executor antipattern | 112 |
| Medium | Remove redundant `text.length` checks | 204, 701 |
| Medium | Extract duplicated fork logic | 273-279, 293-298 |
| Medium | Replace `else` with early returns in fork handler | 274-278 |
| Medium | Inline `data` variable in error handler | 667 |
| Medium | Inline `currentPrompt` variable | 342-343 |
| Low | Replace `let r,g,b` with IIFE returning array | 61-79 |
| Low | Avoid destructuring `useTheme()` | 197 |
| Neutral | `let continued`/`forked` flags are idiomatic SolidJS | 263, 289 |

View File

@@ -0,0 +1,27 @@
# Review: `border.tsx`
## Summary
This is a small, 22-line file that defines two shared border configuration objects for TUI components. The code is clean and functional. There is one minor style improvement available, but overall this file is well-written and appropriately scoped.
## Issues
### 1. Repetitive `as const` on individual array elements (line 16)
The `as const` assertion is applied to each string element individually. This is necessary to narrow the array type from `string[]` to `("left" | "right")[]`, which the `border` prop requires. However, applying `as const` to each element separately is noisier than applying it once to the whole array.
**Line 16:**
```tsx
// Before
border: ["left" as const, "right" as const],
// After
border: ["left", "right"] as const,
```
**Why:** Same type narrowing effect with less repetition. `["left", "right"] as const` produces a `readonly ["left", "right"]` tuple, which is assignable to the `border` prop. One assertion instead of two.
---
That's it. The file is concise, exports are well-named, `EmptyBorder` and `SplitBorder` are descriptive single-concept names, and the spread of `EmptyBorder` into `SplitBorder.customBorderChars` is a clean way to override a single property. No unnecessary destructuring, no `let`, no `else`, no `any`, no over-abstraction. This file is in good shape.

View File

@@ -0,0 +1,59 @@
# Review: `dialog-agent.tsx`
## Summary
This is a small, clean file (32 lines). It follows most style conventions and is easy to read. There are only two minor issues worth addressing.
## Issues
### 1. Unnecessary block body in `.map()` callback (line 11-17)
The `.map()` uses a block body with an explicit `return` when a concise arrow body would suffice. Every sibling dialog file (e.g. `dialog-skill.tsx:23`) uses the concise form for the same pattern.
**Before:**
```tsx
const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.native ? "native" : item.description,
}
}),
)
```
**After:**
```tsx
const options = createMemo(() =>
local.agent.list().map((item) => ({
value: item.name,
title: item.name,
description: item.native ? "native" : item.description,
})),
)
```
**Why:** Removes the unnecessary `return` keyword and braces. The implicit-return form is the established pattern in the codebase (see `dialog-skill.tsx`, `dialog-model.tsx`) and is slightly easier to scan because there's less syntactic noise.
### 2. Verbose `onSelect` handler could be inlined further (lines 25-28)
Minor, but the `onSelect` handler destructures nothing and could be slightly tighter by putting both calls on separate lines without the extra blank-line feel. This is a style-only nit -- the current form is perfectly acceptable.
No change recommended here. Both calls depend on `option` so no simplification is possible, and the current formatting is consistent with the rest of the codebase.
## No issues found
The following were checked and found to be clean:
- **No unnecessary destructuring** -- `local.agent.list()`, `local.agent.current().name`, `local.agent.set()`, `dialog.clear()` all use dot notation correctly.
- **No unnecessary type annotations** -- types are fully inferred; the explicit `DialogSelectOption` annotation that `dialog-skill.tsx:20` uses is absent here, which is correct per the style guide.
- **No `let` where `const` would work** -- no `let` usage at all.
- **No `else` statements** -- none present.
- **No `try`/`catch`** -- none present.
- **No `any` type** -- none present.
- **No unnecessary variables** -- `local` and `dialog` are each used more than once (or exactly once but needed for context hook semantics). `options` is a reactive memo, necessarily a variable.
- **Naming** -- `local`, `dialog`, `options`, `item` are all single-word, clear names. Good.
- **Single responsibility** -- the component does one thing: renders a `DialogSelect` with agent options. No extractable sub-functions needed.

View File

@@ -0,0 +1,220 @@
# Review: `dialog-command.tsx`
## Summary
This file is relatively clean and well-structured overall. The `init()` pattern with signals and memos is consistent with other dialog files in the codebase. There are a handful of style guide violations and minor readability improvements to make, but nothing structurally wrong.
---
## Issues
### 1. Unnecessary destructuring of imports (line 3-11)
Individual named imports from `solid-js` are fine as this is standard practice for framework primitives. No change needed here — this is idiomatic Solid.
---
### 2. Unnecessary intermediate variable in `slashes()` (line 86)
The variable `slash` is used only to null-check and then access properties. Per the style guide: "Reduce total variable count by inlining when a value is only used once." However, here `slash` is used twice (`slash.name`, `slash.aliases`), so the variable is justified for the null guard. But the naming `slash` shadowing the type `Slash` is slightly confusing — renaming isn't necessary since it's a local scope, but worth noting.
No change needed.
---
### 3. `for...of` loop in `useKeyboard` callback could use functional style (lines 64-71)
The style guide prefers functional array methods over for loops. This loop has early returns and side effects (`evt.preventDefault()`), which makes a `for` loop defensible here since `find` + side effects is awkward. However, `find` is actually a clean fit:
**Before (line 64-71):**
```tsx
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
}
}
```
**After:**
```tsx
const match = entries().find((option) => isEnabled(option) && option.keybind && keybind.match(option.keybind, evt))
if (!match) return
evt.preventDefault()
match.onSelect?.(dialog)
```
**Why:** Replaces imperative loop with a declarative `find`, separating the search from the side effect. The early return pattern is preserved. Reads as "find the matching option, then act on it."
---
### 4. `for...of` loop in `trigger()` could use `find` (lines 76-82)
Same pattern as above — an imperative loop that searches for a match and acts on it.
**Before (line 75-83):**
```tsx
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return
option.onSelect?.(dialog)
return
}
}
},
```
**After:**
```tsx
trigger(name: string) {
const match = entries().find((option) => option.value === name)
if (!match || !isEnabled(match)) return
match.onSelect?.(dialog)
},
```
**Why:** Shorter, declarative, and easier to follow. The intent — "find the option with this name and trigger it" — is immediately clear. Avoids nested `if` blocks inside a loop.
---
### 5. `let ref` with mutation in `DialogCommand` (lines 142-148)
The `ref` variable uses `let` and is mutated via the JSX ref callback. This is a standard Solid pattern for imperative refs and can't be avoided with `const` + ternary. No change needed — this is idiomatic.
---
### 6. Verbose `option` parameter name in filter callbacks (lines 49-57)
The callbacks use `option` as the parameter name, which is fine for clarity. But some callbacks are already short enough that a single-character name would reduce line length without hurting readability, consistent with the `(x) => x()` pattern already used on line 39.
**Before (lines 49-57):**
```tsx
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
```
**After:**
```tsx
const visibleOptions = createMemo(() => entries().filter(isVisible))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((x) => x.suggested)
.map((x) => ({
...x,
value: `suggested:${x.value}`,
category: "Suggested",
})),
)
```
**Why:** `entries().filter(isVisible)` is a point-free style that's shorter and reads naturally — `isVisible` already takes a `CommandOption` and returns boolean, so wrapping it in `(option) => isVisible(option)` is redundant. Using `x` in the chained `.filter().map()` is consistent with the existing style on line 39 (`(x) => x()`), and reduces visual noise in the multi-line map.
---
### 7. `keybinds` method name is misleading (line 96-98)
The method `keybinds(enabled: boolean)` toggles whether keybinds are suspended. The name suggests it returns keybinds or configures them. The logic is also inverted — passing `true` _decrements_ the suspend count (enabling), while `false` _increments_ it (suspending). This is counterintuitive.
**Before (line 96-98):**
```tsx
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
```
A more descriptive name would clarify intent:
**After:**
```tsx
suspend(suspended: boolean) {
setSuspendCount((count) => count + (suspended ? 1 : -1))
},
```
**Why:** The current name `keybinds` doesn't communicate that it's toggling suspension. `suspend(true)` reads as "suspend keybinds" and `suspend(false)` reads as "unsuspend keybinds," which matches the mental model. The boolean logic is also flipped to be intuitive — `true` means "yes, suspend."
_Note: This would require updating all call sites. Check usage before applying._
---
### 8. `useCommandDialog` could inline the context check (lines 114-120)
Minor, but the intermediate `value` variable is only used once.
**Before (lines 114-120):**
```tsx
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
```
This is actually a common pattern in the codebase (see `useDialog` in `dialog.tsx:161-167` which is identical). Keeping it consistent is more important than micro-optimizing. **No change needed.**
---
### 9. `CommandProvider` re-calls `useDialog` and `useKeybind` (lines 124-125)
`init()` already calls `useDialog()` and `useKeybind()` internally (lines 35-36). `CommandProvider` calls them again (lines 124-125) to use in its own `useKeyboard` callback. This means two separate references to the same context values.
This is fine — Solid contexts are singletons per provider scope, so both calls return the same object. But it's worth noting that `init()` could expose these if the duplication bothered you. In practice, the current approach is cleaner because `CommandProvider` doesn't need to reach into `init`'s internals.
**No change needed.**
---
### 10. `list` function in `DialogCommand` uses `let ref` and conditional logic (lines 142-148)
**Before (lines 141-148):**
```tsx
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return props.options
return [...props.suggestedOptions, ...props.options]
}
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
}
```
The `list` function checks `ref?.filter` — this means "if the user has typed a filter, show only regular options (let DialogSelect handle filtering); otherwise show suggested + regular." This logic is fine but the reliance on the mutable `ref` makes it non-reactive in Solid terms — `list()` won't re-execute when `ref.filter` changes because `ref` isn't a signal.
This appears to work because `options` being passed as a prop means the parent re-renders trigger re-evaluation. But it's fragile. Consider whether this should use a signal instead. This is more of a latent bug concern than a style issue.
---
## Summary of Recommended Changes
| Line(s) | Issue | Severity |
| ------- | --------------------------------------------------------------------------- | -------- |
| 64-71 | `for` loop -> `find` | Low |
| 76-82 | `for` loop -> `find` | Low |
| 49 | Redundant wrapper `(option) => isVisible(option)` -> point-free `isVisible` | Low |
| 50-57 | Verbose `option` param -> `x` for consistency | Low |
| 96-98 | Misleading method name `keybinds` with inverted boolean | Medium |
The file is concise at 149 lines, well-organized, and follows most of the project's conventions. The main actionable improvements are replacing the two `for` loops with `find`, using point-free style for `isVisible`, and reconsidering the `keybinds` method name.

View File

@@ -0,0 +1,315 @@
# Review: `dialog-mcp.tsx`
## Summary
This is a small, relatively clean file (87 lines). The structure is sound and the
component decomposition (extracting `Status`) is appropriate. However, there are
several style guide violations and minor readability improvements to address:
unnecessary variables, an unnecessary comment, a `try/catch` that could be
simplified, and an unused `setRef` signal.
---
## Issues
### 1. Unused `setRef` signal (line 26)
`setRef` is created but never consumed. The signal value `ref` (the first element)
is discarded, and `setRef` is only passed as a `ref` prop to `DialogSelect`. Since
nothing ever reads the ref signal, this is dead code.
```tsx
// Before (line 26)
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
```
```tsx
// After — remove entirely, and remove the ref prop on line 77
// (remove line 26 and the ref={setRef} on line 77)
```
**Why:** Dead code adds cognitive overhead. If no consumer reads the ref, the signal
serves no purpose.
---
### 2. Unnecessary intermediate variables in `options` memo (lines 31-32)
`mcpData` and `loadingMcp` are each used exactly once. The comment says they exist
to "track" reactive values, but in Solid, simply calling `sync.data.mcp` and
`loading()` inside the memo already tracks them. The variables add nothing.
```tsx
// Before (lines 29-46)
const options = createMemo(() => {
// Track sync data and loading state to trigger re-render when they change
const mcpData = sync.data.mcp
const loadingMcp = loading()
return pipe(
mcpData ?? {},
entries(),
sortBy(([name]) => name),
map(([name, status]) => ({
value: name,
title: name,
description: status.status === "failed" ? "failed" : status.status,
footer: <Status enabled={local.mcp.isEnabled(name)} loading={loadingMcp === name} />,
category: undefined,
})),
)
})
```
```tsx
// After
const options = createMemo(() =>
pipe(
sync.data.mcp ?? {},
entries(),
sortBy(([name]) => name),
map(([name, status]) => ({
value: name,
title: name,
description: status.status === "failed" ? "failed" : status.status,
footer: <Status enabled={local.mcp.isEnabled(name)} loading={loading() === name} />,
category: undefined,
})),
),
)
```
**Why:** Inlining values used once reduces variable count and removes a misleading
comment. Solid's reactivity tracks any signal/store access inside `createMemo`
automatically.
---
### 3. `try/catch` can be avoided (lines 57-70)
The style guide says to avoid `try/catch` where possible. The catch block only logs
to console, which provides minimal value in a TUI. The `finally` block resetting
loading state is the only important part, and that can be handled with `.then()` /
`.finally()` or by restructuring.
```tsx
// Before (lines 52-71)
onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress
if (loading() !== null) return
setLoading(option.value)
try {
await local.mcp.toggle(option.value)
// Refresh MCP status from server
const status = await sdk.client.mcp.status()
if (status.data) {
sync.set("mcp", status.data)
} else {
console.error("Failed to refresh MCP status: no data returned")
}
} catch (error) {
console.error("Failed to toggle MCP:", error)
} finally {
setLoading(null)
}
},
```
```tsx
// After
onTrigger: async (option: DialogSelectOption<string>) => {
if (loading() !== null) return
setLoading(option.value)
await local.mcp.toggle(option.value)
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
},
```
**Why:** The `try/catch` catches errors only to `console.error` them, which is
not meaningfully useful in a TUI context. Removing it follows the style guide
preference to avoid `try/catch`. If error handling is truly needed here, it should
do something visible to the user (e.g. a toast), not just log. The `else` branch
logging "no data returned" is also unlikely to occur and adds noise.
---
### 4. Unnecessary type annotation on `onTrigger` parameter (line 52)
The `keybind` type on `DialogSelect` already defines what `onTrigger` receives. The
explicit `DialogSelectOption<string>` annotation is redundant.
```tsx
// Before (line 52)
onTrigger: async (option: DialogSelectOption<string>) => {
```
```tsx
// After
onTrigger: async (option) => {
```
**Why:** The style guide prefers relying on type inference. The type is already
constrained by the keybind definition in `DialogSelectProps`.
---
### 5. Unnecessary comment on `onSelect` (lines 81-83)
The empty `onSelect` handler with a comment explaining why it's empty is noise. If
the component works correctly without an `onSelect` (i.e., the dialog doesn't auto-close),
then just don't pass the prop. If the prop is required, an empty function with no
comment is clearer.
```tsx
// Before (lines 81-83)
onSelect={(option) => {
// Don't close on select, only on escape
}}
```
```tsx
// After — either remove entirely if optional, or:
onSelect={() => {}}
```
**Why:** Comments explaining what code _doesn't_ do are usually noise. The behavior
is self-evident from an empty handler.
---
### 6. Redundant `category: undefined` (line 43)
Explicitly setting `category` to `undefined` is the same as not including the
property at all.
```tsx
// Before (line 43)
category: undefined,
```
```tsx
// After — remove the line
```
**Why:** `undefined` is the default for missing properties. Including it explicitly
suggests the field is meaningful here when it isn't.
---
### 7. Redundant comment (line 53)
```tsx
// Before (line 54)
// Prevent toggling while an operation is already in progress
if (loading() !== null) return
```
```tsx
// After
if (loading() !== null) return
```
**Why:** The code is self-explanatory. The guard clause checking `loading()` clearly
prevents concurrent operations. The comment restates the obvious.
---
### 8. Variable name `status` shadows conceptually (line 60)
Inside the `onTrigger`, the variable `status` (the API response) is conceptually
different from the `status` in the MCP option mapping (the connection status). Using
`result` would be clearer.
```tsx
// Before (line 60)
const status = await sdk.client.mcp.status()
if (status.data) {
sync.set("mcp", status.data)
```
```tsx
// After
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
```
**Why:** `status` is already heavily used in this file to mean MCP connection status.
Using it for an API response object creates ambiguity.
---
### 9. Import of `TextAttributes` is only used in `Status` (line 8)
Minor, but `TextAttributes` is imported at the top level and only used in the
`Status` sub-component. This is fine structurally but worth noting — the import is
justified since `Status` is in the same file.
No change needed, just noting it's been reviewed.
---
## Suggested final state
```tsx
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "@tui/context/sdk"
function Status(props: { enabled: boolean; loading: boolean }) {
const { theme } = useTheme()
if (props.loading) {
return <span style={{ fg: theme.textMuted }}> Loading</span>
}
if (props.enabled) {
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}> Enabled</span>
}
return <span style={{ fg: theme.textMuted }}> Disabled</span>
}
export function DialogMcp() {
const local = useLocal()
const sync = useSync()
const sdk = useSDK()
const [loading, setLoading] = createSignal<string | null>(null)
const options = createMemo(() =>
pipe(
sync.data.mcp ?? {},
entries(),
sortBy(([name]) => name),
map(([name, status]) => ({
value: name,
title: name,
description: status.status === "failed" ? "failed" : status.status,
footer: <Status enabled={local.mcp.isEnabled(name)} loading={loading() === name} />,
})),
),
)
const keybinds = createMemo(() => [
{
keybind: Keybind.parse("space")[0],
title: "toggle",
onTrigger: async (option) => {
if (loading() !== null) return
setLoading(option.value)
await local.mcp.toggle(option.value)
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
},
},
])
return <DialogSelect title="MCPs" options={options()} keybind={keybinds()} onSelect={() => {}} />
}
```

View File

@@ -33,6 +33,12 @@ export function DialogModel(props: { providerID?: string }) {
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()
const recentList = showSections
? recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
)
: []
function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.flatMap((item) => {
@@ -59,12 +65,7 @@ export function DialogModel(props: { providerID?: string }) {
}
const favoriteOptions = toOptions(favorites, "Favorites")
const recentOptions = toOptions(
recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
),
"Recent",
)
const recentOptions = toOptions(recentList, "Recent")
const providerOptions = pipe(
sync.data.provider,
@@ -119,11 +120,11 @@ export function DialogModel(props: { providerID?: string }) {
)
: []
// Search shows a single merged list (favorites inline)
if (needle) {
return [
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredProviders, ...filteredPopular]
}
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
@@ -156,7 +157,6 @@ export function DialogModel(props: { providerID?: string }) {
},
]}
onFilter={setQuery}
flat={true}
skipFilter={true}
title={title()}
current={local.model.current()}

View File

@@ -0,0 +1,340 @@
# Review: `dialog-model.tsx`
## Summary
The file has a clear structure, but suffers from significant code duplication between `favoriteOptions` and `recentOptions` (lines 48-112). The `showExtra` memo is more complex than needed, `title` memo has a redundant double-call, and a few spots violate the style guide around early returns, inlining, and unnecessary intermediate variables. The option-building logic inside `options` is the main area that needs cleanup.
---
## Issues
### 1. `showExtra` uses negated conditions instead of a direct expression (lines 29-33)
Two `if (!x) return false` followed by `return true` is just a conjunction.
**Before:**
```tsx
const showExtra = createMemo(() => {
if (!connected()) return false
if (props.providerID) return false
return true
})
```
**After:**
```tsx
const showExtra = createMemo(() => connected() && !props.providerID)
```
**Why:** A boolean memo that just combines two conditions doesn't need branching. The expression form is shorter and immediately readable.
---
### 2. Massive duplication between `favoriteOptions` and `recentOptions` (lines 48-112)
These two blocks are nearly identical -- the only differences are the source list and the `category` string. This is ~60 lines that could be a single helper.
**Before:**
```tsx
const favoriteOptions = showSections
? 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 }, { recent: true })
},
},
]
})
: []
const recentOptions = showSections
? recentList.flatMap((item) => {
// ... identical logic with category: "Recent"
})
: []
```
**After:**
```tsx
function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.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,
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 favoriteOptions = toOptions(favorites, "Favorites")
const recentOptions = toOptions(recentList, "Recent")
```
**Why:** DRY. The duplicated block is a maintenance hazard -- any behavior change to one must be mirrored in the other. An inner helper keeps it in one function scope (per style guide) while eliminating the copy-paste.
---
### 3. Unnecessary intermediate variables `q` and `needle` (lines 36-37)
`q` is used only to compute `needle`, and `needle` could be inlined or at least `q` removed.
**Before:**
```tsx
const q = query()
const needle = q.trim()
```
**After:**
```tsx
const needle = query().trim()
```
**Why:** Style guide says to reduce variable count by inlining when a value is only used once. `q` is never referenced again after line 37.
---
### 4. `title` memo calls `provider()` twice with a `!` assertion (lines 202-205)
The double invocation of the same memo plus a non-null assertion is avoidable.
**Before:**
```tsx
const title = createMemo(() => {
if (provider()) return provider()!.name
return "Select model"
})
```
**After:**
```tsx
const title = createMemo(() => provider()?.name ?? "Select model")
```
**Why:** Optional chaining with nullish coalescing is both shorter and avoids the non-null assertion (`!`). It also avoids calling the `provider()` memo twice per evaluation.
---
### 5. `providerOptions` filter uses unnecessary intermediate variable (lines 154-166)
The `value` variable on line 156 just aliases `x.value`, which is already available via dot notation.
**Before:**
```tsx
filter((x) => {
if (!showSections) 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
}),
```
**After:**
```tsx
filter((x) => {
if (!showSections) return true
if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
return false
if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
return false
return true
}),
```
**Why:** Eliminates the `value` alias (style guide: prefer dot notation, reduce variable count) and the `inFavorites`/`inRecents` variables that are each used only once.
---
### 6. `popularProviders` has unnecessary `return` in `map` callback (lines 175-186)
**Before:**
```tsx
const popularProviders = !connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
take(6),
)
: []
```
**After:**
```tsx
const popularProviders = !connected()
? pipe(
providers(),
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []
```
**Why:** Arrow function with implicit return via parenthesized object literal is more concise and consistent with the rest of the file (e.g. line 126 uses this pattern).
---
### 7. `value` variable in `providerOptions` map is only used to build the return object (lines 127-130)
The `value` object is defined and then spread into the return. It's also referenced later in the `description` ternary. This is borderline, but since `value` is used in two places inside the same callback it's acceptable. However, the object can be constructed inline since the references just use `provider.id` and `model` which are already in scope.
**Before:**
```tsx
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,
...
}
}),
```
**After:**
```tsx
map(([model, info]) => ({
value: {
providerID: provider.id,
modelID: model,
},
title: info.name ?? model,
description: favorites.some(
(item) => item.providerID === provider.id && item.modelID === model,
)
? "(Favorite)"
: undefined,
...
})),
```
**Why:** `value.providerID` is just `provider.id` and `value.modelID` is just `model`. Inlining the object and referencing the originals directly removes the intermediate variable and makes the callback use an implicit return, consistent with the style guide.
---
### 8. `DialogSelectRef` type parameter is `unknown` instead of the actual value type (line 23)
**Before:**
```tsx
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
```
The `ref` signal is passed to `DialogSelect` via `ref={setRef}` but `ref()` is never actually read anywhere in this component. This means the signal is dead code.
**After:**
Remove lines 23 and 226 entirely:
```diff
- const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
...
- ref={setRef}
```
**Why:** `ref` is created but never consumed. If it's not needed, it's dead code that adds noise. If it's intended for future use, it should be added when needed.
---
### 9. Unused import: `take` from remeda (line 4)
`take` is imported but never used in this file (it's used in the `popularProviders` pipe via the `pipe` + `take` pattern -- actually, checking again, `take` is not used in this file at all).
**Before:**
```tsx
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
```
**After:**
```tsx
import { map, pipe, flatMap, entries, filter, sortBy } from "remeda"
```
**Why:** Unused imports are clutter. `take` is not referenced anywhere in `dialog-model.tsx`.
---
### 10. `[_, info]` destructure uses unnamed `_` pattern (lines 124-125)
The filter callbacks destructure with `_` for the unused key. This is fine idiomatically, but the second filter on line 125 destructures both `_` and `info` when only `info` is needed.
This is minor and acceptable -- just noting for completeness. The `_` convention is standard for unused positional parameters in tuple destructuring.
---
## Summary of Recommended Changes (by impact)
| Priority | Issue | Lines | Impact |
| -------- | ------------------------------------------------- | ------- | ------------------------------------------------ |
| High | Extract duplicated favorite/recent option builder | 48-112 | ~30 lines removed, eliminates maintenance hazard |
| Medium | Simplify `showExtra` to expression | 29-33 | Clearer intent |
| Medium | Simplify `title` memo | 202-205 | Removes `!` assertion, avoids double memo call |
| Medium | Remove dead `ref` signal | 23, 226 | Removes dead code |
| Low | Inline `q` variable | 36-37 | One fewer variable |
| Low | Remove `value` alias in filter | 154-166 | Prefer dot notation |
| Low | Implicit return in `popularProviders` map | 178-183 | Consistency |
| Low | Inline `value` in `providerOptions` map | 127-130 | Fewer variables |
| Low | Remove unused `take` import | 4 | Clean imports |

View File

@@ -0,0 +1,278 @@
# Review: dialog-provider.tsx
## Overall Quality
This file is reasonably well-structured but has several style guide violations and readability issues. The main problems are: unnecessary `let` with mutation where `const` would work, unnecessary destructuring, an unnecessary intermediate variable, and a redundant `return` statement. Most issues are minor but collectively they add friction when reading the code.
---
## Issues
### 1. `let` with conditional reassignment — use `const` (line 49-65)
`index` is declared as `let` and conditionally reassigned inside an `if` block. The style guide says to prefer `const` with ternaries or expressions over `let` with reassignment.
**Before:**
```tsx
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
```
**After:**
```tsx
const index =
methods.length > 1
? await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
: 0
```
**Why:** Eliminates `let` and the explicit type annotation `number | null` (now inferred from the ternary). The intent — "pick a method index, or default to 0" — is expressed in a single declaration rather than spread across a `let` + `if` + reassignment.
---
### 2. Unnecessary intermediate variable `method` (line 68)
`method` is assigned from `methods[index]` and used only twice (lines 69, 85). Since `methods[index]` is short and clear, one of the usages can be inlined. However, since `method` is used to check `.type` in two separate `if` blocks, keeping it is borderline acceptable. The real issue is that this variable exists alongside `methods` and `index` — three names for what is conceptually one selection. At minimum, the name could be clearer, but given the style guide's "reduce variable count by inlining when value used only once" rule, this is fine as-is since it's used twice.
No change needed — noting for completeness.
---
### 3. Unnecessary destructuring of `useTheme()` (lines 107, 165, 210)
The style guide says "avoid unnecessary destructuring, use dot notation." However, `const { theme } = useTheme()` is used in 42+ places across the codebase and `useTheme()` returns an object with multiple properties (`theme`, `selected`, `all`, `syntax`, etc.). In this file, only `theme` is needed, so destructuring extracts a single property. This is a codebase-wide pattern.
While technically a style guide violation, changing just this file would create inconsistency with the rest of the codebase. If this were to be addressed, it should be done across all files at once. Flagging for awareness but **not recommending a change in isolation**.
---
### 4. Unnecessary destructuring in `CodeMethod` (line 176)
`const { error }` destructures the result of `sdk.client.provider.oauth.callback()` just to check `!error`. This should use dot notation.
**Before:**
```tsx
const { error } = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
code: value,
})
if (!error) {
```
**After:**
```tsx
const result = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
code: value,
})
if (!result.error) {
```
**Why:** Follows the style guide's "avoid unnecessary destructuring" rule. Using `result.error` preserves context about what `error` belongs to. It also avoids shadowing the outer `error` signal (from `createSignal` on line 169), which is a subtle bug risk — the destructured `error` on line 176 shadows the `error` getter from `createSignal(false)` on line 169, making it impossible to reference the signal inside the callback after that line.
---
### 5. Unnecessary `return` before `dialog.replace` (line 86)
The `return` on line 86 serves no purpose — it's the last statement in the `onSelect` handler (inside the last `if` block). There's no code after it that needs to be skipped.
**Before:**
```tsx
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
```
**After:**
```tsx
if (method.type === "api") {
dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
```
**Why:** The `return` implies there's subsequent code to skip, but there isn't. Removing it makes the control flow honest — the reader doesn't need to check what code follows.
---
### 6. Redundant `return` in `createDialogProviderOptions` (line 92)
`options` is only used on line 92 to be returned. The function could return the `createMemo` directly.
**Before:**
```tsx
const options = createMemo(() => {
return pipe(
...
)
})
return options
```
**After:**
```tsx
return createMemo(() => {
return pipe(
...
)
})
```
**Why:** Style guide says "reduce total variable count by inlining when a value is only used once." The variable `options` is assigned and immediately returned — it adds a name without adding clarity.
---
### 7. Explicit type annotation on `PROVIDER_PRIORITY` (line 17)
The `Record<string, number>` annotation is unnecessary — TypeScript infers `{ opencode: number, anthropic: number, ... }` from the object literal, and it's used with bracket access (`PROVIDER_PRIORITY[x.id]`) which works fine with the inferred type. However, the explicit `Record<string, number>` does serve a purpose here: it allows arbitrary string keys in bracket access without a type error. The inferred type would require `as` casts or optional chaining when accessed with dynamic keys.
**No change needed** — the annotation is load-bearing for dynamic key access with `?? 99`.
---
### 8. Unnecessary explicit type annotation on `index` parameter in `map` (line 56)
The `index` parameter in the inner `.map((x, index) => ...)` shadows the outer `index` variable. This is confusing.
**Before:**
```tsx
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
```
**After:**
```tsx
options={methods.map((x, i) => ({
title: x.label,
value: i,
}))}
```
**Why:** The inner `index` shadows the outer `index` variable (line 49). Using `i` avoids the shadowing and reduces confusion about which `index` is being referenced. Single-character names are idiomatic for map/filter index parameters.
---
### 9. Non-null assertion on `result.data!` (lines 76, 82)
After checking `result.data?.method`, `result.data!` is used. This is safe but the non-null assertion could be avoided.
**Before:**
```tsx
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
```
**After:**
```tsx
if (result.data?.method === "code") {
const data = result.data
dialog.replace(() => <CodeMethod providerID={provider.id} title={method.label} index={index} authorization={data} />)
}
if (result.data?.method === "auto") {
const data = result.data
dialog.replace(() => <AutoMethod providerID={provider.id} title={method.label} index={index} authorization={data} />)
}
```
**Why:** Assigning `result.data` to a `const` after the truthiness check narrows the type without `!`. Non-null assertions suppress the type checker — a local `const` works with it. This also captures the value for the closure, which is safer if `result` were ever mutable.
---
### 10. Interface definitions only used once (lines 100-105, 158-163, 202-205)
`AutoMethodProps`, `CodeMethodProps`, and `ApiMethodProps` are each used exactly once — as the props type for their respective component. The style guide says to rely on type inference and avoid explicit interfaces unless necessary. These could be inlined.
**Before:**
```tsx
interface AutoMethodProps {
index: number
providerID: string
title: string
authorization: ProviderAuthAuthorization
}
function AutoMethod(props: AutoMethodProps) {
```
**After:**
```tsx
function AutoMethod(props: {
index: number
providerID: string
title: string
authorization: ProviderAuthAuthorization
}) {
```
**Why:** Removes a level of indirection. The reader sees the shape at the call site without jumping to a separate definition. This is a minor preference — named interfaces are fine for components with many props, and this is a common React/Solid pattern. But for strictly following the style guide's "avoid explicit type annotations or interfaces unless necessary" rule, inlining is more aligned.
**Judgment call** — this is borderline. Named interfaces for component props are a widespread convention in this codebase and provide documentation value. Flagging but not strongly recommending.
---
## Summary of Recommended Changes
| Priority | Issue | Line(s) |
| -------- | ----------------------------------------------------------------------------- | ------------- |
| High | Replace `let index` with `const` + ternary | 49-65 |
| High | Fix variable shadowing (`index``i` in inner map) | 56 |
| Medium | Remove unnecessary destructuring of `error` (also fixes shadowing) | 176 |
| Medium | Remove unnecessary `return` before `dialog.replace` | 86 |
| Medium | Inline `options` variable — return `createMemo` directly | 29, 92 |
| Low | Replace `!` assertions with narrowing via `const` | 76, 82 |
| Info | `useTheme()` destructuring — codebase-wide pattern, don't change in isolation | 107, 165, 210 |
| Info | Inline prop interfaces — borderline, common convention | 100, 158, 202 |

View File

@@ -0,0 +1,372 @@
# Review: `dialog-session-list.tsx`
## Summary
This is a relatively clean 109-line component. The issues are minor but worth fixing: an unused import, unnecessary destructuring, a `let` that should be `const` with a ternary, some single-use variables that could be inlined, and a multiword name that could be shortened.
---
## Issues
### 1. Unused import: `Show` (line 5)
`Show` is imported from `solid-js` but never used anywhere in the file. Dead imports add noise.
**Before:**
```tsx
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
```
**After:**
```tsx
import { createMemo, createSignal, createResource, onMount } from "solid-js"
```
---
### 2. Unnecessary destructuring of `useTheme()` (line 20)
The style guide says to avoid destructuring and prefer dot notation to preserve context. `theme` is destructured from `useTheme()` but should be accessed via dot notation.
**Before:**
```tsx
const { theme } = useTheme()
// used as:
bg: isDeleting ? theme.error : undefined,
```
**After:**
```tsx
const theme = useTheme()
// used as:
bg: isDeleting ? theme.theme.error : undefined,
```
However, this creates an awkward `theme.theme`. A better approach is to name the hook result differently:
```tsx
const theming = useTheme()
// used as:
bg: isDeleting ? theming.theme.error : undefined,
```
Or, since the only thing used from `useTheme()` is `.theme`, and it's referenced exactly once, the destructuring is arguably justified here to avoid `theme.theme`. This one is a judgment call -- the destructuring is tolerable given the naming collision. **Low priority.**
---
### 3. `let` with mutation instead of `const` with ternary (lines 44-47)
The style guide explicitly prefers `const` with ternary over `let` with conditional reassignment.
**Before:**
```tsx
let category = date.toDateString()
if (category === today) {
category = "Today"
}
```
**After:**
```tsx
const formatted = date.toDateString()
const category = formatted === today ? "Today" : formatted
```
This removes the mutable variable and makes the intent clear in a single expression.
---
### 4. Single-use variables that could be inlined (lines 43, 48-50)
`date`, `isDeleting`, `status`, and `isWorking` are each used exactly once. Per the style guide, inlining single-use values reduces variable count and keeps the code tighter.
**Before:**
```tsx
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
```
**After:**
```tsx
.map((x) => {
const formatted = new Date(x.time.updated).toDateString()
const category = formatted === today ? "Today" : formatted
const deleting = toDelete() === x.id
return {
title: deleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: deleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: sync.data.session_status?.[x.id]?.type === "busy" ? <Spinner /> : undefined,
}
})
```
Why this is better:
- `date` was only used to call `.toDateString()` -- inline it.
- `status` and `isWorking` were a two-step chain to check a single condition -- collapse into one expression.
- `isDeleting` renamed to `deleting` (shorter, and `is` prefixes are redundant for booleans used locally). It's kept as a variable since it's referenced twice.
---
### 5. Multi-word name `currentSessionID` (line 33)
The style guide prefers single-word names. This memo just extracts the current session ID for passing to `current=`. It could be shortened.
**Before:**
```tsx
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
```
**After:**
```tsx
const current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
```
Then on line 71:
```tsx
current={current()}
```
The prop name already provides all the context needed, and the memo is only referenced once. `current` is clear enough.
---
### 6. Multi-word name `searchResults` (line 27)
Could be shortened to `results` since it's scoped locally and the search context is obvious.
**Before:**
```tsx
const [searchResults] = createResource(search, async (query) => {
```
```tsx
const sessions = createMemo(() => searchResults() ?? sync.data.session)
```
**After:**
```tsx
const [results] = createResource(search, async (query) => {
```
```tsx
const sessions = createMemo(() => results() ?? sync.data.session)
```
---
### 7. Unnecessary `async` on `onTrigger` callbacks (lines 87, 101)
The delete handler on line 87 doesn't `await` anything -- `sdk.client.session.delete()` is fire-and-forget. The rename handler on line 101 also doesn't await anything. Marking these `async` is misleading since the returned promises are never consumed.
**Before:**
```tsx
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
sessionID: option.value,
})
setToDelete(undefined)
return
}
setToDelete(option.value)
},
```
```tsx
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
```
**After:**
```tsx
onTrigger: (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
sessionID: option.value,
})
setToDelete(undefined)
return
}
setToDelete(option.value)
},
```
```tsx
onTrigger: (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
```
If the type signature requires `async`, keep it -- but if not, removing it avoids implying there's asynchronous work being awaited.
---
### 8. `skipFilter={true}` could be `skipFilter` (line 70)
In JSX, `prop={true}` is equivalent to just `prop`. This is a minor style nit.
**Before:**
```tsx
<DialogSelect
title="Sessions"
options={options()}
skipFilter={true}
```
**After:**
```tsx
<DialogSelect
title="Sessions"
options={options()}
skipFilter
```
---
## Full suggested rewrite
For reference, here's what the component looks like with all fixes applied:
```tsx
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import { createDebouncedSignal } from "../util/signal"
import { Spinner } from "./spinner"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [results] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})
const current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => results() ?? sync.data.session)
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const formatted = new Date(x.time.updated).toDateString()
const category = formatted === today ? "Today" : formatted
const deleting = toDelete() === x.id
return {
title: deleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: deleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: sync.data.session_status?.[x.id]?.type === "busy" ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="Sessions"
options={options()}
skipFilter
current={current()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
sessionID: option.value,
})
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}
```

View File

@@ -0,0 +1,87 @@
# Review: `dialog-session-rename.tsx`
## Summary
This is a small, focused component (32 lines) that is already fairly clean. There are only minor style guide violations to address. The component's structure and logic are straightforward and easy to follow.
---
## Issues
### 1. Unnecessary interface declaration (line 7-9)
The `DialogSessionRenameProps` interface is only used once and could be inlined into the function signature. The style guide prefers relying on type inference and avoiding unnecessary type definitions. Other dialog components in the codebase (e.g., `dialog-tag.tsx:7`, `dialog-stash.tsx:29`) inline their props type directly.
**Before:**
```tsx
interface DialogSessionRenameProps {
session: string
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
```
**After:**
```tsx
export function DialogSessionRename(props: { session: string }) {
```
This removes 4 lines and a named type that adds no value since it's never referenced elsewhere. Inlining makes the component signature self-contained and matches the pattern used by sibling dialog components.
### 2. Unnecessary intermediate variable for `session` memo (line 15)
The `session` memo is used exactly once on line 20 (`session()?.title`). The style guide says to reduce variable count by inlining when a value is only used once. However, there's a nuance here: `createMemo` provides reactive caching, so it's not purely a readability variable -- it's a reactive primitive. In SolidJS, accessing `sync.session.get(props.session)` directly inside JSX would also be reactive since it's inside a tracking scope. The memo adds no caching benefit for a single use.
**Before:**
```tsx
const session = createMemo(() => sync.session.get(props.session))
return (
<DialogPrompt
...
value={session()?.title}
```
**After:**
```tsx
return (
<DialogPrompt
...
value={sync.session.get(props.session)?.title}
```
This eliminates the `createMemo` import and the intermediate variable. The expression is still reactive inside JSX. This is a marginal improvement -- the memo is defensible if there were multiple accesses, but with a single use it's unnecessary indirection.
### 3. `onCancel` callback could use direct reference (line 28)
The `onCancel` handler wraps `dialog.clear()` in an arrow function. Since `dialog.clear` takes no arguments and `onCancel` passes no arguments, you can pass the method directly. However, this depends on whether `clear` relies on `this` binding -- looking at the dialog context implementation (it's a plain object with methods, not a class), direct reference is safe.
**Before:**
```tsx
onCancel={() => dialog.clear()}
```
**After:**
```tsx
onCancel={dialog.clear}
```
Removes a trivial wrapper function. Slightly more concise.
---
## Non-issues (things that are fine as-is)
- **`useSync()` and `useSDK()` as separate variables**: These are hooks that return context objects used in different parts of the JSX. Keeping them as named variables is correct.
- **The `onConfirm` callback**: It has two statements (`sdk.client.session.update(...)` and `dialog.clear()`), so it can't be simplified to a direct reference. This is fine.
- **Import organization**: Imports are grouped logically (external UI, hooks, local context). No issues.
## Final Assessment
The file is already concise and well-structured. The issues above are minor style guide alignment fixes that would remove ~5 lines and one import. The component does one thing clearly and is easy to understand at a glance.

View File

@@ -0,0 +1,138 @@
# Review: `dialog-skill.tsx`
## Summary
This is a small, clean component at 37 lines. It's well-structured overall, but there are a few style guide violations and minor readability improvements available.
---
## Issues
### 1. Unnecessary exported type annotation — prefer inference (line 6-8)
`DialogSkillProps` is only used in one place (the `props` parameter of `DialogSkill`). Exporting it as a named type adds a symbol that could just be inlined. However, if consumers need this type externally, it's justified. Given that this is a dialog component typically rendered internally via `dialog.replace()`, the export is likely unnecessary.
**Before:**
```tsx
export type DialogSkillProps = {
onSelect: (skill: string) => void
}
export function DialogSkill(props: DialogSkillProps) {
```
**After:**
```tsx
export function DialogSkill(props: { onSelect: (skill: string) => void }) {
```
**Why:** Reduces exported surface area and avoids a one-use named type. One fewer symbol to track. If it is needed externally, keep it — but verify that first.
---
### 2. Unnecessary explicit type annotation on `createMemo` (line 20)
The generic `<DialogSelectOption<string>[]>` on `createMemo` is redundant. TypeScript can infer the return type from the array of objects being returned, and `DialogSelect` already accepts `DialogSelectOption<T>[]` so the types flow naturally.
**Before:**
```tsx
const options = createMemo<DialogSelectOption<string>[]>(() => {
```
**After:**
```tsx
const options = createMemo(() => {
```
**Why:** The style guide says "rely on type inference when possible; avoid explicit type annotations unless necessary for exports or clarity." The return type is already clear from the mapped object shape.
---
### 3. Unnecessary intermediate variable `list` — inline it (line 21)
`list` is used twice (lines 22 and 23), so it warrants a variable. However, `maxWidth` is computed from `list` and only used once on line 24. The real issue is that `list` could be a more descriptive name, but per the style guide, single-word names are preferred and `list` is fine.
No change needed here — `list` is used twice so it's justified.
---
### 4. Unnecessary destructuring in import (line 2)
This is fine as-is. `createResource` and `createMemo` are standalone functions from solid-js, not methods on an object. Import destructuring for module imports is standard and not the same as the "avoid destructuring objects" rule which refers to runtime `const { a, b } = obj` patterns.
No change needed.
---
### 5. `result.data ?? []` could mask errors (line 17)
If the API call fails, `result.data` will be undefined and this silently returns an empty array. There's no error handling or user feedback. The `sdk.client.app.skills()` call could fail (network error, server down), and the user would just see an empty skills list with no indication of why.
**Before:**
```tsx
const [skills] = createResource(async () => {
const result = await sdk.client.app.skills()
return result.data ?? []
})
```
**After:**
```tsx
const [skills] = createResource(async () => {
const result = await sdk.client.app.skills()
return result.data ?? []
})
```
This is a minor concern, not a style issue. `createResource` does capture errors via `skills.error`, but it's not used here. Noting it for awareness — not necessarily a change to make.
---
### 6. Inline `result` — it's only used once (line 16-17)
The variable `result` is assigned and immediately accessed on the next line. It can be inlined.
**Before:**
```tsx
const [skills] = createResource(async () => {
const result = await sdk.client.app.skills()
return result.data ?? []
})
```
**After:**
```tsx
const [skills] = createResource(async () => {
return (await sdk.client.app.skills()).data ?? []
})
```
**Why:** The style guide says "reduce total variable count by inlining when a value is only used once." `result` is only used to access `.data`.
---
### 7. `maxWidth` is only used once — could inline (line 22)
`maxWidth` is used only on line 24 inside `padEnd()`. It could be inlined, but this is borderline — the `Math.max(0, ...list.map(...))` expression is already complex, and inlining it into `padEnd()` would hurt readability. Keeping it as-is is reasonable.
No change needed — readability wins over strict inlining here.
---
## Final Assessment
The file is compact and well-organized. The main actionable improvements are:
1. **Remove the explicit generic on `createMemo` (line 20)** — let inference work
2. **Inline `result` variable (lines 16-17)** — used only once
3. **Consider inlining `DialogSkillProps`** — if not imported elsewhere
These are minor polish items. The component is straightforward and easy to understand.

View File

@@ -0,0 +1,244 @@
# Review: `dialog-stash.tsx`
## Summary
This is a small, well-structured file. The overall quality is decent — the component logic is clear and the file is easy to follow. However, there are several style guide violations and minor readability improvements worth addressing.
---
## Issues
### 1. Unnecessary destructuring of `useTheme()` (line 32)
The style guide says to avoid unnecessary destructuring and prefer dot notation to preserve context. However, `const { theme } = useTheme()` is the established convention used across **every** dialog file in the codebase (`dialog-status.tsx`, `dialog-provider.tsx`, `dialog-session-list.tsx`, `dialog-mcp.tsx`). Since `theme` is used as the only field from the hook and the pattern is consistent project-wide, this is acceptable as-is. Changing it here alone would create inconsistency. **No change recommended** unless done as a codebase-wide sweep.
---
### 2. Unnecessary intermediate variables in `getRelativeTime` (lines 10-16)
Four `const` declarations compute cascading values, but `seconds`, `minutes`, and `hours` are each used only once in the comparisons (and once for the next derivation). The cascade is readable enough, but the variable `diff` is only used once and can be inlined.
**Before:**
```tsx
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
```
**After:**
```tsx
const seconds = Math.floor((Date.now() - timestamp) / 1000)
```
**Why:** `now` and `diff` are each used exactly once. Inlining removes two unnecessary variables per the style guide ("reduce total variable count by inlining when a value is only used once"). The remaining `seconds`/`minutes`/`hours`/`days` cascade is fine since each is used for both the comparison and the next derivation.
---
### 3. Explicit return type annotations on helper functions (lines 9, 24)
The style guide says to "rely on type inference when possible; avoid explicit type annotations unless necessary for exports or clarity." Both `getRelativeTime` and `getStashPreview` are module-private functions with obvious `string` return types.
**Before:**
```tsx
function getRelativeTime(timestamp: number): string {
```
```tsx
function getStashPreview(input: string, maxLength: number = 50): string {
```
**After:**
```tsx
function getRelativeTime(timestamp: number) {
```
```tsx
function getStashPreview(input: string, maxLength = 50) {
```
**Why:** TypeScript can trivially infer the return type as `string` from every code path. The `number = 50` default also makes the `: number` annotation redundant. Removing these reduces noise and follows the style guide.
---
### 4. Unnecessary intermediate variable `entries` inside `options` memo (line 38-39)
`entries` is used exactly once on the next line.
**Before:**
```tsx
const options = createMemo(() => {
const entries = stash.list()
// Show most recent first
return entries
.map((entry, index) => {
```
**After:**
```tsx
const options = createMemo(() => {
return stash.list()
.map((entry, index) => {
```
**Why:** The variable is used immediately and only once. Inlining it reduces a variable per the style guide. The comment "Show most recent first" describes the `.toReversed()` at the end, not the `entries` variable, so it can move down or be removed (the `.toReversed()` call is self-documenting).
---
### 5. Unnecessary intermediate variable `lineCount` (line 43)
`lineCount` is used exactly once on line 49.
**Before:**
```tsx
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
}
```
**After:**
```tsx
const lines = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lines > 1 ? `~${lines} lines` : undefined,
}
```
This one is borderline — inlining would make the `footer` line very long and harder to scan. But the name `lineCount` is two words where `lines` works. Per the style guide: "prefer single word variable names where possible."
---
### 6. Unnecessary intermediate variable `entries` in `onSelect` (lines 63-64)
`entries` is used once to look up `entry`, which is itself only used in the `if` block.
**Before:**
```tsx
onSelect={(option) => {
const entries = stash.list()
const entry = entries[option.value]
if (entry) {
stash.remove(option.value)
props.onSelect(entry)
}
dialog.clear()
}}
```
**After:**
```tsx
onSelect={(option) => {
const entry = stash.list()[option.value]
if (entry) {
stash.remove(option.value)
props.onSelect(entry)
}
dialog.clear()
}}
```
**Why:** `entries` is used once, so inline it. One fewer variable to track.
---
### 7. Verbose `onMove` callback (lines 59-61)
The callback wrapping is unnecessarily multi-line for a single statement.
**Before:**
```tsx
onMove={() => {
setToDelete(undefined)
}}
```
**After:**
```tsx
onMove={() => setToDelete(undefined)}
```
**Why:** Single-expression arrow functions are more concise as one-liners. This is a minor readability win that reduces vertical space.
---
### 8. `getStashPreview` could be inlined (lines 24-27)
This function is called exactly once (line 45), takes two args, and is only two lines. It's not reusable or composable.
**Before:**
```tsx
function getStashPreview(input: string, maxLength: number = 50): string {
const firstLine = input.split("\n")[0].trim()
return Locale.truncate(firstLine, maxLength)
}
// ... used as:
getStashPreview(entry.input)
```
**After (inlined at call site):**
```tsx
Locale.truncate(entry.input.split("\n")[0].trim(), 50)
```
**Why:** The style guide says "keep things in one function unless composable or reusable." This function is neither — it's called once with a fixed default. Inlining it reduces indirection. However, this is a judgment call: the name `getStashPreview` does add some semantic clarity. If the team prefers the named version for readability, that's reasonable too.
---
### 9. `getRelativeTime` naming (line 9)
The name `getRelativeTime` uses a `get` prefix which is more of a Java/OOP convention. In this codebase, functions generally don't use `get` prefixes (e.g., `resolveTheme`, `generateSyntax`, etc.).
**Before:**
```tsx
function getRelativeTime(timestamp: number): string {
```
**After:**
```tsx
function relative(timestamp: number) {
```
**Why:** The style guide prefers single-word names. `relative` is clear in context since it's only called with a timestamp and always returns a time string. Alternatively `timeago` works too.
---
## Summary of Recommended Changes
| Priority | Line(s) | Issue |
| -------- | ------- | -------------------------------------------------- |
| Low | 9 | Rename `getRelativeTime` to `relative` |
| Low | 10-12 | Inline `now` and `diff` variables |
| Medium | 9, 24 | Remove explicit `: string` return type annotations |
| Low | 24 | Remove explicit `: number` on default param |
| Medium | 38-39 | Inline `entries` variable in memo |
| Low | 43 | Rename `lineCount` to `lines` |
| Medium | 63-64 | Inline `entries` variable in onSelect |
| Low | 59-61 | Collapse `onMove` to single line |
| Low | 24-27 | Consider inlining `getStashPreview` |
Overall this is a clean file. The issues are all minor style guide violations — no bugs, no `any` types, no `try/catch`, no misuse of `let`. The logic is sound and easy to follow.

View File

@@ -0,0 +1,385 @@
# Review: `dialog-status.tsx`
## Summary
The file is relatively short and straightforward, but has several style guide violations and readability issues: unnecessary destructuring, intermediate variables that could be inlined, verbose object constructions, type casts that hint at incomplete type definitions, and an unused exported type. The `plugins` memo has a dense parsing function that could benefit from early returns.
---
## Issues
### 1. Unnecessary destructuring of `useTheme()` (line 12)
The style guide says: "Avoid unnecessary destructuring. Use dot notation to preserve context."
`useTheme()` returns an object with `theme`, `syntax`, `selected`, etc. Only `theme` is used, but destructuring loses the `useTheme` context.
**Before:**
```tsx
const { theme } = useTheme()
```
**After:**
```tsx
const theme = useTheme()
```
Then use `theme.theme` in the JSX — or, since that reads awkwardly, assign the whole context with a single-word name:
```tsx
const colors = useTheme()
```
and reference `colors.theme.text`, etc. However, since every other component in this codebase destructures `{ theme }` the same way, this is a codebase-wide pattern. Changing it here alone would create inconsistency. **Flag as a broader codebase pattern to address, not a one-file fix.**
---
### 2. Unused exported type (line 8)
`DialogStatusProps` is exported but never used — not as a parameter, not imported anywhere. Dead code.
**Before:**
```tsx
export type DialogStatusProps = {}
```
**After:**
Remove entirely.
**Why:** Dead code adds noise. If props are needed later, they can be added then.
---
### 3. Unnecessary intermediate variables in `plugins` memo (lines 18-39)
`list` and `result` are each used exactly once. They should be inlined per the style guide: "Reduce total variable count by inlining when a value is only used once."
**Before:**
```tsx
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
// ...
})
return result.toSorted((a, b) => a.name.localeCompare(b.name))
})
```
**After:**
```tsx
const plugins = createMemo(() =>
(sync.data.config.plugin ?? [])
.map((value) => {
// ...
})
.toSorted((a, b) => a.name.localeCompare(b.name)),
)
```
**Why:** Fewer variables, same clarity. The chain reads top-to-bottom.
---
### 4. Plugin parsing logic uses nested `if`/`else` instead of early returns (lines 20-37)
The style guide says: "Avoid `else` statements. Prefer early returns." The nested conditionals inside the `map` callback make the two distinct code paths (file URL vs npm package) hard to scan.
**Before:**
```tsx
.map((value) => {
if (value.startsWith("file://")) {
const path = fileURLToPath(value)
const parts = path.split("/")
const filename = parts.pop() || path
if (!filename.includes(".")) return { name: filename }
const basename = filename.split(".")[0]
if (basename === "index") {
const dirname = parts.pop()
const name = dirname || basename
return { name }
}
return { name: basename }
}
const index = value.lastIndexOf("@")
if (index <= 0) return { name: value, version: "latest" }
const name = value.substring(0, index)
const version = value.substring(index + 1)
return { name, version }
})
```
**After:**
```tsx
.map((value) => {
if (value.startsWith("file://")) {
const parts = fileURLToPath(value).split("/")
const filename = parts.pop() || value
if (!filename.includes(".")) return { name: filename }
const base = filename.split(".")[0]
if (base !== "index") return { name: base }
return { name: parts.pop() || base }
}
const idx = value.lastIndexOf("@")
if (idx <= 0) return { name: value, version: "latest" }
return { name: value.substring(0, idx), version: value.substring(idx + 1) }
})
```
**Why:**
- Inlines `path` (used once) into the `.split()` chain.
- Flips the `basename === "index"` condition to an early return, removing the inner nesting and the extra `dirname`/`name` variables.
- Inlines `name`/`version` in the npm-package branch (each used once).
- `base` instead of `basename` — shorter, single concept.
- `idx` instead of `index` — avoids shadowing `Array.prototype.index` connotations while staying short.
---
### 5. Unnecessary intermediate variables in npm-package branch (lines 33-37)
`name` and `version` are each used exactly once on the very next line.
**Before:**
```tsx
const name = value.substring(0, index)
const version = value.substring(index + 1)
return { name, version }
```
**After:**
```tsx
return { name: value.substring(0, idx), version: value.substring(idx + 1) }
```
**Why:** Style guide says to inline values used only once.
---
### 6. Unnecessary intermediate variable `dirname` / `name` (lines 27-29)
Both are used once and can be collapsed.
**Before:**
```tsx
const dirname = parts.pop()
const name = dirname || basename
return { name }
```
**After:**
```tsx
return { name: parts.pop() || base }
```
**Why:** Two variables for a single fallback expression is unnecessarily verbose.
---
### 7. `as Record<string, typeof theme.success>` type cast (line 68)
This cast is masking incomplete type coverage. The status color map doesn't account for unknown statuses — accessing an unhandled status returns `undefined`, which the cast hides.
**Before:**
```tsx
fg: (
{
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>
)[item.status],
```
**After:**
```tsx
fg: ({
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>)[item.status],
```
This is a minor formatting change, but the real issue is the cast. Ideally, the MCP status type would be a union that includes `needs_auth` and `needs_client_registration` so the cast isn't needed. The `(item.status as string)` casts on lines 81 and 84 confirm the types are incomplete upstream. **This is a type definition issue in the SDK, not fixable here alone — but worth noting.**
---
### 8. `(item.status as string)` casts (lines 81, 84)
These casts indicate that `needs_auth` and `needs_client_registration` are missing from the `McpStatus` type definition. The casts are a workaround.
**Before:**
```tsx
<Match when={(item.status as string) === "needs_auth"}>
```
**After (ideal):**
Fix the `McpStatus` type upstream to include these statuses, then remove the casts:
```tsx
<Match when={item.status === "needs_auth"}>
```
**Why:** Type casts defeat the purpose of TypeScript. The proper fix is in the SDK type definitions.
---
### 9. Verbose `style` object for simple `fg` prop (lines 128-131, 149-152)
When the `style` object only sets `fg`, it's unnecessarily verbose compared to using the `fg` prop directly.
**Before:**
```tsx
<text
flexShrink={0}
style={{
fg: theme.success,
}}
>
```
**After:**
```tsx
<text flexShrink={0} fg={theme.success}>
```
**Why:** The `fg` prop is available directly (used elsewhere in this same file, e.g., line 134). Using `style` for a single property adds visual noise.
---
### 10. Repeated bullet-point item pattern (lines 57-91, 100-115, 125-137, 147-159)
The same `<box flexDirection="row" gap={1}>` + bullet `<text>` + label `<text>` pattern appears four times with minor variations. This could be extracted to a local component.
**Before:** Four near-identical blocks.
**After:**
```tsx
function Bullet(props: { fg: RGBA; children: any }) {
return (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={props.fg}>
</text>
<text wrapMode="word" fg={theme.text}>
{props.children}
</text>
</box>
)
}
```
Then usage becomes:
```tsx
<Bullet fg={statusColor[item.status]}>
<b>{key}</b> <span style={{ fg: theme.textMuted }}>...</span>
</Bullet>
```
**Why:** Reduces duplication and makes each list section easier to read. The style guide says "Keep things in one function unless composable or reusable" — this pattern _is_ reusable (4 times in one file).
---
### 11. `Object.keys(sync.data.mcp).length` computed twice (lines 52, 54)
The same expression is evaluated in both the `when` condition and the fallback text.
**Before:**
```tsx
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={...}>
<box>
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
```
**After:**
Extract a memo (or at minimum compute once):
```tsx
const mcpKeys = createMemo(() => Object.keys(sync.data.mcp))
```
Then:
```tsx
<Show when={mcpKeys().length > 0} fallback={...}>
<box>
<text fg={theme.text}>{mcpKeys().length} MCP Servers</text>
```
**Why:** Avoids redundant computation and makes the reactive dependency clearer.
---
### 12. Destructuring in `For` callback (line 56)
The style guide prefers dot notation over destructuring. The `[key, item]` destructure is standard for `Object.entries` iteration and reads well, so this is a borderline case. However, it's worth noting.
**Before:**
```tsx
{([key, item]) => (
```
This is idiomatic for `Object.entries` and acceptable — no change recommended.
---
### 13. `(val() as { error: string }).error` cast (line 85)
This is another symptom of the incomplete `McpStatus` type. The cast is unsafe.
**Before:**
```tsx
{
;(val) => (val() as { error: string }).error
}
```
**After (ideal):** Fix the upstream type so that `needs_client_registration` status includes an `error` field, then:
```tsx
{
;(val) => val().error
}
```
**Why:** Casting to an inline type literal is fragile and hard to read.
---
## Priority Summary
| Priority | Issue | Lines |
| -------- | ------------------------------------------------------------ | ---------------- |
| High | Remove unused `DialogStatusProps` type | 8 |
| High | Inline `list`/`result` variables in plugins memo | 18-39 |
| High | Simplify plugin parsing with early returns, inline variables | 20-37 |
| Medium | Use `fg` prop instead of `style={{ fg }}` for bullet points | 128-131, 149-152 |
| Medium | Extract repeated bullet-point pattern to local component | 57-159 |
| Medium | Compute `Object.keys(sync.data.mcp)` once | 52, 54 |
| Low | Fix `McpStatus` type upstream to remove `as string` casts | 81, 84 |
| Low | Fix `McpStatus` type upstream to remove `as Record<>` cast | 68 |
| Low | Codebase-wide `{ theme }` destructuring pattern | 12 |

View File

@@ -0,0 +1,154 @@
# Review: `dialog-tag.tsx`
## Summary
This is a small 44-line file so the issues are minor, but there are a few style guide violations and unnecessary patterns worth cleaning up: an unused store setter, an intermediate variable that should be inlined, unnecessary destructuring via `createStore` when a simple signal would suffice, and an unused import.
---
## Issues
### 1. Unused store setter from `createStore` (line 11)
`createStore` returns `[store, setStore]`, but only `store` is used. Since `filter` is never written to (no `setStore` call anywhere), the entire store is dead code -- the `filter` property is always `""` and `store.filter` never changes.
This means the `createResource` dependency on `store.filter` is pointless -- the resource only ever fetches once with an empty query, and the `query` parameter is always `""`.
If the intent was to wire this store to `DialogSelect`'s `onFilter` callback (which the component supports), that wiring is missing. As written, the store serves no purpose and should be removed.
**Before (lines 11-13):**
```tsx
const [store] = createStore({
filter: "",
})
```
**After:**
```tsx
// Remove entirely. If filtering is needed, wire DialogSelect's onFilter
// to a signal/store and use it as the resource dependency.
```
And update the resource to remove the dead dependency:
**Before (lines 15-25):**
```tsx
const [files] = createResource(
() => [store.filter],
async () => {
const result = await sdk.client.find.files({
query: store.filter,
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
return sliced
},
)
```
**After:**
```tsx
const [files] = createResource(async () => {
const result = await sdk.client.find.files({ query: "" })
if (result.error) return []
return (result.data ?? []).slice(0, 5)
})
```
This removes the fake reactivity and makes it clear this is a one-shot fetch. If reactive filtering is intended, it needs to actually be wired up -- but that's a feature gap, not a style fix.
**Why:** Dead code obscures intent. A reader has to trace through the store to realize it never changes. Removing it makes the actual behavior obvious.
---
### 2. Unnecessary intermediate variable `sliced` (line 23)
The variable `sliced` is assigned and immediately returned on the next line. Per the style guide: "Reduce total variable count by inlining when a value is only used once."
**Before (lines 22-23):**
```tsx
const sliced = (result.data ?? []).slice(0, 5)
return sliced
```
**After:**
```tsx
return (result.data ?? []).slice(0, 5)
```
**Why:** The variable name adds no clarity beyond what the expression already communicates. Inlining removes a line and reduces cognitive overhead.
---
### 3. Unused import: `createStore` (line 5)
If the store is removed per issue #1, `createStore` from `"solid-js/store"` becomes unused and should be removed.
**Before (line 5):**
```tsx
import { createStore } from "solid-js/store"
```
**After:**
Remove the line entirely.
**Why:** Unused imports are noise.
---
### 4. `createMemo` import may be unnecessary (line 1)
The `createMemo` on line 27 wraps a simple `.map()` over `files()`. In Solid, `files()` is already reactive (it's a resource signal). The memo only prevents re-running the `.map()` when unrelated state changes cause re-evaluation, but in this component there's essentially no other state that could trigger that. Given the tiny data size (max 5 items), the memo provides negligible value and adds complexity.
That said, memos are idiomatic in Solid for derived data, so this is a soft suggestion -- keeping it is defensible.
---
## Suggested final version
```tsx
import { createResource } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const [files] = createResource(async () => {
const result = await sdk.client.find.files({ query: "" })
if (result.error) return []
return (result.data ?? []).slice(0, 5)
})
return (
<DialogSelect
title="Autocomplete"
options={(files() ?? []).map((file) => ({
value: file,
title: file,
}))}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}
```
This version:
- Removes the dead `createStore` and its import
- Inlines the `sliced` variable
- Inlines the `options` memo into the JSX (since the mapping is trivial and only used once)
- Removes the unused `createMemo` import
- Goes from 44 lines to 27 lines with no behavioral change

View File

@@ -0,0 +1,157 @@
# Review: `dialog-theme-list.tsx`
## Summary
This is a small, focused component (51 lines). It's fairly clean overall but has several style guide violations and minor readability issues worth addressing. None are severe, but they add up to make the file slightly messier than it should be.
---
## Issues
### 1. Shorthand property — line 11
The object literal `{ title: value, value: value }` can use shorthand for `value`.
**Before:**
```tsx
.map((value) => ({
title: value,
value: value,
}))
```
**After:**
```tsx
.map((value) => ({
title: value,
value,
}))
```
**Why:** Shorthand properties are idiomatic JS/TS. Repeating `value: value` is visual noise.
---
### 2. `let confirmed` should be `const` with a different pattern — line 15
`confirmed` is declared as `let` and mutated once inside `onSelect`. This is a mutable flag that tracks whether the user confirmed a selection. While there's no simple `const` + ternary replacement here (since it's mutated from a callback), this is an acceptable use of `let` for signal-like mutation in Solid components.
No change needed — this is one of the rare valid uses of `let` in a Solid component for tracking callback state.
---
### 3. Unused `onMount` import — line 4
`onMount` is imported from `solid-js` but never used anywhere in the file.
**Before:**
```tsx
import { onCleanup, onMount } from "solid-js"
```
**After:**
```tsx
import { onCleanup } from "solid-js"
```
**Why:** Dead imports are clutter. They make it harder to understand what the file actually depends on and can confuse readers into thinking there's a missing `onMount` call.
---
### 4. `let ref` with deferred assignment — line 16
`ref` is declared `let ref: DialogSelectRef<string>` with no initial value, then assigned inside the JSX `ref` callback. It's only used inside `onFilter`. This has two problems:
- Unnecessary explicit type annotation (the type can be inferred from usage context)
- The `let` + deferred assignment pattern is fine for Solid refs but the type annotation is redundant since the `ref` callback on `DialogSelect` already constrains the type
**Before:**
```tsx
let ref: DialogSelectRef<string>
```
**After:**
```tsx
let ref!: DialogSelectRef<string>
```
**Why:** The definite assignment assertion (`!`) communicates intent: "this will be assigned before use." Without it, TypeScript may warn about potentially uninitialized usage. The type annotation is still needed here since there's no initializer for inference.
---
### 5. `initial` variable is unnecessary — line 17
`initial` is assigned `theme.selected` and used in three places. However, `theme.selected` is a getter on the theme context that returns `store.active` — it's already a stable read. Inlining would reduce variable count, but since it's used three times (cleanup, `current` prop, and `onFilter`), keeping it is reasonable for readability.
No change needed — used three times, so a variable is justified.
---
### 6. Verbose `onMove` callback — line 28-30
The `onMove` callback wraps a single expression in braces unnecessarily.
**Before:**
```tsx
onMove={(opt) => {
theme.set(opt.value)
}}
```
**After:**
```tsx
onMove={(opt) => theme.set(opt.value)}
```
**Why:** Single-expression arrow functions are cleaner without braces. Reduces three lines to one with no loss of clarity.
---
### 7. `onFilter` uses `else`-like flow instead of early return — lines 39-47
The `onFilter` handler checks `query.length === 0`, handles that case, then falls through to the rest. This is already using an early return pattern, which is good. However, the logic can be slightly tightened.
**Before:**
```tsx
onFilter={(query) => {
if (query.length === 0) {
theme.set(initial)
return
}
const first = ref.filtered[0]
if (first) theme.set(first.value)
}}
```
This is actually fine as-is — it correctly uses early return. No change needed.
---
### 8. Unnecessary `ref` callback wrapper — lines 36-38
The `ref` callback `(r) => { ref = r }` is as minimal as it can be given Solid's ref pattern. No change needed.
---
## Final Assessment
The file is concise and well-structured. The actionable changes are:
| # | Issue | Severity |
| --- | -------------------------------------------- | -------- |
| 1 | Shorthand property `value` | Low |
| 3 | Remove unused `onMount` import | Medium |
| 4 | Add definite assignment assertion to `ref` | Low |
| 6 | Simplify `onMove` to single-expression arrow | Low |
The file follows the style guide well in most respects: it uses `const` where possible, avoids destructuring, uses dot notation, keeps things in one function, and is short and focused. The issues above are minor polish items.

View File

@@ -0,0 +1,220 @@
# Review: `logo.tsx`
## Summary
This is a small 86-line file that renders the ASCII logo with shadow effects. It's mostly fine, but has a few issues: unnecessary destructuring, a mutable imperative loop where a recursive or functional approach would be cleaner, a needless type annotation, and a `let` that could be avoided.
---
## Issues
### 1. Unnecessary destructuring of `useTheme()` (line 13)
The style guide says to avoid unnecessary destructuring and prefer dot notation. Only `theme` is used from `useTheme()`, but destructuring it loses the context of where it came from.
**Before:**
```tsx
const { theme } = useTheme()
```
**After:**
```tsx
const ctx = useTheme()
```
Then use `ctx.theme` throughout. However, `theme` is used many times in `renderLine` and the JSX (lines 16, 78, 79), so destructuring a single heavily-used property is arguably justified here. This is a minor/borderline issue -- the guide says "avoid unnecessary destructuring" but when there's a single property used repeatedly, it can go either way. Worth flagging but not urgent.
---
### 2. Explicit type annotation on `renderLine` is unnecessary (line 15)
The return type `: JSX.Element[]` and parameter type for `line` can be inferred or are obvious from usage. The parameter types are needed since this is a callback, but the return type annotation is redundant -- TypeScript will infer it from the function body.
**Before:**
```tsx
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
```
**After:**
```tsx
const renderLine = (line: string, fg: RGBA, bold: boolean) => {
```
**Why:** The style guide says to rely on type inference when possible. The return type is trivially inferred from the `elements` array and the `return` statement.
---
### 3. Imperative while loop with mutable `let i` and mutable `elements` array (lines 18-70)
This is the biggest issue. The function uses a `while` loop with `let i` and mutates an `elements` array via `.push()`. This is a classic imperative pattern that's harder to follow than a recursive approach or a split-and-map pattern.
The entire `renderLine` function can be rewritten to split the line by the marker regex and map over segments, eliminating `let i`, the `while` loop, and the mutable array.
**Before:**
```tsx
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
const elements: JSX.Element[] = []
let i = 0
while (i < line.length) {
const rest = line.slice(i)
const markerIndex = rest.search(SHADOW_MARKER)
if (markerIndex === -1) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest}
</text>,
)
break
}
if (markerIndex > 0) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest.slice(0, markerIndex)}
</text>,
)
}
const marker = rest[markerIndex]
switch (marker) {
case "_":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
{" "}
</text>,
)
break
case "^":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
case "~":
elements.push(
<text fg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
}
i += markerIndex + 1
}
return elements
}
```
**After:**
```tsx
const renderLine = (line: string, fg: RGBA, bold: boolean) => {
const shadow = tint(theme.background, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return line
.split(/([_^~])/)
.filter(Boolean)
.map((part) => {
if (part === "_")
return (
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
{" "}
</text>
)
if (part === "^")
return (
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
</text>
)
if (part === "~")
return (
<text fg={shadow} attributes={attrs} selectable={false}>
</text>
)
return (
<text fg={fg} attributes={attrs} selectable={false}>
{part}
</text>
)
})
}
```
**Why:** This eliminates:
- `let i = 0` (the style guide prefers `const`)
- The mutable `elements` array
- The `while` loop (the style guide prefers functional array methods)
- ~30 lines of code
The `split` with a capture group regex preserves both the separators and the text between them, making this a natural fit. The `.filter(Boolean)` removes any empty strings from the split result.
Note: the hardcoded `[_^~]` duplicates the marker chars, but `marks` is just `"_^~"` so this could use the same source:
```tsx
return line.split(new RegExp(`([${marks}])`)).filter(Boolean).map((part) => {
```
Though since the regex only needs to be built once, you could hoist it:
```tsx
const SHADOW_SPLIT = new RegExp(`([${marks}])`)
```
---
### 4. `SHADOW_MARKER` regex is unused if the split approach is adopted (line 10)
If issue #3 is addressed, `SHADOW_MARKER` on line 10 becomes dead code and should be removed. Even in the current code, the name `SHADOW_MARKER` is slightly misleading -- it's a pattern that _matches_ markers, not a marker itself. A name like `SHADOW_PATTERN` would be marginally clearer, but this is minor.
---
### 5. Intermediate variable `attrs` could be inlined (line 17)
The variable `attrs` is a simple ternary used in multiple places, so keeping it as a variable is fine for DRY reasons. No change needed -- just noting this was considered and is acceptable.
---
### 6. `shadow` variable name is good but `renderLine` is verbose (line 15)
The style guide prefers single-word names. `renderLine` could be just `render` since it's a local function and the context (inside `Logo`) makes it clear what's being rendered.
**Before:**
```tsx
const renderLine = (line: string, fg: RGBA, bold: boolean) => {
```
**After:**
```tsx
const render = (line: string, fg: RGBA, bold: boolean) => {
```
**Why:** Single-word names are preferred. The function is local to `Logo`, so `render` is unambiguous.
---
## Summary of recommended changes
| Priority | Line(s) | Issue |
| ---------- | ------- | ------------------------------------------------------------- |
| High | 18-70 | Replace imperative while loop with `split`/`map` |
| Medium | 15 | Remove redundant return type annotation |
| Low | 15 | Rename `renderLine` to `render` |
| Low | 10 | Remove or repurpose `SHADOW_MARKER` if split approach adopted |
| Borderline | 13 | Destructuring `{ theme }` -- acceptable given heavy usage |

View File

@@ -0,0 +1,489 @@
# Code Review: `autocomplete.tsx`
## Summary
The file is functional but has a number of style guide violations and readability issues. The main problems are: unnecessary destructuring, multi-word variable names, `let` where `const` would work, `else` branches that should be early returns, for-loops where functional methods are preferred, single-use variables that should be inlined, and overly verbose type annotations. There are also some dead/redundant code patterns and inconsistent naming.
---
## Issues
### 1. Unnecessary destructuring of `useTheme()` (line 81)
Destructuring `{ theme }` loses context about where `theme` comes from. Per style guide: "Avoid unnecessary destructuring. Use dot notation to preserve context."
However, `theme` is used ~20 times throughout the JSX, so accessing it as `useTheme().theme` every time would be worse. This one is borderline acceptable since it's used so heavily and `theme` is clear enough on its own. **Low priority.**
---
### 2. Multi-word variable names (lines 92, 137, 153, 247, 260, 385-392)
The style guide says "Prefer single word variable names where possible."
**Line 92 -- `positionTick` / `setPositionTick`:**
This is a Solid signal, so the naming is driven by convention. Acceptable.
**Line 137 -- `search` / `setSearch`:**
Fine -- single word.
**Lines 385-387 -- `filesValue`, `agentsValue`, `commandsValue`:**
These are needlessly suffixed with `Value`.
```tsx
// Before (line 385-387)
const filesValue = files()
const agentsValue = agents()
const commandsValue = commands()
// After -- just inline them since they're only used once each
const mixed: AutocompleteOption[] =
store.visible === "@" ? [...agents(), ...(files() || []), ...mcpResources()] : [...commands()]
```
This also eliminates 3 single-use variables per the "reduce variable count by inlining" rule.
---
### 3. Single-use variable `searchValue` should be inlined (line 392)
```tsx
// Before (lines 392-396)
const searchValue = search()
if (!searchValue) {
return mixed
}
// After
if (!search()) {
return mixed
}
```
But note `search()` is also used on line 402. Since it's a signal call, calling it twice is fine (signals are cached), but if you want to avoid the double-call, a single `const s = search()` is cleaner than `searchValue`.
```tsx
// Alternative
const s = search()
if (!s) return mixed
// ...
const result = fuzzysort.go(removeLineRange(s), mixed, { ... })
```
---
### 4. Unnecessary `let` in `move()` -- use modular arithmetic (lines 428-435)
```tsx
// Before
function move(direction: -1 | 1) {
if (!store.visible) return
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
if (next >= options().length) next = 0
moveTo(next)
}
// After -- const with modular wrap
function move(direction: -1 | 1) {
if (!store.visible) return
const len = options().length
if (!len) return
moveTo((store.selected + direction + len) % len)
}
```
Eliminates `let`, two `if` reassignments, and the intermediate variable. Cleaner wrap-around logic.
---
### 5. `else` in `tab` handler (lines 571-577)
Style guide says "Avoid `else` statements. Prefer early returns."
```tsx
// Before (lines 571-578)
if (name === "tab") {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
} else {
select()
}
e.preventDefault()
return
}
// After
if (name === "tab") {
const selected = options()[store.selected]
if (selected?.isDirectory) expandDirectory()
else select()
e.preventDefault()
return
}
```
Since both branches are single expressions and the function continues after, this is a minor style point. But the cleanest version removes the `else`:
```tsx
if (name === "tab") {
if (options()[store.selected]?.isDirectory) expandDirectory()
else select()
e.preventDefault()
return
}
```
This also inlines the single-use `selected` variable.
---
### 6. `else if` chain in `insertPart` (lines 202-210)
```tsx
// Before (lines 202-210)
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
part.source.text.value = virtualText
} else if (part.type === "agent" && part.source) {
part.source.start = extmarkStart
part.source.end = extmarkEnd
part.source.value = virtualText
}
```
This is within a closure passed to `setPrompt`, not a standalone function, so early returns aren't applicable here. The `else if` is acceptable in this context since it's a type-discriminated branch. **Low priority.**
---
### 7. For-loop should be functional `map` (lines 303-328 -- `mcpResources`)
Style guide: "Prefer functional array methods (flatMap, filter, map) over for loops."
```tsx
// Before (lines 300-331)
const mcpResources = createMemo(() => {
if (!store.visible || store.visible === "/") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
for (const res of Object.values(sync.data.mcp_resource)) {
const text = `${res.name} (${res.uri})`
options.push({
display: Locale.truncateMiddle(text, width),
value: text,
description: res.description,
onSelect: () => { ... },
})
}
return options
})
// After
const mcpResources = createMemo(() => {
if (!store.visible || store.visible === "/") return []
const width = props.anchor().width - 4
return Object.values(sync.data.mcp_resource).map((res): AutocompleteOption => {
const text = `${res.name} (${res.uri})`
return {
display: Locale.truncateMiddle(text, width),
value: text,
description: res.description,
onSelect: () => {
insertPart(res.name, {
type: "file",
mime: res.mimeType ?? "text/plain",
filename: res.name,
url: res.uri,
source: {
type: "resource",
text: { start: 0, end: 0, value: "" },
clientName: res.client,
uri: res.uri,
},
})
},
}
})
})
```
Eliminates the mutable `options` array and the imperative loop.
---
### 8. For-loop should be functional `map`/`flatMap` (lines 358-372 -- `commands`)
Same issue as above.
```tsx
// Before (lines 358-372)
for (const serverCommand of sync.data.command) {
if (serverCommand.source === "skill") continue
const label = serverCommand.source === "mcp" ? ":mcp" : ""
results.push({
display: "/" + serverCommand.name + label,
description: serverCommand.description,
onSelect: () => { ... },
})
}
// After
const results: AutocompleteOption[] = [
...command.slashes(),
...sync.data.command
.filter((cmd) => cmd.source !== "skill")
.map((cmd): AutocompleteOption => {
const label = cmd.source === "mcp" ? ":mcp" : ""
return {
display: "/" + cmd.name + label,
description: cmd.description,
onSelect: () => {
const text = "/" + cmd.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(text)
props.input().cursorOffset = Bun.stringWidth(text)
},
}
}),
]
```
---
### 9. Redundant variable in `agents` memo (lines 333-335)
```tsx
// Before (lines 333-335)
const agents = createMemo(() => {
const agents = sync.data.agent
return agents
.filter(...)
// After
const agents = createMemo(() => {
return sync.data.agent
.filter(...)
```
The inner `const agents = sync.data.agent` shadows the outer `agents` and is only used once. Inline it.
---
### 10. Ternary for `setSearch` (lines 139-141)
```tsx
// Before (line 140)
setSearch(next ? next : "")
// After
setSearch(next ?? "")
```
`next` is `string | undefined`, so `??` is more precise and idiomatic than a truthy check (which would also coerce empty string to `""`). Actually since `filter()` returns `string | undefined`, `?? ""` is clearer about intent.
---
### 11. Single-use variables that should be inlined in `insertPart` (lines 152-156)
```tsx
// Before (lines 152-156)
const input = props.input()
const currentCursorOffset = input.cursorOffset
const charAfterCursor = props.value.at(currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const append = "@" + text + (needsSpace ? " " : "")
// After
const input = props.input()
const offset = input.cursorOffset
const append = "@" + text + (props.value.at(offset) !== " " ? " " : "")
```
`input` is used many times so keeping it is fine. But `charAfterCursor` and `needsSpace` are single-use and can be inlined. Also `currentCursorOffset` is a long name -- `offset` is sufficient.
---
### 12. Same pattern in `expandDirectory` (lines 460-461)
```tsx
// Before (lines 460-461)
const input = props.input()
const currentCursorOffset = input.cursorOffset
// After
const input = props.input()
const offset = input.cursorOffset
```
`currentCursorOffset` is verbose. `offset` is clear enough given the surrounding code.
---
### 13. `let scroll` should use a different pattern (line 606)
```tsx
// Before (line 606)
let scroll: ScrollBoxRenderable
```
This is a ref pattern common in Solid.js -- assigning via `ref={(r) => (scroll = r)}`. The `let` is unavoidable here due to how Solid refs work. **No change needed**, but adding `!` (definite assignment) could be considered if the type system complains, though it doesn't appear to here.
---
### 14. Unnecessary explicit type annotation on `options` memo (line 384)
```tsx
// Before (line 384)
const options = createMemo((prev: AutocompleteOption[] | undefined) => {
// This is acceptable -- the `prev` parameter type annotation is needed because
// createMemo's accumulator pattern requires it for the overload resolution.
```
**No change needed.**
---
### 15. `displayText` / `path` intermediate in `expandDirectory` (lines 463-464)
```tsx
// Before (lines 463-464)
const displayText = selected.display.trimEnd()
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
// After -- inline displayText since it's only used once
const display = selected.display.trimEnd()
const path = display.startsWith("@") ? display.slice(1) : display
```
Or even more aggressively:
```tsx
const path = selected.display.trimEnd().replace(/^@/, "")
```
This is cleaner and eliminates both variables into one.
---
### 16. Unnecessary empty `options` array + spread in `files` resource (lines 233, 248)
```tsx
// Before (lines 233, 247-287)
const options: AutocompleteOption[] = []
if (!result.error && result.data) {
// ...
options.push(
...sortedFiles.map(...)
)
}
return options
// After -- early return pattern
if (result.error || !result.data) return []
const width = props.anchor().width - 4
return result.data
.sort((a, b) => { ... })
.map((item): AutocompleteOption => { ... })
```
This eliminates the mutable `options` array, the `push(...spread)` pattern, and the wrapping `if` block. Cleaner control flow with early return.
---
### 17. Redundant comment blocks (lines 228, 234-235)
```tsx
// Get files from SDK <- obvious from the code
// Add file options <- obvious from the code
```
These comments describe _what_ the code does, not _why_. They add noise without value. Remove them.
---
### 18. `newText` variable used once in `commands` (lines 365-369)
```tsx
// Before (lines 365-369)
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
// After -- `newText` is used twice (insertText + stringWidth), so keeping it is correct.
```
Actually `newText` is used twice here, so it should stay. **No change needed.**
---
### 19. Inconsistent `if` / `return` style in `onKeyDown` (lines 582-593)
The `store.visible` block uses early returns consistently, but the `!store.visible` block at lines 582-593 doesn't return after `show("@")`:
```tsx
// Before (lines 582-593)
if (!store.visible) {
if (e.name === "@") {
const cursorOffset = props.input().cursorOffset
const charBeforeCursor = cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
if (canTrigger) show("@")
}
if (e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
}
}
// After -- flatten and inline
if (!store.visible && e.name === "@") {
const offset = props.input().cursorOffset
const before = offset === 0 ? undefined : props.input().getTextRange(offset - 1, offset)
if (before === undefined || before === "" || /\s/.test(before)) show("@")
return
}
if (!store.visible && e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
return
}
```
This also renames `cursorOffset` -> `offset` and `charBeforeCursor` -> `before`, and removes the single-use `canTrigger`.
---
### 20. `extractLineRange` could use early return instead of nesting (lines 22-47)
The function is structured well with early returns already. **No change needed.**
---
## Priority Summary
| Priority | Issue | Lines |
| -------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| High | For-loops should be `.map()` / `.filter()` | 303-328, 358-372 |
| High | Inline single-use variables / reduce variable count | 233-288, 385-392, 463-464, 582-593 |
| Medium | `let` in `move()` -- use modular arithmetic | 428-435 |
| Medium | Redundant inner variable shadowing outer name | 333-335 |
| Medium | Verbose variable names (`currentCursorOffset`, `charBeforeCursor`, `charAfterCursor`, `needsSpace`, `filesValue`) | 152-156, 460-461, 385-387, 584-587 |
| Medium | `next ? next : ""` should be `next ?? ""` | 140 |
| Low | Redundant comments | 228, 234-235 |
| Low | `else` in tab handler | 571-577 |
| Low | Mutable `options` array pattern in `files` resource | 233-288 |

View File

@@ -0,0 +1,336 @@
# Code Review: `frecency.tsx`
## Summary
The file is relatively short and mostly functional, but has several style guide violations and readability issues: repeated type literals instead of a single type alias, unnecessary destructuring, inlineable variables, `let`-style patterns via mutable reduce accumulators, and a `try/catch` that could be avoided. The logic is sound but the code is noisier than it needs to be.
---
## Issues
### 1. Repeated type literal (lines 28, 33, 40)
The type `{ path: string; frequency: number; lastOpen: number }` is written out three times. This hurts readability and creates a maintenance risk if the shape changes.
**Before:**
```tsx
.map((line) => {
try {
return JSON.parse(line) as { path: string; frequency: number; lastOpen: number }
} catch {
return null
}
})
.filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null)
const latest = lines.reduce(
(acc, entry) => {
acc[entry.path] = entry
return acc
},
{} as Record<string, { path: string; frequency: number; lastOpen: number }>,
)
```
**After:**
```tsx
type Entry = { path: string; frequency: number; lastOpen: number }
// then use Entry everywhere
```
This is one of the cases where an explicit type _is_ warranted -- it eliminates triple duplication.
---
### 2. `try/catch` in map for JSON parsing (lines 26-32)
The style guide says "avoid `try/catch` where possible." Each line is a self-contained JSON object, so a safe parse helper or inline check is cleaner. A straightforward approach is a small safe-parse inline that returns `null` on failure, but since `JSON.parse` is the only standard API here, the `try/catch` is at least isolated. However, wrapping it differently can make the pipeline read more cleanly.
**Before:**
```tsx
.map((line) => {
try {
return JSON.parse(line) as Entry
} catch {
return null
}
})
.filter((line): line is Entry => line !== null)
```
**After:**
```tsx
.flatMap((line) => {
try {
return [JSON.parse(line) as Entry]
} catch {
return []
}
})
```
Using `flatMap` combines the parse + filter into one step, removing the separate `.filter()` with its redundant type guard. The `try/catch` is still present (unavoidable with `JSON.parse`), but the overall pipeline is shorter.
---
### 3. Mutable accumulator in `reduce` instead of `Object.fromEntries` (lines 35-41)
The `reduce` builds an object by mutating `acc`. This is a common pattern but is more verbose than necessary and less functional in style.
**Before:**
```tsx
const latest = lines.reduce(
(acc, entry) => {
acc[entry.path] = entry
return acc
},
{} as Record<string, Entry>,
)
```
**After:**
```tsx
const latest = Object.fromEntries(lines.map((entry) => [entry.path, entry]))
```
Since later entries overwrite earlier ones in `Object.fromEntries` (just like the reduce), this is equivalent but much shorter. One line instead of six.
---
### 4. Unnecessary destructuring in sort comparator (line 75)
The style guide says to avoid unnecessary destructuring and prefer dot notation.
**Before:**
```tsx
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
```
This destructuring is arguably justified here since `Object.entries` returns tuples and there's no object to dot-access. However, on line 78 there's a more notable issue:
**Before (line 78):**
```tsx
const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
```
The variable name `path` shadows the `path` import from Node.js (line 1). This is a readability and potential bug risk.
**After:**
```tsx
const content = sorted.map(([p, entry]) => JSON.stringify({ path: p, ...entry })).join("\n") + "\n"
```
Or restructure to avoid `Object.entries` entirely (see issue 6).
---
### 5. Inlineable variable `daysSince` (line 10)
The style guide says to reduce variable count by inlining when a value is only used once.
**Before:**
```tsx
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
if (!entry) return 0
const daysSince = (Date.now() - entry.lastOpen) / 86400000 // ms per day
const weight = 1 / (1 + daysSince)
return entry.frequency * weight
}
```
**After:**
```tsx
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
if (!entry) return 0
const weight = 1 / (1 + (Date.now() - entry.lastOpen) / 86400000)
return entry.frequency * weight
}
```
`daysSince` is only used once and the expression is simple enough to inline. The comment `// ms per day` is also lost, but `86400000` is a well-known constant and the function name provides context.
---
### 6. Unnecessary explicit return type on `calculateFrecency` (line 8)
The style guide says to rely on type inference and avoid explicit type annotations unless necessary. The return type `: number` is trivially inferred from arithmetic operations.
**Before:**
```tsx
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
```
**After:**
```tsx
function calculateFrecency(entry?: { frequency: number; lastOpen: number }) {
```
---
### 7. `newEntry` variable is only used twice but could be restructured (lines 66-71)
The variable `newEntry` is used on lines 70 and 71. It's borderline, but the real issue is that the object is constructed and then spread into another object on line 71. This creates two similar-but-different shapes in rapid succession.
**Before:**
```tsx
const newEntry = {
frequency: (store.data[absolutePath]?.frequency || 0) + 1,
lastOpen: Date.now(),
}
setStore("data", absolutePath, newEntry)
appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
```
This is acceptable since `newEntry` is used twice, but the non-null assertion `frecencyFile.name!` on line 71 is a code smell. `Bun.file()` always has a `name` property when constructed from a path string, but the `!` hides a potential issue. Consider storing the path directly.
**After:**
```tsx
const file = path.join(Global.Path.state, "frecency.jsonl")
const frecencyFile = Bun.file(file)
// ...
appendFile(file, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
```
This avoids the non-null assertion entirely by reusing the path string directly.
---
### 8. Inconsistent file write APIs (lines 6, 56, 71, 79)
The file uses both `Bun.write()` (lines 56, 79) and Node's `appendFile` from `fs/promises` (line 71). Mixing APIs for the same file is inconsistent. The style guide says to prefer Bun APIs when possible.
**Before:**
```tsx
import { appendFile } from "fs/promises"
// ...
appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
```
**After:**
```tsx
// Use Bun's append mode via Bun.write with the append flag, or use the file path directly
// Since Bun.write doesn't have append, keep appendFile but at least use the path variable
// consistently rather than frecencyFile.name!
```
This one is tricky since `Bun.write` doesn't support append. The `appendFile` usage is justified, but the import mixing is still worth noting. At minimum, pass the path string directly instead of `frecencyFile.name!`.
---
### 9. Duplicate compaction logic (lines 43-57 and 73-80)
The "sort by lastOpen, slice to MAX, rewrite file" logic appears twice: once during mount (lines 43-57) and once in `updateFrecency` (lines 73-80). This violates DRY and makes maintenance harder. Extracting a `compact` function would clean this up.
**Before:**
```tsx
// In onMount:
const sorted = Object.values(latest)
.sort((a, b) => b.lastOpen - a.lastOpen)
.slice(0, MAX_FRECENCY_ENTRIES)
// ... setStore + Bun.write
// In updateFrecency:
const sorted = Object.entries(store.data)
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
.slice(0, MAX_FRECENCY_ENTRIES)
// ... setStore + Bun.write
```
**After:**
```tsx
function compact() {
const sorted = Object.entries(store.data)
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
.slice(0, MAX_FRECENCY_ENTRIES)
setStore("data", Object.fromEntries(sorted))
const content = sorted.map(([p, entry]) => JSON.stringify({ path: p, ...entry })).join("\n") + "\n"
Bun.write(frecencyFile, content).catch(() => {})
}
```
Then call `compact()` from both places after populating the store.
---
### 10. Variable `text` is only used once (line 23)
**Before:**
```tsx
const text = await frecencyFile.text().catch(() => "")
const lines = text.split("\n").filter(Boolean)
```
**After:**
```tsx
const lines = (await frecencyFile.text().catch(() => "")).split("\n").filter(Boolean)
```
Inlines `text` since it's used only once, per the style guide.
---
### 11. `store` and `setStore` declared after first use (lines 60-62)
The `createStore` call is on line 60, but `setStore` is referenced on line 47 (inside `onMount`). While this works because `onMount` runs asynchronously after the synchronous init completes, it's confusing to read. The store should be declared before the `onMount` block for clarity.
**Before:**
```tsx
onMount(async () => {
// ... uses setStore on line 47
})
const [store, setStore] = createStore({ ... })
```
**After:**
```tsx
const [store, setStore] = createStore({ ... })
onMount(async () => {
// ... uses setStore
})
```
This makes the data flow obvious -- the store exists before the mount callback references it.
---
## Summary of Recommended Changes
| Issue | Severity | Category |
| --------------------------------------- | -------- | ------------------------ |
| Repeated type literal | Medium | Readability / DRY |
| flatMap instead of map+filter | Low | Style guide (functional) |
| Object.fromEntries over reduce | Low | Simplification |
| `path` variable shadows import | Medium | Bug risk |
| Inlineable `daysSince` | Low | Style guide |
| Unnecessary return type annotation | Low | Style guide |
| Non-null assertion `frecencyFile.name!` | Medium | Safety |
| Mixed file write APIs | Low | Consistency |
| Duplicate compaction logic | Medium | DRY |
| Inlineable `text` variable | Low | Style guide |
| Store declared after first reference | Medium | Readability |

View File

@@ -0,0 +1,263 @@
# Review: `history.tsx`
## Summary
The file is reasonably short and focused, but has several style guide violations and readability issues: unnecessary `let` with mutation, unnecessary intermediate variables, inconsistent use of `try/catch`, and a couple of naming/destructuring issues. The logic in `move()` is also harder to follow than it needs to be.
---
## Issues
### 1. Unnecessary destructuring of `createStore` (line 58)
The style guide says to avoid unnecessary destructuring and prefer dot notation. However, `createStore` returns a tuple where destructuring is idiomatic for Solid.js -- this is an acceptable exception since `store[0]` and `store[1]` would be less readable. **No change needed here.**
### 2. `let trimmed` flag is avoidable (lines 86-104)
A mutable `let` is used to communicate state out of the `produce` callback. This can be replaced by checking the length after the store update, avoiding the `let` entirely.
**Before (lines 84-104):**
```tsx
append(item: PromptInfo) {
const entry = clone(item)
let trimmed = false
setStore(
produce((draft) => {
draft.history.push(entry)
if (draft.history.length > MAX_HISTORY_ENTRIES) {
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
if (trimmed) {
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
return
}
appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {})
},
```
**After:**
```tsx
append(item: PromptInfo) {
const entry = clone(item)
const was = store.history.length
setStore(
produce((draft) => {
draft.history.push(entry)
if (draft.history.length > MAX_HISTORY_ENTRIES)
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
draft.index = 0
}),
)
if (was >= MAX_HISTORY_ENTRIES) {
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
return
}
appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {})
},
```
**Why:** Eliminates a `let` and removes the mutable flag pattern. The condition `was >= MAX_HISTORY_ENTRIES` captures the same semantics -- if the history was already at capacity before the push, a trim happened and a full rewrite is needed.
### 3. Unnecessary `lines` variable used only once in mount (lines 36-47)
The `lines` variable is used in three places (setting the store, the length check, and rewriting the file), so it's justified. **No change needed.**
### 4. Inconsistent return types in `move()` (lines 64-83)
`move()` returns `undefined` explicitly on lines 65-66, implicitly on line 68 (bare `return`), and returns objects on lines 78-82 and line 83. The bare `return` on line 68 is inconsistent with the explicit `return undefined` above it, making the intent unclear. Are these different on purpose?
**Before (lines 64-83):**
```tsx
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.input !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
if (Math.abs(next) > store.history.length) return
if (next > 0) return
draft.index = next
}),
)
if (store.index === 0)
return {
input: "",
parts: [],
}
return store.history.at(store.index)
},
```
**After:**
```tsx
move(direction: 1 | -1, input: string) {
if (!store.history.length) return
const current = store.history.at(store.index)
if (!current) return
if (current.input !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
if (Math.abs(next) > store.history.length) return
if (next > 0) return
draft.index = next
}),
)
if (store.index === 0)
return {
input: "",
parts: [],
}
return store.history.at(store.index)
},
```
**Why:** All early returns should be consistent. Using bare `return` (or all `return undefined`) uniformly makes it clear they all mean "no result." The explicit `return undefined` on lines 65-66 suggests they're different from line 68, but they aren't.
### 5. `content` variable is used once -- inline it (lines 53, 99)
The `content` variable on lines 53 and 99 is only used once in each location.
**Before (line 53-54):**
```tsx
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
```
**After:**
```tsx
writeFile(historyFile.name!, lines.map((l) => JSON.stringify(l)).join("\n") + "\n").catch(() => {})
```
Same applies to line 99-100:
**Before:**
```tsx
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
```
**After:**
```tsx
writeFile(historyFile.name!, store.history.map((l) => JSON.stringify(l)).join("\n") + "\n").catch(() => {})
```
**Why:** The style guide says to reduce variable count by inlining when a value is only used once. However, this is a judgment call -- the inlined version is quite long. Either approach is defensible here, but the style guide leans toward inlining.
### 6. Verbose `try/catch` in JSON parsing (lines 40-44)
The `try/catch` for `JSON.parse` is one of the few places where `try/catch` is genuinely needed (parsing untrusted data), so it's acceptable. However, it can be simplified slightly.
**Before (lines 39-46):**
```tsx
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((line): line is PromptInfo => line !== null)
```
**After:**
```tsx
.flatMap((line) => {
try {
return [JSON.parse(line) as PromptInfo]
} catch {
return []
}
})
```
**Why:** Using `flatMap` combines the parse + filter into a single step, reducing the pipeline. The type guard is replaced by an assertion since the data is coming from our own serialized format. This is a minor improvement.
### 7. `historyFile.name!` non-null assertion used repeatedly (lines 54, 100, 104)
The `name` property on `BunFile` is accessed with `!` three times. Since `historyFile` is created from a string path, `name` will always be defined. A cleaner approach is to store the path directly.
**Before (line 33):**
```tsx
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
```
**After:**
```tsx
const historyPath = path.join(Global.Path.state, "prompt-history.jsonl")
const historyFile = Bun.file(historyPath)
```
Then use `historyPath` instead of `historyFile.name!` on lines 54, 100, and 104.
**Why:** Eliminates three non-null assertions. Normally we'd avoid the extra variable, but here it replaces three `!` assertions and makes the intent clearer -- the path is a string we own, and the file handle is for reading.
### 8. `entry` variable on line 85 used only in two places
`clone(item)` is assigned to `entry`, then used on lines 89 and 104. This is fine since it's used twice. **No change needed.**
### 9. `text` variable on line 36 is used only once
**Before (lines 36-47):**
```tsx
const text = await historyFile.text().catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
.map(...)
```
**After (lines 36-47):**
```tsx
const lines = (await historyFile.text().catch(() => ""))
.split("\n")
.filter(Boolean)
.map(...)
```
**Why:** `text` is only used once, so inlining it reduces variable count per the style guide.
### 10. Explicit type annotation on `PromptInfo` export (lines 10-26)
The `PromptInfo` type is exported and used as a type guard, so the explicit type definition is necessary. **No change needed.**
---
## Summary of Recommended Changes
| # | Line(s) | Severity | Description |
| --- | ---------------- | -------- | ------------------------------------------------------------ |
| 2 | 86-104 | Medium | Replace `let trimmed` flag with length check before mutation |
| 4 | 65-66 | Low | Use consistent bare `return` instead of `return undefined` |
| 5 | 53-54, 99-100 | Low | Inline `content` variable (judgment call on readability) |
| 6 | 39-46 | Low | Combine `map`+`filter` into `flatMap` |
| 7 | 33, 54, 100, 104 | Medium | Extract path string to avoid `!` assertions |
| 9 | 36 | Low | Inline `text` variable |
The file is fairly clean overall. The most impactful improvements are #2 (eliminating mutable state leaking out of a callback) and #7 (removing non-null assertions).

View File

@@ -0,0 +1,641 @@
# Code Review: `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`
## Summary
This is a large (~1130 line) prompt component that handles text input, autocomplete, shell mode, paste handling, stash, history, and submission. The core logic is sound but there are several style guide violations and readability issues: unnecessary destructuring, `let` where `const` would work, `else` chains that should be early returns, inlineable variables, unused imports, type annotations that inference handles, and some verbose/repetitive patterns.
---
## Issues
### 1. Unnecessary destructuring of `useTheme()` (line 75)
Style guide says: "Avoid unnecessary destructuring. Use dot notation to preserve context."
```tsx
// Before (line 75)
const { theme, syntax } = useTheme()
// After
const theme = useTheme()
```
Then use `theme.theme` and `theme.syntax` throughout (or rename the hook return). However, since `theme` and `syntax` are used _extensively_ (50+ times each), destructuring is arguably justified here to avoid `t.theme.text` everywhere. But it still violates the style guide. At minimum, a single-word alias would be better:
```tsx
// Alternative: keep destructuring but note it's a conscious exception
const ui = useTheme()
// then ui.theme.text, ui.syntax()
```
**Why**: Preserves context about where `theme` and `syntax` come from. Currently `theme` looks like a standalone variable with no origin.
---
### 2. Unused imports (line 1)
`t`, `dim`, and `fg` are imported from `@opentui/core` but never used anywhere in the file.
```tsx
// Before (line 1)
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
// After
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent } from "@opentui/core"
```
Similarly, `type JSX` (line 2) — `JSX.Element` is used only in the `PromptProps` type for `hint`, but `hint` is never actually read in the component body. This prop appears dead.
**Why**: Dead imports are noise and can confuse readers into thinking these values are used somewhere.
---
### 3. `else if` chain in `submit()` should use early returns (lines 561-624)
Style guide: "Avoid `else` statements. Prefer early returns."
```tsx
// Before (lines 561-624)
if (store.mode === "shell") {
sdk.client.session.shell({ ... })
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
iife(() => { ... })
) {
// Parse command...
sdk.client.session.command({ ... })
} else {
sdk.client.session.prompt({ ... }).catch(() => {})
}
history.append(...)
// After
if (store.mode === "shell") {
sdk.client.session.shell({ ... })
setStore("mode", "normal")
finishSubmit()
return
}
if (inputText.startsWith("/") && isSlashCommand(inputText)) {
// Parse command...
sdk.client.session.command({ ... })
finishSubmit()
return
}
sdk.client.session.prompt({ ... }).catch(() => {})
finishSubmit()
```
Or keep the shared cleanup inline without a helper and just use early returns with duplicated cleanup (3 copies of ~6 lines is acceptable for clarity).
**Why**: The `else if` chain with the `iife` condition is particularly hard to read. Flattening to early returns makes the control flow obvious.
---
### 4. `iife` used for inline condition is hard to read (lines 573-578)
The condition for the slash-command branch uses `iife()` to run an inline function, making a complex boolean check harder to parse.
```tsx
// Before (lines 572-578)
} else if (
inputText.startsWith("/") &&
iife(() => {
const firstLine = inputText.split("\n")[0]
const command = firstLine.split(" ")[0].slice(1)
return sync.data.command.some((x) => x.name === command)
})
) {
// After — extract to a named function or inline check
function isSlashCommand(text: string) {
const firstLine = text.split("\n")[0]
const name = firstLine.split(" ")[0].slice(1)
return sync.data.command.some((x) => x.name === name)
}
// Then:
if (inputText.startsWith("/") && isSlashCommand(inputText)) {
```
**Why**: `iife` inside a condition is a cognitive speed bump. A named function communicates intent directly.
---
### 5. `let` where `const` with ternary works (line 536)
```tsx
// Before (line 536)
let inputText = store.prompt.input
// After — since it's reassigned via string surgery below, `let` is necessary here.
```
Actually, on closer inspection `inputText` is mutated in a loop (lines 542-552) so `let` is required. However, the mutation could be replaced with a functional approach:
```tsx
// Before (lines 536-552)
let inputText = store.prompt.input
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
for (const extmark of sortedExtmarks) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = store.prompt.parts[partIndex]
if (part?.type === "text" && part.text) {
const before = inputText.slice(0, extmark.start)
const after = inputText.slice(extmark.end)
inputText = before + part.text + after
}
}
}
// After — use reduce to avoid `let`
const inputText = input.extmarks
.getAllForTypeId(promptPartTypeId)
.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
.reduce((text, extmark) => {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex === undefined) return text
const part = store.prompt.parts[partIndex]
if (part?.type !== "text" || !part.text) return text
return text.slice(0, extmark.start) + part.text + text.slice(extmark.end)
}, store.prompt.input)
```
**Why**: Eliminates `let`, uses functional style per style guide ("prefer functional array methods over for loops"), and is more concise.
---
### 6. Unnecessary type annotation on `sortedExtmarks` (line 540)
```tsx
// Before (line 540)
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
// After
const sortedExtmarks = allExtmarks.sort((a, b) => b.start - a.start)
```
**Why**: Style guide says "Rely on type inference when possible." The sort callback parameters are inferred from the array type.
---
### 7. Unnecessary explicit type on `part` (line 701)
```tsx
// Before (line 701)
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
type: "file" as const,
...
}
// After — inline into the produce call (see issue #8)
```
**Why**: This type annotation is verbose. The object is only used once (passed to `draft.prompt.parts.push`), so it can be inlined.
---
### 8. Variables used only once should be inlined (multiple locations)
Style guide: "Reduce total variable count by inlining when a value is only used once."
**Line 649-652: `currentOffset`, `extmarkStart`, `extmarkEnd` in `pasteText`**
```tsx
// Before (lines 649-652)
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
// After
const start = input.visualCursor.offset
const end = start + virtualText.length
```
`currentOffset` is immediately aliased to `extmarkStart` — just use one variable. The `extmark` prefix is noise since the context is already about extmarks.
**Lines 684-689: Same pattern in `pasteImage`**
```tsx
// Before (lines 684-689)
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const count = store.prompt.parts.filter((x) => x.type === "file").length
const virtualText = `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
// After
const start = input.visualCursor.offset
const virtualText = `[Image ${store.prompt.parts.filter((x) => x.type === "file").length + 1}]`
const end = start + virtualText.length
input.insertText(virtualText + " ")
```
`count` is used once, `textToInsert` is used once, `currentOffset` is immediately aliased.
**Lines 529-533: sessionID creation**
```tsx
// Before (lines 529-533)
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
// After
const sessionID = props.sessionID ?? (await sdk.client.session.create({}).then((x) => x.data!.id))
```
The async IIFE wrapping a single await is unnecessary. The inner `sessionID` variable shadows the outer one and is returned immediately — just inline it.
**Lines 253-254: `value` in editor command**
```tsx
// Before (lines 253-254)
const value = text
const content = await Editor.open({ value, renderer })
// After
const content = await Editor.open({ value: text, renderer })
```
**Why**: Each of these reduces variable count and makes the code more direct.
---
### 9. `if`/`if` pattern that should be ternary or single expression (lines 108-111)
```tsx
// Before (lines 108-111)
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
// After
createEffect(() => {
input.cursorColor = props.disabled ? theme.backgroundElement : theme.text
})
```
**Why**: The two `if` statements are mutually exclusive but don't read that way. A ternary makes the relationship explicit.
---
### 10. `if`/`if` pattern in visibility effect (lines 377-380)
```tsx
// Before (lines 377-380)
createEffect(() => {
if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur()
})
// After
createEffect(() => {
if (props.visible === false) return input?.blur()
input?.focus()
})
```
**Why**: Same issue — two mutually exclusive conditions should be a single branch with early return.
---
### 11. `showVariant` memo is overly verbose (lines 732-737)
```tsx
// Before (lines 732-737)
const showVariant = createMemo(() => {
const variants = local.model.variant.list()
if (variants.length === 0) return false
const current = local.model.variant.current()
return !!current
})
// After
const showVariant = createMemo(() => local.model.variant.list().length > 0 && !!local.model.variant.current())
```
**Why**: `variants` and `current` are each used once. The entire memo is a simple boolean expression.
---
### 12. Redundant `return` at end of `pasteImage` (line 723)
```tsx
// Before (line 723)
return
}
// After — just remove the bare return
}
```
**Why**: A bare `return` at the end of a function is dead code.
---
### 13. Duplicate "reset prompt" pattern appears 4+ times
The pattern of clearing the prompt appears in `ref.reset()` (lines 364-370), `submit()` (lines 629-634), stash command (lines 472-476), and clear keybind (lines 835-841). Each time it's:
```tsx
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
```
This should be extracted into a helper:
```tsx
function clear() {
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
}
```
**Why**: DRY. Four copies of the same 4-line sequence is a maintenance hazard — if the reset logic changes, all four must be updated.
---
### 14. `restoreExtmarksFromParts` uses `let` + mutation where unnecessary (lines 387-390)
```tsx
// Before (lines 386-423)
parts.forEach((part, partIndex) => {
let start = 0
let end = 0
let virtualText = ""
let styleId: number | undefined
if (part.type === "file" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = fileStyleId
} else if (part.type === "agent" && part.source) {
...
}
...
})
// After — derive values directly
parts.forEach((part, partIndex) => {
const info =
part.type === "file" && part.source?.text
? { start: part.source.text.start, end: part.source.text.end, value: part.source.text.value, styleId: fileStyleId }
: part.type === "agent" && part.source
? { start: part.source.start, end: part.source.end, value: part.source.value, styleId: agentStyleId }
: part.type === "text" && part.source?.text
? { start: part.source.text.start, end: part.source.text.end, value: part.source.text.value, styleId: pasteStyleId }
: undefined
if (!info) return
const extmarkId = input.extmarks.create({
start: info.start,
end: info.end,
virtual: true,
styleId: info.styleId,
typeId: promptPartTypeId,
})
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
})
```
**Why**: Eliminates 4 `let` declarations and the mutation pattern. Each branch clearly produces a complete value or nothing.
---
### 15. `syncedSessionID` uses mutable outer variable (lines 138-156)
```tsx
// Before (lines 138-156)
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
const msg = lastUserMessage()
if (sessionID !== syncedSessionID) {
if (!sessionID || !msg) return
syncedSessionID = sessionID
...
}
})
```
The `let syncedSessionID` is a tracking variable. While it works, the naming `syncedSessionID` is verbose. A shorter name like `synced` would suffice since the context is clear.
```tsx
// After
let synced: string | undefined
```
**Why**: Style guide prefers single-word variable names where possible.
---
### 16. `as const` assertions are unnecessary (lines 668, 702)
```tsx
// Before
type: "text" as const,
type: "file" as const,
// After
type: "text",
type: "file",
```
When the object is used in a context where the literal type is expected (like pushing to a typed array), `as const` is redundant — the store's type already constrains it.
**Why**: Unnecessary type annotations add noise.
---
### 17. `spinnerDef` memo has duplicated config object (lines 739-757)
```tsx
// Before (lines 739-757)
const spinnerDef = createMemo(() => {
const color = local.agent.color(local.agent.current().name)
return {
frames: createFrames({
color,
style: "blocks",
inactiveFactor: 0.6,
minAlpha: 0.3,
}),
color: createColors({
color,
style: "blocks",
inactiveFactor: 0.6,
minAlpha: 0.3,
}),
}
})
// After
const spinnerDef = createMemo(() => {
const opts = {
color: local.agent.color(local.agent.current().name),
style: "blocks" as const,
inactiveFactor: 0.6,
minAlpha: 0.3,
}
return {
frames: createFrames(opts),
color: createColors(opts),
}
})
```
**Why**: The identical config object is duplicated. Extract it to avoid drift and reduce visual noise.
---
### 18. Dead prop: `hint` (line 42)
`hint` is declared in `PromptProps` but never read in the component body.
```tsx
// Before (line 42)
hint?: JSX.Element
// After — remove from PromptProps
```
**Why**: Dead props mislead consumers into thinking they can pass a hint that will be rendered.
---
### 19. Dead prop: `showPlaceholder` (line 43)
`showPlaceholder` is declared in `PromptProps` but never referenced in the component.
```tsx
// Before (line 43)
showPlaceholder?: boolean
// After — remove from PromptProps
```
**Why**: Same as above — dead code.
---
### 20. `exit` is declared after `submit` which uses it (line 647)
```tsx
// Before
async function submit() {
...
if (trimmed === "exit" ...) {
exit() // used here
return
}
...
}
const exit = useExit() // declared here on line 647
// After — move before submit()
const exit = useExit()
async function submit() { ... }
```
**Why**: While JavaScript hoisting makes this work, it's confusing to read. Declaring dependencies before use is a basic readability convention.
---
### 21. Deeply nested IIFE JSX block for retry status (lines 1038-1093)
The retry status display is a ~55-line IIFE inside JSX. This should be extracted into its own component.
```tsx
// Before (lines 1038-1093)
{(() => {
const retry = createMemo(() => { ... })
const message = createMemo(() => { ... })
const isTruncated = createMemo(() => { ... })
const [seconds, setSeconds] = createSignal(0)
onMount(() => { ... })
const handleMessageClick = () => { ... }
const retryText = () => { ... }
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
// After — extract to a component
function RetryStatus(props: { status: () => typeof status }) { ... }
// In JSX:
<RetryStatus status={status} />
```
**Why**: A 55-line IIFE inside JSX is very hard to read. The style guide says "keep things in one function unless composable or reusable" — but this isn't about reuse, it's about the JSX being unreadable with that much logic inline.
---
### 22. Multiple `let` declarations for refs could use definite assignment pattern (lines 59-61)
```tsx
// Before (lines 59-61)
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
```
These are idiomatic in SolidJS for ref callbacks, so this is acceptable. No change needed — just noting that the style guide's `const` preference doesn't apply to SolidJS ref patterns.
---
### 23. Inconsistent `input.clear()` vs `input.setText("")` usage
In some places the code uses `input.clear()` and in others `input.setText(content)`. The `ref.reset()` method calls both `input.clear()` and `input.extmarks.clear()` while `submit()` calls `input.clear()` at the very end (line 645) after already setting the store. This is fine functionally but the ordering in `submit()` is odd — the store is reset on line 630 but the input is cleared on line 645 after the navigation timeout. Moving `input.clear()` next to the other cleanup would be clearer.
---
### 24. Unnecessary variable `nonTextParts` in editor command (line 251)
```tsx
// Before (line 251)
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
// ...25 lines later (line 313)
parts: updatedNonTextParts,
```
`nonTextParts` is used only as the input to the `.map()` chain that produces `updatedNonTextParts`. Could be chained:
```tsx
const updatedParts = store.prompt.parts
.filter((p) => p.type !== "text")
.map((part) => { ... })
.filter((part) => part !== null)
```
**Why**: Reduces variable count per style guide.

View File

@@ -0,0 +1,257 @@
# Code Review: `stash.tsx`
## Summary
This file is reasonably short and functional, but has several style guide violations and readability issues. The main problems are: a `let` + mutation pattern that can be replaced with a cleaner approach, duplicated serialization logic across three methods, a variable name (`line`) used misleadingly in non-line contexts, and unnecessary intermediate variables. The `try/catch` in `onMount` is acceptable here since it's parsing untrusted data line-by-line, but most other issues are straightforward to fix.
---
## Issues
### 1. Duplicated serialization logic (lines 41, 68, 84, 96)
The pattern `entries.map((line) => JSON.stringify(line)).join("\n") + "\n"` appears four times. This is a clear candidate for a helper function. It also uses the name `line` for entries that are not lines.
**Before:**
```tsx
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
// ... repeated in push(), pop(), remove()
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
```
**After:**
```tsx
function serialize(entries: StashEntry[]) {
return entries.map((e) => JSON.stringify(e)).join("\n") + "\n"
}
```
Then each call site becomes `serialize(store.entries)` or `serialize(lines)`. This reduces repetition and makes intent clearer. The function is reused across four call sites, making it a valid candidate for extraction per the style guide ("keep things in one function unless composable or reusable").
---
### 2. `let trimmed` mutation pattern in `push()` (lines 56-71)
Using `let trimmed = false` and mutating it inside the `produce` callback is a sloppy pattern. The trimming condition can be checked independently before or after the store update, since we know the length before pushing.
**Before (lines 56-71):**
```tsx
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(() => {})
```
**After:**
```tsx
const willTrim = store.entries.length + 1 > MAX_STASH_ENTRIES
setStore(
produce((draft) => {
draft.entries.push(stash)
if (willTrim) draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
}),
)
if (willTrim) {
writeFile(stashFile.name!, serialize(store.entries)).catch(() => {})
return
}
appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {})
```
**Why:** Eliminates `let` in favor of `const`, avoids side-effecting a variable from inside a callback (which is confusing to read), and makes the control flow purely based on a pre-computed condition.
---
### 3. Misleading parameter name `line` in callbacks (lines 27, 34, 41, 68, 84, 96)
The `.map()` and `.filter()` callbacks use `line` as the parameter name even when operating on parsed `StashEntry` objects. After parsing, these aren't lines anymore — they're entries.
**Before (line 34):**
```tsx
.filter((line): line is StashEntry => line !== null)
```
**After:**
```tsx
.filter((entry): entry is StashEntry => entry !== null)
```
Similarly in the serialization calls, `line` should be `entry` or just `e`. This is a small readability win — the name should reflect the value's type, not where it came from.
---
### 4. Unnecessary intermediate variable `lines` (line 24)
The variable `lines` is used in two places: assigning to the store, and rewriting the file. However, the store assignment could use the result directly. This is borderline — the variable is used twice so inlining isn't strictly required, but the name `lines` is misleading since after parsing and filtering they are entries, not lines.
**Before (lines 24-37):**
```tsx
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => { ... })
.filter((line): line is StashEntry => line !== null)
.slice(-MAX_STASH_ENTRIES)
setStore("entries", lines)
```
**After:**
```tsx
const entries = text
.split("\n")
.filter(Boolean)
.map((raw) => {
try {
return JSON.parse(raw)
} catch {
return null
}
})
.filter((entry): entry is StashEntry => entry !== null)
.slice(-MAX_STASH_ENTRIES)
setStore("entries", entries)
```
**Why:** `entries` accurately describes what the variable holds. Using `raw` for the unparsed string and `entry` for the parsed object makes the pipeline easier to follow.
---
### 5. Unnecessary `clone()` in `push()` (line 55)
`clone({ ...entry, timestamp: Date.now() })` creates a spread (shallow copy) and then deep-clones it. The spread already creates a new object. If `entry.parts` contains nested references that need isolation, `clone` alone on the merged object would suffice — the spread is redundant.
**Before (line 55):**
```tsx
const stash = clone({ ...entry, timestamp: Date.now() })
```
**After:**
```tsx
const stash = clone({ ...entry, timestamp: Date.now() })
```
This one is actually fine as-is — `clone` handles deep cloning and the spread merges in the timestamp. The overhead is negligible. No change needed, but worth noting that `clone` already returns a new object, so the spread is technically creating an intermediate throwaway object. A minor nit: you could write `clone(Object.assign(entry, { timestamp: Date.now() }))` but the spread is more readable. **No change recommended.**
---
### 6. Ternary content in `pop()` and `remove()` could be simplified (lines 83-84, 95-96)
**Before (lines 83-84):**
```tsx
const content = store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashFile.name!, content).catch(() => {})
```
**After (with the `serialize` helper):**
```tsx
writeFile(stashFile.name!, store.entries.length > 0 ? serialize(store.entries) : "").catch(() => {})
```
**Why:** Inlines `content` since it's only used once, and the `serialize` helper makes it short enough to fit on one line. Reduces variable count per the style guide.
---
### 7. `stashFile.name!` non-null assertion used repeatedly (lines 42, 69, 73, 85, 97)
`stashFile.name!` is used 5 times with a non-null assertion. Since `Bun.file()` constructed with a string path always has a `.name`, this assertion is safe but noisy. Storing the path directly would be cleaner.
**Before:**
```tsx
const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl"))
// ... later ...
writeFile(stashFile.name!, content).catch(() => {})
```
**After:**
```tsx
const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl")
const stashFile = Bun.file(stashPath)
// ... later ...
writeFile(stashPath, content).catch(() => {})
```
**Why:** Eliminates all 5 non-null assertions. The path is the primary identifier; the `BunFile` object is only needed for the initial `.text()` read in `onMount`. This is one case where an extra variable actually reduces noise. Alternatively, since `Bun.file().text()` is only called once, you could inline that too:
```tsx
const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl")
// in onMount:
const text = await Bun.file(stashPath)
.text()
.catch(() => "")
```
This eliminates the `stashFile` variable entirely and all non-null assertions.
---
### 8. `store` and `setStore` declared after first use (lines 37 vs 46)
`setStore` is called on line 37 inside `onMount`, but `createStore` is on line 46. While this works because `onMount` runs asynchronously after init completes, it reads confusingly — the store appears to be used before it's created.
**Before:**
```tsx
onMount(async () => {
// ... uses setStore on line 37
})
const [store, setStore] = createStore({ ... }) // line 46
```
**After:**
```tsx
const [store, setStore] = createStore({
entries: [] as StashEntry[],
})
onMount(async () => {
// ... uses setStore
})
```
**Why:** Declaring the store before `onMount` makes the data flow obvious. The reader doesn't have to reason about hoisting or async timing to understand that `setStore` is available.
---
## Summary of Recommended Changes
| Issue | Severity | Type |
| ------------------------------------- | -------- | ----------------------------- |
| Duplicated serialization logic | Medium | DRY violation |
| `let trimmed` mutation pattern | Medium | Style (prefer `const`) |
| Misleading `line` parameter names | Low | Readability |
| Store declared after first reference | Low | Readability |
| `stashFile.name!` repeated assertions | Low | Noise reduction |
| Inlineable `content` variable | Low | Style (reduce variable count) |

View File

@@ -0,0 +1,96 @@
# Review: `spinner.tsx`
## Summary
This is a small, clean component — only 25 lines. There isn't much wrong with it, but there are a couple of minor issues worth addressing around unnecessary imports, destructuring convention, and a slightly redundant type annotation.
---
## Issues
### 1. Unnecessary destructuring of `useTheme()` (line 11)
**Line 11:**
```tsx
const { theme } = useTheme()
```
Per the style guide: "Avoid unnecessary destructuring. Use dot notation to preserve context."
However, `const { theme } = useTheme()` is the **dominant pattern** across the entire codebase (43 of 44 call sites do this exact destructuring). The one exception (`tips.tsx`) uses `const theme = useTheme().theme`. In this case, `useTheme()` returns an object with many properties (`theme`, `selected`, `all`, `syntax`, `mode`, `set`, etc.) and components only need `theme`. Destructuring is the established convention here, so changing it would create inconsistency with the rest of the codebase.
**Verdict:** No change — codebase convention overrides the general guideline.
---
### 2. `JSX.Element` import can be replaced with `ParentProps` from solid-js (lines 4, 10)
`children?: JSX.Element` requires a dedicated type import from `@opentui/solid`. Solid provides `ParentProps` for exactly this pattern, but looking at the codebase, `ParentProps` wraps an existing props type and always includes `children` (not optional). Since `children` is optional here, the current approach is correct.
However, `JSX.Element` could be imported from `solid-js/jsx-runtime` or simply `solid-js` instead of `@opentui/solid` for consistency with how the rest of the codebase imports Solid types. The only other file that imports `JSX` from `@opentui/solid` is `link.tsx` — and it imports from `solid-js` instead.
**Before (line 4):**
```tsx
import type { JSX } from "@opentui/solid"
```
**After:**
```tsx
import type { JSX } from "solid-js"
```
**Why:** Consistency with the rest of the codebase. `link.tsx` imports `JSX` from `solid-js`. Importing from the framework directly is more conventional and doesn't depend on the UI library re-exporting it.
---
### 3. `RGBA` type import is unnecessary — can rely on inference (lines 5, 10)
The `RGBA` type is imported solely to annotate the `color` prop. But the consumers of `Spinner` already know what type they're passing (they get it from `theme.textMuted` or similar), and the `<spinner>` and `<text>` elements that consume `color()` will enforce their own types. The explicit `RGBA` annotation doesn't add safety here — it just adds an import.
**Before (lines 45, 10):**
```tsx
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
```
**After:**
```tsx
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
```
**Verdict:** Keep `RGBA`. This is an exported component, so explicit prop types are appropriate for API clarity. The style guide says "Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for **exports** or clarity." Since `Spinner` is exported, the annotation is justified.
---
### 4. Minor: `color` helper could be inlined (line 13)
**Line 13:**
```tsx
const color = () => props.color ?? theme.textMuted
```
This reactive accessor is used three times (lines 15, 17, 19), so extracting it is the right call. No change needed.
---
## Final Assessment
This file is already well-written. The only actionable change is the `JSX` import source (issue #2). Everything else either follows codebase convention or is appropriately structured for a small exported component.
### Single recommended change
```diff
-import type { JSX } from "@opentui/solid"
+import type { JSX } from "solid-js"
```

View File

@@ -0,0 +1,215 @@
# Code Review: `tips.tsx`
## Summary
The file is relatively clean but has several style guide violations and unnecessary complexity. The main issues are: an overly complex `parse` function using a confusing `reduce` with a mutable accumulator, an unnecessary type alias, unnecessary destructuring, and a few naming/variable issues. The TIPS array and component itself are straightforward.
---
## Issues
### 1. Unnecessary type alias `TipPart` (line 7)
The `TipPart` type is only used as the return type of `parse`, and that return type can be inferred. Defining a named type for a simple shape used in one place adds unnecessary indirection.
**Before:**
```tsx
type TipPart = { text: string; highlight: boolean }
function parse(tip: string): TipPart[] {
const parts: TipPart[] = []
```
**After:**
```tsx
function parse(tip: string) {
const parts: { text: string; highlight: boolean }[] = []
```
The type annotation on `parts` is still needed to initialize the empty array with the right shape, but the standalone type alias and explicit return type annotation are unnecessary. Inference handles the return type.
---
### 2. `parse` function is needlessly complex (lines 9-31)
The `reduce` with a mutable accumulator object (`{ parts, index }`) is hard to follow. It mutates `acc.parts` (which is the same reference as the outer `parts` variable), making the data flow confusing. A simpler `replaceAll`/`split` approach or a straightforward while-loop with `regex.exec` would be far more readable.
**Before:**
```tsx
function parse(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
const found = Array.from(tip.matchAll(regex))
const state = found.reduce(
(acc, match) => {
const start = match.index ?? 0
if (start > acc.index) {
acc.parts.push({ text: tip.slice(acc.index, start), highlight: false })
}
acc.parts.push({ text: match[1], highlight: true })
acc.index = start + match[0].length
return acc
},
{ parts, index: 0 },
)
if (state.index < tip.length) {
parts.push({ text: tip.slice(state.index), highlight: false })
}
return parts
}
```
**After:**
```tsx
function parse(tip: string) {
const parts: { text: string; highlight: boolean }[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
let last = 0
for (const match of tip.matchAll(regex)) {
const start = match.index ?? 0
if (start > last) parts.push({ text: tip.slice(last, start), highlight: false })
parts.push({ text: match[1], highlight: true })
last = start + match[0].length
}
if (last < tip.length) parts.push({ text: tip.slice(last), highlight: false })
return parts
}
```
The style guide says "prefer functional array methods over for loops," but `reduce` with a mutable accumulator is not meaningfully more functional than a loop -- it's just harder to read. The `for...of` over `matchAll` is the clearest idiom for this regex-walk pattern. The `reduce` version also needlessly creates an intermediate `Array.from()` copy and a wrapper object. The variable `found` (line 12) and `state` (line 13) are both eliminated.
Note: this is one case where `let` is appropriate -- `last` is a loop cursor that must be reassigned.
---
### 3. Unnecessary destructuring of `useTheme()` (line 34)
The style guide says "avoid unnecessary destructuring, use dot notation to preserve context."
**Before:**
```tsx
const theme = useTheme().theme
```
This is actually fine -- it's not destructuring, it's dot access stored in a variable. No change needed.
---
### 4. `themeCount` variable is used only once (line 4-5)
The style guide says "reduce total variable count by inlining when a value is only used once."
**Before:**
```tsx
const themeCount = Object.keys(DEFAULT_THEMES).length
const themeTip = `Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
```
**After:**
```tsx
const themeTip = `Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${Object.keys(DEFAULT_THEMES).length} built-in themes`
```
Inlining removes a single-use intermediate variable without hurting readability.
---
### 5. `found` variable is used only once (line 12)
Already addressed in issue #2. `Array.from(tip.matchAll(regex))` is stored in `found` only to be passed to `reduce`. Eliminating the `reduce` pattern removes this variable entirely.
---
### 6. `start` variable is used only once per iteration (line 16)
Inside the reduce callback, `start` is assigned `match.index ?? 0` and used twice. This is borderline -- keeping it is acceptable since it's used in two places within the same block. However, in the simplified version (issue #2), it remains used twice so it's fine to keep.
---
### 7. Inconsistent mutation pattern in `parse` (lines 10, 13, 17-20, 26-28)
The `parts` array is declared on line 10, passed into `reduce` as part of the initial accumulator on line 22, mutated via `acc.parts.push()` on lines 17/19, and then also mutated directly via `parts.push()` on line 27. The fact that `parts` and `acc.parts` are the same reference is not obvious and makes the code confusing. The `state` variable is only used to read `.index` on line 26, while `.parts` is ignored since it's the same as the outer `parts`. This is the strongest reason to rewrite the function as shown in issue #2.
---
## Final Recommended State
```tsx
import { For } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
const themeTip = `Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${Object.keys(DEFAULT_THEMES).length} built-in themes`
function parse(tip: string) {
const parts: { text: string; highlight: boolean }[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
let last = 0
for (const match of tip.matchAll(regex)) {
const start = match.index ?? 0
if (start > last) parts.push({ text: tip.slice(last, start), highlight: false })
parts.push({ text: match[1], highlight: true })
last = start + match[0].length
}
if (last < tip.length) parts.push({ text: tip.slice(last), highlight: false })
return parts
}
export function Tips() {
const theme = useTheme().theme
const parts = parse(TIPS[Math.floor(Math.random() * TIPS.length)])
return (
<box flexDirection="row" maxWidth="100%">
<text flexShrink={0} style={{ fg: theme.warning }}>
Tip{" "}
</text>
<text flexShrink={1}>
<For each={parts}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
)
}
const TIPS = [
// ... unchanged
]
```
Changes:
- Removed unused imports (`createMemo`, `createSignal`)
- Inlined `themeCount`
- Removed `TipPart` type alias
- Replaced `reduce` with a clear `for...of` loop over `matchAll`
- Eliminated `found` and `state` variables
---
## Unused Imports (line 1)
`createMemo` and `createSignal` are imported but never used anywhere in the file. These should be removed.
**Before:**
```tsx
import { createMemo, createSignal, For } from "solid-js"
```
**After:**
```tsx
import { For } from "solid-js"
```
This is the most clear-cut issue in the file -- dead imports add noise and suggest leftover code from a previous iteration.

View File

@@ -0,0 +1,173 @@
# Review: `todo-item.tsx`
## Summary
This is a small, 33-line component. It's reasonably clean, but has a few style guide violations and a duplicated expression that hurts readability. None of the issues are severe, but fixing them would make the file tighter and more consistent with project conventions.
---
## Issues
### 1. Exported interface is unnecessary (lines 3-6)
The `TodoItemProps` interface is exported but only consumed internally by the `TodoItem` function on line 8. No other file imports `TodoItemProps` -- callers just pass `status` and `content` as JSX attributes. Exporting it adds noise to the module's public API for no benefit. Additionally, an inline type annotation avoids the need for a named interface entirely, which is preferred when the type isn't reused.
If keeping the interface is desired for documentation purposes, it should at minimum not be exported. But per the style guide ("rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity"), an inline type is cleaner here.
**Before (lines 3-8):**
```tsx
export interface TodoItemProps {
status: string
content: string
}
export function TodoItem(props: TodoItemProps) {
```
**After:**
```tsx
export function TodoItem(props: { status: string; content: string }) {
```
**Why:** Removes a standalone type that isn't imported anywhere. Fewer exports, fewer lines, less indirection. One less name to track.
---
### 2. Duplicated color expression (lines 16 and 25)
The same ternary `props.status === "in_progress" ? theme.warning : theme.textMuted` appears identically on lines 16 and 25. This is a readability issue -- if the color logic changes, you'd need to update two places. Extract it to a local variable once.
**Before (lines 12-29):**
```tsx
<box flexDirection="row" gap={0}>
<text
flexShrink={0}
style={{
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
}}
>
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
</text>
<text
flexGrow={1}
wrapMode="word"
style={{
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
}}
>
{props.content}
</text>
</box>
```
**After:**
```tsx
const color = props.status === "in_progress" ? theme.warning : theme.textMuted
return (
<box flexDirection="row" gap={0}>
<text flexShrink={0} style={{ fg: color }}>
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
</text>
<text flexGrow={1} wrapMode="word" style={{ fg: color }}>
{props.content}
</text>
</box>
)
```
**Why:** DRY. The duplicated ternary is the longest expression in the component and it appears twice. Extracting it makes both `<text>` style props trivially readable and ensures the two elements always share the same color.
---
### 3. Nested ternary for the icon is hard to scan (line 19)
The checkbox icon expression is a double-nested ternary on a single line:
```tsx
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
```
This is dense. A local variable with a clearer name makes the three states explicit and easier to scan.
**Before (line 19):**
```tsx
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
```
**After:**
```tsx
const icon = props.status === "completed"
? "✓"
: props.status === "in_progress"
? "•"
: " "
// then in JSX:
[{icon}]{" "}
```
**Why:** The nested ternary inlined in JSX is the densest expression in the file. Breaking it out gives it a name (`icon`) and vertical formatting that makes the three branches scannable at a glance. This also lets the JSX line focus on structure rather than logic.
---
### 4. Destructuring `{ theme }` from `useTheme()` (line 9)
Per the style guide: "Avoid unnecessary destructuring. Use dot notation to preserve context."
However, `const { theme } = useTheme()` is the dominant pattern across the entire codebase (42 occurrences vs 1 use of dot notation). Changing this single file would make it the odd one out. **This is a codebase-wide inconsistency, not a per-file fix.** Flagging it for awareness but recommending no change in isolation.
---
### 5. `status` type is `string` but only three values are valid (line 4)
The `status` prop is typed as `string`, but the component only handles three states: `"completed"`, `"in_progress"`, and an implicit default (pending/empty). A union type would make the contract explicit and catch typos at compile time.
**Before:**
```tsx
status: string
```
**After:**
```tsx
status: "completed" | "in_progress" | "pending"
```
**Why:** The component already branches on specific string values. A union type documents the valid states and gives TypeScript the ability to flag invalid usage. That said, if the status values come from an external API/SDK type, it may be better to reference that type directly rather than duplicating the union.
---
## Suggested final version
Applying issues 1, 2, 3 (and optionally 5):
```tsx
import { useTheme } from "../context/theme"
export function TodoItem(props: { status: string; content: string }) {
const { theme } = useTheme()
const color = props.status === "in_progress" ? theme.warning : theme.textMuted
const icon = props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "
return (
<box flexDirection="row" gap={0}>
<text flexShrink={0} style={{ fg: color }}>
[{icon}]{" "}
</text>
<text flexGrow={1} wrapMode="word" style={{ fg: color }}>
{props.content}
</text>
</box>
)
}
```
This version is 21 lines (down from 33), removes the duplicated ternary, eliminates the exported interface, and makes the icon logic scannable.

View File

@@ -0,0 +1,24 @@
# Review: `packages/opencode/src/cli/cmd/tui/context/args.tsx`
## Summary
This is a 16-line file that is already quite clean. There is only one real issue worth flagging. The file follows the established `createSimpleContext` pattern used across the codebase and is consistent with sibling context files.
## Issues
### 1. Exported `Args` interface -- potentially unnecessary? (lines 3-10)
The `Args` interface is exported, but it's only used as the parameter type for `init`. Since `createSimpleContext` infers `Props` from the `init` function's parameter type, the interface could theoretically be inlined. However, `Args` is imported in `app.tsx` (`import { ArgsProvider, useArgs, type Args } from "./context/args"`), so the named export is justified. No change needed.
### 2. No issues found with the remaining code
The rest of the file is clean:
- `init: (props: Args) => props` is the simplest possible passthrough -- no unnecessary logic.
- Destructuring in `const { use: useArgs, provider: ArgsProvider }` is the established pattern across all sibling context files (`exit.tsx`, `sdk.tsx`, `theme.tsx`, etc.) and is required by the `createSimpleContext` API shape. This is not gratuitous destructuring.
- No `let`, no `else`, no `try/catch`, no `any`, no loops, no unnecessary variables.
- Naming is fine -- `Args` is a single word, `useArgs` and `ArgsProvider` follow the React/Solid convention established by every other context file in this directory.
## Verdict
This file is essentially already at the quality bar set by the style guide. The only potential change (inlining the interface) depends on whether `Args` is imported elsewhere, and even if it isn't, the current form is defensible for readability. No action required.

View File

@@ -0,0 +1,176 @@
# Review: `exit.tsx`
## Summary
This is a small file (~50 lines) that's reasonably well-structured. The main issues are: an unnecessary intermediate variable, an exported type that could be inlined/removed, unnecessary destructuring via the `store` variable, and a slightly verbose exit function body. No major structural problems.
---
## Issues
### 1. Unnecessary `store` variable (lines 17-29)
The `store` object is created, then immediately assigned to `exit.message`. It's only used in one place (the `Object.assign` on line 48) and once internally (line 42). The internal usage (`store.get()`) could just reference `message` directly since it's in the same closure.
**Why:** The style guide says to reduce variable count by inlining values used only once. `store` adds an intermediary name that doesn't clarify anything.
```tsx
// Before (lines 17-49)
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
const exit: Exit = Object.assign(
async (reason?: unknown) => {
renderer.setTerminalTitle("")
renderer.destroy()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
process.exit(0)
},
{
message: store,
},
)
// After
const exit: Exit = Object.assign(
async (reason?: unknown) => {
renderer.setTerminalTitle("")
renderer.destroy()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
if (message) process.stdout.write(message + "\n")
process.exit(0)
},
{
message: {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
},
},
)
```
**Why this helps:** Removes a variable that exists solely to be passed through. The `message` closure variable is right there -- calling `store.get()` to retrieve it is indirect. The `message` object shape is now visible at the `Object.assign` call site where it matters.
---
### 2. Unnecessary `text` variable (line 42-43)
`text` is used exactly once, immediately after assignment.
```tsx
// Before (lines 42-43)
const text = store.get()
if (text) process.stdout.write(text + "\n")
// After
if (message) process.stdout.write(message + "\n")
```
**Why this helps:** Style guide says to inline values used only once. Since `message` is already in scope, there's no need for the indirection through `store.get()` and a temp variable.
---
### 3. Exported `Exit` type may be unnecessary (lines 4-10)
The `Exit` type is defined at module scope but never imported by any other file -- it's only used on line 30 to annotate `exit`. Since `createSimpleContext` infers the return type from `init`, and callers get the type through `useExit()`, this annotation is redundant.
```tsx
// Before (lines 4-10, 30)
type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
get: () => string | undefined
}
}
// ...
const exit: Exit = Object.assign(
// After
const exit = Object.assign(
```
**Why this helps:** The style guide prefers relying on type inference. `Object.assign` produces a well-typed result here. The type annotation duplicates what TypeScript already infers, and removing it means one less thing to keep in sync. If explicit typing is desired for documentation purposes, this is a judgment call -- but it doesn't need to be exported or even named.
---
### 4. Nested `if` could be flattened (lines 36-41)
The nested `if` inside the `reason` block can be simplified. `FormatUnknownError` always returns a string, so `formatted` is always truthy when `FormatError` returns `undefined` -- meaning the inner `if (formatted)` guard is only needed because `FormatError` can return `""` (for `CancelledError`). This is subtle and worth a comment, or could be simplified.
```tsx
// Before (lines 36-41)
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
// After
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) process.stderr.write(formatted + "\n")
}
```
**Why this helps:** Minor -- collapses the inner block to a single-line conditional, matching the style used on line 43 (`if (text) process.stdout.write(...)`). The file is inconsistent: line 43 uses single-line `if`, but lines 38-40 use a block for the same pattern.
---
### 5. `input` parameter name shadows conceptual meaning (line 14)
The `init` callback receives `input` which represents component props (specifically `{ onExit?: () => Promise<void> }`). In a SolidJS context, `props` is the conventional name and is used everywhere else in the codebase.
```tsx
// Before (line 14)
init: (input: { onExit?: () => Promise<void> }) => {
// After
init: (props: { onExit?: () => Promise<void> }) => {
```
And on line 35:
```tsx
// Before
await input.onExit?.()
// After
await props.onExit?.()
```
**Why this helps:** `input` is vague. These are component props passed through `ExitProvider`. Using `props` is consistent with SolidJS conventions and the `helper.tsx` file which names these `Props`.

View File

@@ -0,0 +1,174 @@
# Review: `packages/opencode/src/cli/cmd/tui/context/helper.tsx`
## Summary
This is a small (26-line) utility file with a focused purpose: factory for SolidJS context providers with an optional "ready gate." The code is mostly clean, but there are a few issues — one is a real bug, one is a style violation, and the rest are minor readability improvements.
---
## Issues
### 1. Bug: `.ready` gate is not reactive (line 14)
The `init.ready` property is accessed directly inside JSX, but in every consumer (`local.tsx:209`, `theme.tsx:387`), `ready` is defined as a getter (`get ready() { return store.ready }`). Because `init.ready` is read once outside a tracking scope and passed to `<Show when={...}>`, it won't re-evaluate when the underlying store changes. This means if `ready` starts as `false`, the children will never appear.
The UI package's version of this same helper (`packages/ui/src/context/helper.tsx`) already fixes this correctly by wrapping the access in a `createMemo`:
```tsx
// Before (line 12-17)
const init = input.init(props)
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
```
```tsx
// After
const init = input.init(props)
const ready = createMemo(() => {
// @ts-expect-error
const r = init.ready as boolean | undefined
return r === undefined || r === true
})
return (
<Show when={ready()}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
```
**Why:** Without wrapping in `createMemo`, SolidJS has no way to track the getter. The `<Show when={...}>` receives a static `true`/`false` value at creation time and never updates. This is the most important issue in the file — it's a correctness bug, not just style.
---
### 2. Use `Record<string, any>` — violates `any` avoidance (line 3)
The generic constraint `Props extends Record<string, any>` uses `any`. This is noted in the style guide as something to avoid.
```tsx
// Before (line 3)
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
```
```tsx
// After
export function createSimpleContext<T, Props extends Record<string, unknown>>(input: {
```
**Why:** `Record<string, unknown>` is safer and still permits arbitrary prop shapes. `unknown` forces consumers to narrow before use, which is the whole point of TypeScript.
---
### 3. `@ts-expect-error` is too broad (line 13)
A bare `@ts-expect-error` suppresses all errors on the next line with no explanation of what's being suppressed or why.
```tsx
// Before (line 13-14)
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
```
```tsx
// After
// @ts-expect-error - T may not have a `ready` property
<Show when={ready()}>
```
**Why:** Adding a description makes it clear that this is an intentional access of a property that may not exist on `T`, not a mistake. If this line ever compiles cleanly (e.g., after adding `ready` to the type), the `@ts-expect-error` will correctly trigger a build error reminding you to clean it up — but only if you understand what it was suppressing.
---
### 4. Parameter name `input` shadows the concept — prefer shorter name (lines 3, 5, 11, 21)
The style guide says "prefer single word variable names." `input` is already one word, but it's a vague one that collides with the `init` callback's own parameter also called `input`. A name like `opts` or `def` would be more distinct.
```tsx
// Before (lines 3-6)
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
```
```tsx
// After
export function createSimpleContext<T, Props extends Record<string, unknown>>(opts: {
name: string
init: ((props: Props) => T) | (() => T)
}) {
```
**Why:** The outer parameter is `input` and the inner callback's parameter type is also `input: Props`. While they're at different scopes, using `opts` for the outer and `props` for the inner makes the distinction clear at a glance. It also aligns with SolidJS conventions where component arguments are called `props`.
---
### 5. `init` is used as both a callback name and a local variable (lines 5, 11)
`input.init` is the factory function, and `init` is the result of calling it. This reuse is confusing.
```tsx
// Before (line 11)
const init = input.init(props)
```
```tsx
// After
const value = opts.init(props)
```
**Why:** `value` clearly communicates "the thing the context provides," while `init` suggests "a function that initializes something." Reusing the same name for a function and its return value in the same scope is a readability trap.
---
### 6. Missing `Show` fallback — minor but worth noting (line 14)
When `ready` is `false`, `<Show>` renders nothing. This is intentional (gate the whole subtree until ready), but there's no comment explaining this behavior. A consumer reading this helper for the first time might wonder if children should be rendered in a loading state.
This is not a code change — just a note that a one-line comment would help:
```tsx
// Gate children until init signals ready (or if no ready property exists, render immediately)
```
---
## Full suggested rewrite
For reference, here's what the file would look like with all issues addressed:
```tsx
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, unknown>>(opts: {
name: string
init: ((props: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const value = opts.init(props)
// Gate children until init signals ready (or render immediately if no ready property)
const ready = createMemo(() => {
// @ts-expect-error - T may not have a `ready` property
const r = value.ready as boolean | undefined
return r === undefined || r === true
})
return (
<Show when={ready()}>
<ctx.Provider value={value}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const result = useContext(ctx)
if (!result) throw new Error(`${opts.name} context must be used within a context provider`)
return result
},
}
}
```

View File

@@ -0,0 +1,205 @@
# Review: `keybind.tsx`
## Summary
The file is compact (~100 lines) and the overall structure is reasonable. However, there are several style guide violations and readability issues: unnecessary `let` where `const` is possible, a redundant guard condition, unnecessary destructuring and type annotations, variable shadowing, a missing return type, and some inlining opportunities. None are severe bugs, but cleaning them up would make the file tighter and more consistent with the codebase style guide.
---
## Issues
### 1. Unnecessary `let` for `focus` (line 26)
`focus` is used as mutable state, which is legitimate here since it's reassigned in the `leader` function and read in the timeout callback. However, it's declared with `let` and no initializer, which could be `let focus: Renderable | null = null` for clarity. This one is acceptable as-is since the mutation is inherent to the pattern.
No change needed -- noting for completeness.
---
### 2. Redundant guard `if (!active)` on line 43
The `leader` function returns early on line 40 when `active` is true. So by the time we reach line 43, `active` is _always_ false. The `if (!active)` check is dead logic that adds nesting for no reason.
**Lines 42-49:**
```tsx
// Before
if (!active) {
if (focus && !renderer.currentFocusedRenderable) {
focus.focus()
}
setStore("leader", false)
}
```
```tsx
// After
if (focus && !renderer.currentFocusedRenderable) {
focus.focus()
}
setStore("leader", false)
```
**Why:** The early return on line 40 already guarantees `active` is false here. The redundant check obscures this and adds unnecessary indentation.
---
### 3. Unnecessary type annotation on `parsed` (line 84)
The style guide says to rely on type inference. `result.parse(evt)` already returns `Keybind.Info`, so annotating the variable is redundant.
**Line 84:**
```tsx
// Before
const parsed: Keybind.Info = result.parse(evt)
```
```tsx
// After
const parsed = result.parse(evt)
```
**Why:** The return type of `result.parse` is already `Keybind.Info`. The annotation adds noise without adding safety.
---
### 4. `for` loop in `match` should be `Array.some` (lines 85-89)
The style guide prefers functional array methods over `for` loops. This is a textbook case for `.some()`.
**Lines 82-90:**
```tsx
// Before
match(key: keyof KeybindsConfig, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
return true
}
}
},
```
```tsx
// After
match(key: keyof KeybindsConfig, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed = result.parse(evt)
return keybind.some((k) => Keybind.match(k, parsed))
},
```
**Why:** More concise, idiomatic, and avoids the variable shadowing issue (see next point). Also fixes the implicit `undefined` return -- the original function falls through without returning `false` when no keybind matches.
---
### 5. Variable shadowing: `key` parameter shadows `key` loop variable (line 85)
The `match` method parameter is named `key`, and the `for...of` loop variable is also named `key`. This compiles but is confusing.
This is resolved by the `.some()` rewrite above (using `k` as the callback parameter), but worth noting as its own issue.
**Line 81 vs 85:**
```tsx
// The parameter `key` on line 81 is shadowed by the loop variable `key` on line 85
match(key: keyof KeybindsConfig, evt: ParsedKey) {
...
for (const key of keybind) { // shadows the outer `key`
```
**Why:** Shadowed variables make it unclear which `key` is being referenced and can cause subtle bugs during refactoring.
---
### 6. `result` variable in `print` shadows outer `result` (line 94)
The outer scope defines `const result = { ... }` on line 67. Inside the `print` method, `const result` on line 94 shadows it.
**Lines 91-96:**
```tsx
// Before
print(key: keyof KeybindsConfig) {
const first = keybinds()[key]?.at(0)
if (!first) return ""
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
},
```
```tsx
// After
print(key: keyof KeybindsConfig) {
const first = keybinds()[key]?.at(0)
if (!first) return ""
return Keybind.toString(first).replace("<leader>", Keybind.toString(keybinds().leader![0]!))
},
```
**Why:** Eliminates the shadowing _and_ inlines a single-use variable, following the style guide's guidance to reduce variable count when a value is only used once.
---
### 7. `match` has implicit `undefined` return (lines 81-90)
When no keybind matches, the function falls off the end and implicitly returns `undefined`. Callers treat this as a boolean (line 52: `result.match("leader", evt)`), so it works due to truthiness, but it's sloppy -- the function should explicitly return `false`.
This is fixed by the `.some()` rewrite in issue 4, which always returns a boolean.
---
### 8. Unnecessary destructuring of `sync` (line 14)
`sync` is only used once on line 17 as `sync.data.config.keybinds`. It could be inlined, but since `useSync()` is a hook call that must happen at the top level of the init function (SolidJS reactive context), assigning it to a variable is correct here.
No change needed -- noting for completeness that the single-use is acceptable due to hook semantics.
---
### 9. `keybinds` memo has unnecessary `return` with braces (lines 15-20)
The `createMemo` callback wraps a single `pipe()` expression in braces + explicit return. An arrow with implicit return is cleaner.
**Lines 15-20:**
```tsx
// Before
const keybinds = createMemo(() => {
return pipe(
sync.data.config.keybinds ?? {},
mapValues((value) => Keybind.parse(value)),
)
})
```
```tsx
// After
const keybinds = createMemo(() =>
pipe(
sync.data.config.keybinds ?? {},
mapValues((value) => Keybind.parse(value)),
),
)
```
**Why:** Removes a level of braces and the explicit `return` keyword for a single-expression function. More concise without losing clarity.
---
## Summary of Recommended Changes
| # | Line(s) | Severity | Description |
| --- | ------- | -------- | --------------------------------------------------------------------- |
| 1 | 42-49 | Medium | Remove redundant `if (!active)` guard |
| 2 | 84 | Low | Remove unnecessary `: Keybind.Info` type annotation |
| 3 | 85-89 | Medium | Replace `for` loop with `.some()`, fixing implicit `undefined` return |
| 4 | 85 | Medium | Fix `key` variable shadowing (resolved by #3) |
| 5 | 94-95 | Low | Inline single-use `result` in `print`, fixing variable shadowing |
| 6 | 81-90 | Medium | Explicit `false` return for `match` (resolved by #3) |
| 7 | 15-20 | Low | Use implicit return in memo callback |

View File

@@ -0,0 +1,288 @@
# Review: `packages/opencode/src/cli/cmd/tui/context/kv.tsx`
## Summary
This is a small file (53 lines) providing a key-value store context backed by a JSON file. The overall structure is reasonable, but there are several style guide violations and readability issues — unnecessary destructuring, unused imports, `any` types that could be narrowed, verbose function expressions, and an intermediate variable that could be inlined.
---
## Issues
### 1. Unnecessary destructuring of `createSignal` (line 10)
The style guide says to avoid destructuring and prefer dot notation. However, `createSignal` returns a tuple, not an object — destructuring tuples is idiomatic in Solid and unavoidable here. **No change needed.**
---
### 2. `Record<string, any>` store type (line 11)
The `any` type is explicitly discouraged by the style guide. Since the KV store holds JSON-serializable values loaded from a file, `unknown` is more appropriate. The `get` and `set` methods already act as the boundary where callers provide their own types via `signal<T>` or cast at the call site.
**Before:**
```tsx
const [store, setStore] = createStore<Record<string, any>>()
```
**After:**
```tsx
const [store, setStore] = createStore<Record<string, unknown>>()
```
**Why:** Replacing `any` with `unknown` forces callers to handle the type explicitly, catching bugs at compile time. The `signal` method already has a generic `<T>` to manage this. The `get` method's return type also becomes more honest.
---
### 3. `any` in `get` and `set` parameter types (lines 42, 45)
Same issue — `any` should be narrowed.
**Before:**
```tsx
get(key: string, defaultValue?: any) {
return store[key] ?? defaultValue
},
set(key: string, value: any) {
```
**After:**
```tsx
get<T>(key: string, defaultValue?: T): T {
return (store[key] as T) ?? (defaultValue as T)
},
set(key: string, value: unknown) {
```
**Why:** The `get` method is always called with a default value at every call site (e.g. `kv.get("animations_enabled", true)`, `kv.get("theme_mode", props.mode)`). Making it generic lets TypeScript infer the return type from the default, eliminating implicit `any` at all 15+ call sites. `set` accepts `unknown` since the store holds `unknown`.
---
### 4. Unused import: `Setter` (line 2)
`Setter` is imported but the `signal` method's setter parameter on line 37 types `next` as `Setter<T>`, which means callers would pass a function `(prev: T) => T` — but `result.set` on line 38 just passes `next` directly to `setStore`, which doesn't support the Solid setter protocol. The `Setter` type is misleading here. Looking at actual usage:
```tsx
setShowThinking((prev) => !prev)
```
The consumer passes a function `(prev) => !prev`, but `result.set` at line 38 calls `setStore(key, value)` which does **not** invoke the function — it stores the function literal as the value. This is a **bug**, not just a style issue. But from a style perspective, the import is unused in the way it claims to work.
However, since fixing the bug is out of scope for a style review, at minimum the type should honestly reflect what actually happens — it accepts any value:
**Before:**
```tsx
import { createSignal, type Setter } from "solid-js"
```
**After:**
```tsx
import { createSignal } from "solid-js"
```
**Why:** Removing the unused/misleading import reduces noise.
---
### 5. Verbose `function` expressions in `signal` (lines 34-39)
The two function expressions inside `signal` are unnecessarily verbose. Arrow functions are more concise and consistent with the rest of the codebase.
**Before:**
```tsx
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue)
return [
function () {
return result.get(name)
},
function setter(next: Setter<T>) {
result.set(name, next)
},
] as const
},
```
**After:**
```tsx
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue as unknown)
return [
() => result.get<T>(name, defaultValue),
(next: unknown) => result.set(name, next),
] as const
},
```
**Why:** Arrow functions are shorter and more readable. The named `function setter` serves no purpose — the name isn't used for recursion or stack traces in any meaningful way. Passing `defaultValue` to `get` also ensures a consistent fallback.
---
### 6. Unnecessary intermediate `result` variable (lines 24-50)
The `result` variable exists so that `signal`'s inner functions can reference `result.get` and `result.set`. This self-reference is needed, so the variable can't be fully eliminated. However, the `return result` on line 50 could be inlined if the self-references used the methods directly instead. Since `get` and `set` are simple one-liners, the signal closures could capture `store`/`setStore` directly rather than going through the result object.
**Before:**
```tsx
const result = {
get ready() {
return ready()
},
get store() {
return store
},
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue)
return [
function () {
return result.get(name)
},
function setter(next: Setter<T>) {
result.set(name, next)
},
] as const
},
get(key: string, defaultValue?: any) {
return store[key] ?? defaultValue
},
set(key: string, value: any) {
setStore(key, value)
Bun.write(file, JSON.stringify(store, null, 2))
},
}
return result
```
**After:**
```tsx
function get<T>(key: string, defaultValue?: T): T {
return (store[key] as T) ?? (defaultValue as T)
}
function set(key: string, value: unknown) {
setStore(key, value)
Bun.write(file, JSON.stringify(store, null, 2))
}
return {
get ready() {
return ready()
},
get store() {
return store
},
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue as unknown)
return [() => get<T>(name, defaultValue), (next: unknown) => set(name, next)] as const
},
get,
set,
}
```
**Why:** Extracting `get` and `set` as standalone functions removes the need for the `result` self-reference pattern. The return object can be returned directly without assigning it to a variable first. This follows the style guide's preference for reducing variable count.
---
### 7. Multiline `.then`/`.catch`/`.finally` chain (lines 14-22)
The promise chain has unnecessary line breaks inside each callback.
**Before:**
```tsx
file
.json()
.then((x) => {
setStore(x)
})
.catch(() => {})
.finally(() => {
setReady(true)
})
```
**After:**
```tsx
file
.json()
.then((x) => setStore(x))
.catch(() => {})
.finally(() => setReady(true))
```
**Why:** Each callback is a single expression. The block form with braces adds 6 extra lines for no benefit. The concise arrow form is easier to scan.
---
### 8. `file` variable could be inlined (line 12)
The `file` variable is used in two places (the initial `.json()` read and in `set` for writing), so it can't be inlined. **No change needed.**
---
## Potential Bug (informational)
The `signal` method's setter (line 37-39) accepts `Setter<T>` which in Solid's API means it can be either a raw value or a function `(prev: T) => T`. But `result.set` at line 38 passes whatever it receives directly to `setStore(key, value)`. When consumers call `setShowThinking((prev) => !prev)`, the function `(prev) => !prev` is passed to `set`, and `setStore` from `solid-js/store` does handle function setters — so this actually works by coincidence via Solid's store setter behavior. However, the typing is misleading: the parameter should match what `setStore`'s path-based setter accepts, not Solid's signal `Setter` type. This isn't a style-only issue but worth noting.
---
## Complete Suggested Rewrite
```tsx
import { Global } from "@/global"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import path from "path"
export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: () => {
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, unknown>>()
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file
.json()
.then((x) => setStore(x))
.catch(() => {})
.finally(() => setReady(true))
function get<T>(key: string, defaultValue?: T): T {
return (store[key] as T) ?? (defaultValue as T)
}
function set(key: string, value: unknown) {
setStore(key, value)
Bun.write(file, JSON.stringify(store, null, 2))
}
return {
get ready() {
return ready()
},
get store() {
return store
},
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue as unknown)
return [() => get<T>(name, defaultValue), (next: unknown) => set(name, next)] as const
},
get,
set,
}
},
})
```
Changes from original: 53 lines → 47 lines. Removes `any` (×3), removes unused `Setter` import, eliminates `result` self-reference variable, simplifies function expressions, and compresses the promise chain.

View File

@@ -0,0 +1,481 @@
# Code Review: `local.tsx`
## Summary
The file is functional but has a number of style guide violations and readability issues. The most common problems are: unnecessary destructuring instead of dot notation, use of `let` where `const` with ternary or modular arithmetic would work, `else` branches that could be early returns, verbose variable naming, explicit type annotations where inference suffices, repeated inline logic that could be extracted, and a few places where inlining single-use values would reduce noise.
---
## Issues
### 1. Unnecessary destructuring of `useTheme` (line 44)
The style guide says to avoid destructuring and prefer dot notation. `theme` is the only field used, but the destructuring adds noise.
```tsx
// before (line 44)
const { theme } = useTheme()
// after
const theme = useTheme().theme
```
**Why:** Dot notation preserves context and follows the project convention.
---
### 2. `let` used in `agent.move` where modular arithmetic works (lines 72-74)
`let next` is reassigned twice with bounds wrapping. This is a classic modulo pattern.
```tsx
// before (lines 70-78)
move(direction: 1 | -1) {
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
setAgentStore("current", value.name)
})
},
// after
move(direction: 1 | -1) {
batch(() => {
const list = agents()
const next = ((list.findIndex((x) => x.name === agentStore.current) + direction) % list.length + list.length) % list.length
setAgentStore("current", list[next].name)
})
},
```
**Why:** Eliminates `let` and the two reassignment guards. The `value` intermediate variable (used once) is also inlined.
---
### 3. Unnecessary intermediate variable in `agent.color` (lines 80-91)
`agent` on line 82 is only used once after the index check. Inline the access. Also, `color` on line 85 is used once and can be inlined.
```tsx
// before (lines 79-91)
color(name: string) {
const index = visibleAgents().findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
const agent = visibleAgents()[index]
if (agent?.color) {
const color = agent.color
if (color.startsWith("#")) return RGBA.fromHex(color)
// already validated by config, just satisfying TS here
return theme[color as keyof typeof theme] as RGBA
}
return colors()[index % colors().length]
},
// after
color(name: string) {
const list = visibleAgents()
const index = list.findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
if (list[index].color) {
if (list[index].color.startsWith("#")) return RGBA.fromHex(list[index].color)
return theme[list[index].color as keyof typeof theme] as RGBA
}
return colors()[index % colors().length]
},
```
**Why:** Removes two single-use variables (`agent`, `color`). The optional chaining `agent?.color` was also unnecessary since `index !== -1` guarantees the element exists.
---
### 4. Verbose explicit type annotation on `modelStore` (lines 96-120)
The store's type can be inferred from the initial value. The `Record` and inline object types can be expressed via `as` on the initial value or a named type if needed, but the biggest issue is that the `{ providerID: string; modelID: string }` shape is repeated **6 times** in this block alone. Extract it or use inference.
```tsx
// before (lines 96-120)
const [modelStore, setModelStore] = createStore<{
ready: boolean
model: Record<
string,
{
providerID: string
modelID: string
}
>
recent: {
providerID: string
modelID: string
}[]
favorite: {
providerID: string
modelID: string
}[]
variant: Record<string, string | undefined>
}>({
ready: false,
model: {},
recent: [],
favorite: [],
variant: {},
})
// after
const [modelStore, setModelStore] = createStore({
ready: false,
model: {} as Record<string, { providerID: string; modelID: string }>,
recent: [] as { providerID: string; modelID: string }[],
favorite: [] as { providerID: string; modelID: string }[],
variant: {} as Record<string, string | undefined>,
})
```
**Why:** Lets inference do the work. The type is now co-located with the initial value, and the generic parameter doesn't need to spell out every field.
---
### 5. Single-use variable `file` (line 122)
`file` is used in two places (`file.json()` and `Bun.write(file, ...)`), so it's marginally justified. However, the mutable `state` object on lines 123-125 could be simplified to a plain `let`.
```tsx
// before (lines 123-125)
const state = {
pending: false,
}
// usage: state.pending = true, state.pending = false
// after
let pending = false
// usage: pending = true, pending = false
```
**Why:** A boolean flag doesn't need to be wrapped in an object. A plain `let` is simpler and more direct.
---
### 6. Unnecessary destructuring in `fallbackModel` (lines 158-165, 168-175)
`Provider.parseModel` result is destructured into `{ providerID, modelID }`, then immediately re-wrapped into `{ providerID, modelID }`. Just use the parsed result directly.
```tsx
// before (lines 157-175)
const fallbackModel = createMemo(() => {
if (args.model) {
const { providerID, modelID } = Provider.parseModel(args.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
if (sync.data.config.model) {
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
...
// after
const fallbackModel = createMemo(() => {
if (args.model) {
const parsed = Provider.parseModel(args.model)
if (isModelValid(parsed)) return parsed
}
if (sync.data.config.model) {
const parsed = Provider.parseModel(sync.data.config.model)
if (isModelValid(parsed)) return parsed
}
...
```
**Why:** The destructure-then-reconstruct pattern is pure noise. `parsed` already has the correct shape.
---
### 7. `?? undefined` is redundant (line 203)
`getFirstValidModel` already returns `undefined` when no match is found. `?? undefined` is a no-op.
```tsx
// before (lines 196-205)
const currentModel = createMemo(() => {
const a = agent.current()
return (
getFirstValidModel(
() => modelStore.model[a.name],
() => a.model,
fallbackModel,
) ?? undefined
)
})
// after
const currentModel = createMemo(() => {
return getFirstValidModel(
() => modelStore.model[agent.current().name],
() => agent.current().model,
fallbackModel,
)
})
```
**Why:** Removes dead code. Also inlines the single-use `a` variable.
---
### 8. `let` with bounds check in `model.cycle` (lines 241-243)
Same wrapping pattern as `agent.move`.
```tsx
// before (lines 241-244)
let next = index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
// after
const next = (((index + direction) % recent.length) + recent.length) % recent.length
const val = recent[next]
```
**Why:** Eliminates `let` and the two guard clauses.
---
### 9. `else` branch in `model.cycleFavorite` (lines 259-269)
The `else` can be removed with an early return or by restructuring.
```tsx
// before (lines 258-269)
let index = -1
if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
}
if (index === -1) {
index = direction === 1 ? 0 : favorites.length - 1
} else {
index += direction
if (index < 0) index = favorites.length - 1
if (index >= favorites.length) index = 0
}
// after
const found = current
? favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
: -1
const index =
found === -1
? direction === 1
? 0
: favorites.length - 1
: (((found + direction) % favorites.length) + favorites.length) % favorites.length
```
**Why:** Eliminates `let`, `else`, and the bounds-check reassignments. All expressed as `const` with ternaries.
---
### 10. Duplicated "add to recent" logic (lines 273-278 and 293-298)
The exact same 4-line block for deduplicating + capping recent list appears in both `cycleFavorite` and `set`. Extract it.
```tsx
// before (appears twice)
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
"recent",
uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
save()
// after (extract a helper inside the iife)
function addRecent(entry: { providerID: string; modelID: string }) {
const uniq = uniqueBy([entry, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
"recent",
uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
save()
}
```
Then call `addRecent(next)` and `addRecent(model)` respectively.
**Why:** DRY. The duplicated block is non-trivial and any future change (e.g., changing the cap from 10) would need to be made in two places.
---
### 11. `else` in `mcp.toggle` (lines 372-381)
```tsx
// before (lines 372-381)
async toggle(name: string) {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
// Disable: disconnect the MCP
await sdk.client.mcp.disconnect({ name })
} else {
// Enable/Retry: connect the MCP (handles disabled, failed, and other states)
await sdk.client.mcp.connect({ name })
}
},
// after
async toggle(name: string) {
if (sync.data.mcp[name]?.status === "connected")
return sdk.client.mcp.disconnect({ name })
return sdk.client.mcp.connect({ name })
},
```
**Why:** Early return eliminates the `else`. Also inlines the single-use `status` variable.
---
### 12. `if`/`else` in createEffect (lines 385-400)
The effect uses `if`/`else` where an early return is cleaner.
```tsx
// before (lines 385-400)
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
else
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
}
})
// after
createEffect(() => {
const current = agent.current()
if (!current.model) return
if (isModelValid(current.model)) return model.set(current.model)
toast.show({
variant: "warning",
message: `Agent ${current.name}'s configured model ${current.model.providerID}/${current.model.modelID} is not valid`,
duration: 3000,
})
})
```
**Why:** Early return flattens the nesting. Also, `model.set` was re-constructing `{ providerID, modelID }` from `value.model` which already has that shape -- just pass it directly.
---
### 13. Unnecessary `result` variable (lines 402-407)
```tsx
// before (lines 402-408)
const result = {
model,
agent,
mcp,
}
return result
// after
return { model, agent, mcp }
```
**Why:** Single-use variable; inline it per the style guide.
---
### 14. Repeated model identity comparison pattern
The lambda `(x) => x.providerID === current.providerID && x.modelID === current.modelID` appears on lines 239, 261, 314, and 317. A small helper would reduce noise:
```tsx
function same(a: { providerID: string; modelID: string }, b: { providerID: string; modelID: string }) {
return a.providerID === b.providerID && a.modelID === b.modelID
}
```
Then: `favorites.findIndex((x) => same(x, current))`, `modelStore.favorite.some((x) => same(x, model))`, etc.
**Why:** Reduces repetition of a non-trivial predicate and makes the intent clearer at each call site.
---
### 15. `for...of` loop in `fallbackModel` (lines 178-182)
The style guide prefers functional array methods over for loops.
```tsx
// before (lines 178-182)
for (const item of modelStore.recent) {
if (isModelValid(item)) {
return item
}
}
// after
const valid = modelStore.recent.find((item) => isModelValid(item))
if (valid) return valid
```
**Why:** `.find()` expresses intent more clearly and follows the style guide preference for functional array methods.
---
### 16. `for...of` loop in `getFirstValidModel` (lines 28-34)
Same issue as above.
```tsx
// before (lines 28-34)
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model)) return model
}
}
// after
function getFirstValidModel(...fns: (() => { providerID: string; modelID: string } | undefined)[]) {
return fns.map((fn) => fn()).find((m) => m && isModelValid(m))
}
```
**Why:** Replaces a for loop with functional methods. Also renames `modelFns` -> `fns` for brevity.
---
## Summary of Changes
| Category | Count |
| ------------------------------- | ----- |
| Unnecessary destructuring | 3 |
| `let` -> `const` | 3 |
| `else` -> early return | 3 |
| Single-use variable inlining | 4 |
| Duplicated logic | 2 |
| `for` loop -> functional method | 2 |
| Redundant code (`?? undefined`) | 1 |
| Verbose type annotation | 1 |

View File

@@ -0,0 +1,57 @@
# Review: `packages/opencode/src/cli/cmd/tui/context/prompt.tsx`
## Summary
This is a small 19-line file that creates a SolidJS context for holding a mutable reference to a `PromptRef`. The code is clean and follows existing patterns in the codebase (matches `exit.tsx`, `kv.tsx`, etc.). There are only minor style nits.
## Issues
### 1. Unnecessary type annotation on `set` parameter (line 13)
The `PromptRef | undefined` annotation on the `set` method parameter is redundant — the type of `current` already constrains what can be assigned. However, since `current` is a local `let` variable and not a typed field, the annotation here does serve as documentation for consumers of this context. This is borderline; the annotation is not harmful but could be dropped if you want maximal inference.
```tsx
// before (line 13)
set(ref: PromptRef | undefined) {
current = ref
},
// after — relies on inference from usage, but loses the import of PromptRef
// which makes the parameter type opaque to callers. Keep as-is.
```
**Verdict**: No change recommended. The annotation is justified here because it's part of a public API surface and the type can't be inferred from context alone.
### 2. `let` on line 7 — is `const` with a different pattern possible?
The style guide prefers `const` over `let`. However, this is a mutable ref holder — `let current` is the entire point of this context. There's no ternary or early-return that could replace it. A `const` wrapper (e.g., `const ref = { current: undefined as PromptRef | undefined }`) would be an alternative but is arguably worse:
```tsx
// alternative with const — not an improvement
const ref = { current: undefined as PromptRef | undefined }
return {
get current() {
return ref.current
},
set(r: PromptRef | undefined) {
ref.current = r
},
}
```
**Verdict**: No change recommended. `let` is the right tool here for a simple mutable binding.
### 3. Destructuring in the export (line 4)
The export destructures `createSimpleContext()`'s return value to rename `use``usePromptRef` and `provider``PromptRefProvider`. The style guide says "avoid unnecessary destructuring, use dot notation." However, this is an export-site rename, not a consumption-site destructure — dot notation isn't applicable since you can't rename exports via dot access. Every other context file in this directory (`exit.tsx`, `kv.tsx`, `route.tsx`, etc.) uses the exact same pattern.
```tsx
// current (line 4) — consistent with every other context file
export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({
```
**Verdict**: No change recommended. This is the established codebase pattern and the destructuring is necessary for the rename.
## Overall Assessment
This file is clean. It's 19 lines, follows the codebase conventions, matches the pattern of every sibling context file, and has no real issues. The `let` is justified, the type annotation is reasonable for an API boundary, and the export destructure is the standard pattern. No changes recommended.

View File

@@ -0,0 +1,160 @@
# Review: `packages/opencode/src/cli/cmd/tui/context/route.tsx`
## Summary
This is a small, well-structured file. There are only a few minor issues worth addressing - mostly around unnecessary destructuring, a stray `console.log`, and a type annotation that could be simplified.
## Issues
### 1. Unnecessary destructuring of `createStore` (line 21)
The `[store, setStore]` destructuring is fine here since both values are used, but the variable names could be shortened. More importantly, this is idiomatic SolidJS and acceptable as-is. No change needed.
### 2. Stray `console.log` left in (line 34)
This looks like a debug statement that was never removed. It will pollute terminal output on every navigation.
**Before (line 34):**
```tsx
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
```
**After:**
```tsx
navigate(route: Route) {
setStore(route)
},
```
**Why:** Debug logging left in production code adds noise. If logging is intentional, it should use the project's `Log.create()` pattern, not raw `console.log`.
### 3. Unnecessary type annotation on `useRouteData` parameter (line 43)
The generic constraint `T extends Route["type"]` is fine, but the `type` parameter's annotation `type: T` could be inferred. However, since this is an exported function signature, the explicit type is acceptable for clarity. That said, the `typeof type` in the return type is redundant - `T` already is the type.
**Before (lines 43-46):**
```tsx
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}
```
**After:**
```tsx
export function useRouteData<T extends Route["type"]>(_type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: T }>
}
```
**Why:** `typeof type` resolves to `T` anyway, so using `T` directly is clearer and avoids an unnecessary `typeof` indirection. Also, the `type` parameter is never actually used at runtime - it only exists to capture the generic `T`. Prefixing with `_` communicates that intent. Alternatively, this function could be removed entirely (see issue 5).
### 4. Unnecessary intermediate variable in `useRouteData` (line 44)
The `route` variable is used only once, so it can be inlined.
**Before (lines 43-46):**
```tsx
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}
```
**After:**
```tsx
export function useRouteData<T extends Route["type"]>(_type: T) {
return useRoute().data as Extract<Route, { type: T }>
}
```
**Why:** Per the style guide, reduce variable count by inlining when a value is only used once.
### 5. `useRouteData` may be dead or low-value code (lines 43-46)
This function takes a `type` parameter that is never used at runtime - it only serves as a generic type hint. The caller must already know which route type they're on, meaning this is just a cast helper. Consider whether callers could simply use `useRoute().data as SessionRoute` directly, which would be more explicit about the cast happening.
This isn't necessarily wrong, but it's worth verifying that this function is actually used, and if so, whether it provides enough value to justify its existence.
### 6. Inconsistent object formatting (lines 23-26)
Minor nitpick: the fallback object in the ternary has a trailing comma on the only property, which is fine but the closing brace alignment is slightly awkward due to the nesting inside `createStore()`.
**Before (lines 21-27):**
```tsx
const [store, setStore] = createStore<Route>(
process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
},
)
```
**After:**
```tsx
const [store, setStore] = createStore<Route>(
process.env["OPENCODE_ROUTE"] ? JSON.parse(process.env["OPENCODE_ROUTE"]) : { type: "home" },
)
```
**Why:** The object only has one property. Keeping it on a single line is more readable and reduces vertical noise.
## Combined suggested state
Applying all fixes, the file would look like:
```tsx
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
}
export type SessionRoute = {
type: "session"
sessionID: string
initialPrompt?: PromptInfo
}
export type Route = HomeRoute | SessionRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: () => {
const [store, setStore] = createStore<Route>(
process.env["OPENCODE_ROUTE"] ? JSON.parse(process.env["OPENCODE_ROUTE"]) : { type: "home" },
)
return {
get data() {
return store
},
navigate(route: Route) {
setStore(route)
},
}
},
})
export type RouteContext = ReturnType<typeof useRoute>
export function useRouteData<T extends Route["type"]>(_type: T) {
return useRoute().data as Extract<Route, { type: T }>
}
```

View File

@@ -0,0 +1,251 @@
# Review: `packages/opencode/src/cli/cmd/tui/context/sdk.tsx`
## Summary
This file is reasonably clean overall. It sets up an SDK context with event batching/flushing logic and SSE fallback. The main issues are: multiple `let` variables that form mutable state (acceptable here given the batching pattern), some unnecessary verbosity, an exported type that could be inlined, and a minor style inconsistency with `else`-like control flow. Most of the batching logic is well-structured and the file is short enough to be readable.
---
## Issues
### 1. Unnecessary exported type `EventSource` (lines 6-8)
The `EventSource` type is only used once, as the type of `props.events`. Defining and exporting it separately adds indirection. If nothing outside this file imports it, it should be inlined.
**Before:**
```tsx
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
}
// ... later in props:
events?: EventSource
```
**After:**
```tsx
// inline in props:
events?: {
on: (handler: (event: Event) => void) => () => void
}
```
Check whether `EventSource` is imported elsewhere first. If it is, keep the export but move it closer to where it's relevant or into a shared types file. If not, inline it and remove the export.
---
### 2. Unnecessary intermediate variable `events` (line 75)
The `events` variable is only used once on the very next line to access `.stream`. Inline it per the style guide ("reduce variable count by inlining when a value is only used once").
**Before (lines 75-82):**
```tsx
const events = await sdk.event.subscribe(
{},
{
signal: abort.signal,
},
)
for await (const event of events.stream) {
```
**After:**
```tsx
const response = await sdk.event.subscribe(
{},
{
signal: abort.signal,
},
)
for await (const event of response.stream) {
```
Actually, looking more carefully, the variable _is_ only used once. But renaming to `response` doesn't help. The real simplification is to just chain or keep the name short. This one is borderline - the multi-line `await` makes true inlining awkward. The current form is acceptable, though a shorter name like `sse` would be slightly better than the generic `events` which shadows the conceptual "events" used elsewhere in the function.
**Suggested:**
```tsx
const sse = await sdk.event.subscribe(
{},
{
signal: abort.signal,
},
)
for await (const event of sse.stream) {
```
This avoids confusion with the `queue` of `Event[]` also referred to as "events" on line 38.
---
### 3. Redundant `if` guard around `flush()` (lines 88-90)
`flush()` already has a guard `if (queue.length === 0) return` at line 37. The extra check on line 88 is redundant.
**Before (lines 87-90):**
```tsx
if (timer) clearTimeout(timer)
if (queue.length > 0) {
flush()
}
```
**After:**
```tsx
if (timer) clearTimeout(timer)
flush()
```
**Why:** `flush()` is already a no-op when the queue is empty. Removing the redundant guard reduces noise and avoids the reader wondering if there's a subtle reason for the double-check.
---
### 4. `while (true)` with `if (break)` instead of while condition (lines 73-74)
The `break` on a condition at the top of the loop is an `if/break` pattern that can be expressed as the loop condition directly.
**Before (lines 73-74):**
```tsx
while (true) {
if (abort.signal.aborted) break
```
**After:**
```tsx
while (!abort.signal.aborted) {
```
**Why:** Puts the termination condition where the reader expects it - in the loop header. Reduces one line and one level of indentation for the condition check.
---
### 5. `for...of` loop inside `batch()` could use `forEach` (lines 44-46)
The style guide prefers functional array methods over `for` loops. Since this is a simple iteration with a side effect (emitting), `forEach` is a natural fit and slightly more concise.
**Before (lines 43-47):**
```tsx
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
```
**After:**
```tsx
batch(() => {
events.forEach((event) => emitter.emit(event.type, event))
})
```
**Why:** More concise, consistent with the style guide's preference for functional array methods. The callback is a single expression, so the one-liner reads cleanly.
---
### 6. Variable name `last` is ambiguous (line 34)
`last` stores the timestamp of the last flush, but the name doesn't communicate that. In a file dealing with events and queues, `last` could mean many things.
**Before (line 34):**
```tsx
let last = 0
```
**After:**
```tsx
let flushed = 0
```
Then on line 41: `flushed = Date.now()` and line 52: `const elapsed = Date.now() - flushed`.
**Why:** `flushed` immediately communicates "the last time we flushed," making the elapsed-time calculation on line 52 self-documenting.
---
### 7. Unnecessary intermediate variable `unsub` (line 67)
Used only once on the next line. Inline it.
**Before (lines 67-68):**
```tsx
const unsub = props.events.on(handleEvent)
onCleanup(unsub)
```
**After:**
```tsx
onCleanup(props.events.on(handleEvent))
```
**Why:** Style guide says to reduce variable count by inlining when a value is only used once. The one-liner is still clear about what's happening.
---
### 8. Unnecessary intermediate variable `elapsed` (line 52)
Used only once on the next meaningful line. Could be inlined.
**Before (lines 52, 57):**
```tsx
const elapsed = Date.now() - last
if (timer) return
if (elapsed < 16) {
```
**After:**
```tsx
if (timer) return
if (Date.now() - last < 16) {
```
**Why:** `elapsed` is used exactly once. Inlining it puts the computation right where it's evaluated, reducing the variable count. The expression `Date.now() - last < 16` is simple enough to read inline.
---
### 9. The `flush` function reassigns `queue` via `let` (lines 32, 38-39)
The mutable `queue`/`timer`/`last` trio uses `let` with reassignment. This is a case where `let` is genuinely necessary (the batching pattern requires mutable state), so this is not a violation per se. However, an alternative pattern using a mutable object would use `const`:
**Alternative (not necessarily better, just noting):**
```tsx
const state = { queue: [] as Event[], timer: undefined as Timer | undefined, flushed: 0 }
```
This is a tradeoff - it trades three `let` bindings for one `const` object with mutable properties. The current approach with `let` is arguably clearer for this particular case since the variables are closely related but independently updated. **No change recommended** - just noting for completeness.
---
## Summary of Recommended Changes
| Priority | Line(s) | Issue |
| -------- | ------- | -------------------------------------------------------------- |
| Medium | 73-74 | `while (true)` + `if/break` -> `while (!abort.signal.aborted)` |
| Medium | 88-90 | Redundant `queue.length > 0` guard before `flush()` |
| Low | 67-68 | Inline `unsub` variable |
| Low | 75 | Rename `events` to `sse` to avoid ambiguity |
| Low | 34 | Rename `last` to `flushed` for clarity |
| Low | 52 | Inline `elapsed` variable |
| Low | 44-46 | `for...of` -> `forEach` |
| Low | 6-8 | Consider inlining `EventSource` type if not imported elsewhere |

Some files were not shown because too many files have changed in this diff Show More