diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 47d008fb423..8dd3be58928 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,7 +1,8 @@ ## Style Guide - Try to keep things in one function unless composable or reusable -- AVOID unnecessary destructuring of variables +- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } += obj` just reference it as obj.a and obj.b. this preserves context - AVOID `try`/`catch` where possible - AVOID `else` statements - AVOID using `any` type diff --git a/packages/app/package.json b/packages/app/package.json index c38068ed727..e632efa5f34 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -37,6 +37,7 @@ "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 13d9d147e25..f017815e0fa 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -21,6 +21,7 @@ import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" import { Logo } from "@opencode-ai/ui/logo" +import { I18nProvider } from "@/i18n" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" @@ -53,17 +54,19 @@ export function AppBaseProviders(props: ParentProps) { return ( - - }> - - - - {props.children} - - - - - + + + }> + + + + {props.children} + + + + + + ) } diff --git a/packages/app/src/components/dialog-select-language.tsx b/packages/app/src/components/dialog-select-language.tsx new file mode 100644 index 00000000000..5a057e601a7 --- /dev/null +++ b/packages/app/src/components/dialog-select-language.tsx @@ -0,0 +1,35 @@ +import { useI18n, supportedLocales } from "@/i18n" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" + +const languages = { + en: { name: "English", flag: "🇺🇸" }, + "zh-CN": { name: "简体中文", flag: "🇨🇳" }, + ja: { name: "日本語", flag: "🇯🇵" }, + fr: { name: "Français", flag: "🇫🇷" }, + es: { name: "Español", flag: "🇪🇸" }, +} as const + +export function DialogSelectLanguage() { + const { locale, setLocale } = useI18n() + + return ( + + lang} + current={supportedLocales.find((l) => l === locale())} + onSelect={(lang) => lang && setLocale(lang)} + > + {(lang) => ( +
+ {languages[lang].flag} + {languages[lang].name} +
+ )} +
+
+ ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4958ad2c353..a38170bbd39 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,6 +6,7 @@ import { useServer } from "@/context/server" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" +import { useI18n } from "@/i18n" import { getFilename } from "@opencode-ai/util/path" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { iife } from "@opencode-ai/util/iife" @@ -31,6 +32,7 @@ export function SessionHeader() { const server = useServer() const dialog = useDialog() const sync = useSync() + const { t } = useI18n() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) @@ -93,7 +95,7 @@ export function SessionHeader() { x.title} value={(x) => x.id} onSelect={(session) => { @@ -122,7 +124,7 @@ export function SessionHeader() { />
/
- +
- @@ -220,9 +222,9 @@ export function SessionHeader() { + } diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 68ef0cc1f2b..1f3536e565e 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -4,6 +4,7 @@ import { useSync } from "@/context/sync" import { Icon } from "@opencode-ai/ui/icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Select } from "@opencode-ai/ui/select" +import { useI18n } from "@/i18n" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" @@ -15,6 +16,7 @@ interface NewSessionViewProps { export function NewSessionView(props: NewSessionViewProps) { const sync = useSync() + const { t } = useI18n() const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) @@ -32,13 +34,13 @@ export function NewSessionView(props: NewSessionViewProps) { const label = (value: string) => { if (value === MAIN_WORKTREE) { - if (isWorktree()) return "Main branch" + if (isWorktree()) return t().session.mainBranch const branch = sync.data.vcs?.branch - if (branch) return `Main branch (${branch})` - return "Main branch" + if (branch) return t().session.mainBranchWithName.replace("{branch}", branch) + return t().session.mainBranch } - if (value === CREATE_WORKTREE) return "Create new worktree" + if (value === CREATE_WORKTREE) return t().session.createWorktree return getFilename(value) } @@ -48,7 +50,7 @@ export function NewSessionView(props: NewSessionViewProps) { class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6" style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }} > -
New session
+
{t().session.newSession}
@@ -76,7 +78,7 @@ export function NewSessionView(props: NewSessionViewProps) {
- Last modified  + {t().session.lastModified}  {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()} diff --git a/packages/app/src/i18n/context.tsx b/packages/app/src/i18n/context.tsx new file mode 100644 index 00000000000..37cde79acbf --- /dev/null +++ b/packages/app/src/i18n/context.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, Accessor, Setter, createSignal, createRoot } from "solid-js" +import { locales, type Locale, type Translation, defaultLocale, supportedLocales } from "./locales" + +type I18nContextValue = { + locale: Accessor + setLocale: Setter + t: Accessor + locales: typeof supportedLocales +} + +const I18nContext = createContext() + +export function I18nProvider(props: { children: any }) { + const [locale, setLocale] = createSignal(defaultLocale) + + // Initialize from localStorage or browser language + const initializeLocale = () => { + const stored = localStorage.getItem("opencode-locale") as Locale | null + if (stored && supportedLocales.includes(stored)) { + setLocale(stored) + return + } + + // Detect browser language + const browserLang = navigator.language + if (browserLang.startsWith("zh")) { + setLocale("zh-CN") + } else if (browserLang.startsWith("ja")) { + setLocale("ja") + } else if (browserLang.startsWith("fr")) { + setLocale("fr") + } else if (browserLang.startsWith("es")) { + setLocale("es") + } + } + initializeLocale() + + // Persist locale changes + const persistLocale: Setter = (...args) => { + const result = setLocale(...args) + const newLocale = locale() + localStorage.setItem("opencode-locale", newLocale) + return result + } + + const t = () => locales[locale()] + + const value: I18nContextValue = { + locale, + setLocale: persistLocale, + t, + locales: supportedLocales, + } + + return {props.children} +} + +export function useI18n() { + const context = useContext(I18nContext) + if (!context) { + throw new Error("useI18n must be used within I18nProvider") + } + return context +} + +// Helper to get nested translation values +export function useT() { + const { t } = useI18n() + return t +} diff --git a/packages/app/src/i18n/index.ts b/packages/app/src/i18n/index.ts new file mode 100644 index 00000000000..580f87100eb --- /dev/null +++ b/packages/app/src/i18n/index.ts @@ -0,0 +1,2 @@ +export { I18nProvider, useI18n, useT } from "./context" +export { locales, defaultLocale, supportedLocales, type Locale, type Translation } from "./locales" diff --git a/packages/app/src/i18n/locales/en.ts b/packages/app/src/i18n/locales/en.ts new file mode 100644 index 00000000000..8557f8c3903 --- /dev/null +++ b/packages/app/src/i18n/locales/en.ts @@ -0,0 +1,108 @@ +export default { + common: { + loading: "Loading...", + save: "Save", + cancel: "Cancel", + confirm: "Confirm", + delete: "Delete", + edit: "Edit", + search: "Search", + back: "Back", + next: "Next", + close: "Close", + }, + home: { + title: "OpenCode AI", + subtitle: "AI-powered development tool", + start: "Start Coding", + recentProjects: "Recent projects", + noRecentProjects: "No recent projects", + getStarted: "Get started by opening a local project", + openProject: "Open project", + }, + session: { + new: "New Session", + newSession: "New session", + mainBranch: "Main branch", + mainBranchWithName: "Main branch ({branch})", + createWorktree: "Create new worktree", + lastModified: "Last modified", + backToParent: "Back to parent session", + share: "Share session", + terminate: "Terminate", + archive: "Archive session", + filesChanged: "{count} file{plural} changed", + }, + dialog: { + selectProvider: { + title: "Select Provider", + description: "Choose an AI provider to use", + }, + selectModel: { + title: "Select Model", + description: "Choose a model to use", + unpaid: { + title: "Model Payment Required", + description: "This model requires payment to use", + }, + }, + selectServer: { + title: "Select Server", + description: "Choose a server to connect to", + }, + selectDirectory: { + title: "Select Directory", + description: "Choose a directory to work with", + openProject: "Open project", + }, + selectFile: { + title: "Select File", + description: "Choose a file to work with", + }, + selectMcp: { + title: "Select MCP", + description: "Choose a Model Context Protocol server", + }, + connectProvider: { + title: "Connect Provider", + description: "Configure your AI provider credentials", + }, + editProject: { + title: "Edit Project", + description: "Edit project settings", + editProject: "Edit project", + closeProject: "Close project", + }, + manageModels: { + title: "Manage Models", + description: "Manage available models", + }, + }, + terminal: { + tabs: { + session: "Session", + context: "Context", + lsp: "LSP", + mcp: "MCP", + }, + }, + fileTree: { + empty: "No files found", + refresh: "Refresh", + }, + sidebar: { + toggle: "Toggle sidebar", + newSession: "New session", + loadMore: "Load more", + gettingStarted: "Getting started", + gettingStartedDesc1: "OpenCode includes free models so you can start immediately.", + gettingStartedDesc2: "Connect any provider to use models, inc. Claude, GPT, Gemini etc.", + connectProvider: "Connect provider", + shareFeedback: "Share feedback", + changeLanguage: "Change language", + }, + layout: { + editProject: "Edit project", + closeProject: "Close project", + }, +} as const diff --git a/packages/app/src/i18n/locales/es.ts b/packages/app/src/i18n/locales/es.ts new file mode 100644 index 00000000000..207da6fd659 --- /dev/null +++ b/packages/app/src/i18n/locales/es.ts @@ -0,0 +1,108 @@ +export default { + common: { + loading: "Cargando...", + save: "Guardar", + cancel: "Cancelar", + confirm: "Confirmar", + delete: "Eliminar", + edit: "Editar", + search: "Buscar", + back: "Atrás", + next: "Siguiente", + close: "Cerrar", + }, + home: { + title: "OpenCode AI", + subtitle: "Herramienta de desarrollo impulsada por IA", + start: "Comenzar a codificar", + recentProjects: "Proyectos recientes", + noRecentProjects: "Sin proyectos recientes", + getStarted: "Abre un proyecto local para comenzar", + openProject: "Abrir proyecto", + }, + session: { + new: "Nueva sesión", + newSession: "Nueva sesión", + mainBranch: "Rama principal", + mainBranchWithName: "Rama principal ({branch})", + createWorktree: "Crear nuevo árbol de trabajo", + lastModified: "Última modificación", + backToParent: "Volver a la sesión principal", + share: "Compartir sesión", + terminate: "Terminar", + archive: "Archivar sesión", + filesChanged: "{count} archivo{plural} modificado{plural}", + }, + dialog: { + selectProvider: { + title: "Seleccionar proveedor", + description: "Elige un proveedor de IA para usar", + }, + selectModel: { + title: "Seleccionar modelo", + description: "Elige un modelo para usar", + unpaid: { + title: "Pago del modelo requerido", + description: "Este modelo requiere pago para usarse", + }, + }, + selectServer: { + title: "Seleccionar servidor", + description: "Elige un servidor al cual conectarse", + }, + selectDirectory: { + title: "Seleccionar directorio", + description: "Elige un directorio con el cual trabajar", + openProject: "Abrir proyecto", + }, + selectFile: { + title: "Seleccionar archivo", + description: "Elige un archivo con el cual trabajar", + }, + selectMcp: { + title: "Seleccionar MCP", + description: "Elige un servidor Model Context Protocol", + }, + connectProvider: { + title: "Conectar proveedor", + description: "Configura tus credenciales de proveedor de IA", + }, + editProject: { + title: "Editar proyecto", + description: "Edita la configuración del proyecto", + editProject: "Editar proyecto", + closeProject: "Cerrar proyecto", + }, + manageModels: { + title: "Administrar modelos", + description: "Administra los modelos disponibles", + }, + }, + terminal: { + tabs: { + session: "Sesión", + context: "Contexto", + lsp: "LSP", + mcp: "MCP", + }, + }, + fileTree: { + empty: "No se encontraron archivos", + refresh: "Actualizar", + }, + sidebar: { + toggle: "Alternar barra lateral", + newSession: "Nueva sesión", + loadMore: "Cargar más", + gettingStarted: "Comenzando", + gettingStartedDesc1: "OpenCode incluye modelos gratuitos para que puedas comenzar de inmediato.", + gettingStartedDesc2: "Conecta cualquier proveedor para usar modelos, incl. Claude, GPT, Gemini, etc.", + connectProvider: "Conectar proveedor", + shareFeedback: "Comentar comentarios", + changeLanguage: "Cambiar idioma", + }, + layout: { + editProject: "Editar proyecto", + closeProject: "Cerrar proyecto", + }, +} as const diff --git a/packages/app/src/i18n/locales/fr.ts b/packages/app/src/i18n/locales/fr.ts new file mode 100644 index 00000000000..eb52d3e531a --- /dev/null +++ b/packages/app/src/i18n/locales/fr.ts @@ -0,0 +1,108 @@ +export default { + common: { + loading: "Chargement...", + save: "Enregistrer", + cancel: "Annuler", + confirm: "Confirmer", + delete: "Supprimer", + edit: "Modifier", + search: "Rechercher", + back: "Retour", + next: "Suivant", + close: "Fermer", + }, + home: { + title: "OpenCode AI", + subtitle: "Outil de développement alimenté par l'IA", + start: "Commencer à coder", + recentProjects: "Projets récents", + noRecentProjects: "Aucun projet récent", + getStarted: "Ouvrez un projet local pour commencer", + openProject: "Ouvrir un projet", + }, + session: { + new: "Nouvelle session", + newSession: "Nouvelle session", + mainBranch: "Branche principale", + mainBranchWithName: "Branche principale ({branch})", + createWorktree: "Créer un nouvel arbre de travail", + lastModified: "Dernière modification", + backToParent: "Retour à la session parente", + share: "Partager la session", + terminate: "Terminer", + archive: "Archiver la session", + filesChanged: "{count} fichier{plural} modifié{plural}", + }, + dialog: { + selectProvider: { + title: "Sélectionner un fournisseur", + description: "Choisir un fournisseur d'IA à utiliser", + }, + selectModel: { + title: "Sélectionner un modèle", + description: "Choisir un modèle à utiliser", + unpaid: { + title: "Paiement du modèle requis", + description: "Ce modèle nécessite un paiement pour être utilisé", + }, + }, + selectServer: { + title: "Sélectionner un serveur", + description: "Choisir un serveur auquel se connecter", + }, + selectDirectory: { + title: "Sélectionner un répertoire", + description: "Choisir un répertoire avec lequel travailler", + openProject: "Ouvrir un projet", + }, + selectFile: { + title: "Sélectionner un fichier", + description: "Choisir un fichier avec lequel travailler", + }, + selectMcp: { + title: "Sélectionner un MCP", + description: "Choisir un serveur Model Context Protocol", + }, + connectProvider: { + title: "Connecter un fournisseur", + description: "Configurer vos identifiants de fournisseur d'IA", + }, + editProject: { + title: "Modifier le projet", + description: "Modifier les paramètres du projet", + editProject: "Modifier le projet", + closeProject: "Fermer le projet", + }, + manageModels: { + title: "Gérer les modèles", + description: "Gérer les modèles disponibles", + }, + }, + terminal: { + tabs: { + session: "Session", + context: "Contexte", + lsp: "LSP", + mcp: "MCP", + }, + }, + fileTree: { + empty: "Aucun fichier trouvé", + refresh: "Actualiser", + }, + sidebar: { + toggle: "Afficher/Masquer la barre latérale", + newSession: "Nouvelle session", + loadMore: "Charger plus", + gettingStarted: "Premiers pas", + gettingStartedDesc1: "OpenCode inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", + gettingStartedDesc2: "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini, etc.", + connectProvider: "Connecter un fournisseur", + shareFeedback: "Partager des commentaires", + changeLanguage: "Changer de langue", + }, + layout: { + editProject: "Modifier le projet", + closeProject: "Fermer le projet", + }, +} as const diff --git a/packages/app/src/i18n/locales/index.ts b/packages/app/src/i18n/locales/index.ts new file mode 100644 index 00000000000..ddf15d5609c --- /dev/null +++ b/packages/app/src/i18n/locales/index.ts @@ -0,0 +1,19 @@ +import en from "./en" +import zhCN from "./zh-CN" +import ja from "./ja" +import fr from "./fr" +import es from "./es" + +export const locales = { + en, + "zh-CN": zhCN, + ja, + fr, + es, +} as const + +export type Locale = keyof typeof locales +export const defaultLocale: Locale = "en" +export const supportedLocales: Locale[] = Object.keys(locales) as Locale[] + +export type Translation = typeof locales[Locale] diff --git a/packages/app/src/i18n/locales/ja.ts b/packages/app/src/i18n/locales/ja.ts new file mode 100644 index 00000000000..cf59d9878ae --- /dev/null +++ b/packages/app/src/i18n/locales/ja.ts @@ -0,0 +1,108 @@ +export default { + common: { + loading: "読み込み中...", + save: "保存", + cancel: "キャンセル", + confirm: "確認", + delete: "削除", + edit: "編集", + search: "検索", + back: "戻る", + next: "次へ", + close: "閉じる", + }, + home: { + title: "OpenCode AI", + subtitle: "AI による開発ツール", + start: "コーディングを開始", + recentProjects: "最近のプロジェクト", + noRecentProjects: "最近のプロジェクトがありません", + getStarted: "ローカルプロジェクトを開いて始めましょう", + openProject: "プロジェクトを開く", + }, + session: { + new: "新しいセッション", + newSession: "新しいセッション", + mainBranch: "メインブランチ", + mainBranchWithName: "メインブランチ ({branch})", + createWorktree: "新しいワークツリーを作成", + lastModified: "最終更新", + backToParent: "親セッションに戻る", + share: "セッションを共有", + terminate: "終了", + archive: "セッションをアーカイブ", + filesChanged: "{count} ファイルが変更されました", + }, + dialog: { + selectProvider: { + title: "プロバイダーを選択", + description: "使用する AI プロバイダーを選択してください", + }, + selectModel: { + title: "モデルを選択", + description: "使用するモデルを選択してください", + unpaid: { + title: "モデルの支払いが必要", + description: "このモデルを使用するには支払いが必要です", + }, + }, + selectServer: { + title: "サーバーを選択", + description: "接続するサーバーを選択してください", + }, + selectDirectory: { + title: "ディレクトリを選択", + description: "使用するディレクトリを選択してください", + openProject: "プロジェクトを開く", + }, + selectFile: { + title: "ファイルを選択", + description: "使用するファイルを選択してください", + }, + selectMcp: { + title: "MCP を選択", + description: "モデル コンテキスト プロトコル サーバーを選択してください", + }, + connectProvider: { + title: "プロバイダーに接続", + description: "AI プロバイダーの認証情報を設定してください", + }, + editProject: { + title: "プロジェクトを編集", + description: "プロジェクト設定を編集してください", + editProject: "プロジェクトを編集", + closeProject: "プロジェクトを閉じる", + }, + manageModels: { + title: "モデルを管理", + description: "利用可能なモデルを管理してください", + }, + }, + terminal: { + tabs: { + session: "セッション", + context: "コンテキスト", + lsp: "LSP", + mcp: "MCP", + }, + }, + fileTree: { + empty: "ファイルが見つかりません", + refresh: "更新", + }, + sidebar: { + toggle: "サイドバーを切り替え", + newSession: "新しいセッション", + loadMore: "もっと見る", + gettingStarted: "はじめに", + gettingStartedDesc1: "OpenCode には無料モデルが含まれており、すぐに始められます。", + gettingStartedDesc2: "プロバイダーを接続してモデルを使用できます(Claude、GPT、Gemini など)。", + connectProvider: "プロバイダーに接続", + shareFeedback: "フィードバックを共有", + changeLanguage: "言語を変更", + }, + layout: { + editProject: "プロジェクトを編集", + closeProject: "プロジェクトを閉じる", + }, +} as const diff --git a/packages/app/src/i18n/locales/zh-CN.ts b/packages/app/src/i18n/locales/zh-CN.ts new file mode 100644 index 00000000000..ec32202d07f --- /dev/null +++ b/packages/app/src/i18n/locales/zh-CN.ts @@ -0,0 +1,108 @@ +export default { + common: { + loading: "加载中...", + save: "保存", + cancel: "取消", + confirm: "确认", + delete: "删除", + edit: "编辑", + search: "搜索", + back: "返回", + next: "下一步", + close: "关闭", + }, + home: { + title: "OpenCode AI", + subtitle: "AI 驱动的开发工具", + start: "开始编码", + recentProjects: "最近的项目", + noRecentProjects: "没有最近的项目", + getStarted: "打开本地项目开始使用", + openProject: "打开项目", + }, + session: { + new: "新会话", + newSession: "新会话", + mainBranch: "主分支", + mainBranchWithName: "主分支 ({branch})", + createWorktree: "创建新工作树", + lastModified: "最后修改", + backToParent: "返回父会话", + share: "分享会话", + terminate: "终止", + archive: "归档会话", + filesChanged: "{count} 个文件已更改", + }, + dialog: { + selectProvider: { + title: "选择提供商", + description: "选择要使用的 AI 提供商", + }, + selectModel: { + title: "选择模型", + description: "选择要使用的模型", + unpaid: { + title: "模型需要付费", + description: "此模型需要付费才能使用", + }, + }, + selectServer: { + title: "选择服务器", + description: "选择要连接的服务器", + }, + selectDirectory: { + title: "选择目录", + description: "选择要使用的目录", + openProject: "打开项目", + }, + selectFile: { + title: "选择文件", + description: "选择要使用的文件", + }, + selectMcp: { + title: "选择 MCP", + description: "选择模型上下文协议服务器", + }, + connectProvider: { + title: "连接提供商", + description: "配置您的 AI 提供商凭据", + }, + editProject: { + title: "编辑项目", + description: "编辑项目设置", + editProject: "编辑项目", + closeProject: "关闭项目", + }, + manageModels: { + title: "管理模型", + description: "管理可用模型", + }, + }, + terminal: { + tabs: { + session: "会话", + context: "上下文", + lsp: "LSP", + mcp: "MCP", + }, + }, + fileTree: { + empty: "未找到文件", + refresh: "刷新", + }, + sidebar: { + toggle: "切换侧边栏", + newSession: "新会话", + loadMore: "加载更多", + gettingStarted: "入门指南", + gettingStartedDesc1: "OpenCode 包含免费模型,您可以立即开始使用。", + gettingStartedDesc2: "连接任何提供商以使用模型,包括 Claude、GPT、Gemini 等。", + connectProvider: "连接提供商", + shareFeedback: "分享反馈", + changeLanguage: "切换语言", + }, + layout: { + editProject: "编辑项目", + closeProject: "关闭项目", + }, +} as const diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 275113566ad..8e3358d0238 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -12,6 +12,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectServer } from "@/components/dialog-select-server" import { useServer } from "@/context/server" +import { useI18n } from "@/i18n" export default function Home() { const sync = useGlobalSync() @@ -20,6 +21,7 @@ export default function Home() { const dialog = useDialog() const navigate = useNavigate() const server = useServer() + const { t } = useI18n() const homedir = createMemo(() => sync.data.path.home) function openProject(directory: string) { @@ -40,7 +42,7 @@ export default function Home() { if (platform.openDirectoryPickerDialog && server.isLocal()) { const result = await platform.openDirectoryPickerDialog?.({ - title: "Open project", + title: t().home.openProject, multiple: true, }) resolve(result) @@ -75,9 +77,9 @@ export default function Home() { 0}>
-
Recent projects
+
{t().home.recentProjects}
    @@ -107,12 +109,12 @@ export default function Home() {
    -
    No recent projects
    -
    Get started by opening a local project
    +
    {t().home.noRecentProjects}
    +
    {t().home.getStarted}
    diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 85d61d57beb..c7dd5517d02 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -53,10 +53,12 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogSelectServer } from "@/components/dialog-select-server" +import { DialogSelectLanguage } from "@/components/dialog-select-language" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { useServer } from "@/context/server" +import { useI18n } from "@/i18n" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -91,6 +93,7 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() const theme = useTheme() + const { t } = useI18n() const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeLabel: Record = { @@ -176,7 +179,7 @@ export default function Layout(props: ParentProps) { const session = store.session.find((s) => s.id === perm.sessionID) const sessionKey = `${directory}:${perm.sessionID}` - const sessionTitle = session?.title ?? "New session" + const sessionTitle = session?.title ?? t().session.newSession const projectName = getFilename(directory) const description = `${sessionTitle} in ${projectName} needs permission` const href = `/${base64Encode(directory)}/session/${perm.sessionID}` @@ -359,21 +362,21 @@ export default function Layout(props: ParentProps) { const commands: CommandOption[] = [ { id: "sidebar.toggle", - title: "Toggle sidebar", + title: t().sidebar.toggle, category: "View", keybind: "mod+b", onSelect: () => layout.sidebar.toggle(), }, { id: "project.open", - title: "Open project", + title: t().home.openProject, category: "Project", keybind: "mod+o", onSelect: () => chooseProject(), }, { id: "provider.connect", - title: "Connect provider", + title: t().sidebar.connectProvider, category: "Provider", onSelect: () => connectProvider(), }, @@ -397,9 +400,19 @@ export default function Layout(props: ParentProps) { keybind: "alt+arrowdown", onSelect: () => navigateSessionByOffset(1), }, + { + id: "session.new", + title: t().session.newSession, + category: "Session", + keybind: "mod+n", + onSelect: () => { + const current = params.dir ? base64Decode(params.dir) : undefined + if (current) navigate(`/${params.dir}/session`) + }, + }, { id: "session.archive", - title: "Archive session", + title: t().session.archive, category: "Session", keybind: "mod+shift+backspace", disabled: !params.dir || !params.id, @@ -732,7 +745,7 @@ export default function Layout(props: ParentProps) { @@ -842,14 +855,14 @@ export default function Layout(props: ParentProps) { >
    - +
    - New session + {t().sidebar.newSession}
    @@ -866,7 +879,7 @@ export default function Layout(props: ParentProps) { size="large" onClick={loadMoreSessions} > - Load more + {t().sidebar.loadMore}
    @@ -918,8 +931,7 @@ export default function Layout(props: ParentProps) { @@ -948,7 +960,7 @@ export default function Layout(props: ParentProps) {
    @@ -985,24 +997,24 @@ export default function Layout(props: ParentProps) { 0 && !providers.paid().length && expanded()}>
    -
    Getting started
    -
    OpenCode includes free models so you can start immediately.
    -
    Connect any provider to use models, inc. Claude, GPT, Gemini etc.
    +
    {t().sidebar.gettingStarted}
    +
    {t().sidebar.gettingStartedDesc1}
    +
    {t().sidebar.gettingStartedDesc2}
    - +
    0}> - + @@ -1019,7 +1031,7 @@ export default function Layout(props: ParentProps) { placement="right" value={
    - Open project + {t().home.openProject} {command.keybind("project.open")} @@ -1034,10 +1046,10 @@ export default function Layout(props: ParentProps) { icon="folder-add-left" onClick={chooseProject} > - Open project + {t().home.openProject} - + + + +
    diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5d558ea14ca..ab17dd2235c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -51,6 +51,7 @@ export namespace Agent { "*": "ask", [Truncate.DIR]: "allow", }, + question: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -65,7 +66,13 @@ export namespace Agent { build: { name: "build", options: {}, - permission: PermissionNext.merge(defaults, user), + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + question: "allow", + }), + user, + ), mode: "primary", native: true, }, @@ -75,6 +82,7 @@ export namespace Agent { permission: PermissionNext.merge( defaults, PermissionNext.fromConfig({ + question: "allow", edit: { "*": "deny", ".opencode/plan/*.md": "allow", diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index f6c6b688a35..e6203d66574 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -515,7 +515,15 @@ export const GithubRunCommand = cmd({ // Setup opencode session const repoData = await fetchRepo() - session = await Session.create({}) + session = await Session.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }) subscribeSessionEvents() shareId = await (async () => { if (share === false) return diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bd9d29b4deb..a86b435ec3e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -292,7 +292,28 @@ export const RunCommand = cmd({ : args.title : undefined - const result = await sdk.session.create(title ? { title } : {}) + const result = await sdk.session.create( + title + ? { + title, + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + } + : { + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }, + ) return result.data?.id })() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 8a14d8b2e77..0edc911344c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -8,6 +8,7 @@ import type { Todo, Command, PermissionRequest, + QuestionRequest, LspStatus, McpStatus, McpResource, @@ -42,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ permission: { [sessionID: string]: PermissionRequest[] } + question: { + [sessionID: string]: QuestionRequest[] + } config: Config session: Session[] session_status: { @@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ status: "loading", agent: [], permission: {}, + question: {}, command: [], provider: [], provider_default: {}, @@ -142,6 +147,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "question.replied": + case "question.rejected": { + const requests = store.question[event.properties.sessionID] + if (!requests) break + const match = Binary.search(requests, event.properties.requestID, (r) => r.id) + if (!match.found) break + setStore( + "question", + event.properties.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } + + case "question.asked": { + const request = event.properties + const requests = store.question[request.sessionID] + if (!requests) { + setStore("question", request.sessionID, [request]) + break + } + const match = Binary.search(requests, request.id, (r) => r.id) + if (match.found) { + setStore("question", request.sessionID, match.index, reconcile(request)) + break + } + setStore( + "question", + request.sessionID, + produce((draft) => { + draft.splice(match.index, 0, request) + }), + ) + break + } + case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) break diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e1423e22c22..78f2ff7aa83 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -41,6 +41,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" +import type { QuestionTool } from "@/tool/question" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" import { PermissionPrompt } from "./permission" +import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" @@ -118,9 +120,13 @@ export function Session() { }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => { - if (session()?.parentID) return sync.data.permission[route.sessionID] ?? [] + if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) }) + const questions = createMemo(() => { + if (session()?.parentID) return [] + return children().flatMap((x) => sync.data.question[x.id] ?? []) + }) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -1037,8 +1043,11 @@ export function Session() { 0}> + 0}> + + { prompt = r promptRef.set(r) @@ -1047,7 +1056,7 @@ export function Session() { r.set(route.initialPrompt) } }} - disabled={permissions().length > 0} + disabled={permissions().length > 0 || questions().length > 0} onSubmit={() => { toBottom() }} @@ -1381,6 +1390,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -1442,7 +1454,12 @@ function InlineTool(props: { const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) - const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule")) + const denied = createMemo( + () => + error()?.includes("rejected permission") || + error()?.includes("specified a rule") || + error()?.includes("user dismissed"), + ) return ( ) { ) } +function Question(props: ToolProps) { + const { theme } = useTheme() + const count = createMemo(() => props.input.questions?.length ?? 0) + return ( + + + + + + {(q, i) => ( + + {q.question} + {props.metadata.answers?.[i()] || "(no answer)"} + + )} + + + + + + + Asked {count()} question{count() !== 1 ? "s" : ""} + + + + ) +} + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx new file mode 100644 index 00000000000..82a6a021cf6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -0,0 +1,291 @@ +import { createStore } from "solid-js/store" +import { createMemo, For, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" +import { useKeybind } from "../../context/keybind" +import { useTheme } from "../../context/theme" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import { useDialog } from "../../ui/dialog" + +export function QuestionPrompt(props: { request: QuestionRequest }) { + const sdk = useSDK() + const { theme } = useTheme() + const keybind = useKeybind() + const bindings = useTextareaKeybindings() + + const questions = createMemo(() => props.request.questions) + const single = createMemo(() => questions().length === 1) + const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single) + const [store, setStore] = createStore({ + tab: 0, + answers: [] as string[], + custom: [] as string[], + selected: 0, + editing: false, + }) + + let textarea: TextareaRenderable | undefined + + const question = createMemo(() => questions()[store.tab]) + const confirm = createMemo(() => !single() && store.tab === questions().length) + const options = createMemo(() => question()?.options ?? []) + const other = createMemo(() => store.selected === options().length) + const input = createMemo(() => store.custom[store.tab] ?? "") + + function submit() { + // Fill in empty answers with empty strings + const answers = questions().map((_, i) => store.answers[i] ?? "") + sdk.client.question.reply({ + requestID: props.request.id, + answers, + }) + } + + function reject() { + sdk.client.question.reject({ + requestID: props.request.id, + }) + } + + 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) + } + if (single()) { + sdk.client.question.reply({ + requestID: props.request.id, + answers: [answer], + }) + return + } + setStore("tab", store.tab + 1) + setStore("selected", 0) + } + + const dialog = useDialog() + + useKeyboard((evt) => { + // When editing "Other" textarea + if (store.editing && !confirm()) { + if (evt.name === "escape") { + evt.preventDefault() + setStore("editing", false) + return + } + if (evt.name === "return") { + evt.preventDefault() + const text = textarea?.plainText?.trim() + if (text) { + pick(text, true) + setStore("editing", false) + } + return + } + // Let textarea handle all other keys + return + } + + if (evt.name === "left" || evt.name === "h") { + evt.preventDefault() + const next = (store.tab - 1 + tabs()) % tabs() + setStore("tab", next) + setStore("selected", 0) + } + + if (evt.name === "right" || evt.name === "l") { + evt.preventDefault() + const next = (store.tab + 1) % tabs() + setStore("tab", next) + setStore("selected", 0) + } + + 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 + 1 // options + "Other" + + if (evt.name === "up" || evt.name === "k") { + evt.preventDefault() + setStore("selected", (store.selected - 1 + total) % total) + } + + if (evt.name === "down" || evt.name === "j") { + evt.preventDefault() + setStore("selected", (store.selected + 1) % total) + } + + if (evt.name === "return") { + evt.preventDefault() + if (other()) { + setStore("editing", true) + } else { + const opt = opts[store.selected] + if (opt) { + pick(opt.label) + } + } + } + + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + } + } + }) + + return ( + + + + + + {(q, index) => { + const isActive = () => index() === store.tab + const isAnswered = () => store.answers[index()] !== undefined + return ( + + + {q.header} + + + ) + }} + + + Confirm + + + + + + + + {question()?.question} + + + + {(opt, i) => { + const active = () => i() === store.selected + const picked = () => store.answers[store.tab] === opt.label + return ( + + + + + {i() + 1}. {opt.label} + + + {picked() ? "✓" : ""} + + + {opt.description} + + + ) + }} + + + + + + {options().length + 1}. Type your own answer + + + {input() ? "✓" : ""} + + + +