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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,624 changes: 278 additions & 1,346 deletions src/tui/app.tsx

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions src/tui/chrome-geometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { goalShowsAcceptancePanel, goalShowsWorkPrimary } from "../agent/goal.js";
import { extraPromptChromeRows } from "./prompt-layout.js";
import type { PluginsAdmin } from "./components/plugins-manager.js";

export type GoalChromeArgs = {
goalSnapshot: import("../agent/goal.js").GoalSnapshot | null;
};

export type GoalChromeResult = {
goalActive: boolean;
goalPhase: import("../agent/goal.js").GoalPhase | null;
showAcceptance: boolean;
workPrimary: boolean;
};

/** Goal chrome follows lifecycle phase: planning / reviewing / completed →
* Acceptance panel; implementing → Work primary (Acceptance compact; header
* shows phase). */
export function resolveGoalChrome({ goalSnapshot }: GoalChromeArgs): GoalChromeResult {
const goalActive =
goalSnapshot !== null &&
goalSnapshot.status !== "inactive" &&
goalSnapshot.status !== "cleared";
const goalPhase = goalActive ? goalSnapshot!.phase : null;
return {
goalActive,
goalPhase,
showAcceptance: goalPhase !== null && goalShowsAcceptancePanel(goalPhase),
workPrimary: goalPhase !== null && goalShowsWorkPrimary(goalPhase),
};
}

export function goalChromeRowCount(args: {
goalActive: boolean;
showAcceptance: boolean;
criteriaCount: number;
}): number {
const { goalActive, showAcceptance, criteriaCount } = args;
if (!goalActive) return 0;
// compact phase strip during implementing
if (!showAcceptance) return 3;
return (criteriaCount === 0 ? 2 : criteriaCount + 2) + 2;
}

// The task strip renders above the in-flight indicator: one line when compact,
// the full checklist plus its heading when expanded. +1 is the marginTop wrapper.
export function taskChromeRowCount(args: {
hasActiveTasks: boolean;
taskCount: number;
workExpanded: boolean;
}): number {
const { hasActiveTasks, taskCount, workExpanded } = args;
if (!hasActiveTasks) return 0;
return (workExpanded ? taskCount + 1 : 1) + 1;
}

// The plugins overlay renders outside the modal-stack accounting (like the
// permissions overlay), so reserve rows for its box: chrome + one row per
// plugin + the selected plugin's credential rows.
export function pluginChromeRowCount(args: {
pluginsOpen: boolean;
pluginsAdmin: PluginsAdmin | undefined;
}): number {
const { pluginsOpen, pluginsAdmin } = args;
if (!pluginsOpen || pluginsAdmin === undefined) return 0;
const list = pluginsAdmin.list();
const widestCreds = list.reduce((n, p) => Math.max(n, p.credentials.length), 0);
return 6 + list.length + widestCreds + 2;
}

export function extraChromeRowCount(args: {
mcpNeedsAuthCount: number;
commandMessagePresent: boolean;
goalChromeRows: number;
taskChromeRows: number;
pluginChromeRows: number;
quotaErrorPresent: boolean;
inferenceRetryPresent: boolean;
subAgentChromeRows: number;
inputValue: string;
columns: number | undefined;
rows: number | undefined;
}): number {
return (
(args.mcpNeedsAuthCount > 0 ? 1 : 0) +
(args.commandMessagePresent ? 1 : 0) +
args.goalChromeRows +
args.taskChromeRows +
args.pluginChromeRows +
(args.quotaErrorPresent ? 1 : 0) +
(args.inferenceRetryPresent ? 1 : 0) +
args.subAgentChromeRows +
extraPromptChromeRows(args.inputValue, args.columns ?? 80, args.rows ?? 24)
);
}
129 changes: 129 additions & 0 deletions src/tui/components/overlay-stack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { ReactNode } from "react";
import { PermissionsManager } from "./permissions-manager.js";
import { SettingsOverlay, type CompactionMode } from "./settings-overlay.js";
import { PluginsManager, type PluginsAdmin } from "./plugins-manager.js";
import { LoginProviderPicker } from "./login-provider-picker.js";
import { CodexLoginModal } from "./codex-login-modal.js";
import type { ScopedApproval } from "../../permission/admin.js";
import type { LoginModal } from "../hooks/use-provider-auth.js";
import { startCodexLogin } from "../../auth/codex/login.js";
import { startXaiLogin } from "../../auth/xai/login.js";

export type OverlayStackProps = {
permissionsOpen: boolean;
permissionEntries: ScopedApproval[];
onRevokePermission: (entry: ScopedApproval) => void;
onClosePermissions: () => void;
permissionsOverlayRows: number;

settingsOpen: boolean;
compactionMode: CompactionMode;
onChangeCompactionMode: (mode: CompactionMode) => void;
maxConcurrentSubAgents: number;
onChangeMaxConcurrentSubAgents: (limit: number) => void;
sessionMode: import("../../config/session-mode.js").SessionMode;
savedGlobalSessionMode?: import("../../config/session-mode.js").SessionMode;
savedLocalSessionMode?: import("../../config/session-mode.js").SessionMode;
onChangeSessionMode: (
mode: import("../../config/session-mode.js").SessionMode,
scope: "global" | "local",
) => void;
telemetryEnabled: boolean;
onChangeTelemetryEnabled: (enabled: boolean) => void;
waitForApproval: boolean;
onChangeWaitForApproval: (value: boolean) => void;
onCloseSettings: () => void;

pluginsOpen: boolean;
pluginsAdmin: PluginsAdmin | undefined;
onClosePlugins: () => void;
cwd: string;

loginModal: LoginModal;
onSelectLoginProvider: (provider: LoginModal) => void;
onCloseLoginModal: () => void;
xaiProfileNames: string[];
codexProfileNames: string[];
activeProvider: string;
autoLoginProfile: string | undefined;
switchToXaiProfile: (profile: string) => void;
switchToCodexProfile: (profile: string) => void;
removeXaiProfileEverywhere: (profile: string) => void;
removeCodexProfileEverywhere: (profile: string) => void;
};

/** Renders the overlays that sit outside the modal-stack's own accounting:
* permissions manager, settings, plugins manager, and the login flow modals. */
export function OverlayStack(props: OverlayStackProps): ReactNode {
return (
<>
{props.permissionsOpen && (
<PermissionsManager
entries={props.permissionEntries}
onRevoke={props.onRevokePermission}
onClose={props.onClosePermissions}
maxHeight={props.permissionsOverlayRows}
/>
)}
{props.settingsOpen && (
<SettingsOverlay
permissionEntries={props.permissionEntries}
onRevokePermission={props.onRevokePermission}
compactionMode={props.compactionMode}
onChangeCompactionMode={props.onChangeCompactionMode}
maxConcurrentSubAgents={props.maxConcurrentSubAgents}
onChangeMaxConcurrentSubAgents={props.onChangeMaxConcurrentSubAgents}
sessionMode={props.sessionMode}
{...(props.savedGlobalSessionMode !== undefined
? { savedGlobalSessionMode: props.savedGlobalSessionMode }
: {})}
{...(props.savedLocalSessionMode !== undefined
? { savedLocalSessionMode: props.savedLocalSessionMode }
: {})}
onChangeSessionMode={props.onChangeSessionMode}
telemetryEnabled={props.telemetryEnabled}
onChangeTelemetryEnabled={props.onChangeTelemetryEnabled}
waitForApproval={props.waitForApproval}
onChangeWaitForApproval={props.onChangeWaitForApproval}
onClose={props.onCloseSettings}
maxHeight={props.permissionsOverlayRows}
/>
)}
{props.pluginsOpen && props.pluginsAdmin !== undefined && (
<PluginsManager admin={props.pluginsAdmin} onClose={props.onClosePlugins} cwd={props.cwd} />
)}
{props.loginModal === "choose" && (
<LoginProviderPicker
onSelect={props.onSelectLoginProvider}
onClose={props.onCloseLoginModal}
/>
)}
{(props.loginModal === "codex" || props.loginModal === "xai") && (
<CodexLoginModal
profiles={props.loginModal === "xai" ? props.xaiProfileNames : props.codexProfileNames}
activeProfile={props.activeProvider}
providerPrefix={props.loginModal === "xai" ? "xai/" : "codex/"}
title={props.loginModal === "xai" ? "xAI Login" : "Codex Login"}
subtitle={props.loginModal === "xai" ? "Sign in with a SuperGrok or X Premium+ subscription" : "Sign in with a ChatGPT Plus/Pro subscription"}
providerLabel={props.loginModal === "xai" ? "xAI" : "Codex"}
onStartLogin={(name) => {
const controller = new AbortController();
const start = props.loginModal === "xai" ? startXaiLogin : startCodexLogin;
return start({ profile: name, signal: controller.signal }).then((handle) => ({
authorizeUrl: handle.authorizeUrl,
completed: handle.completed,
cancel: () => {
controller.abort();
handle.cancel();
},
}));
}}
autoLoginProfile={props.autoLoginProfile}
onSwitchProfile={props.loginModal === "xai" ? props.switchToXaiProfile : props.switchToCodexProfile}
onRemoveProfile={props.loginModal === "xai" ? props.removeXaiProfileEverywhere : props.removeCodexProfileEverywhere}
onClose={props.onCloseLoginModal}
/>
)}
</>
);
}
50 changes: 50 additions & 0 deletions src/tui/components/retry-banners.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Box, Text } from "ink";
import type { ReactNode } from "react";

function formatCountdown(ms: number): string {
if (ms <= 0) return "now";
const totalSeconds = Math.ceil(ms / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (days > 0) return `${String(days)}d ${String(hours)}h`;
if (hours > 0) return `${String(hours)}h ${String(minutes)}m`;
if (minutes > 0) return `${String(minutes)}m ${String(seconds)}s`;
return `${String(seconds)}s`;
}

export function QuotaErrorBanner({ retryAt }: { retryAt: number }): ReactNode {
const remaining = retryAt - Date.now();
const expired = remaining <= 0;
return (
<Box flexDirection="column" paddingX={1} paddingY={0}>
<Text color="yellow">
{expired
? "Rate limit reached — retrying…"
: `Rate limit reached — auto-retry in ${formatCountdown(remaining)}`}
</Text>
<Text color="cyan">{"[/agent] Switch provider"}</Text>
</Box>
);
}

export function GatewayRetryBanner({
attempt,
retryAt,
}: {
attempt: number;
retryAt: number;
}): ReactNode {
const remaining = retryAt - Date.now();
const expired = remaining <= 0;
return (
<Box flexDirection="column" paddingX={1} paddingY={0}>
<Text color="yellow">
{expired
? `Inference gateway overloaded — retrying (attempt ${attempt})…`
: `Inference gateway overloaded — retrying (attempt ${attempt}) in ${formatCountdown(remaining)}`}
</Text>
</Box>
);
}
Loading
Loading