chore: snapshot working tree - pty_exited notifications + in-flight inference WIP

feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean).

wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes.

openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
This commit is contained in:
2026-06-14 12:48:47 +00:00
parent 0ed506f1da
commit b18de2a331
204 changed files with 25344 additions and 867 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot } from 'lucide-react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot, Star } from 'lucide-react';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -9,6 +9,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
@@ -113,14 +115,22 @@ interface PickerProps {
/** Grow to fill the row's free space and render the value brighter — used for
* the Model picker so the active model is the most visible control. */
flexible?: boolean;
/** Grouped rendering: renders sections with labels (Favorites-first, then
* per-provider). When provided, `options` is ignored. */
groups?: ModelGroup[];
}
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible }: PickerProps) {
interface ModelGroup {
label: string;
options: Array<{ id: string; label: string }>;
}
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible, groups }: PickerProps) {
const { isMobile } = useViewport();
const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
const list = (
const flatList = (
<div className="py-1">
{options.map((o) => (
<button
@@ -139,6 +149,36 @@ function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly
</div>
);
const groupedList = (
<div className="py-1">
{groups!.map((g, gi) => {
if (g.options.length === 0) return null;
return (
<div key={g.label}>
{gi > 0 && <div className="h-px bg-border mx-2 my-1" />}
<div className="text-[10px] font-medium text-muted-foreground px-2 py-0.5 uppercase tracking-wider">{g.label}</div>
{g.options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
onPick(o.id);
setOpen(false);
}}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{o.label}</span>
</button>
))}
</div>
);
})}
</div>
);
const list = groups ? groupedList : flatList;
if (isMobile) {
return (
<>
@@ -243,6 +283,8 @@ function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: stri
);
}
const FAVORITE_MODELS_KEY = 'favorite_models';
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
@@ -254,9 +296,20 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
[allEntries],
);
const [refreshing, setRefreshing] = useState(false);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const hydratedRef = useRef(false);
// Fetch favorites from settings for the grouped model picker (W5).
useEffect(() => {
api.settings.get().then((settings) => {
const raw = settings[FAVORITE_MODELS_KEY];
if (Array.isArray(raw)) {
setFavoriteModels(raw.filter((m): m is string => typeof m === 'string'));
}
}).catch(() => { /* settings fetch is best-effort */ });
}, []);
useEffect(() => {
hydratedRef.current = false;
}, [projectPath]);
@@ -318,6 +371,54 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
onProviderCommandsChange?.(currentEntry?.commands ?? []);
}, [currentEntry, onProviderCommandsChange]);
// Build grouped model options for the native boocode provider (W5).
// For other providers, use a flat list. Groups: Favorites first, then
// one section per local provider prefix (matching BooChat's ModelPicker).
const modelGroups = useMemo<ModelGroup[] | null>(() => {
if (!currentEntry || currentEntry.name !== 'boocode') return null;
const models = currentEntry.models;
if (models.length === 0) return [];
const favSet = new Set(favoriteModels);
// Build a model map for quick lookup
const modelMap = new Map(models.map((m) => [m.id, m]));
// Group models by provider prefix (the part before the first slash)
const byProvider = new Map<string, Array<{ id: string; label: string }>>();
for (const m of models) {
const slash = m.id.indexOf('/');
const providerPrefix = slash > 0 ? m.id.slice(0, slash) : 'other';
const formatted = { id: m.id, label: formatModelLabel(m.label) };
const arr = byProvider.get(providerPrefix) ?? [];
arr.push(formatted);
byProvider.set(providerPrefix, arr);
}
const groups: ModelGroup[] = [];
// Favorites section: only models that exist in the live inventory
const favModels = [...favSet]
.filter((id) => modelMap.has(id))
.map((id) => ({ id, label: formatModelLabel(modelMap.get(id)!.label) }));
if (favModels.length > 0) {
groups.push({ label: 'Favorites', options: favModels });
}
// One section per provider group
for (const [provider, opts] of byProvider) {
groups.push({ label: provider, options: opts });
}
return groups;
}, [currentEntry, favoriteModels]);
// Flat model options for non-boocode providers
const modelOptions = useMemo(
() => (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) })),
[currentEntry],
);
function persist(next: AgentSessionConfig): void {
const prefs = loadPrefs();
prefs[next.provider] = {
@@ -369,7 +470,6 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
// derived from it.
const permissionModes = availablePermissionModes(currentEntry?.modes ?? []);
const currentPermission = permissionForModeId(value.modeId, currentEntry?.modes ?? []);
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
return (
@@ -423,8 +523,9 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
<CompactPicker
label="Model"
value={value.model}
disabled={modelOptions.length === 0}
disabled={modelGroups ? modelGroups.every((g) => g.options.length === 0) : modelOptions.length === 0}
options={modelOptions}
groups={modelGroups ?? undefined}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
flexible