From b6efac372e6484ee150808387aee43d4662b8f8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 11:06:20 +0000 Subject: [PATCH 1/2] Unify agent sessions and redesign composer picker Co-authored-by: Gao Yu --- src/lib/ChatPane.svelte | 109 ++---- src/lib/CommandPalette.svelte | 17 +- src/lib/Composer.svelte | 456 ++++++++-------------- src/lib/Sidebar.svelte | 4 +- src/lib/composer/AgentModelPopover.svelte | 389 ++++++++++++++++++ src/lib/composer/modelRows.test.ts | 95 +++++ src/lib/composer/modelRows.ts | 108 +++++ src/lib/i18n/messages/chat.ts | 14 +- src/lib/i18n/messages/shell.ts | 26 +- src/lib/ui/EffortSlider.svelte | 300 -------------- src/routes/+page.svelte | 14 +- 11 files changed, 814 insertions(+), 718 deletions(-) create mode 100644 src/lib/composer/AgentModelPopover.svelte create mode 100644 src/lib/composer/modelRows.test.ts create mode 100644 src/lib/composer/modelRows.ts delete mode 100644 src/lib/ui/EffortSlider.svelte diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index 13c980b..02d362d 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -32,10 +32,12 @@ import { processVideo, claudeSessions, + git, gitCheckpointCapture, gitCheckpointRestore, type Op } from '$lib/protocol'; + import { buildModelRows } from '$lib/composer/modelRows'; import { dispatch } from '$lib/backends/router'; import { browser } from '$lib/browser.svelte'; import { prefs } from '$lib/prefs.svelte'; @@ -171,19 +173,20 @@ // yet (an optimistic push counts) and not a resumed conversation. const backendLocked = $derived(!!session.restored || chat.userTurns > 0); - // Effort switch is debounced: reflect the pick immediately on the slider - // (optimistic chat.effort) so the handle stays put, but only send the actual - // `/model` command once the user settles — rapid drags/clicks don't race a - // half-dozen switches through the engine. - let effortTimer: ReturnType | undefined; - function chooseEffort(ef: string) { - chat.effort = ef; - const model = chat.model; - clearTimeout(effortTimer); - effortTimer = setTimeout(() => { - send({ op: 'command', input: `/model ${model} ${ef}` }); - }, 350); - } + // Current git branch for the composer's footer strip, refetched when the + // working directory changes. A detached HEAD reads "detached"; a failed + // probe (not a git repo) hides the chip. + let gitBranch = $state(''); + $effect(() => { + const cwd = project?.path || chat.cwd; + gitBranch = ''; + if (!cwd) return; + git(['branch', '--show-current'], cwd) + .then((out) => { + if (cwd === (project?.path || chat.cwd)) gitBranch = out.trim() || 'detached'; + }) + .catch(() => {}); + }); // Open the model picker as a popover. If we already have a cached catalog, // show it instantly and refresh in the background; otherwise fetch first. @@ -237,7 +240,6 @@ findIdx = 0; }); - const fmtTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`); const isImage = (p: string) => /\.(png|jpe?g|gif|webp|bmp)$/i.test(p); const base = (p: string) => p.replace(/\/+$/, '').split('/').pop() || p; // Engine subagent lifecycle status → localized label (falls back to the raw value). @@ -277,62 +279,24 @@ return p.items.map((it) => ({ id: it.id, label: it.label, detail: it.detail, active: it.active, command: `/resume ${it.id}`, depth: nil })); if (p.kind === 'checkpoint') return p.items.map((it) => ({ id: it.id, label: it.label, detail: it.detail, active: it.active, command: `/rewind ${it.id}`, depth: nil })); - // Model picker. The active provider's rows come from the engine's model_view - // (already filtered — e.g. jucode hides unsupported models — and flagged with - // the active one); other providers come from the client-side config list so - // you can switch to any of them. Same-provider picks use /model (instant); - // cross-provider picks switch via @switch (config rewrite + engine restart). - const cur = chat.provider ?? ''; - // Mirror the engine's jucode allow-list so we don't offer a model it rejects. - const jucodeOk = (n: string) => - ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'].includes(n) || n.startsWith('claude-'); - const groups = { - codex: t('shell.modelGroup.codex'), - claude: t('shell.modelGroup.claude'), - jucode: t('shell.modelGroup.jucode'), - byok: t('shell.modelGroup.byok') - }; - const activeGroup = - chat.backendId === 'codex' - ? groups.codex - : chat.backendId === 'claude' - ? groups.claude - : cur === 'jucode' - ? groups.jucode - : groups.byok; - const activeRows = p.models.map((m) => ({ - id: `${cur}::${m.model}`, - label: m.label || m.model, - vendor: m.vendor || m.model, - detail: m.context_window ? `${cur} · ${fmtTokens(m.context_window)}` : cur, - active: m.active, - command: `/model ${m.model}`, - depth: nil, - group: activeGroup - })); - // Provider switching rewrites the native engine's global config and - // restarts it — meaningful for jucode sessions only. Other backends' - // pickers list just their own engine's model_view catalog. - const otherRows = (chat.backendId !== 'jucode' ? [] : providersList) - .filter((pv) => pv.id !== cur) - .flatMap((pv) => - pv.models - .filter((m) => pv.id !== 'jucode' || jucodeOk(m.name)) - .map((m) => ({ - id: `${pv.id}::${m.name}`, - label: m.name, - vendor: m.name, - detail: `${pv.id}${providers.includes(pv.id) ? '' : ` · ${t('shell.notConfigured')}`} · ${fmtTokens(m.context_window ?? 0)}`, - active: false, - command: `@switch ${pv.id} ${m.name}`, - depth: nil, - group: pv.id === 'jucode' ? groups.jucode : groups.byok - })) - ); - const groupOrder = [groups.codex, groups.claude, groups.jucode, groups.byok]; - return [...activeRows, ...otherRows].sort( - (a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group) - ); + // Model picker rows (pure packing in $lib/composer/modelRows): the active + // provider's models from the engine's model_view plus, for jucode + // sessions, every other configured provider's catalog — each row carrying + // its model's reasoning-effort options for the popover's hover chips. + return buildModelRows({ + models: p.models, + backendId: chat.backendId, + provider: chat.provider ?? '', + providersList, + configured: providers, + groups: { + codex: t('shell.modelGroup.codex'), + claude: t('shell.modelGroup.claude'), + jucode: t('shell.modelGroup.jucode'), + byok: t('shell.modelGroup.byok') + }, + notConfigured: t('shell.notConfigured') + }); }); // Whether to offer a filter box (history and other long lists). @@ -696,7 +660,6 @@ }); onDestroy(() => { if (findDebounce != null) clearTimeout(findDebounce); - clearTimeout(effortTimer); // Moving the tile to another leaf remounts the pane — stash the draft so // the composer text survives the drag (pendingFill restores it). if (input.trim()) chat.pendingFill = input; @@ -789,17 +752,15 @@ onPick={pickFiles} onModel={openModelPicker} onModelSelect={selectRow} - onModelEffort={setEffort} onModelClose={() => chat.closePicker()} modelRows={filteredRows} - modelActive={activeModel} modelTitle={pickerTitle} modelSearch={showPickerSearch} {backendLocked} + {gitBranch} onBackend={(b, acpAgent) => store.switchBackend(session.id, b, acpAgent)} bind:pickerQuery bind:pickerSelIdx={selIdx} - onEffort={chooseEffort} onApproval={setApprovalMode} /> diff --git a/src/lib/CommandPalette.svelte b/src/lib/CommandPalette.svelte index 9178dd2..bfd6353 100644 --- a/src/lib/CommandPalette.svelte +++ b/src/lib/CommandPalette.svelte @@ -3,10 +3,10 @@ import { Search, Plus, FolderPlus, Cpu, RotateCcw, History, Layers, Gauge, Activity, Stethoscope, GitBranch, GitBranchPlus, Store, Settings as SettingsIcon, - PanelLeft, LayoutGrid, SunMoon, ChevronRight, Wrench, SquareTerminal + PanelLeft, LayoutGrid, SunMoon, ChevronRight, Wrench } from 'lucide-svelte'; import type { ChatState } from '$lib/chat.svelte'; - import { caps, BACKEND_IDS, BACKEND_LABELS, type BackendCaps, type BackendId } from '$lib/backends'; + import { caps, type BackendCaps } from '$lib/backends'; import { focusTrap } from '$lib/focusTrap'; import { t } from '$lib/i18n'; @@ -25,8 +25,7 @@ onOpenPanel, onToggleSidebar, onToggleTheme, - onSetup, - onOpenTui + onSetup }: { chat: ChatState | undefined; hasProject: boolean; @@ -46,8 +45,6 @@ onToggleSidebar: () => void; onToggleTheme: () => void; onSetup: () => void; - /** Open a native TUI tile (real interactive CLI in a pty) for a backend. */ - onOpenTui: (backend: BackendId) => void; } = $props(); type Action = { @@ -100,14 +97,6 @@ keywords: `${t('shell.cmd.openPanelKw')} ${p.key} ${p.label}`, run: wrap(() => onOpenPanel(p.key)) })), - ...BACKEND_IDS.map((b): Action => ({ - id: `tui-${b}`, - label: t('shell.cmd.openTui', { name: BACKEND_LABELS[b] }), - hint: t('shell.cmd.openTuiHint'), - icon: SquareTerminal, - keywords: `${t('shell.cmd.openTuiKw')} ${b}`, - run: wrap(() => onOpenTui(b)) - })), { id: 'settings', label: t('shell.cmd.settings'), keys: '⌘,', icon: SettingsIcon, keywords: t('shell.cmd.settingsKw'), run: wrap(onSettings) }, { id: 'setup', label: t('shell.cmd.setup'), hint: t('shell.cmd.setupHint'), icon: Wrench, keywords: t('shell.cmd.setupKw'), run: wrap(onSetup) }, { id: 'sidebar', label: t('shell.cmd.sidebar'), keys: '⌘B', icon: PanelLeft, keywords: t('shell.cmd.sidebarKw'), run: wrap(onToggleSidebar) }, diff --git a/src/lib/Composer.svelte b/src/lib/Composer.svelte index e025b23..bfb4318 100644 --- a/src/lib/Composer.svelte +++ b/src/lib/Composer.svelte @@ -1,12 +1,10 @@ + + + + + diff --git a/src/lib/composer/modelRows.test.ts b/src/lib/composer/modelRows.test.ts new file mode 100644 index 0000000..e996737 --- /dev/null +++ b/src/lib/composer/modelRows.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { buildModelRows } from './modelRows'; + +const groups = { codex: 'Codex', claude: 'Claude', jucode: 'JuCode', byok: 'BYOK' }; + +const base = { + provider: 'jucode', + providersList: [], + configured: ['jucode'], + groups, + notConfigured: 'not configured' +}; + +describe('buildModelRows', () => { + it('packs engine rows with /model commands, active flag and efforts', () => { + const rows = buildModelRows({ + ...base, + backendId: 'jucode', + models: [ + { model: 'gpt-5.5', active: true, context_window: 200_000, reasoning_efforts: ['low', 'high'] }, + { model: 'claude-x', active: false } + ] + }); + expect(rows.map((r) => r.command)).toEqual(['/model gpt-5.5', '/model claude-x']); + expect(rows[0]).toMatchObject({ + active: true, + group: 'JuCode', + detail: 'jucode · 200.0k', + efforts: ['low', 'high'] + }); + expect(rows[1].efforts).toEqual([]); + }); + + it('appends other providers as @switch rows carrying catalog efforts (jucode only)', () => { + const providersList = [ + { id: 'jucode', models: [{ name: 'gpt-5.5', context_window: 1000, reasoning_efforts: ['medium'] }] }, + { id: 'byo', models: [{ name: 'my-model', reasoning_efforts: ['low'] }] } + ]; + const rows = buildModelRows({ + ...base, + backendId: 'jucode', + provider: 'byo2', + configured: ['byo2'], + models: [{ model: 'active-model', active: true }], + providersList + }); + const byo = rows.find((r) => r.id === 'byo::my-model'); + expect(byo).toMatchObject({ + command: '@switch byo my-model', + efforts: ['low'], + detail: 'byo · not configured · 0' + }); + expect(rows.find((r) => r.id === 'jucode::gpt-5.5')?.command).toBe('@switch jucode gpt-5.5'); + + // Non-jucode backends never list cross-provider rows. + const codexRows = buildModelRows({ + ...base, + backendId: 'codex', + models: [{ model: 'gpt-5.3-codex', active: true }], + providersList + }); + expect(codexRows).toHaveLength(1); + expect(codexRows[0].group).toBe('Codex'); + }); + + it('filters jucode catalog entries through the engine allow-list', () => { + const rows = buildModelRows({ + ...base, + backendId: 'jucode', + provider: 'byo', + configured: [], + models: [], + providersList: [ + { + id: 'jucode', + models: [{ name: 'gpt-5.5' }, { name: 'claude-sonnet' }, { name: 'unsupported-model' }] + } + ] + }); + expect(rows.map((r) => r.label)).toEqual(['gpt-5.5', 'claude-sonnet']); + }); + + it('sorts rows into the fixed group order', () => { + const rows = buildModelRows({ + ...base, + backendId: 'jucode', + provider: 'custom', + configured: ['custom'], + models: [{ model: 'byok-model', active: true }], + providersList: [{ id: 'jucode', models: [{ name: 'gpt-5.5' }] }] + }); + // JuCode built-in group comes before Custom/BYOK. + expect(rows.map((r) => r.group)).toEqual(['JuCode', 'BYOK']); + }); +}); diff --git a/src/lib/composer/modelRows.ts b/src/lib/composer/modelRows.ts new file mode 100644 index 0000000..09671e2 --- /dev/null +++ b/src/lib/composer/modelRows.ts @@ -0,0 +1,108 @@ +// Pure packing of the in-chat model picker rows: the current engine's +// model_view catalog plus (for jucode sessions) every other configured +// provider's models, grouped for display. Framework-free so the row shape — +// including each model's reasoning-effort options — stays unit-testable. + +export interface ModelRow { + id: string; + label: string; + vendor?: string; + detail: string; + active: boolean; + command: string; + depth: number | undefined; + group?: string; + /** Reasoning-effort options for this model (empty = none). */ + efforts?: string[]; +} + +export interface EngineModel { + model: string; + label?: string; + vendor?: string; + active: boolean; + context_window?: number; + reasoning_efforts?: string[]; +} + +export interface CatalogProvider { + id: string; + models: { name: string; context_window?: number; reasoning_efforts?: string[] }[]; +} + +export interface ModelGroupLabels { + codex: string; + claude: string; + jucode: string; + byok: string; +} + +const fmtTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`); + +// Mirror the engine's jucode allow-list so we don't offer a model it rejects. +const jucodeOk = (n: string) => + ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'].includes(n) || + n.startsWith('claude-'); + +/** + * The active provider's rows come from the engine's model_view (already + * filtered and flagged with the active model); other providers come from the + * client-side catalog so a jucode session can switch to any of them. + * Same-provider picks use /model (instant); cross-provider picks switch via + * @switch (config rewrite + engine restart). + */ +export function buildModelRows(input: { + models: EngineModel[]; + backendId: string; + provider: string; + providersList: CatalogProvider[]; + /** Provider ids with configured auth (others get a "not configured" hint). */ + configured: string[]; + groups: ModelGroupLabels; + notConfigured: string; +}): ModelRow[] { + const { models, backendId, provider: cur, providersList, configured, groups, notConfigured } = input; + const activeGroup = + backendId === 'codex' + ? groups.codex + : backendId === 'claude' + ? groups.claude + : cur === 'jucode' + ? groups.jucode + : groups.byok; + const activeRows: ModelRow[] = models.map((m) => ({ + id: `${cur}::${m.model}`, + label: m.label || m.model, + vendor: m.vendor || m.model, + detail: m.context_window ? `${cur} · ${fmtTokens(m.context_window)}` : cur, + active: m.active, + command: `/model ${m.model}`, + depth: undefined, + group: activeGroup, + efforts: m.reasoning_efforts ?? [] + })); + // Provider switching rewrites the native engine's global config and + // restarts it — meaningful for jucode sessions only. Other backends' + // pickers list just their own engine's model_view catalog. + const otherRows: ModelRow[] = (backendId !== 'jucode' ? [] : providersList) + .filter((pv) => pv.id !== cur) + .flatMap((pv) => + pv.models + .filter((m) => pv.id !== 'jucode' || jucodeOk(m.name)) + .map((m) => ({ + id: `${pv.id}::${m.name}`, + label: m.name, + vendor: m.name, + detail: `${pv.id}${configured.includes(pv.id) ? '' : ` · ${notConfigured}`} · ${fmtTokens(m.context_window ?? 0)}`, + active: false, + command: `@switch ${pv.id} ${m.name}`, + depth: undefined, + group: pv.id === 'jucode' ? groups.jucode : groups.byok, + efforts: m.reasoning_efforts ?? [] + })) + ); + const order = [groups.codex, groups.claude, groups.jucode, groups.byok]; + return [...activeRows, ...otherRows].sort( + (a, b) => order.indexOf(a.group ?? '') - order.indexOf(b.group ?? '') + ); +} diff --git a/src/lib/i18n/messages/chat.ts b/src/lib/i18n/messages/chat.ts index 9e629da..682e1b7 100644 --- a/src/lib/i18n/messages/chat.ts +++ b/src/lib/i18n/messages/chat.ts @@ -20,7 +20,6 @@ const chat = { steerAction: '插队执行', composerPlaceholder: '给 JuCode 指派一个任务… (拖入/粘贴图片 · 回形针附加文件 · / 唤起命令)', attachTitle: '附加文件', - more: '更多选项', voiceTitle: '语音输入', voiceStopTitle: '停止录音并转写', voiceBusyTitle: '转写中…', @@ -28,14 +27,12 @@ const chat = { videoMeta: '{s} 秒 · {n} 帧', webRefTitle: '网页元素引用', switchModel: '切换模型', - switchBackend: '切换后端', - backendLocked: '已发送首条消息,后端已固定', + switchBackend: '切换编程智能体', acpAgents: 'ACP 智能体', effortTitle: '思考强度', - effortFaster: '更快', - effortSmarter: '更聪明', statusTitle: '状态', approvalModeTitle: '工具审批模式', + gitBranch: '当前 git 分支', context: '上下文', toCompaction: '{pct}% · 到压缩点', contextUsed: '{pct}% · 上下文占用', @@ -81,7 +78,6 @@ const chat = { steerAction: 'Run now', composerPlaceholder: 'Assign JuCode a task… (drop/paste images · paperclip to attach files · / for commands)', attachTitle: 'Attach files', - more: 'More options', voiceTitle: 'Voice input', voiceStopTitle: 'Stop recording and transcribe', voiceBusyTitle: 'Transcribing…', @@ -89,14 +85,12 @@ const chat = { videoMeta: '{s}s · {n} frames', webRefTitle: 'Web element reference', switchModel: 'Switch model', - switchBackend: 'Switch backend', - backendLocked: 'Backend is locked after the first message', + switchBackend: 'Switch coding agent', acpAgents: 'ACP agents', effortTitle: 'Thinking effort', - effortFaster: 'Faster', - effortSmarter: 'Smarter', statusTitle: 'Status', approvalModeTitle: 'Tool approval mode', + gitBranch: 'Current git branch', context: 'Context', toCompaction: '{pct}% · to compaction', contextUsed: '{pct}% · context used', diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index 8417c8c..2ea2ea7 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -151,7 +151,9 @@ const shell = { archive: '归档对话', unarchive: '取消归档', archived: '已归档', - newSession: '新建对话', + // The one-and-only creatable session type (mosaic +, sidebar, palette): + // the coding agent is picked inside the session, not at creation. + agentSession: '创建 Agent 会话', newTask: '新建并行任务', task: { dialogTitle: '新建并行任务', @@ -183,8 +185,8 @@ const shell = { paletteEmpty: '没有匹配的命令', paletteFoot: '↑↓ 选择 · Enter 执行 · Esc 关闭', cmd: { - newSession: '新建对话', - newSessionKw: 'new session 对话', + newSession: '创建 Agent 会话', + newSessionKw: 'new agent session 对话 会话', newProject: '新建项目', newProjectKw: 'new project 项目 目录', newTask: '新建并行任务', @@ -220,10 +222,7 @@ const shell = { sidebar: '切换会话列表', sidebarKw: 'sidebar sessions navigator 侧边栏 会话 列表', theme: '切换主题', - themeKw: 'theme dark light 主题', - openTui: '打开 TUI:{name}', - openTuiHint: '在画布上运行交互式命令行(独立会话)', - openTuiKw: 'tui terminal cli 终端 命令行' + themeKw: 'theme dark light 主题' }, // session runtime (session.svelte.ts) @@ -404,7 +403,9 @@ const shell = { archive: 'Archive thread', unarchive: 'Unarchive', archived: 'Archived', - newSession: 'New conversation', + // The one-and-only creatable session type (mosaic +, sidebar, palette): + // the coding agent is picked inside the session, not at creation. + agentSession: 'New agent session', newTask: 'New parallel task', task: { dialogTitle: 'New parallel task', @@ -436,8 +437,8 @@ const shell = { paletteEmpty: 'No matching commands', paletteFoot: '↑↓ Navigate · Enter Run · Esc Close', cmd: { - newSession: 'New conversation', - newSessionKw: 'new session', + newSession: 'New agent session', + newSessionKw: 'new agent session conversation', newProject: 'New project', newProjectKw: 'new project directory', newTask: 'New parallel task', @@ -473,10 +474,7 @@ const shell = { sidebar: 'Toggle session list', sidebarKw: 'sidebar sessions navigator', theme: 'Toggle theme', - themeKw: 'theme dark light', - openTui: 'Open TUI: {name}', - openTuiHint: 'Run the interactive CLI on the canvas (own session)', - openTuiKw: 'tui terminal cli' + themeKw: 'theme dark light' }, // session runtime (session.svelte.ts) diff --git a/src/lib/ui/EffortSlider.svelte b/src/lib/ui/EffortSlider.svelte deleted file mode 100644 index 5a2acf6..0000000 --- a/src/lib/ui/EffortSlider.svelte +++ /dev/null @@ -1,300 +0,0 @@ - - -
-
- {t('chat.effortFaster')} - {currentLabel} - {t('chat.effortSmarter')} -
-
-
-
-
-
- {#each options as opt, i (opt)} - - {/each} -
-
-
-
- - diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f34827a..d45c982 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -20,7 +20,7 @@ type EventPayload } from '$lib/protocol'; import { dispatch } from '$lib/backends/router'; - import { caps, BACKEND_IDS, type BackendId } from '$lib/backends'; + import { caps } from '$lib/backends'; import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { updater } from '$lib/updater.svelte'; import { browser, type WebRef } from '$lib/browser.svelte'; @@ -47,7 +47,7 @@ openChatTab, reconcileLayout } from '$lib/workbench/canvas'; - import { tuiBackendOf, tuiPanelKind, tuiTabTitle } from '$lib/workbench/tuiTab'; + import { tuiBackendOf, tuiTabTitle } from '$lib/workbench/tuiTab'; import type { WorkspaceEntry } from '$lib/workbench/workspaces'; import type { TabIcon } from '$lib/workbench/tabChrome'; import Mosaic from '$lib/workbench/Mosaic.svelte'; @@ -199,10 +199,12 @@ return true; }) ); + // One agent-session type only: the coding agent is picked INSIDE the + // session (model popup), never at tab creation. Persisted `tui:*` tabs + // still render, but TUI tabs are no longer offered as new options. const addOptions = $derived([ - { key: 'chat', label: t('shell.newSession') }, - ...panelKeys.map((k) => ({ key: k, label: t(`dock.tabs.${k}`) })), - ...BACKEND_IDS.map((b) => ({ key: tuiPanelKind(b), label: tuiTabTitle(b) })) + { key: 'chat', label: t('shell.agentSession') }, + ...panelKeys.map((k) => ({ key: k, label: t(`dock.tabs.${k}`) })) ]); function tileLabel(tab: TileTab): string { @@ -295,7 +297,6 @@ if (existing) applyTiles(activateTab(tiles, existing.id)); else applyTiles(openTab(tiles, focusedLeaf, { id: newTabId(), panel: kind })); } - const openTui = (backend: BackendId) => openPanelTile(tuiPanelKind(backend)); // The workbench-active session always has a chat tile: activating a session // (sidebar click, ⌘N, resume, deep link…) opens or focuses it. @@ -999,7 +1000,6 @@ onToggleSidebar={toggleSidebar} onToggleTheme={cycleTheme} onSetup={() => (showSetup = true)} - onOpenTui={openTui} /> {/if} From 62c209fe925e778850500876e307633eaa2ac998 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 11:16:25 +0000 Subject: [PATCH 2/2] Honor effort chips and hover on the model popover Co-authored-by: Gao Yu --- src/lib/ChatPane.svelte | 43 +++++++++++----- src/lib/Composer.svelte | 17 +++++++ src/lib/composer/AgentModelPopover.svelte | 60 +++++++++++++++++------ src/lib/session.svelte.ts | 7 ++- src/lib/session.test.ts | 34 ++++++++++++- 5 files changed, 132 insertions(+), 29 deletions(-) diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index 02d362d..3c9a003 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -120,6 +120,9 @@ // Picker filter (history / long lists) let pickerQuery = $state(''); let selIdx = $state(0); + // True once the user arrow-keys through the picker — the model popover only + // shows effort chips for a keyboard-focused row (not the default selIdx). + let pickerKeyNav = $state(false); // Ops flow through this session's backend adapter; an unsupported op // (non-jucode stub backends) surfaces as an inline system notice. @@ -173,19 +176,33 @@ // yet (an optimistic push counts) and not a resumed conversation. const backendLocked = $derived(!!session.restored || chat.userTurns > 0); - // Current git branch for the composer's footer strip, refetched when the - // working directory changes. A detached HEAD reads "detached"; a failed - // probe (not a git repo) hides the chip. + // Current git branch for the composer's footer strip. A detached HEAD reads + // "detached"; a failed probe (not a git repo) hides the chip. let gitBranch = $state(''); - $effect(() => { + function refreshGitBranch() { const cwd = project?.path || chat.cwd; - gitBranch = ''; - if (!cwd) return; + if (!cwd) { + gitBranch = ''; + return; + } git(['branch', '--show-current'], cwd) .then((out) => { if (cwd === (project?.path || chat.cwd)) gitBranch = out.trim() || 'detached'; }) .catch(() => {}); + } + // Refetched when the working directory changes (chip resets immediately)… + $effect(() => { + const cwd = project?.path || chat.cwd; + gitBranch = ''; + if (!cwd) return; + refreshGitBranch(); + }); + // …and refreshed in place on window focus + a slow poll, so a checkout made + // in GitPanel or an external terminal doesn't leave the footer stale. + $effect(() => { + const iv = setInterval(refreshGitBranch, 12_000); + return () => clearInterval(iv); }); // Open the model picker as a popover. If we already have a cached catalog, @@ -316,6 +333,7 @@ if (chat.picker) { const i = filteredRows.findIndex((r) => r.active); selIdx = i >= 0 ? i : 0; + pickerKeyNav = false; } }); $effect(() => { @@ -448,14 +466,12 @@ function selectRow(command: string) { // Cross-provider model pick: rewrite config + restart this session (resumes // the conversation) since the engine can't change provider at runtime. + // `@switch [effort]` — the effort chip appends its value. if (command.startsWith('@switch ')) { - const rest = command.slice('@switch '.length); - const sp = rest.indexOf(' '); - const pid = rest.slice(0, sp); - const name = rest.slice(sp + 1); + const [pid, name, effort] = command.slice('@switch '.length).split(/\s+/); const pv = providersList.find((x) => x.id === pid); chat.closePicker(); - if (pv) store.switchProvider(session.id, pv, name); + if (pv && name) store.switchProvider(session.id, pv, name, effort); return; } // Resuming a history item opens it in a fresh session so the current chat @@ -493,9 +509,11 @@ } else if (e.key === 'ArrowDown') { e.preventDefault(); selIdx = Math.min(selIdx + 1, filteredRows.length - 1); + pickerKeyNav = true; } else if (e.key === 'ArrowUp') { e.preventDefault(); selIdx = Math.max(selIdx - 1, 0); + pickerKeyNav = true; } else if (e.key === 'Enter') { e.preventDefault(); const r = filteredRows[selIdx]; @@ -666,7 +684,7 @@ }); - +
{#if Object.keys(chat.subagents).length} @@ -761,6 +779,7 @@ onBackend={(b, acpAgent) => store.switchBackend(session.id, b, acpAgent)} bind:pickerQuery bind:pickerSelIdx={selIdx} + bind:pickerKeyNav onApproval={setApprovalMode} />
diff --git a/src/lib/Composer.svelte b/src/lib/Composer.svelte index bfb4318..cc3c339 100644 --- a/src/lib/Composer.svelte +++ b/src/lib/Composer.svelte @@ -26,6 +26,7 @@ el = $bindable(), pickerQuery = $bindable(''), pickerSelIdx = $bindable(0), + pickerKeyNav = $bindable(false), modelRows = [], modelTitle = '', modelSearch = false, @@ -48,6 +49,8 @@ el: HTMLElement | null; pickerQuery?: string; pickerSelIdx?: number; + /** Arrow keys moved the picker selection (effort chips follow it then). */ + pickerKeyNav?: boolean; modelRows?: ModelRow[]; modelTitle?: string; modelSearch?: boolean; @@ -82,12 +85,23 @@ return; } modelOpen = true; + pickerKeyNav = false; if (bcaps.modelPicker) onModel(); } function closeModelPopover() { modelOpen = false; if (chat.picker?.kind === 'model') onModelClose?.(); } + // Escape closes the popover even when the session has no model picker view + // (ACP agents — `modelOpen` is ours, not chat.picker). Capture phase so the + // key never reaches the pane's window handler or the editor. + function onWindowKeyCapture(e: KeyboardEvent) { + if (e.key === 'Escape' && modelPopoverVisible) { + e.preventDefault(); + e.stopPropagation(); + closeModelPopover(); + } + } function selectFromPopover(command: string) { modelOpen = false; onModelSelect?.(command); @@ -461,6 +475,8 @@ + +
{#if slashMatches.length} (input = c.command + ' ')} onHover={(i) => (slashIdx = i)} /> @@ -525,6 +541,7 @@ {backendLocked} bind:query={pickerQuery} bind:selIdx={pickerSelIdx} + bind:keyNav={pickerKeyNav} onClose={closeModelPopover} onSelect={selectFromPopover} {onBackend} diff --git a/src/lib/composer/AgentModelPopover.svelte b/src/lib/composer/AgentModelPopover.svelte index 71fa6ca..9d016f7 100644 --- a/src/lib/composer/AgentModelPopover.svelte +++ b/src/lib/composer/AgentModelPopover.svelte @@ -24,6 +24,7 @@ backendLocked = true, query = $bindable(''), selIdx = $bindable(0), + keyNav = $bindable(false), onClose, onSelect, onBackend, @@ -37,6 +38,8 @@ backendLocked?: boolean; query?: string; selIdx?: number; + /** The pane's arrow keys moved selIdx — chips follow the focused row. */ + keyNav?: boolean; onClose: () => void; onSelect: (command: string) => void; onBackend?: (b: BackendId, acpAgent?: { id: string; name: string }) => void | Promise; @@ -68,15 +71,28 @@ return p.version ? `${BACKEND_LABELS[id]} · ${p.version}` : BACKEND_LABELS[id]; }; + // An agent switch tears down and respawns the session's engine — a second + // rail click while one is in flight would race it, so gate on a local flag. + let switching = $state(false); async function pickNative(id: BackendId) { - if (id === chat.backendId) return; - await onBackend?.(id); - if (CAPS[id].modelPicker) onRefreshModels(); + if (switching || id === chat.backendId) return; + switching = true; + try { + await onBackend?.(id); + if (CAPS[id].modelPicker) onRefreshModels(); + } finally { + switching = false; + } } async function pickAcp(agent: AcpAgent) { - if (chat.backendId === 'acp' && chat.acpAgentId === agent.id) return; - // ACP agents expose no model catalog — nothing to refresh afterwards. - await onBackend?.('acp', { id: agent.id, name: agent.name }); + if (switching || (chat.backendId === 'acp' && chat.acpAgentId === agent.id)) return; + switching = true; + try { + // ACP agents expose no model catalog — nothing to refresh afterwards. + await onBackend?.('acp', { id: agent.id, name: agent.name }); + } finally { + switching = false; + } } // Effort highlighted on the active row (engine-reported, falling back to @@ -84,11 +100,21 @@ const activeEffort = $derived( chat.picker?.kind === 'model' ? chat.picker.activeEffort || chat.effort : chat.effort ); - // Effort chips only apply to same-engine rows (/model takes an effort - // argument); cross-provider @switch rows restart the engine, which picks - // its own default effort. - const chipEfforts = (row: ModelRow) => - row.command.startsWith('/model ') ? (row.efforts ?? []) : []; + // Every row with efforts gets chips: same-engine rows via `/model + // `, cross-provider rows via `@switch ` + // (the restart applies the picked effort instead of the provider default). + const chipEfforts = (row: ModelRow) => row.efforts ?? []; + + // Chips are hover-driven for the mouse (mouseenter on a row, cleared when + // the pointer leaves the list) and follow selIdx only after the user + // actually arrow-keyed — never on the default/active selection. + let hoverIdx = $state(null); + const chipIdx = $derived(hoverIdx ?? (keyNav ? selIdx : null)); + function hoverRow(i: number) { + hoverIdx = i; + selIdx = i; + keyNav = false; + } @@ -106,6 +132,7 @@ class="railbtn" class:on={chat.backendId === id} class:miss={p ? !p.found : false} + disabled={switching} onclick={() => pickNative(id)} title={railTitle(id)} aria-label={BACKEND_LABELS[id]} @@ -119,6 +146,7 @@
{/if} -
+