mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-10 19:04:17 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
186ff7409c | ||
|
|
1e2f664410 | ||
|
|
a3aad9c9bf | ||
|
|
eb2587844b | ||
|
|
d863a9cf4e | ||
|
|
7d5be1556a | ||
|
|
659f15aa9b | ||
|
|
d1f5b9e911 | ||
|
|
284b00ff23 | ||
|
|
2c5760742b | ||
|
|
70c794e913 | ||
|
|
3929f0b5bd | ||
|
|
6f5dfe125a | ||
|
|
27fa9dc843 | ||
|
|
1e03a55acd | ||
|
|
65c9669283 | ||
|
|
18b6257119 | ||
|
|
c607c01fb9 | ||
|
|
4c4e30cd71 | ||
|
|
19ad7ad809 | ||
|
|
87795384de | ||
|
|
0732ab3393 | ||
|
|
2bccfd7462 | ||
|
|
83853cc5e6 | ||
|
|
4a73d51acd |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -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!**
|
||||
|
||||
|
||||
883
.opencode/agent/translator.md
Normal file
883
.opencode/agent/translator.md
Normal file
@@ -0,0 +1,883 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
[test]
|
||||
root = "./src"
|
||||
preload = ["./happydom.ts"]
|
||||
|
||||
140
packages/app/e2e/projects/workspace-new-session.spec.ts
Normal file
140
packages/app/e2e/projects/workspace-new-session.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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)))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,9 @@ 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}"]`
|
||||
|
||||
126
packages/app/e2e/session/session-undo-redo.spec.ts
Normal file
126
packages/app/e2e/session/session-undo-redo.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -89,7 +89,6 @@ 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
|
||||
@@ -115,9 +114,8 @@ const shutdown = (code: number, reason: string) => {
|
||||
}
|
||||
|
||||
const reportInternalError = (reason: string, error: unknown) => {
|
||||
internalError = true
|
||||
console.error(`e2e-local internal error: ${reason}`)
|
||||
console.error(error)
|
||||
console.warn(`e2e-local ignored server error: ${reason}`)
|
||||
console.warn(error)
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => shutdown(130, "SIGINT"))
|
||||
@@ -177,6 +175,4 @@ try {
|
||||
await cleanup()
|
||||
}
|
||||
|
||||
if (code === 0 && internalError) code = 1
|
||||
|
||||
process.exit(code)
|
||||
|
||||
@@ -6,6 +6,7 @@ let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@/context/file", () => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -20,11 +21,7 @@ import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
const encodedPath = filepath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")
|
||||
return `file://${encodedPath}`
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
|
||||
type Kind = "add" | "del" | "mix"
|
||||
@@ -223,12 +220,14 @@ export default function FileTree(props: {
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
return out.toSorted((a, b) => {
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
const Node = (
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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]%3A/)
|
||||
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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%3A/other/project/file.ts")
|
||||
expect(filePart.url).toContain("/D:/other/project/file.ts")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
|
||||
@@ -27,23 +28,11 @@ type BuildRequestPartsInput = {
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
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 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 fileQuery = (selection: FileSelection | undefined) =>
|
||||
|
||||
175
packages/app/src/components/prompt-input/submit.test.ts
Normal file
175
packages/app/src/components/prompt-input/submit.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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"])
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,7 @@ type PromptSubmitInput = {
|
||||
resetHistoryNavigation: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
setPopover: (popover: "at" | "slash" | null) => void
|
||||
newSessionWorktree?: string
|
||||
newSessionWorktree?: Accessor<string | undefined>
|
||||
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
|
||||
|
||||
@@ -74,7 +74,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let handleTextareaBlur: () => void
|
||||
let disposed = false
|
||||
const cleanups: VoidFunction[] = []
|
||||
let tail = local.pty.tail ?? ""
|
||||
const start =
|
||||
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
||||
let cursor = start ?? 0
|
||||
|
||||
const cleanup = () => {
|
||||
if (!cleanups.length) return
|
||||
@@ -164,13 +166,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const once = { value: false }
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||
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))
|
||||
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()
|
||||
})
|
||||
@@ -289,26 +294,6 @@ 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) {
|
||||
@@ -325,7 +310,6 @@ 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)
|
||||
}
|
||||
@@ -343,7 +327,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const handleOpen = () => {
|
||||
local.onConnect?.()
|
||||
if (sync) syncUntil = Date.now() + windowMs
|
||||
sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
@@ -357,31 +340,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
|
||||
|
||||
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)
|
||||
t.write(data)
|
||||
cursor += data.length
|
||||
}
|
||||
socket.addEventListener("message", handleMessage)
|
||||
cleanups.push(() => socket.removeEventListener("message", handleMessage))
|
||||
@@ -435,7 +418,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
props.onCleanup({
|
||||
...local.pty,
|
||||
buffer,
|
||||
tail,
|
||||
cursor,
|
||||
rows: t.rows,
|
||||
cols: t.cols,
|
||||
scrollY: t.getViewportY(),
|
||||
|
||||
@@ -6,6 +6,7 @@ let createCommentSessionForTest: typeof import("./comments").createCommentSessio
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
|
||||
@@ -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%3A/dev/projects/opencode/README.bs.md")
|
||||
expect(result).toBe("/D:/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%3A/dev/projects/opencode/README.bs.md")
|
||||
expect(result).toBe("/D:/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%3A/")
|
||||
expect(result).toBe("/C:/")
|
||||
})
|
||||
|
||||
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%3A/users/test/file.txt")
|
||||
expect(result).toBe("/c:/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]%3A\//)
|
||||
expect(result).toMatch(/^\/[A-Za-z]:\//)
|
||||
})
|
||||
|
||||
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%3A/path/file.txt")
|
||||
expect(result).toBe("/D:/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%3A")
|
||||
expect(result).toBe("/D:")
|
||||
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%3A/Users/test/")
|
||||
expect(result).toBe("/C:/Users/test/")
|
||||
})
|
||||
|
||||
test("should handle very long paths", () => {
|
||||
|
||||
@@ -90,9 +90,14 @@ 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) => encodeURIComponent(segment))
|
||||
.map((segment, index) => {
|
||||
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
|
||||
return encodeURIComponent(segment)
|
||||
})
|
||||
.join("/")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 }
|
||||
|
||||
@@ -184,11 +185,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
models.setVisibility(model, visible)
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
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() {
|
||||
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 []
|
||||
@@ -203,17 +220,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
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])
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
66
packages/app/src/context/model-variant.test.ts
Normal file
66
packages/app/src/context/model-variant.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
50
packages/app/src/context/model-variant.ts
Normal file
50
packages/app/src/context/model-variant.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => str
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
|
||||
@@ -13,7 +13,7 @@ export type LocalPTY = {
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
tail?: string
|
||||
cursor?: number
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
|
||||
@@ -25,7 +25,8 @@ export default function Home() {
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
const recent = createMemo(() => {
|
||||
return sync.data.project
|
||||
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
|
||||
@@ -1272,8 +1272,6 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
|
||||
setBusy(directory, false)
|
||||
dismiss()
|
||||
|
||||
@@ -1938,7 +1936,7 @@ export default function Layout(props: ParentProps) {
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={window.innerWidth * 0.3 + 64}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={layout.sidebar.resize}
|
||||
onCollapse={layout.sidebar.close}
|
||||
|
||||
@@ -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)).toSorted(sortSessions(now))
|
||||
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
|
||||
|
||||
export const childMapByParent = (sessions: Session[]) => {
|
||||
const map = new Map<string, string[]>()
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 + 5)
|
||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 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 + 5)
|
||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await globalSync.project.loadSessions(props.project.worktree)
|
||||
}
|
||||
|
||||
|
||||
@@ -1026,10 +1026,31 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
@@ -1041,7 +1062,7 @@ export default function Page() {
|
||||
diffStyle: layout.review.diffStyle(),
|
||||
onDiffStyleChange: layout.review.setDiffStyle,
|
||||
loadingClass: "px-6 py-4 text-text-weak",
|
||||
emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1569,7 +1590,7 @@ export default function Page() {
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
@@ -1683,7 +1704,7 @@ export default function Page() {
|
||||
direction="horizontal"
|
||||
size={layout.session.width()}
|
||||
min={450}
|
||||
max={window.innerWidth * 0.45}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
|
||||
onResize={layout.session.resize}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -228,6 +228,7 @@ export const createScrollSpy = (input: Input) => {
|
||||
node.delete(key)
|
||||
visible.delete(key)
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const markDirty = () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function TerminalPanel(props: {
|
||||
direction="vertical"
|
||||
size={props.height}
|
||||
min={100}
|
||||
max={window.innerHeight * 0.6}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={props.resize}
|
||||
onCollapse={props.close}
|
||||
|
||||
@@ -365,48 +365,81 @@ export const useSessionCommands = (input: {
|
||||
return [
|
||||
{
|
||||
id: "session.share",
|
||||
title: input.info()?.share?.url ? "Copy share link" : input.language.t("command.session.share"),
|
||||
title: input.info()?.share?.url
|
||||
? input.language.t("session.share.copy.copyLink")
|
||||
: input.language.t("command.session.share"),
|
||||
description: input.info()?.share?.url
|
||||
? "Copy share URL to clipboard"
|
||||
? input.language.t("toast.session.share.success.description")
|
||||
: 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 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)
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
await input.sdk.client.session
|
||||
|
||||
const url = await input.sdk.client.session
|
||||
.share({ sessionID: input.params.id })
|
||||
.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",
|
||||
}),
|
||||
)
|
||||
.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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -99,4 +99,9 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -195,6 +195,14 @@ 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"
|
||||
@@ -291,6 +299,7 @@ function localStorageDirect(): SyncStorage {
|
||||
export const PersistTesting = {
|
||||
localStorageDirect,
|
||||
localStorageWithPrefix,
|
||||
normalize,
|
||||
}
|
||||
|
||||
export const Persist = {
|
||||
@@ -358,12 +367,11 @@ export function persisted<T>(
|
||||
getItem: (key) => {
|
||||
const raw = current.getItem(key)
|
||||
if (raw !== 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)
|
||||
const next = normalize(defaults, raw, config.migrate)
|
||||
if (next === undefined) {
|
||||
current.removeItem(key)
|
||||
return null
|
||||
}
|
||||
if (raw !== next) current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
@@ -372,16 +380,13 @@ export function persisted<T>(
|
||||
const legacyRaw = legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
current.setItem(key, legacyRaw)
|
||||
const next = normalize(defaults, legacyRaw, config.migrate)
|
||||
if (next === undefined) {
|
||||
legacyStore.removeItem(legacyKey)
|
||||
continue
|
||||
}
|
||||
current.setItem(key, next)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -405,12 +410,11 @@ export function persisted<T>(
|
||||
getItem: async (key) => {
|
||||
const raw = await current.getItem(key)
|
||||
if (raw !== 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)
|
||||
const next = normalize(defaults, raw, config.migrate)
|
||||
if (next === undefined) {
|
||||
await current.removeItem(key).catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
if (raw !== next) await current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
@@ -421,16 +425,13 @@ export function persisted<T>(
|
||||
const legacyRaw = await legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
await current.setItem(key, legacyRaw)
|
||||
const next = normalize(defaults, legacyRaw, config.migrate)
|
||||
if (next === undefined) {
|
||||
await legacyStore.removeItem(legacyKey).catch(() => undefined)
|
||||
continue
|
||||
}
|
||||
await current.setItem(key, next)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@ export const dict = {
|
||||
"workspace.settings.edit": "Edit",
|
||||
|
||||
"workspace.billing.title": "Billing",
|
||||
"workspace.billing.subtitle.beforeLink": "Manage payments methods.",
|
||||
"workspace.billing.subtitle.beforeLink": "Manage payment methods.",
|
||||
"workspace.billing.contactUs": "Contact us",
|
||||
"workspace.billing.subtitle.afterLink": "if you have any questions.",
|
||||
"workspace.billing.currentBalance": "Current Balance",
|
||||
|
||||
@@ -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, legger vi automatisk til $20",
|
||||
"zen.how.step3.body": "når saldoen din når $5, fyller vi automatisk på $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 Taster",
|
||||
"workspace.nav.apiKeys": "API Nøkler",
|
||||
"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": "Spare",
|
||||
"workspace.providers.save": "Lagre",
|
||||
"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": "Koste",
|
||||
"workspace.usage.table.cost": "Kostnad",
|
||||
"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": "Koste",
|
||||
"workspace.cost.title": "Kostnad",
|
||||
"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 Taster",
|
||||
"workspace.keys.title": "API Nøkler",
|
||||
"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": "Spare",
|
||||
"workspace.members.save": "Lagre",
|
||||
"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": "Spare",
|
||||
"workspace.settings.save": "Lagre",
|
||||
"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": "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.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.enabled.state": "aktivert",
|
||||
"workspace.reload.enabled.middle": "Vi laster på nytt",
|
||||
"workspace.reload.enabled.middle": "Vi fyller på",
|
||||
"workspace.reload.processingFee": "behandlingsgebyr",
|
||||
"workspace.reload.enabled.after": "når balansen når",
|
||||
"workspace.reload.enabled.after": "når saldoen når",
|
||||
"workspace.reload.edit": "Redigere",
|
||||
"workspace.reload.enable": "Aktiver",
|
||||
"workspace.reload.enableAutoReload": "Aktiver automatisk reload",
|
||||
"workspace.reload.enableAutoReload": "Aktiver automatisk påfylling",
|
||||
"workspace.reload.reloadAmount": "Last inn $",
|
||||
"workspace.reload.whenBalanceReaches": "Når saldoen når $",
|
||||
"workspace.reload.saving": "Lagrer...",
|
||||
"workspace.reload.save": "Spare",
|
||||
"workspace.reload.save": "Lagre",
|
||||
"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": "Utsikt",
|
||||
"workspace.payments.view": "Vis",
|
||||
"workspace.black.loading": "Laster inn...",
|
||||
"workspace.black.time.day": "dag",
|
||||
"workspace.black.time.days": "dager",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export class AuthError extends Error {}
|
||||
export class CreditsError extends Error {}
|
||||
export class MonthlyLimitError extends Error {}
|
||||
export class SubscriptionError extends Error {
|
||||
export class UserLimitError extends Error {}
|
||||
export class ModelError extends Error {}
|
||||
export class FreeUsageLimitError extends Error {}
|
||||
export class SubscriptionUsageLimitError extends Error {
|
||||
retryAfter?: number
|
||||
constructor(message: string, retryAfter?: number) {
|
||||
super(message)
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
export class UserLimitError extends Error {}
|
||||
export class ModelError extends Error {}
|
||||
export class RateLimitError extends Error {}
|
||||
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
AuthError,
|
||||
CreditsError,
|
||||
MonthlyLimitError,
|
||||
SubscriptionError,
|
||||
UserLimitError,
|
||||
ModelError,
|
||||
RateLimitError,
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
} from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
@@ -52,7 +52,8 @@ export async function handler(
|
||||
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
|
||||
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const MAX_FAILOVER_RETRIES = 3
|
||||
const MAX_429_RETRIES = 3
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
@@ -111,7 +112,7 @@ export async function handler(
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
|
||||
const res = await fetch(reqUrl, {
|
||||
const res = await fetchWith429Retry(reqUrl, {
|
||||
method: "POST",
|
||||
headers: (() => {
|
||||
const headers = new Headers(input.request.headers)
|
||||
@@ -304,9 +305,9 @@ export async function handler(
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
|
||||
if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) {
|
||||
const headers = new Headers()
|
||||
if (error instanceof SubscriptionError && error.retryAfter) {
|
||||
if (error instanceof SubscriptionUsageLimitError && error.retryAfter) {
|
||||
headers.set("retry-after", String(error.retryAfter))
|
||||
}
|
||||
return new Response(
|
||||
@@ -369,7 +370,7 @@ export async function handler(
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (retry.retryCount === MAX_RETRIES) {
|
||||
if (retry.retryCount === MAX_FAILOVER_RETRIES) {
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
||||
}
|
||||
|
||||
@@ -520,7 +521,7 @@ export async function handler(
|
||||
timeUpdated: sub.timeFixedUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
@@ -534,7 +535,7 @@ export async function handler(
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
@@ -597,6 +598,15 @@ 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,
|
||||
|
||||
@@ -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 { RateLimitError } from "./error"
|
||||
import { FreeUsageLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
|
||||
)
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||
logger.debug(`rate limit total: ${total}`)
|
||||
if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
|
||||
if (total >= limitValue) throw new FreeUsageLimitError(`Rate limit exceeded. Please try again later.`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,15 @@ 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
|
||||
@@ -169,7 +178,7 @@ export function initI18n(): Promise<Locale> {
|
||||
if (!store) return state.locale
|
||||
|
||||
const raw = await store.get("language").catch(() => null)
|
||||
const value = typeof raw === "string" ? JSON.parse(raw) : raw
|
||||
const value = parseStored(raw)
|
||||
const next = pickLocale(value) ?? state.locale
|
||||
|
||||
state.locale = next
|
||||
|
||||
@@ -1,584 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,220 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,315 +0,0 @@
|
||||
# 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={() => {}} />
|
||||
}
|
||||
```
|
||||
@@ -33,12 +33,6 @@ 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) => {
|
||||
@@ -65,7 +59,12 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
}
|
||||
|
||||
const favoriteOptions = toOptions(favorites, "Favorites")
|
||||
const recentOptions = toOptions(recentList, "Recent")
|
||||
const recentOptions = toOptions(
|
||||
recents.filter(
|
||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||
),
|
||||
"Recent",
|
||||
)
|
||||
|
||||
const providerOptions = pipe(
|
||||
sync.data.provider,
|
||||
@@ -120,11 +119,11 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
: []
|
||||
|
||||
// Search shows a single merged list (favorites inline)
|
||||
if (needle) {
|
||||
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 [
|
||||
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
|
||||
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
|
||||
]
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||
@@ -157,6 +156,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
},
|
||||
]}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
title={title()}
|
||||
current={local.model.current()}
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,278 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,372 +0,0 @@
|
||||
# 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} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,138 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,244 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,385 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,154 +0,0 @@
|
||||
# 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
|
||||
@@ -1,157 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,220 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,489 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,336 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,263 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,641 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,257 +0,0 @@
|
||||
# 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) |
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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 4–5, 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"
|
||||
```
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,173 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,176 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,174 +0,0 @@
|
||||
# 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
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,205 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,288 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,481 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,160 +0,0 @@
|
||||
# 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 }>
|
||||
}
|
||||
```
|
||||
@@ -1,251 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,385 +0,0 @@
|
||||
# Review: `sync.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file implements a SolidJS store + event sync layer for the TUI. The event handler switch is reasonable in structure, but `bootstrap()` is a tangled mess of redundant `.then()` chains, unnecessary re-awaiting of already-resolved promises, and gratuitous destructuring. There are also scattered style guide violations throughout: `let`-style patterns via mutable arrays, unnecessary intermediate variables, destructuring where dot notation would suffice, and a `for` loop where a functional method would work.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary intermediate variable `event` (line 108)
|
||||
|
||||
`event` is just an alias for `e.details` and adds an extra name for no reason.
|
||||
|
||||
**Before (line 107-109):**
|
||||
|
||||
```tsx
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
sdk.event.listen((e) => {
|
||||
switch (e.details.type) {
|
||||
```
|
||||
|
||||
Then replace all `event.properties` with `e.details.properties` and `event.type` with `e.details.type` throughout the handler. Alternatively, name the callback parameter `event` directly:
|
||||
|
||||
```tsx
|
||||
sdk.event.listen(({ details: event }) => {
|
||||
switch (event.type) {
|
||||
```
|
||||
|
||||
**Why:** Reduces variable count. The style guide says to inline when a value is only used to access properties. That said, `event` is used many times, so the destructured-parameter form is the cleanest option here -- it avoids a new line while keeping the short name.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary destructuring in `permission.asked` and `question.asked` (lines 129, 167)
|
||||
|
||||
`request` is destructured from `event.properties` just to save characters, but the style guide says to prefer dot notation over destructuring.
|
||||
|
||||
**Before (line 129-130):**
|
||||
|
||||
```tsx
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
const requests = store.permission[request.sessionID]
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
case "permission.asked": {
|
||||
const requests = store.permission[event.properties.sessionID]
|
||||
```
|
||||
|
||||
Then use `event.properties` directly in place of `request` throughout the case block. Same for `question.asked` at line 167.
|
||||
|
||||
**Why:** The style guide explicitly says avoid unnecessary destructuring, use dot notation. `request` is just an alias for `event.properties`.
|
||||
|
||||
---
|
||||
|
||||
### 3. `bootstrap()` re-awaits already-resolved promises (lines 352-370)
|
||||
|
||||
After `Promise.all(blockingRequests)` resolves, all the individual promises (`providersPromise`, etc.) are already settled. The code then calls `.then()` on each one _again_ to extract `.data`, wraps those in _another_ `Promise.all`, then destructures the results by index. This is convoluted.
|
||||
|
||||
**Before (lines 351-381):**
|
||||
|
||||
```tsx
|
||||
await Promise.all(blockingRequests).then(() => {
|
||||
const providersResponse = providersPromise.then((x) => x.data!)
|
||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
|
||||
const configResponse = configPromise.then((x) => x.data!)
|
||||
const sessionListResponse = args.continue ? sessionListPromise : undefined
|
||||
|
||||
return Promise.all([
|
||||
providersResponse,
|
||||
providerListResponse,
|
||||
agentsResponse,
|
||||
configResponse,
|
||||
...(sessionListResponse ? [sessionListResponse] : []),
|
||||
]).then((responses) => {
|
||||
const providers = responses[0]
|
||||
const providerList = responses[1]
|
||||
const agents = responses[2]
|
||||
const config = responses[3]
|
||||
const sessions = responses[4]
|
||||
|
||||
batch(() => {
|
||||
setStore("provider", reconcile(providers.providers))
|
||||
setStore("provider_default", reconcile(providers.default))
|
||||
setStore("provider_next", reconcile(providerList))
|
||||
setStore("agent", reconcile(agents))
|
||||
setStore("config", reconcile(config))
|
||||
if (sessions !== undefined) setStore("session", reconcile(sessions))
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Since the promises are already resolved, just await them directly:
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const [providers, providerList, agents, config] = await Promise.all([
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
])
|
||||
|
||||
const sessions = args.continue ? await sessionListPromise : undefined
|
||||
|
||||
batch(() => {
|
||||
setStore("provider", reconcile(providers.data!.providers))
|
||||
setStore("provider_default", reconcile(providers.data!.default))
|
||||
setStore("provider_next", reconcile(providerList.data!))
|
||||
setStore("agent", reconcile(agents.data ?? []))
|
||||
setStore("config", reconcile(config.data!))
|
||||
if (sessions !== undefined) setStore("session", reconcile(sessions))
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** The original creates 5 unnecessary intermediate variables, 2 unnecessary `Promise.all` calls, and 2 unnecessary `.then()` chains for promises that are already settled. This is the biggest readability problem in the file.
|
||||
|
||||
---
|
||||
|
||||
### 4. Chained `.then()` where `async`/`await` would be clearer (lines 351-409)
|
||||
|
||||
The entire `bootstrap()` function is `async` but uses `.then().then().catch()` chaining instead of `await` + `try`/`catch`. Normally we avoid `try`/`catch`, but the current `.then().then().catch()` chain is harder to follow than either approach. Since the error handling calls `exit()`, a top-level catch is reasonable here.
|
||||
|
||||
**Before (lines 351-409):**
|
||||
|
||||
```tsx
|
||||
await Promise.all(blockingRequests)
|
||||
.then(() => {
|
||||
// ... 30 lines of re-awaiting
|
||||
})
|
||||
.then(() => {
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
// non-blocking
|
||||
Promise.all([...]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
})
|
||||
.catch(async (e) => {
|
||||
Log.Default.error("tui bootstrap failed", { ... })
|
||||
await exit(e)
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
async function bootstrap() {
|
||||
console.log("bootstrapping")
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
|
||||
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
|
||||
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({}, { throwOnError: true })
|
||||
|
||||
const [providers, providerList, agents, config] = await Promise.all([
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
]).catch(async (e) => {
|
||||
Log.Default.error("tui bootstrap failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
name: e instanceof Error ? e.name : undefined,
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
await exit(e)
|
||||
throw e // unreachable but satisfies types
|
||||
})
|
||||
|
||||
const sessions = args.continue ? await sessionListPromise : undefined
|
||||
|
||||
batch(() => {
|
||||
setStore("provider", reconcile(providers.data!.providers))
|
||||
setStore("provider_default", reconcile(providers.data!.default))
|
||||
setStore("provider_next", reconcile(providerList.data!))
|
||||
setStore("agent", reconcile(agents.data ?? []))
|
||||
setStore("config", reconcile(config.data!))
|
||||
if (sessions !== undefined) setStore("session", reconcile(sessions))
|
||||
})
|
||||
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
|
||||
// non-blocking
|
||||
Promise.all([
|
||||
...(args.continue ? [] : [sessionListPromise.then((s) => setStore("session", reconcile(s)))]),
|
||||
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.session.status().then((x) => setStore("session_status", reconcile(x.data!))),
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Flat async/await is easier to follow than nested `.then()` chains. The original has 3 levels of `.then()` nesting which makes the control flow hard to trace.
|
||||
|
||||
---
|
||||
|
||||
### 5. Unnecessary shorthand `{ start: start }` (line 335)
|
||||
|
||||
**Before (line 334-335):**
|
||||
|
||||
```tsx
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session.list({ start: start })
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session.list({ start })
|
||||
```
|
||||
|
||||
**Why:** Redundant property name. ES6 shorthand is cleaner.
|
||||
|
||||
---
|
||||
|
||||
### 6. Mutable array used for `blockingRequests` (lines 343-349)
|
||||
|
||||
`blockingRequests` is a `const` array but is built with a spread conditional. This is fine syntactically, but the variable itself is only used once on the very next line. It should be inlined.
|
||||
|
||||
**Before (lines 343-351):**
|
||||
|
||||
```tsx
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
]
|
||||
|
||||
await Promise.all(blockingRequests)
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
await Promise.all([
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
])
|
||||
```
|
||||
|
||||
**Why:** Style guide says to inline when a value is only used once. Also removes an explicit type annotation (`Promise<unknown>[]`) that only exists because the intermediate variable needs it.
|
||||
|
||||
---
|
||||
|
||||
### 7. `for` loop in `session.sync` (line 457)
|
||||
|
||||
**Before (lines 456-459):**
|
||||
|
||||
```tsx
|
||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
||||
messages.data!.forEach((x) => {
|
||||
draft.part[x.info.id] = x.parts
|
||||
})
|
||||
```
|
||||
|
||||
Alternatively, since this is inside a `produce` and we're doing side effects (mutations), the `for` loop is arguably acceptable here. But the style guide says to prefer functional array methods. Either way, this is a minor point.
|
||||
|
||||
**Why:** Style guide prefers functional array methods over `for` loops.
|
||||
|
||||
---
|
||||
|
||||
### 8. `console.log` left in `bootstrap` (line 332)
|
||||
|
||||
**Before (line 332):**
|
||||
|
||||
```tsx
|
||||
console.log("bootstrapping")
|
||||
```
|
||||
|
||||
The codebase has a `Log` utility. This should either use `Log.Default.info(...)` or be removed.
|
||||
|
||||
**Why:** Inconsistent with the rest of the file which uses `Log.Default.error` at line 403. Stray `console.log` calls look like debugging leftovers.
|
||||
|
||||
---
|
||||
|
||||
### 9. Unnecessary explicit type annotation on the store (lines 35-76)
|
||||
|
||||
The store's type is a 40-line inline type annotation. This is a necessary evil since `createStore` needs to know the shape, and the initial value has empty arrays/objects that can't infer the element types. However, the annotation could be extracted to a named type alias above to keep the `createStore` call readable.
|
||||
|
||||
This is not strictly a violation but a readability suggestion. The `init` function is already very long; extracting the type would help.
|
||||
|
||||
---
|
||||
|
||||
### 10. Repeated `event.properties.sessionID` / `event.properties.info` (throughout)
|
||||
|
||||
Several case blocks repeatedly access `event.properties.sessionID` or `event.properties.info` 3-4 times. For example in `message.updated` (lines 228-265), `event.properties.info` is referenced 6 times. This is a tension with the "no destructuring" rule. Given the repetition, a local alias here is justified -- but it should be for `event.properties.info`, not a destructuring.
|
||||
|
||||
**Before (lines 228-232):**
|
||||
|
||||
```tsx
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
case "message.updated": {
|
||||
const msg = event.properties.info
|
||||
const messages = store.message[msg.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", msg.sessionID, [msg])
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** When a deeply nested path is accessed 6+ times, a short alias improves readability without violating the spirit of "avoid destructuring." This isn't destructuring -- it's a named reference to a nested object.
|
||||
|
||||
---
|
||||
|
||||
### 11. Inconsistent callback parameter naming in `.then()` chains
|
||||
|
||||
The non-blocking section uses `(x)` uniformly (line 386-397), which is fine. But the `session.status` callback at line 392-394 has an unnecessary block body:
|
||||
|
||||
**Before (lines 392-394):**
|
||||
|
||||
```tsx
|
||||
sdk.client.session.status().then((x) => {
|
||||
setStore("session_status", reconcile(x.data!))
|
||||
}),
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
sdk.client.session.status().then((x) => setStore("session_status", reconcile(x.data!))),
|
||||
```
|
||||
|
||||
**Why:** Every other `.then()` in the same block uses a concise arrow. This one has braces and a newline for no reason. Consistency matters.
|
||||
|
||||
---
|
||||
|
||||
## Priority
|
||||
|
||||
1. **High -- `bootstrap()` rewrite (issues 3, 4, 6):** The nested `.then()` chains with redundant re-awaiting of settled promises is the single biggest quality problem. It makes the most critical function in the file unnecessarily hard to follow.
|
||||
2. **Medium -- Alias `event.properties.info` where used heavily (issue 10):** Reduces noise in the longest case blocks.
|
||||
3. **Medium -- Remove `console.log` or use `Log` (issue 8):** Consistency with existing patterns.
|
||||
4. **Low -- Everything else:** Minor style nits that improve consistency but don't affect comprehension significantly.
|
||||
@@ -1,384 +0,0 @@
|
||||
# Code Review: `theme.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is functional and well-structured at a high level, but has several style guide violations and readability issues. The main problems are: unnecessary destructuring, `let` where `const` with ternary would work, `else` branches instead of early returns, leftover `console.log` debug statements, a `@ts-expect-error` suppression that hides a typing issue, and duplicated markup heading rules. The `generateGrayScale` function is the worst offender with multiple `let` reassignments and nested `if/else` branches.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Leftover `console.log` debug statements (lines 319, 325)
|
||||
|
||||
Debug logging that should not be in production code.
|
||||
|
||||
```tsx
|
||||
// Before (line 319, 325)
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolveSystemTheme")
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
console.log(colors.palette)
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
function resolveSystemTheme() {
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
```
|
||||
|
||||
**Why:** Console logs are noise in production. They clutter terminal output for end users.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary destructuring in `selectedForeground` (line 114)
|
||||
|
||||
```tsx
|
||||
// Before (line 114)
|
||||
const { r, g, b } = targetColor
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
const luminance = 0.299 * targetColor.r + 0.587 * targetColor.g + 0.114 * targetColor.b
|
||||
```
|
||||
|
||||
**Why:** Style guide says to avoid unnecessary destructuring and use dot notation to preserve context. `targetColor.r` is clearer about what `r` belongs to.
|
||||
|
||||
---
|
||||
|
||||
### 3. `else` branches in `resolveColor` instead of early returns (lines 185-191)
|
||||
|
||||
```tsx
|
||||
// Before (lines 185-191)
|
||||
if (defs[c] != null) {
|
||||
return resolveColor(defs[c])
|
||||
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
|
||||
return resolveColor(theme.theme[c as keyof ThemeColors]!)
|
||||
} else {
|
||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
if (defs[c] != null) return resolveColor(defs[c])
|
||||
if (theme.theme[c as keyof ThemeColors] !== undefined) return resolveColor(theme.theme[c as keyof ThemeColors]!)
|
||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||
```
|
||||
|
||||
**Why:** Each branch returns, so `else if` and `else` are unnecessary. Flattening the chain makes the control flow easier to scan.
|
||||
|
||||
---
|
||||
|
||||
### 4. `else` branches in `resolveTheme` for optional fields (lines 208-222)
|
||||
|
||||
```tsx
|
||||
// Before (lines 208-222)
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
if (hasSelectedListItemText) {
|
||||
resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
|
||||
} else {
|
||||
resolved.selectedListItemText = resolved.background
|
||||
}
|
||||
|
||||
if (theme.theme.backgroundMenu !== undefined) {
|
||||
resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
|
||||
} else {
|
||||
resolved.backgroundMenu = resolved.backgroundElement
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
resolved.selectedListItemText = hasSelectedListItemText
|
||||
? resolveColor(theme.theme.selectedListItemText!)
|
||||
: resolved.background
|
||||
|
||||
resolved.backgroundMenu =
|
||||
theme.theme.backgroundMenu !== undefined ? resolveColor(theme.theme.backgroundMenu) : resolved.backgroundElement
|
||||
```
|
||||
|
||||
**Why:** These are simple value assignments, not control flow. Ternaries are more concise and eliminate the `else` branches the style guide discourages.
|
||||
|
||||
---
|
||||
|
||||
### 5. `@ts-expect-error` suppression on Proxy (line 364)
|
||||
|
||||
```tsx
|
||||
// Before (lines 362-367)
|
||||
theme: new Proxy(values(), {
|
||||
get(_target, prop) {
|
||||
// @ts-expect-error
|
||||
return values()[prop]
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
theme: new Proxy(values(), {
|
||||
get(_target, prop) {
|
||||
return values()[prop as keyof Theme]
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
**Why:** The `@ts-expect-error` hides a real type issue. Casting `prop` to `keyof Theme` is more precise and removes the suppression. The style guide says to avoid `any` and type-unsafe patterns.
|
||||
|
||||
---
|
||||
|
||||
### 6. `generateGrayScale` uses `let` excessively where `const` with ternary/early-return would work (lines 537-589)
|
||||
|
||||
This function is the most problematic in the file. It uses `let` for 4 variables and nested `if/else` branches.
|
||||
|
||||
```tsx
|
||||
// Before (lines 547-586)
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const factor = i / 12.0
|
||||
|
||||
let grayValue: number
|
||||
let newR: number
|
||||
let newG: number
|
||||
let newB: number
|
||||
|
||||
if (isDark) {
|
||||
if (luminance < 10) {
|
||||
grayValue = Math.floor(factor * 0.4 * 255)
|
||||
newR = grayValue
|
||||
newG = grayValue
|
||||
newB = grayValue
|
||||
} else {
|
||||
const newLum = luminance + (255 - luminance) * factor * 0.4
|
||||
const ratio = newLum / luminance
|
||||
newR = Math.min(bgR * ratio, 255)
|
||||
newG = Math.min(bgG * ratio, 255)
|
||||
newB = Math.min(bgB * ratio, 255)
|
||||
}
|
||||
} else {
|
||||
if (luminance > 245) {
|
||||
grayValue = Math.floor(255 - factor * 0.4 * 255)
|
||||
newR = grayValue
|
||||
newG = grayValue
|
||||
newB = grayValue
|
||||
} else {
|
||||
const newLum = luminance * (1 - factor * 0.4)
|
||||
const ratio = newLum / luminance
|
||||
newR = Math.max(bgR * ratio, 0)
|
||||
newG = Math.max(bgG * ratio, 0)
|
||||
newB = Math.max(bgB * ratio, 0)
|
||||
}
|
||||
}
|
||||
|
||||
grays[i] = RGBA.fromInts(Math.floor(newR), Math.floor(newG), Math.floor(newB))
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After - extract a helper and use early returns
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
grays[i] = grayAt(i / 12.0, bgR, bgG, bgB, luminance, isDark)
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
function grayAt(factor: number, bgR: number, bgG: number, bgB: number, luminance: number, isDark: boolean): RGBA {
|
||||
if (isDark && luminance < 10) {
|
||||
const v = Math.floor(factor * 0.4 * 255)
|
||||
return RGBA.fromInts(v, v, v)
|
||||
}
|
||||
if (isDark) {
|
||||
const ratio = (luminance + (255 - luminance) * factor * 0.4) / luminance
|
||||
return RGBA.fromInts(
|
||||
Math.floor(Math.min(bgR * ratio, 255)),
|
||||
Math.floor(Math.min(bgG * ratio, 255)),
|
||||
Math.floor(Math.min(bgB * ratio, 255)),
|
||||
)
|
||||
}
|
||||
if (luminance > 245) {
|
||||
const v = Math.floor(255 - factor * 0.4 * 255)
|
||||
return RGBA.fromInts(v, v, v)
|
||||
}
|
||||
const ratio = (luminance * (1 - factor * 0.4)) / luminance
|
||||
return RGBA.fromInts(
|
||||
Math.floor(Math.max(bgR * ratio, 0)),
|
||||
Math.floor(Math.max(bgG * ratio, 0)),
|
||||
Math.floor(Math.max(bgB * ratio, 0)),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Eliminates all 4 `let` declarations, removes nested `if/else`, and uses early returns. Each case is now a clear, self-contained branch. The `grayValue` variable was just an intermediate that assigned the same value to R, G, and B -- inlining it into a single `v` removes the indirection.
|
||||
|
||||
---
|
||||
|
||||
### 7. `generateMutedTextColor` uses `let` with `if/else` (lines 599-617)
|
||||
|
||||
```tsx
|
||||
// Before (lines 599-617)
|
||||
let grayValue: number
|
||||
|
||||
if (isDark) {
|
||||
if (bgLum < 10) {
|
||||
grayValue = 180
|
||||
} else {
|
||||
grayValue = Math.min(Math.floor(160 + bgLum * 0.3), 200)
|
||||
}
|
||||
} else {
|
||||
if (bgLum > 245) {
|
||||
grayValue = 75
|
||||
} else {
|
||||
grayValue = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
|
||||
}
|
||||
}
|
||||
|
||||
return RGBA.fromInts(grayValue, grayValue, grayValue)
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After - early returns, no let
|
||||
if (isDark) {
|
||||
const v = bgLum < 10 ? 180 : Math.min(Math.floor(160 + bgLum * 0.3), 200)
|
||||
return RGBA.fromInts(v, v, v)
|
||||
}
|
||||
const v = bgLum > 245 ? 75 : Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
|
||||
return RGBA.fromInts(v, v, v)
|
||||
```
|
||||
|
||||
**Why:** Replaces `let` + 4-branch `if/else` with `const` + ternaries and an early return. Each mode (dark/light) is handled in 2 lines instead of 8.
|
||||
|
||||
---
|
||||
|
||||
### 8. Unnecessary destructuring in `generateGrayScale` (lines 541-543)
|
||||
|
||||
```tsx
|
||||
// Before (lines 541-543)
|
||||
const bgR = bg.r * 255
|
||||
const bgG = bg.g * 255
|
||||
const bgB = bg.b * 255
|
||||
```
|
||||
|
||||
This is borderline acceptable because the values are used many times in the loop, but the variable names (`bgR`, `bgG`, `bgB`) are effectively just `bg.r * 255` etc. If the function is refactored per issue #6 to pass these as arguments to a helper, this becomes fine. Noting for awareness but not a hard blocker.
|
||||
|
||||
---
|
||||
|
||||
### 9. Duplicated markup heading rules (lines 858-906)
|
||||
|
||||
`markup.heading` through `markup.heading.6` all have the identical style. This could be a single rule.
|
||||
|
||||
```tsx
|
||||
// Before (lines 858-906)
|
||||
{
|
||||
scope: ["markup.heading"],
|
||||
style: {
|
||||
foreground: theme.markdownHeading,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.heading.1"],
|
||||
style: {
|
||||
foreground: theme.markdownHeading,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
// ... repeated 5 more times for .2 through .6
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
{
|
||||
scope: [
|
||||
"markup.heading",
|
||||
"markup.heading.1",
|
||||
"markup.heading.2",
|
||||
"markup.heading.3",
|
||||
"markup.heading.4",
|
||||
"markup.heading.5",
|
||||
"markup.heading.6",
|
||||
],
|
||||
style: {
|
||||
foreground: theme.markdownHeading,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**Why:** Reduces ~50 lines to ~13 lines. The style is identical for all heading levels; duplicating the rule objects is pure noise.
|
||||
|
||||
---
|
||||
|
||||
### 10. Unnecessary explicit type annotation on `result` (line 406)
|
||||
|
||||
```tsx
|
||||
// Before (line 406)
|
||||
const result: Record<string, ThemeJson> = {}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
const result = {} as Record<string, ThemeJson>
|
||||
```
|
||||
|
||||
**Why:** Minor, but the explicit annotation form is slightly more verbose. An `as` assertion is equivalent here since the object is immediately populated. Either form is acceptable; this is the weakest issue in the list.
|
||||
|
||||
---
|
||||
|
||||
### 11. Unnecessary return in `resolveTheme` filter/map (lines 199-204)
|
||||
|
||||
```tsx
|
||||
// Before (lines 199-204)
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => {
|
||||
return [key, resolveColor(value as ColorValue)]
|
||||
}),
|
||||
) as Partial<ThemeColors>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
|
||||
) as Partial<ThemeColors>
|
||||
```
|
||||
|
||||
**Why:** The `.map` callback has an unnecessary block body with explicit `return`. An arrow with implicit return is shorter and idiomatic for simple transforms.
|
||||
|
||||
---
|
||||
|
||||
### 12. `useRenderer()` called after it's used (lines 348 vs 320)
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
function resolveSystemTheme() { // line 318 - uses `renderer`
|
||||
renderer.getPalette(...) // line 320
|
||||
}
|
||||
|
||||
const renderer = useRenderer() // line 348 - declared after usage
|
||||
```
|
||||
|
||||
This works due to hoisting in closures (the function isn't called until `onMount`), but it's confusing to read. The `renderer` declaration should be moved above `resolveSystemTheme` for clarity.
|
||||
|
||||
```tsx
|
||||
// After - move line 348 to before function resolveSystemTheme()
|
||||
const renderer = useRenderer()
|
||||
|
||||
function resolveSystemTheme() {
|
||||
renderer.getPalette(...)
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Reading top-to-bottom, encountering `renderer` before it's declared forces the reader to scan ahead. Declaring it first matches the reading order.
|
||||
@@ -1,370 +0,0 @@
|
||||
# Code Review: `packages/opencode/src/cli/cmd/tui/routes/home.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is reasonably compact at 141 lines and the overall structure is clear. There are several style guide violations and cleanup opportunities: dead code (unused import and variable), an `else if` chain that should use early returns, an unnecessary intermediate memo, scattered hook calls that hurt readability, and a `let` that could potentially be avoided. Most issues are minor but they accumulate.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Dead import and unused variable: `useKeybind` / `keybind` (lines 4, 92)
|
||||
|
||||
`useKeybind` is imported and called, but the resulting `keybind` variable is never referenced anywhere in the component. This is dead code.
|
||||
|
||||
```tsx
|
||||
// Before (line 4)
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
|
||||
// After
|
||||
// Remove entirely
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Before (line 92)
|
||||
const keybind = useKeybind()
|
||||
|
||||
// After
|
||||
// Remove entirely
|
||||
```
|
||||
|
||||
**Why:** Dead code is noise. It makes readers wonder what they're missing and increases the surface area for confusion during future edits.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary intermediate memo: `isFirstTimeUser` (line 37)
|
||||
|
||||
`isFirstTimeUser` is only consumed inside `showTips`. It doesn't need to be its own named memo — it can be inlined. Per the style guide: "Reduce total variable count by inlining when a value is only used once."
|
||||
|
||||
```tsx
|
||||
// Before (lines 37-43)
|
||||
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
|
||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
||||
const showTips = createMemo(() => {
|
||||
// Don't show tips for first-time users
|
||||
if (isFirstTimeUser()) return false
|
||||
return !tipsHidden()
|
||||
})
|
||||
|
||||
// After
|
||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
||||
const showTips = createMemo(() => {
|
||||
if (sync.data.session.length === 0) return false
|
||||
return !tipsHidden()
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Eliminates a variable that exists only to be read once. The condition `sync.data.session.length === 0` is already self-documenting in context.
|
||||
|
||||
---
|
||||
|
||||
### 3. `else if` chain in `onMount` callback (lines 79-88)
|
||||
|
||||
The style guide says "Avoid `else` statements. Prefer early returns." The `onMount` callback uses `else if` where sequential early returns would be cleaner.
|
||||
|
||||
```tsx
|
||||
// Before (lines 79-88)
|
||||
onMount(() => {
|
||||
if (once) return
|
||||
if (route.initialPrompt) {
|
||||
prompt.set(route.initialPrompt)
|
||||
once = true
|
||||
} else if (args.prompt) {
|
||||
prompt.set({ input: args.prompt, parts: [] })
|
||||
once = true
|
||||
prompt.submit()
|
||||
}
|
||||
})
|
||||
|
||||
// After
|
||||
onMount(() => {
|
||||
if (once) return
|
||||
if (route.initialPrompt) {
|
||||
prompt.set(route.initialPrompt)
|
||||
once = true
|
||||
return
|
||||
}
|
||||
if (args.prompt) {
|
||||
prompt.set({ input: args.prompt, parts: [] })
|
||||
once = true
|
||||
prompt.submit()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Flat control flow is easier to scan. Each branch is independent and self-contained with an early return, rather than implicitly guarded by an `else`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Scattered / disorganized hook calls (lines 22-28, 78, 90, 92)
|
||||
|
||||
Hook calls and variable declarations are scattered throughout the function body with logic interleaved between them. `useArgs()` is called on line 78, `useDirectory()` on line 90, `useKeybind()` on line 92 — all far from the initial block of hooks at lines 22-27. Grouping all hooks at the top makes the component's dependencies immediately visible.
|
||||
|
||||
```tsx
|
||||
// Before (scattered across lines 22-28, 78, 90, 92)
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const command = useCommandDialog()
|
||||
// ... 50 lines of logic ...
|
||||
let prompt: PromptRef
|
||||
const args = useArgs()
|
||||
// ... onMount ...
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
|
||||
// After (grouped at top)
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const command = useCommandDialog()
|
||||
const args = useArgs()
|
||||
const directory = useDirectory()
|
||||
```
|
||||
|
||||
**Why:** Grouping hooks at the top is the standard convention for component readability. When hooks are scattered, you have to read the entire function to understand the component's dependencies. Note: SolidJS doesn't enforce hook ordering rules like React, but grouping them is still better for readability.
|
||||
|
||||
---
|
||||
|
||||
### 5. `let prompt: PromptRef` with type annotation (line 77)
|
||||
|
||||
This uses `let` with an explicit type annotation. The `let` is required here because the value is assigned inside a JSX ref callback, so it can't be a `const`. However, the explicit `: PromptRef` type annotation is unnecessary — TypeScript can infer it from the ref callback usage, or it could be declared as `let prompt!: PromptRef` to signal definite assignment.
|
||||
|
||||
```tsx
|
||||
// Before (line 77)
|
||||
let prompt: PromptRef
|
||||
|
||||
// After
|
||||
let prompt!: PromptRef
|
||||
```
|
||||
|
||||
**Why:** The `!` (definite assignment assertion) communicates intent: "this will be assigned before use." It also removes the possibility of `prompt` being `undefined` at the type level without an explicit annotation. This is a minor improvement. Note: the `PromptRef` type import on line 1 can also be removed since the type is inferred.
|
||||
|
||||
---
|
||||
|
||||
### 6. Unnecessary `return` in `mcpError` memo (lines 29-31)
|
||||
|
||||
The memo body is a single expression wrapped in braces with an explicit `return`. Arrow functions with a single expression can use the concise form.
|
||||
|
||||
```tsx
|
||||
// Before (lines 29-31)
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
})
|
||||
|
||||
// After
|
||||
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
|
||||
```
|
||||
|
||||
Same applies to `connectedMcpCount` (lines 33-35):
|
||||
|
||||
```tsx
|
||||
// Before (lines 33-35)
|
||||
const connectedMcpCount = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
|
||||
})
|
||||
|
||||
// After
|
||||
const connectedMcpCount = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
|
||||
```
|
||||
|
||||
**Why:** Removing the braces and `return` reduces visual noise. The concise arrow form signals "this is a pure expression" at a glance.
|
||||
|
||||
---
|
||||
|
||||
### 7. Multi-word variable names (lines 28, 33, 37, 39)
|
||||
|
||||
The style guide prefers single-word names where possible. Several memos use camelCase multi-word names.
|
||||
|
||||
| Line | Current | Suggested |
|
||||
| ---- | ------------------- | -------------------------------------------------------- |
|
||||
| 28 | `mcpError` | Fine — two short words, no clear single-word alternative |
|
||||
| 33 | `connectedMcpCount` | `connected` (context makes it clear) |
|
||||
| 37 | `isFirstTimeUser` | Inline it (see issue #2) |
|
||||
| 26 | `promptRef` | Fine — mirrors the context name |
|
||||
| 39 | `showTips` | `tips` (it's a boolean signal for whether to show tips) |
|
||||
|
||||
```tsx
|
||||
// Before (line 33)
|
||||
const connectedMcpCount = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
|
||||
|
||||
// After
|
||||
const connected = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
|
||||
```
|
||||
|
||||
**Why:** Shorter names reduce line length and cognitive load. In a component focused on MCP status, `connected` is unambiguous. This is a soft suggestion — the current names aren't terrible, but the style guide explicitly prefers brevity.
|
||||
|
||||
---
|
||||
|
||||
### 8. Duplicated MCP status indicator JSX (lines 58-75 vs 117-131)
|
||||
|
||||
The hint area (lines 58-75) and the footer (lines 117-131) both render MCP status indicators with slightly different formatting. The footer duplicates the `Switch`/`Match` pattern for the dot color. This isn't necessarily a "extract to a component" situation (style guide: keep things in one function unless composable or reusable), but it's worth noting the duplication exists. If MCP status display logic changes, both locations need updating.
|
||||
|
||||
No code change suggested — just flagging the maintenance risk.
|
||||
|
||||
---
|
||||
|
||||
### 9. Module-level `let once` with TODO comment (lines 18-19)
|
||||
|
||||
```tsx
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
```
|
||||
|
||||
This is a module-level mutable variable used as a "run once" guard for the `onMount` callback. The TODO acknowledges this is a hack. It works but is fragile — the state persists across hot reloads and is invisible to the component's reactive system. No immediate fix needed, but this is technical debt worth tracking.
|
||||
|
||||
---
|
||||
|
||||
## Suggested full rewrite (for reference)
|
||||
|
||||
```tsx
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Tips } from "../component/tips"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useDirectory } from "../context/directory"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const command = useCommandDialog()
|
||||
const args = useArgs()
|
||||
const directory = useDirectory()
|
||||
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
|
||||
const connected = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
|
||||
|
||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
||||
const tips = createMemo(() => {
|
||||
if (sync.data.session.length === 0) return false
|
||||
return !tipsHidden()
|
||||
})
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("tips_hidden", !tipsHidden())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const Hint = (
|
||||
<Show when={connected() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
||||
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>•</span>{" "}
|
||||
{Locale.pluralize(connected(), "{} mcp server", "{} mcp servers")}
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
||||
let prompt!: PromptRef
|
||||
onMount(() => {
|
||||
if (once) return
|
||||
if (route.initialPrompt) {
|
||||
prompt.set(route.initialPrompt)
|
||||
once = true
|
||||
return
|
||||
}
|
||||
if (args.prompt) {
|
||||
prompt.set({ input: args.prompt, parts: [] })
|
||||
once = true
|
||||
prompt.submit()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box height={3} />
|
||||
<Logo />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
|
||||
<Prompt
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
hint={Hint}
|
||||
/>
|
||||
</box>
|
||||
<box height={3} width="100%" maxWidth={75} alignItems="center" paddingTop={2}>
|
||||
<Show when={tips()}>
|
||||
<Tips />
|
||||
</Show>
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: connected() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{connected()} MCP
|
||||
</text>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Changes in the rewrite
|
||||
|
||||
1. Removed `useKeybind` import and usage (dead code)
|
||||
2. Removed `type PromptRef` import (use definite assignment instead)
|
||||
3. Grouped all hook calls at the top of the function
|
||||
4. Inlined `isFirstTimeUser` into `showTips`
|
||||
5. Renamed `showTips` to `tips`, `connectedMcpCount` to `connected`
|
||||
6. Converted multi-line single-expression memos to concise arrow form
|
||||
7. Replaced `else if` with early return in `onMount`
|
||||
8. Changed `let prompt: PromptRef` to `let prompt!: PromptRef`
|
||||
@@ -1,318 +0,0 @@
|
||||
# Review: `dialog-fork-from-timeline.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
A small 65-line component with several style guide violations: an imperative loop where functional array methods would be cleaner, unnecessary type annotations, a mutable `result` array built via `push` + `reverse`, and a couple of naming/destructuring issues. The logic itself is correct but the construction of the options memo is messier than it needs to be.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Imperative `for` loop with `push` + `reverse` — use functional array methods (lines 23-60)
|
||||
|
||||
The `options` memo builds an array imperatively: it creates a mutable `let`-style array (via `as` cast), pushes into it in a for loop with `continue`, then reverses in place. This is the exact pattern the style guide discourages. Using `flatMap` + `filter` + `reverse()` (or `toReversed()`) expresses the same thing declaratively and avoids the mutable accumulator.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
const result = [] as DialogSelectOption<string>[]
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x) => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
) as TextPart
|
||||
if (!part) continue
|
||||
result.push({
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: async (dialog) => { ... },
|
||||
})
|
||||
}
|
||||
result.reverse()
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo(() =>
|
||||
(sync.data.message[props.sessionID] ?? [])
|
||||
.filter((m) => m.role === "user")
|
||||
.flatMap((message) => {
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x) => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
) as TextPart | undefined
|
||||
if (!part) return []
|
||||
return [
|
||||
{
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: async (dialog) => { ... },
|
||||
},
|
||||
]
|
||||
})
|
||||
.toReversed(),
|
||||
)
|
||||
```
|
||||
|
||||
**Why:** Functional methods (`filter`, `flatMap`, `toReversed`) eliminate the mutable accumulator, the `continue` control flow, and the in-place `reverse()`. The return type `DialogSelectOption<string>[]` annotation on the memo is also unnecessary — it's inferred from the array literal. This matches the style guide preferences for functional array methods, preferring `const`, and relying on type inference.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary explicit return type annotation on `createMemo` (line 21)
|
||||
|
||||
The style guide says "rely on type inference when possible; avoid explicit type annotations unless necessary for exports or clarity." The return type `DialogSelectOption<string>[]` is fully inferrable from the array contents.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo(() =>
|
||||
```
|
||||
|
||||
**Why:** The type is inferred from the returned array. Removing the annotation reduces noise and follows the style guide.
|
||||
|
||||
---
|
||||
|
||||
### 3. Unsafe cast `as TextPart` — should be `as TextPart | undefined` (line 28)
|
||||
|
||||
`Array.find()` can return `undefined`, but the cast `as TextPart` hides that. The next line checks `if (!part) continue`, which is correct defensively, but the cast tells TypeScript it's never undefined — a contradiction. This is not an `any` but it is a sloppy cast that obscures the actual type.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic && !x.ignored) as TextPart
|
||||
if (!part) continue
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic && !x.ignored) as
|
||||
| TextPart
|
||||
| undefined
|
||||
if (!part) return []
|
||||
```
|
||||
|
||||
**Why:** The cast should preserve the `| undefined` from `.find()`. This makes the null check meaningful to TypeScript rather than being dead code from the type system's perspective. A type guard on `filter` would also work but the cast approach is fine as long as `undefined` is included.
|
||||
|
||||
---
|
||||
|
||||
### 4. Variable `initialPrompt` is only used once — inline it (lines 39-54)
|
||||
|
||||
The style guide says "reduce total variable count by inlining when a value is only used once." `initialPrompt` is computed and immediately passed to `route.navigate`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
const initialPrompt = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
initialPrompt,
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
initialPrompt: (sync.data.part[message.id] ?? []).reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Eliminates two intermediate variables (`parts` and `initialPrompt`) that are each only used once. Keeps the data flow linear and reduces the variable count.
|
||||
|
||||
---
|
||||
|
||||
### 5. `reduce` with mutation — `reduce` is the wrong tool here (lines 40-48)
|
||||
|
||||
The `reduce` mutates its accumulator (`agg.input += ...`, `agg.parts.push(...)`) which defeats the purpose of `reduce`. A simple loop or a pair of functional operations would be cleaner and more honest about the mutation. However, if the goal is to stay functional per the style guide, building the two fields separately is clearer:
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const initialPrompt = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
const initialPrompt = {
|
||||
input: parts
|
||||
.filter((p) => p.type === "text" && !p.synthetic)
|
||||
.map((p) => (p as TextPart).text)
|
||||
.join(""),
|
||||
parts: parts.filter((p) => p.type === "file") as PromptInfo["parts"],
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** The original `reduce` mutates its accumulator on every iteration, which is a code smell — `reduce` should ideally produce new values. Building each field with `filter` + `map` is more declarative and easier to read at a glance. Each field's derivation is self-contained.
|
||||
|
||||
---
|
||||
|
||||
### 6. Variable `forked` used only for `forked.data!.id` — inline it (lines 35-36)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const forked = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: message.id,
|
||||
})
|
||||
...
|
||||
sessionID: forked.data!.id,
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const forked = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: message.id,
|
||||
})
|
||||
...
|
||||
sessionID: forked.data!.id,
|
||||
```
|
||||
|
||||
This one is borderline — the `await` makes inlining awkward. Keeping `forked` as a variable is acceptable here. No change needed.
|
||||
|
||||
---
|
||||
|
||||
### 7. Nested `if` in reduce callback — flatten with `&&` (lines 42-44)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
if (part.type === "text" && !part.synthetic) agg.input += part.text
|
||||
```
|
||||
|
||||
**Why:** The nested `if` adds indentation for no reason. Combining into a single condition is simpler and follows the style guide's preference for avoiding unnecessary complexity.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Full Rewrite
|
||||
|
||||
Applying all of the above (except issue 6 which is fine as-is):
|
||||
|
||||
```tsx
|
||||
import { createMemo, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import type { TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
|
||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
const options = createMemo(() =>
|
||||
(sync.data.message[props.sessionID] ?? [])
|
||||
.filter((m) => m.role === "user")
|
||||
.flatMap((message) => {
|
||||
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic && !x.ignored) as
|
||||
| TextPart
|
||||
| undefined
|
||||
if (!part) return []
|
||||
return [
|
||||
{
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: async (dialog) => {
|
||||
const forked = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: message.id,
|
||||
})
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
initialPrompt: {
|
||||
input: parts
|
||||
.filter((p) => p.type === "text" && !p.synthetic)
|
||||
.map((p) => (p as TextPart).text)
|
||||
.join(""),
|
||||
parts: parts.filter((p) => p.type === "file") as PromptInfo["parts"],
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
.toReversed(),
|
||||
)
|
||||
|
||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
|
||||
}
|
||||
```
|
||||
|
||||
Changes from original:
|
||||
|
||||
- Removed unused import `DialogSelectOption`
|
||||
- Removed explicit return type on `createMemo`
|
||||
- Replaced imperative for-loop + push + reverse with `filter` + `flatMap` + `toReversed()`
|
||||
- Fixed unsafe `as TextPart` cast to `as TextPart | undefined`
|
||||
- Replaced mutating `reduce` with declarative `filter`/`map` for building `initialPrompt`
|
||||
- Inlined `initialPrompt` into `route.navigate`
|
||||
- Flattened nested `if` (no longer applicable after rewrite since the reduce is gone)
|
||||
@@ -1,303 +0,0 @@
|
||||
# Review: `dialog-message.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is reasonably short and focused, but has a clear **duplicated logic block** (the prompt-info extraction pattern appears twice identically), some unnecessary intermediate variables, and a few style guide violations around `let`-style mutation inside `reduce` accumulators. Overall the structure is readable but could be tighter.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Duplicated prompt extraction logic (lines 37–47 and 86–96)
|
||||
|
||||
The reduce block that builds `{ input, parts }` from message parts is copy-pasted verbatim in "Revert" and "Fork". This violates DRY and makes future changes error-prone — you'd have to update both. Extract it to a local helper.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
// lines 37–47 (in Revert)
|
||||
const parts = sync.data.part[msg.id]
|
||||
const promptInfo = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
|
||||
// lines 86–96 (in Fork — identical)
|
||||
const parts = sync.data.part[msg.id]
|
||||
return parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function prompt(msgID: string): PromptInfo {
|
||||
const parts = sync.data.part[msgID]
|
||||
return {
|
||||
input: parts
|
||||
.filter((p) => p.type === "text" && !p.synthetic)
|
||||
.map((p) => (p as { text: string }).text)
|
||||
.join(""),
|
||||
parts: parts.filter((p) => p.type === "file") as PromptInfo["parts"],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This also replaces the mutation-heavy `reduce` with functional `filter`/`map` (style guide: prefer functional array methods). The `reduce` here mutates `agg.input` via `+=` and `agg.parts` via `.push()` — both are imperative patterns that a `filter`+`map` avoids.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary IIFE in Fork handler (lines 83–97)
|
||||
|
||||
The `initialPrompt` is assigned via an immediately-invoked function expression. This adds a layer of indentation and cognitive overhead for no composability benefit.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const initialPrompt = (() => {
|
||||
const msg = message()
|
||||
if (!msg) return undefined
|
||||
const parts = sync.data.part[msg.id]
|
||||
return parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
})()
|
||||
```
|
||||
|
||||
**After** (with the helper from issue 1):
|
||||
|
||||
```tsx
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
const result = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
type: "session",
|
||||
initialPrompt: prompt(msg.id),
|
||||
})
|
||||
dialog.clear()
|
||||
```
|
||||
|
||||
The early return on `!msg` eliminates the need for the IIFE. This also moves the guard to the top of the handler, consistent with the other two handlers.
|
||||
|
||||
---
|
||||
|
||||
### 3. Unnecessary intermediate variable `text` in Copy handler (lines 62–68)
|
||||
|
||||
The `text` variable is only used once, on the very next line. Inline it.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const parts = sync.data.part[msg.id]
|
||||
const text = parts.reduce((agg, part) => {
|
||||
if (part.type === "text" && !part.synthetic) {
|
||||
agg += part.text
|
||||
}
|
||||
return agg
|
||||
}, "")
|
||||
|
||||
await Clipboard.copy(text)
|
||||
```
|
||||
|
||||
**After** (with the shared helper or inline):
|
||||
|
||||
```tsx
|
||||
await Clipboard.copy(
|
||||
sync.data.part[msg.id]
|
||||
.filter((p) => p.type === "text" && !p.synthetic)
|
||||
.map((p) => (p as { text: string }).text)
|
||||
.join(""),
|
||||
)
|
||||
```
|
||||
|
||||
Or, if the shared `prompt` helper exists, just `Clipboard.copy(prompt(msg.id).input)`.
|
||||
|
||||
---
|
||||
|
||||
### 4. `result` variable in Fork only used for `result.data!.id` (line 79–99)
|
||||
|
||||
The `result` variable is only used once to access `.data!.id`. It could be destructured or inlined, but more importantly the handler calls `sdk.client.session.fork` _before_ checking if the message exists (the IIFE at line 83 checks `message()` after the fork call). This means a fork API call fires even if the message is somehow null.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
onSelect: async (dialog) => {
|
||||
const result = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
const initialPrompt = (() => {
|
||||
const msg = message()
|
||||
if (!msg) return undefined
|
||||
...
|
||||
})()
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
onSelect: async (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
|
||||
const result = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
type: "session",
|
||||
initialPrompt: prompt(msg.id),
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
```
|
||||
|
||||
Guard first, then do work. Consistent with Revert and Copy handlers.
|
||||
|
||||
---
|
||||
|
||||
### 5. Inconsistent guard placement across handlers
|
||||
|
||||
Revert (line 28–29) and Copy (line 59–60) both guard `message()` at the top of the handler. Fork (line 83–85) buries it inside an IIFE after already making an API call. All three should follow the same pattern: guard at the top, bail early.
|
||||
|
||||
---
|
||||
|
||||
### 6. Variable name `agg` in reduce callbacks (lines 39, 63, 88)
|
||||
|
||||
`agg` is fine for a generic accumulator, but the functional rewrite proposed in issue 1 eliminates the reduces entirely, making this moot. If reduces are kept, `agg` is acceptable but `acc` is more conventional in this codebase — though either is a minor nit.
|
||||
|
||||
---
|
||||
|
||||
### 7. Variable `msg` shadows the reactive accessor pattern unnecessarily
|
||||
|
||||
In every handler, `const msg = message()` is called. This is fine — it unwraps the memo. But in the Fork handler the variable name `msg` is repeated inside the IIFE creating an inner scope shadow. With the IIFE removed (issue 2), this goes away.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Full Rewrite
|
||||
|
||||
```tsx
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
sessionID: string
|
||||
setPrompt?: (prompt: PromptInfo) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
|
||||
|
||||
function prompt(msgID: string): PromptInfo {
|
||||
const parts = sync.data.part[msgID]
|
||||
return {
|
||||
input: parts
|
||||
.filter((p) => p.type === "text" && !p.synthetic)
|
||||
.map((p) => (p as { text: string }).text)
|
||||
.join(""),
|
||||
parts: parts.filter((p) => p.type === "file") as PromptInfo["parts"],
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Message Actions"
|
||||
options={[
|
||||
{
|
||||
title: "Revert",
|
||||
value: "session.revert",
|
||||
description: "undo messages and file changes",
|
||||
onSelect: (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
sdk.client.session.revert({
|
||||
sessionID: props.sessionID,
|
||||
messageID: msg.id,
|
||||
})
|
||||
if (props.setPrompt) props.setPrompt(prompt(msg.id))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Copy",
|
||||
value: "message.copy",
|
||||
description: "message text to clipboard",
|
||||
onSelect: async (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
await Clipboard.copy(prompt(msg.id).input)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Fork",
|
||||
value: "session.fork",
|
||||
description: "create a new session",
|
||||
onSelect: async (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
const result = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
type: "session",
|
||||
initialPrompt: prompt(msg.id),
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### What changed
|
||||
|
||||
| Change | Lines affected | Why |
|
||||
| -------------------------------------- | ------------------- | ------------------------------------------------------ |
|
||||
| Extract `prompt()` helper | 37–47, 62–68, 86–96 | Eliminates triple duplication of part-extraction logic |
|
||||
| Replace `reduce` with `filter`/`map` | 38–47, 63–68, 87–96 | Functional style, no mutation, easier to read |
|
||||
| Remove IIFE in Fork | 83–97 | Unnecessary complexity; early return is cleaner |
|
||||
| Move guard before fork API call | 83–85 | Don't call API if message is null |
|
||||
| Inline `text` / `promptInfo` variables | 62, 38 | Each used only once; inlining reduces noise |
|
||||
| Reorder `route`/`message` declarations | 16–17 | Group hooks together before derived state |
|
||||
@@ -1,63 +0,0 @@
|
||||
# Review: `dialog-subagent.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
This is a small, clean 27-line file. There are no major issues — the code is straightforward and easy to follow. However, there are a couple of minor improvements that would bring it in line with the repo's style guide and sibling dialog files.
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unused import: `useRoute` is pulled in but only used once — consider whether the variable is needed (line 2, 5)
|
||||
|
||||
`route` is only used once (line 16), so it can be inlined to reduce the variable count per the style guide ("Reduce total variable count by inlining when a value is only used once").
|
||||
|
||||
**Before (lines 5, 16-19):**
|
||||
|
||||
```tsx
|
||||
const route = useRoute()
|
||||
|
||||
// ...
|
||||
onSelect: (dialog) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
onSelect: (dialog) => {
|
||||
useRoute().navigate({
|
||||
type: "session",
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
```
|
||||
|
||||
**However** — this one depends on whether `useRoute()` is a SolidJS context hook that must be called at the component's top level (outside callbacks). Looking at sibling files like `dialog-message.tsx` (line 17), `useRoute()` is called at the top level and stored in a variable, which is the correct pattern for context hooks. So the current code is actually **correct** — `useRoute()` _must_ be called at the top level of the component, not inside a callback.
|
||||
|
||||
**Verdict: No change needed.** The file is already following the correct pattern for SolidJS context hooks.
|
||||
|
||||
---
|
||||
|
||||
### 2. No issues found with the remaining patterns
|
||||
|
||||
Checking against each style guide rule:
|
||||
|
||||
- **Destructuring**: No unnecessary destructuring. `props.sessionID` uses dot notation. Good.
|
||||
- **`let` vs `const`**: No `let` usage. Good.
|
||||
- **`else` statements**: None present. Good.
|
||||
- **`any` type**: None used. Good.
|
||||
- **`try`/`catch`**: None present. Good.
|
||||
- **Naming**: `DialogSubagent`, `route`, `dialog` — all clean, concise names. Good.
|
||||
- **Type annotations**: The `props` parameter type is necessary since it's a component signature. Good.
|
||||
- **Single-use variables**: `route` is used once but must be called at the top level (SolidJS context constraint). Acceptable.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This file is clean. It's 27 lines, does one thing, and does it clearly. There are no meaningful improvements to make — it already follows the style guide and is consistent with sibling dialog files like `dialog-message.tsx` and `dialog-timeline.tsx`.
|
||||
@@ -1,197 +0,0 @@
|
||||
# Review: `dialog-timeline.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
This is a small, focused file (47 lines). The overall structure is fine, but there are a few style guide violations: a mutable accumulation pattern using a `for` loop + imperative `push`/`reverse` where functional array methods would be cleaner, an unnecessary explicit type annotation on the `createMemo` callback, and an unnecessary `as` cast. The sibling file `dialog-fork-from-timeline.tsx` has the same issues (it was likely copy-pasted), so these aren't unique to this file, but they should still be cleaned up.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Imperative `for` loop with mutable array instead of functional chain (lines 23-43)
|
||||
|
||||
The `options` memo builds a `result` array imperatively: declares an empty array with `as` cast, uses a `for` loop with `continue`, calls `.push()`, then `.reverse()`. This is exactly the pattern the style guide says to avoid: "Prefer functional array methods (flatMap, filter, map) over for loops."
|
||||
|
||||
A `filter` + `flatMap` (or `filter` + `map`) + `toReversed()` chain is more declarative, eliminates the mutable `result` variable, removes the `as` cast, and is consistent with how other dialogs in the codebase build options (see `dialog-stash.tsx:40-52`).
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
const result = [] as DialogSelectOption<string>[]
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x) => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
) as TextPart
|
||||
if (!part) continue
|
||||
result.push({
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} />
|
||||
))
|
||||
},
|
||||
})
|
||||
}
|
||||
result.reverse()
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo(() => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.flatMap((message) => {
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x): x is TextPart => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
)
|
||||
if (!part) return []
|
||||
return {
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} />
|
||||
))
|
||||
},
|
||||
}
|
||||
})
|
||||
.toReversed()
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Eliminates the mutable `result` array, the `as` cast, and the imperative `for`/`continue`/`push`/`reverse` pattern. The `flatMap` with `return []` is idiomatic for filter+map in one pass. The type guard `x is TextPart` on the `.find()` predicate removes the need for the `as TextPart` cast, which is safer. This is consistent with `dialog-stash.tsx` which uses `.map(...).toReversed()`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary explicit return type annotation on `createMemo` (line 22)
|
||||
|
||||
The style guide says "Rely on type inference when possible; avoid explicit type annotations unless necessary for exports or clarity." The return type `DialogSelectOption<string>[]` is fully inferrable from the returned value, especially after the refactor above where `.flatMap()` produces the right type. Even in the current code, the explicit annotation is redundant since `result` is already cast.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const options = createMemo(() => {
|
||||
```
|
||||
|
||||
**Why:** The type is inferred from the return value. The annotation adds noise without adding safety. If the type needs to be `DialogSelectOption<string>[]`, the structure of the returned objects already ensures that. This is consistent with how other dialogs in the codebase define their `options` memos (see `dialog-stash.tsx:37`, `dialog-model.tsx:35`, `dialog-mcp.tsx:29`).
|
||||
|
||||
---
|
||||
|
||||
### 3. Unsafe `as TextPart` cast (line 29)
|
||||
|
||||
The `as TextPart` cast bypasses type safety. If `find` returns an element that doesn't match, the cast silently lies about the type. A type guard predicate on `.find()` is both safer and eliminates the cast.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic && !x.ignored) as TextPart
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x): x is TextPart => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
)
|
||||
```
|
||||
|
||||
**Why:** The type guard narrows the type properly through the type system instead of overriding it. If the predicate logic ever drifts from the actual `TextPart` type, the compiler will catch it. The `as` cast would silently allow the mismatch.
|
||||
|
||||
---
|
||||
|
||||
### 4. `import type { TextPart }` may become unused (line 4)
|
||||
|
||||
After switching to a type guard (`x is TextPart`), the `TextPart` import is still needed but now used in a value position (the type predicate). This is fine and the import should stay as `import type` since type predicates are erased at runtime. Just noting this is a non-issue.
|
||||
|
||||
---
|
||||
|
||||
### 5. Minor: `DialogSelectOption` type import could be dropped (line 3)
|
||||
|
||||
If the explicit return type annotation is removed (issue 2), the `type DialogSelectOption` import on line 3 is no longer needed.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
```
|
||||
|
||||
**Why:** Dead imports are noise. Removing it keeps the import block clean.
|
||||
|
||||
---
|
||||
|
||||
## Suggested final state
|
||||
|
||||
```tsx
|
||||
import { createMemo, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import type { TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
|
||||
export function DialogTimeline(props: {
|
||||
sessionID: string
|
||||
onMove: (messageID: string) => void
|
||||
setPrompt?: (prompt: PromptInfo) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.flatMap((message) => {
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x): x is TextPart => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
)
|
||||
if (!part) return []
|
||||
return {
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} />
|
||||
))
|
||||
},
|
||||
}
|
||||
})
|
||||
.toReversed()
|
||||
})
|
||||
|
||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
|
||||
}
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
The sibling file `dialog-fork-from-timeline.tsx` has the exact same imperative loop pattern (lines 21-61) and would benefit from the identical refactor. This appears to be a copy-paste origin.
|
||||
@@ -1,286 +0,0 @@
|
||||
# Review: `footer.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is short and mostly readable, but has several style guide violations and patterns that add unnecessary complexity. The main issues are: unnecessary destructuring of `useTheme()`, a convoluted timer mechanism using a store where a simple signal suffices, a redundant conditional branch, and an inner `Switch` that can be replaced with a ternary.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useTheme()` (line 10)
|
||||
|
||||
The style guide says to avoid unnecessary destructuring and prefer dot notation. `theme` is the only property used, but the destructuring `{ theme }` is the established convention across this codebase (see `header.tsx:13`, `header.tsx:62`), so this is a minor, repo-wide pattern. Noting it for completeness but not a priority to change here alone.
|
||||
|
||||
```tsx
|
||||
// Current (line 10)
|
||||
const { theme } = useTheme()
|
||||
|
||||
// Preferred by style guide
|
||||
const theme = useTheme().theme
|
||||
```
|
||||
|
||||
**Why:** Dot notation preserves context and reduces destructuring per the style guide. However, since this pattern is used consistently across the codebase, changing it here alone would create inconsistency.
|
||||
|
||||
---
|
||||
|
||||
### 2. Overly complex timer mechanism using `createStore` (lines 23-50)
|
||||
|
||||
A `createStore` with a single boolean field `welcome` is overkill. A `createSignal` is simpler and more idiomatic for a single boolean toggle. The `tick` function also has a redundant branch: `if (!store.welcome)` followed by `if (store.welcome)` -- the second branch is unreachable because the first one returns. This makes the logic confusing.
|
||||
|
||||
```tsx
|
||||
// Current (lines 23-50)
|
||||
const [store, setStore] = createStore({
|
||||
welcome: false,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
// Track all timeouts to ensure proper cleanup
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function tick() {
|
||||
if (connected()) return
|
||||
if (!store.welcome) {
|
||||
setStore("welcome", true)
|
||||
timeouts.push(setTimeout(() => tick(), 5000))
|
||||
return
|
||||
}
|
||||
|
||||
if (store.welcome) {
|
||||
setStore("welcome", false)
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
return
|
||||
}
|
||||
}
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
|
||||
onCleanup(() => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
// Suggested
|
||||
const [welcome, setWelcome] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function tick() {
|
||||
if (connected()) return
|
||||
const next = !welcome()
|
||||
setWelcome(next)
|
||||
timeouts.push(setTimeout(() => tick(), next ? 5000 : 10_000))
|
||||
}
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
|
||||
onCleanup(() => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Why:**
|
||||
|
||||
- `createSignal` is the correct primitive for a single reactive boolean. `createStore` is for objects/collections.
|
||||
- The second `if (store.welcome)` on line 39 is dead code -- the `if (!store.welcome)` block on line 33 always returns. This makes the reader think there's a third case, but there isn't.
|
||||
- Toggling a boolean is a single operation, not two separate branches.
|
||||
|
||||
---
|
||||
|
||||
### 3. Unnecessary comment (line 28)
|
||||
|
||||
```tsx
|
||||
// Current (line 28)
|
||||
// Track all timeouts to ensure proper cleanup
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
```
|
||||
|
||||
**Why:** The code is self-explanatory -- a `timeouts` array paired with `onCleanup(() => timeouts.forEach(clearTimeout))` is a clear pattern. The comment adds no information.
|
||||
|
||||
---
|
||||
|
||||
### 4. Unnecessary type annotation on `timeouts` (line 29)
|
||||
|
||||
```tsx
|
||||
// Current (line 29)
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
// Suggested
|
||||
const timeouts = [] as ReturnType<typeof setTimeout>[]
|
||||
```
|
||||
|
||||
**Why:** Minor -- both forms are acceptable for empty arrays where the type can't be inferred. The `as` form is slightly more concise. This is a nitpick.
|
||||
|
||||
---
|
||||
|
||||
### 5. Inner `Switch` can be a simple ternary (lines 74-81)
|
||||
|
||||
The nested `Switch`/`Match` with `when={true}` as a fallback is unnecessarily heavy for choosing between two colors. A ternary on the `style` prop is simpler and more readable.
|
||||
|
||||
```tsx
|
||||
// Current (lines 74-81)
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
// Suggested
|
||||
<span style={{ fg: mcpError() ? theme.error : theme.success }}>⊙ </span>
|
||||
```
|
||||
|
||||
**Why:** 8 lines of JSX reduced to 1. The `Switch`/`Match` pattern is for multiple branches or complex conditions. For a binary choice on a single prop value, a ternary is clearer and avoids the overhead of two `Match` components.
|
||||
|
||||
---
|
||||
|
||||
### 6. `permissions()` called 3 times in the JSX (lines 63-67)
|
||||
|
||||
```tsx
|
||||
// Current (lines 63-67)
|
||||
<Show when={permissions().length > 0}>
|
||||
<text fg={theme.warning}>
|
||||
<span style={{ fg: theme.warning }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
```
|
||||
|
||||
Each `permissions()` call re-evaluates the memo accessor. While `createMemo` caches the result so this is not a performance issue, reading from the memo once and assigning to a variable (or using `Show`'s callback form) improves readability by reducing noise.
|
||||
|
||||
```tsx
|
||||
// Suggested - use Show's keyed callback to capture the value
|
||||
<Show when={permissions().length || undefined} keyed>
|
||||
{(count) => (
|
||||
<text fg={theme.warning}>
|
||||
<span style={{ fg: theme.warning }}>△</span> {count} Permission
|
||||
{count > 1 ? "s" : ""}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
```
|
||||
|
||||
**Why:** Eliminates triple accessor calls, and the `count` parameter makes the pluralization logic easier to read.
|
||||
|
||||
---
|
||||
|
||||
### 7. `store.welcome` reference in JSX should be `welcome()` after refactor (line 57)
|
||||
|
||||
If you apply the `createSignal` refactor from issue #2, update the JSX reference:
|
||||
|
||||
```tsx
|
||||
// Current (line 57)
|
||||
<Match when={store.welcome}>
|
||||
|
||||
// After refactor
|
||||
<Match when={welcome()}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. `createStore` import is unnecessary after refactor (line 6)
|
||||
|
||||
If `createStore` is replaced with `createSignal`, the import from `"solid-js/store"` can be removed entirely.
|
||||
|
||||
```tsx
|
||||
// Current (line 6)
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
// After refactor: remove this line
|
||||
```
|
||||
|
||||
And `createSignal` is already available from `"solid-js"` -- just add it to the existing import on line 1.
|
||||
|
||||
---
|
||||
|
||||
## Suggested final state
|
||||
|
||||
```tsx
|
||||
import { createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useConnected } from "../../component/dialog-model"
|
||||
import { useRoute } from "../../context/route"
|
||||
|
||||
export function Footer() {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const route = useRoute()
|
||||
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
|
||||
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
|
||||
const lsp = createMemo(() => Object.keys(sync.data.lsp))
|
||||
const permissions = createMemo(() => {
|
||||
if (route.data.type !== "session") return []
|
||||
return sync.data.permission[route.data.sessionID] ?? []
|
||||
})
|
||||
const directory = useDirectory()
|
||||
const connected = useConnected()
|
||||
|
||||
const [welcome, setWelcome] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
const timeouts = [] as ReturnType<typeof setTimeout>[]
|
||||
|
||||
function tick() {
|
||||
if (connected()) return
|
||||
const next = !welcome()
|
||||
setWelcome(next)
|
||||
timeouts.push(setTimeout(() => tick(), next ? 5000 : 10_000))
|
||||
}
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
|
||||
onCleanup(() => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={welcome()}>
|
||||
<text fg={theme.text}>
|
||||
Get started <span style={{ fg: theme.textMuted }}>/connect</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={connected()}>
|
||||
<Show when={permissions().length || undefined} keyed>
|
||||
{(count) => (
|
||||
<text fg={theme.warning}>
|
||||
<span style={{ fg: theme.warning }}>△</span> {count} Permission
|
||||
{count > 1 ? "s" : ""}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}>•</span> {lsp().length} LSP
|
||||
</text>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ fg: mcpError() ? theme.error : theme.success }}>⊙ </span>
|
||||
{mcp()} MCP
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Impact summary
|
||||
|
||||
| Issue | Severity | Lines saved | Type |
|
||||
| ------------------------------- | -------- | ----------- | -------------------------- |
|
||||
| `createStore` -> `createSignal` | Medium | ~8 | Unnecessary complexity |
|
||||
| Dead code in `tick()` | Medium | 5 | Unreachable branch |
|
||||
| Inner `Switch` -> ternary | Low | 7 | Verbose JSX |
|
||||
| Triple `permissions()` call | Low | 0 | Readability |
|
||||
| Remove `createStore` import | Low | 1 | Dead import after refactor |
|
||||
| Unnecessary comment | Low | 1 | Noise |
|
||||
@@ -1,258 +0,0 @@
|
||||
# Code Review: `header.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is reasonably clean but has several style guide violations: unnecessary destructuring, a `let` that should be `const`, an intermediate variable that should be inlined, and repeated hover-button JSX that could be extracted. There are also minor readability wins around type annotations and naming.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useTheme()` (lines 13, 22, 62)
|
||||
|
||||
`{ theme }` is destructured in three places. Per the style guide, prefer dot notation to preserve context.
|
||||
|
||||
**Lines 13, 22, 62:**
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const { theme } = useTheme()
|
||||
|
||||
// After
|
||||
const theme = useTheme().theme
|
||||
```
|
||||
|
||||
This is a marginal call since `useTheme()` only returns `theme`, but dot notation is more consistent with the style guide and makes it clear what object it came from. Alternatively, if `useTheme` could just return the theme directly, that would be even better -- but that's outside this file's scope.
|
||||
|
||||
---
|
||||
|
||||
### 2. `let result` should be `const` with ternary (lines 55-58)
|
||||
|
||||
The style guide says to prefer `const` over `let` and use ternaries instead of reassignment.
|
||||
|
||||
**Lines 55-58:**
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
let result = total.toLocaleString()
|
||||
if (model?.limit.context) {
|
||||
result += " " + Math.round((total / model.limit.context) * 100) + "%"
|
||||
}
|
||||
return result
|
||||
|
||||
// After
|
||||
const base = total.toLocaleString()
|
||||
return model?.limit.context ? base + " " + Math.round((total / model.limit.context) * 100) + "%" : base
|
||||
```
|
||||
|
||||
Eliminates `let` and the mutation. The variable `result` is vague anyway -- renaming to `base` or just inlining avoids the issue.
|
||||
|
||||
---
|
||||
|
||||
### 3. Intermediate variable `total` in `cost` memo can be inlined (lines 39-46)
|
||||
|
||||
`total` is only used once (in `format()`). Per the style guide: "Reduce total variable count by inlining when a value is only used once."
|
||||
|
||||
**Lines 38-47:**
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
// After
|
||||
const cost = createMemo(() =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(
|
||||
pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
This is a judgment call -- the original is also readable. But it does follow the style guide more closely.
|
||||
|
||||
---
|
||||
|
||||
### 4. Intermediate variable `total` in `context` memo (lines 52-53)
|
||||
|
||||
`total` is used twice (line 53 computation and line 57 formatting), so it can't be inlined. However, the variable name `total` is reused across both `cost` and `context` memos for different things. In `cost` it means total dollar cost; in `context` it means total token count. This is fine since they're in different scopes, but renaming to `tokens` in the `context` memo would better communicate intent.
|
||||
|
||||
**Lines 52-53:**
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const total =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
|
||||
// After (if kept as a variable)
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `model` variable is only used once -- inline it (line 54)
|
||||
|
||||
The `model` variable is only referenced on line 56. It can be inlined into the condition.
|
||||
|
||||
**Lines 54-58:**
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
let result = total.toLocaleString()
|
||||
if (model?.limit.context) {
|
||||
result += " " + Math.round((total / model.limit.context) * 100) + "%"
|
||||
}
|
||||
return result
|
||||
|
||||
// After
|
||||
const limit = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]?.limit.context
|
||||
const base = tokens.toLocaleString()
|
||||
return limit ? base + " " + Math.round((tokens / limit) * 100) + "%" : base
|
||||
```
|
||||
|
||||
This collapses three variables (`model`, `result`) into two (`limit`, `base`), eliminates the `let`, and is more direct about what we actually care about: the context limit number.
|
||||
|
||||
---
|
||||
|
||||
### 6. Repeated hover-button pattern (lines 92-121)
|
||||
|
||||
The three navigation buttons (Parent, Prev, Next) follow an identical pattern with only the label, hover key, command, and keybind differing. This is a clear candidate for extraction into a small local component to reduce the ~30 lines of near-duplicate JSX.
|
||||
|
||||
**Lines 92-121:**
|
||||
|
||||
```tsx
|
||||
// Before (repeated 3 times)
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.parent")}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
// After -- extract a local helper used three times
|
||||
const NavButton = (props: {
|
||||
id: "parent" | "prev" | "next"
|
||||
label: string
|
||||
command: string
|
||||
bind: string
|
||||
}) => (
|
||||
<box
|
||||
onMouseOver={() => setHover(props.id)}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger(props.command)}
|
||||
backgroundColor={hover() === props.id ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
{props.label} <span style={{ fg: theme.textMuted }}>{keybind.print(props.bind)}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
|
||||
// Usage:
|
||||
<box flexDirection="row" gap={2}>
|
||||
<NavButton id="parent" label="Parent" command="session.parent" bind="session_parent" />
|
||||
<NavButton id="prev" label="Prev" command="session.child.previous" bind="session_child_cycle_reverse" />
|
||||
<NavButton id="next" label="Next" command="session.child.next" bind="session_child_cycle" />
|
||||
</box>
|
||||
```
|
||||
|
||||
This cuts ~20 lines of duplicated JSX and makes it trivial to add/remove/reorder navigation buttons. The style guide says "keep things in one function unless composable or reusable" -- these buttons are reusable within the component.
|
||||
|
||||
---
|
||||
|
||||
### 7. `Title` component may be unnecessary (lines 12-19)
|
||||
|
||||
`Title` is only used once (line 127). It could be inlined into the JSX at the call site, removing a component boundary and the explicit type annotation on `props`.
|
||||
|
||||
**Lines 12-19 and 127:**
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
// ...
|
||||
<Title session={session} />
|
||||
|
||||
// After (inline at line 127)
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{session().title}</span>
|
||||
</text>
|
||||
```
|
||||
|
||||
This removes an extra component, an extra `useTheme()` call, and a type annotation. The `theme` variable is already in scope in `Header`.
|
||||
|
||||
---
|
||||
|
||||
### 8. `ContextInfo` component may be unnecessary (lines 21-30)
|
||||
|
||||
`ContextInfo` is used twice (lines 89, 128), so extraction is justified. However, it takes two `Accessor` props with explicit type annotations. The type annotation `Accessor<string | undefined>` on `context` could be dropped if the component were inlined or if the type were inferred from usage. This is a minor point -- keeping the component is fine since it's used twice.
|
||||
|
||||
No change recommended, just noting the explicit types.
|
||||
|
||||
---
|
||||
|
||||
### 9. Unused `narrow()` duplication (line 85)
|
||||
|
||||
`narrow()` is called three times in the subagent branch (lines 85, 85, 85 -- twice in the same expression). This is fine for a reactive signal but worth noting: `narrow()` appears in `flexDirection={narrow() ? "column" : "row"}` and `gap={narrow() ? 1 : 0}`. This is acceptable in SolidJS.
|
||||
|
||||
No change needed.
|
||||
|
||||
---
|
||||
|
||||
## Combined refactor of `context` memo
|
||||
|
||||
Applying issues 4 and 5 together, the full `context` memo becomes:
|
||||
|
||||
```tsx
|
||||
// Before (lines 49-60)
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
let result = total.toLocaleString()
|
||||
if (model?.limit.context) {
|
||||
result += " " + Math.round((total / model.limit.context) * 100) + "%"
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// After
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||
if (!last) return
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const limit = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]?.limit.context
|
||||
const base = tokens.toLocaleString()
|
||||
return limit ? base + " " + Math.round((tokens / limit) * 100) + "%" : base
|
||||
})
|
||||
```
|
||||
|
||||
Changes: `let` eliminated, `model` inlined to just extract `limit`, `total` renamed to `tokens` for clarity.
|
||||
@@ -1,849 +0,0 @@
|
||||
# Code Review: `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx`
|
||||
|
||||
## Overall Quality
|
||||
|
||||
This is a large (~2125 line) component file that handles the main session view. The core structure is reasonable, but there are numerous style guide violations, unnecessary complexity, inconsistent patterns, and readability issues scattered throughout. The most pervasive problems are: unnecessary destructuring, `any` usage, verbose variable declarations for single-use values, `let` where `const` would work, `else` branches where early returns are cleaner, and inconsistent naming conventions.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useRoute()` (line 113)
|
||||
|
||||
`navigate` is destructured from `useRoute()` losing the context of where it comes from. The style guide says to prefer dot notation.
|
||||
|
||||
```tsx
|
||||
// Before (line 113)
|
||||
const { navigate } = useRoute()
|
||||
|
||||
// After
|
||||
const route = useRoute()
|
||||
// Then use route.navigate(...) everywhere
|
||||
```
|
||||
|
||||
However, `route` is already taken by `useRouteData` on line 112. So the real fix is to rename or inline:
|
||||
|
||||
```tsx
|
||||
// After
|
||||
const router = useRoute()
|
||||
// use router.navigate(...)
|
||||
```
|
||||
|
||||
**Why:** Preserves context. When you see `router.navigate()` you know where the function comes from.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary destructuring of `useTheme()` (lines 116, 152, 239, 323, 357, 496–497, 586, 627, 694, 750, 830, 897, 966, 1060)
|
||||
|
||||
This pattern repeats throughout the file:
|
||||
|
||||
```tsx
|
||||
// Before (line 116, and many others)
|
||||
const { theme } = useTheme()
|
||||
|
||||
// After
|
||||
const ctx = useTheme()
|
||||
// use ctx.theme
|
||||
```
|
||||
|
||||
Actually since `useTheme()` is called so often and `theme` alone is clear enough in context, this one is borderline acceptable. But in components that also destructure `syntax` and `subtleSyntax`, the destructuring adds noise:
|
||||
|
||||
```tsx
|
||||
// Before (line 1323)
|
||||
const { theme, subtleSyntax } = useTheme()
|
||||
|
||||
// Before (line 1357)
|
||||
const { theme, syntax } = useTheme()
|
||||
|
||||
// After
|
||||
const t = useTheme()
|
||||
// use t.theme, t.syntax, t.subtleSyntax
|
||||
```
|
||||
|
||||
**Why:** Consistent with style guide preference for dot notation. Reduces variable declarations.
|
||||
|
||||
---
|
||||
|
||||
### 3. Multi-word variable names that could be simplified (various lines)
|
||||
|
||||
```tsx
|
||||
// Before (line 118)
|
||||
const session = createMemo(() => sync.session.get(route.sessionID))
|
||||
|
||||
// Line 119-124: children is fine
|
||||
|
||||
// Line 135-137
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
})
|
||||
|
||||
// Line 139-141
|
||||
const lastAssistant = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant")
|
||||
})
|
||||
```
|
||||
|
||||
`lastAssistant` could be `last`:
|
||||
|
||||
```tsx
|
||||
// After
|
||||
const last = createMemo(() => messages().findLast((x) => x.role === "assistant"))
|
||||
```
|
||||
|
||||
**Why:** Style guide prefers single-word variable names.
|
||||
|
||||
---
|
||||
|
||||
### 4. Unnecessary multi-line memo bodies — use expression form (lines 135–141)
|
||||
|
||||
```tsx
|
||||
// Before (lines 135-137)
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
})
|
||||
|
||||
// Before (lines 139-141)
|
||||
const lastAssistant = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant")
|
||||
})
|
||||
|
||||
// After
|
||||
const pending = createMemo(() => messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id)
|
||||
const last = createMemo(() => messages().findLast((x) => x.role === "assistant"))
|
||||
```
|
||||
|
||||
**Why:** Expression-body arrows are more concise when there's only a return statement.
|
||||
|
||||
---
|
||||
|
||||
### 5. `let` used where `const` should be (lines 203, 220–221)
|
||||
|
||||
```tsx
|
||||
// Before (line 203)
|
||||
let lastSwitch: string | undefined = undefined
|
||||
|
||||
// Before (lines 220-221)
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef
|
||||
```
|
||||
|
||||
`lastSwitch` is reassigned so `let` is technically required, but the pattern is a mutable variable in a closure — consider using a `{ current: undefined }` ref pattern. The `scroll` and `prompt` vars are assigned via refs, which is a SolidJS pattern that requires `let`. These are acceptable exceptions.
|
||||
|
||||
However, `lastSwitch` has an unnecessary type annotation:
|
||||
|
||||
```tsx
|
||||
// Before (line 203)
|
||||
let lastSwitch: string | undefined = undefined
|
||||
|
||||
// After
|
||||
let lastSwitch: string | undefined
|
||||
```
|
||||
|
||||
**Why:** `undefined` is already the default value of an uninitialized variable. The explicit `= undefined` is redundant.
|
||||
|
||||
---
|
||||
|
||||
### 6. `else if` where early returns / guard clauses would be cleaner (lines 211–217)
|
||||
|
||||
```tsx
|
||||
// Before (lines 211-217)
|
||||
if (part.tool === "plan_exit") {
|
||||
local.agent.set("build")
|
||||
lastSwitch = part.id
|
||||
} else if (part.tool === "plan_enter") {
|
||||
local.agent.set("plan")
|
||||
lastSwitch = part.id
|
||||
}
|
||||
|
||||
// After
|
||||
if (part.tool === "plan_exit") {
|
||||
local.agent.set("build")
|
||||
lastSwitch = part.id
|
||||
return
|
||||
}
|
||||
if (part.tool === "plan_enter") {
|
||||
local.agent.set("plan")
|
||||
lastSwitch = part.id
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Flat control flow is easier to scan. Each case is independent.
|
||||
|
||||
---
|
||||
|
||||
### 7. Multi-word function names (lines 247, 278, 301)
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const findNextVisibleMessage = ...
|
||||
const scrollToMessage = ...
|
||||
function moveChild(direction: number) ...
|
||||
|
||||
// After - if these stay as helper functions
|
||||
const findNext = ...
|
||||
const scrollTo = ... // or just inline into the single call site
|
||||
function cycle(direction: number) ...
|
||||
```
|
||||
|
||||
**Why:** Style guide prefers single-word names. `cycle` better describes cycling through children.
|
||||
|
||||
---
|
||||
|
||||
### 8. Unnecessary intermediate variable `messagesList` (line 249)
|
||||
|
||||
```tsx
|
||||
// Before (lines 248-250)
|
||||
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
|
||||
const children = scroll.getChildren()
|
||||
const messagesList = messages()
|
||||
const scrollTop = scroll.y
|
||||
|
||||
// After
|
||||
const findNext = (direction: "next" | "prev"): string | null => {
|
||||
const kids = scroll.getChildren()
|
||||
const msgs = messages()
|
||||
```
|
||||
|
||||
Also `scrollTop` is only used twice and could be inlined:
|
||||
|
||||
```tsx
|
||||
// Before (line 271)
|
||||
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
|
||||
// and line 274
|
||||
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
|
||||
|
||||
// After (inline scroll.y)
|
||||
return visible.find((c) => c.y > scroll.y + 10)?.id ?? null
|
||||
return [...visible].reverse().find((c) => c.y < scroll.y - 10)?.id ?? null
|
||||
```
|
||||
|
||||
**Why:** Inline values used only once or twice, especially when the source expression is already short.
|
||||
|
||||
---
|
||||
|
||||
### 9. Unnecessary explicit return type annotation `: string | null` (line 247)
|
||||
|
||||
```tsx
|
||||
// Before (line 247)
|
||||
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
|
||||
|
||||
// After
|
||||
const findNext = (direction: "next" | "prev") => {
|
||||
```
|
||||
|
||||
**Why:** Style guide says rely on type inference. The return type is obvious from the code.
|
||||
|
||||
---
|
||||
|
||||
### 10. `else` in `findNextVisibleMessage` (lines 269–274)
|
||||
|
||||
```tsx
|
||||
// Before (lines 269-274)
|
||||
if (direction === "next") {
|
||||
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
|
||||
}
|
||||
// Find last message above current position
|
||||
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
|
||||
|
||||
// This is already using early return — good. No change needed.
|
||||
```
|
||||
|
||||
Actually this is already fine. Moving on.
|
||||
|
||||
---
|
||||
|
||||
### 11. `let next` with reassignment in `moveChild` (lines 301–312)
|
||||
|
||||
```tsx
|
||||
// Before (lines 301-312)
|
||||
function moveChild(direction: number) {
|
||||
if (children().length === 1) return
|
||||
let next = children().findIndex((x) => x.id === session()?.id) + direction
|
||||
if (next >= children().length) next = 0
|
||||
if (next < 0) next = children().length - 1
|
||||
if (children()[next]) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: children()[next].id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// After
|
||||
function cycle(direction: number) {
|
||||
const list = children()
|
||||
if (list.length <= 1) return
|
||||
const idx = list.findIndex((x) => x.id === session()?.id)
|
||||
const next = (((idx + direction) % list.length) + list.length) % list.length
|
||||
const target = list[next]
|
||||
if (target) router.navigate({ type: "session", sessionID: target.id })
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Replaces `let` + conditional reassignment with a single `const` using modular arithmetic. Cleaner and no mutation.
|
||||
|
||||
---
|
||||
|
||||
### 12. Verbose `onMouseOver`/`onMouseOut` handlers (lines 1172–1177, 1004–1005, and others)
|
||||
|
||||
```tsx
|
||||
// Before (lines 1172-1177)
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
|
||||
// After
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
```
|
||||
|
||||
**Why:** Single-expression arrow functions don't need braces. This pattern appears in multiple places (lines 988, 1004–1005, 1172–1177).
|
||||
|
||||
---
|
||||
|
||||
### 13. `any` type usage (lines 526, 1265, 1482, 1803–1804, 1809–1811, 1819–1821, 1869)
|
||||
|
||||
The style guide explicitly says "avoid using the `any` type."
|
||||
|
||||
```tsx
|
||||
// Line 526 - keybind cast
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
|
||||
// Line 1265 - Dynamic component prop
|
||||
part={part as any}
|
||||
|
||||
// Line 1482 - ToolProps permission type
|
||||
permission: Record<string, any>
|
||||
|
||||
// Lines 1803-1804
|
||||
complete={(props.input as any).url} part={props.part}>
|
||||
WebFetch {(props.input as any).url}
|
||||
|
||||
// Lines 1809-1811
|
||||
function CodeSearch(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
|
||||
// Lines 1819-1821 - same pattern
|
||||
function WebSearch(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
|
||||
// Line 1869
|
||||
const title = item().state.status === "completed" ? (item().state as any).title : ""
|
||||
```
|
||||
|
||||
Most of these could be fixed by defining proper tool types for `CodeSearch`, `WebSearch`, and `WebFetch`, or by using type narrowing.
|
||||
|
||||
```tsx
|
||||
// After (CodeSearch example) - define a type or use the actual tool type
|
||||
function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
|
||||
// no more `as any` needed
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `any` defeats the type system. These are all avoidable.
|
||||
|
||||
---
|
||||
|
||||
### 14. Unnecessary `try/catch` blocks (lines 767–784, 796–847, 897–916)
|
||||
|
||||
```tsx
|
||||
// Before (lines 767-784) - Copy session transcript
|
||||
onSelect: async (dialog) => {
|
||||
try {
|
||||
const sessionData = session()
|
||||
if (!sessionData) return
|
||||
...
|
||||
await Clipboard.copy(transcript)
|
||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||
} catch (error) {
|
||||
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
|
||||
// After - use .catch()
|
||||
onSelect: async (dialog) => {
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const msgs = messages()
|
||||
const transcript = formatTranscript(
|
||||
s,
|
||||
msgs.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
|
||||
{ thinking: showThinking(), toolDetails: showDetails(), assistantMetadata: showAssistantMetadata() },
|
||||
)
|
||||
await Clipboard.copy(transcript)
|
||||
.then(() => toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to copy session transcript", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
```
|
||||
|
||||
Similarly for the export handler (lines 796–847) and `revertDiffFiles` (lines 897–916).
|
||||
|
||||
**Why:** Style guide says avoid `try/catch` where possible. Promise `.catch()` is preferred.
|
||||
|
||||
---
|
||||
|
||||
### 15. Unnecessary intermediate variables used only once (various)
|
||||
|
||||
```tsx
|
||||
// Before (line 768-769)
|
||||
const sessionData = session()
|
||||
if (!sessionData) return
|
||||
const sessionMessages = messages()
|
||||
|
||||
// After
|
||||
const s = session()
|
||||
if (!s) return
|
||||
// Use messages() directly, or `const msgs = messages()` if needed for readability
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Before (lines 802-803)
|
||||
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
|
||||
|
||||
// This is used once on line 805 — inline it:
|
||||
const options = await DialogExportOptions.show(
|
||||
dialog,
|
||||
`session-${s.id.slice(0, 8)}.md`,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Before (line 829-830)
|
||||
const exportDir = process.cwd()
|
||||
const filename = options.filename.trim()
|
||||
const filepath = path.join(exportDir, filename)
|
||||
|
||||
// After
|
||||
const filepath = path.join(process.cwd(), options.filename.trim())
|
||||
```
|
||||
|
||||
**Why:** Style guide says reduce variable count by inlining when a value is only used once.
|
||||
|
||||
---
|
||||
|
||||
### 16. Redundant `createMemo` wrapper in `Write` component (lines 1695–1698)
|
||||
|
||||
```tsx
|
||||
// Before (lines 1695-1698)
|
||||
const code = createMemo(() => {
|
||||
if (!props.input.content) return ""
|
||||
return props.input.content
|
||||
})
|
||||
|
||||
// After
|
||||
const code = createMemo(() => props.input.content ?? "")
|
||||
```
|
||||
|
||||
**Why:** The `if (!x) return ""; return x` pattern is just a nullish coalescing.
|
||||
|
||||
---
|
||||
|
||||
### 17. Redundant `createMemo` in `List` component (lines 1788–1793)
|
||||
|
||||
```tsx
|
||||
// Before (lines 1788-1793)
|
||||
const dir = createMemo(() => {
|
||||
if (props.input.path) {
|
||||
return normalizePath(props.input.path)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// After
|
||||
const dir = createMemo(() => (props.input.path ? normalizePath(props.input.path) : ""))
|
||||
```
|
||||
|
||||
Or even inline it since it's used once:
|
||||
|
||||
```tsx
|
||||
<InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
|
||||
List {props.input.path ? normalizePath(props.input.path) : ""}
|
||||
</InlineTool>
|
||||
```
|
||||
|
||||
**Why:** Single-expression ternary is clearer than multi-line if/return for such a simple case.
|
||||
|
||||
---
|
||||
|
||||
### 18. `shouldHide` memo is inverted logic — makes it harder to read (lines 1394–1398)
|
||||
|
||||
```tsx
|
||||
// Before (lines 1394-1398)
|
||||
const shouldHide = createMemo(() => {
|
||||
if (ctx.showDetails()) return false
|
||||
if (props.part.state.status !== "completed") return false
|
||||
return true
|
||||
})
|
||||
|
||||
// After — rename and simplify
|
||||
const hidden = createMemo(() => !ctx.showDetails() && props.part.state.status === "completed")
|
||||
```
|
||||
|
||||
**Why:** Expressing positive conditions directly is clearer than a series of negated early returns that eventually return `true`.
|
||||
|
||||
---
|
||||
|
||||
### 19. `else` in export handler (lines 825–842)
|
||||
|
||||
```tsx
|
||||
// Before (lines 825-842)
|
||||
if (options.openWithoutSaving) {
|
||||
await Editor.open({ value: transcript, renderer })
|
||||
} else {
|
||||
const exportDir = process.cwd()
|
||||
...
|
||||
}
|
||||
|
||||
// After
|
||||
if (options.openWithoutSaving) {
|
||||
await Editor.open({ value: transcript, renderer })
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const filepath = path.join(process.cwd(), options.filename.trim())
|
||||
await Bun.write(filepath, transcript)
|
||||
const result = await Editor.open({ value: transcript, renderer })
|
||||
if (result !== undefined) await Bun.write(filepath, result)
|
||||
toast.show({ message: `Session exported to ${options.filename.trim()}`, variant: "success" })
|
||||
dialog.clear()
|
||||
```
|
||||
|
||||
**Why:** Style guide says avoid `else`, prefer early returns.
|
||||
|
||||
---
|
||||
|
||||
### 20. Verbose `for` loop in "Jump to last user message" (lines 679–697)
|
||||
|
||||
```tsx
|
||||
// Before (lines 679-697)
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (!message || message.role !== "user") continue
|
||||
const parts = sync.data.part[message.id]
|
||||
if (!parts || !Array.isArray(parts)) continue
|
||||
const hasValidTextPart = parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored)
|
||||
if (hasValidTextPart) {
|
||||
const child = scroll.getChildren().find((child) => child.id === message.id)
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// After — use findLast + functional style
|
||||
const target = messages
|
||||
.filter((m) => m.role === "user")
|
||||
.findLast((m) => {
|
||||
const parts = sync.data.part[m.id]
|
||||
return Array.isArray(parts) && parts.some((p) => p?.type === "text" && !p.synthetic && !p.ignored)
|
||||
})
|
||||
if (target) {
|
||||
const child = scroll.getChildren().find((c) => c.id === target.id)
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Style guide prefers functional array methods over `for` loops.
|
||||
|
||||
---
|
||||
|
||||
### 21. Duplicated diff rendering config (lines 1921–1939 and 1979–1997)
|
||||
|
||||
The `<diff>` element is rendered with identical props in both `Edit` and `ApplyPatch`. This is a clear candidate for extraction:
|
||||
|
||||
```tsx
|
||||
// After — extract shared diff rendering
|
||||
function DiffView(props: { diff?: string; filePath: string }) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const view = createMemo(() => {
|
||||
if (ctx.sync.data.config.tui?.diff_style === "stacked") return "unified"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={props.diff}
|
||||
view={view()}
|
||||
filetype={filetype(props.filePath)}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode={ctx.diffWrapMode()}
|
||||
fg={theme.text}
|
||||
addedBg={theme.diffAddedBg}
|
||||
removedBg={theme.diffRemovedBg}
|
||||
contextBg={theme.diffContextBg}
|
||||
addedSignColor={theme.diffHighlightAdded}
|
||||
removedSignColor={theme.diffHighlightRemoved}
|
||||
lineNumberFg={theme.diffLineNumber}
|
||||
lineNumberBg={theme.diffContextBg}
|
||||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `view` memo is also duplicated between `Edit` (lines 1899–1903) and `ApplyPatch` (lines 1970–1974) — identical logic.
|
||||
|
||||
**Why:** The style guide says keep things in one function _unless composable or reusable_. This diff rendering block is clearly reusable.
|
||||
|
||||
---
|
||||
|
||||
### 22. Shadowed variable name `input` (line 2109)
|
||||
|
||||
There's a top-level utility function named `input` that formats tool input parameters. This shadows the concept of "input" used throughout the file for tool props.
|
||||
|
||||
```tsx
|
||||
// Before (line 2109)
|
||||
function input(input: Record<string, any>, omit?: string[]): string {
|
||||
|
||||
// After — rename the function and parameter
|
||||
function formatInput(params: Record<string, any>, omit?: string[]) {
|
||||
```
|
||||
|
||||
Or even shorter:
|
||||
|
||||
```tsx
|
||||
function params(input: Record<string, any>, omit?: string[]) {
|
||||
```
|
||||
|
||||
**Why:** A function named `input` that takes a parameter named `input` is confusing. Also `Record<string, any>` violates the no-`any` rule.
|
||||
|
||||
---
|
||||
|
||||
### 23. Repeated `sync.data.part[message.id] ?? []` pattern
|
||||
|
||||
This pattern appears on lines 260–261, 466, 683–684, 732, 773, 817, 1063, 1071, 1840. Consider extracting it:
|
||||
|
||||
```tsx
|
||||
// Could be a helper on the context
|
||||
const parts = (id: string) => sync.data.part[id] ?? []
|
||||
```
|
||||
|
||||
**Why:** DRY. Reduces noise in every call site.
|
||||
|
||||
---
|
||||
|
||||
### 24. Inconsistent Switch/Match fallback pattern
|
||||
|
||||
Throughout the file, fallback/default cases use `<Match when={true}>` (lines 684, 1471, 1729, 1886, 1955, 2029, 2050, 2084). This is a SolidJS convention so it's acceptable, but the file mixes `<Match when={true}>` with `<Show fallback={...}>` — the pattern is inconsistent.
|
||||
|
||||
Not a strict style guide violation, but worth noting for consistency.
|
||||
|
||||
---
|
||||
|
||||
### 25. `async` on `createEffect` is suspicious (line 177)
|
||||
|
||||
```tsx
|
||||
// Before (line 177)
|
||||
createEffect(async () => {
|
||||
await sync.session
|
||||
.sync(route.sessionID)
|
||||
.then(...)
|
||||
.catch(...)
|
||||
})
|
||||
```
|
||||
|
||||
The `async` is unnecessary here since the promise is already handled via `.then()/.catch()`. Also, `async` effects in SolidJS don't behave as one might expect — the returned promise is ignored.
|
||||
|
||||
```tsx
|
||||
// After
|
||||
createEffect(() => {
|
||||
sync.session
|
||||
.sync(route.sessionID)
|
||||
.then(() => {
|
||||
if (scroll) scroll.scrollBy(100_000)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
toast.show({ message: `Session not found: ${route.sessionID}`, variant: "error" })
|
||||
return router.navigate({ type: "home" })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** `async` on a SolidJS effect is misleading — the framework doesn't await the return value.
|
||||
|
||||
---
|
||||
|
||||
### 26. Unnecessary explicit type annotation on `revertDiffFiles` return (line 893)
|
||||
|
||||
The `parsePatch` return has hunks with typed lines. The `.map` chain infers the return type perfectly — no annotation needed. The `try/catch` also swallows errors silently, which violates the style guide:
|
||||
|
||||
```tsx
|
||||
// Before (lines 893-917)
|
||||
const revertDiffFiles = createMemo(() => {
|
||||
const diffText = revertInfo()?.diff ?? ""
|
||||
if (!diffText) return []
|
||||
try {
|
||||
const patches = parsePatch(diffText)
|
||||
return patches.map((patch) => {
|
||||
const filename = patch.newFileName || patch.oldFileName || "unknown"
|
||||
const cleanFilename = filename.replace(/^[ab]\//, "")
|
||||
return {
|
||||
filename: cleanFilename,
|
||||
additions: patch.hunks.reduce(...),
|
||||
deletions: patch.hunks.reduce(...),
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// After — inline single-use variables, remove try/catch if parsePatch doesn't throw on valid input
|
||||
const revertDiffFiles = createMemo(() => {
|
||||
const diff = revertInfo()?.diff
|
||||
if (!diff) return []
|
||||
return parsePatch(diff).map((patch) => ({
|
||||
filename: (patch.newFileName || patch.oldFileName || "unknown").replace(/^[ab]\//, ""),
|
||||
additions: patch.hunks.reduce((sum, h) => sum + h.lines.filter((l) => l.startsWith("+")).length, 0),
|
||||
deletions: patch.hunks.reduce((sum, h) => sum + h.lines.filter((l) => l.startsWith("-")).length, 0),
|
||||
}))
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Inlines `filename`/`cleanFilename` (both used once), removes unnecessary `try/catch`.
|
||||
|
||||
---
|
||||
|
||||
### 27. `revertInfo` / `revertMessageID` / `revertDiffFiles` / `revertRevertedMessages` — overly verbose naming (lines 890–935)
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const revertInfo = createMemo(() => session()?.revert)
|
||||
const revertMessageID = createMemo(() => revertInfo()?.messageID)
|
||||
const revertDiffFiles = createMemo(...)
|
||||
const revertRevertedMessages = createMemo(...)
|
||||
|
||||
// After — group under a single memo or use shorter names
|
||||
const revert = createMemo(() => {
|
||||
const info = session()?.revert
|
||||
if (!info?.messageID) return
|
||||
const diff = info.diff
|
||||
const files = diff ? parsePatch(diff).map(...) : []
|
||||
const reverted = messages().filter((x) => x.id >= info.messageID && x.role === "user")
|
||||
return { messageID: info.messageID, reverted, diff, files }
|
||||
})
|
||||
```
|
||||
|
||||
This eliminates 3 intermediate memos (`revertInfo`, `revertMessageID`, `revertDiffFiles`, `revertRevertedMessages`) and replaces them with a single `revert` memo (which already exists on line 925 but depends on all the others).
|
||||
|
||||
**Why:** Reduces total variable count. The intermediate memos aren't used independently enough to justify separate declarations — they're only consumed by the `revert` memo.
|
||||
|
||||
---
|
||||
|
||||
### 28. Verbose arrow function bodies in event handlers (line 1097–1098)
|
||||
|
||||
```tsx
|
||||
// Before (lines 1097-1098)
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
|
||||
// After
|
||||
onSubmit={toBottom}
|
||||
```
|
||||
|
||||
Or if the signature doesn't match:
|
||||
|
||||
```tsx
|
||||
onSubmit={() => toBottom()}
|
||||
```
|
||||
|
||||
**Why:** Unnecessary braces around a single expression.
|
||||
|
||||
---
|
||||
|
||||
### 29. `createMemo` with no reactivity benefit (line 1159)
|
||||
|
||||
```tsx
|
||||
// Before (line 1159)
|
||||
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
|
||||
```
|
||||
|
||||
In SolidJS, props are reactive getters. But `createMemo` here is fine for caching. This is acceptable.
|
||||
|
||||
---
|
||||
|
||||
### 30. Comment that just restates the code (line 1387)
|
||||
|
||||
```tsx
|
||||
// Before (line 1387)
|
||||
// Pending messages moved to individual tool pending functions
|
||||
```
|
||||
|
||||
This comment is outdated/orphaned — it describes something that already happened. Remove it.
|
||||
|
||||
**Why:** Dead comments are noise.
|
||||
|
||||
---
|
||||
|
||||
### 31. Giant `Switch` block for tool dispatch (lines 1425–1474)
|
||||
|
||||
The `ToolPart` component has a 50-line `Switch/Match` block that manually maps tool names to components. This could use the existing `PART_MAPPING`-like pattern:
|
||||
|
||||
```tsx
|
||||
const TOOL_MAPPING: Record<string, (props: ToolProps<any>) => JSX.Element> = {
|
||||
bash: Bash,
|
||||
glob: Glob,
|
||||
read: Read,
|
||||
grep: Grep,
|
||||
list: List,
|
||||
webfetch: WebFetch,
|
||||
codesearch: CodeSearch,
|
||||
websearch: WebSearch,
|
||||
write: Write,
|
||||
edit: Edit,
|
||||
task: Task,
|
||||
apply_patch: ApplyPatch,
|
||||
todowrite: TodoWrite,
|
||||
question: Question,
|
||||
skill: Skill,
|
||||
}
|
||||
|
||||
// Then in ToolPart:
|
||||
const Component = TOOL_MAPPING[props.part.tool] ?? GenericTool
|
||||
return (
|
||||
<Show when={!hidden()}>
|
||||
<Component {...toolprops} />
|
||||
</Show>
|
||||
)
|
||||
```
|
||||
|
||||
**Why:** Eliminates ~50 lines of repetitive Switch/Match. Easier to add new tools. The pattern already exists in `PART_MAPPING` on line 1316.
|
||||
|
||||
---
|
||||
|
||||
### 32. Repeated `dialog.clear()` at end of every `onSelect` handler
|
||||
|
||||
Almost every command handler ends with `dialog.clear()`. This suggests the caller should handle clearing after `onSelect` returns, rather than requiring every handler to remember it.
|
||||
|
||||
Not necessarily a code change in this file alone, but worth noting as a design issue.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Most Impactful Changes
|
||||
|
||||
| Priority | Issue | Lines | Impact |
|
||||
| -------- | ------------------------------ | --------------------------------------- | ------------------------ |
|
||||
| High | `any` type usage | 526, 1265, 1482, 1803, 1809, 1819, 1869 | Type safety |
|
||||
| High | Duplicated diff rendering | 1921–1939, 1979–1997 | ~40 lines of duplication |
|
||||
| High | Giant Switch for tool dispatch | 1425–1474 | ~50 lines → ~5 lines |
|
||||
| Medium | `try/catch` blocks | 767, 796, 897 | Style guide violation |
|
||||
| Medium | Unnecessary destructuring | 113, 116, etc. | Style consistency |
|
||||
| Medium | `for` loop → functional | 679–697 | Style guide violation |
|
||||
| Medium | Intermediate memo chain | 890–935 | 4 memos → 1 memo |
|
||||
| Low | Verbose arrow functions | 1097, 1172 | Minor readability |
|
||||
| Low | Multi-word names | 139, 247, 278 | Style preference |
|
||||
| Low | Inline single-use variables | 249, 802, 829 | Minor cleanup |
|
||||
@@ -1,249 +0,0 @@
|
||||
# Review: `permission.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
Generally well-structured file with clear component separation. The main issues are: inconsistent `useTheme()` usage (destructured in most places but not `EditBody`), unnecessary destructuring of `props`, a for-loop that should be a functional find, an unnecessary IIFE wrapping JSX, a `let` that's forced by ref semantics (acceptable), and some inlineable variables. Most fixes are small but they'd bring the file in line with the style guide.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Inconsistent `useTheme()` pattern in `EditBody` (lines 48-49)
|
||||
|
||||
`EditBody` is the only component that avoids destructuring `useTheme()` — it pulls out `theme` and `syntax` via intermediate `themeState`. Every other component in the file (and 42 other call sites across the codebase) uses `const { theme } = useTheme()`. The `syntax` accessor needs the full object, but the current approach creates an unnecessary intermediate variable name.
|
||||
|
||||
```tsx
|
||||
// before (lines 48-50)
|
||||
const themeState = useTheme()
|
||||
const theme = themeState.theme
|
||||
const syntax = themeState.syntax
|
||||
|
||||
// after
|
||||
const { theme, syntax } = useTheme()
|
||||
```
|
||||
|
||||
**Why:** This is the one place in the file where destructuring is actually the established codebase convention (42+ identical call sites). The intermediate `themeState` variable adds nothing. Normally the style guide says prefer dot notation, but when the entire codebase uses `const { theme } = useTheme()` as an idiom, consistency wins.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary destructuring in `TextBody` (line 99)
|
||||
|
||||
`TextBody` destructures `theme` from `useTheme()` — this is consistent with the rest of the codebase so it's fine. However, the `props` object is accessed via dot notation correctly throughout. No issue here; noting for completeness.
|
||||
|
||||
---
|
||||
|
||||
### 3. For-loop should be a functional `.find()` (lines 131-137)
|
||||
|
||||
The `input` memo uses a `for` loop to search for a matching part. This is a classic case for `.find()`.
|
||||
|
||||
```tsx
|
||||
// before (lines 128-138)
|
||||
const input = createMemo(() => {
|
||||
const tool = props.request.tool
|
||||
if (!tool) return {}
|
||||
const parts = sync.data.part[tool.messageID] ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
|
||||
return part.state.input ?? {}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
// after
|
||||
const input = createMemo(() => {
|
||||
const tool = props.request.tool
|
||||
if (!tool) return {}
|
||||
const parts = sync.data.part[tool.messageID] ?? []
|
||||
const match = parts.find((p) => p.type === "tool" && p.callID === tool.callID && p.state.status !== "pending")
|
||||
return match?.state.input ?? {}
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** The style guide says "prefer functional array methods over for loops." The `.find()` version is shorter, declarative, and eliminates the early-return-from-loop pattern.
|
||||
|
||||
Note: `tool` is extracted to a local variable because it's used twice (`tool.messageID`, `tool.callID`) — this is justified per the style guide's "inline when used once" rule.
|
||||
|
||||
---
|
||||
|
||||
### 4. Unnecessary IIFE wrapping JSX in `PermissionPrompt` (lines 196-295)
|
||||
|
||||
The `"permission"` stage match wraps its entire body in an IIFE `{(() => { const body = (...); return body })()}`. The `body` variable is assigned and immediately returned — the IIFE and variable serve no purpose.
|
||||
|
||||
```tsx
|
||||
// before (lines 196-295)
|
||||
<Match when={store.stage === "permission"}>
|
||||
{(() => {
|
||||
const body = (
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
...
|
||||
/>
|
||||
)
|
||||
|
||||
return body
|
||||
})()}
|
||||
</Match>
|
||||
|
||||
// after
|
||||
<Match when={store.stage === "permission"}>
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
...
|
||||
/>
|
||||
</Match>
|
||||
```
|
||||
|
||||
**Why:** The IIFE adds nesting and cognitive overhead for zero benefit. It looks like leftover scaffolding from when there may have been additional logic around the `body` variable. Removing it makes the structure match the other `<Match>` branches.
|
||||
|
||||
---
|
||||
|
||||
### 5. Inlineable variables in `external_directory` handler (lines 241-256)
|
||||
|
||||
The `external_directory` match has multiple intermediate variables (`meta`, `parent`, `filepath`, `pattern`, `derived`, `raw`, `dir`) where several are only used once. This can be tightened, though some intermediates do aid readability. The main candidates for inlining are `raw` and `dir`.
|
||||
|
||||
```tsx
|
||||
// before (lines 241-256)
|
||||
{
|
||||
;(() => {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.patterns?.[0]
|
||||
const derived = typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = normalizePath(raw)
|
||||
|
||||
return <TextBody icon="←" title={`Access external directory ` + dir} />
|
||||
})()
|
||||
}
|
||||
|
||||
// after
|
||||
{
|
||||
;(() => {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.patterns?.[0]
|
||||
const derived = typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||
|
||||
return <TextBody icon="←" title={"Access external directory " + normalizePath(parent ?? filepath ?? derived)} />
|
||||
})()
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `raw` and `dir` are each used exactly once. Inlining them reduces variable count per the style guide. The remaining variables (`meta`, `parent`, `filepath`, `pattern`, `derived`) are justified — they're either used more than once or significantly aid readability of the coalesce chain.
|
||||
|
||||
---
|
||||
|
||||
### 6. `let input` in `RejectPrompt` (line 302)
|
||||
|
||||
```tsx
|
||||
// line 302
|
||||
let input: TextareaRenderable
|
||||
```
|
||||
|
||||
This is a `let` with an explicit type annotation. Normally the style guide prefers `const` and inference. However, this is a SolidJS `ref` pattern — the value is assigned later via `ref={(val) => (input = val)}` on line 353. This is an established SolidJS idiom and the type annotation is required since there's no initializer for inference.
|
||||
|
||||
**Verdict:** Acceptable as-is. This is a framework-imposed pattern, not a style issue.
|
||||
|
||||
---
|
||||
|
||||
### 7. `useRenderer()` is called but unused in `Prompt` (line 428)
|
||||
|
||||
```tsx
|
||||
// line 428
|
||||
const renderer = useRenderer()
|
||||
```
|
||||
|
||||
`renderer` is never referenced anywhere in the `Prompt` function body or JSX. It should be removed.
|
||||
|
||||
```tsx
|
||||
// before (line 428)
|
||||
const renderer = useRenderer()
|
||||
|
||||
// after
|
||||
// (delete the line)
|
||||
```
|
||||
|
||||
**Why:** Dead code. It's likely a leftover from a previous iteration. Removing it eliminates a confusing signal to readers and removes an unused import dependency.
|
||||
|
||||
After removing this, `useRenderer` can also be removed from the import on line 3:
|
||||
|
||||
```tsx
|
||||
// before (line 3)
|
||||
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
|
||||
// after
|
||||
import { Portal, useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
```
|
||||
|
||||
Note: `useRenderer` is still used transitively by other code if needed, but check if `EditBody` or any other component in this file uses it. Searching the file — `useRenderer` only appears on lines 3 and 428, confirming it's safe to remove.
|
||||
|
||||
---
|
||||
|
||||
### 8. `==` instead of `===` in keyboard handler (lines 396, 402)
|
||||
|
||||
```tsx
|
||||
// line 396
|
||||
if (evt.name === "left" || evt.name == "h") {
|
||||
// line 402
|
||||
if (evt.name === "right" || evt.name == "l") {
|
||||
```
|
||||
|
||||
These mix `===` and `==` in the same expression. The `==` for `"h"` and `"l"` is almost certainly unintentional — there's no reason to use loose equality here.
|
||||
|
||||
```tsx
|
||||
// after
|
||||
if (evt.name === "left" || evt.name === "h") {
|
||||
if (evt.name === "right" || evt.name === "l") {
|
||||
```
|
||||
|
||||
**Why:** Inconsistent equality operators in the same condition are a code smell. Strict equality is always preferred when comparing strings.
|
||||
|
||||
---
|
||||
|
||||
### 9. Unnecessary `PermissionStage` type alias (line 19)
|
||||
|
||||
```tsx
|
||||
// line 19
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
```
|
||||
|
||||
This type is only used on line 123 as a cast: `"permission" as PermissionStage`. If the store were typed properly via inference or the `createStore` generic, the alias wouldn't be needed. However, SolidJS `createStore` doesn't always infer literal types from initial values, so this cast is a pragmatic workaround.
|
||||
|
||||
**Verdict:** Acceptable, but could be inlined into the cast site if preferred:
|
||||
|
||||
```tsx
|
||||
// alternative (line 122-124)
|
||||
const [store, setStore] = createStore({
|
||||
stage: "permission" as "permission" | "always" | "reject",
|
||||
})
|
||||
```
|
||||
|
||||
This is a style preference — the named type is arguably clearer. No strong recommendation to change.
|
||||
|
||||
---
|
||||
|
||||
### 10. Unnecessary explicit type annotation on `keys` (line 384)
|
||||
|
||||
```tsx
|
||||
// line 384
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
```
|
||||
|
||||
The `as` cast is necessary here because `Object.keys` returns `string[]` in TypeScript. This is a well-known TS limitation and the cast is justified. No change needed.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Recommended Changes
|
||||
|
||||
| Priority | Issue | Lines | Impact |
|
||||
| -------- | --------------------------------------------------- | -------- | ------------------------------------- |
|
||||
| High | Remove unnecessary IIFE wrapper | 196-295 | Reduces nesting, improves readability |
|
||||
| High | Remove unused `useRenderer()` + import | 3, 428 | Dead code removal |
|
||||
| Medium | Replace for-loop with `.find()` | 128-138 | Follows style guide, more declarative |
|
||||
| Medium | Fix `==` to `===` | 396, 402 | Correctness / consistency |
|
||||
| Low | Consistent `useTheme()` destructuring in `EditBody` | 48-50 | Consistency with codebase convention |
|
||||
| Low | Inline `raw`/`dir` variables | 253-254 | Reduces variable count |
|
||||
@@ -1,472 +0,0 @@
|
||||
# Code Review: `question.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is functional but has several style guide violations and readability issues. The main problems are: unnecessary destructuring, unnecessary memos that add indirection without benefit, `else` blocks that should be early returns, intermediate variables used only once, and a large keyboard handler that's hard to follow. The JSX portion is reasonable given the UI complexity, though there are minor simplifications possible.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useTheme()` (line 15)
|
||||
|
||||
The style guide says to avoid destructuring and prefer dot notation. `theme` is extracted from `useTheme()` but this is the only field used, adding an unnecessary destructuring step.
|
||||
|
||||
**Before (line 15):**
|
||||
|
||||
```tsx
|
||||
const { theme } = useTheme()
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const theme = useTheme().theme
|
||||
```
|
||||
|
||||
**Why:** Follows the style guide preference against destructuring. Preserves the origin of the value.
|
||||
|
||||
---
|
||||
|
||||
### 2. Excessive memo indirection (lines 19-44)
|
||||
|
||||
Many of these memos simply re-derive a property from another memo and are called exactly once in the keyboard handler or JSX. They obscure what's happening by forcing the reader to jump back to definitions. Some are warranted (e.g., `question`, `confirm`, `options` — used many times), but several are used only once or twice and just wrap a trivial expression.
|
||||
|
||||
Specifically:
|
||||
|
||||
- `custom` (line 36) — just `question()?.custom !== false`. Used twice (line 37 and JSX). Borderline, but the name `custom` shadows the parameter name `custom` in `pick()` on line 60, which causes a real bug-risk/confusion issue (see issue #3).
|
||||
- `other` (line 37) — `custom() && store.selected === options().length`. Used in keyboard handler and JSX. Acceptable as a memo.
|
||||
- `input` (line 38) — `store.custom[store.tab] ?? ""`. Used several times, fine.
|
||||
- `multi` (line 39) — `question()?.multiple === true`. Used many times, fine.
|
||||
- `customPicked` (lines 40-44) — contains an intermediate variable `value` that's used only once.
|
||||
|
||||
**Before (lines 40-44):**
|
||||
|
||||
```tsx
|
||||
const customPicked = createMemo(() => {
|
||||
const value = input()
|
||||
if (!value) return false
|
||||
return store.answers[store.tab]?.includes(value) ?? false
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const customPicked = createMemo(() => {
|
||||
if (!input()) return false
|
||||
return store.answers[store.tab]?.includes(input()) ?? false
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** `value` is an intermediate variable for something used only in two spots in a 3-line function. Inlining it is clearer. (Note: in Solid, `input()` is a memo so calling it twice has no perf cost.)
|
||||
|
||||
---
|
||||
|
||||
### 3. `custom` parameter shadows memo name (line 60)
|
||||
|
||||
The `pick` function has a parameter named `custom` that shadows the memo `custom` on line 36. This is confusing and error-prone.
|
||||
|
||||
**Before (line 60):**
|
||||
|
||||
```tsx
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function pick(answer: string, isCustom = false) {
|
||||
```
|
||||
|
||||
And update the usage on line 64:
|
||||
|
||||
```tsx
|
||||
if (isCustom) {
|
||||
```
|
||||
|
||||
And the call site on line 184:
|
||||
|
||||
```tsx
|
||||
pick(text, true) // no change needed, positional
|
||||
```
|
||||
|
||||
**Why:** Avoids shadowing the outer `custom` memo. The reader doesn't have to wonder which `custom` is being referenced. Also, the `boolean = false` type annotation is unnecessary — TypeScript infers it from the default value.
|
||||
|
||||
---
|
||||
|
||||
### 4. Unnecessary type annotation on `custom` parameter (line 60)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function pick(answer: string, isCustom = false) {
|
||||
```
|
||||
|
||||
**Why:** The type `boolean` is inferred from the default value `false`. The style guide says to rely on type inference when possible.
|
||||
|
||||
---
|
||||
|
||||
### 5. `else` block in keyboard handler (lines 217-250)
|
||||
|
||||
The style guide says to avoid `else` and prefer early returns. The large `if (confirm()) { ... } else { ... }` block on lines 208-250 uses an `else` that makes the code harder to scan.
|
||||
|
||||
**Before (lines 208-250):**
|
||||
|
||||
```tsx
|
||||
if (confirm()) {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
submit()
|
||||
}
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
} else {
|
||||
const opts = options()
|
||||
const total = opts.length + (custom() ? 1 : 0)
|
||||
// ... rest of handler
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
if (confirm()) {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
submit()
|
||||
}
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const total = options().length + (custom() ? 1 : 0)
|
||||
const max = Math.min(total, 9)
|
||||
const digit = Number(evt.name)
|
||||
// ... rest of handler (no longer nested in else)
|
||||
```
|
||||
|
||||
**Why:** Eliminates the `else` block and reduces nesting by one level. The `return` after the confirm block makes the control flow explicit. The entire non-confirm branch is now at the top level of the callback, making it easier to read.
|
||||
|
||||
---
|
||||
|
||||
### 6. Intermediate variable `opts` used only for `.length` (line 218)
|
||||
|
||||
**Before (lines 218-219):**
|
||||
|
||||
```tsx
|
||||
const opts = options()
|
||||
const total = opts.length + (custom() ? 1 : 0)
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const total = options().length + (custom() ? 1 : 0)
|
||||
```
|
||||
|
||||
**Why:** `opts` is only used for `.length`. Inlining reduces variable count per the style guide.
|
||||
|
||||
---
|
||||
|
||||
### 7. Intermediate variable `index` used only once (lines 225-227)
|
||||
|
||||
**Before (lines 225-227):**
|
||||
|
||||
```tsx
|
||||
const index = digit - 1
|
||||
moveTo(index)
|
||||
selectOption()
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
moveTo(digit - 1)
|
||||
selectOption()
|
||||
```
|
||||
|
||||
**Why:** `index` is used exactly once. Inlining is clearer and reduces variable count.
|
||||
|
||||
---
|
||||
|
||||
### 8. Intermediate variable `direction` used only once (line 204)
|
||||
|
||||
**Before (lines 204-205):**
|
||||
|
||||
```tsx
|
||||
const direction = evt.shift ? -1 : 1
|
||||
selectTab((store.tab + direction + tabs()) % tabs())
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
selectTab((store.tab + (evt.shift ? -1 : 1) + tabs()) % tabs())
|
||||
```
|
||||
|
||||
**Why:** `direction` is used once. The inline ternary is compact and readable enough in context.
|
||||
|
||||
---
|
||||
|
||||
### 9. `moveTo` function is trivial indirection (lines 91-93)
|
||||
|
||||
**Before (lines 91-93):**
|
||||
|
||||
```tsx
|
||||
function moveTo(index: number) {
|
||||
setStore("selected", index)
|
||||
}
|
||||
```
|
||||
|
||||
This function is a single `setStore` call. It's called in 6 places. While it does provide a semantic name, the style guide says to keep things in one function unless composable or reusable. This is technically reusable, so it's borderline acceptable. However, `moveTo` is a vague name — `moveTo` what? It should at least be clearer.
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function select(index: number) {
|
||||
setStore("selected", index)
|
||||
}
|
||||
```
|
||||
|
||||
Or simply inline `setStore("selected", ...)` at call sites since the intent is clear from the store key name.
|
||||
|
||||
**Why:** `moveTo` doesn't communicate what is being moved. `select` or direct `setStore` calls would be clearer.
|
||||
|
||||
---
|
||||
|
||||
### 10. `selectTab` could be simplified (lines 95-98)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
function selectTab(index: number) {
|
||||
setStore("tab", index)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
```
|
||||
|
||||
Minor: two sequential `setStore` calls could use the batch form, but Solid's `createStore` doesn't support multi-key set in one call, so this is fine as-is. No change needed.
|
||||
|
||||
---
|
||||
|
||||
### 11. Repeated pattern for updating `store.custom` and `store.answers` (lines 61-68, 80-88, 148-179)
|
||||
|
||||
The pattern of spreading `store.custom` or `store.answers` into a new array, mutating an index, then calling `setStore` appears 5+ times. This is a candidate for extraction.
|
||||
|
||||
**Suggestion — helper functions:**
|
||||
|
||||
```tsx
|
||||
function setCustom(tab: number, value: string) {
|
||||
const next = [...store.custom]
|
||||
next[tab] = value
|
||||
setStore("custom", next)
|
||||
}
|
||||
|
||||
function setAnswers(tab: number, values: string[]) {
|
||||
const next = [...store.answers]
|
||||
next[tab] = values
|
||||
setStore("answers", next)
|
||||
}
|
||||
```
|
||||
|
||||
Then the `pick` function becomes:
|
||||
|
||||
**Before (lines 60-78):**
|
||||
|
||||
```tsx
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = answer
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function pick(answer: string, isCustom = false) {
|
||||
setAnswers(store.tab, [answer])
|
||||
if (isCustom) setCustom(store.tab, answer)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Similarly `toggle` simplifies:
|
||||
|
||||
**Before (lines 80-89):**
|
||||
|
||||
```tsx
|
||||
function toggle(answer: string) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
const index = next.indexOf(answer)
|
||||
if (index === -1) next.push(answer)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function toggle(answer: string) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = existing.includes(answer) ? existing.filter((x) => x !== answer) : [...existing, answer]
|
||||
setAnswers(store.tab, next)
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Eliminates repeated boilerplate. The `toggle` rewrite also replaces imperative indexOf/splice with functional `filter`/spread, which is more idiomatic per the style guide's preference for functional array methods. The two mutually exclusive `if` statements (lines 84-85) checking the same condition are especially awkward — they should be an if/else or a ternary.
|
||||
|
||||
---
|
||||
|
||||
### 12. Two mutually exclusive `if` without `else` in `toggle` (lines 84-85)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const index = next.indexOf(answer)
|
||||
if (index === -1) next.push(answer)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
```
|
||||
|
||||
These are logically `if/else` but written as two separate `if` checks on opposite conditions. This is confusing — the reader has to verify they're mutually exclusive.
|
||||
|
||||
**After (using functional approach from issue #11):**
|
||||
|
||||
```tsx
|
||||
const next = existing.includes(answer) ? existing.filter((x) => x !== answer) : [...existing, answer]
|
||||
```
|
||||
|
||||
**Why:** Mutually exclusive conditions should be expressed as a single branching construct, not two independent `if` statements. The functional approach avoids mutation entirely.
|
||||
|
||||
---
|
||||
|
||||
### 13. `submit` function has an intermediate `answers` variable (lines 46-52)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
function submit() {
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function submit() {
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers: questions().map((_, i) => store.answers[i] ?? []),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `answers` is used once. Inline it to reduce variable count.
|
||||
|
||||
---
|
||||
|
||||
### 14. `reject` function body could be more concise (lines 54-58)
|
||||
|
||||
The object literal is spread across 3 lines for a single property:
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
function reject() {
|
||||
sdk.client.question.reject({
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This is fine stylistically. No change needed — just noting it's already concise enough.
|
||||
|
||||
---
|
||||
|
||||
### 15. `tabHover` signal type is overly broad (line 22)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
```
|
||||
|
||||
The `"confirm"` string literal is used only for the confirm tab. Since confirm is the last tab (index `questions().length`), this could just be `number | null` and use the actual index. But this is a design choice that affects readability of the JSX (`tabHover() === "confirm"` is arguably more readable than `tabHover() === questions().length`). **No change recommended** — this is a reasonable tradeoff.
|
||||
|
||||
---
|
||||
|
||||
### 16. Large keyboard handler could benefit from extraction (lines 125-251)
|
||||
|
||||
The `useKeyboard` callback is ~125 lines. While the style guide says keep things in one function, this handler has two clearly distinct modes (editing mode vs. navigation mode) plus confirm vs. question handling. Breaking the editing-mode handling into a separate function would improve readability.
|
||||
|
||||
**Suggestion:**
|
||||
|
||||
```tsx
|
||||
function handleEditing(evt: KeyboardEvent): boolean {
|
||||
// returns true if handled
|
||||
if (!store.editing || confirm()) return false
|
||||
|
||||
if (evt.name === "escape") {
|
||||
evt.preventDefault()
|
||||
setStore("editing", false)
|
||||
return true
|
||||
}
|
||||
// ... rest of editing handler
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
Then in `useKeyboard`:
|
||||
|
||||
```tsx
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
if (handleEditing(evt)) return
|
||||
// ... navigation/selection handling
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Reduces cognitive load. The reader can understand the keyboard handler's structure at a glance: "skip if dialog open, handle editing mode, handle navigation." Each piece is independently readable.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes by Priority
|
||||
|
||||
| Priority | Issue | Lines |
|
||||
| -------- | ------------------------------------------------------------------------------ | --------------------- |
|
||||
| High | `else` block → early return in keyboard handler | 208-250 |
|
||||
| High | Mutually exclusive `if` statements in `toggle` | 84-85 |
|
||||
| High | `custom` parameter shadows memo name | 60 |
|
||||
| High | Extract repeated array-update boilerplate | 61-68, 80-88, 148-179 |
|
||||
| Medium | Unnecessary destructuring of `useTheme()` | 15 |
|
||||
| Medium | Inline single-use variables (`opts`, `index`, `direction`, `answers`, `value`) | 218, 225, 204, 47, 41 |
|
||||
| Medium | Unnecessary type annotation on default parameter | 60 |
|
||||
| Low | Rename `moveTo` → `select` or inline | 91-93 |
|
||||
| Low | Extract editing keyboard handler | 125-190 |
|
||||
| Low | Inline `answers` in `submit` | 46-52 |
|
||||
@@ -1,246 +0,0 @@
|
||||
# Code Review: sidebar.tsx
|
||||
|
||||
## Overall Quality
|
||||
|
||||
The file is functional but has several issues: dead imports, unnecessary comments, a variable shadowing bug, redundant null coalescing, and some patterns that don't align with the project style guide. The JSX structure has a lot of repetition in the collapsible section pattern that hurts readability.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Dead imports (lines 5, 6, 8, 10)
|
||||
|
||||
Four imports are unused. `Locale`, `path`, `Global`, and `useKeybind` are imported but never referenced in the function body. Dead imports are noise and suggest leftover refactoring.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
import { Locale } from "@/util/locale"
|
||||
import path from "path"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Global } from "@/global"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Installation } from "@/installation"
|
||||
```
|
||||
|
||||
### 2. Unnecessary comments on self-explanatory memos (lines 30-31, 33-34)
|
||||
|
||||
The comments restate exactly what the code does. `mcpEntries` clearly sorts MCP entries alphabetically; `connectedMcpCount` clearly counts connected MCPs. Comments should explain _why_, not _what_.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
// Sort MCP servers alphabetically for consistent display order
|
||||
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
|
||||
|
||||
// Count connected and error MCP servers for collapsed header display
|
||||
const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
|
||||
const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
|
||||
```
|
||||
|
||||
### 3. Variable shadowing in todo `<For>` callback (line 220)
|
||||
|
||||
The `<For>` callback parameter `todo` shadows the outer `todo` memo from line 20. This is a bug waiting to happen — if someone tries to access the array `todo()` inside this callback, they'll get the individual item instead.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<For each={todo()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
|
||||
```
|
||||
|
||||
### 4. Redundant `|| []` on a value already defaulted (line 239)
|
||||
|
||||
`diff()` is defined on line 19 with `?? []`, so it already returns an empty array when there's no data. The `|| []` on line 239 is redundant and misleading — it suggests the value could be falsy when it can't be.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<For each={diff() || []}>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<For each={diff()}>
|
||||
```
|
||||
|
||||
### 5. Unnecessary block body with explicit return in `<For>` callback (lines 240-256)
|
||||
|
||||
The diff `<For>` callback uses `{(item) => { return (...) }}` when a concise arrow `{(item) => (...)}` would do. Every other `<For>` in this file uses the concise form — this one is inconsistent.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<For each={diff()}>
|
||||
{(item) => {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
...
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<For each={diff()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
...
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
```
|
||||
|
||||
### 6. Type casts `(item.status as string)` suggest a type gap (lines 149-150)
|
||||
|
||||
Casting `item.status as string` to compare against `"needs_auth"` and `"needs_client_registration"` means the SDK type doesn't include these values, but the runtime does. This is a code smell — the cast silences the type system. There's not much to do without fixing the upstream type, but this should be tracked. At minimum, the same pattern is used at lines 38-40 in the `errorMcpCount` memo where the comparison works without a cast — that inconsistency is confusing.
|
||||
|
||||
### 7. `as Record<string, typeof theme.success>` type assertion for status color map (line 136)
|
||||
|
||||
The inline object mapping statuses to colors is cast to `Record<string, ...>` to allow arbitrary key indexing. A helper function or a more explicit lookup would be safer and more readable.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
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
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
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 mostly a formatting nit — the extra parentheses wrapping and indentation make it look more complex than it is. Flattening the expression onto fewer lines improves scanability. However, the `as Record<string, ...>` cast itself is still a smell tied to issue #6's incomplete status type.
|
||||
|
||||
### 8. `directory()` split twice on the same value (line 299-300)
|
||||
|
||||
`directory()` is called and `.split("/")` is performed twice — once to get everything except the last segment, and again to get the last segment. This is minor but could be a single split.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
|
||||
<span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span>
|
||||
</text>
|
||||
```
|
||||
|
||||
**After — using a memo or inline:**
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{directory().slice(0, directory().lastIndexOf("/") + 1)}</span>
|
||||
<span style={{ fg: theme.text }}>{directory().slice(directory().lastIndexOf("/") + 1)}</span>
|
||||
</text>
|
||||
```
|
||||
|
||||
Or keep it as-is — this is a minor readability preference. Both are clear, but the double split is slightly wasteful.
|
||||
|
||||
### 9. `Intl.NumberFormat` created on every recompute (lines 45-48)
|
||||
|
||||
The `cost` memo constructs a new `Intl.NumberFormat` every time messages change. The formatter is stateless and could be hoisted out of the component.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const cost = createMemo(() => {
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
// At module level:
|
||||
const currencyFormat = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
// In component:
|
||||
const cost = createMemo(() => {
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return currencyFormat.format(total)
|
||||
})
|
||||
```
|
||||
|
||||
### 10. `context` memo has an intermediate variable `total` that could be inlined (lines 54-55)
|
||||
|
||||
The `total` variable is only used twice (for `tokens` and `percentage`), but it's a sum of five terms so inlining would hurt readability. However, the `model` variable on line 56 is only used once — on line 59 — and could be inlined per the style guide's "reduce variable count" rule.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
return {
|
||||
tokens: total.toLocaleString(),
|
||||
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const limit = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]?.limit.context
|
||||
return {
|
||||
tokens: total.toLocaleString(),
|
||||
percentage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
```
|
||||
|
||||
This also uses a shorter, more descriptive name (`limit`) for what we actually care about.
|
||||
|
||||
### 11. `{ theme }` destructuring from `useTheme()` (line 17)
|
||||
|
||||
Per the style guide, prefer dot notation over destructuring. However, this pattern (`const { theme } = useTheme()`) is used across 20+ files in this codebase and `useTheme()` returns multiple properties. Changing it here alone would be inconsistent — this is a codebase-wide decision, not a sidebar-specific fix.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Review: `dialog-alert.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
This is a small, focused component. Overall quality is decent -- the JSX structure is clean and the `show` static method is a nice pattern. However, there are a few style guide violations and one minor readability improvement.
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useTheme()` (line 14)
|
||||
|
||||
The style guide says: "Avoid unnecessary destructuring. Use dot notation to preserve context."
|
||||
|
||||
`useTheme()` returns an object with `theme`, `selected`, `syntax`, etc. Destructuring `{ theme }` here loses that context. Since only `theme` is used, dot notation via a single variable is cleaner.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const dialog = useDialog()
|
||||
const ctx = useTheme()
|
||||
```
|
||||
|
||||
Then replace all `theme.` references with `ctx.theme.` (lines 25, 28, 33, 39, 45).
|
||||
|
||||
However -- looking at the broader codebase, `const { theme } = useTheme()` is used pervasively (including in `dialog.tsx` line 16). This is a codebase-wide pattern, not a local issue. Changing it here alone would create inconsistency. If the style guide is to be enforced, it should be done as a sweeping change. **Pragmatically, this is low priority.**
|
||||
|
||||
### 2. Unused import: `DialogContext` type (line 3)
|
||||
|
||||
`DialogContext` is imported on line 3 but is only used in the `show` static method parameter on line 52. This is fine -- it _is_ used. No issue here on second look.
|
||||
|
||||
### 3. Duplicated confirm-and-close logic (lines 17-20, 40-43)
|
||||
|
||||
The keyboard handler and the button's `onMouseUp` both do the same thing:
|
||||
|
||||
```tsx
|
||||
// line 17-20
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
|
||||
// line 40-43
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
```
|
||||
|
||||
This is a small duplication. Extracting it into a local function would reduce the chance of them diverging:
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}
|
||||
})
|
||||
|
||||
// ... later in JSX:
|
||||
onMouseUp={() => {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function confirm() {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") confirm()
|
||||
})
|
||||
|
||||
// ... later in JSX:
|
||||
onMouseUp = { confirm }
|
||||
```
|
||||
|
||||
**Why:** Eliminates duplication. If the confirm behavior ever changes (e.g., adding analytics or animation), you only update one place. Also makes the JSX more concise.
|
||||
|
||||
That said, the style guide says "Keep things in one function unless composable or reusable" -- and this _is_ reused (twice), so extraction is justified.
|
||||
|
||||
### 4. No other issues
|
||||
|
||||
The file is clean. Naming is fine (`dialog`, `props`). No `let` where `const` would work. No `else` statements. No `try/catch`. No `any` type. No unnecessary type annotations (the exported `DialogAlertProps` type is necessary for the public API). No for loops. The component is short and readable.
|
||||
|
||||
## Final Assessment
|
||||
|
||||
One actionable improvement: extract the duplicated confirm+clear logic into a local `confirm` function. Everything else is consistent with codebase conventions.
|
||||
@@ -1,127 +0,0 @@
|
||||
# Review: dialog-confirm.tsx
|
||||
|
||||
## Summary
|
||||
|
||||
This is a small, well-structured component. The code is clean and readable overall. There are only a few minor issues worth addressing — an unused import, an unnecessary callback parameter, and one `export` that could arguably be dropped.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unused import: `For` from `solid-js` could be dropped in favor of a simpler pattern (line 5)
|
||||
|
||||
`For` is a SolidJS control flow component for rendering dynamic lists. Here the array `["cancel", "confirm"]` is a static literal — it never changes. Using `For` adds indirection for something that could just be two inline `<box>` elements, removing the runtime overhead of a reactive list and the need for the `For` import entirely.
|
||||
|
||||
However, this is a **minor tradeoff**: the `For` approach avoids duplicating the `<box>` + `<text>` markup. Both are reasonable. If the team prefers DRY over simplicity here, the current code is fine. But the duplication is only ~8 lines, and inlining makes each button's intent immediately visible without mentally mapping `key === "confirm"` / `key === "cancel"` branches.
|
||||
|
||||
**Before (lines 48-65):**
|
||||
|
||||
```tsx
|
||||
<For each={["cancel", "confirm"]}>
|
||||
{(key) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={key === store.active ? theme.primary : undefined}
|
||||
onMouseUp={(evt) => {
|
||||
if (key === "confirm") props.onConfirm?.()
|
||||
if (key === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>{Locale.titlecase(key)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
;(["cancel", "confirm"] as const).map((key) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={key === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
if (key === "confirm") props.onConfirm?.()
|
||||
if (key === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>{Locale.titlecase(key)}</text>
|
||||
</box>
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** For a static array, `.map()` is simpler and removes the `For` import. The `For` component exists for reactive lists where items can be added/removed — overkill for a fixed 2-element array. This also aligns with the style guide's preference for functional array methods (`map`, `filter`, `flatMap`) over alternatives.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unused `evt` parameter in `onMouseUp` callback (line 54)
|
||||
|
||||
The `onMouseUp` handler inside the `For` loop declares `(evt) => { ... }` but never uses `evt`.
|
||||
|
||||
**Before (line 54):**
|
||||
|
||||
```tsx
|
||||
onMouseUp={(evt) => {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
onMouseUp={() => {
|
||||
```
|
||||
|
||||
**Why:** Unused parameters are noise. Removing `evt` signals to the reader that the event object isn't needed, which makes the callback's intent clearer at a glance.
|
||||
|
||||
---
|
||||
|
||||
### 3. `DialogConfirmProps` type export may be unnecessary (line 9)
|
||||
|
||||
`DialogConfirmProps` is exported but likely only consumed internally by this file. If no external consumer imports it, the `export` keyword adds false surface area to the module's public API.
|
||||
|
||||
**Before (line 9):**
|
||||
|
||||
```tsx
|
||||
export type DialogConfirmProps = {
|
||||
```
|
||||
|
||||
**After (if no external consumers):**
|
||||
|
||||
```tsx
|
||||
type DialogConfirmProps = {
|
||||
```
|
||||
|
||||
**Why:** Keeping exports minimal makes it easier to understand what a module's public contract is. If this type is imported elsewhere, keep the export.
|
||||
|
||||
---
|
||||
|
||||
## Non-issues (things that look like issues but aren't)
|
||||
|
||||
### `const { theme } = useTheme()` (line 18)
|
||||
|
||||
This destructuring violates the style guide's "avoid unnecessary destructuring" rule in isolation. However, every single dialog file in the `ui/` directory uses this exact pattern (`dialog-alert.tsx`, `dialog-select.tsx`, `dialog-prompt.tsx`, `dialog-help.tsx`, `dialog.tsx`, `toast.tsx`). This is clearly an established codebase convention for `useTheme()`. Changing it only in this file would create inconsistency, which is worse than the destructuring itself. If this pattern should change, it should change everywhere at once.
|
||||
|
||||
### `createStore` with inline type cast (line 20)
|
||||
|
||||
```tsx
|
||||
const [store, setStore] = createStore({
|
||||
active: "confirm" as "confirm" | "cancel",
|
||||
})
|
||||
```
|
||||
|
||||
The `as` cast is necessary here because TypeScript would infer the type as `string` without it, and the store needs the narrower union type for the equality checks on lines 25-26 and 31 to be type-safe. This is fine.
|
||||
|
||||
### The `show` static method pattern (lines 71-85)
|
||||
|
||||
Attaching `show` as a static method on `DialogConfirm` is an established pattern in this codebase (same pattern exists on `DialogAlert.show`). It cleanly encapsulates the Promise-based dialog flow. No issue here.
|
||||
|
||||
---
|
||||
|
||||
## Final Assessment
|
||||
|
||||
This file is in good shape. The only concrete fix is removing the unused `evt` parameter (issue #2). Issues #1 and #3 are judgment calls that depend on team preference. The file is concise, follows codebase conventions, and is easy to understand.
|
||||
@@ -1,264 +0,0 @@
|
||||
# Review: `dialog-export-options.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file works but has significant repetition and inconsistency with the project style guide. The main problems: (1) the checkbox UI is copy-pasted four times with no abstraction, (2) the confirm payload is assembled identically in two places, (3) unnecessary destructuring and unused imports, and (4) the `onConfirm` callback in `show()` wraps `resolve` for no reason.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useTheme()` (line 26)
|
||||
|
||||
The style guide says to avoid unnecessary destructuring and use dot notation. Every other reference is `theme.something`, so `theme` is fine, but the `{ theme }` destructure is consistent with the rest of the codebase (`dialog-confirm.tsx`, `dialog-prompt.tsx` all do this). **No change needed here** -- it's an established codebase pattern.
|
||||
|
||||
### 2. Duplicated confirm payload (lines 37-44, 92-99)
|
||||
|
||||
The exact same object is assembled in two places: the `useKeyboard` handler and the `onSubmit` handler. This is a maintenance hazard -- if a new option is added, both must be updated.
|
||||
|
||||
**Before (lines 36-45 and 91-100):**
|
||||
|
||||
```tsx
|
||||
// in useKeyboard
|
||||
if (evt.name === "return") {
|
||||
props.onConfirm?.({
|
||||
filename: textarea.plainText,
|
||||
thinking: store.thinking,
|
||||
toolDetails: store.toolDetails,
|
||||
assistantMetadata: store.assistantMetadata,
|
||||
openWithoutSaving: store.openWithoutSaving,
|
||||
})
|
||||
}
|
||||
|
||||
// in onSubmit
|
||||
onSubmit={() => {
|
||||
props.onConfirm?.({
|
||||
filename: textarea.plainText,
|
||||
thinking: store.thinking,
|
||||
toolDetails: store.toolDetails,
|
||||
assistantMetadata: store.assistantMetadata,
|
||||
openWithoutSaving: store.openWithoutSaving,
|
||||
})
|
||||
}}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
function confirm() {
|
||||
props.onConfirm?.({
|
||||
filename: textarea.plainText,
|
||||
thinking: store.thinking,
|
||||
toolDetails: store.toolDetails,
|
||||
assistantMetadata: store.assistantMetadata,
|
||||
openWithoutSaving: store.openWithoutSaving,
|
||||
})
|
||||
}
|
||||
|
||||
// in useKeyboard
|
||||
if (evt.name === "return") confirm()
|
||||
|
||||
// in JSX
|
||||
onSubmit = { confirm }
|
||||
```
|
||||
|
||||
**Why:** Eliminates duplication. One place to update when options change.
|
||||
|
||||
### 3. Redundant type annotation on `order` array (lines 47-53)
|
||||
|
||||
The `order` array has a verbose inline type annotation that just repeats the union literal already used by `store.active`. The `as const` assertion would give the same type safety more concisely.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
|
||||
"filename",
|
||||
"thinking",
|
||||
"toolDetails",
|
||||
"assistantMetadata",
|
||||
"openWithoutSaving",
|
||||
]
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const order = ["filename", "thinking", "toolDetails", "assistantMetadata", "openWithoutSaving"] as const
|
||||
```
|
||||
|
||||
**Why:** The type is already fully expressed by the literal values. The annotation is redundant noise. `as const` preserves the narrow types for `indexOf` and indexing.
|
||||
|
||||
### 4. Checkbox toggle logic is repetitive (lines 59-64)
|
||||
|
||||
Four nearly identical `if` statements that each check `store.active` and toggle the matching field.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
if (evt.name === "space") {
|
||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
||||
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
|
||||
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
|
||||
evt.preventDefault()
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
if (evt.name === "space") {
|
||||
const field = store.active
|
||||
if (field !== "filename") setStore(field, !store[field])
|
||||
evt.preventDefault()
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Eliminates four branches that do the same thing. Adding a new checkbox option won't require touching this block.
|
||||
|
||||
### 5. Massively repeated checkbox JSX (lines 111-159)
|
||||
|
||||
Four checkbox `<box>` blocks are copy-pasted with only the field name and label text changing. This is the biggest readability issue in the file.
|
||||
|
||||
**Before (lines 111-159):** ~48 lines of near-identical JSX repeated 4 times.
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
;(["thinking", "toolDetails", "assistantMetadata", "openWithoutSaving"] as const).map((field) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
paddingLeft={1}
|
||||
backgroundColor={store.active === field ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => setStore("active", field)}
|
||||
>
|
||||
<text fg={store.active === field ? theme.primary : theme.textMuted}>{store[field] ? "[x]" : "[ ]"}</text>
|
||||
<text fg={store.active === field ? theme.primary : theme.text}>{labels[field]}</text>
|
||||
</box>
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
With a simple mapping at the top of the component:
|
||||
|
||||
```tsx
|
||||
const labels = {
|
||||
thinking: "Include thinking",
|
||||
toolDetails: "Include tool details",
|
||||
assistantMetadata: "Include assistant metadata",
|
||||
openWithoutSaving: "Open without saving",
|
||||
} as const
|
||||
```
|
||||
|
||||
**Why:** 48 lines become ~15. Every checkbox is guaranteed to have consistent structure. Adding a new option means adding one entry to `labels` and one field to the store -- not copy-pasting another 12-line block.
|
||||
|
||||
### 6. Unnecessary callback wrapper in `show()` (line 200)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
onConfirm={(options) => resolve(options)}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
onConfirm = { resolve }
|
||||
```
|
||||
|
||||
**Why:** The wrapper lambda does nothing -- it just passes its argument through. Direct reference is cleaner and is what `dialog-confirm.tsx` does (line 78: `onConfirm={() => resolve(true)}`; that one is different because it transforms the value).
|
||||
|
||||
### 7. Unused import: `Show` from solid-js is used, but `JSX` is not (line 5)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
import { onMount, Show, type JSX } from "solid-js"
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
import { onMount, Show } from "solid-js"
|
||||
```
|
||||
|
||||
**Why:** `JSX` is imported but never referenced in this file. Dead imports add noise.
|
||||
|
||||
### 8. Unused import: `createStore` from solid-js/store (line 4) -- actually used; ignore.
|
||||
|
||||
Actually `createStore` is used on line 28. Disregard.
|
||||
|
||||
### 9. `show()` takes too many positional boolean arguments (lines 177-183)
|
||||
|
||||
Six positional parameters where four are booleans is error-prone at call sites. It's easy to swap two booleans and get a silent bug.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
DialogExportOptions.show = (
|
||||
dialog: DialogContext,
|
||||
defaultFilename: string,
|
||||
defaultThinking: boolean,
|
||||
defaultToolDetails: boolean,
|
||||
defaultAssistantMetadata: boolean,
|
||||
defaultOpenWithoutSaving: boolean,
|
||||
) => {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
DialogExportOptions.show = (
|
||||
dialog: DialogContext,
|
||||
defaults: {
|
||||
filename: string
|
||||
thinking: boolean
|
||||
toolDetails: boolean
|
||||
assistantMetadata: boolean
|
||||
openWithoutSaving: boolean
|
||||
},
|
||||
) => {
|
||||
```
|
||||
|
||||
And inside:
|
||||
|
||||
```tsx
|
||||
<DialogExportOptions
|
||||
defaultFilename={defaults.filename}
|
||||
defaultThinking={defaults.thinking}
|
||||
...
|
||||
```
|
||||
|
||||
**Why:** An options object prevents boolean-swap bugs at call sites and is self-documenting. Compare:
|
||||
|
||||
```tsx
|
||||
// Before -- which boolean is which?
|
||||
DialogExportOptions.show(dialog, "file.md", true, false, true, false)
|
||||
|
||||
// After -- obvious
|
||||
DialogExportOptions.show(dialog, {
|
||||
filename: "file.md",
|
||||
thinking: true,
|
||||
toolDetails: false,
|
||||
assistantMetadata: true,
|
||||
openWithoutSaving: false,
|
||||
})
|
||||
```
|
||||
|
||||
This is a larger API change so it depends on how many call sites exist, but it's worth flagging.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes by Impact
|
||||
|
||||
| Priority | Issue | Lines Saved | Risk |
|
||||
| -------- | ------------------------------------- | ----------- | ------------------- |
|
||||
| High | Extract checkbox into loop (#5) | ~33 | Low |
|
||||
| High | Deduplicate confirm payload (#2) | ~8 | Low |
|
||||
| Medium | Simplify toggle logic (#4) | ~3 | Low |
|
||||
| Medium | Remove redundant type annotation (#3) | ~4 | Low |
|
||||
| Low | Remove unused `JSX` import (#7) | 0 | None |
|
||||
| Low | Inline `resolve` wrapper (#6) | 0 | None |
|
||||
| Low | Options object for `show()` (#9) | 0 | Medium (API change) |
|
||||
@@ -1,187 +0,0 @@
|
||||
# Review: `dialog-help.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
This is a small, simple component (41 lines). Overall quality is decent — the structure is clear and the JSX layout is readable. However, there are a few style guide violations and minor issues worth fixing.
|
||||
|
||||
---
|
||||
|
||||
## Issue 1: Unnecessary destructuring of `useTheme()` (line 9)
|
||||
|
||||
The style guide says: **"Avoid unnecessary destructuring. Use dot notation to preserve context."**
|
||||
|
||||
Every other dialog in this codebase (`dialog-alert.tsx`, `dialog-confirm.tsx`) also destructures `{ theme }` from `useTheme()` — this is a codebase-wide pattern violation, but it still applies here. Destructuring a single property from a context hook loses the association with its source.
|
||||
|
||||
**Before (line 9):**
|
||||
|
||||
```tsx
|
||||
const { theme } = useTheme()
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const theme = useTheme().theme
|
||||
```
|
||||
|
||||
This preserves the origin (`useTheme()`) while still giving a clean local name. Alternatively, if other properties of the theme context were used, dot notation (`theme.theme.text`) would be awkward — but since only `.theme` is accessed, extracting it directly is fine.
|
||||
|
||||
**Why:** Follows the style guide preference against destructuring. Keeps one clear assignment.
|
||||
|
||||
---
|
||||
|
||||
## Issue 2: Inconsistent import path style (lines 2–3, 5)
|
||||
|
||||
Compare the imports in this file vs. sibling dialog files:
|
||||
|
||||
**dialog-help.tsx:**
|
||||
|
||||
```tsx
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
```
|
||||
|
||||
**dialog-alert.tsx / dialog-confirm.tsx:**
|
||||
|
||||
```tsx
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
```
|
||||
|
||||
`dialog-help.tsx` uses the `@tui/` alias for `useTheme` and `useKeybind`, while sibling files use relative paths (`../context/theme`). This is an inconsistency across the dialog files. Either style works, but within the same directory of closely related components, consistency matters.
|
||||
|
||||
**Why:** Inconsistent import styles make it harder to grep for usages and create cognitive friction when reading related files.
|
||||
|
||||
---
|
||||
|
||||
## Issue 3: `useKeyboard` handler could use `||` instead of two comparisons (line 13)
|
||||
|
||||
This is very minor, but the condition reads slightly more naturally collapsed:
|
||||
|
||||
**Before (line 13):**
|
||||
|
||||
```tsx
|
||||
if (evt.name === "return" || evt.name === "escape") {
|
||||
```
|
||||
|
||||
This is actually fine as-is. No change needed — it's readable and clear. Mentioned only for completeness.
|
||||
|
||||
---
|
||||
|
||||
## Issue 4: No static `.show()` helper like sibling dialogs
|
||||
|
||||
`dialog-alert.tsx` and `dialog-confirm.tsx` both export a static `.show()` method on the component:
|
||||
|
||||
```tsx
|
||||
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
|
||||
() => resolve(),
|
||||
)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`DialogHelp` has no such helper. If callers need to imperatively show the help dialog, they must manually call `dialog.replace(() => <DialogHelp />)` themselves. This isn't necessarily a bug — if there's only one call site and no need for a promise-based API, it's fine. But it's worth noting as an inconsistency with the sibling pattern.
|
||||
|
||||
**Why:** Consistency with sibling dialog components. If help is shown from multiple places, a `.show()` helper avoids duplication.
|
||||
|
||||
---
|
||||
|
||||
## Issue 5: `useKeybind` is used only once — could be inlined (line 10, 30)
|
||||
|
||||
The `keybind` variable is used exactly once, on line 30. Per the style guide: **"Reduce total variable count by inlining when a value is only used once."**
|
||||
|
||||
**Before (lines 10, 29–31):**
|
||||
|
||||
```tsx
|
||||
const keybind = useKeybind()
|
||||
|
||||
// ...
|
||||
<text fg={theme.textMuted}>
|
||||
Press {keybind.print("command_list")} to see all available actions and commands in any context.
|
||||
</text>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<text fg={theme.textMuted}>
|
||||
Press {useKeybind().print("command_list")} to see all available actions and commands in any context.
|
||||
</text>
|
||||
```
|
||||
|
||||
**Why:** Removes a variable that exists only to be dereferenced once. Follows the style guide principle of reducing variable count by inlining single-use values.
|
||||
|
||||
**Caveat:** In SolidJS, calling `useKeybind()` inside JSX is fine because hooks in Solid are just function calls that read from context — they don't have the React "rules of hooks" restriction. The context lookup happens once during component creation regardless of where the call is placed.
|
||||
|
||||
---
|
||||
|
||||
## Issue 6: `dialog` variable is used twice — but could still be worth inlining in the keyboard handler (line 8)
|
||||
|
||||
`dialog` is used in three places (lines 14, 24, 34), so inlining would not be appropriate here. This is fine as-is.
|
||||
|
||||
---
|
||||
|
||||
## Suggested cleaned-up version
|
||||
|
||||
```tsx
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
|
||||
export function DialogHelp() {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme().theme
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return" || evt.name === "escape") {
|
||||
dialog.clear()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Help
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc/enter
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Press {useKeybind().print("command_list")} to see all available actions and commands in any context.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.selectedListItemText}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Changes from original:
|
||||
|
||||
1. `const { theme } = useTheme()` → `const theme = useTheme().theme` (no destructuring)
|
||||
2. Removed `const keybind = useKeybind()` variable, inlined the single usage
|
||||
|
||||
---
|
||||
|
||||
## What's already good
|
||||
|
||||
- Clean, minimal component — does one thing
|
||||
- Consistent JSX structure matching sibling dialogs
|
||||
- Proper use of `useKeyboard` for keyboard handling
|
||||
- No unnecessary type annotations
|
||||
- No `let`, no `else`, no `try/catch`
|
||||
- No `any` types
|
||||
- Single-word variable names (`dialog`, `theme`, `keybind`)
|
||||
@@ -1,234 +0,0 @@
|
||||
# Code Review: `dialog-prompt.tsx`
|
||||
|
||||
## Overall Quality
|
||||
|
||||
This is a small, focused component — 81 lines total. It's reasonably clean already, but there are several style guide violations and minor readability issues worth addressing.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useTheme()` (line 18)
|
||||
|
||||
The style guide says: _"Avoid unnecessary destructuring. Use dot notation to preserve context."_
|
||||
|
||||
`theme` is extracted from `useTheme()` but `theme` alone loses the context that it came from the theme system. Using dot notation is more consistent with the rest of the codebase pattern (though notably `dialog.tsx` also destructures — both should be fixed).
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const { theme } = useTheme()
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const theme = useTheme()
|
||||
```
|
||||
|
||||
Then all references to `theme.text` become `theme.theme.text`, etc.
|
||||
|
||||
However — looking at the broader codebase, `dialog.tsx` also uses `const { theme } = useTheme()` (line 16). This is a pervasive pattern across the TUI code. The destructuring here is arguably justified because `useTheme()` returns an object with multiple fields (`theme`, `selected`, `syntax`, etc.) and `theme` is the only one used. Accessing `theme.theme.text` everywhere would actually hurt readability. **This one is debatable** — the destructuring removes a level of nesting that would otherwise be redundant noise in JSX. I'd leave it as-is given how the `useTheme` API is designed, but flag it for awareness.
|
||||
|
||||
---
|
||||
|
||||
### 2. `let` used where it could be avoided (line 19)
|
||||
|
||||
```tsx
|
||||
let textarea: TextareaRenderable
|
||||
```
|
||||
|
||||
This is assigned via a `ref` callback on line 54. In SolidJS, `ref` callbacks require `let` — the framework assigns the value after render. This is an accepted SolidJS pattern and **cannot be replaced with `const`**. No change needed, but the missing `!` (definite assignment) or initialization is worth noting for clarity — the variable is used in `onMount` and `useKeyboard` before it's guaranteed to be assigned. The `onMount` callback on line 30 does guard against this with `if (!textarea || textarea.isDestroyed)`, but the `useKeyboard` handler on line 23 does not — it accesses `textarea.plainText` without checking if `textarea` is defined.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
if (!textarea) return
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Defensive consistency. The `onMount` handler already guards against `textarea` being undefined/destroyed, but the keyboard handler doesn't. If the keyboard event fires before the ref is assigned (unlikely but possible during rapid mount/unmount cycles), this would throw.
|
||||
|
||||
---
|
||||
|
||||
### 3. Duplicate `onConfirm` logic (lines 22-25 and 49-51)
|
||||
|
||||
The confirm action is wired up in two places: the `useKeyboard` handler (line 22) and the `onSubmit` prop of `<textarea>` (line 49). The `<textarea>` already has `keyBindings={[{ name: "return", action: "submit" }]}` which maps Enter to the `onSubmit` callback. This means the `useKeyboard` handler for "return" is redundant — the textarea's own key binding will handle it.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}
|
||||
})
|
||||
|
||||
// ... later in JSX:
|
||||
<textarea
|
||||
onSubmit={() => {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}}
|
||||
keyBindings={[{ name: "return", action: "submit" }]}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
**After:**
|
||||
Remove the `useKeyboard` handler entirely. The `<textarea>` already handles Enter via its `keyBindings` + `onSubmit`. If both are needed (e.g., to catch Enter when the textarea isn't focused), this should be documented with a comment explaining why.
|
||||
|
||||
```tsx
|
||||
// Remove lines 21-25 entirely, or add a comment:
|
||||
// Handles enter when textarea is not focused
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
if (!textarea) return
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Duplicate logic is a maintenance burden. If the confirm behavior changes, you'd need to update two places. If both are genuinely needed, a comment should explain the distinction.
|
||||
|
||||
---
|
||||
|
||||
### 4. Unnecessary `onCancel` prop that is never triggered (lines 13, 75)
|
||||
|
||||
The `DialogPromptProps` type declares `onCancel` (line 13), and `DialogPrompt.show` passes a cancel handler (line 75), but nothing in the component ever calls `props.onCancel`. Cancellation is handled by the dialog's `onClose` callback passed to `dialog.replace()` on line 77. The `onCancel` prop on the component itself is dead code.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onConfirm?: (value: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
And on line 75:
|
||||
|
||||
```tsx
|
||||
// Before:
|
||||
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
|
||||
|
||||
// After:
|
||||
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} />
|
||||
```
|
||||
|
||||
**Why:** Dead props add confusion. A reader would expect `onCancel` to be called somewhere in the component, and its absence suggests a bug. The cancel path is already handled by the second argument to `dialog.replace()`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Verbose `onSubmit` callback (lines 49-51)
|
||||
|
||||
The `onSubmit` handler wraps a single expression in braces unnecessarily.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
onSubmit={() => {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
onSubmit={() => props.onConfirm?.(textarea.plainText)}
|
||||
```
|
||||
|
||||
**Why:** Reduces visual noise. Single-expression arrow functions are more concise without braces.
|
||||
|
||||
---
|
||||
|
||||
### 6. `onConfirm` callback in `show` could be simplified (line 75)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
onConfirm={(value) => resolve(value)}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
onConfirm = { resolve }
|
||||
```
|
||||
|
||||
**Why:** `resolve` already accepts a `string | null` and `onConfirm` passes a `string`. The wrapping arrow function is unnecessary indirection. `resolve` can be passed directly since it matches the expected signature.
|
||||
|
||||
---
|
||||
|
||||
### 7. Missing `dialog.clear()` after confirm (line 23, 50)
|
||||
|
||||
When the user confirms, `onConfirm` is called but the dialog is never dismissed. The caller (`DialogPrompt.show`) resolves the promise but doesn't call `dialog.clear()`. This means the dialog remains visible until the consumer explicitly clears it. If this is intentional (the consumer decides when to close), it should be documented. If not, confirm should also close the dialog.
|
||||
|
||||
Looking at the pattern: `dialog.replace` sets up an `onClose` that resolves null (line 77), and pressing Escape triggers that via the dialog system. But pressing Enter calls `onConfirm` without closing. This is likely a bug or at minimum inconsistent — the promise resolves but the dialog stays open until the consumer acts.
|
||||
|
||||
---
|
||||
|
||||
### 8. Unused import: `type DialogContext` partially used (line 3)
|
||||
|
||||
`DialogContext` is imported as a type on line 3 and used only in the `show` static method's parameter on line 71. This is fine — just noting it's correctly typed as a type-only import.
|
||||
|
||||
---
|
||||
|
||||
### 9. The `ref` callback has an unnecessary type annotation (line 54)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
ref={(val) => (textarea = val)}
|
||||
```
|
||||
|
||||
**Why:** The style guide says to rely on type inference when possible. SolidJS/OpenTUI should infer the type of the ref callback parameter from the `<textarea>` element. If it doesn't (due to framework typing limitations), the annotation is justified — but it's worth trying without it first.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Recommended Changes
|
||||
|
||||
| Priority | Issue | Lines |
|
||||
| -------- | ---------------------------------------------------- | ------------ |
|
||||
| Medium | Duplicate confirm logic (useKeyboard + onSubmit) | 21-25, 49-51 |
|
||||
| Medium | Dead `onCancel` prop never called | 13, 75 |
|
||||
| Medium | Missing null guard on `textarea` in keyboard handler | 23 |
|
||||
| Low | Verbose `onSubmit` callback | 49-51 |
|
||||
| Low | Wrapping arrow in `onConfirm={resolve}` | 75 |
|
||||
| Low | Unnecessary type annotation on ref callback | 54 |
|
||||
| Info | Dialog not cleared on confirm — possible bug | 23, 50 |
|
||||
@@ -15,6 +15,7 @@ export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
placeholder?: string
|
||||
options: DialogSelectOption<T>[]
|
||||
flat?: boolean
|
||||
ref?: (ref: DialogSelectRef<T>) => void
|
||||
onMove?: (option: DialogSelectOption<T>) => void
|
||||
onFilter?: (query: string) => void
|
||||
@@ -100,7 +101,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const flatten = createMemo(() => props.flat && store.filter.length > 0)
|
||||
|
||||
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
|
||||
if (flatten()) return [["", filtered()]]
|
||||
const result = pipe(
|
||||
filtered(),
|
||||
groupBy((x) => x.category ?? ""),
|
||||
@@ -117,10 +121,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
)
|
||||
})
|
||||
|
||||
const rows = createMemo(() => {
|
||||
const headers = grouped().reduce((acc, [category], i) => {
|
||||
if (!category) return acc
|
||||
return acc + (i > 0 ? 2 : 1)
|
||||
}, 0)
|
||||
return flat().length + headers
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
const height = createMemo(() =>
|
||||
Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
|
||||
)
|
||||
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
|
||||
|
||||
const selected = createMemo(() => flat()[store.selected])
|
||||
|
||||
@@ -311,7 +321,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={option.footer}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
# Review: `dialog-select.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is reasonably well-structured but has several style guide violations: unnecessary destructuring, unnecessary variables, a `let` that could be `const`, commented-out code, and some verbose patterns that reduce readability. The `Option` component has an unused prop. Most issues are minor but collectively they add friction when reading the code.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `useTheme()` (lines 51, 356)
|
||||
|
||||
The style guide says to avoid destructuring and prefer dot notation. `theme` is used extensively, so destructuring isn't terrible here, but it violates the convention. Since `theme` is used dozens of times in JSX, this one is borderline -- keeping dot notation would be verbose in the JSX. However, both instances destructure identically and should at least be consistent with the rest of the codebase's direction.
|
||||
|
||||
```tsx
|
||||
// Before (line 51)
|
||||
const { theme } = useTheme()
|
||||
|
||||
// After
|
||||
const theme = useTheme().theme
|
||||
```
|
||||
|
||||
Same at line 356. This preserves the single variable while avoiding destructuring syntax.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unnecessary intermediate variable `result` in `filtered` memo (lines 85-92)
|
||||
|
||||
The `result` variable is only used once. Inline it.
|
||||
|
||||
```tsx
|
||||
// Before (lines 85-92)
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
return result
|
||||
|
||||
// After
|
||||
return fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
```
|
||||
|
||||
**Why:** Reduces variable count. The value is used exactly once, so the name adds no clarity.
|
||||
|
||||
---
|
||||
|
||||
### 3. Unnecessary intermediate variable `result` in `grouped` memo (lines 103-111)
|
||||
|
||||
Same pattern -- `result` assigned and immediately returned.
|
||||
|
||||
```tsx
|
||||
// Before (lines 103-111)
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
filtered(),
|
||||
groupBy((x) => x.category ?? ""),
|
||||
entries(),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
// After
|
||||
const grouped = createMemo(() =>
|
||||
pipe(
|
||||
filtered(),
|
||||
groupBy((x) => x.category ?? ""),
|
||||
entries(),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**Why:** Removes dead weight. Also removes the commented-out `mapValues` line (line 107), which is noise.
|
||||
|
||||
---
|
||||
|
||||
### 4. Commented-out code (line 107)
|
||||
|
||||
```tsx
|
||||
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
|
||||
```
|
||||
|
||||
Dead code should be removed. Version control exists for history.
|
||||
|
||||
---
|
||||
|
||||
### 5. Unnecessary `return` wrapper in `flat` memo (lines 113-118)
|
||||
|
||||
```tsx
|
||||
// Before (lines 113-118)
|
||||
const flat = createMemo(() => {
|
||||
return pipe(
|
||||
grouped(),
|
||||
flatMap(([_, options]) => options),
|
||||
)
|
||||
})
|
||||
|
||||
// After
|
||||
const flat = createMemo(() =>
|
||||
pipe(
|
||||
grouped(),
|
||||
flatMap(([_, options]) => options),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**Why:** Arrow with expression body is more concise than arrow with block body containing a single return.
|
||||
|
||||
---
|
||||
|
||||
### 6. `let` used for `input` (line 72)
|
||||
|
||||
`input` is assigned once via `ref` callback and never reassigned in normal flow. This is a DOM ref pattern so `let` is actually necessary here (assigned in JSX ref callback). No change needed -- this is an acceptable use of `let` for ref capture.
|
||||
|
||||
---
|
||||
|
||||
### 7. `move` function uses `let` where modular arithmetic would work (lines 142-148)
|
||||
|
||||
```tsx
|
||||
// Before (lines 142-148)
|
||||
function move(direction: number) {
|
||||
if (flat().length === 0) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
moveTo(next, true)
|
||||
}
|
||||
|
||||
// After
|
||||
function move(direction: number) {
|
||||
const len = flat().length
|
||||
if (len === 0) return
|
||||
moveTo((((store.selected + direction) % len) + len) % len, true)
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Replaces `let` with `const` and removes two conditionals. The modular arithmetic pattern `((n % len) + len) % len` is standard for wrapping indices. However, if the team considers the modular arithmetic less readable, a simpler improvement:
|
||||
|
||||
```tsx
|
||||
// Alternative
|
||||
function move(direction: number) {
|
||||
if (flat().length === 0) return
|
||||
const next = store.selected + direction
|
||||
moveTo(next < 0 ? flat().length - 1 : next >= flat().length ? 0 : next, true)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. `moveTo` uses `else` (lines 160-173)
|
||||
|
||||
```tsx
|
||||
// Before (lines 160-173)
|
||||
if (center) {
|
||||
const centerOffset = Math.floor(scroll.height / 2)
|
||||
scroll.scrollBy(y - centerOffset)
|
||||
} else {
|
||||
if (y >= scroll.height) {
|
||||
scroll.scrollBy(y - scroll.height + 1)
|
||||
}
|
||||
if (y < 0) {
|
||||
scroll.scrollBy(y)
|
||||
if (isDeepEqual(flat()[0].value, selected()?.value)) {
|
||||
scroll.scrollTo(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After
|
||||
if (center) {
|
||||
scroll.scrollBy(y - Math.floor(scroll.height / 2))
|
||||
return
|
||||
}
|
||||
if (y >= scroll.height) {
|
||||
scroll.scrollBy(y - scroll.height + 1)
|
||||
}
|
||||
if (y < 0) {
|
||||
scroll.scrollBy(y)
|
||||
if (isDeepEqual(flat()[0].value, selected()?.value)) {
|
||||
scroll.scrollTo(0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Early return eliminates `else` block and reduces nesting by one level. Also inlines the single-use `centerOffset` variable.
|
||||
|
||||
---
|
||||
|
||||
### 9. Unused `onMouseOver` prop in `Option` component (line 354)
|
||||
|
||||
```tsx
|
||||
// Before (line 354)
|
||||
function Option(props: {
|
||||
title: string
|
||||
description?: string
|
||||
active?: boolean
|
||||
current?: boolean
|
||||
footer?: JSX.Element | string
|
||||
gutter?: JSX.Element
|
||||
onMouseOver?: () => void // <-- never used in the component body
|
||||
})
|
||||
```
|
||||
|
||||
`onMouseOver` is declared in the props type but never referenced in the component's JSX or logic. Remove it.
|
||||
|
||||
```tsx
|
||||
// After
|
||||
function Option(props: {
|
||||
title: string
|
||||
description?: string
|
||||
active?: boolean
|
||||
current?: boolean
|
||||
footer?: JSX.Element | string
|
||||
gutter?: JSX.Element
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Dead code in a type definition is misleading -- it suggests the component handles mouse events when it doesn't.
|
||||
|
||||
---
|
||||
|
||||
### 10. Verbose `for...of` loop for keybind matching (lines 197-206)
|
||||
|
||||
```tsx
|
||||
// Before (lines 197-206)
|
||||
for (const item of props.keybind ?? []) {
|
||||
if (item.disabled || !item.keybind) continue
|
||||
if (Keybind.match(item.keybind, keybind.parse(evt))) {
|
||||
const s = selected()
|
||||
if (s) {
|
||||
evt.preventDefault()
|
||||
item.onTrigger(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After
|
||||
const s = selected()
|
||||
const parsed = keybind.parse(evt)
|
||||
for (const item of props.keybind ?? []) {
|
||||
if (item.disabled || !item.keybind) continue
|
||||
if (!Keybind.match(item.keybind, parsed)) continue
|
||||
if (!s) continue
|
||||
evt.preventDefault()
|
||||
item.onTrigger(s)
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `keybind.parse(evt)` and `selected()` are called inside a loop but don't change per iteration -- hoist them. Also flattens nesting with early `continue`. Note: the style guide prefers functional array methods, but in this case the early-exit behavior (`evt.preventDefault`) makes `for...of` acceptable. Alternatively:
|
||||
|
||||
```tsx
|
||||
const s = selected()
|
||||
if (!s) return
|
||||
const parsed = keybind.parse(evt)
|
||||
;(props.keybind ?? [])
|
||||
.filter((item) => !item.disabled && item.keybind && Keybind.match(item.keybind, parsed))
|
||||
.forEach((item) => {
|
||||
evt.preventDefault()
|
||||
item.onTrigger(s)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 11. `any` type in `DialogSelectOption` (line 32)
|
||||
|
||||
```tsx
|
||||
// Before (line 32)
|
||||
export interface DialogSelectOption<T = any> {
|
||||
```
|
||||
|
||||
The style guide says to avoid `any`. Consider `unknown` as the default:
|
||||
|
||||
```tsx
|
||||
// After
|
||||
export interface DialogSelectOption<T = unknown> {
|
||||
```
|
||||
|
||||
**Why:** `unknown` is type-safe. Consumers that don't pass a type parameter will need to narrow, which prevents accidental misuse. This may require downstream changes, so verify callsites first.
|
||||
|
||||
---
|
||||
|
||||
### 12. Duplicate `isDeepEqual` import (lines 4 and 8)
|
||||
|
||||
`isDeepEqual` is imported from `remeda` on line 8 separately from the other `remeda` imports on line 3. Consolidate:
|
||||
|
||||
```tsx
|
||||
// Before (lines 3, 8)
|
||||
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
|
||||
...
|
||||
import { isDeepEqual } from "remeda"
|
||||
|
||||
// After
|
||||
import { entries, filter, flatMap, groupBy, isDeepEqual, pipe, take } from "remeda"
|
||||
```
|
||||
|
||||
**Why:** Two import statements from the same module is messy and suggests the second was added as an afterthought.
|
||||
|
||||
---
|
||||
|
||||
### 13. Unused import: `take` (line 3)
|
||||
|
||||
`take` is imported from `remeda` but never used in the file. Remove it.
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
|
||||
|
||||
// After
|
||||
import { entries, filter, flatMap, groupBy, isDeepEqual, pipe } from "remeda"
|
||||
```
|
||||
|
||||
**Why:** Dead imports are noise and may confuse readers into thinking the function is used somewhere.
|
||||
|
||||
---
|
||||
|
||||
### 14. Variable name `s` is too terse (line 200)
|
||||
|
||||
```tsx
|
||||
const s = selected()
|
||||
```
|
||||
|
||||
While the style guide says prefer single-word names, `s` is a single _letter_ and provides no context. In a block where `selected` is the memo, call the result `option` to match the pattern used on line 188.
|
||||
|
||||
```tsx
|
||||
// Before (line 200)
|
||||
const s = selected()
|
||||
if (s) {
|
||||
evt.preventDefault()
|
||||
item.onTrigger(s)
|
||||
}
|
||||
|
||||
// After
|
||||
const option = selected()
|
||||
if (option) {
|
||||
evt.preventDefault()
|
||||
item.onTrigger(option)
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `option` communicates what the value represents. `s` requires the reader to look up what `selected()` returns.
|
||||
|
||||
---
|
||||
|
||||
### 15. `paddingLeft={3}` on text inside `Option` (line 378)
|
||||
|
||||
The `Option` component has `paddingLeft={3}` on its text element, while the parent `<box>` in `DialogSelect` conditionally sets `paddingLeft` to 1 or 3. This padding logic is split across two components, making layout reasoning harder. Not a code quality bug per se, but worth noting for maintainability -- consider consolidating padding decisions in one place.
|
||||
|
||||
---
|
||||
|
||||
## Minor Nits
|
||||
|
||||
- **Line 55**: The `as` cast `"keyboard" as "keyboard" | "mouse"` is fine but could also be expressed with `satisfies` or a type annotation on the store. Low priority.
|
||||
- **Line 268**: `ref={(r: ScrollBoxRenderable) => (scroll = r)}` -- the explicit type annotation on `r` may be unnecessary if the JSX intrinsic types provide it. Worth checking.
|
||||
@@ -1,293 +0,0 @@
|
||||
# Code Review: `dialog.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
The file is reasonably clean but has several style guide violations and readability issues: unnecessary destructuring, use of `any`, a `for` loop where a functional method works, unnecessary `async` on handlers that don't await, and some naming/inlining opportunities.
|
||||
|
||||
---
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring of `theme` (line 16)
|
||||
|
||||
Style guide says: avoid unnecessary destructuring, use dot notation.
|
||||
|
||||
```tsx
|
||||
// Before (line 16)
|
||||
const { theme } = useTheme()
|
||||
|
||||
// After
|
||||
const theme = useTheme().theme
|
||||
```
|
||||
|
||||
Or, even better, inline `theme` directly where used (line 41) since it's only accessed once:
|
||||
|
||||
```tsx
|
||||
// Before (lines 16, 41)
|
||||
const { theme } = useTheme()
|
||||
...
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
|
||||
// After (line 41, remove line 16)
|
||||
backgroundColor={useTheme().theme.backgroundPanel}
|
||||
```
|
||||
|
||||
This removes a variable and the destructuring in one step. However, if the hook shouldn't be called inside JSX (reactive context matters in Solid), keeping a `const theme = useTheme()` and using `theme.theme.backgroundPanel` is the safe alternative.
|
||||
|
||||
---
|
||||
|
||||
### 2. Use of `any` type (line 100)
|
||||
|
||||
Style guide says: avoid `any`.
|
||||
|
||||
```tsx
|
||||
// Before (line 100)
|
||||
replace(input: any, onClose?: () => void) {
|
||||
|
||||
// After
|
||||
replace(input: JSX.Element, onClose?: () => void) {
|
||||
```
|
||||
|
||||
`JSX.Element` is already imported and is the correct type for what gets stored as `element` in the stack.
|
||||
|
||||
---
|
||||
|
||||
### 3. Unnecessary `async` on mouse handlers (lines 21, 35)
|
||||
|
||||
Neither handler uses `await`. The `async` keyword is pointless.
|
||||
|
||||
```tsx
|
||||
// Before (line 21)
|
||||
onMouseUp={async () => {
|
||||
if (renderer.getSelection()) return
|
||||
props.onClose?.()
|
||||
}}
|
||||
|
||||
// After
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()) return
|
||||
props.onClose?.()
|
||||
}}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Before (line 35)
|
||||
onMouseUp={async (e) => {
|
||||
if (renderer.getSelection()) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
|
||||
// After
|
||||
onMouseUp={(e) => {
|
||||
if (renderer.getSelection()) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `for` loops in `clear` and `replace` (lines 91, 105)
|
||||
|
||||
Style guide says: prefer functional array methods over for loops.
|
||||
|
||||
```tsx
|
||||
// Before (lines 91-93)
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
}
|
||||
|
||||
// After
|
||||
store.stack.forEach((item) => item.onClose?.())
|
||||
```
|
||||
|
||||
Same fix applies to lines 105-107 in `replace`.
|
||||
|
||||
---
|
||||
|
||||
### 5. `let` used for `focus` (line 71)
|
||||
|
||||
`focus` is declared with `let` and mutated in multiple places, which is acceptable here since it genuinely needs reassignment. However, the type annotation `Renderable | null` is unnecessary since TypeScript can infer from the initial value and usage. Unfortunately, the initial value is just `null` so the type cannot be inferred. This one is fine as-is.
|
||||
|
||||
No change needed, but worth noting it was considered.
|
||||
|
||||
---
|
||||
|
||||
### 6. Recursive `find` function uses `for` loop (lines 76-82)
|
||||
|
||||
The nested `find` function uses a `for` loop. This is a recursive tree search where early return matters for performance, so a `for` loop is acceptable here. A `.some()` call would work too and be slightly more idiomatic:
|
||||
|
||||
```tsx
|
||||
// Before (lines 76-82)
|
||||
function find(item: Renderable) {
|
||||
for (const child of item.getChildren()) {
|
||||
if (child === focus) return true
|
||||
if (find(child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// After
|
||||
function find(item: Renderable): boolean {
|
||||
return item.getChildren().some((child) => child === focus || find(child))
|
||||
}
|
||||
```
|
||||
|
||||
This collapses 7 lines into 1 and is equally readable. The return type annotation is needed here because of the recursive call.
|
||||
|
||||
---
|
||||
|
||||
### 7. Verbose `onMouseUp` handler in `DialogProvider` (lines 141-149)
|
||||
|
||||
The handler mixes `await` with `.then()/.catch()` chaining, which is inconsistent. Pick one style.
|
||||
|
||||
```tsx
|
||||
// Before (lines 141-149)
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
}}
|
||||
|
||||
// After
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text || text.length === 0) return
|
||||
try {
|
||||
await Clipboard.copy(text)
|
||||
toast.show({ message: "Copied to clipboard", variant: "info" })
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
}
|
||||
renderer.clearSelection()
|
||||
}}
|
||||
```
|
||||
|
||||
Or, if we want to avoid `try/catch` per the style guide, keep the `.then()/.catch()` but drop `async/await` since the `await` does nothing useful when chained with `.then()`:
|
||||
|
||||
```tsx
|
||||
// Alternative (no try/catch, no async)
|
||||
onMouseUp={() => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text || text.length === 0) return
|
||||
Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
.finally(() => renderer.clearSelection())
|
||||
}}
|
||||
```
|
||||
|
||||
This version is cleaner: no `async`, no mixed styles, and `clearSelection` always runs via `.finally()`.
|
||||
|
||||
---
|
||||
|
||||
### 8. `text && text.length > 0` is redundant (line 143)
|
||||
|
||||
If `text` is a non-empty string, `text.length > 0` is sufficient. If `text` is `undefined`, `text?.length` handles it. But since `text` comes from optional chaining it could be `undefined`, so `text && text.length > 0` can be simplified:
|
||||
|
||||
```tsx
|
||||
// Before (line 143)
|
||||
if (text && text.length > 0) {
|
||||
|
||||
// After
|
||||
if (text?.length) {
|
||||
```
|
||||
|
||||
Truthy check on `length` covers both `undefined` and `0`.
|
||||
|
||||
---
|
||||
|
||||
### 9. `store.stack` passed directly to `setStore` loses reactivity safety (line 63)
|
||||
|
||||
```tsx
|
||||
// Before (line 63)
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
```
|
||||
|
||||
This is fine functionally, but using a function form is more idiomatic for store updates derived from current state:
|
||||
|
||||
```tsx
|
||||
// After
|
||||
setStore("stack", (s) => s.slice(0, -1))
|
||||
```
|
||||
|
||||
Minor, but more consistent with Solid store patterns.
|
||||
|
||||
---
|
||||
|
||||
### 10. Unnecessary variable `current` used only once (line 61)
|
||||
|
||||
Style guide says: inline when a value is only used once.
|
||||
|
||||
```tsx
|
||||
// Before (lines 61-62)
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
|
||||
// After
|
||||
store.stack.at(-1)!.onClose?.()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 11. Unnecessary variable `value` in `useDialog` (lines 162-166)
|
||||
|
||||
Style guide says: inline when a value is only used once. However, the conditional check requires the variable. This is fine as-is but could use a one-liner pattern:
|
||||
|
||||
```tsx
|
||||
// Before (lines 161-167)
|
||||
export function useDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useDialog must be used within a DialogProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// This is already clean. No change needed.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. Batch in `clear` but not in `replace` (lines 94 vs 108)
|
||||
|
||||
In `clear` (line 94), `setStore` calls are wrapped in `batch()`. In `replace` (lines 108-114), two `setStore` calls are made without `batch()`. This is inconsistent. Either both should use `batch` or neither should (Solid batches synchronous updates in event handlers automatically, but `clear` and `replace` might be called outside event handlers).
|
||||
|
||||
```tsx
|
||||
// Before (lines 108-114)
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [
|
||||
{
|
||||
element: input,
|
||||
onClose,
|
||||
},
|
||||
])
|
||||
|
||||
// After
|
||||
batch(() => {
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [{ element: input, onClose }])
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| # | Line(s) | Severity | Issue |
|
||||
| --- | ------- | -------- | ---------------------------------------- |
|
||||
| 1 | 16 | Low | Unnecessary destructuring |
|
||||
| 2 | 100 | Medium | `any` type |
|
||||
| 3 | 21, 35 | Low | Unnecessary `async` |
|
||||
| 4 | 91, 105 | Low | `for` loop instead of functional method |
|
||||
| 5 | 71 | — | `let` is justified here |
|
||||
| 6 | 76-82 | Low | Verbose recursive search |
|
||||
| 7 | 141-149 | Medium | Mixed async/then style |
|
||||
| 8 | 143 | Low | Redundant truthiness check |
|
||||
| 9 | 63 | Low | Could use function form for store update |
|
||||
| 10 | 61 | Low | Unnecessary intermediate variable |
|
||||
| 11 | 162 | — | Fine as-is |
|
||||
| 12 | 108 | Medium | Inconsistent `batch` usage |
|
||||
@@ -1,135 +0,0 @@
|
||||
# Review: `packages/opencode/src/cli/cmd/tui/ui/link.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
This is a small, focused component — only 29 lines. It's already in reasonable shape, but there are a few issues that conflict with the project's style guide.
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary intermediate variable `displayText` (line 16)
|
||||
|
||||
`displayText` is used exactly once (line 25). Per the style guide: _"Reduce total variable count by inlining when a value is only used once."_
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export function Link(props: LinkProps) {
|
||||
const displayText = props.children ?? props.href
|
||||
|
||||
return (
|
||||
<text
|
||||
fg={props.fg}
|
||||
onMouseUp={() => {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
export function Link(props: LinkProps) {
|
||||
return (
|
||||
<text
|
||||
fg={props.fg}
|
||||
onMouseUp={() => {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{props.children ?? props.href}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Also, `displayText` is a two-word name. The style guide prefers single-word names. Inlining removes the problem entirely.
|
||||
|
||||
### 2. Exported `LinkProps` interface may be unnecessary (lines 5-9)
|
||||
|
||||
The `LinkProps` interface is exported but only consumed internally by `Link` in this same file. No other file imports `LinkProps` (only `Link` is imported, in `dialog-provider.tsx`). If it doesn't need to be exported, the export can be dropped. Better yet, the type can be inlined directly into the function signature to reduce boilerplate, since it's only used once:
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
export interface LinkProps {
|
||||
href: string
|
||||
children?: JSX.Element | string
|
||||
fg?: RGBA
|
||||
}
|
||||
|
||||
export function Link(props: LinkProps) {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
export function Link(props: { href: string; children?: JSX.Element | string; fg?: RGBA }) {
|
||||
```
|
||||
|
||||
This is a judgment call — the named interface does improve readability for a public component API, and keeping it is defensible. But per the style guide's preference for reducing unnecessary type annotations and keeping things concise, inlining is the more consistent choice. If the interface is kept, at minimum drop the `export` keyword since nothing imports it.
|
||||
|
||||
### 3. JSDoc comment adds no value (lines 11-14)
|
||||
|
||||
The comment restates what the code already makes obvious from the component name, props, and the `open()` call. It doesn't describe any non-obvious behavior or edge cases.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* Link component that renders clickable hyperlinks.
|
||||
* Clicking anywhere on the link text opens the URL in the default browser.
|
||||
*/
|
||||
export function Link(props: LinkProps) {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
export function Link(props: LinkProps) {
|
||||
```
|
||||
|
||||
Comments should explain _why_, not restate _what_. A function named `Link` with an `href` prop and an `onMouseUp` handler that calls `open()` is self-documenting.
|
||||
|
||||
### 4. Unnecessary braces in `onMouseUp` callback (lines 21-23)
|
||||
|
||||
The arrow function body wraps a single expression in braces. This can be simplified to a concise arrow.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
onMouseUp={() => {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
onMouseUp={() => open(props.href).catch(() => {})}
|
||||
```
|
||||
|
||||
Minor, but consistent with keeping things concise.
|
||||
|
||||
## Full suggested rewrite
|
||||
|
||||
Applying all of the above:
|
||||
|
||||
```tsx
|
||||
import type { JSX } from "solid-js"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import open from "open"
|
||||
|
||||
export function Link(props: { href: string; children?: JSX.Element | string; fg?: RGBA }) {
|
||||
return (
|
||||
<text fg={props.fg} onMouseUp={() => open(props.href).catch(() => {})}>
|
||||
{props.children ?? props.href}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This cuts the file from 29 lines to 10 lines with no loss of clarity or functionality.
|
||||
@@ -1,202 +0,0 @@
|
||||
# Review: `toast.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
This file is reasonably clean for its size. The `Toast` component is straightforward. The main issues are in the `init` function: unnecessary destructuring, a `let` that could be avoided, an `any` type, and some redundant type annotations. A few naming and inlining improvements would tighten it up.
|
||||
|
||||
## Issues
|
||||
|
||||
### 1. Unnecessary destructuring in `show` (line 60)
|
||||
|
||||
The `duration` is destructured out of `parsedOptions` just to pass the remainder to `setStore`. But `duration` is an optional field on the schema and ends up on `currentToast` in the store type anyway (since `ToastOptions` includes it). The destructuring creates a throwaway `currentToast` variable that shadows the concept of the store field. Just pass the whole object and read `duration` via dot notation.
|
||||
|
||||
```tsx
|
||||
// before (lines 59-61)
|
||||
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
|
||||
const { duration, ...currentToast } = parsedOptions
|
||||
setStore("currentToast", currentToast)
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null)
|
||||
}, duration).unref()
|
||||
```
|
||||
|
||||
```tsx
|
||||
// after
|
||||
const parsed = TuiEvent.ToastShow.properties.parse(options)
|
||||
setStore("currentToast", parsed)
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null)
|
||||
}, parsed.duration).unref()
|
||||
```
|
||||
|
||||
**Why:** Eliminates a rest-destructure and an extra variable. `parsed.duration` is clearer than a destructured `duration` floating in scope. Also renames `parsedOptions` to `parsed` — shorter, single-concept name per the style guide.
|
||||
|
||||
### 2. `let` for `timeoutHandle` (line 55)
|
||||
|
||||
`timeoutHandle` is a mutable `let` used to track the current timeout across calls. This is a legitimate use of `let` since it's mutated inside a closure across multiple invocations — no ternary trick applies here. **No change needed.** Noting it was evaluated.
|
||||
|
||||
### 3. `any` type on `error` parameter (line 67)
|
||||
|
||||
The style guide says avoid `any`. The parameter `err` is typed as `any` but only used via `instanceof Error`. It should be `unknown`.
|
||||
|
||||
```tsx
|
||||
// before (line 67)
|
||||
error: (err: any) => {
|
||||
```
|
||||
|
||||
```tsx
|
||||
// after
|
||||
error: (err: unknown) => {
|
||||
```
|
||||
|
||||
**Why:** `unknown` is type-safe and forces the `instanceof` check the code already does. `any` silently allows unsafe access.
|
||||
|
||||
### 4. `error` uses implicit fall-through instead of early return (lines 67-77)
|
||||
|
||||
The `error` method uses `return toast.show(...)` for the `Error` case but falls through for the else case. Adding an explicit `return` to the second call makes the parallel structure clearer.
|
||||
|
||||
```tsx
|
||||
// before (lines 67-77)
|
||||
error: (err: any) => {
|
||||
if (err instanceof Error)
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: err.message,
|
||||
})
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: "An unknown error has occurred",
|
||||
})
|
||||
},
|
||||
```
|
||||
|
||||
```tsx
|
||||
// after
|
||||
error: (err: unknown) => {
|
||||
if (err instanceof Error)
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: err.message,
|
||||
})
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: "An unknown error has occurred",
|
||||
})
|
||||
},
|
||||
```
|
||||
|
||||
This is minor — the current code works — but the explicit `return` makes it immediately obvious both branches exit the function. Without it, a reader has to mentally confirm there's no code after the second `toast.show`.
|
||||
|
||||
### 5. Unnecessary type annotation on `currentToast` getter (line 78)
|
||||
|
||||
The return type `: ToastOptions | null` is redundant — it's inferred from `store.currentToast` which is already typed as `ToastOptions | null` on line 52.
|
||||
|
||||
```tsx
|
||||
// before (line 78)
|
||||
get currentToast(): ToastOptions | null {
|
||||
return store.currentToast
|
||||
},
|
||||
```
|
||||
|
||||
```tsx
|
||||
// after
|
||||
get currentToast() {
|
||||
return store.currentToast
|
||||
},
|
||||
```
|
||||
|
||||
**Why:** Style guide says rely on type inference. The annotation adds no safety here since the store is already typed.
|
||||
|
||||
### 6. Unnecessary type annotation on `timeoutHandle` (line 55)
|
||||
|
||||
The type `NodeJS.Timeout | null` is inferred from the `null` initializer and the `setTimeout` assignment.
|
||||
|
||||
```tsx
|
||||
// before (line 55)
|
||||
let timeoutHandle: NodeJS.Timeout | null = null
|
||||
```
|
||||
|
||||
```tsx
|
||||
// after
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
||||
```
|
||||
|
||||
Actually, `NodeJS.Timeout` won't be inferred from `null` alone — TypeScript would type it as `null`. The annotation is necessary here. However, `ReturnType<typeof setTimeout>` is more portable than `NodeJS.Timeout` if Bun's `setTimeout` returns something different. In practice, Bun matches Node's types here, so either works. **No change needed** — the annotation is justified.
|
||||
|
||||
### 7. `useToast` could be simplified (lines 94-100)
|
||||
|
||||
The `if` block with braces could be a one-liner.
|
||||
|
||||
```tsx
|
||||
// before (lines 94-100)
|
||||
export function useToast() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within a ToastProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// after
|
||||
export function useToast() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error("useToast must be used within a ToastProvider")
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Single-statement `throw` doesn't benefit from braces. The one-liner form matches the early-return pattern used elsewhere in the codebase.
|
||||
|
||||
### 8. `{ theme }` destructuring on line 14
|
||||
|
||||
The style guide says to avoid unnecessary destructuring and prefer dot notation. However, `const { theme } = useTheme()` is the dominant convention across the entire TUI codebase (40+ files). Changing it here alone would be inconsistent. **No change recommended** for this file in isolation.
|
||||
|
||||
## Consolidated diff
|
||||
|
||||
If all recommended changes were applied:
|
||||
|
||||
```tsx
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
currentToast: null as ToastOptions | null,
|
||||
})
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | null = null
|
||||
|
||||
const toast = {
|
||||
show(options: ToastOptions) {
|
||||
const parsed = TuiEvent.ToastShow.properties.parse(options)
|
||||
setStore("currentToast", parsed)
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null)
|
||||
}, parsed.duration).unref()
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
if (err instanceof Error)
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: err.message,
|
||||
})
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: "An unknown error has occurred",
|
||||
})
|
||||
},
|
||||
get currentToast() {
|
||||
return store.currentToast
|
||||
},
|
||||
}
|
||||
return toast
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error("useToast must be used within a ToastProvider")
|
||||
return value
|
||||
}
|
||||
```
|
||||
@@ -15,6 +15,17 @@ export namespace Pty {
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
const BUFFER_CHUNK = 64 * 1024
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
const bytes = encoder.encode(json)
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
const pty = lazy(async () => {
|
||||
const { spawn } = await import("bun-pty")
|
||||
@@ -68,6 +79,8 @@ export namespace Pty {
|
||||
info: Info
|
||||
process: IPty
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Set<WSContext>
|
||||
}
|
||||
|
||||
@@ -139,23 +152,27 @@ export namespace Pty {
|
||||
info,
|
||||
process: ptyProcess,
|
||||
buffer: "",
|
||||
bufferCursor: 0,
|
||||
cursor: 0,
|
||||
subscribers: new Set(),
|
||||
}
|
||||
state().set(id, session)
|
||||
ptyProcess.onData((data) => {
|
||||
let open = false
|
||||
session.cursor += data.length
|
||||
|
||||
for (const ws of session.subscribers) {
|
||||
if (ws.readyState !== 1) {
|
||||
session.subscribers.delete(ws)
|
||||
continue
|
||||
}
|
||||
open = true
|
||||
ws.send(data)
|
||||
}
|
||||
if (open) return
|
||||
|
||||
session.buffer += data
|
||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||
session.buffer = session.buffer.slice(-BUFFER_LIMIT)
|
||||
const excess = session.buffer.length - BUFFER_LIMIT
|
||||
session.buffer = session.buffer.slice(excess)
|
||||
session.bufferCursor += excess
|
||||
})
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
log.info("session exited", { id, exitCode })
|
||||
@@ -215,28 +232,47 @@ export namespace Pty {
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(id: string, ws: WSContext) {
|
||||
export function connect(id: string, ws: WSContext, cursor?: number) {
|
||||
const session = state().get(id)
|
||||
if (!session) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
session.subscribers.add(ws)
|
||||
if (session.buffer) {
|
||||
const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
|
||||
session.buffer = ""
|
||||
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
|
||||
const from =
|
||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||
|
||||
const data = (() => {
|
||||
if (!session.buffer) return ""
|
||||
if (from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
|
||||
if (data) {
|
||||
try {
|
||||
for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) {
|
||||
ws.send(buffer.slice(i, i + BUFFER_CHUNK))
|
||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
|
||||
ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||
}
|
||||
} catch {
|
||||
session.subscribers.delete(ws)
|
||||
session.buffer = buffer
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(meta(end))
|
||||
} catch {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
session.subscribers.add(ws)
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(String(message))
|
||||
|
||||
@@ -151,11 +151,18 @@ export const PtyRoutes = lazy(() =>
|
||||
validator("param", z.object({ ptyID: z.string() })),
|
||||
upgradeWebSocket((c) => {
|
||||
const id = c.req.param("ptyID")
|
||||
const cursor = (() => {
|
||||
const value = c.req.query("cursor")
|
||||
if (!value) return
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||
return parsed
|
||||
})()
|
||||
let handler: ReturnType<typeof Pty.connect>
|
||||
if (!Pty.get(id)) throw new Error("Session not found")
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
handler = Pty.connect(id, ws)
|
||||
handler = Pty.connect(id, ws, cursor)
|
||||
},
|
||||
onMessage(event) {
|
||||
handler?.onMessage(String(event.data))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user