Skip to content

Commit 62c209f

Browse files
cursoragentgaoyu06
andcommitted
Honor effort chips and hover on the model popover
Co-authored-by: Gao Yu <gaoyu06@users.noreply.github.com>
1 parent b6efac3 commit 62c209f

5 files changed

Lines changed: 132 additions & 29 deletions

File tree

src/lib/ChatPane.svelte

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@
120120
// Picker filter (history / long lists)
121121
let pickerQuery = $state('');
122122
let selIdx = $state(0);
123+
// True once the user arrow-keys through the picker — the model popover only
124+
// shows effort chips for a keyboard-focused row (not the default selIdx).
125+
let pickerKeyNav = $state(false);
123126
124127
// Ops flow through this session's backend adapter; an unsupported op
125128
// (non-jucode stub backends) surfaces as an inline system notice.
@@ -173,19 +176,33 @@
173176
// yet (an optimistic push counts) and not a resumed conversation.
174177
const backendLocked = $derived(!!session.restored || chat.userTurns > 0);
175178
176-
// Current git branch for the composer's footer strip, refetched when the
177-
// working directory changes. A detached HEAD reads "detached"; a failed
178-
// probe (not a git repo) hides the chip.
179+
// Current git branch for the composer's footer strip. A detached HEAD reads
180+
// "detached"; a failed probe (not a git repo) hides the chip.
179181
let gitBranch = $state('');
180-
$effect(() => {
182+
function refreshGitBranch() {
181183
const cwd = project?.path || chat.cwd;
182-
gitBranch = '';
183-
if (!cwd) return;
184+
if (!cwd) {
185+
gitBranch = '';
186+
return;
187+
}
184188
git(['branch', '--show-current'], cwd)
185189
.then((out) => {
186190
if (cwd === (project?.path || chat.cwd)) gitBranch = out.trim() || 'detached';
187191
})
188192
.catch(() => {});
193+
}
194+
// Refetched when the working directory changes (chip resets immediately)…
195+
$effect(() => {
196+
const cwd = project?.path || chat.cwd;
197+
gitBranch = '';
198+
if (!cwd) return;
199+
refreshGitBranch();
200+
});
201+
// …and refreshed in place on window focus + a slow poll, so a checkout made
202+
// in GitPanel or an external terminal doesn't leave the footer stale.
203+
$effect(() => {
204+
const iv = setInterval(refreshGitBranch, 12_000);
205+
return () => clearInterval(iv);
189206
});
190207
191208
// Open the model picker as a popover. If we already have a cached catalog,
@@ -316,6 +333,7 @@
316333
if (chat.picker) {
317334
const i = filteredRows.findIndex((r) => r.active);
318335
selIdx = i >= 0 ? i : 0;
336+
pickerKeyNav = false;
319337
}
320338
});
321339
$effect(() => {
@@ -448,14 +466,12 @@
448466
function selectRow(command: string) {
449467
// Cross-provider model pick: rewrite config + restart this session (resumes
450468
// the conversation) since the engine can't change provider at runtime.
469+
// `@switch <provider> <model> [effort]` — the effort chip appends its value.
451470
if (command.startsWith('@switch ')) {
452-
const rest = command.slice('@switch '.length);
453-
const sp = rest.indexOf(' ');
454-
const pid = rest.slice(0, sp);
455-
const name = rest.slice(sp + 1);
471+
const [pid, name, effort] = command.slice('@switch '.length).split(/\s+/);
456472
const pv = providersList.find((x) => x.id === pid);
457473
chat.closePicker();
458-
if (pv) store.switchProvider(session.id, pv, name);
474+
if (pv && name) store.switchProvider(session.id, pv, name, effort);
459475
return;
460476
}
461477
// Resuming a history item opens it in a fresh session so the current chat
@@ -493,9 +509,11 @@
493509
} else if (e.key === 'ArrowDown') {
494510
e.preventDefault();
495511
selIdx = Math.min(selIdx + 1, filteredRows.length - 1);
512+
pickerKeyNav = true;
496513
} else if (e.key === 'ArrowUp') {
497514
e.preventDefault();
498515
selIdx = Math.max(selIdx - 1, 0);
516+
pickerKeyNav = true;
499517
} else if (e.key === 'Enter') {
500518
e.preventDefault();
501519
const r = filteredRows[selIdx];
@@ -666,7 +684,7 @@
666684
});
667685
</script>
668686

669-
<svelte:window onkeydown={onWindowKey} />
687+
<svelte:window onkeydown={onWindowKey} onfocus={refreshGitBranch} />
670688

671689
<div class="chatpane">
672690
{#if Object.keys(chat.subagents).length}
@@ -761,6 +779,7 @@
761779
onBackend={(b, acpAgent) => store.switchBackend(session.id, b, acpAgent)}
762780
bind:pickerQuery
763781
bind:pickerSelIdx={selIdx}
782+
bind:pickerKeyNav
764783
onApproval={setApprovalMode}
765784
/>
766785
</div>

src/lib/Composer.svelte

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
el = $bindable(),
2727
pickerQuery = $bindable(''),
2828
pickerSelIdx = $bindable(0),
29+
pickerKeyNav = $bindable(false),
2930
modelRows = [],
3031
modelTitle = '',
3132
modelSearch = false,
@@ -48,6 +49,8 @@
4849
el: HTMLElement | null;
4950
pickerQuery?: string;
5051
pickerSelIdx?: number;
52+
/** Arrow keys moved the picker selection (effort chips follow it then). */
53+
pickerKeyNav?: boolean;
5154
modelRows?: ModelRow[];
5255
modelTitle?: string;
5356
modelSearch?: boolean;
@@ -82,12 +85,23 @@
8285
return;
8386
}
8487
modelOpen = true;
88+
pickerKeyNav = false;
8589
if (bcaps.modelPicker) onModel();
8690
}
8791
function closeModelPopover() {
8892
modelOpen = false;
8993
if (chat.picker?.kind === 'model') onModelClose?.();
9094
}
95+
// Escape closes the popover even when the session has no model picker view
96+
// (ACP agents — `modelOpen` is ours, not chat.picker). Capture phase so the
97+
// key never reaches the pane's window handler or the editor.
98+
function onWindowKeyCapture(e: KeyboardEvent) {
99+
if (e.key === 'Escape' && modelPopoverVisible) {
100+
e.preventDefault();
101+
e.stopPropagation();
102+
closeModelPopover();
103+
}
104+
}
91105
function selectFromPopover(command: string) {
92106
modelOpen = false;
93107
onModelSelect?.(command);
@@ -461,6 +475,8 @@
461475
462476
</script>
463477

478+
<svelte:window onkeydowncapture={onWindowKeyCapture} />
479+
464480
<div class="composer-wrap">
465481
{#if slashMatches.length}
466482
<SlashMenu matches={slashMatches} selected={slashIdx} onSelect={(c) => (input = c.command + ' ')} onHover={(i) => (slashIdx = i)} />
@@ -525,6 +541,7 @@
525541
{backendLocked}
526542
bind:query={pickerQuery}
527543
bind:selIdx={pickerSelIdx}
544+
bind:keyNav={pickerKeyNav}
528545
onClose={closeModelPopover}
529546
onSelect={selectFromPopover}
530547
{onBackend}

src/lib/composer/AgentModelPopover.svelte

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
backendLocked = true,
2525
query = $bindable(''),
2626
selIdx = $bindable(0),
27+
keyNav = $bindable(false),
2728
onClose,
2829
onSelect,
2930
onBackend,
@@ -37,6 +38,8 @@
3738
backendLocked?: boolean;
3839
query?: string;
3940
selIdx?: number;
41+
/** The pane's arrow keys moved selIdx — chips follow the focused row. */
42+
keyNav?: boolean;
4043
onClose: () => void;
4144
onSelect: (command: string) => void;
4245
onBackend?: (b: BackendId, acpAgent?: { id: string; name: string }) => void | Promise<void>;
@@ -68,27 +71,50 @@
6871
return p.version ? `${BACKEND_LABELS[id]} · ${p.version}` : BACKEND_LABELS[id];
6972
};
7073
74+
// An agent switch tears down and respawns the session's engine — a second
75+
// rail click while one is in flight would race it, so gate on a local flag.
76+
let switching = $state(false);
7177
async function pickNative(id: BackendId) {
72-
if (id === chat.backendId) return;
73-
await onBackend?.(id);
74-
if (CAPS[id].modelPicker) onRefreshModels();
78+
if (switching || id === chat.backendId) return;
79+
switching = true;
80+
try {
81+
await onBackend?.(id);
82+
if (CAPS[id].modelPicker) onRefreshModels();
83+
} finally {
84+
switching = false;
85+
}
7586
}
7687
async function pickAcp(agent: AcpAgent) {
77-
if (chat.backendId === 'acp' && chat.acpAgentId === agent.id) return;
78-
// ACP agents expose no model catalog — nothing to refresh afterwards.
79-
await onBackend?.('acp', { id: agent.id, name: agent.name });
88+
if (switching || (chat.backendId === 'acp' && chat.acpAgentId === agent.id)) return;
89+
switching = true;
90+
try {
91+
// ACP agents expose no model catalog — nothing to refresh afterwards.
92+
await onBackend?.('acp', { id: agent.id, name: agent.name });
93+
} finally {
94+
switching = false;
95+
}
8096
}
8197
8298
// Effort highlighted on the active row (engine-reported, falling back to
8399
// the session's current effort).
84100
const activeEffort = $derived(
85101
chat.picker?.kind === 'model' ? chat.picker.activeEffort || chat.effort : chat.effort
86102
);
87-
// Effort chips only apply to same-engine rows (/model takes an effort
88-
// argument); cross-provider @switch rows restart the engine, which picks
89-
// its own default effort.
90-
const chipEfforts = (row: ModelRow) =>
91-
row.command.startsWith('/model ') ? (row.efforts ?? []) : [];
103+
// Every row with efforts gets chips: same-engine rows via `/model <name>
104+
// <effort>`, cross-provider rows via `@switch <provider> <model> <effort>`
105+
// (the restart applies the picked effort instead of the provider default).
106+
const chipEfforts = (row: ModelRow) => row.efforts ?? [];
107+
108+
// Chips are hover-driven for the mouse (mouseenter on a row, cleared when
109+
// the pointer leaves the list) and follow selIdx only after the user
110+
// actually arrow-keyed — never on the default/active selection.
111+
let hoverIdx = $state<number | null>(null);
112+
const chipIdx = $derived(hoverIdx ?? (keyNav ? selIdx : null));
113+
function hoverRow(i: number) {
114+
hoverIdx = i;
115+
selIdx = i;
116+
keyNav = false;
117+
}
92118
</script>
93119

94120
<button class="pop-backdrop" aria-label="close" onclick={onClose}></button>
@@ -106,6 +132,7 @@
106132
class="railbtn"
107133
class:on={chat.backendId === id}
108134
class:miss={p ? !p.found : false}
135+
disabled={switching}
109136
onclick={() => pickNative(id)}
110137
title={railTitle(id)}
111138
aria-label={BACKEND_LABELS[id]}
@@ -119,6 +146,7 @@
119146
<button
120147
class="railbtn"
121148
class:on={chat.backendId === 'acp' && chat.acpAgentId === agent.id}
149+
disabled={switching}
122150
onclick={() => pickAcp(agent)}
123151
title={agent.name}
124152
aria-label={agent.name}
@@ -137,7 +165,7 @@
137165
<input bind:value={query} placeholder={t('shell.pickerSearchPlaceholder')} autofocus />
138166
</div>
139167
{/if}
140-
<div class="rows">
168+
<div class="rows" role="presentation" onmouseleave={() => (hoverIdx = null)}>
141169
{#each rows as row, i (row.id)}
142170
{#if row.group && (i === 0 || rows[i - 1]?.group !== row.group)}
143171
<div class="row-group">{row.group}</div>
@@ -146,14 +174,14 @@
146174
class="prow"
147175
class:sel={i === selIdx}
148176
onclick={() => onSelect(row.command)}
149-
onmouseenter={() => (selIdx = i)}
177+
onmouseenter={() => hoverRow(i)}
150178
>
151179
<Vendor model={row.vendor ?? row.label} size={15} />
152180
<span class="prow-main">{row.label || t('shell.empty')}</span>
153181
<span class="prow-detail">{row.detail}</span>
154182
{#if row.active}<Check size={14} class="prow-check" />{/if}
155183
</button>
156-
{#if i === selIdx && chipEfforts(row).length}
184+
{#if i === chipIdx && chipEfforts(row).length}
157185
<div class="effrow">
158186
<span class="effcap">{t('chat.effortTitle')}</span>
159187
{#each chipEfforts(row) as ef (ef)}
@@ -252,6 +280,10 @@
252280
.railbtn.miss {
253281
opacity: 0.45;
254282
}
283+
.railbtn:disabled {
284+
cursor: default;
285+
opacity: 0.5;
286+
}
255287
.railsep {
256288
width: 20px;
257289
border-top: 1px solid var(--hairline);

src/lib/session.svelte.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,9 @@ export class SessionStore {
452452
async switchProvider(
453453
id: string,
454454
provider: { id: string; base_url: string; format: string; models: { name: string; reasoning_efforts?: string[] }[] },
455-
model: string
455+
model: string,
456+
/** Explicit effort pick (popover chip); must be one of the model's efforts. */
457+
effort?: string
456458
) {
457459
const s = this.allSessions.find((x) => x.id === id);
458460
if (!s) return;
@@ -464,7 +466,8 @@ export class SessionStore {
464466
models: provider.models,
465467
model
466468
};
467-
if (efforts.length) patch.reasoning_effort = efforts.includes('medium') ? 'medium' : efforts[0];
469+
if (effort && efforts.includes(effort)) patch.reasoning_effort = effort;
470+
else if (efforts.length) patch.reasoning_effort = efforts.includes('medium') ? 'medium' : efforts[0];
468471
try {
469472
await writeConfig(patch);
470473
} catch (e) {

src/lib/session.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ vi.mock('./protocol', () => ({
1212
}));
1313

1414
import { SessionStore } from './session.svelte';
15-
import { createSession, sendOp, git } from './protocol';
15+
import { createSession, sendOp, git, writeConfig } from './protocol';
1616
import { setLocale } from './i18n';
1717
import type { Project, WorktreeMeta } from './types';
1818

@@ -128,6 +128,38 @@ describe('SessionStore lifecycle', () => {
128128
expect(p.sessions.find((s) => s.id === rid)?.backendId).toBe('jucode');
129129
});
130130

131+
it('switchProvider honors a valid effort override and falls back otherwise', async () => {
132+
const store = new SessionStore();
133+
const p = proj();
134+
store.projects.push(p);
135+
const id = store.addSession(p);
136+
const provider = {
137+
id: 'byo',
138+
base_url: 'https://api.example.com',
139+
format: 'openai',
140+
models: [{ name: 'm1', reasoning_efforts: ['low', 'high'] }]
141+
};
142+
// A chip pick carries an explicit effort — written verbatim when valid.
143+
await store.switchProvider(id, provider, 'm1', 'high');
144+
expect(writeConfig).toHaveBeenCalledWith(
145+
expect.objectContaining({ provider: 'byo', model: 'm1', reasoning_effort: 'high' })
146+
);
147+
// An unknown effort falls back to the default (no medium → first listed).
148+
await store.switchProvider(id, provider, 'm1', 'bogus');
149+
expect(writeConfig).toHaveBeenLastCalledWith(
150+
expect.objectContaining({ reasoning_effort: 'low' })
151+
);
152+
// No effort argument keeps the medium-first default.
153+
await store.switchProvider(
154+
id,
155+
{ ...provider, models: [{ name: 'm1', reasoning_efforts: ['low', 'medium', 'high'] }] },
156+
'm1'
157+
);
158+
expect(writeConfig).toHaveBeenLastCalledWith(
159+
expect.objectContaining({ reasoning_effort: 'medium' })
160+
);
161+
});
162+
131163
it('removeProject tears down its sessions and clears a dangling activeId', () => {
132164
const store = new SessionStore();
133165
const p = proj();

0 commit comments

Comments
 (0)