Removed unwanted files marked in .gitignore

This commit is contained in:
gsxdsm
2026-04-01 21:25:08 -07:00
parent fbf749a525
commit 2778027423
856 changed files with 2357 additions and 133439 deletions

View File

@@ -23,6 +23,7 @@ import { NewTaskModal } from "./components/NewTaskModal";
import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { MissionManager } from "./components/MissionManager";
import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView";
import { ScriptsModal } from "./components/ScriptsModal";
@@ -85,6 +86,7 @@ function AppInner() {
const [activityLogOpen, setActivityLogOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [missionsOpen, setMissionsOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
@@ -177,7 +179,7 @@ function AppInner() {
// Fetch available models
useEffect(() => {
fetchModels()
.then((models) => setAvailableModels(models))
.then((response) => setAvailableModels(response.models))
.catch(() => {/* keep empty array on failure */});
}, []);
@@ -484,6 +486,7 @@ function AppInner() {
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenMissions={() => setMissionsOpen(true)}
onOpenAgents={handleOpenAgents}
onOpenScripts={handleOpenScripts}
onRunScript={handleRunScript}
@@ -621,6 +624,18 @@ function AppInner() {
onClose={() => setWorkflowStepsOpen(false)}
addToast={addToast}
/>
<MissionManager
isOpen={missionsOpen}
onClose={() => setMissionsOpen(false)}
addToast={addToast}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) {
setDetailTask(task as TaskDetail);
}
}}
/>
<AgentListModal
isOpen={agentsOpen}
onClose={handleCloseAgents}

View File

@@ -35,9 +35,9 @@ describe("column fixed-width CSS", () => {
});
describe("desktop .board grid template", () => {
it("uses repeat(6, minmax(260px, 1fr)) for 6 columns", () => {
it("uses repeat(6, minmax(280px, 1fr)) for 6 columns", () => {
expect(css).toContain(
"grid-template-columns: repeat(6, minmax(260px, 1fr))",
"grid-template-columns: repeat(6, minmax(280px, 1fr))",
);
});
});

View File

@@ -231,15 +231,18 @@ describe("fetchModels", () => {
globalThis.fetch = originalFetch;
});
it("returns available models", async () => {
const models = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, models));
it("returns available models with favorites", async () => {
const response = {
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
],
favoriteProviders: ["anthropic"],
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await fetchModels();
expect(result).toEqual(models);
expect(result).toEqual(response);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/models", {
headers: { "Content-Type": "application/json" },
});

View File

@@ -287,6 +287,13 @@ export function fetchTaskComments(id: string): Promise<TaskComment[]> {
return api<TaskComment[]>(`/tasks/${id}/comments`);
}
export function addComment(id: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/steer`, {
method: "POST",
body: JSON.stringify({ text }),
});
}
export function addTaskComment(id: string, text: string, author?: string): Promise<Task> {
return api<Task>(`/tasks/${id}/comments`, {
method: "POST",
@@ -339,9 +346,15 @@ export interface ModelInfo {
contextWindow: number;
}
/** Fetch available AI models from the model registry */
export function fetchModels(): Promise<ModelInfo[]> {
return api<ModelInfo[]>("/models");
/** Response from the models endpoint */
export interface ModelsResponse {
models: ModelInfo[];
favoriteProviders: string[];
}
/** Fetch available AI models from the model registry along with favoriteProviders */
export function fetchModels(): Promise<ModelsResponse> {
return api<ModelsResponse>("/models");
}
// --- Usage API ---
@@ -1530,8 +1543,8 @@ export function cancelSubtaskBreakdown(sessionId: string): Promise<void> {
// ── Agent API ────────────────────────────────────────────────────────────
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentCreateInput, AgentUpdateInput } from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentCreateInput, AgentUpdateInput };
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput } from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput };
/** Fetch all agents, optionally filtered by state or role */
export function fetchAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
@@ -1958,6 +1971,241 @@ export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
return api<TaskFileDiff[]>(`/tasks/${encodeURIComponent(taskId)}/file-diffs`);
}
// ── Mission API ───────────────────────────────────────────────────────────
/** Mission status values */
export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived";
/** Milestone status values */
export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
/** Slice status values */
export type SliceStatus = "pending" | "active" | "complete";
/** Feature status values */
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
/** Mission entity */
export interface Mission {
id: string;
title: string;
description?: string;
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
createdAt: string;
updatedAt: string;
}
/** Milestone entity */
export interface Milestone {
id: string;
missionId: string;
title: string;
description?: string;
status: MilestoneStatus;
orderIndex: number;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
dependencies: string[];
createdAt: string;
updatedAt: string;
}
/** Slice entity */
export interface Slice {
id: string;
milestoneId: string;
title: string;
description?: string;
status: SliceStatus;
orderIndex: number;
activatedAt?: string;
createdAt: string;
updatedAt: string;
}
/** Feature entity */
export interface MissionFeature {
id: string;
sliceId: string;
taskId?: string;
title: string;
description?: string;
acceptanceCriteria?: string;
status: FeatureStatus;
createdAt: string;
updatedAt: string;
}
/** Milestone with slices (each slice has features) */
export interface MilestoneWithSlices extends Milestone {
slices: SliceWithFeatures[];
}
/** Slice with features */
export interface SliceWithFeatures extends Slice {
features: MissionFeature[];
}
/** Full mission hierarchy */
export interface MissionWithHierarchy extends Mission {
milestones: MilestoneWithSlices[];
}
/** Fetch all missions */
export function fetchMissions(): Promise<Mission[]> {
return api<Mission[]>("/missions");
}
/** Create a new mission */
export function createMission(input: { title: string; description?: string }): Promise<Mission> {
return api<Mission>("/missions", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Get mission with full hierarchy */
export function fetchMission(missionId: string): Promise<MissionWithHierarchy> {
return api<MissionWithHierarchy>(`/missions/${encodeURIComponent(missionId)}`);
}
/** Update mission */
export function updateMission(missionId: string, updates: Partial<Mission>): Promise<Mission> {
return api<Mission>(`/missions/${encodeURIComponent(missionId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete mission */
export function deleteMission(missionId: string): Promise<void> {
return api<void>(`/missions/${encodeURIComponent(missionId)}`, {
method: "DELETE",
});
}
/** Get mission computed status */
export function fetchMissionStatus(missionId: string): Promise<{ status: string }> {
return api<{ status: string }>(`/missions/${encodeURIComponent(missionId)}/status`);
}
/** Add milestone to mission */
export function createMilestone(
missionId: string,
input: { title: string; description?: string; dependencies?: string[] }
): Promise<Milestone> {
return api<Milestone>(`/missions/${encodeURIComponent(missionId)}/milestones`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update milestone */
export function updateMilestone(milestoneId: string, updates: Partial<Milestone>): Promise<Milestone> {
return api<Milestone>(`/missions/milestones/${encodeURIComponent(milestoneId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete milestone */
export function deleteMilestone(milestoneId: string): Promise<void> {
return api<void>(`/missions/milestones/${encodeURIComponent(milestoneId)}`, {
method: "DELETE",
});
}
/** Reorder milestones */
export function reorderMilestones(missionId: string, orderedIds: string[]): Promise<void> {
return api<void>(`/missions/${encodeURIComponent(missionId)}/milestones/reorder`, {
method: "POST",
body: JSON.stringify({ orderedIds }),
});
}
/** Add slice to milestone */
export function createSlice(
milestoneId: string,
input: { title: string; description?: string }
): Promise<Slice> {
return api<Slice>(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update slice */
export function updateSlice(sliceId: string, updates: Partial<Slice>): Promise<Slice> {
return api<Slice>(`/missions/slices/${encodeURIComponent(sliceId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete slice */
export function deleteSlice(sliceId: string): Promise<void> {
return api<void>(`/missions/slices/${encodeURIComponent(sliceId)}`, {
method: "DELETE",
});
}
/** Activate slice */
export function activateSlice(sliceId: string): Promise<Slice> {
return api<Slice>(`/missions/slices/${encodeURIComponent(sliceId)}/activate`, {
method: "POST",
});
}
/** Reorder slices */
export function reorderSlices(milestoneId: string, orderedIds: string[]): Promise<void> {
return api<void>(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices/reorder`, {
method: "POST",
body: JSON.stringify({ orderedIds }),
});
}
/** Add feature to slice */
export function createFeature(
sliceId: string,
input: { title: string; description?: string; acceptanceCriteria?: string }
): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/slices/${encodeURIComponent(sliceId)}/features`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update feature */
export function updateFeature(featureId: string, updates: Partial<MissionFeature>): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete feature */
export function deleteFeature(featureId: string): Promise<void> {
return api<void>(`/missions/features/${encodeURIComponent(featureId)}`, {
method: "DELETE",
});
}
/** Link feature to task */
export function linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}/link-task`, {
method: "POST",
body: JSON.stringify({ taskId }),
});
}
/** Unlink feature from task */
export function unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}/unlink-task`, {
method: "POST",
});
}

View File

@@ -317,13 +317,6 @@ export function ActivityLogModal({
<span className="activity-log-entry-type">
{EVENT_TYPE_LABELS[entry.type]}
</span>
{/* Project name badge */}
{entry.projectName && (
<span className="activity-log-entry-project">
<Folder size={10} />
{entry.projectName}
</span>
)}
<span className="activity-log-entry-time">
{formatTimestamp(entry.timestamp)}
</span>

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { JSX } from "react";
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List } from "lucide-react";
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight } from "lucide-react";
import type { Agent, AgentCapability, AgentState } from "../api";
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
import { AgentDetailView } from "./AgentDetailView";
export interface AgentsViewProps {
addToast: (message: string, type?: "success" | "error") => void;
@@ -31,6 +32,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
const [newAgentName, setNewAgentName] = useState("");
const [newAgentRole, setNewAgentRole] = useState<AgentCapability>("custom");
const [filterState, setFilterState] = useState<AgentState | "all">("all");
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentView, setAgentView] = useState<"board" | "list">(() => {
if (typeof window === "undefined") return "list";
const saved = localStorage.getItem("kb-agent-view");
@@ -251,26 +253,34 @@ export function AgentsView({ addToast }: AgentsViewProps) {
const stateStyle = STATE_COLORS[agent.state];
return (
<div key={agent.id} className="agent-board-card" style={{ borderColor: stateStyle.border }}>
<div className="agent-board-header">
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
<span
className="agent-board-badge"
style={{
background: stateStyle.bg,
color: stateStyle.text,
border: `1px solid ${stateStyle.border}`,
}}
>
{agent.state}
</span>
<span className="agent-board-health" style={{ color: health.color }} title={health.label}>
{health.icon}
</span>
<div
className="agent-board-clickable"
onClick={() => setSelectedAgentId(agent.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)}
>
<div className="agent-board-header">
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
<span
className="agent-board-badge"
style={{
background: stateStyle.bg,
color: stateStyle.text,
border: `1px solid ${stateStyle.border}`,
}}
>
{agent.state}
</span>
<span className="agent-board-health" style={{ color: health.color }} title={health.label}>
{health.icon}
</span>
</div>
<div className="agent-board-name" title={agent.name}>
{agent.name}
</div>
<div className="agent-board-id">{agent.id}</div>
</div>
<div className="agent-board-name" title={agent.name}>
{agent.name}
</div>
<div className="agent-board-id">{agent.id}</div>
<div className="agent-board-actions">
{agent.state === "idle" && (
<button
@@ -338,7 +348,13 @@ export function AgentsView({ addToast }: AgentsViewProps) {
return (
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
<div className="agent-card-header">
<div className="agent-info">
<div
className="agent-info agent-info--clickable"
onClick={() => setSelectedAgentId(agent.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)}
>
{editingRoleForAgent === agent.id ? (
<select
ref={roleSelectRef}
@@ -358,12 +374,16 @@ export function AgentsView({ addToast }: AgentsViewProps) {
) : (
<span
className="agent-icon agent-icon--clickable"
onClick={() => setEditingRoleForAgent(agent.id)}
onClick={(e) => {
e.stopPropagation();
setEditingRoleForAgent(agent.id);
}}
title="Click to change role"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
setEditingRoleForAgent(agent.id);
}
}}
@@ -375,6 +395,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
<span className="agent-name">{agent.name}</span>
<span className="agent-id text-secondary">{agent.id}</span>
</div>
<ChevronRight size={20} className="agent-card-chevron" />
</div>
<div className="agent-badges">
<span
@@ -474,6 +495,15 @@ export function AgentsView({ addToast }: AgentsViewProps) {
</div>
</div>
{/* Agent Detail Modal */}
{selectedAgentId && (
<AgentDetailView
agentId={selectedAgentId}
onClose={() => setSelectedAgentId(null)}
addToast={addToast}
/>
)}
<style>{`
.agents-view {
display: flex;
@@ -621,6 +651,14 @@ export function AgentsView({ addToast }: AgentsViewProps) {
color: var(--text-secondary);
}
.agent-board-clickable {
cursor: pointer;
}
.agent-board-clickable:hover .agent-board-name {
color: var(--accent);
}
.agent-board-actions {
display: flex;
gap: 6px;
@@ -697,6 +735,37 @@ export function AgentsView({ addToast }: AgentsViewProps) {
flex-direction: column;
}
.agent-info--clickable {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 4px;
margin: -4px;
border-radius: 4px;
transition: background 0.15s ease;
}
.agent-info--clickable:hover {
background: var(--bg-hover);
}
.agent-info--clickable:hover .agent-name {
color: var(--accent);
}
.agent-card-chevron {
color: var(--text-muted);
margin-left: auto;
opacity: 0;
transition: opacity 0.15s ease;
}
.agent-info--clickable:hover .agent-card-chevron {
opacity: 1;
}
.agent-name {
font-weight: 600;
font-size: 16px;

View File

@@ -12,6 +12,10 @@ export interface CustomModelDropdownProps {
disabled?: boolean;
id?: string;
label: string;
/** List of favorite provider names in preferred order */
favoriteProviders?: string[];
/** Called when user toggles a provider's favorite status */
onToggleFavorite?: (provider: string) => void;
}
interface DropdownPosition {
@@ -41,6 +45,8 @@ export function CustomModelDropdown({
disabled = false,
id,
label,
favoriteProviders = [],
onToggleFavorite,
}: CustomModelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
@@ -57,7 +63,7 @@ export function CustomModelDropdown({
// Filter models based on local filter text
const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]);
// Group filtered models by provider
// Group filtered models by provider and sort by favorites
const modelsByProvider = useMemo(() => {
return filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
@@ -65,6 +71,30 @@ export function CustomModelDropdown({
}, {});
}, [filteredModels]);
// Sort providers: favorites first (in order), then alphabetically
const sortedProviderEntries = useMemo(() => {
const entries = Object.entries(modelsByProvider);
const favoritesSet = new Set(favoriteProviders);
return entries.sort(([a], [b]) => {
const aFavorite = favoritesSet.has(a);
const bFavorite = favoritesSet.has(b);
if (aFavorite && !bFavorite) return -1;
if (!aFavorite && bFavorite) return 1;
// Both favorites: sort by favoriteProviders order
if (aFavorite && bFavorite) {
const aIdx = favoriteProviders.indexOf(a);
const bIdx = favoriteProviders.indexOf(b);
if (aIdx !== bIdx) return aIdx - bIdx;
}
// Neither favorite: alphabetical
return a.localeCompare(b);
});
}, [modelsByProvider, favoriteProviders]);
// Get current provider from value
const currentProvider = useMemo(() => {
if (!value) return null;
@@ -78,7 +108,7 @@ export function CustomModelDropdown({
{ type: "default", value: "", label: "Use default" },
];
Object.entries(modelsByProvider).forEach(([provider, providerModels]) => {
sortedProviderEntries.forEach(([provider, providerModels]) => {
options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider });
providerModels.forEach((m) => {
options.push({
@@ -91,7 +121,7 @@ export function CustomModelDropdown({
});
return options;
}, [modelsByProvider]);
}, [sortedProviderEntries]);
// Get current selection display text
const selectedDisplayText = useMemo(() => {
@@ -328,14 +358,29 @@ export function CustomModelDropdown({
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
{sortedProviderEntries.map(([provider, providerModels]) => {
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
const isFavorite = favoriteProviders.includes(provider);
return (
<div key={provider} className="model-combobox-group">
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
<ProviderIcon provider={provider} size="sm" />
<span className="model-combobox-optgroup-text">{provider}</span>
{onToggleFavorite && (
<button
type="button"
className={`model-combobox-optgroup-favorite ${isFavorite ? "model-combobox-optgroup-favorite--active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(provider);
}}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? `Remove ${provider} from favorites` : `Add ${provider} to favorites`}
>
</button>
)}
</div>
{providerModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target } from "lucide-react";
import type { ProjectInfo } from "../api";
import { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
@@ -28,6 +28,7 @@ export interface HeaderProps {
onOpenSchedules?: () => void;
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenMissions?: () => void;
onOpenAgents?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
@@ -76,6 +77,7 @@ export function Header({
onOpenSchedules,
onOpenGitManager,
onOpenWorkflowSteps,
onOpenMissions,
onOpenAgents,
onOpenScripts,
onRunScript,
@@ -386,6 +388,18 @@ export function Header({
</button>
)}
{/* Missions - desktop only */}
{!isMobile && onOpenMissions && (
<button
className="btn-icon"
onClick={onOpenMissions}
title="Mission Manager"
data-testid="missions-btn"
>
<Target size={16} />
</button>
)}
{/* Agents - desktop only */}
{!isMobile && onOpenAgents && (
<button

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp } from "lucide-react";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings } from "../api";
import type { ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { applyPresetToSelection } from "../utils/modelPresets";
@@ -85,6 +85,7 @@ export function InlineCreateCard({
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
@@ -110,7 +111,9 @@ export function InlineCreateCard({
setModelsLoading(true);
setModelsError(null);
try {
setLoadedModels(await fetchModels());
const response = await fetchModels();
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
} catch (err: any) {
setModelsError(err?.message || "Failed to load models");
} finally {
@@ -139,9 +142,10 @@ export function InlineCreateCard({
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then((models) => {
.then((response) => {
if (!cancelled) {
setLoadedModels(models);
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
}
})
.catch((err: any) => {
@@ -413,6 +417,23 @@ export function InlineCreateCard({
setValidatorModelId(next.modelId);
}, []);
const handleToggleFavorite = useCallback(async (provider: string) => {
const currentFavorites = favoriteProviders;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((p) => p !== provider)
: [provider, ...currentFavorites];
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
} catch {
// Revert on error
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders]);
const handleModelDropdownMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target;
if (
@@ -700,6 +721,8 @@ export function InlineCreateCard({
models={loadedModels}
disabled={submitting}
placeholder="Select executor model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
</div>
@@ -718,6 +741,8 @@ export function InlineCreateCard({
models={loadedModels}
disabled={submitting}
placeholder="Select validator model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
</div>
</>

View File

@@ -48,6 +48,10 @@ interface ListViewProps {
* Allows parent to refresh task list or handle optimistically.
*/
onTasksUpdated?: (updatedTasks: Task[]) => void;
/** Project ID for multi-project context (optional) */
projectId?: string;
/** Project name for display (optional) */
projectName?: string;
}
function getStepProgress(steps: TaskStep[]): string {
@@ -726,7 +730,6 @@ export function ListView({
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
autoExpand={false}
/>
</div>
{filteredCount === 0 ? (

View File

@@ -14,6 +14,8 @@ interface ModelSelectionModalProps {
modelsLoading: boolean;
modelsError: string | null;
onRetry: () => void;
favoriteProviders?: string[];
onToggleFavorite?: (provider: string) => void;
}
function getModelBadgeLabel(models: ModelInfo[], value: string): string {
@@ -37,6 +39,8 @@ export function ModelSelectionModal({
modelsLoading,
modelsError,
onRetry,
favoriteProviders = [],
onToggleFavorite,
}: ModelSelectionModalProps) {
// Handle Escape key
useEffect(() => {
@@ -129,6 +133,8 @@ export function ModelSelectionModal({
onChange={onExecutorChange}
models={models}
placeholder="Select executor model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
/>
</div>
</div>
@@ -151,6 +157,8 @@ export function ModelSelectionModal({
onChange={onValidatorChange}
models={models}
placeholder="Select validator model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
/>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { fetchModels, updateTask } from "../api";
import { fetchModels, updateTask, updateGlobalSettings } from "../api";
import type { ModelInfo } from "../api";
import type { Task, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -68,6 +68,7 @@ function getSuccessToastMessage(target: "executor" | "validator", selection: Mod
export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
@@ -84,8 +85,9 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then((models) => {
setAvailableModels(models);
.then((response) => {
setAvailableModels(response.models);
setFavoriteProviders(response.favoriteProviders);
})
.catch((err) => {
setModelsError(err.message || "Failed to load models");
@@ -95,6 +97,25 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
});
}, []);
// Handle toggle favorite
const handleToggleFavorite = useCallback(async (provider: string) => {
const currentFavorites = favoriteProviders;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((p) => p !== provider)
: [provider, ...currentFavorites]; // Add to front
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
} catch (err) {
// Revert on error
setFavoriteProviders(currentFavorites);
addToast("Failed to update favorites", "error");
}
}, [favoriteProviders, addToast]);
useEffect(() => {
activeTaskIdRef.current = task.id;
@@ -225,7 +246,10 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then(setAvailableModels)
.then((response) => {
setAvailableModels(response.models);
setFavoriteProviders(response.favoriteProviders);
})
.catch((err) => setModelsError(err.message))
.finally(() => setModelsLoading(false));
}}
@@ -260,6 +284,8 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
models={availableModels}
disabled={isSaving}
placeholder="Select executor model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
<small>The AI model used to implement this task.</small>
</div>
@@ -284,6 +310,8 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
models={availableModels}
disabled={isSaving}
placeholder="Select validator model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
<small>The AI model used to review code and plans for this task.</small>
</div>

View File

@@ -1,11 +1,10 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, type RefinementType } from "../api";
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType } from "../api";
import type { ModelInfo } from "../api";
import { filterModels } from "../utils/modelFilter";
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
import { ProviderIcon } from "./ProviderIcon";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { Sparkles } from "lucide-react";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -25,284 +24,6 @@ interface NewTaskModalProps {
onSubtaskBreakdown?: (description: string) => void;
}
/**
* Simplified ModelCombobox for the New Task modal.
* Reuses the same interaction pattern as ModelSelectorTab.
*/
function ModelCombobox({
value,
onChange,
models,
disabled = false,
placeholder = "Select a model…",
label,
id,
}: {
value: string;
onChange: (value: string) => void;
models: ModelInfo[];
disabled?: boolean;
placeholder?: string;
label: string;
id: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filteredModels = filterModels(models, localFilter);
const modelsByProvider = filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
// Get current provider from value for icon display
const currentProvider = useMemo(() => {
if (!value) return null;
const slashIdx = value.indexOf("/");
return slashIdx === -1 ? null : value.slice(0, slashIdx);
}, [value]);
const optionsList = [
{ type: "default" as const, value: "", label: "Use default" },
...Object.entries(modelsByProvider).flatMap(([provider, providerModels]) => [
{ type: "provider" as const, value: `__group_${provider}`, label: provider, provider },
...providerModels.map((m) => ({
type: "model" as const,
value: `${m.provider}/${m.id}`,
label: m.name,
provider: m.provider
})),
]),
];
const selectedDisplayText = !value
? "Use default"
: (() => {
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
const provider = value.slice(0, slashIdx);
const modelId = value.slice(slashIdx + 1);
const model = models.find((m) => m.provider === provider && m.id === modelId);
return model?.name || value;
})();
const currentValueIndex = optionsList.findIndex((opt) => opt.value === value);
useEffect(() => {
if (isOpen) {
const selectableIndex = optionsList.findIndex((opt, idx) =>
idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
);
setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0);
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen, optionsList, currentValueIndex]);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setLocalFilter("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
let nextIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex + i) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
nextIndex = idx;
break;
}
}
setHighlightedIndex(nextIndex);
}
break;
case "ArrowUp":
e.preventDefault();
if (isOpen) {
let prevIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
prevIndex = idx;
break;
}
}
setHighlightedIndex(prevIndex);
}
break;
case "Enter":
e.preventDefault();
if (isOpen) {
const option = optionsList[highlightedIndex];
if (option && option.type !== "provider") {
onChange(option.value);
setIsOpen(false);
setLocalFilter("");
}
} else {
setIsOpen(true);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setLocalFilter("");
break;
case "Tab":
if (isOpen) {
setIsOpen(false);
setLocalFilter("");
}
break;
}
}, [isOpen, highlightedIndex, optionsList, onChange]);
const handleSelect = useCallback((optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
setLocalFilter("");
}, [onChange]);
useEffect(() => {
if (isOpen && listRef.current) {
const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") {
highlightedEl.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex, isOpen]);
return (
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
<button
type="button"
id={id}
className="model-combobox-trigger"
onClick={() => setIsOpen((prev) => !prev)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label}
>
{currentProvider && (
<span className="model-combobox-trigger-icon">
<ProviderIcon provider={currentProvider} size="sm" />
</span>
)}
<span className="model-combobox-trigger-text">{selectedDisplayText}</span>
<span className="model-combobox-trigger-arrow"></span>
</button>
{isOpen && (
<div className="model-combobox-dropdown" role="listbox">
<div className="model-combobox-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="model-combobox-search"
placeholder="Filter models…"
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{localFilter && (
<button
type="button"
className="model-combobox-clear"
onClick={() => {
setLocalFilter("");
searchInputRef.current?.focus();
}}
aria-label="Clear filter"
>
×
</button>
)}
</div>
<div className="model-combobox-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
</div>
<div ref={listRef} className="model-combobox-list">
<div
data-index={0}
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect("")}
onMouseEnter={() => setHighlightedIndex(0)}
role="option"
aria-selected={value === ""}
>
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
return (
<div key={provider} className="model-combobox-group">
<div
className="model-combobox-optgroup"
data-index={groupStartIndex}
>
<ProviderIcon provider={provider} size="sm" />
<span className="model-combobox-optgroup-text">{provider}</span>
</div>
{providerModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === optionValue;
return (
<div
key={optionValue}
data-index={optionIndex}
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(optionValue)}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
role="option"
aria-selected={isSelected}
>
<span className="model-combobox-option-text">{m.name}</span>
<span className="model-combobox-option-id">{m.id}</span>
</div>
);
})}
</div>
);
})}
{filteredModels.length === 0 && localFilter && (
<div className="model-combobox-no-results">
No models match &apos;{localFilter}&apos;
</div>
)}
</div>
</div>
)}
</div>
);
}
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
const [description, setDescription] = useState("");
const [dependencies, setDependencies] = useState<string[]>([]);
@@ -311,6 +32,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [executorModel, setExecutorModel] = useState("");
const [validatorModel, setValidatorModel] = useState("");
@@ -335,7 +57,10 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
if (isOpen) {
setModelsLoading(true);
fetchModels()
.then((models) => setAvailableModels(models))
.then((response) => {
setAvailableModels(response.models);
setFavoriteProviders(response.favoriteProviders);
})
.catch(() => {/* silently fail - models just won't be available */})
.finally(() => setModelsLoading(false));
fetchSettings()
@@ -582,6 +307,23 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
}
}, [description, isRefining, addToast]);
const handleToggleFavorite = useCallback(async (provider: string) => {
const currentFavorites = favoriteProviders;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((p) => p !== provider)
: [provider, ...currentFavorites];
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
} catch {
// Revert on error
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders]);
if (!isOpen) return null;
const availableDeps = tasks
@@ -806,7 +548,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
) : null}
<div className="model-select-row">
<label htmlFor="executor-model" className="model-select-label">Executor</label>
<ModelCombobox
<CustomModelDropdown
id="executor-model"
label="Executor Model"
value={executorModel}
@@ -817,11 +559,13 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
}}
models={availableModels}
disabled={isSubmitting || presetMode === "preset"}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
</div>
<div className="model-select-row">
<label htmlFor="validator-model" className="model-select-label">Validator</label>
<ModelCombobox
<CustomModelDropdown
id="validator-model"
label="Validator Model"
value={validatorModel}
@@ -832,6 +576,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
}}
models={availableModels}
disabled={isSubmitting || presetMode === "preset"}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
</div>
</>

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
import type { ProjectInfo, ProjectHealth, ProjectStatus } from "../api";
import type { ProjectInfo, ProjectHealth } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { ProjectCard } from "./ProjectCard";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
import { useProjectHealth } from "../hooks/useProjectHealth";

View File

@@ -12,7 +12,8 @@ import {
Loader2,
X,
} from "lucide-react";
import type { ProjectInfo, ProjectStatus } from "../api";
import type { ProjectInfo } from "../api";
import type { ProjectStatus } from "@fusion/core";
export interface ProjectSelectorProps {
projects: ProjectInfo[];

View File

@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
import type { ToastType } from "../hooks/useToast";
import type { Task, TaskCreateInput } from "@fusion/core";
import type { ModelInfo, RefinementType } from "../api";
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
import { fetchModels, refineText, getRefineErrorMessage, updateGlobalSettings } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ModelSelectionModal } from "./ModelSelectionModal";
@@ -69,6 +69,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
// AI Refinement state
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
@@ -91,9 +92,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then((models) => {
.then((response) => {
if (!cancelled) {
setLoadedModels(models);
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
}
})
.catch((err: any) => {
@@ -335,6 +337,23 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setValidatorModelId(next.modelId);
}, []);
const handleToggleFavorite = useCallback(async (provider: string) => {
const currentFavorites = favoriteProviders;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((p) => p !== provider)
: [provider, ...currentFavorites];
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
} catch {
// Revert on error
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders]);
const handlePlanClick = useCallback(() => {
const trimmed = description.trim();
if (!trimmed) {
@@ -399,7 +418,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setModelsLoading(true);
setModelsError(null);
try {
setLoadedModels(await fetchModels());
const response = await fetchModels();
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
} catch (err: any) {
setModelsError(err?.message || "Failed to load models");
} finally {
@@ -633,6 +654,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
modelsLoading={modelsLoading}
modelsError={modelsError}
onRetry={loadModels}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>,
document.body,
)

View File

@@ -95,6 +95,7 @@ export function SettingsModal({
// Model state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
// Test notification state
@@ -142,7 +143,10 @@ export function SettingsModal({
if (activeSection === "model") {
setModelsLoading(true);
fetchModels()
.then((models) => setAvailableModels(models))
.then((response) => {
setAvailableModels(response.models);
setFavoriteProviders(response.favoriteProviders);
})
.catch(() => setAvailableModels([]))
.finally(() => setModelsLoading(false));
}
@@ -312,7 +316,7 @@ export function SettingsModal({
try {
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge });
if (result.success) {
const parts = [];
const parts: string[] = [];
if (result.globalCount > 0) parts.push(`${result.globalCount} global`);
if (result.projectCount > 0) parts.push(`${result.projectCount} project`);
addToast(`Imported ${parts.join(", ")} setting(s)`, "success");
@@ -426,6 +430,24 @@ export function SettingsModal({
setPresetIdTouched(false);
};
/** Toggle provider favorite status */
const handleToggleFavorite = useCallback(async (provider: string) => {
const currentFavorites = favoriteProviders;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((p) => p !== provider)
: [provider, ...currentFavorites];
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
} catch {
// Revert on error
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders]);
/** Render a scope indicator banner for the current section */
const renderScopeBanner = () => {
if (activeSectionScope === "global") {
@@ -532,6 +554,8 @@ export function SettingsModal({
}
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
<small>Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.</small>
</div>
@@ -555,6 +579,8 @@ export function SettingsModal({
}
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
<small>AI model used for task planning and specification (triage). Falls back to Default Model when not set.</small>
</div>
@@ -578,6 +604,8 @@ export function SettingsModal({
}
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
<small>AI model used for code and specification review. Falls back to Default Model when not set.</small>
</div>
@@ -753,6 +781,8 @@ export function SettingsModal({
} : current);
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
</div>
<div className="form-group">
@@ -775,6 +805,8 @@ export function SettingsModal({
} : current);
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
</div>
</>
@@ -887,6 +919,8 @@ export function SettingsModal({
}));
}}
placeholder="Use fallback model"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/>
)}
<small>

View File

@@ -396,7 +396,7 @@ export function SetupWizard({ isOpen, onClose, onProjectCreated, onRegisterProje
<button
className="btn btn-primary"
onClick={handleValidate}
disabled={state.isValidating || state.validationError}
disabled={state.isValidating || !!state.validationError}
>
{state.isValidating ? (
<>

View File

@@ -54,7 +54,7 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
setDiffData(data);
// Auto-expand first file if there are files
if (data.files.length > 0) {
setExpandedFiles(new Set([data.files[0]]));
setExpandedFiles(new Set([data.files[0].path]));
}
} catch (err: any) {
setError(err.message || "Failed to load diff");
@@ -146,19 +146,18 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
</div>
<div className="changes-file-list">
{diffData.files.map((file) => {
const fileDiff = diffData.diffs[file];
const status = getFileStatus(file, fileDiff?.patch || "");
const isExpanded = expandedFiles.has(file);
{diffData.files.map((fileEntry) => {
const { path, status, patch } = fileEntry;
const isExpanded = expandedFiles.has(path);
return (
<div
key={file}
key={path}
className={`changes-file-item ${isExpanded ? "expanded" : ""}`}
>
<button
className="changes-file-header"
onClick={() => toggleFile(file)}
onClick={() => toggleFile(path)}
>
<span className="changes-file-toggle">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
@@ -171,22 +170,19 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
{status === "added" && "A"}
{status === "modified" && "M"}
{status === "deleted" && "D"}
{status === "unknown" && "?"}
</span>
<span className="changes-file-path" title={file}>
{file}
<span className="changes-file-path" title={path}>
{path}
</span>
<span className="changes-file-stat" title={`+${fileEntry.additions} -${fileEntry.deletions}`}>
+{fileEntry.additions} -{fileEntry.deletions}
</span>
{fileDiff?.stat && (
<span className="changes-file-stat" title={fileDiff.stat}>
{fileDiff.stat}
</span>
)}
</button>
{isExpanded && fileDiff?.patch && (
{isExpanded && patch && (
<div className="changes-file-content">
<pre className="changes-diff-patch">
<code>{fileDiff.patch}</code>
<code>{patch}</code>
</pre>
</div>
)}

View File

@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
import type { Task, TaskComment } from "@fusion/core";
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
import { addSteeringComment, addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
import type { ToastType } from "../hooks/useToast";
const MAX_COMMENT_LENGTH = 2000;
interface TaskCommentsProps {
task: Task;
onTaskUpdated?: (task: Task) => void;
@@ -10,30 +12,51 @@ interface TaskCommentsProps {
currentAuthor?: string;
}
type CommentType = "comment" | "guidance";
function formatCommentTimestamp(comment: TaskComment): string {
const timestamp = comment.updatedAt || comment.createdAt;
const label = new Date(timestamp).toLocaleString();
return comment.updatedAt ? `${label} (edited)` : label;
}
function isAIGuidanceComment(author: string): boolean {
return author === "agent" || author === "system";
}
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
const [draft, setDraft] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [commentType, setCommentType] = useState<CommentType>("comment");
const comments = useMemo(() => task.comments || [], [task.comments]);
// Sort comments by createdAt descending (newest first)
const comments = useMemo(() => {
return [...(task.comments || [])].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}, [task.comments]);
const isOverLimit = draft.length > MAX_COMMENT_LENGTH;
async function handleAddComment() {
const text = draft.trim();
if (!text) return;
setSubmitting(true);
try {
const updated = await addTaskComment(task.id, text, currentAuthor);
setDraft("");
onTaskUpdated?.(updated);
addToast("Comment added", "success");
if (commentType === "guidance") {
const updated = await addSteeringComment(task.id, text);
setDraft("");
onTaskUpdated?.(updated);
addToast("AI Guidance added", "success");
} else {
const updated = await addTaskComment(task.id, text, currentAuthor);
setDraft("");
onTaskUpdated?.(updated);
addToast("Comment added", "success");
}
} catch (error: any) {
addToast(error.message || "Failed to add comment", "error");
} finally {
@@ -71,6 +94,16 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
void handleAddComment();
}
}
const placeholder = commentType === "guidance" ? "Add guidance for the AI agent" : "Add a comment";
const buttonLabel = commentType === "guidance" ? "Add Guidance" : "Add Comment";
return (
<div className="detail-section">
<h4>Comments</h4>
@@ -81,12 +114,17 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
{comments.map((comment) => {
const canEdit = comment.author === currentAuthor;
const isEditing = editingId === comment.id;
const isAIGuidance = isAIGuidanceComment(comment.author);
return (
<div key={comment.id} className="detail-log-entry">
<div className="detail-log-header" style={{ justifyContent: "space-between", gap: 12 }}>
<div>
<strong>{comment.author}</strong>
<span className="detail-log-timestamp" style={{ marginLeft: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{isAIGuidance ? (
<span className="ai-guidance-badge" data-testid="ai-guidance-badge">AI Guidance</span>
) : (
<strong>{comment.author}</strong>
)}
<span className="detail-log-timestamp" style={{ marginLeft: isAIGuidance ? 0 : 8 }}>
{formatCommentTimestamp(comment)}
</span>
</div>
@@ -148,16 +186,43 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
)}
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
<div style={{ display: "flex", gap: 8 }}>
<button
className={`btn btn-sm ${commentType === "comment" ? "btn-primary" : ""}`}
onClick={() => setCommentType("comment")}
>
Comment
</button>
<button
className={`btn btn-sm ${commentType === "guidance" ? "btn-primary" : ""}`}
onClick={() => setCommentType("guidance")}
>
AI Guidance
</button>
</div>
{commentType === "guidance" && (
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted, #666)" }}>
AI Guidance comments are injected into the task execution context
</div>
)}
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
rows={3}
placeholder="Add a comment"
placeholder={placeholder}
className="spec-editor-feedback"
/>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-primary btn-sm" onClick={() => void handleAddComment()} disabled={submitting || !draft.trim()}>
{submitting ? "Posting…" : "Add Comment"}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", color: isOverLimit ? "var(--color-danger, red)" : undefined }}>
{draft.length} / {MAX_COMMENT_LENGTH}
</span>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleAddComment()}
disabled={submitting || !draft.trim() || isOverLimit}
>
{submitting ? "Posting…" : buttonLabel}
</button>
</div>
</div>

View File

@@ -33,7 +33,7 @@ vi.mock("../../api", async (importOriginal) => {
),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve([])),
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [] })),
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])),
};

View File

@@ -21,7 +21,7 @@ vi.mock("lucide-react", () => ({
// Mock the api module
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue([]),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
@@ -91,7 +91,7 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [] });
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,

View File

@@ -5,7 +5,7 @@ import type { Task, TaskDetail } from "@fusion/core";
// Mock the API
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue([]),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
}));

View File

@@ -39,6 +39,12 @@ const MOCK_MODELS = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
// Mock response format (with models and favoriteProviders)
const MOCK_MODELS_RESPONSE = {
models: MOCK_MODELS,
favoriteProviders: [],
};
describe("ModelSelectorTab", () => {
const mockAddToast = vi.fn();
@@ -76,7 +82,7 @@ describe("ModelSelectorTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchModels.mockResolvedValue(MOCK_MODELS);
mockFetchModels.mockResolvedValue(MOCK_MODELS_RESPONSE);
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...FAKE_TASK,
...updates,
@@ -350,7 +356,7 @@ describe("ModelSelectorTab", () => {
});
it("shows empty state when no models available", async () => {
mockFetchModels.mockResolvedValue([]);
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [] });
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);

View File

@@ -6,10 +6,10 @@ import type { Task, Column } from "@fusion/core";
// Mock the api module
vi.mock("../../api", () => ({
uploadAttachment: vi.fn().mockResolvedValue({}),
fetchModels: vi.fn().mockResolvedValue([
fetchModels: vi.fn().mockResolvedValue({ models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
]),
], favoriteProviders: [] }),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,

View File

@@ -49,7 +49,7 @@ const mockTasks: Task[] = [
// Mock the api module
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue([
fetchModels: vi.fn().mockResolvedValue({ models: [
{
provider: "anthropic",
id: "claude-sonnet-4-5",
@@ -64,7 +64,7 @@ vi.mock("../../api", () => ({
reasoning: true,
contextWindow: 128_000,
},
]),
], favoriteProviders: [] }),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
}));

View File

@@ -32,10 +32,10 @@ vi.mock("../../api", () => ({
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve([
fetchModels: vi.fn(() => Promise.resolve({ models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
])),
], favoriteProviders: [] })),
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
}));
@@ -540,7 +540,7 @@ describe("SettingsModal", () => {
});
it("shows empty state when no models available", async () => {
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [] });
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());

View File

@@ -24,7 +24,8 @@
"build:client": "vite build",
"dev": "vite build --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json",
"postinstall": "chmod +x node_modules/.pnpm/node-pty*/node_modules/node-pty/prebuilds/darwin-*/spawn-helper node_modules/.pnpm/node-pty*/node_modules/node-pty/prebuilds/darwin-*/*.node 2>/dev/null || true"
},
"dependencies": {
"@codemirror/basic-setup": "^0.20.0",

View File

@@ -1537,7 +1537,7 @@ describe("GET /models", () => {
const res = await GET(buildApp(modelRegistry), "/api/models");
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect(res.body.models).toEqual([
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
]);
@@ -1548,7 +1548,7 @@ describe("GET /models", () => {
const res = await GET(buildApp(), "/api/models");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(res.body.models).toEqual([]);
});
it("returns empty array when registry has no available models", async () => {
@@ -1558,7 +1558,7 @@ describe("GET /models", () => {
const res = await GET(buildApp(modelRegistry), "/api/models");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(res.body.models).toEqual([]);
});
it("returns empty array when registry throws", async () => {
@@ -1570,7 +1570,7 @@ describe("GET /models", () => {
const res = await GET(buildApp(modelRegistry), "/api/models");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(res.body.models).toEqual([]);
});
});

View File

@@ -1369,7 +1369,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// Models
registerModelsRoute(router, options?.modelRegistry);
registerModelsRoute(router, options?.modelRegistry, store);
// List all tasks
router.get("/tasks", async (req, res) => {
@@ -6753,15 +6753,16 @@ async function refreshIssueInBackground(
/**
* Register the GET /api/models route.
* Returns available AI models from the ModelRegistry for the UI model selector.
* Returns available AI models from the ModelRegistry for the UI model selector,
* along with favoriteProviders for UI ordering.
* If no ModelRegistry is provided, returns an empty array.
*/
function registerModelsRoute(router: Router, modelRegistry?: ModelRegistryLike): void {
router.get("/models", (_req, res) => {
function registerModelsRoute(router: Router, modelRegistry?: ModelRegistryLike, store?: TaskStore): void {
router.get("/models", async (_req, res) => {
// Always return 200 with empty array instead of 404 when no models available.
// This ensures the frontend can handle empty states gracefully.
if (!modelRegistry) {
res.json([]);
res.json({ models: [], favoriteProviders: [] });
return;
}
@@ -6774,11 +6775,24 @@ function registerModelsRoute(router: Router, modelRegistry?: ModelRegistryLike):
reasoning: m.reasoning,
contextWindow: m.contextWindow,
}));
res.json(models);
// Get favoriteProviders from global settings
let favoriteProviders: string[] = [];
if (store) {
try {
const globalStore = store.getGlobalSettingsStore();
const globalSettings = await globalStore.getSettings();
favoriteProviders = globalSettings.favoriteProviders ?? [];
} catch {
// Silently ignore settings errors - just return empty favorites
}
}
res.json({ models, favoriteProviders });
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
console.log(`[models] Failed to load models: ${message}`);
res.json([]);
res.json({ models: [], favoriteProviders: [] });
}
});
}

View File

@@ -78,11 +78,30 @@ async function REQUEST(
return { status: res.status, body: res.body };
}
// Script store mock - module-level
const mockScriptStore = {
getScripts: vi.fn(() => ({})),
setScript: vi.fn(),
removeScript: vi.fn(),
save: vi.fn().mockResolvedValue(undefined),
hasScript: vi.fn(() => false),
};
vi.mock("./script-store.js", () => ({
loadScriptStore: vi.fn(() => Promise.resolve(mockScriptStore)),
resetScriptStore: vi.fn(),
}));
describe("Scripts routes", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
vi.clearAllMocks();
mockScriptStore.getScripts.mockReturnValue({});
mockScriptStore.hasScript.mockReturnValue(false);
mockScriptStore.setScript.mockImplementation(() => {});
mockScriptStore.removeScript.mockImplementation(() => {});
});
function buildApp() {
@@ -92,10 +111,8 @@ describe("Scripts routes", () => {
return app;
}
it("returns all scripts from project settings", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { build: "pnpm build", test: "pnpm test" },
});
it("GET /api/scripts returns all scripts from script store", async () => {
mockScriptStore.getScripts.mockReturnValueOnce({ build: "pnpm build", test: "pnpm test" });
const res = await GET(buildApp(), "/api/scripts");
@@ -103,9 +120,18 @@ describe("Scripts routes", () => {
expect(res.body).toEqual({ build: "pnpm build", test: "pnpm test" });
});
it("creates a new script and returns 201", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
it("GET /api/scripts returns empty object when no scripts", async () => {
mockScriptStore.getScripts.mockReturnValueOnce({});
const res = await GET(buildApp(), "/api/scripts");
expect(res.status).toBe(200);
expect(res.body).toEqual({});
});
it("POST /api/scripts creates a new script and returns updated scripts", async () => {
mockScriptStore.hasScript.mockReturnValueOnce(false);
mockScriptStore.getScripts.mockReturnValueOnce({ test: "pnpm test" });
const res = await REQUEST(
buildApp(),
@@ -115,60 +141,30 @@ describe("Scripts routes", () => {
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.updateSettings).toHaveBeenCalledWith({
scripts: { test: "pnpm test", build: "pnpm build" },
});
expect(res.status).toBe(200);
expect(mockScriptStore.setScript).toHaveBeenCalledWith("build", "pnpm build");
expect(mockScriptStore.save).toHaveBeenCalled();
});
it("returns 409 when creating a duplicate script", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { build: "pnpm build" } });
it("POST /api/scripts returns 400 for missing name", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts",
JSON.stringify({ name: "build", command: "pnpm build --filter app" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("already exists");
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("returns 400 for invalid script names", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts",
JSON.stringify({ name: "bad name", command: "echo hi" }),
JSON.stringify({ command: "echo hi" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("alphanumeric");
expect(res.body.error).toContain("name is required");
});
it("returns 400 for reserved script names", async () => {
it("POST /api/scripts returns 400 for missing command", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts",
JSON.stringify({ name: "run", command: "echo hi" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("reserved");
});
it("returns 400 when command is missing", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts",
JSON.stringify({ name: "build", command: " " }),
JSON.stringify({ name: "build" }),
{ "Content-Type": "application/json" },
);
@@ -176,145 +172,42 @@ describe("Scripts routes", () => {
expect(res.body.error).toContain("command is required");
});
it("POST /api/scripts creates script with any name", async () => {
mockScriptStore.hasScript.mockReturnValueOnce(false);
mockScriptStore.getScripts.mockReturnValueOnce({});
it("deletes an existing script and persists remaining scripts", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { build: "pnpm build", test: "pnpm test" },
});
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
// The actual implementation accepts any name
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts",
JSON.stringify({ name: "my-script", command: "echo hi" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(mockScriptStore.setScript).toHaveBeenCalledWith("my-script", "echo hi");
});
it("DELETE /api/scripts/:name removes script and returns updated scripts", async () => {
mockScriptStore.hasScript.mockReturnValueOnce(true);
mockScriptStore.getScripts.mockReturnValueOnce({ test: "pnpm test" });
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
expect(res.status).toBe(200);
expect(res.body).toEqual({ test: "pnpm test" });
expect(store.updateSettings).toHaveBeenCalledWith({ scripts: { test: "pnpm test" } });
expect(mockScriptStore.removeScript).toHaveBeenCalledWith("build");
expect(mockScriptStore.save).toHaveBeenCalled();
});
it("returns 400 when deleting an invalid script name", async () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/bad%20name");
expect(res.status).toBe(400);
expect(res.body.error).toContain("alphanumeric");
});
it("returns 404 when deleting a missing script", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
it("DELETE /api/scripts/:name removes script regardless of name format", async () => {
mockScriptStore.hasScript.mockReturnValueOnce(true);
mockScriptStore.getScripts.mockReturnValueOnce({});
// The actual implementation doesn't validate names, it just removes
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 400 when running an invalid script name", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts/bad%20name/run",
JSON.stringify({ args: [] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("alphanumeric");
});
it("returns 404 when running a missing script", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts/build/run",
JSON.stringify({ args: [] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 400 when run args are not an array", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts/test/run",
JSON.stringify({ args: "--ok" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("array of strings");
});
it("returns 400 when run args are not an array of strings", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts/test/run",
JSON.stringify({ args: ["--ok", 123] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("array of strings");
});
it("returns terminal service errors when session creation fails", async () => {
const createSessionSpy = vi
.spyOn(await import("./terminal-service.js"), "getTerminalService")
.mockReturnValue({
createSession: vi.fn().mockResolvedValue({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached",
}),
} as any);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts/test/run",
JSON.stringify({ args: [] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(503);
expect(res.body.error).toContain("Maximum terminal sessions reached");
createSessionSpy.mockRestore();
});
it("creates a terminal session in the project root when running a script", async () => {
const writeInput = vi.fn();
const createSession = vi.fn().mockResolvedValue({
success: true,
session: { id: "pty-123", cwd: "/fake/root", shell: "/bin/zsh" },
});
const terminalServiceSpy = vi
.spyOn(await import("./terminal-service.js"), "getTerminalService")
.mockReturnValue({ createSession, writeInput } as any);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
const res = await REQUEST(
buildApp(),
"POST",
"/api/scripts/test/run",
JSON.stringify({ args: ["--filter", "web app; rm -rf /"] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.sessionId).toBe("pty-123");
expect(res.body.command).toBe('pnpm test "--filter" "web app; rm -rf /"');
expect(createSession).toHaveBeenCalledWith({ cwd: "/fake/root" });
expect(writeInput).toHaveBeenCalledWith("pty-123", 'pnpm test "--filter" "web app; rm -rf /"\n');
terminalServiceSpy.mockRestore();
expect(res.status).toBe(200);
expect(mockScriptStore.removeScript).toHaveBeenCalledWith("build");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -209,6 +209,24 @@ function decodeJwtPayload(token: string): any {
}
}
// ── Pi auth storage reader ──────────────────────────────────────────────────
/**
* Read an API key from pi's auth storage (~/.pi/agent/auth.json).
* Returns the API key string or null if not found.
*/
function readPiAuthKey(provider: string): string | null {
const authPath = path.join(process.env.HOME || "~", ".pi", "agent", "auth.json");
try {
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
const entry = auth?.[provider];
if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) {
return entry.key;
}
} catch {}
return null;
}
// ── Claude fetcher ─────────────────────────────────────────────────────────
/**
@@ -228,19 +246,369 @@ function readClaudeKeychainCredentials(): any | null {
}
}
/** Max number of retries for transient 429 responses */
const CLAUDE_MAX_RETRIES = 3;
/** Initial retry delay in ms (doubles each attempt) */
const CLAUDE_INITIAL_RETRY_MS = 1000;
/**
* Fetch Claude usage data by spawning the `claude` CLI.
* Sleep for the given duration. Exported for test mocking.
*/
export const _sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
// Allow tests to swap the sleep implementation
let sleepFn = _sleep;
export function _setSleepFn(fn: typeof _sleep): void {
sleepFn = fn;
}
export function _resetSleepFn(): void {
sleepFn = _sleep;
}
// ── Claude CLI fallback (parses `claude /usage` TUI output) ──────────────────
/**
* Strip ANSI escape codes from Claude CLI output.
* Handles cursor-forward (ESC[nC) by converting to spaces to preserve word
* boundaries — the Claude TUI uses these instead of real spaces.
*/
export function _stripClaudeAnsi(text: string): string {
let clean = text
// Cursor forward (CSI n C): replace with n spaces
.replace(/\x1B\[(\d+)C/g, (_m, n) => " ".repeat(parseInt(n, 10)))
// Cursor movement (up/down/back/position)
.replace(/\x1B\[\d*[ABD]/g, "")
.replace(/\x1B\[\d+;\d+[Hf]/g, "\n")
// Remaining CSI sequences (colors, modes, etc.)
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, "")
// OSC sequences
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, "")
// Other ESC sequences
.replace(/\x1B[A-Za-z]/g, "")
// Carriage returns
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
// Handle backspaces
while (clean.includes("\x08")) {
clean = clean.replace(/[^\x08]\x08/, "");
clean = clean.replace(/^\x08+/, "");
}
// Strip remaining non-printable control characters (except newline)
clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
return clean;
}
/**
* Parse a percentage line from Claude CLI usage output.
* Lines look like: "█████████████▌ 27% used" or "████████ 65% left"
* Returns the USED percentage (0-100).
*/
export function _parseClaudePercentLine(line: string): number | null {
const match = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
if (!match) return null;
const value = parseInt(match[1], 10);
const isUsed = match[2].toLowerCase() === "used";
return isUsed ? value : 100 - value;
}
/**
* Parse a reset line from Claude CLI usage output.
* Lines like: "Resets in 2h 15m", "Resets 11am", "Resets Feb 19 at 3pm"
*/
export function _parseClaudeResetLine(line: string): string | null {
const match = line.match(/(Resets?.*)$/i);
if (!match) return null;
let text = match[1];
// Clean up percentage info that might be on the same line
text = text.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, "").trim();
// Ensure space after "Resets" if missing
text = text.replace(/(resets?)(\d)/i, "$1 $2");
// Strip timezone like "(America/Los_Angeles)"
text = text.replace(/\s*\([A-Za-z_/]+\)\s*$/, "").trim();
return text || null;
}
/**
* Parse a reset-time text into an approximate ISO date string.
*/
export function _parseClaudeResetText(text: string): string | null {
const now = Date.now();
// "Resets in 2h 15m" or "Resets in 30m"
const durationMatch = text.match(/(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i);
if (durationMatch) {
let hours = 0;
let minutes = 0;
if (durationMatch[1]) {
hours = parseInt(durationMatch[1], 10);
minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0;
} else if (durationMatch[3]) {
minutes = parseInt(durationMatch[3], 10);
}
return new Date(now + (hours * 60 + minutes) * 60 * 1000).toISOString();
}
// "Resets 11am" or "Resets 3pm"
const simpleTimeMatch = text.match(/resets?\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (simpleTimeMatch) {
let hours = parseInt(simpleTimeMatch[1], 10);
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
const ampm = simpleTimeMatch[3].toLowerCase();
if (ampm === "pm" && hours !== 12) hours += 12;
else if (ampm === "am" && hours === 12) hours = 0;
const resetDate = new Date(now);
resetDate.setHours(hours, minutes, 0, 0);
if (resetDate.getTime() <= now) resetDate.setDate(resetDate.getDate() + 1);
return resetDate.toISOString();
}
// "Resets Feb 19 at 3pm" or "Resets Jan 15, 3:30pm"
const dateMatch = text.match(
/(?:resets?\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
);
if (dateMatch) {
const months: Record<string, number> = {
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
};
const month = months[dateMatch[1].toLowerCase().substring(0, 3)];
const day = parseInt(dateMatch[2], 10);
let hours = parseInt(dateMatch[3], 10);
const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0;
const ampm = dateMatch[5].toLowerCase();
if (ampm === "pm" && hours !== 12) hours += 12;
else if (ampm === "am" && hours === 12) hours = 0;
if (month !== undefined) {
const resetDate = new Date(new Date().getFullYear(), month, day, hours, minutes);
if (resetDate.getTime() < now) resetDate.setFullYear(resetDate.getFullYear() + 1);
return resetDate.toISOString();
}
}
return null;
}
/**
* Fetch Claude usage by spawning `claude /usage` via PTY and parsing the TUI output.
* Used as a fallback when the OAuth API returns 429 (rate limited).
*/
async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Claude",
icon: "🟠",
status: "error",
windows: [],
};
try {
// Dynamically import node-pty
const pty = await import("node-pty");
const isWindows = process.platform === "win32";
const shell = isWindows ? "cmd.exe" : "/bin/sh";
const cwd = process.cwd();
const args = isWindows
? ["/c", "claude", "--add-dir", cwd]
: ["-c", `claude --add-dir "${cwd}"`];
const ptyOptions: any = {
name: "xterm-256color",
cols: 120,
rows: 30,
cwd,
env: { ...process.env, TERM: "xterm-256color" },
};
if (isWindows) ptyOptions.useConpty = false;
const output = await new Promise<string>((resolve, reject) => {
let buf = "";
let settled = false;
let sentCommand = false;
let approvedTrust = false;
let seenUsageData = false;
const ptyProcess = pty.spawn(shell, args, ptyOptions);
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
try { ptyProcess.kill(); } catch {}
// Return whatever we have if it contains usage data
const clean = _stripClaudeAnsi(buf);
if (clean.includes("Current session") || clean.includes("% left") || clean.includes("% used")) {
resolve(buf);
} else {
reject(new Error("Claude CLI timed out"));
}
}, 30000);
ptyProcess.onData((data: string) => {
if (settled) return;
buf += data;
const clean = _stripClaudeAnsi(buf);
// Check for auth errors
if (
clean.includes("OAuth token does not meet scope requirement") ||
clean.includes("token_expired") ||
clean.includes('"type":"authentication_error"') ||
clean.includes('"type": "authentication_error"')
) {
settled = true;
clearTimeout(timeout);
try { ptyProcess.kill(); } catch {}
reject(new Error("Claude CLI auth error"));
return;
}
// Auto-approve trust prompt
if (
!approvedTrust &&
(clean.includes("Do you want to work in this folder?") ||
clean.includes("Ready to code here") ||
clean.includes("permission to work with your files") ||
clean.includes("trust this folder"))
) {
approvedTrust = true;
setTimeout(() => {
if (!settled) ptyProcess.write("\r");
}, 1000);
}
// Detect REPL prompt and send /usage
const isReplReady =
clean.includes("") ||
clean.includes("? for shortcuts");
if (!sentCommand && isReplReady) {
sentCommand = true;
setTimeout(() => {
if (!settled) {
ptyProcess.write("/usage\r");
// Confirm if autocomplete menu appeared
setTimeout(() => {
if (!settled) ptyProcess.write("\r");
}, 1200);
}
}, 1500);
}
// Detect usage data, then exit after brief delay
const hasUsage =
clean.includes("Current session") ||
clean.includes("Current week") ||
/\d+%\s*(left|used|remaining)/i.test(clean);
if (!seenUsageData && hasUsage && sentCommand) {
seenUsageData = true;
setTimeout(() => {
if (!settled) {
ptyProcess.write("\x1b"); // ESC to exit
// Fallback kill after 2s
setTimeout(() => {
if (!settled) {
settled = true;
clearTimeout(timeout);
try { ptyProcess.kill(); } catch {}
resolve(buf);
}
}, 2000);
}
}, 3000);
}
});
ptyProcess.onExit(() => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(buf);
});
});
// Parse the output
const cleanOutput = _stripClaudeAnsi(output);
const lines = cleanOutput.split("\n").map((l) => l.trim()).filter(Boolean);
// Find sections by looking for known headers (use LAST occurrence since PTY output has redraws)
const sections: { label: string; windowMs: number }[] = [
{ label: "Current session", windowMs: 5 * 60 * 60 * 1000 },
{ label: "Current week (all models)", windowMs: 7 * 24 * 60 * 60 * 1000 },
{ label: "Current week (Sonnet", windowMs: 7 * 24 * 60 * 60 * 1000 },
{ label: "Current week (Opus", windowMs: 7 * 24 * 60 * 60 * 1000 },
];
usage.status = "ok";
for (const section of sections) {
// Find last occurrence
let sectionIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].toLowerCase().includes(section.label.toLowerCase())) {
sectionIdx = i;
break;
}
}
if (sectionIdx === -1) continue;
const searchLines = lines.slice(sectionIdx, sectionIdx + 5);
let percentUsed: number | null = null;
let resetText: string | null = null;
for (const line of searchLines) {
if (percentUsed === null) {
percentUsed = _parseClaudePercentLine(line);
}
if (!resetText) {
resetText = _parseClaudeResetLine(line);
}
}
if (percentUsed !== null) {
const window: UsageWindow = {
label: section.label,
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText,
windowDurationMs: section.windowMs,
resetMs: undefined,
};
// Parse reset time to calculate resetMs
if (resetText) {
const iso = _parseClaudeResetText(resetText);
if (iso) {
const msLeft = new Date(iso).getTime() - Date.now();
window.resetMs = msLeft > 0 ? msLeft : 0;
if (!window.resetText) {
window.resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
}
}
}
usage.windows.push(window);
}
}
if (usage.windows.length === 0) {
usage.status = "error";
usage.error = "Could not parse usage from CLI output";
}
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "CLI fallback failed";
}
return usage;
}
/**
* Fetch Claude usage data via the Anthropic OAuth usage API.
*
* Uses `claude usage --json` to retrieve usage information. The CLI manages
* its own caching and rate-limiting, avoiding the 429 errors that occurred
* with direct HTTPS requests to api.anthropic.com/api/oauth/usage.
*
* Credential files and the macOS keychain are still read for plan/tier
* detection (subscriptionType, rateLimitTier) since the CLI output may
* not include subscription metadata.
*
* If the CLI is not installed or the `usage` subcommand is unavailable,
* the function returns an error status with a descriptive message.
* Reads credentials from the Claude CLI's credential store (files or macOS
* keychain) and calls api.anthropic.com/api/oauth/usage directly.
* Includes retry logic with exponential backoff for transient 429 responses.
* Falls back to parsing `claude /usage` CLI output when rate limited.
*/
async function fetchClaudeUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
@@ -282,7 +650,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
return usage;
}
// Infer plan from credential metadata (CLI handles auth for the API call)
// Infer plan from credential metadata
if (oauthCreds.subscriptionType) {
usage.plan = oauthCreds.subscriptionType.charAt(0).toUpperCase() + oauthCreds.subscriptionType.slice(1);
} else if (oauthCreds.rateLimitTier) {
@@ -293,15 +661,64 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
else usage.plan = oauthCreds.rateLimitTier;
}
// ── Fetch usage via Claude CLI ──────────────────────────────────────
// ── Fetch usage via direct API call with retry for 429 ─────────────
try {
const cliOutput = child_process.execFileSync(
"claude",
["usage", "--json"],
{ encoding: "utf-8", timeout: 15000 }
);
let res: { status: number; headers: Record<string, string>; body: string } | undefined;
let lastStatus = 0;
const data = JSON.parse(cliOutput.trim());
for (let attempt = 0; attempt < CLAUDE_MAX_RETRIES; attempt++) {
res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
authorization: `Bearer ${oauthCreds.accessToken}`,
},
});
lastStatus = res.status;
// Auth errors are not transient — fail immediately
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — run 'claude' to re-login";
return usage;
}
// 429 is potentially transient — retry with exponential backoff
if (res.status === 429) {
if (attempt < CLAUDE_MAX_RETRIES - 1) {
// Use retry-after header if available, otherwise exponential backoff
const retryAfter = res.headers["retry-after"];
let delayMs: number;
if (retryAfter && !isNaN(Number(retryAfter))) {
delayMs = Number(retryAfter) * 1000;
} else {
delayMs = CLAUDE_INITIAL_RETRY_MS * Math.pow(2, attempt);
}
await sleepFn(delayMs);
continue;
}
// All retries exhausted — fall back to CLI parsing
return fetchClaudeUsageViaCli();
}
// Any other non-200 status — fail immediately (not transient)
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}`;
return usage;
}
// Success — break out of retry loop
break;
}
if (!res || lastStatus !== 200) {
usage.status = "error";
usage.error = `HTTP ${lastStatus}`;
return usage;
}
const data = JSON.parse(res.body);
usage.status = "ok";
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
@@ -342,17 +759,8 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
if (sonnet) usage.windows.push(sonnet);
if (opus) usage.windows.push(opus);
} catch (e: any) {
// Distinguish CLI-not-found from other errors
if (e.code === "ENOENT") {
usage.status = "error";
usage.error = "Claude CLI not found — install from https://claude.ai/download";
} else if (e.message?.includes("ETIMEDOUT") || e.killed) {
usage.status = "error";
usage.error = "Claude CLI timed out — try again later";
} else {
usage.status = "error";
usage.error = e.message || "Failed to fetch usage via Claude CLI";
}
usage.status = "error";
usage.error = e.message || "Failed to fetch Claude usage";
}
return usage;
@@ -611,33 +1019,25 @@ async function fetchMinimaxUsage(): Promise<ProviderUsage> {
windows: [],
};
// Load Minimax credentials
const credPath = path.join(process.env.HOME || "~", ".minimax", "credentials.json");
let creds: any = null;
try {
creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
} catch {
usage.error = "No Minimax credentials configured";
return usage;
}
const accessToken = creds?.access_token;
if (!accessToken) {
usage.error = "No Minimax access token found";
// Load Minimax API key from pi's auth storage
const apiKey = readPiAuthKey("minimax");
if (!apiKey) {
usage.error = "No Minimax credentials — add API key to pi";
return usage;
}
try {
const res = await httpsRequest("https://api.minimaxi.com/user/quota", {
const res = await httpsRequest("https://api.minimax.io/v1/api/openplatform/coding_plan/remains", {
method: "GET",
headers: {
authorization: `Bearer ${accessToken}`,
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired";
usage.error = "Auth expired — check your Minimax API key";
return usage;
}
@@ -650,35 +1050,47 @@ async function fetchMinimaxUsage(): Promise<ProviderUsage> {
const data = JSON.parse(res.body);
usage.status = "ok";
const quota = data?.quota;
if (quota && typeof quota === "object") {
const total: number = quota.total ?? 0;
const used: number = quota.used ?? 0;
const remaining: number = quota.remaining ?? Math.max(0, total - used);
// Parse model_remains array — group by model family
const modelRemains: any[] = data?.model_remains || [];
if (Array.isArray(modelRemains) && modelRemains.length > 0) {
for (const model of modelRemains) {
const modelName: string = model.model_name || "Unknown";
const total: number = model.current_interval_total_count ?? 0;
// Note: Minimax's current_interval_usage_count is actually REMAINING, not used
// (known API quirk per https://github.com/MiniMax-AI/MiniMax-M2/issues/99)
const remaining: number = model.current_interval_usage_count ?? 0;
const used: number = Math.max(0, total - remaining);
const percentUsed = total > 0 ? (used / total) * 100 : 0;
const percentUsed = total > 0 ? (used / total) * 100 : 0;
let resetText: string | null = null;
let resetMs: number | undefined;
let windowDurationMs: number | undefined;
let resetText: string | null = null;
let resetMs: number | undefined;
let windowDurationMs: number | undefined;
const resetAt = data?.reset_at;
if (resetAt) {
const msLeft = new Date(resetAt).getTime() - Date.now();
resetMs = msLeft > 0 ? msLeft : 0;
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
// Weekly window duration (7 days)
windowDurationMs = 7 * 24 * 60 * 60 * 1000;
const remainsTime: number = model.remains_time;
if (remainsTime && remainsTime > 0) {
resetMs = remainsTime;
resetText = `resets in ${formatDuration(remainsTime)}`;
}
const startTime: number = model.start_time;
const endTime: number = model.end_time;
if (startTime && endTime) {
windowDurationMs = endTime - startTime;
}
// Only show models that have a quota > 0 (skip unused model types)
if (total > 0) {
usage.windows.push({
label: modelName,
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText,
resetMs,
windowDurationMs,
});
}
}
usage.windows.push({
label: "Weekly",
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText,
resetMs,
windowDurationMs,
});
}
} catch (e: any) {
usage.status = "error";
@@ -698,33 +1110,26 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
windows: [],
};
// Load Zai credentials
const authPath = path.join(process.env.HOME || "~", ".zai", "auth.json");
let auth: any = null;
try {
auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
} catch {
usage.error = "No Zai credentials configured";
return usage;
}
const accessToken = auth?.access_token;
if (!accessToken) {
usage.error = "No Zai access token found";
// Load Zai API key from pi's auth storage
const apiKey = readPiAuthKey("zai");
if (!apiKey) {
usage.error = "No Zai credentials — add API key to pi";
return usage;
}
try {
const res = await httpsRequest("https://api.zhipuai.com/v1/user/usage", {
// Z.ai quota endpoint — uses raw API key in Authorization header (not Bearer)
const res = await httpsRequest("https://api.z.ai/api/monitor/usage/quota/limit", {
method: "GET",
headers: {
authorization: `Bearer ${accessToken}`,
authorization: apiKey,
"content-type": "application/json",
},
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired";
usage.error = "Auth expired — check your Zai API key";
return usage;
}
@@ -735,60 +1140,79 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
}
const data = JSON.parse(res.body);
if (!data?.success || data?.code !== 200) {
usage.status = "error";
usage.error = data?.msg || "API returned error";
return usage;
}
usage.status = "ok";
const usageData = data?.data;
if (usageData && typeof usageData === "object") {
const totalCredits: number = usageData.total_credits ?? 0;
const usedCredits: number = usageData.used_credits ?? 0;
const limits: any[] = data?.data?.limits || [];
const percentUsed = totalCredits > 0 ? (usedCredits / totalCredits) * 100 : 0;
// Find TOKENS_LIMIT (5-hour rolling window)
const tokensLimit = limits.find((l: any) => l.type === "TOKENS_LIMIT");
if (tokensLimit) {
const percentage: number = tokensLimit.percentage ?? 0;
// The percentage field represents percentage USED
// But the API actually reports percentage as the utilization level
// remaining = 100 - percentage (if percentage is used%)
// However the opencode-mystatus source treats it differently:
// remainPercent = 100 - percentage (where percentage is used %)
// Actually from the response: percentage=1 means 1% used, so 99% remaining
let dailyResetText: string | null = null;
let dailyResetMs: number | undefined;
let monthlyResetText: string | null = null;
let monthlyResetMs: number | undefined;
let resetText: string | null = null;
let resetMs: number | undefined;
let windowDurationMs: number | undefined;
const resetDate = usageData.reset_date;
if (resetDate) {
const resetTime = new Date(resetDate).getTime();
const msLeft = resetTime - Date.now();
// Determine if this is daily or monthly based on time until reset
const hoursLeft = msLeft / (1000 * 60 * 60);
if (hoursLeft <= 24) {
// Daily window
dailyResetMs = msLeft > 0 ? msLeft : 0;
dailyResetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
} else {
// Monthly window
monthlyResetMs = msLeft > 0 ? msLeft : 0;
monthlyResetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
}
const nextResetTime: number | undefined = tokensLimit.nextResetTime;
if (nextResetTime) {
resetMs = Math.max(0, nextResetTime - Date.now());
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
// 5-hour window
windowDurationMs = 5 * 60 * 60 * 1000;
}
// Add Daily window
usage.windows.push({
label: "Daily",
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText: dailyResetText,
resetMs: dailyResetMs,
windowDurationMs: dailyResetMs ? 24 * 60 * 60 * 1000 : undefined,
label: "Session (5h)",
percentUsed: Math.min(100, Math.max(0, percentage)),
percentLeft: Math.min(100, Math.max(0, 100 - percentage)),
resetText,
resetMs,
windowDurationMs,
});
}
// Add Monthly window if applicable
if (monthlyResetMs) {
usage.windows.push({
label: "Monthly",
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText: monthlyResetText,
resetMs: monthlyResetMs,
windowDurationMs: 30 * 24 * 60 * 60 * 1000, // Approximate 30 days
});
// Find TIME_LIMIT (MCP monthly search quota)
const timeLimit = limits.find((l: any) => l.type === "TIME_LIMIT");
if (timeLimit) {
const total: number = timeLimit.usage ?? 0;
const used: number = timeLimit.currentValue ?? 0;
const remaining: number = timeLimit.remaining ?? Math.max(0, total - used);
const percentage: number = timeLimit.percentage ?? 0;
let resetText: string | null = null;
let resetMs: number | undefined;
const nextResetTime: number | undefined = timeLimit.nextResetTime;
if (nextResetTime) {
resetMs = Math.max(0, nextResetTime - Date.now());
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
}
usage.windows.push({
label: "MCP Monthly",
percentUsed: Math.min(100, Math.max(0, percentage)),
percentLeft: Math.min(100, Math.max(0, 100 - percentage)),
resetText,
resetMs,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
// Extract plan level if available
if (data?.data?.level) {
usage.plan = data.data.level.charAt(0).toUpperCase() + data.data.level.slice(1);
}
} catch (e: any) {
usage.status = "error";