Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 59 additions & 79 deletions src/lib/ChatPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -118,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.
Expand Down Expand Up @@ -171,19 +176,34 @@
// 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<typeof setTimeout> | 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. A detached HEAD reads
// "detached"; a failed probe (not a git repo) hides the chip.
let gitBranch = $state('');
function refreshGitBranch() {
const cwd = project?.path || chat.cwd;
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,
// show it instantly and refresh in the background; otherwise fetch first.
Expand Down Expand Up @@ -237,7 +257,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).
Expand Down Expand Up @@ -277,62 +296,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).
Expand All @@ -352,6 +333,7 @@
if (chat.picker) {
const i = filteredRows.findIndex((r) => r.active);
selIdx = i >= 0 ? i : 0;
pickerKeyNav = false;
}
});
$effect(() => {
Expand Down Expand Up @@ -484,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 <provider> <model> [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
Expand Down Expand Up @@ -529,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];
Expand Down Expand Up @@ -696,14 +678,13 @@
});
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;
});
</script>

<svelte:window onkeydown={onWindowKey} />
<svelte:window onkeydown={onWindowKey} onfocus={refreshGitBranch} />

<div class="chatpane">
{#if Object.keys(chat.subagents).length}
Expand Down Expand Up @@ -789,17 +770,16 @@
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}
bind:pickerKeyNav
onApproval={setApprovalMode}
/>
</div>
Expand Down
17 changes: 3 additions & 14 deletions src/lib/CommandPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -25,8 +25,7 @@
onOpenPanel,
onToggleSidebar,
onToggleTheme,
onSetup,
onOpenTui
onSetup
}: {
chat: ChatState | undefined;
hasProject: boolean;
Expand All @@ -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 = {
Expand Down Expand Up @@ -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) },
Expand Down
Loading
Loading