From cff60faf85522cde0515e20d25e20796fba13636 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 22 Jun 2026 00:52:46 +0800 Subject: [PATCH] feat(web): UI polish across composer, model picker, sidebar, settings & remote wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the in-progress UI/UX work on the web frontend together with the supporting backend/desktop WIP already present in the tree. Composer & model picker (ChatInput.vue): - Drop the channel/chat-bubble button; flatten the "+", approval-mode and model triggers (transparent at rest, background on hover only) - Model picker: list only enabled models, tighten rows, darker/larger capability icons, "当前" tag -> CheckCircle icon, transparent pinned row, hashed per-provider tile colors (no more washed-out gray), darker search placeholder + footer text, narrower & taller panel - Mode selector: Plan tinted green, Full access softened red, flat icons Sidebar: remove the redundant workspace archive toggle Settings: flat row icons (no tile backgrounds), green "online" status Remote wizard: auto-save the SSH host to config on connect; wider/taller dialog i18n: drop the duplicated gear glyph on "Manage models"; replace the stale "autopilot" wording with "full access" in zh-Hans/zh-Hant/ja/ko Also carries pre-existing uncommitted work across internal/* (mode, approval, config, web/acp handlers, sessions), docs/, and the Tauri desktop shell. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + Makefile | 11 +- README.md | 10 +- design/model-and-approval-redesign.html | 1675 +++++++++++++++++ desktop/package.json | 2 +- desktop/splash/index.html | 32 +- desktop/src-tauri/capabilities/default.json | 4 +- desktop/src-tauri/icons/128x128.png | Bin 15348 -> 11795 bytes desktop/src-tauri/icons/128x128@2x.png | Bin 56664 -> 36872 bytes desktop/src-tauri/icons/32x32.png | Bin 1889 -> 1393 bytes desktop/src-tauri/icons/64x64.png | Bin 4897 -> 3904 bytes desktop/src-tauri/icons/Square107x107Logo.png | Bin 11154 -> 8931 bytes desktop/src-tauri/icons/Square142x142Logo.png | Bin 18704 -> 14107 bytes desktop/src-tauri/icons/Square150x150Logo.png | Bin 20604 -> 15392 bytes desktop/src-tauri/icons/Square284x284Logo.png | Bin 69459 -> 43665 bytes desktop/src-tauri/icons/Square30x30Logo.png | Bin 1686 -> 1253 bytes desktop/src-tauri/icons/Square310x310Logo.png | Bin 82132 -> 50178 bytes desktop/src-tauri/icons/Square44x44Logo.png | Bin 2896 -> 2214 bytes desktop/src-tauri/icons/Square71x71Logo.png | Bin 5742 -> 4636 bytes desktop/src-tauri/icons/Square89x89Logo.png | Bin 8083 -> 6575 bytes desktop/src-tauri/icons/StoreLogo.png | Bin 3457 -> 2697 bytes .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 2803 -> 2644 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 23764 -> 17422 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 3210 -> 3100 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 2749 -> 2591 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 11321 -> 8921 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 3147 -> 2890 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 7470 -> 7166 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 41141 -> 27855 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 8683 -> 8091 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 15302 -> 13192 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 89768 -> 52959 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 17173 -> 15113 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 26302 -> 20413 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 156662 -> 80851 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 28620 -> 23691 bytes desktop/src-tauri/icons/icon.icns | Bin 1296774 -> 552457 bytes desktop/src-tauri/icons/icon.ico | Bin 69200 -> 46653 bytes desktop/src-tauri/icons/icon.png | Bin 214967 -> 100931 bytes .../src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 1000 -> 701 bytes .../icons/ios/AppIcon-20x20@2x-1.png | Bin 2504 -> 1801 bytes .../src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 2504 -> 1801 bytes .../src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 4476 -> 3377 bytes .../src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 1620 -> 1163 bytes .../icons/ios/AppIcon-29x29@2x-1.png | Bin 4289 -> 3237 bytes .../src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 4289 -> 3237 bytes .../src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 7745 -> 6117 bytes .../src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 2504 -> 1801 bytes .../icons/ios/AppIcon-40x40@2x-1.png | Bin 6821 -> 5339 bytes .../src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 6821 -> 5339 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 13622 -> 10333 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 727251 -> 251859 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 13622 -> 10333 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 29038 -> 20186 bytes .../src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 6286 -> 4991 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 21197 -> 15219 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 25279 -> 17717 bytes desktop/src-tauri/icons/tray-template.png | Bin 0 -> 61318 bytes desktop/src-tauri/src/main.rs | 13 +- desktop/src-tauri/src/sidecar.rs | 51 +- desktop/src-tauri/src/tray.rs | 23 +- desktop/src-tauri/tauri.conf.json | 5 +- docs/commands.md | 2 +- docs/configuration.md | 6 +- docs/overview/agent.md | 10 +- docs/overview/plan-mode.md | 4 +- docs/overview/sessions.md | 2 +- docs/tools.md | 6 +- docs/web-interface.md | 4 +- internal/command/acp.go | 37 +- internal/command/interactive.go | 28 +- internal/command/mode_helpers_test.go | 44 +- internal/command/web.go | 2 +- internal/config/config.go | 6 +- internal/handler/acp.go | 10 +- internal/handler/web.go | 2 +- internal/handler/web_test.go | 2 +- internal/mode/mode.go | 48 +- internal/mode/mode_test.go | 27 +- internal/runner/approval.go | 14 +- internal/runner/approval_test.go | 12 +- internal/session/history.go | 2 +- internal/session/mode_roundtrip_test.go | 12 +- internal/tui/input_views.go | 12 +- internal/tui/messages.go | 2 +- internal/tui/mode_pill_test.go | 10 +- internal/tui/pickers.go | 2 +- internal/tui/statusbar_component.go | 2 +- internal/tui/styles.go | 12 +- internal/tui/tui.go | 8 +- internal/tui/update.go | 2 +- internal/web/cors_test.go | 2 + internal/web/frontend.go | 8 + internal/web/frontend_headless.go | 35 + internal/web/mode_test.go | 116 +- internal/web/server.go | 51 +- web/public/icon.svg | 22 + web/src/App.vue | 16 +- web/src/components/ApprovalBanner.vue | 307 ++- web/src/components/AskUserCard.vue | 20 +- web/src/components/BranchPicker.vue | 12 +- web/src/components/ChatInput.vue | 1227 ++++++++++-- web/src/components/ChatMessage.vue | 10 +- web/src/components/DiffViewer.vue | 6 +- web/src/components/FileTreePanel.vue | 2 +- web/src/components/GoalBanner.vue | 2 +- web/src/components/ProjectSwitcher.vue | 20 +- web/src/components/RemoteConnectWizard.vue | 37 +- web/src/components/RightPanel.vue | 4 +- web/src/components/SettingsDialog.vue | 1086 +++++++---- web/src/components/Sidebar.vue | 25 +- web/src/components/TaskList.vue | 6 +- web/src/components/TerminalInstance.vue | 6 +- web/src/components/ToolCallCard.vue | 18 +- web/src/components/TopBar.vue | 4 +- web/src/components/WorkspacePicker.vue | 12 +- web/src/composables/api.ts | 5 +- web/src/composables/apiBase.ts | 105 ++ web/src/composables/ws.ts | 7 +- web/src/i18n/locales/en.ts | 27 +- web/src/i18n/locales/ja.ts | 27 +- web/src/i18n/locales/ko.ts | 27 +- web/src/i18n/locales/zh-Hans.ts | 27 +- web/src/i18n/locales/zh-Hant.ts | 27 +- web/src/main.ts | 14 + web/src/stores/chat.ts | 66 +- web/src/styles/tokens.css | 12 + web/src/types/api.ts | 9 +- 128 files changed, 4574 insertions(+), 990 deletions(-) create mode 100644 design/model-and-approval-redesign.html create mode 100644 desktop/src-tauri/icons/tray-template.png create mode 100644 internal/web/frontend_headless.go create mode 100644 web/public/icon.svg create mode 100644 web/src/composables/apiBase.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 224d5da..a141739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Appearance settings tab** in the web UI: a System (follow-OS) option plus dark/light swatch grids that render a true mini-preview of each theme. Themes apply via `html[data-theme]`; the legacy light/dark/system localStorage values migrate automatically. ### Changed +- Renamed the session modes to **Ask for approval / Plan / Full access** across the web UI, terminal UI, and ACP. Their canonical IDs are now `approval` / `plan` / `full_access`; the old `ask`, `agent`, and `autopilot` IDs are no longer accepted. - The terminal palette was de-frozen: the ~50 lipgloss styles that were baked in at import time are now rebuilt from the active theme by `ApplyTheme`, and previously-hardcoded colors (subagent purple, on-primary text, team-panel and context-bar colors) are now semantic tokens. Markdown (glamour) follows the theme's light/dark appearance. ### Fixed diff --git a/Makefile b/Makefile index bca8dab..cd5194b 100644 --- a/Makefile +++ b/Makefile @@ -81,12 +81,15 @@ SIDECAR_EXE := $(if $(findstring windows,$(RUST_TARGET)),.exe,) desktop-icons: cd $(DESKTOP_DIR) && npx --yes @tauri-apps/cli@2 icon ../web/public/icon.svg -o src-tauri/icons -# Build the sidecar binary (frontend embedded) named for the host target triple, -# which is what Tauri's externalBin resolver expects. -desktop-sidecar: generate build-web +# Build the sidecar binary for the desktop shell. The desktop app serves the +# page itself (Tauri's built-in frontend), so the sidecar is built with the +# `jcode_headless` tag: it omits the embedded SPA (no dist/ needed, smaller +# binary) and exposes only the REST + WebSocket API on a loopback port. The +# frontend is built separately and bundled by the Tauri dev/build targets. +desktop-sidecar: generate @echo "Building jcode sidecar for $(RUST_TARGET)..." @mkdir -p $(SIDECAR_DIR) - go build -ldflags "$(LDFLAGS)" -o $(SIDECAR_DIR)/jcode-$(RUST_TARGET)$(SIDECAR_EXE) $(PKG) + go build -tags jcode_headless -ldflags "$(LDFLAGS)" -o $(SIDECAR_DIR)/jcode-$(RUST_TARGET)$(SIDECAR_EXE) $(PKG) # Run the desktop app in development (hot window; rebuilds the sidecar first). desktop-dev: desktop-sidecar diff --git a/README.md b/README.md index d7a0a5d..77e9809 100644 --- a/README.md +++ b/README.md @@ -120,13 +120,13 @@ Save connections as named aliases and jump between hosts with `/ssh`: └─────────────────────────────────────────────────┘ ``` -### 📋 Modes — Ask · Plan · Autopilot +### 📋 Modes — Ask for approval · Plan · Full access Press **Shift+Tab** to cycle the session mode: -- **Ask** (default) — full tools, but you approve each non-trivial tool call. +- **Ask for approval** (default) — full tools, but you approve each non-trivial tool call. - **Plan** — the agent explores your codebase **read-only** and presents a structured plan before touching any file. Review, approve or reject with feedback — then it executes step by step. -- **Autopilot** — full tools, every call auto-approved, end-to-end with no interruptions. +- **Full access** — full tools, every call auto-approved, end-to-end with no interruptions. ``` Plan │ Model: openai / gpt-4o │ [██░░░░░░░░] 12% @@ -146,7 +146,7 @@ Connect any [MCP](https://modelcontextprotocol.io/)-compatible server — stdio, ``` ``` - Ask │ Model: openai / gpt-4o │ [████░░░░░░] 2% │ MCP: 2/5 + Ask for approval │ Model: openai / gpt-4o │ [████░░░░░░] 2% │ MCP: 2/5 ``` ### 💰 Token Usage & Budget Control @@ -245,7 +245,7 @@ No manual configuration needed — the agent adapts to your project. | ------------- | --------------------------------- | | **Enter** | Submit prompt / select option | | **Ctrl+C** | Press once to warn, twice to exit | -| **Shift+Tab** | Cycle mode (Ask → Plan → Autopilot) | +| **Shift+Tab** | Cycle mode (Ask for approval → Plan → Full access) | | **Ctrl+L** | Model picker | | **Ctrl+T** | Toggle team panel | | **Shift+↑/↓** | Switch between teammates | diff --git a/design/model-and-approval-redesign.html b/design/model-and-approval-redesign.html new file mode 100644 index 0000000..6cf03bb --- /dev/null +++ b/design/model-and-approval-redesign.html @@ -0,0 +1,1675 @@ + + + + + +jcode — Model & Approval Redesign + + + + + + + + +
+
+
+

Model & Approval — redesign

+

+ Four surfaces in jcode that read as utilitarian: the model + selector + its Manage dialog, the mode (autonomy) selector, and the + approval-request card. The goal is a single shared language — one + identity tile, one chip scale, one button ramp — so they stop looking + like four different features. +

+
+
+ + +
+
+ + +
+
+ 01 +

Model selector

+
+ +
+ +
+ + ↑ sits in the composer toolbar +
+ + +
+ + + +
+ Current + a +
+
Claude Sonnet 4.5
+
claude-sonnet-4-5 · 200K
+
+ + + +
+ +
+
Favorites 2
+ + + + + +
Anthropic
+ + + + + + + +
OpenAI
+ + + + + +
Google
+ + +
+ +
+ +
+
+
+ +

+ What changed. The provider gets a colored squircle identity tile + (the one "identity primitive" reused in the dialog and approval card). + Capability — reasoning / tools / images / no-images — is three mono-stroke + dots on the right, readable in one scan instead of buried in a tooltip. + The current model is pinned above a search field so a long list can't + scroll it out of view, and the favorite star only appears on hover or when + set — no ambient stars competing for attention. +

+
+ + +
+
+ 01b +

Mode selector — autonomy level

+
+ +
+ +
+ + + +
+ + +
+ + + + + +
+
+ +

+ What changed. Kept the flat three-item list, but each mode keeps a + one-line consequence so Full access can't be picked blind — terse + ("Act freely — no approval prompts"), not the wall of text from the first + pass. Full access alone gets the destructive tint, on both the + trigger and its row, so you can tell at a glance when the agent can act + without you. Icons swapped to fit the semantics: + HandRaisedIcon for ask-for-approval (a literal "stop and + check" gesture), ShieldExclamationIcon for full access (the + guarded-but-risky state). +

+
+ + +
+
+ 02 +

Manage models — dialog

+
+ +
+
+ +
+
+ +

+ What changed. Promoted from an ad-hoc teleported card to the + SettingsDialog anatomy: scrim, header with an icon tile + subtitle, filter + row, and a footer with a count summary + actions. Raw + <input type=checkbox> swapped for the project's + s-switch so the whole app has one toggle shape. Rows are the + selector's twin — same identity tile, same id-and-capabilities subline — + so manage and select feel like one feature viewed two ways. +

+
+ + +
+
+ 03 +

Approval request

+
+ +
+ + + + +
+ +
+
+
Add a unit test for the new ResolvePath helper.
+
I'll write the test, then run it.
+ + +
+
+
+
+ +
+
+
+ Edit file + + + Outside workspace + +
+
internal/tools/env_test.go · 3 edits · +42 −7
+
+
+ + + +
+
+
+
+ + +
+
+
+
+ +
+
+
+ Run command +
+
go test ./internal/tools/... -run TestResolvePath -count=1
+ +
{ "cwd": "/Users/jack/workpath/jjj/jcode", "timeout": 120 }
+
+
+ + + +
+
+
+
+ + +
+
+
+
+ +
+
+
+ Edit file +
+
internal/tools/env_test.go
+
+ + Click again to auto-approve the rest of this session +
+
+
+ + + +
+
+
+
+ + +
+
+ + edit + Allowed + · internal/tools/env_test.go +
+
+ + execute + Denied + · rm -rf node_modules +
+
+
+
+ +

+ What changed. The card leads with what — an identity tile + + the action verb ("Edit file", "Run command") — then the target in mono, + then the decision. The primary argument (file path / command) is shown by + default; only the raw JSON payload hides behind a quiet toggle. Three + buttons with a strict role ramp: a calm filled Allow once, an + outlined Allow all that arms to red on click, and a ghost + Deny that only leans red on hover — never the resting eye. Orange + stays out of it: this is accent-neutral territory, matching the rest of + the app's de-emphasis policy. +

+
+
+ + + + diff --git a/desktop/package.json b/desktop/package.json index 950e7d6..cad37f3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -8,7 +8,7 @@ "tauri": "tauri", "dev": "tauri dev", "build": "tauri build", - "icon": "tauri icon ../../web/public/icon.svg -o src-tauri/icons" + "icon": "tauri icon ../web/public/icon.svg -o src-tauri/icons" }, "devDependencies": { "@tauri-apps/cli": "^2.11.3" diff --git a/desktop/splash/index.html b/desktop/splash/index.html index ce8ed8a..f28c065 100644 --- a/desktop/splash/index.html +++ b/desktop/splash/index.html @@ -4,21 +4,48 @@ jcode + diff --git a/web/src/components/BranchPicker.vue b/web/src/components/BranchPicker.vue index 3bb90a8..815410a 100644 --- a/web/src/components/BranchPicker.vue +++ b/web/src/components/BranchPicker.vue @@ -268,7 +268,7 @@ function reset() { background: var(--color-muted); } .bp-row.active { - background: var(--accent-wash-soft); + background: var(--neutral-wash-soft); } .bp-row:disabled { opacity: 0.6; @@ -279,7 +279,7 @@ function reset() { flex-shrink: 0; } .bp-row.active .bp-row-icon { - color: var(--color-primary); + color: var(--color-accent-neutral); } .bp-row-name { flex: 1; @@ -289,7 +289,7 @@ function reset() { text-overflow: ellipsis; } .bp-check { - color: var(--color-primary); + color: var(--color-accent-neutral); flex-shrink: 0; } .bp-hint { @@ -358,7 +358,7 @@ function reset() { outline: none; } .bp-create-input:focus { - border-color: var(--color-primary); + border-color: var(--color-accent-neutral); } .bp-create-btn { flex-shrink: 0; @@ -366,8 +366,8 @@ function reset() { padding: 0 12px; border: none; border-radius: var(--radius-md); - background: var(--color-primary); - color: var(--color-on-primary); + background: var(--color-accent-neutral); + color: var(--color-surface); font-size: 12px; font-weight: 500; cursor: pointer; diff --git a/web/src/components/ChatInput.vue b/web/src/components/ChatInput.vue index 28eca33..2ff34a9 100644 --- a/web/src/components/ChatInput.vue +++ b/web/src/components/ChatInput.vue @@ -6,8 +6,8 @@ import { api } from '@/composables/api' import type { SlashCommandInfo, ChatImage } from '@/types/api' import WorkspacePicker from '@/components/WorkspacePicker.vue' import BranchPicker from '@/components/BranchPicker.vue' -import { useBranch } from '@/composables/useBranch' -import { ChatBubbleLeftIcon, ClipboardDocumentListIcon, BoltIcon, PlusIcon, PaperClipIcon, XMarkIcon, ChevronDownIcon, ChatBubbleLeftRightIcon, StopIcon, PaperAirplaneIcon } from '@heroicons/vue/24/outline' +import { HandRaisedIcon, ShieldExclamationIcon, ClipboardDocumentListIcon, BoltIcon, PlusIcon, PaperClipIcon, XMarkIcon, ChevronDownIcon, StopIcon, PaperAirplaneIcon, MagnifyingGlassIcon, SquaresPlusIcon, PhotoIcon, WrenchScrewdriverIcon, CheckIcon, StarIcon, SparklesIcon } from '@heroicons/vue/24/outline' +import { StarIcon as StarIconSolid, CheckCircleIcon } from '@heroicons/vue/24/solid' // Which way the workspace/branch pickers open. The docked composer opens them // upward (default); the centered welcome composer has more empty room below, so @@ -18,9 +18,6 @@ withDefaults(defineProps<{ pickerPlacement?: 'top' | 'bottom' }>(), { const store = useChatStore() const { t } = useI18n() -// Current git branch (singleton) — used to decide whether the composer's top row -// is worth showing once the workspace picker is hidden mid-conversation. -const { current: branchCurrent } = useBranch() const input = ref('') const textarea = ref(null) const showModelPicker = ref(false) @@ -41,9 +38,9 @@ const pendingImagePreviews = ref([]) const fileInput = ref(null) const modes = computed(() => [ - { value: 'ask' as const, label: t('chat.modes.ask'), icon: ChatBubbleLeftIcon }, - { value: 'plan' as const, label: t('chat.modes.plan'), icon: ClipboardDocumentListIcon }, - { value: 'autopilot' as const, label: t('chat.modes.autopilot'), icon: BoltIcon }, + { value: 'approval' as const, label: t('chat.modes.approval'), icon: HandRaisedIcon, sub: t('chat.modes.approvalSub'), risk: 'neutral' as const }, + { value: 'plan' as const, label: t('chat.modes.plan'), icon: ClipboardDocumentListIcon, sub: t('chat.modes.planSub'), risk: 'plan' as const }, + { value: 'full_access' as const, label: t('chat.modes.fullAccess'), icon: ShieldExclamationIcon, sub: t('chat.modes.fullAccessSub'), risk: 'danger' as const }, ]) const filteredSlashCommands = computed(() => { @@ -68,6 +65,15 @@ const filteredProviders = computed(() => { .filter(p => p.models.length > 0) }) +// The picker lists only models the user has enabled ("opened"). Disabled models +// stay hidden here but remain visible in the Manage dialog (which uses +// filteredProviders directly) so they can be re-enabled. +const pickerProviders = computed(() => + filteredProviders.value + .map(p => ({ ...p, models: p.models.filter(m => m.enabled !== false) })) + .filter(p => p.models.length > 0), +) + // Get full display name for a model (e.g., "DeepSeek V4 Pro") function getModelDisplayName(providerId: string, modelId: string): string { for (const p of store.providers) { @@ -79,6 +85,111 @@ function getModelDisplayName(providerId: string, modelId: string): string { return modelId } +// Provider identity tile — a tinted squircle with the provider's initial. The +// single "identity primitive" reused across the selector, the Manage dialog, +// and (conceptually) the approval card. Color is keyed off the provider id so +// it's stable without a server-provided brand asset. +const PROVIDER_COLORS: Record = { + anthropic: '#D97757', + openai: '#10A37F', + google: '#4285F4', + deepseek: '#4D6BFE', + moonshot: '#1A1A1A', + zhipu: '#3B5BFE', +} +// Distinct fallbacks for providers without an explicit brand color (e.g. the +// "ZHIPU AI Coding Plan" id, third-party gateways). All are saturated enough +// for white initials and deliberately exclude washed/gray tones so the tile +// never disappears into the surface. Keyed by a stable hash of the id so the +// same provider always gets the same color. +const PROVIDER_FALLBACK_COLORS = [ + '#7C5CFC', // violet + '#E0567A', // rose + '#0EA5A4', // teal + '#E0922F', // amber + '#2E7D5B', // green + '#3D7DE0', // azure + '#C2410C', // burnt orange + '#6366F1', // indigo +] +function providerColor(id: string): string { + const explicit = PROVIDER_COLORS[id] + if (explicit) return explicit + let h = 0 + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0 + return PROVIDER_FALLBACK_COLORS[h % PROVIDER_FALLBACK_COLORS.length]! +} +function providerInitial(name: string): string { + const n = (name || '?').trim() + // Latin initial; for CJK names fall back to the first code point. + return /[A-Za-z]/.test(n) ? n[0]!.toLowerCase() : n[0] ?? '?' +} + +// Compact context-limit label for the subline: 200000 → "200K", 1000000 → "1M". +function formatContext(limit?: number): string | null { + if (!limit || limit <= 0) return null + if (limit >= 1_000_000) { + const m = limit / 1_000_000 + return (Number.isInteger(m) ? m : m.toFixed(1)) + 'M' + } + if (limit >= 1000) return Math.round(limit / 1000) + 'K' + return String(limit) +} + +// Subline under a model row: "claude-sonnet-4-5 · 200K". +function modelSubline(providerId: string, m: { id: string; context_limit?: number } | undefined): string { + if (!m) return '' + const parts = [m.id] + const ctx = formatContext(m.context_limit) + if (ctx) parts.push(ctx) + return parts.join(' · ') +} + +// Resolve a provider's display name from its id (the dropdown rows carry the +// name, but recent/favorite refs only have the id). +function providerDisplayName(id: string): string { + return store.providers.find((p) => p.id === id)?.name ?? id +} + +// Look up the ModelInfo for a provider+model pair (used by recent/favorite refs +// which only carry ids). +function modelInfoFor(provider: string, model: string) { + const p = store.providers.find((x) => x.id === provider) + return p?.models.find((m) => m.id === model) +} + +// The ModelInfo for the currently-active model — drives the pinned row subline +// + capability dots. +const currentModelInfo = computed(() => modelInfoFor(store.providerName, store.modelName)) + +// Favorite recent models (recent keeps the recency order), filtered by the +// search box and excluding the current model (it's pinned above). +const favoriteModelRefs = computed(() => { + const q = modelFilter.value.trim().toLowerCase() + return store.recentModels.filter((r) => { + if (!store.favoriteModels.has(`${r.provider}/${r.model}`)) return false + if (store.providerName === r.provider && store.modelName === r.model) return false + if (!q) return true + const name = getModelDisplayName(r.provider, r.model).toLowerCase() + return name.includes(q) || r.provider.toLowerCase().includes(q) + }) +}) + +// Enter in the filter selects the first visible model. +function selectFirstFiltered() { + const p = pickerProviders.value[0] + const m = p?.models[0] + if (p && m) selectModel(p.id, m.id) +} + +// Counts for the Manage dialog footer: visible (after filter) vs total models. +const manageVisibleCount = computed(() => + filteredProviders.value.reduce((n, p) => n + p.models.length, 0), +) +const manageTotalCount = computed(() => + store.providers.reduce((n, p) => n + p.models.length, 0), +) + function autoResize() { const el = textarea.value if (!el) return @@ -186,15 +297,23 @@ function applySlashCommand(cmd: SlashCommandInfo) { async function send() { const text = input.value.trim() - if ((!text && pendingImages.value.length === 0) || store.isRunning) return + if (!text && pendingImages.value.length === 0) return const images = pendingImages.value.length > 0 ? [...pendingImages.value] : undefined + // Capture the running state before clearing the box: while a turn is in + // flight we queue the message (terminal-style type-ahead) instead of sending + // it now; it goes out automatically when the current turn completes. + const queue = store.isRunning input.value = '' pendingImages.value = [] pendingImagePreviews.value = [] showSlashMenu.value = false await nextTick() autoResize() - store.sendMessage(text || '(see attached images)', images) + if (queue) { + store.enqueueMessage(text || '(see attached images)', images) + } else { + store.sendMessage(text || '(see attached images)', images) + } } function selectModel(provider: string, model: string) { @@ -250,15 +369,18 @@ function removeImage(index: number) { pendingImagePreviews.value.splice(index, 1) } -function selectMode(mode: 'ask' | 'plan' | 'autopilot') { +function selectMode(mode: 'approval' | 'plan' | 'full_access') { showModePicker.value = false store.switchMode(mode) } function modeLabel(m: string): string { - return m === 'plan' ? t('chat.modes.plan') : m === 'autopilot' ? t('chat.modes.autopilot') : t('chat.modes.ask') + return m === 'plan' ? t('chat.modes.plan') : m === 'full_access' ? t('chat.modes.fullAccess') : t('chat.modes.approval') } +// The icon shown on the mode trigger reflects the active mode. +const currentModeIcon = computed(() => modes.value.find((m) => m.value === store.mode)?.icon ?? HandRaisedIcon) + function handleClickOutside(e: MouseEvent) { if (containerRef.value && !containerRef.value.contains(e.target as Node)) { showModelPicker.value = false @@ -337,6 +459,25 @@ watch(() => store.imageSupport, (supported) => {