1204 lines
35 KiB
TypeScript
1204 lines
35 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
import { emitTo } from "@tauri-apps/api/event";
|
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import "./App.css";
|
|
import { ConversationWorkspace } from "./components/ConversationWorkspace";
|
|
import { DirectorySidebar } from "./components/DirectorySidebar";
|
|
import { ExtensionDialog } from "./components/ExtensionDialog";
|
|
import { CommandFormDialog } from "./components/CommandFormDialog";
|
|
import type { RpcCommand } from "./commands/rpc";
|
|
import { SessionPicker } from "./components/SessionPicker";
|
|
import { SessionTabs } from "./components/SessionTabs";
|
|
import {
|
|
catalogModels,
|
|
getModelCatalog,
|
|
modelIdentity,
|
|
modelIdentityFromValue,
|
|
modelIdentityValue,
|
|
} from "./model-catalog";
|
|
import type {
|
|
DirectorySession,
|
|
DirectoryWorkspace,
|
|
Model,
|
|
QuickPaneSettings,
|
|
RuntimeView,
|
|
UiState,
|
|
} from "./workspace/types";
|
|
import { useWorkspace } from "./workspace/useWorkspace";
|
|
|
|
const minimumUiScale = 0.9,
|
|
maximumUiScale = 1.4;
|
|
const clamp = (value: number) =>
|
|
Math.min(maximumUiScale, Math.max(minimumUiScale, value));
|
|
const defaultQuickPaneSettings: QuickPaneSettings = {
|
|
workspace: "",
|
|
provider: "",
|
|
model: "",
|
|
followUpProvider: "",
|
|
followUpModel: "",
|
|
instructions:
|
|
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
|
|
maximumSeconds: 60,
|
|
maximumToolCalls: 6,
|
|
};
|
|
function validateQuickPaneSettings(settings: QuickPaneSettings) {
|
|
if (!settings.workspace.startsWith("/"))
|
|
return "Workspace must be an absolute path.";
|
|
if (!settings.provider || !settings.model)
|
|
return "Quick provider and model are required.";
|
|
if (!settings.followUpProvider || !settings.followUpModel)
|
|
return "Follow-up provider and model are required.";
|
|
if (!settings.instructions.trim())
|
|
return "Supplemental instructions are required.";
|
|
if (
|
|
!Number.isInteger(settings.maximumSeconds) ||
|
|
settings.maximumSeconds < 1 ||
|
|
settings.maximumSeconds > 600
|
|
)
|
|
return "Seconds must be an integer from 1 to 600.";
|
|
if (
|
|
!Number.isInteger(settings.maximumToolCalls) ||
|
|
settings.maximumToolCalls < 1 ||
|
|
settings.maximumToolCalls > 64
|
|
)
|
|
return "Tool calls must be an integer from 1 to 64.";
|
|
}
|
|
let submissionId = 0;
|
|
type LocalOperation = {
|
|
id: number;
|
|
kind:
|
|
| "creating"
|
|
| "opening"
|
|
| "closing"
|
|
| "listing"
|
|
| "submitting"
|
|
| "command"
|
|
| "restarting"
|
|
| "renewing";
|
|
detail: string;
|
|
runtimeId?: string;
|
|
command?: string;
|
|
};
|
|
type CommandNotice = {
|
|
kind: "error";
|
|
message: string;
|
|
runtimeId: string;
|
|
};
|
|
|
|
const modelRpcCommands = new Set([
|
|
"get_available_models",
|
|
"set_model",
|
|
"cycle_model",
|
|
"set_thinking_level",
|
|
"cycle_thinking_level",
|
|
]);
|
|
|
|
function requestedCommand(command: string, args: Record<string, unknown>) {
|
|
return command === "pi_rpc_command" && typeof args.command === "string"
|
|
? args.command
|
|
: command;
|
|
}
|
|
|
|
function sessionStateLabel(state?: string) {
|
|
return !state || state === "idle" ? "Pi ready" : state;
|
|
}
|
|
|
|
function commandProgress(command: string) {
|
|
if (command === "restart") return "Restarting Pi and restoring the session…";
|
|
if (command === "renew_session")
|
|
return "Clearing session history and starting anew…";
|
|
if (command === "get_available_models") return "Loading available Pi models…";
|
|
if (command === "set_model") return "Changing Pi model…";
|
|
if (command === "cycle_model") return "Cycling Pi model…";
|
|
if (command === "set_thinking_level") return "Updating thinking level…";
|
|
if (command === "cycle_thinking_level") return "Cycling thinking level…";
|
|
return "Updating Pi session…";
|
|
}
|
|
|
|
export default function App() {
|
|
const { state, dispatch, refresh, loadSnapshot } = useWorkspace();
|
|
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
|
|
const [runtimeStatuses, setRuntimeStatuses] = useState<Record<string, string>>(
|
|
{},
|
|
);
|
|
const [commandForms, setCommandForms] = useState<Record<string, RpcCommand>>(
|
|
{},
|
|
);
|
|
const [commandNotices, setCommandNotices] = useState<
|
|
Record<string, CommandNotice>
|
|
>({});
|
|
const [view, setView] = useState<"conversation" | "settings">("conversation");
|
|
const [adding, setAdding] = useState(false);
|
|
const [folderPath, setFolderPath] = useState("");
|
|
const [sessions, setSessions] = useState<DirectorySession[] | undefined>(
|
|
undefined,
|
|
);
|
|
const selected = state.selectedRuntimeId
|
|
? state.runtimesById[state.selectedRuntimeId]
|
|
: undefined;
|
|
const directory = state.selectedDirectoryPath
|
|
? state.directoriesByPath[state.selectedDirectoryPath]
|
|
: undefined;
|
|
const runtimes = useMemo(
|
|
() =>
|
|
(state.runtimeOrderByDirectory[state.selectedDirectoryPath ?? ""] ?? [])
|
|
.map((id) => state.runtimesById[id])
|
|
.filter(Boolean),
|
|
[
|
|
state.runtimeOrderByDirectory,
|
|
state.selectedDirectoryPath,
|
|
state.runtimesById,
|
|
],
|
|
);
|
|
const mountedRef = useRef(true);
|
|
const operationSequence = useRef(0);
|
|
const activeAgentCommands = useRef(new Set<string>());
|
|
const [operations, setOperations] = useState<Record<number, LocalOperation>>(
|
|
{},
|
|
);
|
|
const [abortingRuntimeIds, setAbortingRuntimeIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
|
|
const [uiState, setUiState] = useState<UiState>();
|
|
const [quickPaneSettings, setQuickPaneSettings] = useState<QuickPaneSettings>(
|
|
defaultQuickPaneSettings,
|
|
);
|
|
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
|
|
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
|
|
const [modelCatalog, setModelCatalog] = useState<Model[]>([]);
|
|
const setRuntimeStatus = (runtimeId: string, detail: string) =>
|
|
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
|
|
const clearRuntimeStatus = (runtimeId: string) =>
|
|
setRuntimeStatuses((current) => {
|
|
if (!(runtimeId in current)) return current;
|
|
const next = { ...current };
|
|
delete next[runtimeId];
|
|
return next;
|
|
});
|
|
const startOperation = (
|
|
kind: LocalOperation["kind"],
|
|
detail: string,
|
|
runtimeId?: string,
|
|
command?: string,
|
|
) => {
|
|
const operation = {
|
|
id: ++operationSequence.current,
|
|
kind,
|
|
detail,
|
|
runtimeId,
|
|
command,
|
|
};
|
|
setOperations((current) => ({ ...current, [operation.id]: operation }));
|
|
if (runtimeId) clearRuntimeStatus(runtimeId);
|
|
else setStatus(detail);
|
|
return operation.id;
|
|
};
|
|
const finishOperation = (id: number) =>
|
|
setOperations((current) => {
|
|
if (!(id in current)) return current;
|
|
const next = { ...current };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
void invoke<UiState>("load_ui_state")
|
|
.then((loaded) => {
|
|
if (!active) return;
|
|
setUiState(loaded);
|
|
setQuickPaneSettings({
|
|
...defaultQuickPaneSettings,
|
|
...loaded.quickPane,
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
if (active)
|
|
setQuickPaneSettingsError(
|
|
`Could not load Quick Pane settings: ${String(error)}`,
|
|
);
|
|
});
|
|
void getModelCatalog()
|
|
.then((models) => {
|
|
if (active) setModelCatalog(models);
|
|
})
|
|
.catch((error) => {
|
|
if (active)
|
|
setQuickPaneSettingsError(
|
|
`Could not load model catalog: ${String(error)}`,
|
|
);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
const selectedRuntimeId = selected?.summary.runtimeId;
|
|
useEffect(() => {
|
|
if (!selectedRuntimeId) return;
|
|
requestAnimationFrame(() =>
|
|
dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
|
|
);
|
|
}, [selectedRuntimeId, dispatch]);
|
|
|
|
useEffect(() => {
|
|
setAbortingRuntimeIds((current) => {
|
|
const next = new Set(
|
|
[...current].filter(
|
|
(runtimeId) =>
|
|
state.runtimesById[runtimeId]?.summary.state === "streaming",
|
|
),
|
|
);
|
|
return next.size === current.size ? current : next;
|
|
});
|
|
}, [state.runtimesById]);
|
|
|
|
async function saveQuickPaneSettings() {
|
|
const validationError = validateQuickPaneSettings(quickPaneSettings);
|
|
if (validationError) {
|
|
setQuickPaneSettingsError(validationError);
|
|
return;
|
|
}
|
|
if (!uiState) return;
|
|
try {
|
|
const current = await invoke<UiState>("load_ui_state");
|
|
const next = { ...current, quickPane: quickPaneSettings };
|
|
await invoke("save_ui_state", { state: next });
|
|
void emitTo(
|
|
"quick-pane",
|
|
"quick-pane-settings-saved",
|
|
quickPaneSettings,
|
|
).catch(() => undefined);
|
|
setUiState(next);
|
|
setQuickPaneSettingsError(undefined);
|
|
setQuickPaneSettingsStatus("Quick Pane settings saved.");
|
|
} catch (error) {
|
|
setQuickPaneSettingsError(
|
|
`Could not save Quick Pane settings: ${String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function createRuntime(path = state.selectedDirectoryPath) {
|
|
if (!path || !mountedRef.current) return;
|
|
const operationId = startOperation("creating", "Starting a new Pi session…");
|
|
try {
|
|
const result = await invoke<{ runtime: RuntimeView["summary"] }>(
|
|
"create_session_runtime",
|
|
{ worktreePath: path },
|
|
);
|
|
if (!mountedRef.current) return;
|
|
await refresh();
|
|
if (!mountedRef.current) return;
|
|
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
|
|
await loadSnapshot(result.runtime.runtimeId);
|
|
if (mountedRef.current) setStatus("New session ready");
|
|
} catch (error) {
|
|
if (mountedRef.current)
|
|
setStatus(`Could not start session: ${String(error)}`);
|
|
} finally {
|
|
if (mountedRef.current) finishOperation(operationId);
|
|
}
|
|
}
|
|
async function closeRuntime(runtime: RuntimeView) {
|
|
if (
|
|
runtime.summary.state === "streaming" &&
|
|
!window.confirm(
|
|
"Close this working session? Pi will be stopped, but its history is kept.",
|
|
)
|
|
)
|
|
return;
|
|
const operationId = startOperation(
|
|
"closing",
|
|
`Closing ${runtime.summary.label}…`,
|
|
runtime.summary.runtimeId,
|
|
);
|
|
try {
|
|
await invoke("close_session_runtime", {
|
|
runtimeId: runtime.summary.runtimeId,
|
|
});
|
|
if (!mountedRef.current) return;
|
|
await refresh();
|
|
if (mountedRef.current) setRestoreTabFocus(true);
|
|
} catch (error) {
|
|
if (mountedRef.current)
|
|
setRuntimeStatus(
|
|
runtime.summary.runtimeId,
|
|
`Could not close session: ${String(error)}`,
|
|
);
|
|
} finally {
|
|
if (mountedRef.current) finishOperation(operationId);
|
|
}
|
|
}
|
|
async function showSessions() {
|
|
if (!state.selectedDirectoryPath) return;
|
|
const operationId = startOperation("listing", "Loading saved sessions…");
|
|
try {
|
|
const result = await invoke<{ sessions: DirectorySession[] }>(
|
|
"list_directory_sessions",
|
|
{ worktreePath: state.selectedDirectoryPath },
|
|
);
|
|
setSessions(result.sessions);
|
|
} catch (error) {
|
|
setStatus(`Could not load saved sessions: ${String(error)}`);
|
|
} finally {
|
|
if (mountedRef.current) finishOperation(operationId);
|
|
}
|
|
}
|
|
async function openSession(session: DirectorySession) {
|
|
if (session.runtimeId) {
|
|
dispatch({ type: "selectRuntime", runtimeId: session.runtimeId });
|
|
setSessions(undefined);
|
|
return;
|
|
}
|
|
if (!state.selectedDirectoryPath || !mountedRef.current) return;
|
|
const operationId = startOperation("opening", "Opening saved Pi session…");
|
|
try {
|
|
const result = await invoke<{ runtime: RuntimeView["summary"] }>(
|
|
"open_session_runtime",
|
|
{
|
|
worktreePath: state.selectedDirectoryPath,
|
|
sessionPath: session.path,
|
|
},
|
|
);
|
|
if (!mountedRef.current) return;
|
|
await refresh();
|
|
if (!mountedRef.current) return;
|
|
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
|
|
await loadSnapshot(result.runtime.runtimeId);
|
|
if (mountedRef.current) setSessions(undefined);
|
|
} catch (error) {
|
|
if (mountedRef.current)
|
|
setStatus(`Could not open session: ${String(error)}`);
|
|
} finally {
|
|
if (mountedRef.current) finishOperation(operationId);
|
|
}
|
|
}
|
|
async function addDirectory() {
|
|
const path = folderPath.trim();
|
|
if (!path.startsWith("/")) {
|
|
setStatus("Enter an absolute folder path");
|
|
return;
|
|
}
|
|
await createRuntime(path);
|
|
if (mountedRef.current) {
|
|
setFolderPath("");
|
|
setAdding(false);
|
|
}
|
|
}
|
|
async function forgetDirectory(entry: DirectoryWorkspace) {
|
|
if (!window.confirm(`Forget ${entry.worktreePath}?`)) return;
|
|
try {
|
|
await invoke("forget_directory", { worktreePath: entry.worktreePath });
|
|
await refresh();
|
|
} catch (error) {
|
|
setStatus(String(error));
|
|
}
|
|
}
|
|
async function agentCommand(
|
|
command: string,
|
|
args: Record<string, unknown> = {},
|
|
reload = true,
|
|
target = selected,
|
|
showOperation = true,
|
|
) {
|
|
const runtimeId = target?.summary.runtimeId,
|
|
agentId = target?.summary.agentId;
|
|
if (!runtimeId || !agentId) {
|
|
if (runtimeId)
|
|
setRuntimeStatus(runtimeId, "This session is not currently available");
|
|
else setStatus("This session is not currently available");
|
|
return false;
|
|
}
|
|
if (activeAgentCommands.current.has(runtimeId)) return false;
|
|
const operationCommand = requestedCommand(command, args);
|
|
const restarting = operationCommand === "restart";
|
|
const renewing = operationCommand === "renew_session";
|
|
activeAgentCommands.current.add(runtimeId);
|
|
setCommandNotices((current) => {
|
|
if (!(runtimeId in current)) return current;
|
|
const next = { ...current };
|
|
delete next[runtimeId];
|
|
return next;
|
|
});
|
|
const operationId = showOperation
|
|
? startOperation(
|
|
renewing ? "renewing" : restarting ? "restarting" : "command",
|
|
commandProgress(operationCommand),
|
|
runtimeId,
|
|
operationCommand,
|
|
)
|
|
: undefined;
|
|
try {
|
|
await invoke(command, { agentId, ...args });
|
|
if (reload) await loadSnapshot(runtimeId, { throwOnError: true });
|
|
return true;
|
|
} catch (error) {
|
|
const subject = modelRpcCommands.has(operationCommand)
|
|
? "Pi model command failed"
|
|
: renewing
|
|
? "Session renewal failed"
|
|
: restarting
|
|
? "Pi restart failed"
|
|
: "Pi command failed";
|
|
const message = `${subject}: ${String(error)}`;
|
|
setCommandNotices((current) => ({
|
|
...current,
|
|
[runtimeId]: { kind: "error", message, runtimeId },
|
|
}));
|
|
return false;
|
|
} finally {
|
|
activeAgentCommands.current.delete(runtimeId);
|
|
if (mountedRef.current && operationId) finishOperation(operationId);
|
|
}
|
|
}
|
|
async function submit() {
|
|
if (!selected) return;
|
|
const text = selected.draft.trim();
|
|
if (!text) return;
|
|
if (text === "/new") {
|
|
dispatch({
|
|
type: "draftChanged",
|
|
runtimeId: selected.summary.runtimeId,
|
|
draft: "",
|
|
});
|
|
await createRuntime();
|
|
return;
|
|
}
|
|
if (text === "/resume") {
|
|
dispatch({
|
|
type: "draftChanged",
|
|
runtimeId: selected.summary.runtimeId,
|
|
draft: "",
|
|
});
|
|
await showSessions();
|
|
return;
|
|
}
|
|
const name = text.match(/^\/name\s+([\s\S]+)$/)?.[1]?.trim();
|
|
if (text === "/name" || name) {
|
|
if (!name) {
|
|
setRuntimeStatus(
|
|
selected.summary.runtimeId,
|
|
"Enter a session name after /name",
|
|
);
|
|
return;
|
|
}
|
|
await agentCommand("set_session_name", { name });
|
|
dispatch({
|
|
type: "draftChanged",
|
|
runtimeId: selected.summary.runtimeId,
|
|
draft: "",
|
|
});
|
|
return;
|
|
}
|
|
const compact = text.match(/^\/compact(?:\s+([\s\S]+))?$/);
|
|
if (compact) {
|
|
await agentCommand("compact", {
|
|
customInstructions: compact[1]?.trim() || undefined,
|
|
});
|
|
dispatch({
|
|
type: "draftChanged",
|
|
runtimeId: selected.summary.runtimeId,
|
|
draft: "",
|
|
});
|
|
return;
|
|
}
|
|
const runtimeId = selected.summary.runtimeId,
|
|
id = ++submissionId,
|
|
initialDelivery =
|
|
selected.summary.state === "streaming" ? "follow_up" : "prompt",
|
|
operationId = startOperation(
|
|
"submitting",
|
|
initialDelivery === "follow_up"
|
|
? "Queueing follow-up…"
|
|
: "Sending prompt to Pi…",
|
|
selected.summary.runtimeId,
|
|
);
|
|
dispatch({
|
|
type: "submissionAdded",
|
|
runtimeId,
|
|
submission: {
|
|
id,
|
|
text,
|
|
userMessageCount: selected.messages.filter(
|
|
(message) => message.role === "user",
|
|
).length,
|
|
phase: "sending",
|
|
delivery: initialDelivery,
|
|
},
|
|
});
|
|
dispatch({ type: "draftChanged", runtimeId, draft: "" });
|
|
try {
|
|
const result = await invoke<{ delivery?: "prompt" | "follow_up" }>(
|
|
"submit_prompt",
|
|
{ agentId: selected.summary.agentId, message: text },
|
|
);
|
|
const delivery = result.delivery ?? initialDelivery;
|
|
dispatch({ type: "submissionSent", runtimeId, id, delivery });
|
|
clearRuntimeStatus(runtimeId);
|
|
} catch (error) {
|
|
setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
|
|
dispatch({ type: "submissionRemoved", runtimeId, id });
|
|
dispatch({ type: "draftChanged", runtimeId, draft: text });
|
|
} finally {
|
|
finishOperation(operationId);
|
|
}
|
|
}
|
|
async function abortSelected() {
|
|
const target = selected;
|
|
if (!target?.summary.agentId) return;
|
|
const runtimeId = target.summary.runtimeId;
|
|
setAbortingRuntimeIds((current) => new Set(current).add(runtimeId));
|
|
clearRuntimeStatus(runtimeId);
|
|
try {
|
|
await invoke("abort", { agentId: target.summary.agentId });
|
|
} catch (error) {
|
|
setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
|
|
setAbortingRuntimeIds((current) => {
|
|
const next = new Set(current);
|
|
next.delete(runtimeId);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
async function runRpcCommand(
|
|
command: RpcCommand,
|
|
input: Record<string, unknown> = {},
|
|
target = selected,
|
|
) {
|
|
if (!target) return;
|
|
const runtimeId = target.summary.runtimeId;
|
|
const succeeded = await agentCommand(
|
|
"pi_rpc_command",
|
|
{ command: command.command, input },
|
|
true,
|
|
target,
|
|
);
|
|
if (succeeded)
|
|
setCommandForms((current) => {
|
|
const next = { ...current };
|
|
delete next[runtimeId];
|
|
return next;
|
|
});
|
|
}
|
|
async function respond(response: Record<string, unknown>) {
|
|
const target = selected;
|
|
const extension = target?.extensions[0];
|
|
if (!extension?.id) return;
|
|
await agentCommand(
|
|
"respond_to_extension",
|
|
{ requestId: extension.id, response },
|
|
true,
|
|
target,
|
|
);
|
|
}
|
|
const closePicker = useCallback(() => setSessions(undefined), []);
|
|
const activeOperations = Object.values(operations);
|
|
const globalOperation = activeOperations
|
|
.filter((entry) => !entry.runtimeId)
|
|
.reduce<LocalOperation | undefined>(
|
|
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
|
|
undefined,
|
|
);
|
|
const selectedOperations = selectedRuntimeId
|
|
? activeOperations.filter((entry) => entry.runtimeId === selectedRuntimeId)
|
|
: [];
|
|
const selectedOperation = selectedOperations.reduce<
|
|
LocalOperation | undefined
|
|
>(
|
|
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
|
|
undefined,
|
|
);
|
|
const pendingSubmissions = selected?.pendingSubmissions ?? [];
|
|
const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1];
|
|
const isSubmitting =
|
|
pendingSubmissions.some((entry) => entry.phase === "sending") ||
|
|
selectedOperations.some((entry) => entry.kind === "submitting");
|
|
const isRestarting = selectedOperations.some(
|
|
(entry) => entry.kind === "restarting",
|
|
);
|
|
const isRenewing = selectedOperations.some(
|
|
(entry) => entry.kind === "renewing",
|
|
);
|
|
const isAborting = selectedRuntimeId
|
|
? abortingRuntimeIds.has(selectedRuntimeId)
|
|
: false;
|
|
const isSessionLocked = isRestarting || isRenewing;
|
|
const visibleCommandForm = selectedRuntimeId
|
|
? commandForms[selectedRuntimeId]
|
|
: undefined;
|
|
const commandOperation = selectedOperations.find(
|
|
(entry) => entry.kind === "command",
|
|
);
|
|
const commandFormPending = Boolean(commandOperation);
|
|
const modelCommandPending =
|
|
commandFormPending && modelRpcCommands.has(commandOperation?.command ?? "");
|
|
const visibleCommandNotice = selectedRuntimeId
|
|
? commandNotices[selectedRuntimeId]
|
|
: undefined;
|
|
const displayedStatus =
|
|
state.newSessionLaunch?.detail ??
|
|
globalOperation?.detail ??
|
|
(isAborting ? "Aborting Pi…" : undefined) ??
|
|
selectedOperation?.detail ??
|
|
(pendingSubmission
|
|
? pendingSubmission.phase === "sending"
|
|
? pendingSubmission.delivery === "follow_up"
|
|
? "Queueing follow-up…"
|
|
: "Sending prompt to Pi…"
|
|
: pendingSubmission.delivery === "follow_up"
|
|
? "Follow-up queued · waiting for Pi…"
|
|
: "Prompt accepted · waiting for Pi…"
|
|
: undefined) ??
|
|
(selectedRuntimeId ? runtimeStatuses[selectedRuntimeId] : undefined) ??
|
|
(selected?.progress.phase === "working" ||
|
|
selected?.progress.phase === "recovering"
|
|
? selected.progress.detail
|
|
: undefined) ??
|
|
selected?.error ??
|
|
selected?.summary.error?.message ??
|
|
selected?.progress.detail ??
|
|
status;
|
|
const isWorking =
|
|
state.newSessionLaunch?.phase === "starting" ||
|
|
isSessionLocked ||
|
|
isSubmitting ||
|
|
selected?.progress.phase === "working" ||
|
|
selected?.progress.phase === "recovering";
|
|
const sessionStatusLabel =
|
|
state.newSessionLaunch?.phase === "starting"
|
|
? "Starting"
|
|
: isRenewing
|
|
? "Renewing"
|
|
: isRestarting
|
|
? "Restarting"
|
|
: isWorking
|
|
? "Working"
|
|
: sessionStateLabel(selected?.summary.state);
|
|
const sessionStatusPhase = isWorking
|
|
? ("working" as const)
|
|
: (selected?.progress.phase ?? "idle");
|
|
const isStartingSession =
|
|
state.newSessionLaunch?.phase === "starting" ||
|
|
activeOperations.some((entry) => entry.kind === "creating");
|
|
const sessionTabsBusy = activeOperations.some(
|
|
(entry) =>
|
|
!entry.runtimeId &&
|
|
(entry.kind === "creating" ||
|
|
entry.kind === "opening" ||
|
|
entry.kind === "listing"),
|
|
);
|
|
const closingRuntimeIds = new Set(
|
|
activeOperations
|
|
.filter((entry) => entry.kind === "closing" && entry.runtimeId)
|
|
.map((entry) => entry.runtimeId as string),
|
|
);
|
|
const renewingRuntimeIds = new Set(
|
|
activeOperations
|
|
.filter((entry) => entry.kind === "renewing" && entry.runtimeId)
|
|
.map((entry) => entry.runtimeId as string),
|
|
);
|
|
const sameDirectoryCollision = directory && directory.openCount > 1;
|
|
const quickPaneModelOptions = catalogModels(modelCatalog, [
|
|
{
|
|
provider: quickPaneSettings.provider,
|
|
modelId: quickPaneSettings.model,
|
|
},
|
|
{
|
|
provider: quickPaneSettings.followUpProvider,
|
|
modelId: quickPaneSettings.followUpModel,
|
|
},
|
|
]);
|
|
const quickModelValue =
|
|
quickPaneSettings.provider && quickPaneSettings.model
|
|
? modelIdentityValue({
|
|
provider: quickPaneSettings.provider,
|
|
modelId: quickPaneSettings.model,
|
|
})
|
|
: "";
|
|
const followUpModelValue =
|
|
quickPaneSettings.followUpProvider && quickPaneSettings.followUpModel
|
|
? modelIdentityValue({
|
|
provider: quickPaneSettings.followUpProvider,
|
|
modelId: quickPaneSettings.followUpModel,
|
|
})
|
|
: "";
|
|
return (
|
|
<main
|
|
className="app-shell"
|
|
style={{ zoom: state.interfaceScale }}
|
|
aria-busy={isStartingSession || isSessionLocked}
|
|
>
|
|
<header
|
|
className="workflow-header"
|
|
onMouseDown={(event) => {
|
|
if (
|
|
event.button === 0 &&
|
|
!(event.target as HTMLElement).closest("button,input,select,textarea")
|
|
)
|
|
void getCurrentWindow().startDragging();
|
|
}}
|
|
>
|
|
<div className="workspace-heading">
|
|
<span className="brand">PI</span>
|
|
<h1>{selected?.summary.label ?? "Pi workspace"}</h1>
|
|
</div>
|
|
<button
|
|
className="quiet"
|
|
onClick={() =>
|
|
setView(view === "conversation" ? "settings" : "conversation")
|
|
}
|
|
>
|
|
{view === "conversation" ? "Controls" : "Back"}
|
|
</button>
|
|
</header>
|
|
{state.resourceWarning && (
|
|
<div className="workspace-warning" role="alert">
|
|
Many Pi sessions are open. Startup and memory use may be high.
|
|
</div>
|
|
)}
|
|
{sameDirectoryCollision && !state.dismissedCollisionWarning && (
|
|
<div className="workspace-warning collision" role="alert">
|
|
<span>
|
|
Multiple sessions are open in the same directory. Concurrent work can
|
|
collide in files and Git; separate worktrees are safer.
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="quiet warning-dismiss"
|
|
onClick={() => dispatch({ type: "dismissCollisionWarning" })}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
{visibleCommandNotice && !visibleCommandForm && (
|
|
<div className="command-error" role="alert">
|
|
<span>{visibleCommandNotice.message}</span>
|
|
<button
|
|
type="button"
|
|
className="quiet warning-dismiss"
|
|
onClick={() => {
|
|
if (!selectedRuntimeId) return;
|
|
setCommandNotices((current) => {
|
|
const next = { ...current };
|
|
delete next[selectedRuntimeId];
|
|
return next;
|
|
});
|
|
}}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
{selected?.modelError && (
|
|
<div className="command-error" role="alert">
|
|
<span>{selected.modelError}</span>
|
|
</div>
|
|
)}
|
|
{selected?.notifications.map((notice, index) => (
|
|
<div
|
|
className={`notification ${notice.notifyType ?? "info"}`}
|
|
role={notice.notifyType === "error" ? "alert" : "status"}
|
|
key={`${notice.id ?? "notice"}-${index}`}
|
|
>
|
|
{notice.message}
|
|
</div>
|
|
))}
|
|
<div className="workspace-layout">
|
|
<DirectorySidebar
|
|
state={state}
|
|
onSelect={(path) =>
|
|
dispatch({ type: "selectDirectory", worktreePath: path })
|
|
}
|
|
onAdd={() => setAdding(true)}
|
|
onForget={forgetDirectory}
|
|
/>
|
|
<section className="session-area">
|
|
{adding && (
|
|
<form
|
|
className="directory-add"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void addDirectory();
|
|
}}
|
|
>
|
|
<input
|
|
autoFocus
|
|
value={folderPath}
|
|
onChange={(event) => setFolderPath(event.currentTarget.value)}
|
|
placeholder="/absolute/path"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
const path = await open({ directory: true, multiple: false });
|
|
if (path) setFolderPath(path);
|
|
}}
|
|
>
|
|
Browse…
|
|
</button>
|
|
<button type="submit">Add</button>
|
|
<button type="button" className="quiet" onClick={() => setAdding(false)}>
|
|
Cancel
|
|
</button>
|
|
</form>
|
|
)}
|
|
<SessionTabs
|
|
runtimes={runtimes}
|
|
selectedId={state.selectedRuntimeId}
|
|
onSelect={(runtimeId) => dispatch({ type: "selectRuntime", runtimeId })}
|
|
onClose={(runtime) => void closeRuntime(runtime)}
|
|
onRenew={(runtime) =>
|
|
void agentCommand("renew_session", {}, true, runtime)
|
|
}
|
|
onNew={() => void createRuntime()}
|
|
onResume={() => void showSessions()}
|
|
busy={sessionTabsBusy}
|
|
closingRuntimeIds={closingRuntimeIds}
|
|
renewingRuntimeIds={renewingRuntimeIds}
|
|
restoreFocus={restoreTabFocus}
|
|
onFocusRestored={() => setRestoreTabFocus(false)}
|
|
/>
|
|
{view === "settings" ? (
|
|
<section className="settings" aria-busy={isSessionLocked}>
|
|
<h2>Session controls</h2>
|
|
<label className="default-worktree-control">
|
|
Default new-session directory
|
|
<select
|
|
value={state.defaultNewSessionWorktree ?? ""}
|
|
onChange={(event) =>
|
|
dispatch({
|
|
type: "setDefaultNewSessionWorktree",
|
|
worktreePath: event.currentTarget.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Use system default</option>
|
|
{state.directoryOrder.map((path) => (
|
|
<option key={path} value={path}>
|
|
{path}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>
|
|
Used by Ctrl+Super+Space. Only currently open directories are listed.
|
|
</small>
|
|
</label>
|
|
<form
|
|
className="quick-pane-settings"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void saveQuickPaneSettings();
|
|
}}
|
|
>
|
|
<h2>Quick Pane</h2>
|
|
<label>
|
|
Workspace
|
|
<input
|
|
value={quickPaneSettings.workspace}
|
|
onChange={(event) =>
|
|
setQuickPaneSettings({
|
|
...quickPaneSettings,
|
|
workspace: event.currentTarget.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Quick model
|
|
<select
|
|
value={quickModelValue}
|
|
onChange={(event) => {
|
|
const identity = modelIdentityFromValue(event.currentTarget.value);
|
|
if (!identity) return;
|
|
setQuickPaneSettings({
|
|
...quickPaneSettings,
|
|
provider: identity.provider,
|
|
model: identity.modelId,
|
|
});
|
|
}}
|
|
>
|
|
<option value="" disabled>
|
|
Select model
|
|
</option>
|
|
{quickPaneModelOptions.map((model) => {
|
|
const identity = modelIdentity(model);
|
|
if (!identity) return null;
|
|
return (
|
|
<option
|
|
key={modelIdentityValue(identity)}
|
|
value={modelIdentityValue(identity)}
|
|
>
|
|
{identity.provider}/{identity.modelId}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
<small>Provider comes from selected catalog model.</small>
|
|
</label>
|
|
<label>
|
|
Follow-up model
|
|
<select
|
|
value={followUpModelValue}
|
|
onChange={(event) => {
|
|
const identity = modelIdentityFromValue(event.currentTarget.value);
|
|
if (!identity) return;
|
|
setQuickPaneSettings({
|
|
...quickPaneSettings,
|
|
followUpProvider: identity.provider,
|
|
followUpModel: identity.modelId,
|
|
});
|
|
}}
|
|
>
|
|
<option value="" disabled>
|
|
Select model
|
|
</option>
|
|
{quickPaneModelOptions.map((model) => {
|
|
const identity = modelIdentity(model);
|
|
if (!identity) return null;
|
|
return (
|
|
<option
|
|
key={modelIdentityValue(identity)}
|
|
value={modelIdentityValue(identity)}
|
|
>
|
|
{identity.provider}/{identity.modelId}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
<small>Provider comes from selected catalog model.</small>
|
|
</label>
|
|
<label>
|
|
Maximum seconds
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="600"
|
|
value={quickPaneSettings.maximumSeconds}
|
|
onChange={(event) =>
|
|
setQuickPaneSettings({
|
|
...quickPaneSettings,
|
|
maximumSeconds: Number(event.currentTarget.value),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Maximum tool calls
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="64"
|
|
value={quickPaneSettings.maximumToolCalls}
|
|
onChange={(event) =>
|
|
setQuickPaneSettings({
|
|
...quickPaneSettings,
|
|
maximumToolCalls: Number(event.currentTarget.value),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Supplemental instructions
|
|
<textarea
|
|
value={quickPaneSettings.instructions}
|
|
onChange={(event) =>
|
|
setQuickPaneSettings({
|
|
...quickPaneSettings,
|
|
instructions: event.currentTarget.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<button type="submit" disabled={!uiState}>
|
|
Save Quick Pane settings
|
|
</button>
|
|
{quickPaneSettingsStatus && (
|
|
<p role="status">{quickPaneSettingsStatus}</p>
|
|
)}
|
|
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
|
|
</form>
|
|
{selected ? (
|
|
<>
|
|
<p>{selected.summary.worktreePath}</p>
|
|
<p>
|
|
{selected.state.messageCount ?? 0} messages ·{" "}
|
|
{selected.summary.queueCount} queued
|
|
</p>
|
|
<div className="settings-actions">
|
|
<button
|
|
disabled={isSessionLocked}
|
|
onClick={() => void agentCommand("retry")}
|
|
>
|
|
Retry
|
|
</button>
|
|
<button
|
|
className="danger"
|
|
disabled={isSessionLocked}
|
|
onClick={() => void agentCommand("restart")}
|
|
>
|
|
{isRestarting ? "Restarting Pi…" : "Restart Pi"}
|
|
</button>
|
|
</div>
|
|
{isRestarting && (
|
|
<div className="restart-progress" role="status" aria-live="polite">
|
|
<span className="working-pulse" aria-hidden="true" />
|
|
<span>
|
|
<strong>Restarting Pi</strong>
|
|
<small>Restoring this session and its extensions…</small>
|
|
</span>
|
|
</div>
|
|
)}
|
|
<label>
|
|
Interface scale{" "}
|
|
<input
|
|
type="range"
|
|
min={minimumUiScale}
|
|
max={maximumUiScale}
|
|
step="0.05"
|
|
value={state.interfaceScale}
|
|
onChange={(event) =>
|
|
dispatch({
|
|
type: "setScale",
|
|
scale: clamp(Number(event.currentTarget.value)),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
</>
|
|
) : (
|
|
<p>No session selected.</p>
|
|
)}
|
|
</section>
|
|
) : selected ? (
|
|
<ConversationWorkspace
|
|
runtime={selected}
|
|
onDraft={(draft) =>
|
|
dispatch({
|
|
type: "draftChanged",
|
|
runtimeId: selected.summary.runtimeId,
|
|
draft,
|
|
})
|
|
}
|
|
onSubmit={() => void submit()}
|
|
onAbort={() => void abortSelected()}
|
|
onScroll={(atBottom, scrollTop) => {
|
|
dispatch({
|
|
type: "scrollChanged",
|
|
runtimeId: selected.summary.runtimeId,
|
|
atBottom,
|
|
scrollTop,
|
|
});
|
|
if (atBottom)
|
|
requestAnimationFrame(() =>
|
|
dispatch({
|
|
type: "markRead",
|
|
runtimeId: selected.summary.runtimeId,
|
|
}),
|
|
);
|
|
}}
|
|
onModel={(model: Model) =>
|
|
agentCommand("set_model", {
|
|
provider: model.provider,
|
|
modelId: model.id,
|
|
})
|
|
}
|
|
onThinking={(level) => agentCommand("set_thinking_level", { level })}
|
|
isModelUpdating={modelCommandPending}
|
|
locked={isSessionLocked}
|
|
lockReason={isRenewing ? "renewing" : "restarting"}
|
|
status={{
|
|
label: sessionStatusLabel,
|
|
detail: displayedStatus,
|
|
phase: sessionStatusPhase,
|
|
bridge: state.bridge.status,
|
|
extensions: Object.values(selected.extensionStatuses),
|
|
}}
|
|
isSubmitting={isSubmitting}
|
|
isAborting={isAborting}
|
|
onCommand={(command) => {
|
|
if (command.input?.length)
|
|
setCommandForms((current) => ({
|
|
...current,
|
|
[selected.summary.runtimeId]: command,
|
|
}));
|
|
else void runRpcCommand(command, {}, selected);
|
|
}}
|
|
/>
|
|
) : (
|
|
<section className="empty-workspace">
|
|
<h2>No open sessions</h2>
|
|
<p>Start a session in this directory, or add another directory.</p>
|
|
{state.selectedDirectoryPath && (
|
|
<button onClick={() => void createRuntime()}>New session</button>
|
|
)}
|
|
</section>
|
|
)}
|
|
</section>
|
|
</div>
|
|
{sessions && (
|
|
<SessionPicker
|
|
sessions={sessions}
|
|
onOpen={(session) => void openSession(session)}
|
|
onClose={closePicker}
|
|
/>
|
|
)}
|
|
<CommandFormDialog
|
|
command={visibleCommandForm}
|
|
pending={commandFormPending}
|
|
commandError={visibleCommandNotice?.message}
|
|
onCancel={() => {
|
|
if (!selectedRuntimeId) return;
|
|
setCommandForms((current) => {
|
|
const next = { ...current };
|
|
delete next[selectedRuntimeId];
|
|
return next;
|
|
});
|
|
}}
|
|
onSubmit={(input) =>
|
|
visibleCommandForm &&
|
|
void runRpcCommand(visibleCommandForm, input, selected)
|
|
}
|
|
/>
|
|
<ExtensionDialog
|
|
extension={selected?.extensions[0]}
|
|
onRespond={(response) => void respond(response)}
|
|
/>
|
|
{isStartingSession && (
|
|
<div
|
|
className="startup-blocker"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="startup-blocker-title"
|
|
tabIndex={-1}
|
|
autoFocus
|
|
>
|
|
<div className="startup-blocker-card">
|
|
<span className="working-bars startup-bars" aria-hidden="true">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
<div>
|
|
<strong id="startup-blocker-title">Starting new session</strong>
|
|
<p>{state.newSessionLaunch?.detail ?? "Creating a fresh Pi runtime…"}</p>
|
|
<small>Pi is loading its session and extensions. Please wait.</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|