From 42898336656b0aa37a210ac15ce6086b9af464b1 Mon Sep 17 00:00:00 2001 From: dbc-hbin Date: Sun, 13 Sep 2026 00:03:43 +0900 Subject: [PATCH 1/5] feat: release 0.7.9 with shared OMP settings --- DESIGN.md | 43 +- README.ja.md | 11 +- README.ko.md | 14 +- README.md | 12 +- README.zh-CN.md | 11 +- android/app/build.gradle | 4 +- .../ompgui/remote/store/AppPreferences.kt | 9 + .../dbchbin/ompgui/remote/ui/ChatPanels.kt | 22 +- .../dbchbin/ompgui/remote/ui/ChatScreen.kt | 5 +- .../dbchbin/ompgui/remote/ui/MessageText.kt | 2 + .../ompgui/remote/ui/ModelSettingsPanel.kt | 2 +- .../ompgui/remote/ui/NativeSettings.kt | 212 +++ .../ompgui/remote/ui/OmpguiRemoteApp.kt | 3 + .../dbchbin/ompgui/remote/ui/SettingsSheet.kt | 990 +++++--------- .../app/src/main/res/values-ja/strings.xml | 35 + .../app/src/main/res/values-ko/strings.xml | 37 +- .../src/main/res/values-zh-rCN/strings.xml | 35 + android/app/src/main/res/values/strings.xml | 37 +- .../ompgui/remote/ui/NativeSettingsTest.kt | 108 ++ app/api/auth/login/[provider]/route.ts | 2 +- app/api/model-roles/route.ts | 2 +- app/api/omp-settings/route.ts | 61 +- app/api/providers/enable/route.ts | 2 +- components/AgentsConfig.tsx | 52 +- components/AppShell.tsx | 2 +- components/MessageView.test.mjs | 97 -- components/MessageView.tsx | 4 +- components/NativeSettingsEditor.tsx | 423 ++++++ components/SettingsConfig.test.mjs | 82 -- components/SettingsConfig.tsx | 689 ++-------- components/SettingsTabs.test.mjs | 25 - components/ui/field.tsx | 122 +- hooks/useHideThinking.ts | 8 + lib/api-contract.test.mjs | 2 +- lib/i18n/locales/en.json | 44 +- lib/i18n/locales/ja.json | 44 +- lib/i18n/locales/ko.json | 42 +- lib/i18n/locales/zh-CN.json | 44 +- lib/native-settings-client.test.mjs | 44 - lib/native-settings-client.ts | 42 - lib/omp/agents-service.test.mjs | 4 + lib/omp/agents-service.ts | 10 +- lib/omp/config-file.test.mjs | 4 +- lib/omp/file-lock.test.mjs | 178 +++ lib/omp/file-lock.ts | 250 ++++ lib/omp/model-roles.test.mjs | 6 +- lib/omp/model-roles.ts | 59 +- lib/omp/settings-catalog.ts | 371 +++++ lib/omp/settings-config.test.mjs | 635 ++++++--- lib/omp/settings-config.ts | 1210 +++++++++++------ lib/omp/settings-service.ts | 98 ++ lib/relay/connection.ts | 36 +- lib/relay/extensions-requests.test.mjs | 2 + lib/relay/extensions-requests.ts | 62 +- lib/relay/models-requests.test.mjs | 20 +- lib/relay/models-requests.ts | 28 +- lib/relay/protocol.test.mjs | 34 +- lib/relay/protocol.ts | 51 +- lib/relay/system-requests.test.mjs | 27 +- lib/relay/system-requests.ts | 129 +- lib/thinking-preference.ts | 38 + next.config.ts | 2 +- package-lock.json | 330 ++++- package.json | 4 +- 64 files changed, 4454 insertions(+), 2559 deletions(-) create mode 100644 android/app/src/main/java/com/dbchbin/ompgui/remote/ui/NativeSettings.kt create mode 100644 android/app/src/test/java/com/dbchbin/ompgui/remote/ui/NativeSettingsTest.kt create mode 100644 components/NativeSettingsEditor.tsx delete mode 100644 components/SettingsConfig.test.mjs create mode 100644 hooks/useHideThinking.ts delete mode 100644 lib/native-settings-client.test.mjs delete mode 100644 lib/native-settings-client.ts create mode 100644 lib/omp/file-lock.test.mjs create mode 100644 lib/omp/file-lock.ts create mode 100644 lib/omp/settings-catalog.ts create mode 100644 lib/omp/settings-service.ts create mode 100644 lib/thinking-preference.ts diff --git a/DESIGN.md b/DESIGN.md index 4a1ecd2f3..8f9f465dd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -51,7 +51,7 @@ Browser │ HTTP / Server-Sent Events ▼ ompgui (Next.js on Node) - ├─ reads native OMP session files and selected configuration + ├─ reads native OMP sessions and catalog-filtered global/project settings ├─ serves allow-listed project files └─ starts one `omp --mode rpc-ui` child per active session │ NDJSON over stdio @@ -96,13 +96,40 @@ atomic where possible; archive or deletion stops the associated live process first. ompgui does not provide a general editor for session JSONL or opaque OMP state. -Models and allow-listed OMP settings use surgical YAML updates that preserve -unrelated content. Plugin operations run the installed `omp plugin` CLI. MCP -configuration is project-local, validated before writing, and saved atomically. -Renaming a server to an existing name returns HTTP 409 without overwriting either -configuration. -Configuration replacements use exclusive, owner-only temporary files. Project -configuration targets are checked through symlinks before creating directories. +Models use surgical YAML updates. Native OMP settings are defined once in +`lib/omp/settings-catalog.ts`; `/api/omp-settings` and Relay's `settings.get` / +`settings.update` call the same settings service, so browser and Android receive +the same catalog, validation, scope policy, redaction, confirmation rules, and +per-layer/effective-value DTOs. Values in that DTO are saved configuration, not +live-session state or environment/CLI overrides; clients supply catalog defaults +for absent effective values. + +The global layer reads `~/.omp/agent/config.yml`, falling back to an existing +`config.yaml`; an authorized project layer reads and writes only `.omp/config.yml`. +Project values recursively merge over global values for display; a null project +model-role entry inherits the global role, and ordered role-selector arrays are +preserved. Scoped model/provider registry arrays that the simple editor cannot +represent are marked read-only and protected against replacement or reset. +A save applies a minimal leaf patch, with structured records replacing that record at the selected +layer and ordered arrays remaining ordered; reset removes the selected-layer leaf +so defaults or inheritance apply. Catalog-designated secret values are never +returned, only presence, and sensitive changes fail closed without their required +confirmation. YAML aliases are isolated before mutation so editing an anchor +does not change unrelated alias consumers. Unrelated YAML and comments are +preserved. New or restarted OMP sessions consume saved changes; ompgui does not +silently restart a live RPC child. + +Every settings read-modify-write holds OMP's native cross-process config lock and +then performs an atomic replacement. The Node implementation uses `koffi` for the +platform primitives and `xxhash-wasm` for OMP-compatible lock names; Darwin +interoperability has been exercised, while the Linux and Windows implementations +are source-complete but were not runtime-tested in that verification pass. +Configuration replacements use exclusive, owner-only temporary files, and project +targets are checked through symlinks before directories are created. + +Plugin operations run the installed `omp plugin` CLI. MCP configuration is +project-local, validated before writing, and saved atomically. Renaming a server +to an existing name returns HTTP 409 without overwriting either configuration. ### Server-owned pre-dispatch queue diff --git a/README.ja.md b/README.ja.md index 6e4da91fe..182d24c70 100644 --- a/README.ja.md +++ b/README.ja.md @@ -6,6 +6,8 @@ > `android/` の実験的なネイティブ Compose クライアントは、認証済みの `/relay` WebSocket を使用し、セッション・履歴の操作、リンク付きメッセージ、オフラインの Mermaid 図とコードの構文強調、画像/PDF/音声/HTML/Markdown/DOCX のインラインプレビューを提供します。ファイルの自動更新は表示中のみ動作し、未保存の編集を保持します。許可された隠しファイルの閲覧とアーカイブ検索にも対応します。モデル・設定には公開 models.dev カタログ、高度な OMP 設定、構成とは別に表示されるセッションごとの実際の MCP 実行状態が含まれます。添付ファイルは端末上で巨大な単一 JSON にまとめず、ストリーミングでステージングします。画像は各 10 MiB で最大 10 件、テキスト添付は独立した枠で各 256 KiB、最大 10 件です。OMP 自体の画像正規化とプロバイダーごとの画像数制限は引き続き適用されます。 > +> **現在の設定機能:** Web と Android APK は、スコープ、継承、リセット、検証、保護されたシークレット/管理者操作を含む同一の検索・カテゴリ対応 OMP 設定カタログを使用します。端末ローカルの思考表示設定は会話の描画だけを変え、最終回答は隠しません。 +> > このクライアントは公開済みの v0.7.2 APK を置き換えるものではなく、ホスト型/E2E リレーを追加するものでもありません。JDK 21 を使用し、リポジトリのルートで `npm install` を実行してから、`android/` で `./gradlew :app:assembleDebug` を実行します。オフラインアセットはインストール済みの npm 依存パッケージから生成されます。出力先は `android/app/build/outputs/apk/debug/app-debug.apk` です。サーバーで無効化されている更新、ログアウト、保存済み API キーの変更操作は引き続き利用できません。 [oh-my-pi (omp) コーディングエージェント](https://github.com/can1357/oh-my-pi)のローカル Web UI です。ompgui はローカルの omp セッションファイルを読み込み、セッションの閲覧、リアルタイムチャット、モデル設定、スキル管理、プロジェクトファイルのプレビューを行えるブラウザワークスペースを提供します。 @@ -127,7 +129,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **ブランチをまたいで作業**: サイドバーから Git ワークツリーを切り替えると、新しいセッションとエクスプローラーが選択したチェックアウトに追従します。 - **プロジェクトを見ながらチャット**: エージェントの作業中に、左側でファイルを閲覧し、右側でソース、ドキュメント、画像、音声、PDF をプレビューできます。 - **セッション状態をひと目で把握**: コンテキスト使用量、コスト、コンパクション状態、システムプロンプトの詳細をトップバーで確認できます。 -- **ターミナルに頼らない設定**: モデル、ログイン/APIキー、モデルテスト、ネイティブ OMP 制御(アドバイザー、承認、Bash ポリシー、思考、コンパクション、メモリ、自動学習、リトライ/フォールバック)、スキル、プラグイン、プロジェクト MCP サーバーを Web UI から管理できます。 +- **OMP 設定を一貫して管理**: Web と Android が、モデル、エージェント、ツール、安全性、システム動作のための検索・カテゴリ対応カタログを共有し、グローバル/プロジェクト継承、デフォルトへのリセット、高度な値の検証を提供します。 - **Settings で MCP を管理**: 専用の MCP タブでプロジェクトサーバーの状態(有効 / 無効 / 無効な設定)を表示し、追加、編集、名前変更、検証、削除を行えます。設定エラーはトーストで通知されます。 - **OMP を最新に保つ**: Settings からインストール済みランタイムを確認・更新し、必要に応じてアクティブなセッションを再起動できます。 - **完了を見逃さない**: エージェント完了時のブラウザー通知を有効にし、インストール済みスキルの更新を確認できます。 @@ -146,6 +148,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | 別の omp エージェントディレクトリを指定(デフォルト `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | サーバーサイドリクエスト用の標準プロキシ変数 | +### OMP 設定 + +Web UI と Android APK は同じ共有カタログをカテゴリと検索で表示し、デフォルト値、グローバルまたはプロジェクトのスコープ、継承された有効値、リセットに対応します。グローバル設定は `~/.omp/agent/config.yml` を使用し、`config.yml` が存在せず既存の `config.yaml` がある場合に限り、後者を読み書きのフォールバックとして使用します。プロジェクト設定は、許可されたワークスペースの正規パス `.omp/config.yml` だけを使用します。表示する有効値では、プロジェクト層をグローバル層へ再帰的にマージします。保存時には、構造化レコードが選択した層の該当レコード全体を置換し、順序付き配列は順序を保持します。数値、列挙値、配列、構造化値は最小限の YAML 更新前に検証され、無関係なキーとコメントは保持されます。 + +表示される有効値は保存済みのグローバル/プロジェクト構成を統合したもので、実行中セッションの実値でも、環境変数や CLI による上書きでもありません。変更は新規または再起動したセッションで反映され、保存だけで実行中の RPC セッションが自動再起動することはありません。カタログで確認対象に指定された変更には明示的な確認が必要です。シークレットは書き込み専用で、クライアントには有無だけが返され、置換または削除はできますが保存値は読み取れません。端末ごとの思考表示設定はローカル表示だけに作用し、思考ブロックを隠しても最終回答は隠しません。 + ## アーキテクチャ ompgui は Node 上でホストされる Next.js アプリで、インストール済みの `omp` バイナリを操作します。エージェント自体は同梱していません: @@ -153,6 +161,7 @@ ompgui は Node 上でホストされる Next.js アプリで、インストー - **ライブセッション**: `omp --mode rpc-ui`(stdio 上の NDJSON)を、アクティブなセッションごとに 1 つの子プロセスとして起動します。そのため、エージェントのバージョンは常にインストールされているものと完全に一致します。 - **セッション閲覧**: omp のセッションファイル(`~/.omp/agent/sessions//_.jsonl`)を直接読み込みます。タイトル変更、アーカイブ、削除は、OMP のライブ書き込みと競合しないよう保護されたネイティブファイルのメンテナンス操作です。 - **モデルと認証**: omp 子プロセスに対する RPC コマンドを使用します。モデルパネルは omp エージェントディレクトリ内の `models.yml` を編集します。 +- **OMP 設定**: `lib/omp/settings-catalog.ts` が Web と Relay/Android で共有するカタログを定義し、設定サービスがカタログで絞り込んだ各層/有効値を返して、検証済みの最小パッチを適用します。グローバルおよび許可されたプロジェクトへの書き込みは、OMP 互換のプロセス間ロックとアトミック置換を使い、ネイティブ OMP と ompgui が互いのファイル世代を上書きしないようにします。 - **スキルとプラグイン**: omp のスキルディレクトリ(`~/.omp/agent/skills`、プロジェクトの `.omp/skills`、互換ディレクトリ)をスキャンし、プラグイン管理には `omp plugin` を呼び出します。 - **ファイルアクセス**: ファイルの閲覧とプレビューは、選択したプロジェクトディレクトリとセッションに現れる作業ディレクトリに限定されます。 - **フォークとセッション内ブランチの違い**: フォークは新しい `.jsonl` ファイルを作成します。「ここから編集」は同じセッションファイル内に別のブランチを作成します。 diff --git a/README.ko.md b/README.ko.md index 8bf394e8d..f8ec55b87 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,6 +6,8 @@ > `android/`의 실험용 네이티브 Compose 클라이언트는 인증된 `/relay` WebSocket을 사용하며, 세션/기록 제어와 링크·도구 호출·결과·오류·미디어 참조를 포함하는 풍부한 온라인 대화 기록, 오프라인 Mermaid 다이어그램 및 코드 구문 강조, 이미지/PDF/오디오/HTML/Markdown/DOCX 인라인 미리보기를 제공합니다. 온라인 기록은 보호된 오프라인 스냅샷과 별개입니다. 크기가 제한된 미리보기에서 `sessions.content`로 전체 텍스트를 나누어 가져오고 미디어 참조도 페이지 단위로 조회하므로 대화 기록을 4,000자로 잘라내지 않습니다. 오프라인 캐시의 개인정보 보호 제한은 그대로 유지됩니다. 파일 자동 새로고침은 화면에 보일 때만 동작하며 저장하지 않은 편집 내용을 보존합니다. 허용된 숨김 파일 탐색과 아카이브 검색도 지원합니다. 모델/설정에서는 공개 models.dev 카탈로그, 고급 OMP 설정, 구성 상태와 구분되는 세션별 실제 MCP 실행 상태를 확인할 수 있습니다. 첨부 파일은 휴대폰에서 하나의 거대한 JSON으로 묶지 않고 스트리밍으로 스테이징하며, 이미지는 개당 10 MiB로 최대 10개, 텍스트 첨부는 별도로 개당 256 KiB로 최대 10개까지 지원합니다. OMP 자체의 이미지 정규화와 제공자별 이미지 개수 제한은 계속 적용됩니다. > +> **현재 설정 기능:** 웹과 Android APK는 범위, 상속, 초기화, 유효성 검사, 보호된 비밀/관리자 제어를 포함하는 동일한 검색·카테고리형 OMP 설정 카탈로그를 사용합니다. 기기 로컬의 추론 표시 설정은 대화 렌더링만 바꾸며 최종 답변은 숨기지 않습니다. +> > 네이티브 인터페이스는 웹 앱의 Warm 및 OMP 색상 팔레트를 따르며, 간결한 프로젝트/세션 트리와 설정/사용량 하단 메뉴, 도구 모음을 한 줄로 정리한 메시지 입력창을 제공합니다. 기록, 세션 정보, 명령 및 런타임 제어는 세션 메뉴에 모았고, 보조 워크스페이스 작업은 더보기 메뉴에 유지했습니다. 런타임 제어는 펼칠 수 있는 트리 카테고리로 구성하고 현재 값을 일정한 열에 맞춰 표시합니다. 설정은 간결한 행 단위 선택 컨트롤을 사용하며, 드롭다운 메뉴는 해당 컨트롤에 붙어 열립니다. Android 네이티브 컨트롤, 파일 선택기, 시트 탐색 방식과 서로 겹치지 않는 최소 48dp 터치 영역을 유지합니다. > > 이 클라이언트는 배포된 v0.7.2 APK를 대체하거나 호스팅형/E2E 릴레이를 추가하지 않습니다. JDK 21을 사용하여 저장소 루트에서 `npm install`을 실행한 뒤, `android/`에서 `./gradlew :app:assembleDebug`로 빌드합니다. 오프라인 에셋은 설치된 npm 의존성에서 생성되며, 출력 경로는 `android/app/build/outputs/apk/debug/app-debug.apk`입니다. 서버에서 비활성화된 업데이트, 로그아웃, 저장된 API 키 변경 작업은 여전히 사용할 수 없습니다. @@ -142,7 +144,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **정확한 Markdown 미리보기**: YAML frontmatter를 요약 카드(제목 및 키/값 행)로 렌더링하고, 수식 펜스를 목록 안에서도 정렬하며, `5~7U` 같은 CJK 범위를 올바르게 표시합니다(GFM에서는 취소선에 이제 `~~`가 필요합니다). - **Windows에서 자연스럽게 프로젝트 선택**: 파일 시스템 루트의 드라이브 선택기와 대소문자 무시·심볼릭 링크 인식 프로젝트 식별자로 드라이브와 worktree 사이에서도 사이드바를 안정적으로 유지합니다. - **세션 상태 명확히 확인**: 상단 바에서 컨텍스트 사용량, 비용, 압축 상태, 시스템 프롬프트 세부 정보를 확인합니다. -- **터미널 설정 줄이기**: 웹 UI에서 모델, 로그인/API 키, 모델 테스트, OMP 기본 제어(어드바이저, 승인, Bash 정책, 추론, 압축, 메모리, 자동 학습, 재시도/폴백), 스킬, 플러그인, 프로젝트 MCP 서버를 관리합니다. +- **OMP 설정을 일관되게 관리**: 웹과 Android가 모델, 에이전트, 도구, 안전, 시스템 동작을 위한 하나의 검색·카테고리형 설정 카탈로그를 공유하며 전역/프로젝트 상속, 기본값 초기화, 고급 값 검증을 제공합니다. - **Settings에서 MCP 관리**: 전용 MCP 탭에서 설치된 프로젝트 서버의 상태(활성화/비활성화/잘못됨)를 확인하고 추가·편집·이름 변경·검증·삭제를 수행하며 설정 오류를 코너 토스트로 표시합니다. - **OMP 최신 상태 유지**: Settings에서 설치된 런타임 버전을 확인하고 업데이트하거나 필요할 때 활성 세션을 재시작합니다. - **완료 알림 받기**: 에이전트가 작업을 마치면 브라우저 알림을 받도록 선택하고 설치된 스킬의 업데이트를 확인합니다. @@ -161,6 +163,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | 다른 omp 에이전트 디렉터리 지정(기본 `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 서버 측 요청에 사용하는 표준 프록시 변수 | +### OMP 설정 + +웹 UI와 Android APK는 동일한 공유 카탈로그를 카테고리/검색 방식으로 표시하며 기본값, 전역 또는 프로젝트 범위, 상속된 유효 값, 초기화를 지원합니다. 전역 설정은 `~/.omp/agent/config.yml`을 사용하며, `config.yml`이 없고 기존 `config.yaml`이 있을 때만 그 파일을 읽기/쓰기 대체 경로로 사용합니다. 프로젝트 설정은 승인된 워크스페이스의 표준 `.omp/config.yml`만 사용합니다. 화면에 표시되는 유효 값은 프로젝트 계층을 전역 계층 위에 재귀적으로 병합합니다. 저장할 때는 구조화된 레코드가 선택한 계층의 해당 레코드 전체를 대체하고, 순서가 있는 배열은 순서를 유지합니다. 숫자, enum, 배열, 구조화된 값은 최소 YAML 업데이트 전에 검증되고 관련 없는 키와 주석은 보존됩니다. + +표시되는 유효 값은 저장된 전역/프로젝트 구성의 병합 결과이며, 이미 실행 중인 세션의 실제 값이나 환경 변수/CLI 재정의 값이 아닙니다. 변경은 새 세션 또는 재시작한 세션에서 적용되며 저장만으로 실행 중인 RPC 세션을 자동 재시작하지 않습니다. 카탈로그에서 확인 대상으로 지정한 변경에는 명시적 확인이 필요합니다. 비밀 값은 쓰기 전용이므로 클라이언트에는 존재 여부만 반환되고 교체 또는 삭제만 가능하며 저장된 값은 읽을 수 없습니다. 기기별 추론 표시 설정은 로컬 표현에만 영향을 주어 추론 블록은 숨길 수 있지만 최종 답변은 숨기지 않습니다. + ## 아키텍처 ompgui은 Node가 호스팅하는 Next.js 앱으로, 설치된 `omp` 바이너리를 구동합니다. 에이전트를 내장하지 않습니다. @@ -168,7 +176,7 @@ ompgui은 Node가 호스팅하는 Next.js 앱으로, 설치된 `omp` 바이너 - **실시간 세션**: `omp --mode rpc-ui`(stdio 기반 NDJSON)를 실행하며 활성 세션마다 자식 프로세스를 하나씩 사용합니다. 설치된 OMP가 RPC v2를 알리면 v2를 협상하고, 큰 프레임은 제한된 청크 재조립을 사용하며, 구버전은 v1로 폴백합니다. 실행 전 호스트 환경(`PORT`, `NEXT_*`, `NODE_ENV`)을 제거하고 POSIX(프로세스 그룹)와 Windows(`taskkill /t`) 모두 정상 종료를 지원합니다. - **세션 탐색**: omp 세션 파일(`~/.omp/agent/sessions//_.jsonl`)을 직접 읽습니다. 제목·보관·삭제는 실시간 OMP 쓰기를 보호하는 제한적인 기본 파일 유지보수 작업입니다. 프로젝트는 안정적인 `projectKey`(Windows 대소문자 무시, 심볼릭 링크 해석)로 묶어 사이드바가 드라이브나 worktree 사이에서 이동하지 않게 합니다. - **모델 및 인증**: 엄격한 페이로드 검증(알 수 없는 형태 차단, 안전한 폴백)을 적용한 RPC 명령으로 omp 자식 프로세스와 통신합니다. Models 패널은 omp 에이전트 디렉터리의 `models.yml`을 편집하며 빈 자리 행을 제거하고 모호한 `enabledModels` 항목을 거부합니다. -- **기본 설정**: General/MCP 설정 패널은 `~/.omp/agent/config.yml`(또는 `config.yaml` 폴백)의 허용 목록에 포함된 항목을 읽고 씁니다. 관련 없는 키와 주석은 보존하며 변경 사항은 새 세션과 재시작한 세션에 적용됩니다. +- **OMP 설정**: `lib/omp/settings-catalog.ts`가 웹과 Relay/Android가 공유하는 카탈로그를 정의하고, 설정 서비스가 카탈로그로 필터링한 계층별/유효 값을 반환하며 검증된 최소 패치를 적용합니다. 전역 및 승인된 프로젝트 쓰기는 OMP 호환 프로세스 간 잠금과 원자적 교체를 사용해 네이티브 OMP와 ompgui가 서로의 파일 세대를 덮어쓰지 않게 합니다. - **스킬 및 플러그인**: omp 스킬 디렉터리(`~/.omp/agent/skills`, 프로젝트 `.omp/skills`, 호환 디렉터리)를 검색하고 플러그인 관리는 `omp plugin`을 호출합니다. - **MCP 서버**: 프로젝트 서버는 Git 최상위의 OMP 기본 위치(`.omp/mcp.json`, 이후 호환 파일)를 통해 관리하며 stdio/http/sse 스키마를 검증하고 원자적으로 기록합니다. - **파일 접근**: 파일 탐색과 미리보기는 선택한 프로젝트 디렉터리 및 세션에 나타난 작업 디렉터리로 제한됩니다. `isWindowsAbsolutePath`/`samePath` 단일 헬퍼로 경로를 정규화하고 `realpath` 해석 뒤 심볼릭 링크 탈출을 거부합니다. Windows에서는 루트에서 드라이브 목록을 제공하는 디렉터리 선택기를 사용합니다. @@ -212,7 +220,7 @@ ompgui은 영어, 중국어 간체(简体中文), 일본어(日本語), 한국 - **접근성**: WCAG AA 준수 — Lighthouse 접근성 점수 100/100, 전면 키보드 탐색, focus-visible 링, ARIA 역할 - **성능**: 메모이제이션된 목록 컴포넌트, RAF로 제한한 스크롤/마우스 핸들러, 디바운스 검색, 스트리밍 JSONL 리더, ETag 캐시 세션 목록 - **복원력**: omp 자식 프로세스의 정상 종료(프로세스 그룹 종료), 오류 경계, 원자적 세션 파일 재작성 -- **테스트**: 세션 파싱, 터미널 입력, Markdown 렌더링, 메시지 표시, 기본 설정, MCP 구성을 다루는 집중 테스트 스위트 +- **테스트**: 세션 파싱, 터미널 입력, Markdown 렌더링, 메시지 표시, OMP 설정, MCP 구성을 다루는 집중 테스트 스위트 ## 크레딧 diff --git a/README.md b/README.md index 21f9b8a73..6ae007710 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > The Kotlin companion app in `android/` uses authenticated `/relay` WebSockets for session/history controls and rich online transcripts with linked messages, tool calls/results/errors, and media references, plus offline Mermaid diagrams and code highlighting and inline image/PDF/audio/HTML/Markdown/DOCX previews. Online history is independent of the protected offline snapshot: bounded previews fetch full text through `sessions.content` and paginate media references, rather than truncating the transcript at 4,000 characters. Offline cache privacy restrictions remain unchanged. Visible-only file auto-refresh preserves unsaved edits; browsing supports allowed hidden files and archive search. Models/settings include the public models.dev catalog, advanced OMP settings, and actual per-session MCP runtime status shown separately from configuration. Attachments are streamed into staging rather than packed into one giant phone JSON payload: up to 10 images at 10 MiB each and, independently, 10 text attachments at 256 KiB each. OMP's own image normalization and provider-specific image-count limi… > -> New in v0.7.0: Settings redesigned with tabbed categories and always-visible search (web-like parity), all Strings migrated to string resources (English/Korean/Japanese/Chinese complete), Usage sheet redesigned with capacity chips and color-coded progress bars, and tighter session/message spacing. +> **Current settings:** Web and the Android APK now use the same searchable, categorized OMP settings catalog, including scope, inheritance, reset, validation, and guarded secret/admin controls. The device-local thinking-visibility switch only changes transcript rendering; final answers remain visible. > > Its native interface follows the web app’s Warm and OMP palettes, with a compact project/session tree, a Settings/Usage footer, and a compact composer with a single-row toolbar. History, session information, commands, and runtime controls live in the session menu; secondary workspace actions remain in overflow menus. Runtime controls use expandable tree categories with aligned current values. Settings use compact row selectors, and dropdown menus are anchored to their triggers. Native Android controls, file pickers, sheet navigation, and distinct touch regions of at least 48 dp are retained. > @@ -144,7 +144,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **Preview markdown faithfully**: YAML frontmatter renders in a summary card (title + key/value rows), math fences stay aligned inside lists, and CJK ranges like `5~7U` are no longer mangled (GFM now requires `~~` for strikethrough). - **Pick projects naturally on Windows**: a drive picker at the filesystem root and a case-folded, symlink-aware project identity keep the sidebar stable across drives and worktrees. - **See session state clearly**: context usage, cost, compaction state, and system prompt details are visible from the top bar. -- **Configure less from the terminal**: manage models, login/API keys, model tests, native OMP controls (advisor, approval, Bash policy, thinking, compaction, memory, auto-learn, retry/fallback), skills, plugins, and project MCP servers from the web UI. +- **Configure OMP consistently**: web and Android share one searchable, categorized settings catalog for models, agents, tools, safety, and system behavior, with global/project inheritance, reset-to-default controls, and validated advanced values. - **MCP management in Settings**: a dedicated MCP tab lists installed project servers with status (enabled / disabled / invalid), supports add/edit/rename/validate/remove, and surfaces configuration failures as corner toasts. - **Keep OMP current**: check the installed runtime version, update it, and restart active sessions from Settings when needed. - **Stay informed**: opt into browser notifications when an agent finishes, and check installed skills for updates. @@ -163,6 +163,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | Point at another omp agent directory (default `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Standard proxy variables for server-side requests | +### OMP settings + +The web UI and Android APK render the same shared catalog with category/search navigation, defaults, global or project scope, inherited effective values, and reset. Global settings use `~/.omp/agent/config.yml`; only when it is absent and `config.yaml` already exists is that file used as the read/write fallback. Project settings use only the authorized workspace's canonical `.omp/config.yml`. Displayed effective values recursively merge the project layer over the global layer. On save, a structured record replaces the corresponding record in the selected layer, while ordered arrays retain their order. Numbers, enums, arrays, and structured values are validated before a minimal YAML update; unrelated keys and comments are preserved. + +The displayed effective value is the saved global/project configuration, not the value of an already-running session and not environment-variable or CLI overrides. Changes are picked up by new or restarted sessions; saving does not automatically restart live RPC sessions. Changes marked by the catalog as confirmation-required require explicit confirmation. Secrets are write-only: clients receive presence status only and can replace or delete them, never read their stored value. The per-device thinking-visibility preference is local presentation only: it can hide thinking blocks without hiding the final answer. + ## Architecture ompgui is a Node-hosted Next.js app that drives your installed `omp` binary — it does not embed the agent: @@ -170,7 +176,7 @@ ompgui is a Node-hosted Next.js app that drives your installed `omp` binary — - **Live sessions**: spawns `omp --mode rpc-ui` (NDJSON over stdio), one child process per active session, so the agent version is always exactly what you have installed. It negotiates RPC v2 when the installed OMP advertises it, uses bounded chunk reassembly for large frames, and falls back to v1 for older versions. Host env (`PORT`, `NEXT_*`, `NODE_ENV`) is stripped before spawn, and shutdown is graceful on both POSIX (process-group) and Windows (`taskkill /t`). - **Session browsing**: reads omp's session files (`~/.omp/agent/sessions//_.jsonl`) directly; title, archive, and delete are narrow native-file maintenance operations guarded against live OMP writes. Projects are grouped by a stable `projectKey` (Windows case-folded, symlink-resolved) so the sidebar doesn't jump between drives or worktrees. - **Models and auth**: RPC commands against the omp child process with strict payload validation (unknown-shape guards, safe fallbacks); the Models panel edits `models.yml` in the omp agent directory, dropping blank placeholder rows and rejecting ambiguous `enabledModels` entries. -- **Native settings**: the General/MCP settings panels read and write the allow-listed subset of `~/.omp/agent/config.yml` (or `config.yaml` fallback), preserving unrelated keys and comments. Changes apply to new and restarted sessions. +- **Native settings**: `lib/omp/settings-catalog.ts` defines the catalog shared by the web and Relay/Android clients, while the settings service returns catalog-filtered per-layer and effective values and applies validated minimal patches. Global and authorized project writes use OMP-compatible cross-process locks plus atomic replacement so native OMP and ompgui cannot overwrite one another's generation. - **Skills and plugins**: scans omp's skill directories (`~/.omp/agent/skills`, project `.omp/skills`, and compat dirs) and shells out to `omp plugin` for plugin management. - **MCP servers**: project servers are managed through OMP's native locations (`.omp/mcp.json`, then compatibility files) at the git top level, validated against the stdio/http/sse schema and written atomically. - **File access**: file browsing and preview are scoped to the selected project directory and working directories that appear in sessions; paths are canonicalized via a single `isWindowsAbsolutePath`/`samePath` helper and symlink escapes are rejected after `realpath` resolution. On Windows the directory picker offers a drive list at the root. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9abfd4a3e..e2993b73a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,6 +6,8 @@ > `android/` 中的实验性原生 Compose 客户端使用经过身份验证的 `/relay` WebSocket,提供会话/历史记录控制、带链接的消息、离线 Mermaid 图表与代码语法高亮,以及图片/PDF/音频/HTML/Markdown/DOCX 内联预览。文件仅在界面可见时自动刷新,并保留未保存的编辑;支持浏览允许访问的隐藏文件和搜索归档。模型与设置包括公开的 models.dev 目录、高级 OMP 设置,以及与配置状态分开显示的各会话实际 MCP 运行状态。附件通过流式传输暂存,而不是在手机端打包成一个巨大的 JSON:图片最多 10 个,每个 10 MiB;文本附件独立计数,最多 10 个,每个 256 KiB。OMP 自身的图像规范化处理和各提供商的图片数量限制仍然适用。 > +> **当前设置功能:** Web 与 Android APK 使用同一套可搜索、分类的 OMP 设置目录,包括作用域、继承、重置、校验以及受保护的机密/管理员操作。设备本地的思考可见性开关只改变对话渲染,不会隐藏最终答案。 +> > 此客户端不会替代已发布的 v0.7.2 APK,也不会添加托管式/E2E 中继。构建时使用 JDK 21,先在仓库根目录运行 `npm install`,再在 `android/` 中运行 `./gradlew :app:assembleDebug`;离线资源从已安装的 npm 依赖生成。输出文件为 `android/app/build/outputs/apk/debug/app-debug.apk`。服务器禁用的更新、退出登录以及已存储 API 密钥的修改操作仍不可用。 [oh-my-pi (omp) 编程智能体](https://github.com/can1357/oh-my-pi)的本地 Web UI。ompgui 读取本机的 omp 会话文件,在浏览器中提供一个工作区,支持会话浏览、实时对话、模型配置、技能管理和项目文件预览。 @@ -127,7 +129,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **跨分支工作**:在侧边栏切换 Git 工作树,新会话和资源管理器都会跟随你选择的检出。 - **边看项目边聊天**:左侧浏览文件,右侧预览源码、文档、图片、音频和 PDF,同时智能体继续工作。 - **清晰掌握会话状态**:上下文用量、费用、压缩上下文状态和系统提示词详情都显示在顶栏。 -- **减少对终端配置的依赖**:在 Web UI 中管理模型、登录/API 密钥、模型测试、原生 OMP 控制(顾问、审批、Bash 策略、思考、压缩、记忆、自动学习、重试/回退)、技能、插件和项目 MCP 服务器。 +- **一致地管理 OMP 设置**:Web 与 Android 共享一套面向模型、智能体、工具、安全和系统行为的可搜索、分类设置目录,并提供全局/项目继承、重置为默认值以及高级值校验。 - **在设置中管理 MCP**:专用 MCP 标签页显示项目服务器状态(已启用 / 已禁用 / 无效),支持添加、编辑、重命名、校验和删除,并通过角落提示显示配置失败。 - **保持 OMP 为最新版本**:可在设置中检查已安装运行时、更新它,并按需重启活动会话。 - **及时获知完成状态**:可选择在智能体完成时接收浏览器通知,并检查已安装技能的更新。 @@ -146,6 +148,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | 指向其他 omp agent 目录(默认 `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 服务器端请求使用的标准代理变量 | +### OMP 设置 + +Web UI 与 Android APK 通过分类和搜索呈现同一个共享目录,支持默认值、全局或项目作用域、继承后的有效值以及重置。全局设置使用 `~/.omp/agent/config.yml`;只有在 `config.yml` 不存在且已有 `config.yaml` 时,才将后者作为读写回退。项目设置仅使用已授权工作区中的规范路径 `.omp/config.yml`。界面显示有效值时,会把项目层递归合并到全局层之上;保存时,结构化记录会替换所选层中对应的完整记录,有序数组则保留顺序。数字、枚举、数组和结构化值会在最小化更新 YAML 前接受校验;无关的键和注释会保留。 + +界面显示的有效值是已保存的全局/项目配置合并结果,不是已运行会话中的实际值,也不包含环境变量或 CLI 覆盖。更改由新建或重启后的会话读取;保存不会自动重启正在运行的 RPC 会话。目录中被指定为需要确认的更改必须得到明确确认。机密值为只写:客户端只收到是否已设置的状态,可以替换或删除,但不能读取已保存的值。每台设备的思考可见性设置只影响本地显示:可以隐藏思考块,但不会隐藏最终答案。 + ## 架构 ompgui 是一个由 Node 托管的 Next.js 应用,驱动你已安装的 `omp` 二进制文件——它并不内嵌智能体: @@ -153,6 +161,7 @@ ompgui 是一个由 Node 托管的 Next.js 应用,驱动你已安装的 `omp` - **实时会话**:启动 `omp --mode rpc-ui`(基于 stdio 的 NDJSON),每个活动会话对应一个子进程,因此智能体版本始终与你安装的完全一致。 - **会话浏览**:直接读取 omp 的会话文件(`~/.omp/agent/sessions//_.jsonl`);标题、归档和删除是受保护的原生文件维护操作,不会与 OMP 的实时写入竞争。 - **模型与认证**:通过 RPC 命令与 omp 子进程交互;模型面板编辑 omp agent 目录中的 `models.yml`。 +- **OMP 设置**:`lib/omp/settings-catalog.ts` 定义 Web 与 Relay/Android 共用的目录;设置服务返回经目录过滤的各层值和有效值,并应用通过校验的最小补丁。全局及已授权项目的写入使用与 OMP 兼容的跨进程锁和原子替换,避免原生 OMP 与 ompgui 相互覆盖文件版本。 - **技能与插件**:扫描 omp 的技能目录(`~/.omp/agent/skills`、项目内 `.omp/skills` 及兼容目录),并调用 `omp plugin` 进行插件管理。 - **文件访问**:文件浏览与预览仅限于所选项目目录以及会话中出现过的工作目录。 - **分叉与会话内分支**:分叉会创建新的 `.jsonl` 文件;“从此处编辑”则在同一会话文件内创建另一个分支。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 1889603fe..eabb6af58 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -51,8 +51,8 @@ android { applicationId "com.dbchbin.ompgui.remote" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 708 - versionName "0.7.8" + versionCode 709 + versionName "0.7.9" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt index d295dd0f8..a24567dd5 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt @@ -16,6 +16,7 @@ import android.content.SharedPreferences * - completion chime (desktop `ompgui-sound-enabled`, default true) * - submit during run: steer | queue (desktop `omp-web:submit-during-run`) * - tool calls collapsed (desktop `ompgui:tool-calls-collapsed`, default true) + * - model thinking visibility for this device (default shown) * * Backed by `ompgui_app_prefs` to preserve the existing SettingsSheet keys * (`language`, `soundChime`, `submissionMode`). @@ -28,6 +29,7 @@ object AppPreferences { const val KEY_SOUND_CHIME = "soundChime" const val KEY_SUBMISSION_MODE = "submissionMode" const val KEY_TOOL_CALLS_COLLAPSED = "toolCallsCollapsed" + const val KEY_SHOW_THINKING = "showThinking" const val THEME_SYSTEM = "system" const val THEME_LIGHT = "light" @@ -104,4 +106,11 @@ object AppPreferences { fun setToolCallsCollapsed(context: Context, collapsed: Boolean) { prefs(context).edit().putBoolean(KEY_TOOL_CALLS_COLLAPSED, collapsed).apply() } + + fun isThinkingShown(context: Context): Boolean = + prefs(context).getBoolean(KEY_SHOW_THINKING, true) + + fun setThinkingShown(context: Context, shown: Boolean) { + prefs(context).edit().putBoolean(KEY_SHOW_THINKING, shown).apply() + } } diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt index 9b186db4c..c7ba450d1 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt @@ -1111,17 +1111,18 @@ fun TranscriptContent( } } val content = full?.optJSONArray("content") ?: message.content + val thinkingShown = LocalThinkingShown.current val truncated = message.truncated && full == null var localActivityExpanded by remember(sessionId, leafId, message.entryId) { mutableStateOf(false) } val showActivity = activityExpanded ?: localActivityExpanded val hasActivity = content != null && (0 until content.length()).any { val type = content.optJSONObject(it)?.optString("type") - type == "thinking" || type == "toolCall" + (thinkingShown && type == "thinking") || type == "toolCall" } Column(Modifier.fillMaxWidth()) { Column(contentModifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { if (hasActivity && activityExpanded == null) TranscriptDisclosure( - label = "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)}", + label = if (thinkingShown) "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)}" else stringResource(R.string.new_session_tools), expanded = showActivity, loading = message.streaming, onClick = { localActivityExpanded = !localActivityExpanded }, @@ -1134,7 +1135,7 @@ fun TranscriptContent( "text" -> LongMessageText(requester, sessionId, message.copy(text = block.optString("text")), showCopy = false, collapsible = message.role != "assistant") "toolCall" -> if (showActivity) TranscriptTool(requester, sessionId, leafId, block, results[block.optString("toolCallId")], truncated) "image" -> if (block.optString("data").isNotEmpty()) HistoryImage(block) - "thinking" -> { + "thinking" -> if (thinkingShown) { var thinking by remember(block) { mutableStateOf(null) } var thinkingBusy by remember { mutableStateOf(false) } var thinkingFailure by remember { mutableStateOf(null) } @@ -1405,7 +1406,9 @@ private fun ChatHistoryContent(requester: RelayRequester, sessionId: String, lea @Composable private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: String?, entryId: String, message: JSONObject, onOpenSession: (String) -> Unit, onEditMessage: (String) -> Unit) { val context = LocalContext.current - var details by remember { mutableStateOf(null) } + val thinkingShown = LocalThinkingShown.current + var thinkingDetails by remember { mutableStateOf(null) } + var actionFeedback by remember { mutableStateOf(null) } var media by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } @@ -1445,8 +1448,8 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S } else { MessageText(block.optString("text"), Modifier.fillMaxWidth()) } - "thinking" -> RuntimeChip(stringResource(R.string.chat_load_thinking, index + 1), enabled = !busy && entryId.isNotBlank(), onClick = { - perform { details = ChatRequests.thinking(requester, sessionId, entryId, index).getString("thinking"); thinkingExpanded = true } + "thinking" -> if (thinkingShown) RuntimeChip(stringResource(R.string.chat_load_thinking, index + 1), enabled = !busy && entryId.isNotBlank(), onClick = { + perform { thinkingDetails = ChatRequests.thinking(requester, sessionId, entryId, index).getString("thinking"); thinkingExpanded = true } }) "image" -> HistoryImage(block) else -> { @@ -1465,12 +1468,13 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S } else { MessageText(content?.toString().orEmpty(), plainText = true) } - details?.let { + if (thinkingShown) thinkingDetails?.let { RuntimeChip(if (thinkingExpanded) stringResource(R.string.chat_hide_details) else stringResource(R.string.chat_show_details), onClick = { thinkingExpanded = !thinkingExpanded }) if (thinkingExpanded) androidx.compose.foundation.text.selection.SelectionContainer { Text(it, color = OmpColors.TextMuted, fontSize = 13.sp, lineHeight = 20.sp) } } + actionFeedback?.let { Text(it, color = OmpColors.TextMuted, fontSize = 13.sp, lineHeight = 20.sp) } media?.let { images -> for (i in 0 until images.length()) images.optJSONObject(i)?.let { HistoryImage(it) } } error?.let { Text(it, color = OmpColors.StatusError) } if (message.optString("role") == "user") RuntimeChip(stringResource(R.string.chat_copy_to_composer), onClick = { @@ -1484,7 +1488,7 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S perform { val result = ChatRequests.media(requester, sessionId, entryId) media = result.getJSONArray("images") - details = context.getString(R.string.chat_media_summary, media?.length() ?: 0, result.optInt("missingCount")) + actionFeedback = context.getString(R.string.chat_media_summary, media?.length() ?: 0, result.optInt("missingCount")) } }) if (message.optString("role") == "user") RuntimeChip(stringResource(R.string.chat_fork_here), enabled = !busy, onClick = { @@ -1492,7 +1496,7 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S val command = JSONObject().put("type", "fork").put("entryId", entryId) if (!leafId.isNullOrBlank()) command.put("leafId", leafId) val result = ChatRequests.command(requester, sessionId, command).getJSONObject("result") - if (result.optBoolean("cancelled")) details = context.getString(R.string.chat_fork_cancelled) + if (result.optBoolean("cancelled")) actionFeedback = context.getString(R.string.chat_fork_cancelled) else onOpenSession(result.getString("newSessionId")) } }) diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt index 2f6d33dda..15d992ffa 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt @@ -1035,6 +1035,7 @@ private fun AssistantTurn( results: Map, ) { var expanded by remember(sessionId, leafId, messages.first().entryId) { mutableStateOf(false) } + val thinkingShown = LocalThinkingShown.current var hasActivity = false var pending = false var failed = false @@ -1050,7 +1051,7 @@ private fun AssistantTurn( for (index in 0 until content.length()) { val block = content.optJSONObject(index) ?: continue when (block.optString("type")) { - "thinking" -> hasActivity = true + "thinking" -> if (thinkingShown) hasActivity = true "toolCall" -> { hasActivity = true val result = results[block.optString("toolCallId")] @@ -1068,7 +1069,7 @@ private fun AssistantTurn( else -> stringResource(R.string.chat_tool_complete) } TranscriptDisclosure( - label = "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)} · $status", + label = if (thinkingShown) "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)} · $status" else "${stringResource(R.string.new_session_tools)} · $status", expanded = expanded, loading = pending, failed = failed, diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt index ca9dcf967..c41b1893a 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt @@ -48,6 +48,8 @@ import androidx.compose.ui.unit.sp private val ircSenderPattern = Regex("agent `([^`\\r\\n<>]+)`") +val LocalThinkingShown = androidx.compose.runtime.staticCompositionLocalOf { true } + /** Message-only rendering; file previews and generic Markdown retain their original behavior. */ @Composable fun MessageText(text: String, modifier: Modifier = Modifier, plainText: Boolean = false) { diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt index 4eb24b685..2e3fd8e10 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt @@ -87,7 +87,7 @@ import org.json.JSONObject * Server-disabled operations (API-key store/remove, logout) surface the exact * 501 capability codes plus terminal guidance instead of fake success. */ -enum class ModelSettingsSection { All, Defaults, Roles, Providers } +enum class ModelSettingsSection { All, Defaults, Roles, Providers, Hidden } @Composable fun ModelSettingsPanel( diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/NativeSettings.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/NativeSettings.kt new file mode 100644 index 000000000..e7dd8487b --- /dev/null +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/NativeSettings.kt @@ -0,0 +1,212 @@ +package com.dbchbin.ompgui.remote.ui + +import org.json.JSONArray +import org.json.JSONObject + +internal data class NativeSettingText(val en: String, val ko: String) { + fun localized(korean: Boolean): String = if (korean && ko.isNotBlank()) ko else en +} + +internal data class NativeSettingDefinition( + val path: String, + val category: String, + val group: String, + val label: NativeSettingText, + val description: NativeSettingText, + val kind: String, + val defaultValue: Any?, + val choices: List, + val min: Double?, + val max: Double?, + val step: Double?, + val integer: Boolean, + val items: String?, + val recordValues: String?, + val secret: Boolean, + val admin: Boolean, + val confirmation: Boolean, + val scope: String, +) + +internal data class NativeSettingIssue(val path: String, val message: String) + +internal data class NativeSettingsSnapshot( + val path: String, + val scope: String, + val cwd: String?, + val settings: JSONObject, + val effectiveSettings: JSONObject, + val catalog: List, + val secretStatus: Map, + val issues: List, +) + +internal fun parseNativeSettingsSnapshot(json: JSONObject): NativeSettingsSnapshot { + val scope = json.optString("scope", "global") + require(scope == "global" || scope == "project") { "Invalid settings scope" } + val path = json.optString("path").takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("Settings response is missing its host path") + val settings = json.optJSONObject("settings") + ?: throw IllegalArgumentException("Settings response is missing persisted overrides") + val effective = json.optJSONObject("effectiveSettings") + ?: throw IllegalArgumentException("Settings response is missing effective settings") + val catalogJson = json.optJSONArray("catalog") + ?: throw IllegalArgumentException("Settings response is missing the settings catalog") + val catalog = buildList { + for (index in 0 until catalogJson.length()) { + val item = catalogJson.optJSONObject(index) ?: continue + val settingPath = item.optString("path").takeIf { it.isNotBlank() } ?: continue + val category = item.optString("category") + val kind = item.optString("kind") + val settingScope = item.optString("scope") + if (category !in setOf("models", "intelligence", "agents", "tools", "safety", "system") || + kind !in setOf("boolean", "number", "string", "enum", "array", "object") || + settingScope !in setOf("global", "both")) continue + val label = item.optJSONObject("label") ?: JSONObject() + val description = item.optJSONObject("description") ?: JSONObject() + val choicesJson = item.optJSONArray("choices") + val choices = buildList { + if (choicesJson != null) for (choiceIndex in 0 until choicesJson.length()) { + choicesJson.optString(choiceIndex).takeIf { it.isNotBlank() }?.let(::add) + } + } + add( + NativeSettingDefinition( + path = settingPath, + category = category, + group = item.optString("group").ifBlank { settingPath.substringBefore('.') }, + label = NativeSettingText(label.optString("en").ifBlank { settingPath }, label.optString("ko")), + description = NativeSettingText(description.optString("en"), description.optString("ko")), + kind = kind, + defaultValue = item.opt("defaultValue")?.takeUnless { it == JSONObject.NULL }, + choices = choices, + min = item.opt("min")?.let(::jsonNumber), + max = item.opt("max")?.let(::jsonNumber), + step = item.opt("step")?.let(::jsonNumber), + integer = item.optBoolean("integer"), + items = item.optString("items").takeIf { it in setOf("string", "number", "object") }, + recordValues = item.optString("recordValues").takeIf { it.isNotBlank() }, + secret = item.optBoolean("secret"), + admin = item.optBoolean("admin"), + confirmation = item.optBoolean("confirmation"), + scope = settingScope, + ), + ) + } + } + require(catalog.isNotEmpty()) { "Settings catalog is empty or unsupported" } + val secretJson = json.optJSONObject("secretStatus") ?: JSONObject() + val secretStatus = buildMap { + for (key in secretJson.keys()) put(key, secretJson.optBoolean(key)) + } + val issuesJson = json.optJSONArray("issues") ?: JSONArray() + val issues = buildList { + for (index in 0 until issuesJson.length()) { + val issue = issuesJson.optJSONObject(index) ?: continue + val issuePath = issue.optString("path").takeIf { it.isNotBlank() } ?: continue + val message = issue.optString("message").takeIf { it.isNotBlank() } ?: continue + add(NativeSettingIssue(issuePath, message)) + } + } + return NativeSettingsSnapshot(path, scope, json.optString("cwd").takeIf { it.isNotBlank() }, settings, effective, catalog, secretStatus, issues) +} + +internal fun settingIssueForPath(issues: List, path: String): NativeSettingIssue? = + issues.firstOrNull { issue -> + issue.path == path || issue.path.startsWith("$path.") || path.startsWith("${issue.path}.") + } + +private fun jsonNumber(value: Any): Double? = when (value) { + is Number -> value.toDouble() + is String -> value.toDoubleOrNull() + else -> null +} + +internal fun nestedSetting(root: JSONObject, path: String): Any? { + var node: Any = root + for (segment in path.split('.')) { + if (node !is JSONObject || !node.has(segment)) return null + node = node.opt(segment) ?: return null + if (node == JSONObject.NULL) return null + } + return node +} + +internal fun nestedSettingsPatch(path: String, value: Any): JSONObject { + val root = JSONObject() + var node = root + val segments = path.split('.') + require(segments.isNotEmpty() && segments.none { it.isBlank() }) { "Invalid setting path" } + for (segment in segments.dropLast(1)) { + val child = JSONObject() + node.put(segment, child) + node = child + } + node.put(segments.last(), value) + return root +} + +internal fun parseSettingDraft(definition: NativeSettingDefinition, draft: String): Any { + return when (definition.kind) { + "number" -> { + val value = draft.trim().toDoubleOrNull() ?: throw IllegalArgumentException("Enter a number") + if (definition.integer && value % 1.0 != 0.0) throw IllegalArgumentException("Enter a whole number") + definition.min?.let { require(value >= it) { "Minimum: ${formatJsonNumber(it)}" } } + definition.max?.let { require(value <= it) { "Maximum: ${formatJsonNumber(it)}" } } + definition.step?.takeIf { it > 0.0 }?.let { step -> + val units = (value - (definition.min ?: 0.0)) / step + require(kotlin.math.abs(units - kotlin.math.round(units)) < 1e-9) { "Use increments of ${formatJsonNumber(step)}" } + } + if (definition.integer) value.toLong() else value + } + "array" -> JSONArray(draft).also { array -> + for (index in 0 until array.length()) when (definition.items) { + "number" -> require(array.opt(index) is Number) { "Every item must be a number" } + "object" -> require(array.opt(index) is JSONObject) { "Every item must be an object" } + else -> { + require(array.opt(index) is String) { "Every item must be text" } + if (definition.choices.isNotEmpty()) require(array.getString(index) in definition.choices) { "Unsupported item: ${array.getString(index)}" } + } + } + } + "object" -> JSONObject(draft).also { objectValue -> + when (definition.path) { + "providers.maxInFlightRequests" -> for (key in objectValue.keys()) require(objectValue.opt(key) is Number) { "$key must be a number" } + "modelTags" -> for (key in objectValue.keys()) { + val tag = objectValue.optJSONObject(key) ?: throw IllegalArgumentException("$key must be an object") + require(tag.opt("name") is String && tag.optString("name").isNotBlank()) { "$key.name is required" } + if (tag.has("color")) require(tag.opt("color") is String) { "$key.color must be text" } + if (tag.has("hidden")) require(tag.opt("hidden") is Boolean) { "$key.hidden must be true or false" } + } + "images.urls.options" -> for (key in objectValue.keys()) require(objectValue.opt(key) is JSONObject) { "$key must be an object" } + } + for (key in objectValue.keys()) { + val entry = objectValue.opt(key) + when (definition.recordValues) { + "string" -> require(entry is String) { "$key must be text" } + "boolean-string" -> require(entry is Boolean || entry is String) { "$key must be true, false, or text" } + "string-array" -> { + require(entry is JSONArray) { "$key must be an array" } + for (index in 0 until entry.length()) require(entry.opt(index) is String) { "$key must contain only text" } + } + "string-or-string-array" -> { + require(entry is String || entry is JSONArray) { "$key must be text or a text array" } + if (entry is JSONArray) for (index in 0 until entry.length()) require(entry.opt(index) is String) { "$key must contain only text" } + } + "approval" -> require(entry is String && entry in setOf("allow", "prompt", "deny")) { "$key must be allow, prompt, or deny" } + } + } + } + else -> draft + } +} + +internal fun formatSettingValue(value: Any?): String = when (value) { + null, JSONObject.NULL -> "" + is JSONObject -> value.toString(2) + is JSONArray -> value.toString(2) + is Double -> formatJsonNumber(value) + else -> value.toString() +} + +private fun formatJsonNumber(value: Double): String = if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt index 7e9b0c9cb..a30ff5447 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt @@ -43,9 +43,11 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { val backDispatcherOwner = requireNotNull(androidx.activity.compose.LocalOnBackPressedDispatcherOwner.current) val preferences = remember(context) { AppPreferences.prefs(context) } var language by remember { mutableStateOf(AppPreferences.getLanguage(context, Locale.getDefault().language)) } + var thinkingShown by remember { mutableStateOf(AppPreferences.isThinkingShown(context)) } DisposableEffect(preferences) { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey -> if (changedKey == AppPreferences.KEY_LANGUAGE) language = AppPreferences.getLanguage(context, Locale.getDefault().language) + if (changedKey == AppPreferences.KEY_SHOW_THINKING) thinkingShown = AppPreferences.isThinkingShown(context) } preferences.registerOnSharedPreferenceChangeListener(listener) onDispose { preferences.unregisterOnSharedPreferenceChangeListener(listener) } @@ -89,6 +91,7 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { LocalMarkdownNavigation provides MarkdownNavigation(state.sessionCwd) { path -> state.sessionCwd?.let { cwd -> filePreview = cwd to path } }, + LocalThinkingShown provides thinkingShown, ) { key(language) { RemoteTheme { diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt index cf524d85f..1381c4f25 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt @@ -1,32 +1,29 @@ package com.dbchbin.ompgui.remote.ui +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.BorderStroke -import androidx.compose.ui.Alignment -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.activity.compose.BackHandler +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.dbchbin.ompgui.remote.R import com.dbchbin.ompgui.remote.net.ConnectionState @@ -38,36 +35,29 @@ import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject -private data class SettingField(val path: String, val category: String, val kind: String = "boolean", val choices: List = emptyList(), val min: Int = 0, val max: Int = Int.MAX_VALUE) +private data class SettingsMutation( + val path: String, + val value: Any? = null, + val reset: Boolean = false, + val secret: String? = null, + val clearSecret: Boolean = false, +) -private val settingFields = buildList { - add(SettingField("defaultThinkingLevel", "Models", "choice", listOf("auto", "minimal", "low", "medium", "high", "xhigh", "max"))) - add(SettingField("textVerbosity", "Models", "choice", listOf("low", "medium", "high"))) - add(SettingField("personality", "Models", "choice", listOf("default", "friendly", "pragmatic", "none"))) - for (path in listOf("hideThinkingBlock", "externalThinking", "registryHasScopedEntries")) add(SettingField(path, "Models")) - for (path in listOf("enabledModels", "disabledProviders", "modelProviderOrder")) add(SettingField(path, "Models", "array")) - add(SettingField("tools.approvalMode", "Safety", "choice", listOf("always-ask", "write", "yolo"))) - add(SettingField("tools.approval.bash", "Safety", "choice", listOf("allow", "prompt", "deny"))) - add(SettingField("tools.approval.extension", "Safety", "choice", listOf("allow", "prompt"))) - for (path in listOf("advisor.enabled", "advisor.subagents", "retry.enabled", "retry.modelFallback", "compaction.enabled", "compaction.midTurnEnabled", "compaction.autoContinue", "compaction.remoteEnabled", "autolearn.enabled", "autolearn.autoContinue", "mnemopi.autoRecall", "mnemopi.autoRetain", "mnemopi.noEmbeddings")) add(SettingField(path, "Intelligence")) - add(SettingField("advisor.syncBacklog", "Intelligence", "choice", listOf("off", "1", "3", "5"))) - add(SettingField("advisor.immuneTurns", "Intelligence", "integer", min = 0, max = 20)) - add(SettingField("retry.maxRetries", "Intelligence", "integer", min = 0, max = 20)) - add(SettingField("retry.fallbackRevertPolicy", "Intelligence", "choice", listOf("cooldown-expiry", "never"))) - add(SettingField("retry.fallbackChains", "Models", "object")) - add(SettingField("compaction.strategy", "Intelligence", "choice", listOf("snapcompact", "handoff", "context-full", "shake", "off"))) - add(SettingField("compaction.keepRecentTokens", "Intelligence", "integer", min = 1000, max = 1000000)) - add(SettingField("autolearn.minToolCalls", "Intelligence", "integer", min = 0, max = 100)) - add(SettingField("memory.backend", "Intelligence", "choice", listOf("off", "local", "mnemopi", "hindsight"))) - add(SettingField("mnemopi.scoping", "Intelligence", "choice", listOf("global", "per-project", "per-project-tagged"))) - add(SettingField("task.eager", "Agents", "choice", listOf("default", "preferred", "always"))) - add(SettingField("task.prewalk", "Agents")) - add(SettingField("task.disabledAgents", "Agents", "array")) - for (path in listOf("task.agentModelOverrides", "task.agentPrewalk", "task.agentAdvisor")) add(SettingField(path, "Agents", "object")) - for (path in listOf("mcp.enableProjectConfig", "mcp.renderMarkdownResults", "mcp.notifications", "browser.enabled", "browser.relay", "browser.headless", "computer.enabled", "web_search.enabled", "github.enabled", "security.enabled", "checkpoint.enabled")) add(SettingField(path, "Tools")) - add(SettingField("mcp.notificationDebounceMs", "Tools", "integer", min = 0, max = 60000)) - add(SettingField("computer.display", "Tools", "string")) -} +private data class LocalField( + val path: String, + val kind: String = "boolean", + val choices: List = emptyList(), +) + +private data class CatalogDraft( + val value: String, + val error: String? = null, + val secret: String = "", + val arrayAddition: String = "", + val dirty: Boolean = false, +) + +private data class CatalogDraftKey(val scope: String, val cwd: String, val path: String) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -78,369 +68,197 @@ fun SettingsSheet( currentModel: ModelRef?, onUnpair: () -> Unit, onDismiss: () -> Unit, - settings: JSONObject? = null, + @Suppress("UNUSED_PARAMETER") settings: JSONObject? = null, deviceId: String = "", settingsCwd: String = "", ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - var snapshot by remember { mutableStateOf(settings) } + val coroutineScope = rememberCoroutineScope() + val korean = context.resources.configuration.locales[0].language == "ko" + var selectedScope by rememberSaveable(settingsCwd) { mutableStateOf("global") } + var snapshot by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var notice by remember { mutableStateOf(null) } - var query by rememberSaveable { mutableStateOf("") } var reload by remember { mutableIntStateOf(0) } - var category by rememberSaveable { mutableStateOf("Browse") } + var query by rememberSaveable { mutableStateOf("") } + var category by rememberSaveable { mutableStateOf("browse") } var confirmation by remember { mutableStateOf Unit>?>(null) } var showUsage by remember { mutableStateOf(false) } - val sectionState = rememberSaveableStateHolder() - val scrollPositions = remember { mutableMapOf() } - val scrollKey = if (query.isBlank()) category else "search" - val contentScroll = scrollPositions.getOrPut(scrollKey) { androidx.compose.foundation.ScrollState(0) } - var modelsVisited by remember { mutableStateOf(false) } - val modelCategory = category == "Models" || category == "Roles" || category == "Providers" - val categories = listOf("Browse", "General", "Roles", "Safety", "Models", "Providers", "Intelligence", "Agents", "Tools", "System") - val categoryTitles = listOf( - stringResource(R.string.settings_category_browse), - stringResource(R.string.settings_category_general), - stringResource(R.string.settings_category_roles), - stringResource(R.string.settings_category_safety), - stringResource(R.string.settings_category_models), - stringResource(R.string.settings_category_providers), - stringResource(R.string.settings_category_intelligence), - stringResource(R.string.settings_category_agents), - stringResource(R.string.settings_category_tools), - stringResource(R.string.settings_category_system), - ) - val categoryDescriptions = listOf( - stringResource(R.string.settings_browse_hint), - stringResource(R.string.settings_category_desc_general), - stringResource(R.string.settings_category_desc_roles), - stringResource(R.string.settings_category_desc_safety), - stringResource(R.string.settings_category_desc_models), - stringResource(R.string.settings_category_desc_providers), - stringResource(R.string.settings_category_desc_intelligence), - stringResource(R.string.settings_category_desc_agents), - stringResource(R.string.settings_category_desc_tools), - stringResource(R.string.settings_category_desc_system), - ) - val contentCategories = categories.drop(1) - val contentTitles = categoryTitles.drop(1) - val contentDescriptions = categoryDescriptions.drop(1) - val selectedCategoryIndex = categories.indexOf(category).coerceAtLeast(0) - val selectedCategoryDescription = categoryDescriptions.getOrElse(selectedCategoryIndex) { "" } - val groupAdvisor = stringResource(R.string.settings_group_advisor) - val groupCompaction = stringResource(R.string.settings_group_compaction) - val groupMemory = stringResource(R.string.settings_group_memory) - val groupRetry = stringResource(R.string.settings_group_retry) - val groupTask = stringResource(R.string.settings_group_task) - val groupMcp = stringResource(R.string.settings_group_mcp) - val groupModels = stringResource(R.string.settings_group_models) - val groupToolsSafety = stringResource(R.string.settings_group_tools_safety) - val groupAppearance = stringResource(R.string.settings_group_appearance) - val groupInterface = stringResource(R.string.settings_group_interface) - val tabAgentInventory = stringResource(R.string.settings_tab_agent_inventory) - val tabTaskDefaults = stringResource(R.string.settings_tab_task_defaults) - val settingsSubtabAria = stringResource(R.string.settings_subtab_aria) - val settingsNavAria = stringResource(R.string.settings_nav_aria) - val settingsAllSections = stringResource(R.string.settings_all_sections) - val settingsBrowseTitle = stringResource(R.string.settings_browse_title) - LaunchedEffect(modelCategory) { if (modelCategory) modelsVisited = true } - // Migrate stale saveable values if any future ids change; keep unknown as Browse. - LaunchedEffect(category) { - if (category !in categories) category = "Browse" + // Deliberately not saveable: this owner survives filtering/collapse/category changes, + // while write-only credentials never enter Android saved-instance-state Bundles. + val catalogDrafts = remember { mutableStateMapOf() } + + fun hasDirtyDrafts(scope: String = selectedScope, cwd: String = settingsCwd) = + catalogDrafts.any { (key, draft) -> draft.dirty && key.scope == scope && key.cwd == cwd } + fun discardDrafts(scope: String = selectedScope, cwd: String = settingsCwd) { + catalogDrafts.keys.filter { it.scope == scope && it.cwd == cwd }.forEach(catalogDrafts::remove) } - val save: (String, Any) -> Unit = { path, value -> - if (!busy) scope.launch { - busy = true - error = null - notice = null - try { - val patch = JSONObject() - var node = patch - val segments = path.split('.') - for (segment in segments.dropLast(1)) { - val child = JSONObject() - node.put(segment, child) - node = child - } - node.put(segments.last(), value) - val result = requester.request("system", "settings.update", JSONObject().put("settings", patch)) - snapshot = result.getJSONObject("settings") - notice = if (result.optJSONObject("application")?.optString("mode") == "runtime-refresh") { - context.getString(R.string.settings_saved_runtime) - } else { - context.getString(R.string.settings_saved_new_sessions) - } - } catch (failure: CancellationException) { throw failure - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_update_failed) - } finally { busy = false } - } + + val fixedCategories = listOf("browse", "general", "roles", "providers", "models", "intelligence", "agents", "tools", "safety", "administrator", "system") + + fun requestArgs(): JSONObject = JSONObject().put("scope", selectedScope).apply { + if (selectedScope == "project") put("cwd", settingsCwd) + } + fun switchScope(next: String) { + if (next == selectedScope) return + val switch = { discardDrafts(); snapshot = null; selectedScope = next } + if (hasDirtyDrafts()) { + confirmation = context.getString(R.string.settings_unsaved_scope_change) to switch + } else switch() } - LaunchedEffect(requester, reload) { + fun refresh() { + val action: () -> Unit = { reload++; Unit } + if (hasDirtyDrafts()) confirmation = context.getString(R.string.settings_unsaved_refresh) to action else action() + } + fun requestDismiss() { + val action = { discardDrafts(); onDismiss() } + if (hasDirtyDrafts()) confirmation = context.getString(R.string.settings_unsaved_dismiss) to action else action() + } + suspend fun load() { busy = true error = null - try { snapshot = requester.request("system", "settings.get", JSONObject()).getJSONObject("settings") - } catch (failure: CancellationException) { throw failure - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_load_failed) - } finally { busy = false } + try { + snapshot = parseNativeSettingsSnapshot(requester.request("system", "settings.get", requestArgs())) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + error = failure.message ?: context.getString(R.string.settings_load_failed) + } finally { + busy = false + } + } + fun mutate(mutation: SettingsMutation, confirmed: Boolean = false, onSuccess: () -> Unit = {}) { + if (busy) return + val definition = snapshot?.catalog?.firstOrNull { it.path == mutation.path } ?: return + val action = { + coroutineScope.launch { + busy = true + error = null + notice = null + try { + val args = requestArgs() + when { + mutation.reset -> args.put("reset", JSONArray().put(mutation.path)) + definition.secret -> args.put("secrets", JSONObject().put(mutation.path, if (mutation.clearSecret) JSONObject.NULL else mutation.secret)) + else -> args.put("settings", nestedSettingsPatch(mutation.path, requireNotNull(mutation.value))) + } + if (definition.confirmation) args.put("confirm", JSONArray().put(mutation.path)) + val result = requester.request("system", "settings.update", args) + snapshot = parseNativeSettingsSnapshot(result) + notice = context.getString(R.string.settings_saved_new_sessions) + onSuccess() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + error = failure.message ?: context.getString(R.string.settings_update_failed) + } finally { + busy = false + } + } + } + if (definition.confirmation && !confirmed) { + val proposed = when { + mutation.reset -> if (selectedScope == "project") context.getString(R.string.settings_state_inherited) else context.getString(R.string.settings_state_default) + definition.secret && mutation.clearSecret -> context.getString(R.string.settings_secret_delete) + definition.secret -> context.getString(R.string.settings_secret_masked_replacement) + else -> formatSettingValue(mutation.value) + } + confirmation = context.getString(R.string.settings_confirm_scoped_change, if (selectedScope == "project") context.getString(R.string.settings_scope_project) else context.getString(R.string.settings_scope_global), mutation.path, proposed) to { + mutate(mutation, confirmed = true, onSuccess = onSuccess) + } + } else action() } - OmpModalSheet( - onDismissRequest = onDismiss, - fullHeight = true, - containerColor = OmpColors.Bg, - contentColor = OmpColors.Text, - ) { - // Hierarchy only — Dialog owns system Back dismiss when this is inactive. - BackHandler(enabled = !showUsage && (query.isNotBlank() || category != "Browse")) { - if (query.isNotBlank()) query = "" - else category = "Browse" + + LaunchedEffect(requester, selectedScope, settingsCwd, reload) { load() } + LaunchedEffect(category) { if (category !in fixedCategories) category = "browse" } + + OmpModalSheet(onDismissRequest = ::requestDismiss, fullHeight = true, containerColor = OmpColors.Bg, contentColor = OmpColors.Text) { + BackHandler(enabled = !showUsage && (query.isNotBlank() || category != "browse")) { + if (query.isNotBlank()) query = "" else category = "browse" } Column(Modifier.fillMaxWidth().weight(1f)) { - Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) - IconButton(enabled = !busy, onClick = { notice = null; reload++ }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Refresh, stringResource(R.string.settings_refresh), modifier = Modifier.size(20.dp)) } - IconButton(onClick = onDismiss, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Close, stringResource(R.string.settings_close), modifier = Modifier.size(20.dp)) } + IconButton(enabled = !busy, onClick = { refresh() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Refresh, stringResource(R.string.settings_refresh)) } + IconButton(onClick = ::requestDismiss, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Close, stringResource(R.string.settings_close)) } } HorizontalDivider(color = OmpColors.Border) - - Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - placeholder = { Text(stringResource(R.string.settings_search)) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp), - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium, - shape = MaterialTheme.shapes.small, - trailingIcon = if (query.isNotBlank()) { - { - IconButton(onClick = { query = "" }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Default.Close, stringResource(R.string.settings_clear_search), modifier = Modifier.size(16.dp)) - } - } - } else null, - ) - } - - val showBrowseOverview = query.isBlank() && category == "Browse" - if (!showBrowseOverview) { - val quickTabIndex = contentCategories.indexOf(category).coerceAtLeast(0) - ScrollableTabRow( - selectedTabIndex = quickTabIndex, - edgePadding = 0.dp, - containerColor = OmpColors.Bg, - contentColor = OmpColors.Text, - modifier = Modifier.semantics { contentDescription = settingsNavAria }, - indicator = { tabPositions -> - if (tabPositions.isNotEmpty() && quickTabIndex in tabPositions.indices) { - TabRowDefaults.SecondaryIndicator( - Modifier.tabIndicatorOffset(tabPositions[quickTabIndex]), - color = OmpColors.Accent, - ) - } - }, - ) { - contentCategories.forEachIndexed { index, name -> - val title = contentTitles[index] - val description = contentDescriptions[index] - Tab( - selected = category == name, - onClick = { category = name; query = ""; notice = null }, - modifier = Modifier - .heightIn(min = 48.dp) - .semantics { contentDescription = "$title. $description" }, - text = { - Text( - title, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - ) - } + Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(query, { query = it }, modifier = Modifier.fillMaxWidth(), singleLine = true, placeholder = { Text(stringResource(R.string.settings_search)) }, trailingIcon = if (query.isBlank()) null else {{ IconButton(onClick = { query = "" }) { Icon(Icons.Default.Close, stringResource(R.string.settings_clear_search)) } }}) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = selectedScope == "global", onClick = { switchScope("global") }, enabled = !busy, label = { Text(stringResource(R.string.settings_scope_global)) }, modifier = Modifier.heightIn(min = 48.dp)) + FilterChip(selected = selectedScope == "project", onClick = { switchScope("project") }, enabled = !busy && settingsCwd.isNotBlank(), label = { Text(stringResource(R.string.settings_scope_project)) }, modifier = Modifier.heightIn(min = 48.dp)) } - HorizontalDivider(color = OmpColors.Border) - } - - Column( - Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + Text( + if (selectedScope == "project") stringResource(R.string.settings_scope_project_path, snapshot?.cwd ?: settingsCwd) + else stringResource(R.string.settings_scope_global_path, snapshot?.path ?: stringResource(R.string.settings_path_loading)), + style = MaterialTheme.typography.bodySmall, + color = OmpColors.TextMuted, + ) + if (selectedScope == "project") Text(stringResource(R.string.settings_scope_config_path, snapshot?.path ?: stringResource(R.string.settings_path_loading)), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + Text(stringResource(R.string.settings_apply_new_sessions), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } notice?.let { Text(it, color = OmpColors.TextMuted, style = MaterialTheme.typography.bodySmall) } - if (query.isBlank()) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - if (showBrowseOverview) settingsBrowseTitle else categoryTitles[selectedCategoryIndex], - style = MaterialTheme.typography.titleSmall, - color = OmpColors.Text, - ) - Text( - if (showBrowseOverview) stringResource(R.string.settings_browse_hint) else selectedCategoryDescription, - style = MaterialTheme.typography.bodySmall, - color = OmpColors.TextMuted, - ) - } - if (!showBrowseOverview) { - TextButton( - onClick = { category = "Browse"; query = ""; notice = null }, - modifier = Modifier.heightIn(min = 48.dp), - ) { - Text(settingsAllSections) - } - } - } + snapshot?.issues?.forEach { issue -> Text(stringResource(R.string.settings_stored_value_issue, issue.path, issue.message), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } + if (category != "browse" || query.isNotBlank()) { + ScrollableTabRow(selectedTabIndex = fixedCategories.drop(1).indexOf(category).coerceAtLeast(0), edgePadding = 0.dp, containerColor = OmpColors.Bg) { + fixedCategories.drop(1).forEach { id -> Tab(selected = category == id, onClick = { category = id; query = "" }, modifier = Modifier.heightIn(min = 48.dp), text = { Text(settingsCategoryTitle(id), maxLines = 1, overflow = TextOverflow.Ellipsis) }) } } } - Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(contentScroll).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - sectionState.SaveableStateProvider(if (query.isBlank()) category else "search") { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (showBrowseOverview) { - SettingsCategoryOverview( - categories = contentCategories, - titles = contentTitles, - descriptions = contentDescriptions, - onSelect = { selected -> - category = selected - query = "" - notice = null - }, - ) - } - var showTaskDefaults by rememberSaveable(category) { mutableStateOf(false) } - if (query.isBlank() && category == "Agents") { - val tabs = listOf(tabAgentInventory, tabTaskDefaults) - ScrollableTabRow( - selectedTabIndex = if (showTaskDefaults) 1 else 0, - edgePadding = 0.dp, - containerColor = OmpColors.Bg, - contentColor = OmpColors.Text, - modifier = Modifier.semantics { contentDescription = settingsSubtabAria }, - ) { - tabs.forEachIndexed { index, title -> - Tab( - selected = showTaskDefaults == (index == 1), - onClick = { showTaskDefaults = index == 1 }, - modifier = Modifier.heightIn(min = 48.dp), - text = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - ) + Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (category == "browse" && query.isBlank()) { + fixedCategories.drop(1).forEach { id -> + Surface(Modifier.fillMaxWidth().heightIn(min = 64.dp).clickable(role = Role.Button) { category = id }, color = OmpColors.BgPanel, shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, OmpColors.Border)) { + Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { Text(settingsCategoryTitle(id), style = MaterialTheme.typography.titleSmall); Text(settingsCategoryDescription(id), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } + Icon(Icons.AutoMirrored.Filled.ArrowForward, null) } } } - val localMatches = query.isBlank() && category == "General" || query.isNotBlank() && listOf("general", "theme", "palette", "language", "appearance", "completion chime", "submit", "collapse tool calls", "interface").any { it.contains(query, true) } - if (localMatches) LocalPreferencesSection(groupAppearance = groupAppearance, groupInterface = groupInterface) - val groupLabels = SettingsGroupLabels( - advisor = groupAdvisor, - compaction = groupCompaction, - memory = groupMemory, - retry = groupRetry, - task = groupTask, - mcp = groupMcp, - models = groupModels, - toolsSafety = groupToolsSafety, + } else { + if (query.isBlank()) { Text(settingsCategoryTitle(category), style = MaterialTheme.typography.titleSmall); Text(settingsCategoryDescription(category), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } + if (category == "general" || queryMatches(query, "general theme palette language completion submit collapse thinking interface")) LocalPreferencesSection() + val catalog = snapshot?.catalog.orEmpty() + val globallyManagedSettings = setOf( + "enabledModels", "disabledProviders", "modelProviderOrder", "modelRoles", + "task.disabledAgents", "task.agentModelOverrides", "task.agentPrewalk", "task.agentAdvisor", ) - val fields = settingFields.filter { - if (query.isBlank()) { - when (category) { - "Browse" -> false - "Agents" -> showTaskDefaults && it.category == "Agents" - // Tools fields render inside ExtensionSettingsPanel Optional Tools. - "Tools" -> false - else -> it.category == category - } - } else { - val matchedCategories = contentCategories.filterIndexed { index, _ -> - contentTitles[index].contains(query, true) || - contentDescriptions[index].contains(query, true) - } - it.path.contains(query, true) || - it.category.contains(query, true) || - groupLabels.forField(it).contains(query, true) || - it.category in matchedCategories + val fields = catalog.filter { definition -> + if (selectedScope == "global" && definition.path in globallyManagedSettings) return@filter false + val textMatches = query.isNotBlank() && listOf(definition.path, definition.group, definition.label.localized(korean), definition.description.localized(korean)).any { it.contains(query, true) } + if (query.isNotBlank()) textMatches else when (category) { + "administrator" -> definition.admin + in setOf("models", "intelligence", "agents", "tools", "safety") -> definition.category == category && !definition.admin + "system" -> definition.category == "system" && !definition.admin + else -> false } } - val rolesCategoryIndex = categories.indexOf("Roles") - val rolesMatch = query.isNotBlank() && ( - categoryTitles[rolesCategoryIndex].contains(query, true) || - categoryDescriptions[rolesCategoryIndex].contains(query, true) || - "modelRoles".contains(query, true) || - nativeModelRoles.any { it.contains(query, true) } - ) - if (rolesMatch) { - SettingsCategoryOverview( - categories = listOf("Roles"), - titles = listOf(categoryTitles[rolesCategoryIndex]), - descriptions = listOf(categoryDescriptions[rolesCategoryIndex]), - onSelect = { selected -> - category = selected - query = "" - notice = null - }, - ) - } - if (query.isNotBlank() && fields.isEmpty() && !localMatches && !rolesMatch) Text(stringResource(R.string.settings_no_matches), color = OmpColors.TextMuted) - SettingFieldGroups( - fields = fields, - snapshot = snapshot, - busy = busy, - groupLabels = groupLabels, - onConfirm = { message, action -> confirmation = message to action }, - save = save, - ) + CatalogSettings(fields, snapshot, selectedScope, settingsCwd, busy, korean, catalogDrafts, ::mutate) + val hasCatalogMatches = fields.isNotEmpty() + val auxiliaryMatch = query.isBlank() && category in setOf("roles", "providers", "agents", "tools", "system") + if (query.isNotBlank() && !hasCatalogMatches && !queryMatches(query, "general theme palette language completion submit collapse thinking interface")) Text(stringResource(R.string.settings_no_matches), color = OmpColors.TextMuted) if (query.isBlank()) when (category) { - "Models" -> currentModel?.let { Text(stringResource(R.string.settings_current_model, it.displayName(), it.provider), style = MaterialTheme.typography.bodySmall) } - "Agents" -> if (!showTaskDefaults) ExtensionAgentsSection(requester, settingsCwd) - "Tools" -> ExtensionSettingsPanel(requester, settingsCwd) { - SettingFieldGroups( - fields = settingFields.filter { it.category == "Tools" }, - snapshot = snapshot, - busy = busy, - groupLabels = groupLabels, - onConfirm = { message, action -> confirmation = message to action }, - save = save, - ) - } - "System" -> { - Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleSmall) - for ((label, value) in listOf( - stringResource(R.string.settings_relay) to serverUrl, - stringResource(R.string.settings_device) to deviceId, - stringResource(R.string.settings_status) to connection.toString(), - )) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(label, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - SelectionContainer { Text(value, style = MaterialTheme.typography.bodyMedium) } - } - } - OutlinedButton(onClick = { showUsage = true }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small) { Text(stringResource(R.string.settings_usage)) } - SystemControls(requester, deviceId, onUnpair) - } - } + "models" -> { currentModel?.let { Text(stringResource(R.string.settings_current_model, it.displayName(), it.provider), style = MaterialTheme.typography.bodySmall) }; Text(stringResource(R.string.settings_global_model_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } + "roles" -> Text(stringResource(R.string.settings_global_model_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + "providers" -> Text(stringResource(R.string.settings_global_provider_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + "agents" -> { Text(stringResource(R.string.settings_separate_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted); ExtensionAgentsSection(requester, settingsCwd) } + "tools" -> { Text(stringResource(R.string.settings_separate_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted); ExtensionSettingsPanel(requester, settingsCwd) } + "system" -> { ConnectionAndUsage(serverUrl, deviceId, connection) { showUsage = true }; SystemControls(requester, deviceId, onUnpair) } } + @Suppress("UNUSED_VARIABLE") val keepCompilerHappy = auxiliaryMatch } - if (modelsVisited || modelCategory) { - val visible = modelCategory && query.isBlank() - val visibility = if (visible) Modifier else Modifier.clearAndSetSemantics {}.layout { _, _ -> layout(0, 0) {} } - Column(Modifier.fillMaxWidth().then(visibility)) { - ModelSettingsPanel( - requester, - settingsCwd, - selectedSection = when (category) { - "Roles" -> ModelSettingsSection.Roles - "Providers" -> ModelSettingsSection.Providers - else -> ModelSettingsSection.Defaults - }, - ) - } + val modelSection = if (query.isNotBlank()) ModelSettingsSection.Hidden else when (category) { + "models" -> ModelSettingsSection.Defaults + "roles" -> ModelSettingsSection.Roles + "providers" -> ModelSettingsSection.Providers + else -> ModelSettingsSection.Hidden } + // One composition identity owns provider credentials and OAuth polling for + // the entire sheet lifetime, including browse and search transitions. + ModelSettingsPanel(requester, settingsCwd, modelSection) } } } @@ -449,92 +267,62 @@ fun SettingsSheet( } @Composable -private fun SettingsCategoryOverview( - categories: List, - titles: List, - descriptions: List, - onSelect: (String) -> Unit, -) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - categories.forEachIndexed { index, id -> - val categoryTitle = titles[index] - val categoryDescription = descriptions[index] - Surface( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 64.dp) - .clickable(role = Role.Button, onClick = { onSelect(id) }) - .semantics { contentDescription = "$categoryTitle. $categoryDescription" }, - color = OmpColors.BgPanel, - shape = MaterialTheme.shapes.small, - border = BorderStroke(1.dp, OmpColors.Border), - ) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(categoryTitle, style = MaterialTheme.typography.titleSmall, color = OmpColors.Text) - Text(categoryDescription, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - } - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = OmpColors.TextDim, - modifier = Modifier.size(18.dp), - ) - } - } - } - } -} +private fun settingsCategoryTitle(id: String): String = stringResource(when (id) { + "browse" -> R.string.settings_category_browse + "general" -> R.string.settings_category_general + "roles" -> R.string.settings_category_roles + "providers" -> R.string.settings_category_providers + "models" -> R.string.settings_category_models + "intelligence" -> R.string.settings_category_intelligence + "agents" -> R.string.settings_category_agents + "tools" -> R.string.settings_category_tools + "safety" -> R.string.settings_category_safety + "administrator" -> R.string.settings_category_administrator + else -> R.string.settings_category_system +}) -private data class SettingsGroupLabels( - val advisor: String, - val compaction: String, - val memory: String, - val retry: String, - val task: String, - val mcp: String, - val models: String, - val toolsSafety: String, -) { - fun forField(field: SettingField): String = when (field.path.substringBefore('.', field.category)) { - "advisor" -> advisor - "compaction" -> compaction - "memory", "autolearn", "mnemopi" -> memory - "retry" -> retry - "task" -> task - "mcp" -> mcp - "Models" -> models - "tools" -> toolsSafety - else -> field.path.substringBefore('.', field.category).replaceFirstChar { char -> char.uppercase() } - } -} +@Composable +private fun settingsCategoryDescription(id: String): String = stringResource(when (id) { + "browse" -> R.string.settings_browse_hint + "general" -> R.string.settings_category_desc_general + "roles" -> R.string.settings_category_desc_roles + "providers" -> R.string.settings_category_desc_providers + "models" -> R.string.settings_category_desc_models + "intelligence" -> R.string.settings_category_desc_intelligence + "agents" -> R.string.settings_category_desc_agents + "tools" -> R.string.settings_category_desc_tools + "safety" -> R.string.settings_category_desc_safety + "administrator" -> R.string.settings_category_desc_administrator + else -> R.string.settings_category_desc_system +}) + +private fun queryMatches(query: String, haystack: String) = query.isNotBlank() && haystack.contains(query, true) @Composable -private fun SettingFieldGroups( - fields: List, - snapshot: JSONObject?, +private fun CatalogSettings( + fields: List, + snapshot: NativeSettingsSnapshot?, + scope: String, + cwd: String, busy: Boolean, - groupLabels: SettingsGroupLabels, - onConfirm: (String, () -> Unit) -> Unit, - save: (String, Any) -> Unit, + korean: Boolean, + drafts: MutableMap, + mutate: (SettingsMutation, Boolean, () -> Unit) -> Unit, ) { - val context = LocalContext.current - for ((group, groupFields) in fields.groupBy(groupLabels::forField)) { - Text(group, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) - for (field in groupFields) { - var node: JSONObject? = snapshot - val segments = field.path.split('.') - for (segment in segments.dropLast(1)) node = node?.optJSONObject(segment) - val value = node?.opt(segments.last())?.takeUnless { it == JSONObject.NULL } - key(field.path) { - SettingEditor(field, value, !busy && snapshot != null) { newValue -> - if (field.path == "computer.enabled" && newValue == true || field.path == "tools.approvalMode" && newValue == "yolo" || field.path == "tools.approval.bash" && newValue == "allow") { - onConfirm(context.getString(R.string.settings_confirm_security_grant, field.path, newValue)) { save(field.path, newValue) } - } else save(field.path, newValue) + fields.groupBy { it.group }.forEach { (group, grouped) -> + var expanded by rememberSaveable(group) { mutableStateOf(false) } + val forceExpanded = fields.size <= 12 + Surface(Modifier.fillMaxWidth(), color = OmpColors.BgPanel, shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, OmpColors.Border)) { + Column { + Row(Modifier.fillMaxWidth().clickable(role = Role.Button) { expanded = !expanded }.heightIn(min = 48.dp).padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Text(group, Modifier.weight(1f), style = MaterialTheme.typography.titleSmall) + Text(grouped.size.toString(), color = OmpColors.TextMuted) + Icon(if (expanded || forceExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, null) + } + if (expanded || forceExpanded) grouped.forEach { definition -> + key(definition.path, scope, cwd) { + CatalogSettingEditor(definition, snapshot, scope, cwd, busy, korean, drafts) { mutation, success -> mutate(mutation, false, success) } + } } } } @@ -542,133 +330,106 @@ private fun SettingFieldGroups( } @Composable -private fun SettingEditor(field: SettingField, value: Any?, enabled: Boolean, save: (Any) -> Unit) { - val context = LocalContext.current - var draft by rememberSaveable(field.path, value?.toString()) { mutableStateOf(value?.toString() ?: "") } - var error by remember(field.path) { mutableStateOf(null) } - val label = when (field.path) { - "Theme" -> stringResource(R.string.settings_label_theme) - "Palette" -> stringResource(R.string.settings_label_palette) - "Language" -> stringResource(R.string.settings_label_language) - "Completion chime" -> stringResource(R.string.settings_label_chime) - "Submit during run" -> stringResource(R.string.settings_label_submit) - "Collapse tool calls" -> stringResource(R.string.settings_label_collapse) - else -> field.path.substringAfterLast('.').replace(Regex("([a-z])([A-Z])"), "$1 $2").replaceFirstChar { it.uppercase() } - } - val description = when (field.path) { - "Theme" -> stringResource(R.string.settings_desc_theme) - "Palette" -> stringResource(R.string.settings_desc_palette) - "Language" -> stringResource(R.string.settings_desc_language) - "Completion chime" -> stringResource(R.string.settings_desc_chime) - "Submit during run" -> stringResource(R.string.settings_desc_submit) - "Collapse tool calls" -> stringResource(R.string.settings_desc_collapse) - "tools.approvalMode" -> stringResource(R.string.settings_desc_approval_mode) - "tools.approval.bash" -> stringResource(R.string.settings_desc_approval_bash) - "tools.approval.extension" -> stringResource(R.string.settings_desc_approval_extension) - "defaultThinkingLevel" -> stringResource(R.string.settings_desc_thinking_level) - "textVerbosity" -> stringResource(R.string.settings_desc_verbosity) - "personality" -> stringResource(R.string.settings_desc_personality) - "hideThinkingBlock" -> stringResource(R.string.settings_desc_hide_thinking) - "externalThinking" -> stringResource(R.string.settings_desc_external_thinking) - "advisor.enabled" -> stringResource(R.string.settings_desc_advisor_enabled) - "advisor.subagents" -> stringResource(R.string.settings_desc_advisor_subagents) - "advisor.syncBacklog" -> stringResource(R.string.settings_desc_advisor_sync) - "compaction.enabled" -> stringResource(R.string.settings_desc_compaction_enabled) - "compaction.autoContinue" -> stringResource(R.string.settings_desc_compaction_auto) - "compaction.strategy" -> stringResource(R.string.settings_desc_compaction_strategy) - "compaction.midTurnEnabled" -> stringResource(R.string.settings_desc_compaction_mid) - "memory.backend" -> stringResource(R.string.settings_desc_memory_backend) - "autolearn.enabled" -> stringResource(R.string.settings_desc_autolearn_enabled) - "autolearn.autoContinue" -> stringResource(R.string.settings_desc_autolearn_auto) - "mnemopi.scoping" -> stringResource(R.string.settings_desc_mnemopi_scoping) - "mnemopi.autoRecall" -> stringResource(R.string.settings_desc_mnemopi_recall) - "mnemopi.autoRetain" -> stringResource(R.string.settings_desc_mnemopi_retain) - "retry.enabled" -> stringResource(R.string.settings_desc_retry_enabled) - "retry.maxRetries" -> stringResource(R.string.settings_desc_retry_max) - "retry.modelFallback" -> stringResource(R.string.settings_desc_retry_fallback) - "retry.fallbackRevertPolicy" -> stringResource(R.string.settings_desc_retry_revert) - "retry.fallbackChains" -> stringResource(R.string.settings_desc_retry_chains) - "mcp.enableProjectConfig" -> stringResource(R.string.settings_desc_mcp_project) - "mcp.renderMarkdownResults" -> stringResource(R.string.settings_desc_mcp_markdown) - "mcp.notifications" -> stringResource(R.string.settings_desc_mcp_notifications) - else -> null +private fun CatalogSettingEditor( + definition: NativeSettingDefinition, + snapshot: NativeSettingsSnapshot?, + scope: String, + cwd: String, + busy: Boolean, + korean: Boolean, + drafts: MutableMap, + submit: (SettingsMutation, () -> Unit) -> Unit, +) { + val override = snapshot?.let { nestedSetting(it.settings, definition.path) } + val effective = snapshot?.let { nestedSetting(it.effectiveSettings, definition.path) } ?: definition.defaultValue + val storedIssue = snapshot?.let { settingIssueForPath(it.issues, definition.path) } + val editable = snapshot != null && !busy && (scope == "global" || definition.scope == "both") + val label = definition.label.localized(korean) + val draftKey = CatalogDraftKey(scope, cwd, definition.path) + val baseline = formatSettingValue(override ?: effective) + val draft = drafts[draftKey] ?: CatalogDraft(value = baseline) + var menuOpen by remember { mutableStateOf(false) } + fun updateDraft(next: CatalogDraft) { drafts[draftKey] = next } + fun clearDraft() { drafts.remove(draftKey) } + LaunchedEffect(baseline) { + if (!draft.dirty && draft.value != baseline) updateDraft(draft.copy(value = baseline, error = null)) } - Column(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { HorizontalDivider(color = OmpColors.Border) - Column(Modifier.padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(label, style = MaterialTheme.typography.titleSmall) - description?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } - if (field.category != "General") Text(field.path, style = MaterialTheme.typography.labelSmall, color = OmpColors.TextDim) - if (value == null) Text(stringResource(R.string.settings_uses_server_default), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - if (field.kind == "integer") Text("${field.min}–${field.max}", style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - } - if (field.kind == "boolean" && value is Boolean) { - Switch(checked = value, onCheckedChange = { save(it) }, enabled = enabled, modifier = Modifier.heightIn(min = 48.dp).semantics { contentDescription = label }) - } - } - when { - field.kind == "integer" -> { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - OutlinedTextField(draft, { draft = it; error = null }, enabled = enabled, modifier = Modifier.fillMaxWidth().semantics { contentDescription = label }, singleLine = true, textStyle = MaterialTheme.typography.bodyMedium, keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number), isError = error != null) - TextButton(modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), enabled = enabled && draft != (value?.toString() ?: ""), onClick = { - try { - val parsed = draft.toInt().also { require(it in field.min..field.max) { context.getString(R.string.settings_integer_range, field.min, field.max) } } - save(parsed) - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_invalid_value) } - }) { Text(stringResource(R.string.settings_save)) } - } - } - (field.kind == "boolean" && value !is Boolean) || field.kind == "choice" -> { - val options = if (field.kind == "boolean") listOf("true", "false") else field.choices - var expanded by remember { mutableStateOf(false) } - Box(Modifier.fillMaxWidth()) { - OutlinedButton(onClick = { expanded = true }, enabled = enabled, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, OmpColors.Border)) { - Text(when { - field.path == "Submit during run" && value == AppPreferences.SUBMIT_STEER -> stringResource(R.string.chat_submit_steer) - field.path == "Submit during run" && value == AppPreferences.SUBMIT_QUEUE -> stringResource(R.string.chat_submit_queue) - else -> value?.toString() ?: stringResource(R.string.settings_default) - }, Modifier.weight(1f), color = OmpColors.Text) - Icon(Icons.Default.ExpandMore, stringResource(R.string.settings_choose, field.path), modifier = Modifier.size(18.dp)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { option -> - DropdownMenuItem(text = { Text(when { - field.path == "Submit during run" && option == AppPreferences.SUBMIT_STEER -> stringResource(R.string.chat_submit_steer) - field.path == "Submit during run" && option == AppPreferences.SUBMIT_QUEUE -> stringResource(R.string.chat_submit_queue) - else -> option - }) }, onClick = { expanded = false; save(if (field.kind == "boolean") option.toBoolean() else option) }) - } - } - } + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall) + if (definition.description.localized(korean).isNotBlank()) Text(definition.description.localized(korean), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + Text(definition.path, style = MaterialTheme.typography.labelSmall, color = OmpColors.TextDim) + Text( + when { storedIssue != null -> stringResource(R.string.settings_state_stored_issue); override != null -> stringResource(R.string.settings_state_override); scope == "project" -> stringResource(R.string.settings_state_inherited); else -> stringResource(R.string.settings_state_default) }, + style = MaterialTheme.typography.labelSmall, + color = OmpColors.TextMuted, + ) + if (storedIssue != null) Text(storedIssue.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + if (scope == "project" && definition.scope == "global") Text(stringResource(R.string.settings_global_only), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + } + if (definition.kind == "boolean" && !definition.secret) Switch(checked = (override ?: effective) == true, enabled = editable, onCheckedChange = { checked -> submit(SettingsMutation(definition.path, checked)) {} }, modifier = Modifier.heightIn(min = 48.dp).semantics { contentDescription = label }) + } + when { + definition.secret -> { + Text(if (snapshot?.secretStatus?.get(definition.path) == true) stringResource(R.string.settings_secret_configured) else stringResource(R.string.settings_secret_not_configured), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + OutlinedTextField(draft.secret, { updateDraft(draft.copy(secret = it, error = null, dirty = it.isNotBlank())) }, enabled = editable, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.settings_secret_replacement)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), isError = draft.error != null) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(enabled = editable && snapshot?.secretStatus?.get(definition.path) == true, modifier = Modifier.heightIn(min = 48.dp), onClick = { submit(SettingsMutation(definition.path, clearSecret = true), ::clearDraft) }) { Text(stringResource(R.string.settings_clear)) } + TextButton(enabled = editable && draft.secret.isNotBlank(), modifier = Modifier.heightIn(min = 48.dp), onClick = { submit(SettingsMutation(definition.path, secret = draft.secret), ::clearDraft) }) { Text(stringResource(R.string.settings_save)) } } } + definition.kind == "enum" -> Box(Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { menuOpen = true }, enabled = editable, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text((override ?: effective)?.toString() ?: stringResource(R.string.settings_default), Modifier.weight(1f)); Icon(Icons.Default.ExpandMore, null) } + DropdownMenu(menuOpen, { menuOpen = false }) { definition.choices.forEach { choice -> DropdownMenuItem(text = { Text(choice) }, onClick = { menuOpen = false; submit(SettingsMutation(definition.path, choice)) {} }) } } } - if (field.kind != "boolean" && field.kind != "choice" && field.kind != "integer") { - OutlinedTextField(draft, { draft = it; error = null }, enabled = enabled, modifier = Modifier.fillMaxWidth(), label = { Text(when (field.kind) { "array" -> stringResource(R.string.settings_json_array); "object" -> stringResource(R.string.settings_json_object); else -> stringResource(R.string.settings_value) }) }, isError = error != null) - TextButton(modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), enabled = enabled && draft != (value?.toString() ?: ""), onClick = { - try { - val parsed: Any = when (field.kind) { - "array" -> JSONArray(draft).also { array -> for (index in 0 until array.length()) require(array.get(index) is String && array.getString(index).isNotBlank()) { context.getString(R.string.settings_array_entries) } } - "object" -> JSONObject(draft) - else -> draft - } - save(parsed) - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_invalid_value) } - }) { Text(stringResource(R.string.settings_save)) } + definition.kind == "array" && definition.items != "object" -> OrderedArrayEditor( + definition = definition, + value = draft.value, + addition = draft.arrayAddition, + enabled = editable, + onChange = { value, addition -> updateDraft(draft.copy(value = value, arrayAddition = addition, error = null, dirty = value != baseline || addition.isNotBlank())) }, + onSave = { + try { val parsed = parseSettingDraft(definition, draft.value); submit(SettingsMutation(definition.path, parsed), ::clearDraft) } + catch (failure: Exception) { updateDraft(draft.copy(error = failure.message)) } + }, + ) + definition.kind != "boolean" -> { + OutlinedTextField(draft.value, { updateDraft(draft.copy(value = it, error = null, dirty = it != baseline)) }, enabled = editable, modifier = Modifier.fillMaxWidth(), minLines = if (definition.kind in setOf("object", "array")) 3 else 1, maxLines = if (definition.kind in setOf("object", "array")) 10 else 4, singleLine = definition.kind in setOf("number", "string"), keyboardOptions = KeyboardOptions(keyboardType = if (definition.kind == "number") KeyboardType.Decimal else KeyboardType.Text), isError = draft.error != null, label = { Text(if (definition.kind in setOf("object", "array")) stringResource(R.string.settings_json_value) else stringResource(R.string.settings_value)) }) + TextButton(enabled = editable && draft.value != baseline, modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), onClick = { + try { val parsed = parseSettingDraft(definition, draft.value); submit(SettingsMutation(definition.path, parsed), ::clearDraft) } + catch (failure: Exception) { updateDraft(draft.copy(error = failure.message)) } + }) { Text(stringResource(R.string.settings_save)) } } - error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } } + draft.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } + if ((override != null || storedIssue != null) && !definition.secret) TextButton(enabled = editable, modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), onClick = { submit(SettingsMutation(definition.path, reset = true), ::clearDraft) }) { Text(stringResource(R.string.settings_reset_inherit)) } } } @Composable -private fun LocalPreferencesSection( - groupAppearance: String, - groupInterface: String, -) { +private fun OrderedArrayEditor(definition: NativeSettingDefinition, value: String, addition: String, enabled: Boolean, onChange: (String, String) -> Unit, onSave: () -> Unit) { + val initial = remember(value) { runCatching { JSONArray(value) }.getOrElse { JSONArray() } } + val values = remember(definition.path, value) { mutableStateListOf().apply { for (index in 0 until initial.length()) add(initial.opt(index)?.toString().orEmpty()) } } + fun sync(nextAddition: String = addition) = onChange(JSONArray().apply { values.forEach { put(if (definition.items == "number") it.toDoubleOrNull() ?: it else it) } }.toString(2), nextAddition) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + values.forEachIndexed { index, item -> Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { + Text(item, Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + IconButton(enabled = enabled && index > 0, onClick = { values.add(index - 1, values.removeAt(index)); sync() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.KeyboardArrowUp, stringResource(R.string.settings_move_up)) } + IconButton(enabled = enabled && index < values.lastIndex, onClick = { values.add(index + 1, values.removeAt(index)); sync() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.KeyboardArrowDown, stringResource(R.string.settings_move_down)) } + IconButton(enabled = enabled, onClick = { values.removeAt(index); sync() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Delete, stringResource(R.string.settings_remove)) } + } } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField(addition, { onChange(value, it) }, enabled = enabled, modifier = Modifier.weight(1f), singleLine = true, label = { Text(stringResource(R.string.settings_add_item)) }) + IconButton(enabled = enabled && addition.isNotBlank(), onClick = { values.add(addition); sync("") }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Add, stringResource(R.string.settings_add_item)) } + } + TextButton(enabled = enabled, modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), onClick = onSave) { Text(stringResource(R.string.settings_save)) } + } +} + +@Composable +private fun LocalPreferencesSection() { val context = LocalContext.current var theme by remember { mutableStateOf(AppPreferences.getTheme(context)) } var palette by remember { mutableStateOf(AppPreferences.getPalette(context)) } @@ -676,14 +437,26 @@ private fun LocalPreferencesSection( var chime by remember { mutableStateOf(AppPreferences.isSoundChime(context)) } var submit by remember { mutableStateOf(AppPreferences.getSubmitBehavior(context)) } var collapsed by remember { mutableStateOf(AppPreferences.isToolCallsCollapsed(context)) } - Text(groupAppearance, style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) - SettingEditor(SettingField("Theme", "General", "choice", listOf("system", "light", "dark")), theme, true) { theme = it.toString(); AppPreferences.setTheme(context, theme) } - SettingEditor(SettingField("Palette", "General", "choice", listOf("warm", "omp")), palette, true) { palette = it.toString(); AppPreferences.setPalette(context, palette) } - SettingEditor(SettingField("Language", "General", "choice", listOf("English", "한국어")), language, true) { language = it.toString(); AppPreferences.setLanguage(context, language) } - Text(groupInterface, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) - SettingEditor(SettingField("Completion chime", "General"), chime, true) { chime = it == true; AppPreferences.setSoundChime(context, chime) } - SettingEditor(SettingField("Submit during run", "General", "choice", listOf("steer", "queue")), submit, true) { submit = it.toString(); AppPreferences.setSubmitBehavior(context, submit) } - SettingEditor(SettingField("Collapse tool calls", "General"), collapsed, true) { collapsed = it == true; AppPreferences.setToolCallsCollapsed(context, collapsed) } + var showThinking by remember { mutableStateOf(AppPreferences.isThinkingShown(context)) } + Text(stringResource(R.string.settings_group_appearance), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) + LocalChoice(LocalField("Theme", "enum", listOf("system", "light", "dark")), theme) { theme = it; AppPreferences.setTheme(context, it) } + LocalChoice(LocalField("Palette", "enum", listOf("warm", "omp")), palette) { palette = it; AppPreferences.setPalette(context, it) } + LocalChoice(LocalField("Language", "enum", listOf("English", "한국어")), language) { language = it; AppPreferences.setLanguage(context, it) } + Text(stringResource(R.string.settings_group_interface), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) + LocalToggle(stringResource(R.string.settings_label_chime), chime) { chime = it; AppPreferences.setSoundChime(context, it) } + LocalChoice(LocalField("Submit", "enum", listOf("steer", "queue")), submit) { submit = it; AppPreferences.setSubmitBehavior(context, it) } + LocalToggle(stringResource(R.string.settings_label_collapse), collapsed) { collapsed = it; AppPreferences.setToolCallsCollapsed(context, it) } + LocalToggle(stringResource(R.string.settings_label_show_thinking), showThinking) { showThinking = it; AppPreferences.setThinkingShown(context, it) } +} + +@Composable private fun LocalToggle(label: String, value: Boolean, change: (Boolean) -> Unit) { Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { Text(label, Modifier.weight(1f)); Switch(value, change, modifier = Modifier.heightIn(min = 48.dp)) } } +@Composable private fun LocalChoice(field: LocalField, value: String, change: (String) -> Unit) { var open by remember { mutableStateOf(false) }; Box(Modifier.fillMaxWidth()) { OutlinedButton({ open = true }, Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text(field.path, Modifier.weight(1f)); Text(value); Icon(Icons.Default.ExpandMore, null) }; DropdownMenu(open, { open = false }) { field.choices.forEach { choice -> DropdownMenuItem({ Text(choice) }, { open = false; change(choice) }) } } } } + +@Composable +private fun ConnectionAndUsage(serverUrl: String, deviceId: String, connection: ConnectionState, showUsage: () -> Unit) { + Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleSmall) + listOf(stringResource(R.string.settings_relay) to serverUrl, stringResource(R.string.settings_device) to deviceId, stringResource(R.string.settings_status) to connection.toString()).forEach { (label, value) -> Column { Text(label, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted); SelectionContainer { Text(value) } } } + OutlinedButton(showUsage, Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text(stringResource(R.string.settings_usage)) } } @Composable @@ -695,59 +468,16 @@ private fun SystemControls(requester: RelayRequester, deviceId: String, onUnpair var result by remember { mutableStateOf(null) } var devices by remember { mutableStateOf(null) } var confirmation by remember { mutableStateOf Unit>?>(null) } - val request: (String, JSONObject) -> Unit = { action, args -> - scope.launch { - busy = true; error = null - try { - result = requester.request("system", action, args) - if (action == "devices.list") devices = result?.getJSONArray("devices") - if (action == "devices.revoke") { - if (args.optString("deviceId") == deviceId) onUnpair() - else devices = requester.request("system", "devices.list", JSONObject()).getJSONArray("devices") - } - } catch (failure: CancellationException) { throw failure - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_request_failed) - } finally { busy = false } - } - } - Text(stringResource(R.string.settings_system_updates), modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall) + val request: (String, JSONObject) -> Unit = { action, args -> scope.launch { busy = true; error = null; try { result = requester.request("system", action, args); if (action == "devices.list") devices = result?.getJSONArray("devices"); if (action == "devices.revoke") { if (args.optString("deviceId") == deviceId) onUnpair() else devices = requester.request("system", "devices.list", JSONObject()).getJSONArray("devices") } } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_request_failed) } finally { busy = false } } } + Text(stringResource(R.string.settings_system_updates), style = MaterialTheme.typography.titleSmall) if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - for ((label, action) in listOf( - stringResource(R.string.settings_omp_version) to "omp.check", - stringResource(R.string.settings_app_version) to "app.check", - stringResource(R.string.settings_paired_devices) to "devices.list", - )) { - Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) - TextButton(modifier = Modifier.width(128.dp).heightIn(min = 48.dp), enabled = !busy, onClick = { request(action, JSONObject().apply { if (action == "app.check") put("force", true) }) }) { - Text(if (action == "devices.list") stringResource(R.string.settings_refresh_action) else stringResource(R.string.settings_check_version)) - } - } - } - result?.let { response -> - var expanded by remember(response) { mutableStateOf(true) } - TextButton(onClick = { expanded = !expanded }) { Text(if (expanded) stringResource(R.string.settings_hide_response) else stringResource(R.string.settings_show_response)) } - if (expanded) Surface(color = OmpColors.BgPanel, shape = MaterialTheme.shapes.small) { - SelectionContainer { Text(response.toString(2), Modifier.padding(12.dp), style = MaterialTheme.typography.bodySmall) } - } - } + listOf(stringResource(R.string.settings_omp_version) to "omp.check", stringResource(R.string.settings_app_version) to "app.check", stringResource(R.string.settings_paired_devices) to "devices.list").forEach { (label, action) -> Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { Text(label, Modifier.weight(1f)); TextButton(enabled = !busy, onClick = { request(action, JSONObject().apply { if (action == "app.check") put("force", true) }) }, modifier = Modifier.heightIn(min = 48.dp)) { Text(if (action == "devices.list") stringResource(R.string.settings_refresh_action) else stringResource(R.string.settings_check_version)) } } } + result?.let { response -> var expanded by remember(response) { mutableStateOf(false) }; TextButton({ expanded = !expanded }, Modifier.heightIn(min = 48.dp)) { Text(if (expanded) stringResource(R.string.settings_hide_response) else stringResource(R.string.settings_show_response)) }; if (expanded) SelectionContainer { Text(response.toString(2), style = MaterialTheme.typography.bodySmall) } } Text(stringResource(R.string.settings_updates_hint), style = MaterialTheme.typography.bodySmall) - devices?.let { list -> - Text(stringResource(R.string.settings_paired_devices), style = MaterialTheme.typography.titleSmall) - for (index in 0 until list.length()) { - val device = list.optJSONObject(index) ?: continue - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(device.optString("label"), style = MaterialTheme.typography.bodyMedium) - Text("${device.optString("id")}\n${context.getString(R.string.settings_last_seen, device.opt("lastSeenAt"))}", style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - } - TextButton(modifier = Modifier.width(128.dp).heightIn(min = 48.dp), enabled = !busy, onClick = { confirmation = context.getString(R.string.settings_revoke_confirm, device.optString("label")) to { request("devices.revoke", JSONObject().put("deviceId", device.getString("id"))) } }) { Text(stringResource(R.string.settings_revoke_device)) } - } - } } + devices?.let { list -> for (index in 0 until list.length()) { val device = list.optJSONObject(index) ?: continue; Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text(device.optString("label")); Text(device.optString("id"), style = MaterialTheme.typography.bodySmall) }; TextButton(onClick = { confirmation = context.getString(R.string.settings_revoke_confirm, device.optString("label")) to { request("devices.revoke", JSONObject().put("deviceId", device.getString("id"))) } }) { Text(stringResource(R.string.settings_revoke_device)) } } } } HorizontalDivider(color = OmpColors.Border) - Text(stringResource(R.string.settings_session_actions), modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall) - OutlinedButton(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, enabled = !busy, onClick = { confirmation = context.getString(R.string.settings_restart_confirm) to { request("omp.restart", JSONObject().put("confirm", true)) } }) { Text(stringResource(R.string.settings_restart_omp)) } - OutlinedButton(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, enabled = !busy, onClick = { confirmation = context.getString(R.string.settings_unpair_confirm) to onUnpair }) { Text(stringResource(R.string.settings_unpair_device)) } - confirmation?.let { pending -> AlertDialog(onDismissRequest = { confirmation = null }, title = { Text(stringResource(R.string.settings_confirm_title)) }, text = { OmpDialogSystemBars(); Text(pending.first) }, confirmButton = { TextButton(onClick = { confirmation = null; pending.second() }) { Text(stringResource(R.string.settings_confirm)) } }, dismissButton = { TextButton(onClick = { confirmation = null }) { Text(stringResource(R.string.settings_cancel)) } }) } + OutlinedButton(onClick = { confirmation = context.getString(R.string.settings_restart_confirm) to { request("omp.restart", JSONObject().put("confirm", true)) } }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), enabled = !busy) { Text(stringResource(R.string.settings_restart_omp)) } + OutlinedButton(onClick = { confirmation = context.getString(R.string.settings_unpair_confirm) to onUnpair }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), enabled = !busy) { Text(stringResource(R.string.settings_unpair_device)) } + confirmation?.let { pending -> AlertDialog({ confirmation = null }, { TextButton({ confirmation = null; pending.second() }) { Text(stringResource(R.string.settings_confirm)) } }, title = { Text(stringResource(R.string.settings_confirm_title)) }, text = { OmpDialogSystemBars(); Text(pending.first) }, dismissButton = { TextButton({ confirmation = null }) { Text(stringResource(R.string.settings_cancel)) } }) } } diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml index 09ef16293..27002f171 100644 --- a/android/app/src/main/res/values-ja/strings.xml +++ b/android/app/src/main/res/values-ja/strings.xml @@ -643,6 +643,41 @@ This removes the project server and its saved environment variables and headers. サブエージェント、モデルルーティング、prewalk、Advisor オプションツール、MCP サーバー、スキル、OMP プラグイン アプリ更新、ランタイムバージョン、接続、使用量 + 管理者 + 保護されたホストアクセス、認証情報、その他の高リスク設定 + グローバル + プロジェクト + ホスト設定: %1$s + プロジェクト: %1$s + 設定ファイル: %1$s + 読み込み中… + カタログ値は新しいセッションの有効な設定です。環境変数とコマンドラインの上書きが優先される場合があります。 + このスコープで上書き + グローバル設定から継承 + スキーマの既定値 + 保存済みの値を確認してください + 保存済み設定 %1$s: %2$s + グローバル専用です。編集するにはグローバルに切り替えてください。 + 継承に戻す + 設定済み(値は返されません) + 未設定 + 置換するシークレット + 消去 + 検証済み JSON + 項目を追加 + 削除 + 上へ移動 + 下へ移動 + この端末でモデルの思考を表示 + スコープを切り替えますか?このスコープの未保存の下書きは適用されず破棄されます。 + ホストから再読み込みしますか?未保存の下書きは保持され、適用されません。 + 設定を閉じますか?未保存の下書きは適用されず破棄されます。 + %1$s の変更を確認\nパス: %2$s\n変更後: %3$s + 設定済みシークレットを削除 + •••••••• に置換 + 以下のモデルレジストリはグローバルです。スコープ選択は上のカタログ上書きにのみ適用されます。 + 以下のプロバイダーアカウント、API キー、モデルはグローバルです。スコープ選択で保存場所は変わりません。 + この管理画面は表示されている独自のグローバル/プロジェクト保存先を使用します。カタログのスコープ選択は影響しません。 設定セクション カスタマイズするセクションを選んでください。 すべてのセクション diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index b9d3ee5f9..ad39c7251 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -156,7 +156,7 @@ 연결 사용량 및 용량 저장됨; 런타임이 새로고침되었습니다 - 저장됨; 새 세션부터 적용됩니다 + 저장됨; 새 세션부터 적용됩니다. 환경 변수와 명령줄 재정의가 우선할 수 있습니다. 설정 업데이트에 실패했습니다 설정을 불러오지 못했습니다 보안 변경 확인 @@ -186,6 +186,41 @@ 서브에이전트, 모델 라우팅, prewalk, Advisor 선택 도구, MCP 서버, 스킬, OMP 플러그인 앱 업데이트, 런타임 버전, 연결, 사용량 + 관리자 + 보호된 호스트 접근, 자격 증명 및 기타 고위험 설정 + 전역 + 프로젝트 + 호스트 설정: %1$s + 프로젝트: %1$s + 설정 파일: %1$s + 불러오는 중… + 카탈로그 값은 새 세션의 유효 설정이며 환경 변수와 명령줄 재정의가 우선할 수 있습니다. + 이 범위에서 재정의됨 + 전역 설정에서 상속됨 + 스키마 기본값 + 저장된 값을 확인해야 합니다 + 저장된 설정 %1$s: %2$s + 전역 전용입니다. 수정하려면 전역으로 전환하세요. + 상속으로 재설정 + 구성됨 (값은 반환되지 않음) + 구성되지 않음 + 대체 비밀값 + 지우기 + 검증된 JSON + 항목 추가 + 항목 제거 + 위로 이동 + 아래로 이동 + 이 기기에서 모델 Thinking 표시 + 범위를 전환할까요? 이 범위의 저장하지 않은 초안은 적용되지 않고 삭제됩니다. + 호스트에서 새로고침할까요? 저장하지 않은 초안은 보존되며 적용되지 않습니다. + 설정을 닫을까요? 저장하지 않은 초안은 적용되지 않고 삭제됩니다. + %1$s 변경 확인\n경로: %2$s\n제안 값: %3$s + 구성된 비밀값 삭제 + ••••••••로 교체 + 아래 모델 레지스트리는 전역입니다. 범위 선택기는 위의 카탈로그 재정의에만 적용됩니다. + 아래 프로바이더 계정, API 키 및 모델은 전역입니다. 범위 선택기로 위치가 바뀌지 않습니다. + 이 관리자는 자체 표시된 전역/프로젝트 위치를 사용하며 카탈로그 범위 선택기의 영향을 받지 않습니다. 설정 섹션 사용자 지정할 섹션을 선택하세요. 모든 섹션 diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 8bc565a53..425fe62a2 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -643,6 +643,41 @@ This removes the project server and its saved environment variables and headers. 子代理、模型路由、prewalk 与 Advisor 可选工具、MCP 服务器、技能与 OMP 插件 应用更新、运行时版本、连接与用量 + 管理员 + 受保护的主机访问、凭据及其他高风险设置 + 全局 + 项目 + 主机设置:%1$s + 项目:%1$s + 设置文件:%1$s + 加载中… + 目录值是新会话的有效设置;环境变量和命令行覆盖项可能优先。 + 在此范围覆盖 + 继承自全局设置 + 架构默认值 + 已存储的值需要处理 + 已存储设置 %1$s:%2$s + 仅限全局;请切换到“全局”后编辑。 + 重置为继承 + 已配置(绝不返回其值) + 未配置 + 替换密钥 + 清除 + 已验证的 JSON + 添加项目 + 移除 + 上移 + 下移 + 在此设备上显示模型思考 + 切换范围?此范围内未保存的草稿将不会应用并会被丢弃。 + 从主机刷新?未保存的草稿会保留,但不会应用。 + 关闭设置?未保存的草稿将不会应用并会被丢弃。 + 确认%1$s更改\n路径:%2$s\n拟议值:%3$s + 删除已配置的密钥 + 替换为 •••••••• + 下方模型注册表为全局设置。范围选择器仅适用于上方的目录覆盖项。 + 下方提供商账户、API 密钥和模型均为全局设置。范围选择器不会改变其位置。 + 此管理器使用其自身显示的全局/项目位置;目录范围选择器不会改变它。 设置分区 选择要自定义的分区。 全部分区 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2f1a7bcc1..08395ad5d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -156,7 +156,7 @@ Connection Usage & capacity Saved; runtime refreshed - Saved; applies to new sessions + Saved; applies to new sessions. Environment and command-line overrides may take precedence. Settings update failed Settings load failed Confirm security change @@ -186,6 +186,41 @@ Configured subagents, model routing, prewalk, and advisor Optional tools, MCP servers, managed skills, and OMP plugins App updates, runtime version, connection, and usage + Administrator + Protected host access, credentials, and other high-risk settings + Global + Project + Host settings: %1$s + Project: %1$s + Settings file: %1$s + loading… + Catalog values are effective settings for new sessions; environment and command-line overrides may take precedence. + Override at this scope + Inherited from global settings + Schema default + Stored value needs attention + Stored setting %1$s: %2$s + Global only; switch to Global to edit. + Reset to inherit + Configured (value is never returned) + Not configured + Replacement secret + Clear + Validated JSON + Add item + Remove item + Move up + Move down + Show model thinking on this device + Switch scope? Unsaved drafts in this scope will be discarded without being applied. + Refresh from the host? Unsaved drafts will be preserved and not applied. + Close settings? Unsaved drafts will be discarded without being applied. + Confirm %1$s change\nPath: %2$s\nProposed: %3$s + Delete configured secret + Replace with •••••••• + The model registry below is global. The scope selector applies only to catalog overrides above. + Provider accounts, API keys, and models below are global. The scope selector does not relocate them. + This manager uses its own displayed global/project location; the catalog scope selector does not change it. Settings sections Choose a section to customize. All sections diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/NativeSettingsTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/NativeSettingsTest.kt new file mode 100644 index 000000000..d0b74c0ff --- /dev/null +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/NativeSettingsTest.kt @@ -0,0 +1,108 @@ +package com.dbchbin.ompgui.remote.ui + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeSettingsTest { + @Test + fun parsesServerCatalogAndKeepsSecretPresenceOnly() { + val response = JSONObject() + .put("path", "/tmp/home/.omp/settings.yml") + .put("scope", "project") + .put("cwd", "/tmp/project") + .put("settings", JSONObject().put("retry", JSONObject().put("maxRetries", 4))) + .put("effectiveSettings", JSONObject().put("retry", JSONObject().put("maxRetries", 4))) + .put("secretStatus", JSONObject().put("auth.broker.token", true)) + .put("issues", JSONArray().put(JSONObject().put("path", "retry.maxRetries").put("message", "Stored value was omitted"))) + .put("catalog", JSONArray() + .put(definition("retry.maxRetries", "number").put("integer", true).put("min", 0).put("max", 20)) + .put(definition("auth.broker.token", "string").put("secret", true).put("admin", true).put("scope", "global"))) + + val snapshot = parseNativeSettingsSnapshot(response) + + assertEquals("project", snapshot.scope) + assertEquals(4, nestedSetting(snapshot.settings, "retry.maxRetries")) + assertTrue(snapshot.secretStatus.getValue("auth.broker.token")) + assertNull(nestedSetting(snapshot.settings, "auth.broker.token")) + assertTrue(snapshot.catalog.last().secret) + assertTrue(snapshot.catalog.last().admin) + assertEquals("global", snapshot.catalog.last().scope) + assertEquals("retry.maxRetries", snapshot.issues.single().path) + assertEquals("Stored value was omitted", snapshot.issues.single().message) + } + + @Test + fun ignoresMalformedCatalogRowsButRejectsMissingCatalog() { + val valid = definition("memory.backend", "enum").put("choices", JSONArray(listOf("off", "mnemopi"))) + val response = JSONObject() + .put("path", "/tmp/settings.yml") + .put("scope", "global") + .put("settings", JSONObject()) + .put("effectiveSettings", JSONObject()) + .put("catalog", JSONArray().put(JSONObject().put("path", "bad")).put(valid)) + + val snapshot = parseNativeSettingsSnapshot(response) + assertEquals(listOf("memory.backend"), snapshot.catalog.map { it.path }) + assertEquals(listOf("off", "mnemopi"), snapshot.catalog.single().choices) + + response.remove("catalog") + assertTrue(runCatching { parseNativeSettingsSnapshot(response) }.isFailure) + } + + @Test + fun relatesPersistedIssuesToCatalogParentsAndChildrenOnly() { + val issues = listOf( + NativeSettingIssue("modelRoles.reviewer", "Invalid role"), + NativeSettingIssue("providers", "Invalid provider map"), + ) + + assertEquals("modelRoles.reviewer", settingIssueForPath(issues, "modelRoles")?.path) + assertEquals("providers", settingIssueForPath(issues, "providers.openai")?.path) + assertNull(settingIssueForPath(issues, "retry.maxRetries")) + } + + @Test + fun createsMinimalNestedPatchWithoutSiblingSettings() { + val patch = nestedSettingsPatch("retry.maxRetries", 7) + assertEquals(7, patch.getJSONObject("retry").getInt("maxRetries")) + assertEquals(1, patch.length()) + assertEquals(1, patch.getJSONObject("retry").length()) + assertFalse(patch.has("settings")) + } + + @Test + fun validatesNumericBoundsAndArrayItemTypes() { + val number = parsedDefinition(definition("retry.maxRetries", "number").put("integer", true).put("min", 0).put("max", 20)) + assertEquals(12L, parseSettingDraft(number, "12")) + assertTrue(runCatching { parseSettingDraft(number, "12.5") }.isFailure) + assertTrue(runCatching { parseSettingDraft(number, "21") }.isFailure) + + val array = parsedDefinition(definition("task.disabledAgents", "array").put("items", "string")) + assertEquals(2, (parseSettingDraft(array, "[\"one\",\"two\"]") as JSONArray).length()) + assertTrue(runCatching { parseSettingDraft(array, "[1]") }.isFailure) + } + + private fun parsedDefinition(value: JSONObject): NativeSettingDefinition { + val response = JSONObject() + .put("path", "/tmp/settings.yml") + .put("scope", "global") + .put("settings", JSONObject()) + .put("effectiveSettings", JSONObject()) + .put("catalog", JSONArray().put(value)) + return parseNativeSettingsSnapshot(response).catalog.single() + } + + private fun definition(path: String, kind: String) = JSONObject() + .put("path", path) + .put("category", if (path.startsWith("retry") || path.startsWith("memory")) "intelligence" else if (path.startsWith("task")) "agents" else "system") + .put("group", path.substringBefore('.')) + .put("label", JSONObject().put("en", path).put("ko", path)) + .put("description", JSONObject().put("en", "Description").put("ko", "설명")) + .put("kind", kind) + .put("scope", "both") +} diff --git a/app/api/auth/login/[provider]/route.ts b/app/api/auth/login/[provider]/route.ts index 052eaedab..b873d6179 100644 --- a/app/api/auth/login/[provider]/route.ts +++ b/app/api/auth/login/[provider]/route.ts @@ -168,7 +168,7 @@ export async function GET( const ready = await child.waitReady(READY_TIMEOUT_MS); await child.negotiateProtocol(ready); await child.sendCommand({ type: "login", providerId: provider }, LOGIN_TIMEOUT_MS); - enableProvider(provider); + await enableProvider(provider); invalidateModelsCache(); disposeUtilityRpc(); send({ type: "success" }); diff --git a/app/api/model-roles/route.ts b/app/api/model-roles/route.ts index aca4af4ad..5239ffb25 100644 --- a/app/api/model-roles/route.ts +++ b/app/api/model-roles/route.ts @@ -21,7 +21,7 @@ export async function PUT(request: Request) { const roles = Object.fromEntries(Object.entries(body.roles).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[0].trim().length > 0 && entry[1].trim().length > 0, )); - writeModelRoles(roles); + await writeModelRoles(roles); invalidateModelsCache(); return NextResponse.json({ success: true, roles }); } catch (error) { diff --git a/app/api/omp-settings/route.ts b/app/api/omp-settings/route.ts index c11552566..3fb326abe 100644 --- a/app/api/omp-settings/route.ts +++ b/app/api/omp-settings/route.ts @@ -1,61 +1,28 @@ import { NextResponse } from "next/server"; -import { invalidateModelsCache } from "@/lib/models-cache"; -import { disposeUtilityRpc, runUtilityCommand, type OmpModel } from "@/lib/omp/rpc-utility"; -import { mergeNativeSettings, readNativeSettings, writeNativeSettings, type NativeSettings } from "@/lib/omp/settings-config"; -import { assertNoAmbiguousModelScopes } from "@/lib/model-scope"; +import { parseJsonWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data"; +import { applyNativeSettings, getNativeSettings, nativeSettingsErrorPayload } from "@/lib/omp/settings-service"; export const dynamic = "force-dynamic"; +const MAX_SETTINGS_REQUEST_BYTES = 256 * 1024; -export function GET() { +export async function GET(request: Request) { try { - return NextResponse.json(readNativeSettings()); + const url = new URL(request.url); + const scope = url.searchParams.get("scope") ?? undefined; + const cwd = url.searchParams.get("cwd") ?? undefined; + return NextResponse.json(await getNativeSettings({ ...(scope ? { scope } : {}), ...(cwd ? { cwd } : {}) })); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 400 }); + return NextResponse.json(nativeSettingsErrorPayload(error), { status: 400 }); } } export async function PUT(request: Request) { try { - const body = await request.json() as { settings?: NativeSettings } | null; - if (!body || !body.settings || typeof body.settings !== "object" || Array.isArray(body.settings)) { - return NextResponse.json({ error: "settings must be an object" }, { status: 400 }); - } - if (body.settings.enabledModels !== undefined) { - // The utility process is best-effort here: settings remain editable when - // omp is unavailable, but an available catalog rejects ambiguous bare IDs. - try { - const response = await runUtilityCommand<{ models?: unknown }>({ type: "get_available_models" }, 120_000); - if (Array.isArray(response.models)) { - const models = response.models.filter((model): model is OmpModel => ( - typeof model === "object" && model !== null - && typeof (model as OmpModel).id === "string" - && typeof (model as OmpModel).provider === "string" - )); - assertNoAmbiguousModelScopes(body.settings.enabledModels, models); - } - } catch (error) { - if (error instanceof Error && error.message.startsWith("Ambiguous enabledModels entry")) throw error; - } - } - const current = readNativeSettings(); - const next = mergeNativeSettings(current.settings, body.settings); - writeNativeSettings(next); - const registryInvalidated = body.settings.enabledModels !== undefined - || body.settings.disabledProviders !== undefined - || body.settings.modelProviderOrder !== undefined; - if (registryInvalidated) { - invalidateModelsCache(); - disposeUtilityRpc(); - } - return NextResponse.json({ - success: true, - settings: readNativeSettings().settings, - application: { - mode: registryInvalidated ? "runtime-refresh" : "new-session", - restartRequired: false, - }, - }); + const body = await parseJsonWithinLimit(request, MAX_SETTINGS_REQUEST_BYTES); + return NextResponse.json(await applyNativeSettings(body)); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 400 }); + return NextResponse.json(nativeSettingsErrorPayload(error), { + status: error instanceof RequestBodyTooLargeError ? 413 : 400, + }); } } diff --git a/app/api/providers/enable/route.ts b/app/api/providers/enable/route.ts index 03cf53913..d7c5c1ca6 100644 --- a/app/api/providers/enable/route.ts +++ b/app/api/providers/enable/route.ts @@ -9,7 +9,7 @@ export async function POST(request: Request) { if (typeof body.provider !== "string" || !body.provider.trim()) { return NextResponse.json({ error: "provider is required" }, { status: 400 }); } - enableProvider(body.provider); + await enableProvider(body.provider); invalidateModelsCache(); disposeUtilityRpc(); return NextResponse.json({ success: true }); diff --git a/components/AgentsConfig.tsx b/components/AgentsConfig.tsx index 2d9a7d6a0..aa9e69d9e 100644 --- a/components/AgentsConfig.tsx +++ b/components/AgentsConfig.tsx @@ -14,6 +14,28 @@ type Props = { }; type ScopeFilter = "all" | AgentSource; +type AgentOverrideType = "model" | "prewalk" | "advisor"; + +function canonicalSelector(value: unknown): string | undefined { + if (value === true) return "on"; + if (value === false) return "off"; + return typeof value === "string" ? value : undefined; +} + +function selectorEnabled(value: unknown): boolean { + const selector = canonicalSelector(value); + return selector !== undefined && selector !== "off"; +} + +function canonicalSelectorMap(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [name, selector] of Object.entries(value)) { + const canonical = canonicalSelector(selector); + if (canonical !== undefined) result[name] = canonical; + } + return result; +} export function AgentsConfig({ cwd, onSaved }: Props) { const { t } = useI18n(); @@ -123,8 +145,8 @@ export function AgentsConfig({ cwd, onSaved }: Props) { const updateAgentOverride = async ( agentName: string, - overrideType: "model" | "prewalk" | "advisor", - value: string | boolean | undefined, + overrideType: AgentOverrideType, + value: string | undefined, ) => { try { const settingsRes = await fetch("/api/omp-settings"); @@ -141,7 +163,7 @@ export function AgentsConfig({ cwd, onSaved }: Props) { } task.agentModelOverrides = overrides; } else if (overrideType === "prewalk") { - const prewalks = { ...(task.agentPrewalk || {}) }; + const prewalks = canonicalSelectorMap(task.agentPrewalk); if (value === undefined) { delete prewalks[agentName]; } else { @@ -149,7 +171,7 @@ export function AgentsConfig({ cwd, onSaved }: Props) { } task.agentPrewalk = prewalks; } else if (overrideType === "advisor") { - const advisors = { ...(task.agentAdvisor || {}) }; + const advisors = canonicalSelectorMap(task.agentAdvisor); if (value === undefined) { delete advisors[agentName]; } else { @@ -412,10 +434,12 @@ function AgentCard({ onToggleDisabled: (disabled: boolean) => void; onEdit: () => void; onDelete: () => void; - onUpdateOverride: (type: "model" | "prewalk" | "advisor", val: string | boolean | undefined) => void; + onUpdateOverride: (type: AgentOverrideType, val: string | undefined) => void; }) { const { t } = useI18n(); const isCustom = agent.source === "user" || agent.source === "project"; + const advisorEnabled = selectorEnabled(agent.advisorOverride); + const prewalkEnabled = selectorEnabled(agent.prewalkOverride); const isReadOnly = agent.tools?.every((tool) => tool === "read" || tool === "grep" || tool === "glob" || tool.startsWith("web_")); const scopeBadgeColor = @@ -532,33 +556,35 @@ function AgentCard({
diff --git a/components/AppShell.tsx b/components/AppShell.tsx index fdf924dce..e7d8e4258 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1877,7 +1877,7 @@ export function AppShell() { >