Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"dev": "bun run src/index.tsx",
"check": "tsc --noEmit",
"test:e2e": "bun test ../../e2e/tui/keybinding-normalize.test.ts ../../e2e/tui/util-text.test.ts ../../e2e/tui/diff.test.ts --timeout 30000",
"test:e2e": "bun test ../../e2e/tui/keybinding-normalize.test.ts ../../e2e/tui/util-text.test.ts ../../e2e/tui/diff.test.ts --timeout 30000 && bun test ../../e2e/tui/app-shell.test.ts --test-name-pattern \"NAV-(SNAP|KEY|INT|EDGE)-\" --timeout 30000",
"test:e2e:full": "bun test ../../e2e/tui/ --timeout 30000"
},
"dependencies": {
Expand Down
14 changes: 6 additions & 8 deletions apps/tui/src/components/AuthErrorScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useRef, useCallback } from "react";
import { useKeyboard } from "@opentui/react";
import { useTheme } from "../hooks/useTheme.js";
import { TextAttributes } from "../theme/tokens.js";
import { detectColorCapability } from "../theme/detect.js";
import { createTheme, TextAttributes } from "../theme/tokens.js";
import type { AuthTokenSource } from "../../../cli/src/auth-state.js";

export interface AuthErrorScreenProps {
Expand All @@ -12,7 +12,7 @@ export interface AuthErrorScreenProps {
}

export function AuthErrorScreen({ variant, host, tokenSource, onRetry }: AuthErrorScreenProps) {
const theme = useTheme();
const theme = createTheme(detectColorCapability());

const lastRetryRef = useRef(0);
const handleRetry = useCallback(() => {
Expand Down Expand Up @@ -40,15 +40,13 @@ export function AuthErrorScreen({ variant, host, tokenSource, onRetry }: AuthErr
<box flexDirection="column" flexGrow={1} justifyContent="center" paddingX={2}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>✗ Not authenticated</text>
<text />
<text>No token found for <text fg={theme.muted}>{host}</text>.</text>
<text>{`No token found for ${host}.`}</text>
<text />
<text>Run the following command to log in:</text>
<text />
<text fg={theme.primary} attributes={TextAttributes.BOLD}> codeplane auth login</text>
<text />
<text>
Or set the <text attributes={TextAttributes.BOLD}>CODEPLANE_TOKEN</text> environment variable.
</text>
<text>Or set the CODEPLANE_TOKEN environment variable.</text>
</box>
<box height={1} border={["top"]} borderStyle="single" borderColor={theme.border}>
<text fg={theme.muted}>q quit │ R retry │ Ctrl+C quit</text>
Expand All @@ -66,7 +64,7 @@ export function AuthErrorScreen({ variant, host, tokenSource, onRetry }: AuthErr
<box flexDirection="column" flexGrow={1} justifyContent="center" paddingX={2}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>✗ Session expired</text>
<text />
<text>Stored token for <text fg={theme.muted}>{host}</text> from {sourceLabel} is invalid or expired.</text>
<text>{`Stored token for ${host} from ${sourceLabel} is invalid or expired.`}</text>
<text />
<text>Run the following command to re-authenticate:</text>
<text />
Expand Down
10 changes: 5 additions & 5 deletions apps/tui/src/components/AuthLoadingScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react";
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
import { useSpinner } from "../hooks/useSpinner.js";
import { useTheme } from "../hooks/useTheme.js";
import { TextAttributes } from "../theme/tokens.js";
import { detectColorCapability } from "../theme/detect.js";
import { createTheme, TextAttributes } from "../theme/tokens.js";
import { truncateText } from "../util/text.js";

export interface AuthLoadingScreenProps {
Expand All @@ -12,7 +12,7 @@ export interface AuthLoadingScreenProps {
export function AuthLoadingScreen({ host }: AuthLoadingScreenProps) {
const { width } = useTerminalDimensions();
const spinnerFrame = useSpinner(true);
const theme = useTheme();
const theme = createTheme(detectColorCapability());

const displayHost = truncateText(host, width - 4);

Expand All @@ -32,10 +32,10 @@ export function AuthLoadingScreen({ host }: AuthLoadingScreenProps) {
justifyContent="center"
alignItems="center"
>
<text>
<box flexDirection="row">
<text fg={theme.primary}>{spinnerFrame}</text>
<text> Authenticating…</text>
</text>
</box>
<text fg={theme.muted}>{displayHost}</text>
</box>

Expand Down
38 changes: 19 additions & 19 deletions apps/tui/src/components/ErrorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,25 +368,25 @@ export function ErrorScreen({
{/* Action hints */}
<box paddingX={config.paddingX} paddingBottom={1}>
<box flexDirection="row" gap={2}>
<text>
<box flexDirection="row">
<text fg={primaryColor} attributes={TextAttributes.BOLD}>r</text>
<text fg={mutedColor}>:restart</text>
</text>
<text>
</box>
<box flexDirection="row">
<text fg={primaryColor} attributes={TextAttributes.BOLD}>q</text>
<text fg={mutedColor}>:quit</text>
</text>
</box>
{hasStack && (
<text>
<box flexDirection="row">
<text fg={primaryColor} attributes={TextAttributes.BOLD}>s</text>
<text fg={mutedColor}>:trace</text>
</text>
</box>
)}
<box flexGrow={1} />
<text>
<box flexDirection="row">
<text fg={primaryColor} attributes={TextAttributes.BOLD}>?</text>
<text fg={mutedColor}>:help</text>
</text>
</box>
</box>
</box>

Expand All @@ -405,17 +405,17 @@ export function ErrorScreen({
>
<text attributes={TextAttributes.BOLD}>Error Screen Keybindings</text>
<text fg={mutedColor}>──────────────────────</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>r</text> Restart TUI</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>q</text> Quit TUI</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>Ctrl+C</text> Quit immediately</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>s</text> Toggle stack trace</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>j/↓</text> Scroll trace down</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>k/↑</text> Scroll trace up</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>G</text> Jump to trace bottom</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>gg</text> Jump to trace top</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>Ctrl+D</text> Page down</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>Ctrl+U</text> Page up</text>
<text><text attributes={TextAttributes.BOLD} fg={primaryColor}>?</text> Close this help</text>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>r</text><text> Restart TUI</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>q</text><text> Quit TUI</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>Ctrl+C</text><text> Quit immediately</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>s</text><text> Toggle stack trace</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>j/↓</text><text> Scroll trace down</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>k/↑</text><text> Scroll trace up</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>G</text><text> Jump to trace bottom</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>gg</text><text> Jump to trace top</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>Ctrl+D</text><text> Page down</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>Ctrl+U</text><text> Page up</text></box>
<box flexDirection="row"><text attributes={TextAttributes.BOLD} fg={primaryColor}>?</text><text> Close this help</text></box>
<box flexGrow={1} />
<text fg={mutedColor}>Press ? or Esc to close</text>
</box>
Expand Down
113 changes: 108 additions & 5 deletions apps/tui/src/components/GlobalKeybindings.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,129 @@
import React, { useCallback } from "react";
import { useNavigation } from "../providers/NavigationProvider.js";
import React, { useCallback, useContext, useEffect, useRef } from "react";
import { useNavigation } from "../hooks/useNavigation.js";
import { useGlobalKeybindings } from "../hooks/useGlobalKeybindings.js";
import { useOverlay } from "../hooks/useOverlay.js";
import { useSidebarState } from "../hooks/useSidebarState.js";
import { executeGoTo, goToBindings } from "../navigation/goToBindings.js";
import { KeybindingContext, StatusBarHintsContext } from "../providers/KeybindingProvider.js";
import { PRIORITY, type KeyHandler } from "../providers/keybinding-types.js";
import { normalizeKeyDescriptor } from "../providers/normalize-key.js";

const GO_TO_TIMEOUT_MS = 1_500;

export function GlobalKeybindings({ children }: { children: React.ReactNode }) {
const nav = useNavigation();
const overlay = useOverlay();
const sidebar = useSidebarState();
const keybindingCtx = useContext(KeybindingContext);
const statusBarCtx = useContext(StatusBarHintsContext);

if (!keybindingCtx) {
throw new Error("GlobalKeybindings must be used within a KeybindingProvider");
}
if (!statusBarCtx) {
throw new Error("GlobalKeybindings must be used within StatusBarHintsContext");
}

const goToScopeIdRef = useRef<string | null>(null);
const goToHintsCleanupRef = useRef<(() => void) | null>(null);
const goToTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const clearGoToMode = useCallback(() => {
if (goToScopeIdRef.current) {
keybindingCtx.removeScope(goToScopeIdRef.current);
goToScopeIdRef.current = null;
}
if (goToHintsCleanupRef.current) {
goToHintsCleanupRef.current();
goToHintsCleanupRef.current = null;
}
if (goToTimeoutRef.current) {
clearTimeout(goToTimeoutRef.current);
goToTimeoutRef.current = null;
}
}, [keybindingCtx]);

useEffect(() => {
return () => {
if (goToScopeIdRef.current) {
keybindingCtx.removeScope(goToScopeIdRef.current);
goToScopeIdRef.current = null;
}
if (goToHintsCleanupRef.current) {
goToHintsCleanupRef.current();
goToHintsCleanupRef.current = null;
}
if (goToTimeoutRef.current) {
clearTimeout(goToTimeoutRef.current);
goToTimeoutRef.current = null;
}
};
// keybindingCtx.removeScope is stable from KeybindingProvider.
// We only want this cleanup on unmount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const onQuit = useCallback(() => {
if (nav.canGoBack) { nav.pop(); } else { process.exit(0); }
if (nav.canPop()) { nav.pop(); } else { process.exit(0); }
}, [nav]);

const onEscape = useCallback(() => {
if (nav.canGoBack) { nav.pop(); }
if (nav.canPop()) { nav.pop(); }
}, [nav]);

const onForceQuit = useCallback(() => { process.exit(0); }, []);
const onHelp = useCallback(() => { overlay.openOverlay("help"); }, [overlay]);
const onCommandPalette = useCallback(() => { overlay.openOverlay("command-palette"); }, [overlay]);
const onGoTo = useCallback(() => { /* TODO: wired in go-to keybindings ticket */ }, []);
const onGoTo = useCallback(() => {
clearGoToMode();

let repoContext: { owner: string; repo: string } | null = null;
for (let i = nav.stack.length - 1; i >= 0; i -= 1) {
const params = nav.stack[i]?.params;
if (params?.owner && params?.repo) {
repoContext = { owner: params.owner, repo: params.repo };
break;
}
}

const goToBindingsMap = new Map<string, KeyHandler>();
for (const binding of goToBindings) {
const key = normalizeKeyDescriptor(binding.key);
goToBindingsMap.set(key, {
key,
description: `Go to ${binding.description}`,
group: "Go-to",
handler: () => {
executeGoTo(nav, binding, repoContext);
clearGoToMode();
},
});
}

const escapeKey = normalizeKeyDescriptor("escape");
goToBindingsMap.set(escapeKey, {
key: escapeKey,
description: "Cancel go-to",
group: "Go-to",
handler: clearGoToMode,
});

goToScopeIdRef.current = keybindingCtx.registerScope({
priority: PRIORITY.GOTO,
bindings: goToBindingsMap,
active: true,
});

goToHintsCleanupRef.current = statusBarCtx.overrideHints([
{ keys: "g d", label: "dashboard", order: 0 },
{ keys: "g r", label: "repositories", order: 10 },
{ keys: "g n", label: "notifications", order: 20 },
{ keys: "g s", label: "search", order: 30 },
{ keys: "Esc", label: "cancel", order: 90 },
]);

goToTimeoutRef.current = setTimeout(clearGoToMode, GO_TO_TIMEOUT_MS);
}, [clearGoToMode, keybindingCtx, nav, statusBarCtx]);
const onToggleSidebar = useCallback(() => { sidebar.toggle(); }, [sidebar]);

useGlobalKeybindings({ onQuit, onEscape, onForceQuit, onHelp, onCommandPalette, onGoTo, onToggleSidebar });
Expand Down
26 changes: 20 additions & 6 deletions apps/tui/src/components/HeaderBar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useMemo } from "react";
import { useLayout } from "../hooks/useLayout.js";
import { useTheme } from "../hooks/useTheme.js";
import { useNavigation } from "../providers/NavigationProvider.js";
import { useNavigation } from "../hooks/useNavigation.js";
import { truncateBreadcrumb } from "../util/text.js";
import { statusToToken, TextAttributes } from "../theme/tokens.js";
import { screenRegistry } from "../router/registry.js";
import { ScreenName } from "../router/types.js";

export function HeaderBar() {
const { width, breakpoint } = useLayout();
Expand All @@ -15,7 +17,23 @@ export function HeaderBar() {
const unreadCount = 0; // placeholder

const breadcrumbSegments = useMemo(() => {
return nav.stack.map((entry) => entry.breadcrumb);
return nav.stack.map((entry) => {
const definition = screenRegistry[entry.screen as ScreenName];
if (!definition) {
return entry.screen;
}
return definition.breadcrumbLabel(entry.params ?? {});
});
}, [nav.stack]);

const repoContext = useMemo(() => {
for (let i = nav.stack.length - 1; i >= 0; i--) {
const params = nav.stack[i]?.params;
if (params?.owner && params?.repo) {
return `${params.owner}/${params.repo}`;
}
}
return "";
}, [nav.stack]);

const rightWidth = 12;
Expand All @@ -26,10 +44,6 @@ export function HeaderBar() {
const currentSegment = parts.pop() || "";
const breadcrumbPrefix = parts.length > 0 ? parts.join(" › ") + " › " : "";

const repoContext = nav.repoContext
? `${nav.repoContext.owner}/${nav.repoContext.repo}`
: "";

return (
<box flexDirection="row" height={1} width="100%" borderColor={theme.border} border={["bottom"]}>
<box flexGrow={1}>
Expand Down
2 changes: 1 addition & 1 deletion apps/tui/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export {
} from "./useSpinner.js";
export { useLayout } from "./useLayout.js";
export type { LayoutContext } from "./useLayout.js";
export { useNavigation } from "../providers/NavigationProvider.js";
export { useNavigation } from "./useNavigation.js";
export { useAuth } from "./useAuth.js";
export { useLoading } from "./useLoading.js";
export { useScreenLoading } from "./useScreenLoading.js";
Expand Down
19 changes: 19 additions & 0 deletions apps/tui/src/hooks/useNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useContext } from "react";
import { NavigationContext } from "../providers/NavigationProvider.js";
import type { NavigationContextType } from "../router/types.js";

/**
* Access the navigation context from the nearest NavigationProvider.
*
* @throws {Error} if called outside a NavigationProvider.
*/
export function useNavigation(): NavigationContextType {
const context = useContext(NavigationContext);
if (context === null) {
throw new Error(
"useNavigation must be used within a NavigationProvider. " +
"Ensure the component is rendered inside the provider hierarchy."
);
}
return context;
}
Loading