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 @@
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
Manage models
+
Choose which models appear in the selector. Disabled models stay configured but hidden.
+
+
+
+
+
+
+
+
+
+ 12 / 14
+
+
+
+
+
+ a
+ Anthropic
+ anthropic
+ 4
+
+
+ a
+
+ Claude Opus 4.1
+
claude-opus-4-1 · 200K · reasoning
+
+ Recommended
+
+
+
+ a
+
+ Claude Sonnet 4.5
+
claude-sonnet-4-5 · 200K · reasoning
+
+
+
+
+ a
+
+ Claude Haiku 4
+
claude-haiku-4 · 200K
+
+
+
+
+ a
+
+ Claude Sonnet 4
+
claude-sonnet-4 · 200K
+
+
+
+
+
+
+ o
+ OpenAI
+ openai
+ 3
+
+
+ o
+
+ GPT-5 Pro
+
gpt-5-pro · 400K · reasoning
+
+
+
+
+ o
+
+ GPT-5 Mini
+
gpt-5-mini · 128K
+
+
+
+
+ o
+
+ o4-mini
+
o4-mini · 128K · reasoning
+
+
+
+
+
+
+ g
+ Google
+ google
+ 2
+
+
+ g
+
+ Gemini 2.5 Pro
+
gemini-2.5-pro · 1M · reasoning
+
+
+
+
+ g
+
+ Gemini 2.5 Flash
+
gemini-2.5-flash · 1M
+
+
+
+
+
+
+ 9 of 14 models visible in the selector
+
+
+
+
+
+
+
+
+
+
+ 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
+ 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) => {