diff --git a/apps/tui/package.json b/apps/tui/package.json
index 265c1ff1..ba35aebf 100644
--- a/apps/tui/package.json
+++ b/apps/tui/package.json
@@ -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": {
diff --git a/apps/tui/src/components/AuthErrorScreen.tsx b/apps/tui/src/components/AuthErrorScreen.tsx
index 3840e878..954b549a 100644
--- a/apps/tui/src/components/AuthErrorScreen.tsx
+++ b/apps/tui/src/components/AuthErrorScreen.tsx
@@ -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 {
@@ -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(() => {
@@ -40,15 +40,13 @@ export function AuthErrorScreen({ variant, host, tokenSource, onRetry }: AuthErr
✗ Not authenticated
- No token found for {host}.
+ {`No token found for ${host}.`}
Run the following command to log in:
codeplane auth login
-
- Or set the CODEPLANE_TOKEN environment variable.
-
+ Or set the CODEPLANE_TOKEN environment variable.
q quit │ R retry │ Ctrl+C quit
@@ -66,7 +64,7 @@ export function AuthErrorScreen({ variant, host, tokenSource, onRetry }: AuthErr
✗ Session expired
- Stored token for {host} from {sourceLabel} is invalid or expired.
+ {`Stored token for ${host} from ${sourceLabel} is invalid or expired.`}
Run the following command to re-authenticate:
diff --git a/apps/tui/src/components/AuthLoadingScreen.tsx b/apps/tui/src/components/AuthLoadingScreen.tsx
index 67df905f..9d1540fd 100644
--- a/apps/tui/src/components/AuthLoadingScreen.tsx
+++ b/apps/tui/src/components/AuthLoadingScreen.tsx
@@ -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 {
@@ -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);
@@ -32,10 +32,10 @@ export function AuthLoadingScreen({ host }: AuthLoadingScreenProps) {
justifyContent="center"
alignItems="center"
>
-
+
{spinnerFrame}
Authenticating…
-
+
{displayHost}
diff --git a/apps/tui/src/components/ErrorScreen.tsx b/apps/tui/src/components/ErrorScreen.tsx
index 4713724a..f49e4b5f 100644
--- a/apps/tui/src/components/ErrorScreen.tsx
+++ b/apps/tui/src/components/ErrorScreen.tsx
@@ -368,25 +368,25 @@ export function ErrorScreen({
{/* Action hints */}
-
+
r
:restart
-
-
+
+
q
:quit
-
+
{hasStack && (
-
+
s
:trace
-
+
)}
-
+
?
:help
-
+
@@ -405,17 +405,17 @@ export function ErrorScreen({
>
Error Screen Keybindings
──────────────────────
- r Restart TUI
- q Quit TUI
- Ctrl+C Quit immediately
- s Toggle stack trace
- j/↓ Scroll trace down
- k/↑ Scroll trace up
- G Jump to trace bottom
- gg Jump to trace top
- Ctrl+D Page down
- Ctrl+U Page up
- ? Close this help
+ r Restart TUI
+ q Quit TUI
+ Ctrl+C Quit immediately
+ s Toggle stack trace
+ j/↓ Scroll trace down
+ k/↑ Scroll trace up
+ G Jump to trace bottom
+ gg Jump to trace top
+ Ctrl+D Page down
+ Ctrl+U Page up
+ ? Close this help
Press ? or Esc to close
diff --git a/apps/tui/src/components/GlobalKeybindings.tsx b/apps/tui/src/components/GlobalKeybindings.tsx
index d825d76c..f06768f9 100644
--- a/apps/tui/src/components/GlobalKeybindings.tsx
+++ b/apps/tui/src/components/GlobalKeybindings.tsx
@@ -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(null);
+ const goToHintsCleanupRef = useRef<(() => void) | null>(null);
+ const goToTimeoutRef = useRef | 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();
+ 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 });
diff --git a/apps/tui/src/components/HeaderBar.tsx b/apps/tui/src/components/HeaderBar.tsx
index 9a019dd4..42dfdab0 100644
--- a/apps/tui/src/components/HeaderBar.tsx
+++ b/apps/tui/src/components/HeaderBar.tsx
@@ -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();
@@ -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;
@@ -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 (
diff --git a/apps/tui/src/hooks/index.ts b/apps/tui/src/hooks/index.ts
index 60ba2f2f..fc8d56f1 100644
--- a/apps/tui/src/hooks/index.ts
+++ b/apps/tui/src/hooks/index.ts
@@ -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";
diff --git a/apps/tui/src/hooks/useNavigation.ts b/apps/tui/src/hooks/useNavigation.ts
new file mode 100644
index 00000000..e30a5ee5
--- /dev/null
+++ b/apps/tui/src/hooks/useNavigation.ts
@@ -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;
+}
diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx
index ce632e2c..724020c2 100644
--- a/apps/tui/src/index.tsx
+++ b/apps/tui/src/index.tsx
@@ -61,16 +61,16 @@ function App() {
currentScreen={screenRef.current}
noColor={noColor}
>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -78,13 +78,13 @@ function App() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/apps/tui/src/navigation/deepLinks.ts b/apps/tui/src/navigation/deepLinks.ts
index 8532b6c6..d43a4d87 100644
--- a/apps/tui/src/navigation/deepLinks.ts
+++ b/apps/tui/src/navigation/deepLinks.ts
@@ -1,6 +1,7 @@
-import type { ScreenEntry } from "../router/types.js";
+import type { NavigationProviderProps } from "../router/types.js";
import { ScreenName } from "../router/types.js";
-import { createEntry } from "../providers/NavigationProvider.js";
+
+type DeepLinkStackEntry = NonNullable[number];
export interface DeepLinkArgs {
screen?: string;
@@ -11,7 +12,7 @@ export interface DeepLinkArgs {
export interface DeepLinkResult {
/** Pre-populated stack entries */
- stack: ScreenEntry[];
+ stack: DeepLinkStackEntry[];
/** Non-empty when validation failed */
error?: string;
}
@@ -40,7 +41,7 @@ function resolveScreenName(input: string): ScreenName | null {
}
export function buildInitialStack(args: DeepLinkArgs): DeepLinkResult {
- const dashboardEntry = () => createEntry(ScreenName.Dashboard);
+ const dashboardEntry = (): DeepLinkStackEntry => ({ screen: ScreenName.Dashboard });
if (!args.screen && !args.repo) {
return { stack: [dashboardEntry()] };
@@ -70,10 +71,13 @@ export function buildInitialStack(args: DeepLinkArgs): DeepLinkResult {
repoName = parts[1];
}
- const stack: ScreenEntry[] = [dashboardEntry()];
+ const stack: DeepLinkStackEntry[] = [dashboardEntry()];
if (owner && repoName) {
- stack.push(createEntry(ScreenName.RepoOverview, { owner, repo: repoName }));
+ stack.push({
+ screen: ScreenName.RepoOverview,
+ params: { owner, repo: repoName },
+ });
}
if (screenName && screenName !== ScreenName.Dashboard) {
@@ -107,7 +111,10 @@ export function buildInitialStack(args: DeepLinkArgs): DeepLinkResult {
// avoid pushing duplicates if RepoOverview is the target
if (screenName !== ScreenName.RepoOverview || !owner) {
- stack.push(createEntry(screenName, params));
+ stack.push({
+ screen: screenName,
+ params,
+ });
}
}
diff --git a/apps/tui/src/navigation/goToBindings.ts b/apps/tui/src/navigation/goToBindings.ts
index 87e52e09..14f377b8 100644
--- a/apps/tui/src/navigation/goToBindings.ts
+++ b/apps/tui/src/navigation/goToBindings.ts
@@ -1,4 +1,4 @@
-import type { NavigationContext } from "../router/types.js";
+import type { NavigationContextType } from "../router/types.js";
import { ScreenName } from "../router/types.js";
export interface GoToBinding {
@@ -23,7 +23,7 @@ export const goToBindings: readonly GoToBinding[] = [
] as const;
export function executeGoTo(
- nav: NavigationContext,
+ nav: NavigationContextType,
binding: GoToBinding,
repoContext: { owner: string; repo: string } | null,
): { error?: string } {
@@ -33,14 +33,14 @@ export function executeGoTo(
nav.reset(ScreenName.Dashboard);
- if (repoContext) {
+ if (binding.requiresRepo && repoContext) {
nav.push(ScreenName.RepoOverview, {
owner: repoContext.owner,
repo: repoContext.repo,
});
}
- const params = repoContext
+ const params = binding.requiresRepo && repoContext
? { owner: repoContext.owner, repo: repoContext.repo }
: undefined;
diff --git a/apps/tui/src/providers/NavigationProvider.tsx b/apps/tui/src/providers/NavigationProvider.tsx
index acbe8d3f..d2d103b3 100644
--- a/apps/tui/src/providers/NavigationProvider.tsx
+++ b/apps/tui/src/providers/NavigationProvider.tsx
@@ -1,153 +1,143 @@
-import { createContext, useContext, useState, useRef, useMemo } from "react";
-import type { ScreenEntry, NavigationContext as INavigationContext } from "../router/types.js";
-import { ScreenName, MAX_STACK_DEPTH, DEFAULT_ROOT_SCREEN } from "../router/types.js";
-import { screenRegistry } from "../router/registry.js";
-
-export const NavigationContext = createContext(null);
-
-export interface NavigationProviderProps {
- /** Pre-built initial stack for deep-link launch. */
- initialStack?: ScreenEntry[];
- /** Initial screen to render if no initialStack. Defaults to Dashboard. */
- initialScreen?: ScreenName;
- /** Initial params for the initial screen. */
- initialParams?: Record;
- children: React.ReactNode;
+import {
+ createContext,
+ useCallback,
+ useMemo,
+ useState,
+} from "react";
+import type {
+ NavigationContextType,
+ NavigationProviderProps,
+ ScreenEntry,
+} from "../router/types.js";
+import {
+ DEFAULT_ROOT_SCREEN,
+ MAX_STACK_DEPTH,
+ screenEntriesEqual,
+} from "../router/types.js";
+
+export const NavigationContext = createContext(null);
+
+function normalizeParams(
+ params?: Record,
+): Record | undefined {
+ if (!params) {
+ return undefined;
+ }
+
+ const keys = Object.keys(params);
+ if (keys.length === 0) {
+ return undefined;
+ }
+
+ return { ...params };
}
-export function createEntry(
- screen: ScreenName,
- params: Record = {},
+export function createScreenEntry(
+ screen: string,
+ params?: Record,
): ScreenEntry {
- const definition = screenRegistry[screen];
return {
id: crypto.randomUUID(),
screen,
- params,
- breadcrumb: definition.breadcrumbLabel(params),
+ params: normalizeParams(params),
};
}
+export function pushStack(
+ prev: readonly ScreenEntry[],
+ screen: string,
+ params?: Record,
+): ScreenEntry[] {
+ const top = prev[prev.length - 1];
+ if (top && screenEntriesEqual(top, { screen, params })) {
+ return prev as ScreenEntry[];
+ }
+
+ const next = [...prev, createScreenEntry(screen, params)];
+ if (next.length > MAX_STACK_DEPTH) {
+ return next.slice(next.length - MAX_STACK_DEPTH);
+ }
+
+ return next;
+}
+
+export function popStack(prev: readonly ScreenEntry[]): ScreenEntry[] {
+ if (prev.length <= 1) {
+ return prev as ScreenEntry[];
+ }
+
+ return prev.slice(0, -1);
+}
+
+export function replaceStack(
+ prev: readonly ScreenEntry[],
+ screen: string,
+ params?: Record,
+): ScreenEntry[] {
+ const nextEntry = createScreenEntry(screen, params);
+ if (prev.length <= 1) {
+ return [nextEntry];
+ }
+
+ return [...prev.slice(0, -1), nextEntry];
+}
+
+export function resetStack(
+ screen: string,
+ params?: Record,
+): ScreenEntry[] {
+ return [createScreenEntry(screen, params)];
+}
+
export function NavigationProvider({
- initialStack,
- initialScreen,
+ initialScreen = DEFAULT_ROOT_SCREEN,
initialParams,
+ initialStack,
children,
}: NavigationProviderProps) {
const [stack, setStack] = useState(() => {
if (initialStack && initialStack.length > 0) {
- return initialStack;
+ const capped = initialStack.slice(-MAX_STACK_DEPTH);
+ return capped.map((entry) => createScreenEntry(entry.screen, entry.params));
}
- return [createEntry(initialScreen || DEFAULT_ROOT_SCREEN, initialParams)];
+
+ return [createScreenEntry(initialScreen, initialParams)];
});
- const scrollCacheRef = useRef