import { CLOUD_PLAN_KEY } from "../../shared/cloud-upgrade";
import { ActionRow } from "../../../../components/ui/action-row";
import { getCurrentPluginTarget } from "../../../current-target";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { apiClient } from "../../../../api-client";
import {
Box,
ScrollBox,
Text,
TextAttributes,
useUiCapabilities,
type BoxRenderable,
type ScrollBoxRenderable,
type TextareaRenderable,
} from "../../../../ui";
import {
Button,
ChoiceDialog,
DataTableView,
EmptyState,
getPaneSidebarWidth,
MessageComposer,
Prose,
QueryBar,
shouldShowPaneSidebar,
Spinner,
usePaneFooter,
type DataTableCell,
type DataTableColumn,
type PaneHint,
} from "../../../../components";
import { MarkdownText } from "../../../../components/markdown-text";
import { useShortcut } from "../../../../react/input";
import {
useAppDispatch,
useAppSelector,
usePaneAppConfig,
} from "../../../../state/app/context";
import { useInlineTickers } from "../../../../state/hooks/inline-tickers";
import { useRemoteControlHandler } from "../../../../remote/app-host";
import { colors } from "../../../../theme/colors";
import type { PaneProps } from "../../../../types/plugin";
import { collectUniqueTickerSymbols } from "../../../../tickers/tokenizer";
import { isPlainKey } from "../../../../utils/keyboard";
import { truncateWithEllipsis } from "../../../../utils/text-wrap";
import { usePluginAppActions, usePluginTickerActions } from "../../../runtime";
import { usePlanAccess } from "../../shared/plan-access";
import { SignInWall } from "../auth-actions";
import { afterLayout, revealInScrollBox } from "../reveal-in-scroll-box";
import { ASKGSessionController, type ASKGControllerManifest } from "./controller";
import {
createASKGRendererToolExecutor,
loadASKGClientManifest,
resolveToolPaneTarget,
} from "./host";
import {
askgConversationLabel,
askgConversationListStore,
} from "./conversation-store";
import { ConfirmDialog } from "../../../../components/ui/confirm-dialog";
import { useDialog, type PromptContext } from "../../../../ui/dialog";
import { subscribeASKGQuestions } from "./pending-question";
import { ASKGConversationSidebar } from "./sidebar";
import {
activeTurn,
canRetryASKGError,
describeASKGError,
describeToolStatus,
formatCellValue,
isTurnRunning,
pendingConfirmation,
rowSymbol,
toolResultTables,
type ASKGConversationState,
type ASKGResultTable,
type ASKGToolRow,
type ASKGTurn,
} from "./model";
import type { JsonValue } from "./protocol";
export const ASKG_PANE_ID = "askg";
const CLIENT_VERSION = "1";
const MIN_DETAIL_HEIGHT = 8;
function clientKind(): "tui" | "desktop" | "web" {
const target = getCurrentPluginTarget();
return target === "web" || target === "desktop" ? target : "tui";
}
function tierLabel(row: ASKGToolRow): string | null {
if (row.writeTier === "read") return null;
if (row.writeTier === "ui-write") return "layout";
if (row.writeTier === "user-data") return "your data";
return "broker";
}
function statusColor(row: ASKGToolRow): string {
switch (row.status) {
case "ok":
return colors.textDim;
case "partial":
case "awaiting-confirmation":
return colors.warning;
case "error":
case "timeout":
return colors.negative;
case "denied":
case "cancelled":
return colors.textMuted;
default:
return colors.textDim;
}
}
function previewLines(preview: JsonValue | undefined): string[] {
if (preview == null) return [];
if (typeof preview === "string") return preview.split("\n");
if (typeof preview === "number" || typeof preview === "boolean") return [String(preview)];
if (Array.isArray(preview)) return preview.map((entry) => formatCellValue(entry));
return Object.entries(preview).map(([key, value]) => `${key}: ${formatCellValue(value)}`);
}
/** Undo a write; not `u`, which installs an app update whenever one is waiting. */
const UNDO_KEY = "z";
/** Upgrade from a Pro-only answer, on the key every Pro prompt uses. */
const UPGRADE_KEY = CLOUD_PLAN_KEY;
function canUndo(row: ASKGToolRow): boolean {
return !!row.undoToken && (!row.undo || row.undo.status === "available");
}
function ToolTimelineRow({
row,
width,
selected,
expanded,
selectedRowRef,
onSelect,
onToggle,
onUndo,
}: {
row: ASKGToolRow;
width: number;
selected: boolean;
expanded: boolean;
/** Takes the row while it is selected, so the transcript can scroll to it. */
selectedRowRef: (node: BoxRenderable | null) => void;
onSelect: () => void;
onToggle: () => void;
onUndo: () => void;
}) {
const hasRows = row.result !== undefined;
const marker = hasRows ? (expanded ? "▾" : "▸") : "·";
const tier = tierLabel(row);
const status = describeToolStatus(row);
const undoLabel = row.undo?.status === "running"
? "undoing…"
: row.undo?.status === "done"
? "undone"
: row.undo?.status === "failed"
? "undo failed"
: row.undoToken
? "undo"
: null;
const trailing = ` ${status}${row.origin === "server" ? " · Gloom" : ""}`;
const summaryWidth = Math.max(
6,
width - marker.length - row.name.length - trailing.length - (tier ? tier.length + 3 : 0) - 4,
);
return (
{ onSelect(); onToggle(); }}
>
{row.argumentSummary ? (
{` ${truncateWithEllipsis(row.argumentSummary, summaryWidth)}`}
) : null}
{tier ? {`${tier} `} : null}
{row.status === "running" || row.status === "pending" ? (
) : (
{status}
)}
{row.origin === "server" ? · Gloom : null}
{undoLabel ? (
{row.undo?.note ? {` ${row.undo.note}`} : null}
) : null}
{row.note && row.status !== "ok" ? (
) : null}
);
}
/** Rows the confirmation block reserves for the dry run preview. */
const MAX_PREVIEW_LINES = 4;
export function confirmationBlockHeight(row: ASKGToolRow): number {
return 3 + Math.min(MAX_PREVIEW_LINES, previewLines(row.preview).length);
}
function ConfirmationPrompt({
row,
width,
onApprove,
onDecline,
}: {
row: ASKGToolRow;
width: number;
onApprove: () => void;
onDecline: () => void;
}) {
const lines = previewLines(row.preview).slice(0, MAX_PREVIEW_LINES);
return (
{`Approve ${row.name}?`}
{` writes ${tierLabel(row) ?? "data"}${row.argumentSummary ? ` · ${row.argumentSummary}` : ""}`}
{lines.map((line, index) => (
{truncateWithEllipsis(line, Math.max(10, width - 2))}
))}
);
}
function ToolResultDetail({
row,
width,
height,
focused,
openPaneShortcut,
onOpenSymbol,
onOpenPane,
}: {
row: ASKGToolRow;
width: number;
height: number;
/** The rows have the keyboard: j/k walk them and Enter opens one. */
focused: boolean;
openPaneShortcut?: string;
onOpenSymbol: (symbol: string) => void;
onOpenPane: () => void;
}) {
const tables = useMemo(() => toolResultTables(row.result), [row.result]);
const [tableIndex, setTableIndex] = useState(0);
const [rowIndex, setRowIndex] = useState(0);
useEffect(() => {
setTableIndex(0);
}, [row.toolCallId]);
useEffect(() => {
setRowIndex(0);
}, [row.toolCallId, tableIndex]);
const table = tables[Math.min(tableIndex, tables.length - 1)] ?? null;
const paneTarget = useMemo(() => resolveToolPaneTarget(row.name), [row.name]);
const columns = useMemo(() => (
(table?.columns ?? []).map((column) => ({
id: column.key,
label: column.header,
width: column.width ?? Math.max(column.header.length + 2, 10),
align: column.align === "right" ? "right" : "left",
flexGrow: 1,
}))
), [table]);
const renderCell = useCallback((
item: Record,
column: DataTableColumn,
): DataTableCell => {
const value = formatCellValue(item[column.id]);
const symbol = rowSymbol(item);
return {
text: value,
color: symbol && (column.id === "symbol" || column.id === "ticker")
? colors.textBright
: colors.text,
};
}, []);
const headerHeight = 1 + (tables.length > 1 ? 1 : 0);
// The timeline row above already names the tool and its arguments, so the
// detail header carries what the row cannot: shape, size, and cost.
const metadata = [
table?.title,
row.rowCount !== undefined ? `${row.rowCount} ${row.rowCount === 1 ? "row" : "rows"}` : null,
row.elapsedMs !== undefined ? `${row.elapsedMs}ms` : null,
row.truncated ? "truncated" : null,
].filter(Boolean).join(" · ");
return (
{truncateWithEllipsis(metadata, Math.max(10, width - 16))}
{paneTarget ? (
) : null}
{tables.length > 1 ? (
({
value: String(index),
label: entry.title ?? `Section ${index + 1}`,
})),
onChange: (value: string) => setTableIndex(Number(value)),
}]}
/>
) : null}
{table && table.rows.length > 0 ? (
>
focused={focused}
// A row cursor only while the rows have the keyboard; the pointer
// opens a row as it always has.
selection={focused
? { kind: "index", selectedIndex: rowIndex, onChange: (index) => setRowIndex(index) }
: { kind: "none" }}
rootWidth={width}
rootHeight={Math.max(3, height - headerHeight)}
columns={columns}
items={table.rows}
sortColumnId={null}
sortDirection="asc"
onHeaderClick={() => {}}
getItemKey={(_item, index) => String(index)}
renderCell={renderCell}
onActivate={(item) => {
const symbol = rowSymbol(item);
if (symbol) onOpenSymbol(symbol);
}}
emptyStateTitle="No rows returned"
/>
) : (
{row.note ?? "This tool returned no rows."}
)}
);
}
function TurnView({
turn,
width,
selectedToolCallId,
expandedToolCallId,
selectedRowRef,
latest,
catalog,
openTicker,
onSelectTool,
onToggleTool,
onUndo,
onRetry,
onUpgrade,
}: {
turn: ASKGTurn;
width: number;
selectedToolCallId: string | null;
expandedToolCallId: string | null;
selectedRowRef: (node: BoxRenderable | null) => void;
/** The last turn: its Retry and Upgrade answer the pane's keys. */
latest: boolean;
catalog: ReturnType["catalog"];
openTicker: (symbol: string) => void;
onSelectTool: (toolCallId: string) => void;
onToggleTool: (toolCallId: string) => void;
onUndo: (toolCallId: string) => void;
onRetry: () => void;
onUpgrade: () => void;
}) {
return (
{turn.tools.length > 0 ? (
{turn.tools.map((row) => (
onSelectTool(row.toolCallId)}
onToggle={() => onToggleTool(row.toolCallId)}
onUndo={() => onUndo(row.toolCallId)}
/>
))}
) : null}
{turn.answer ? (
) : turn.status === "streaming" && turn.tools.length === 0 ? (
) : null}
{turn.error ? (
{/* A failure that loses its tail tells the user nothing, so it wraps
rather than running off the edge of the pane. */}
{turn.error.code === "tier_required" ? (
) : canRetryASKGError(turn.error) ? (
) : null}
) : null}
);
}
export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
const { nativePaneChrome } = useUiCapabilities();
const dispatch = useAppDispatch();
const planAccess = usePlanAccess();
const remoteHandler = useRemoteControlHandler();
const config = usePaneAppConfig();
const activeSymbol = useAppSelector((state) => state.recentTickers[0] ?? null);
const { pinTicker } = usePluginTickerActions();
const { createPaneFromTemplate, showPane, openCommandBar } = usePluginAppActions();
const dialog = useDialog();
const configRef = useRef(config);
configRef.current = config;
const remoteHandlerRef = useRef(remoteHandler);
remoteHandlerRef.current = remoteHandler;
const contextRef = useRef({ symbol: activeSymbol, paneId });
contextRef.current = { symbol: activeSymbol, paneId };
const controller = useMemo(() => new ASKGSessionController({
transport: apiClient.askg,
loadManifest: () => loadASKGClientManifest(),
getExecutor: (manifest: ASKGControllerManifest) => {
const handler = remoteHandlerRef.current;
if (!handler) return null;
return createASKGRendererToolExecutor({
config: configRef.current,
remoteHandler: handler,
manifest: {
tools: manifest.tools,
manifestHash: manifest.manifestHash,
skipped: [],
},
});
},
client: { kind: clientKind(), version: CLIENT_VERSION },
getContext: () => ({
...(contextRef.current.symbol ? { symbol: contextRef.current.symbol } : {}),
paneId: contextRef.current.paneId,
}),
}), []);
useEffect(() => () => controller.dispose(), [controller]);
const state: ASKGConversationState = useSyncExternalStore(
useCallback((listener) => controller.subscribe(listener), [controller]),
useCallback(() => controller.getState(), [controller]),
useCallback(() => controller.getState(), [controller]),
);
const [inputValue, setInputValue] = useState("");
const [inputFocused, setInputFocused] = useState(false);
const [selectedToolCallId, setSelectedToolCallId] = useState(null);
const [expandedToolCallId, setExpandedToolCallId] = useState(null);
const inputRef = useRef(null);
const scrollRef = useRef(null);
const [queuedQuestion, setQueuedQuestion] = useState(null);
const [sidebarFocused, setSidebarFocused] = useState(false);
// The row the keyboard is on while the sidebar has focus. Arrows move it and
// Enter opens it, so walking the list does not load a transcript per keypress.
const [sidebarCursorId, setSidebarCursorId] = useState(null);
// The expanded result's rows have the keyboard: j/k walk them and Enter
// opens one. x moves in, Esc comes back to the timeline.
const [resultFocused, setResultFocused] = useState(false);
const conversations = useSyncExternalStore(
useCallback((listener) => askgConversationListStore.subscribe(listener), []),
useCallback(() => askgConversationListStore.getSnapshot(), []),
useCallback(() => askgConversationListStore.getSnapshot(), []),
);
useEffect(() => {
if (!planAccess.emailVerified) return;
askgConversationListStore.useTransport(apiClient.askg);
askgConversationListStore.ensureLoaded();
}, [planAccess.emailVerified]);
// The same gate chat uses: at least two things to switch between, in a pane
// wide enough to spare the width. One conversation is the one on screen.
const showSidebar = shouldShowPaneSidebar(
conversations.conversations.length,
width,
height,
);
const sidebarWidth = showSidebar
? getPaneSidebarWidth(width, !!nativePaneChrome, conversations.width)
: 0;
const running = isTurnRunning(state);
const confirmation = pendingConfirmation(state);
const timelineRows = useMemo(
() => state.turns.flatMap((turn) => turn.tools),
[state.turns],
);
const expandedRow = useMemo(
() => timelineRows.find((row) => row.toolCallId === expandedToolCallId) ?? null,
[expandedToolCallId, timelineRows],
);
// The last question is the one a retry would repeat; an older failure is
// already history the user moved past.
const retryableTurn = useMemo(() => {
const turn = activeTurn(state);
if (!turn || turn.status !== "error" || !turn.error) return null;
return canRetryASKGError(turn.error) ? turn : null;
}, [state]);
const needsUpgrade = useMemo(() => {
const turn = activeTurn(state);
return turn?.status === "error" && turn.error?.code === "tier_required";
}, [state]);
const ask = useCallback((question: string) => {
const trimmed = question.trim();
if (!trimmed) return;
void controller.ask(trimmed);
}, [controller]);
const openConversation = useCallback(async (conversationId: string) => {
if (conversationId === state.conversationId) return;
const conversation = await apiClient.askg.loadConversation(conversationId);
// A row the sidebar still shows may be gone; the refresh drops it.
if (!conversation) {
void askgConversationListStore.refresh();
return;
}
setSelectedToolCallId(null);
setExpandedToolCallId(null);
controller.openConversation(conversation);
}, [controller, state.conversationId]);
const newConversation = useCallback(() => {
setSelectedToolCallId(null);
setExpandedToolCallId(null);
controller.startConversation();
}, [controller]);
// A transcript has no undo, so it is never one click from gone.
const deleteConversation = useCallback(async (conversationId: string): Promise => {
const row = conversations.conversations.find(
(conversation) => conversation.id === conversationId,
);
const label = row ? askgConversationLabel(row) : "this conversation";
const confirmed = await dialog.prompt({
closeOnClickOutside: true,
content: (context: unknown) => (
)}
title="Delete conversation"
body={[`Delete "${label}"? This cannot be undone.`]}
confirmLabel="Delete conversation"
width={48}
/>
),
}).catch(() => false);
if (confirmed !== true) return false;
if (conversationId === state.conversationId) controller.startConversation();
void askgConversationListStore.delete(conversationId);
return true;
}, [controller, conversations.conversations, dialog, state.conversationId]);
const focusInput = useCallback(() => {
setInputFocused(true);
dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
inputRef.current?.focus?.();
}, [dispatch]);
const blurInput = useCallback(() => {
setInputFocused(false);
dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
}, [dispatch]);
useEffect(() => {
if (!focused && inputFocused) blurInput();
}, [blurInput, focused, inputFocused]);
useEffect(() => () => {
dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
}, [dispatch]);
// A question typed at the command bar, either opening this pane or landing
// in the conversation already open here.
useEffect(() => subscribeASKGQuestions(setQueuedQuestion), []);
useEffect(() => {
// The restored cloud session arrives after the first render, so a question
// that beat it waits instead of being dropped.
if (!queuedQuestion || !planAccess.emailVerified) return;
setQueuedQuestion(null);
ask(queuedQuestion);
}, [ask, planAccess.emailVerified, queuedQuestion]);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll?.viewport) return;
scroll.scrollTo({ x: 0, y: Math.max(0, scroll.scrollHeight - scroll.viewport.height) });
}, [state.turns]);
// A conversation the platform just opened belongs in the sidebar straight
// away, titled by the question that opened it.
useEffect(() => {
if (!state.conversationId) return;
const first = state.turns[0];
askgConversationListStore.note(
state.conversationId,
first?.prompt?.trim() ? first.prompt : null,
);
}, [state.conversationId]);
// The stored title and message count only settle once the turn is recorded,
// so the list is reread exactly when one finishes.
const wasRunningRef = useRef(false);
useEffect(() => {
const finished = wasRunningRef.current && !running;
wasRunningRef.current = running;
if (finished) void askgConversationListStore.refresh();
}, [running]);
// A list that shrank below the point of switching leaves nothing to focus.
useEffect(() => {
if (!showSidebar && sidebarFocused) {
setSidebarFocused(false);
setSidebarCursorId(null);
}
}, [showSidebar, sidebarFocused]);
// The answer that follows a question owns the keyboard (cancel, the tool
// rows, retry), so sending leaves the composer; Enter comes back to it.
const submitInput = useCallback(() => {
const value = inputRef.current?.editBuffer.getText() ?? inputValue;
const trimmed = value.trim();
if (!trimmed) return;
setInputValue("");
inputRef.current?.editBuffer.setText?.("");
blurInput();
ask(trimmed);
}, [ask, blurInput, inputValue]);
const toggleExpanded = useCallback((toolCallId: string) => {
setExpandedToolCallId((current) => (current === toolCallId ? null : toolCallId));
}, []);
useEffect(() => {
if (!expandedToolCallId) setResultFocused(false);
}, [expandedToolCallId]);
const moveSelection = useCallback((direction: -1 | 1) => {
if (timelineRows.length === 0) return;
const index = timelineRows.findIndex((row) => row.toolCallId === selectedToolCallId);
// Selection starts at the newest row: that is the answer being read.
const nextIndex = index < 0
? timelineRows.length - 1
: (index + direction + timelineRows.length) % timelineRows.length;
setSelectedToolCallId(timelineRows[nextIndex]?.toolCallId ?? null);
}, [selectedToolCallId, timelineRows]);
// The selected row follows the keyboard into view in a long conversation.
const selectedRowNodeRef = useRef(null);
const selectedRowRef = useCallback((node: BoxRenderable | null) => {
selectedRowNodeRef.current = node;
}, []);
useEffect(() => {
if (!selectedToolCallId) return;
return afterLayout(() => revealInScrollBox(scrollRef.current, selectedRowNodeRef.current));
}, [selectedToolCallId]);
const openPaneForTool = useCallback((row: ASKGToolRow) => {
const target = resolveToolPaneTarget(row.name);
if (!target) return;
if (target.templateId) {
createPaneFromTemplate(target.templateId, row.argumentSummary
? { arg: row.argumentSummary.split(" · ")[0] }
: undefined);
return;
}
showPane(target.paneId);
}, [createPaneFromTemplate, showPane]);
const openSymbol = useCallback((symbol: string) => {
pinTicker(symbol, { floating: true });
}, [pinTicker]);
// A write waiting on the user owns the keyboard, so the answer never gets
// typed into the composer instead.
useEffect(() => {
if (confirmation && inputFocused) blurInput();
}, [blurInput, confirmation, inputFocused]);
const leaveSidebar = useCallback(() => {
setSidebarFocused(false);
setSidebarCursorId(null);
}, []);
const moveSidebarCursor = useCallback((direction: -1 | 1) => {
const rows = conversations.conversations;
if (rows.length === 0) return;
const index = rows.findIndex((row) => row.id === sidebarCursorId);
const nextIndex = index < 0
? 0
: (index + direction + rows.length) % rows.length;
setSidebarCursorId(rows[nextIndex]?.id ?? null);
}, [conversations.conversations, sidebarCursorId]);
const focusSidebar = useCallback(() => {
setResultFocused(false);
setSidebarFocused(true);
setSidebarCursorId(state.conversationId);
}, [state.conversationId]);
// The row the sidebar's keyboard is on, or the open conversation.
const sidebarTargetId = sidebarCursorId ?? state.conversationId;
const deleteSidebarConversation = useCallback(() => {
const rows = conversations.conversations;
const index = rows.findIndex((row) => row.id === sidebarTargetId);
if (index < 0 || !sidebarTargetId) return;
// The cursor stays on the list: the row after, or the one before the last.
const neighbor = rows[index + 1]?.id ?? rows[index - 1]?.id ?? null;
void deleteConversation(sidebarTargetId).then((deleted) => {
if (deleted) setSidebarCursorId(neighbor);
});
}, [conversations.conversations, deleteConversation, sidebarTargetId]);
const answerTexts = useMemo(
() => state.turns.map((turn) => turn.answer).filter(Boolean),
[state.turns],
);
const { catalog, openTicker } = useInlineTickers(answerTexts, { badgeQuotes: true });
// Ticker badges in the answer open on click; `t` lists the latest answer's.
const answerTickers = useMemo(() => {
const latest = [...state.turns].reverse().find((turn) => turn.answer)?.answer;
return latest
? collectUniqueTickerSymbols([latest]).filter((symbol) => catalog[symbol]?.status !== "missing")
: [];
}, [catalog, state.turns]);
const chooseAnswerTicker = useCallback(async () => {
if (answerTickers.length === 0) return;
const symbol = await dialog.prompt({
closeOnClickOutside: true,
content: (context: unknown) => (
)}
title="Tickers in the answer"
choices={answerTickers.map((entry) => ({
id: entry,
label: entry,
description: catalog[entry]?.ticker?.metadata.name,
}))}
/>
),
}).catch(() => undefined);
if (symbol) openTicker(symbol);
}, [answerTickers, catalog, dialog, openTicker]);
const upgrade = useCallback(() => openCommandBar("Upgrade to Pro"), [openCommandBar]);
const selectedRow = timelineRows.find((row) => row.toolCallId === selectedToolCallId) ?? null;
const confirmationHeight = confirmation ? confirmationBlockHeight(confirmation) : 0;
const composerHeight = nativePaneChrome ? 3 : 2;
// The conversation keeps a readable slice no matter what else is open.
const detailBudget = height - composerHeight - confirmationHeight - 6;
const detailHeight = expandedRow && detailBudget >= MIN_DETAIL_HEIGHT
? Math.min(Math.floor(height / 2), detailBudget)
: 0;
const resultActive = resultFocused && !!expandedRow && detailHeight > 0 && !inputFocused && !(sidebarFocused && showSidebar);
// `o` opens the pane of the result the rows belong to, else the selected row's.
const paneRow = resultActive ? expandedRow : selectedRow;
const canOpenPane = !!paneRow && !!resolveToolPaneTarget(paneRow.name);
const expandTarget = resultActive ? expandedRow : selectedRow;
const toggleResult = useCallback((row: ASKGToolRow) => {
const expanding = expandedToolCallId !== row.toolCallId;
toggleExpanded(row.toolCallId);
setResultFocused(expanding && toolResultTables(row.result).some((table) => table.rows.length > 0));
}, [expandedToolCallId, toggleExpanded]);
useShortcut((event) => {
// Behind the sign-in wall the keys are the wall's (Enter logs in).
if (!focused || !planAccess.emailVerified) return;
const consume = () => {
event.preventDefault();
event.stopPropagation();
};
if (confirmation) {
if (isPlainKey(event, "y")) {
consume();
controller.resolveConfirmation(confirmation.toolCallId, true);
return;
}
if (isPlainKey(event, "n", "escape")) {
consume();
controller.resolveConfirmation(confirmation.toolCallId, false);
return;
}
}
// The sidebar owns the keyboard while it has focus, so the same arrows
// that walk the tool timeline walk the conversation list instead.
if (sidebarFocused && showSidebar) {
if (isPlainKey(event, "escape", "right")) {
consume();
leaveSidebar();
} else if (isPlainKey(event, "enter", "return")) {
consume();
const target = sidebarTargetId;
leaveSidebar();
if (target) void openConversation(target);
} else if (isPlainKey(event, "j", "down")) {
consume();
moveSidebarCursor(1);
} else if (isPlainKey(event, "k", "up")) {
consume();
moveSidebarCursor(-1);
} else if (isPlainKey(event, "n")) {
consume();
leaveSidebar();
newConversation();
} else if (isPlainKey(event, "d", "delete")) {
consume();
deleteSidebarConversation();
}
// `<` and `>` fall through to the sidebar kit, which resizes it.
return;
}
if (inputFocused) {
if (isPlainKey(event, "escape")) {
consume();
blurInput();
}
return;
}
if (resultActive) {
if (isPlainKey(event, "escape")) {
consume();
setResultFocused(false);
return;
}
// The result table walks and opens its own rows.
if (isPlainKey(event, "j", "k", "up", "down", "enter", "return", "home", "end", "pageup", "pagedown")) return;
}
if (isPlainKey(event, "left") && showSidebar) {
consume();
focusSidebar();
return;
}
if (isPlainKey(event, "enter", "return")) {
consume();
focusInput();
return;
}
// With tool rows the arrows walk them; without, they scroll the answer.
if (isPlainKey(event, "j", "down") && timelineRows.length > 0) {
consume();
moveSelection(1);
return;
}
if (isPlainKey(event, "k", "up") && timelineRows.length > 0) {
consume();
moveSelection(-1);
return;
}
if (isPlainKey(event, "x") && expandTarget) {
consume();
toggleResult(expandTarget);
return;
}
if (isPlainKey(event, "o") && paneRow && canOpenPane) {
consume();
openPaneForTool(paneRow);
return;
}
if (isPlainKey(event, UNDO_KEY) && selectedRow && canUndo(selectedRow)) {
consume();
void controller.undo(selectedRow.toolCallId);
return;
}
if (isPlainKey(event, "t") && answerTickers.length > 0) {
consume();
void chooseAnswerTicker();
return;
}
if (event.name === UPGRADE_KEY && !event.ctrl && !event.meta && !event.alt && needsUpgrade) {
consume();
upgrade();
return;
}
if (isPlainKey(event, "r") && retryableTurn) {
consume();
void controller.retryTurn(retryableTurn.id);
return;
}
if (isPlainKey(event, "c") && running) {
consume();
controller.cancel();
}
}, { allowEditable: true });
// Hints name only what the current focus takes: the composer takes letters,
// the sidebar its own list keys, and the timeline everything else.
const hints = useMemo(() => {
const list: PaneHint[] = [];
if (!planAccess.emailVerified) return list;
if (confirmation) {
list.push(
{ id: "approve", key: "y", label: "es approve", title: "Approve", onPress: () => controller.resolveConfirmation(confirmation.toolCallId, true) },
{ id: "decline", key: "n", label: "o decline", title: "Decline", onPress: () => controller.resolveConfirmation(confirmation.toolCallId, false) },
);
}
if (inputFocused) return list;
if (showSidebar && sidebarFocused) {
// While a write waits, n answers it.
if (!confirmation) list.push({ id: "new", key: "n", label: "ew conversation", onPress: () => { leaveSidebar(); newConversation(); } });
if (sidebarTargetId) list.push({ id: "delete", key: "d", label: "elete", onPress: deleteSidebarConversation });
return list;
}
if (running) list.push({ id: "cancel", key: "c", label: "ancel", onPress: () => controller.cancel() });
if (retryableTurn) list.push({ id: "retry", key: "r", label: "etry", onPress: () => void controller.retryTurn(retryableTurn.id) });
if (needsUpgrade) list.push({ id: "upgrade", key: UPGRADE_KEY, label: "upgrade", title: "Upgrade to Pro", onPress: upgrade });
if (resultActive) list.push({ id: "result-back", key: "Esc", label: "timeline", title: "Back to Timeline", onPress: () => setResultFocused(false) });
if (expandTarget) {
const expanded = expandTarget.toolCallId === expandedToolCallId;
list.push({
id: "expand",
key: "x",
label: expanded ? " collapse" : "pand rows",
title: expanded ? "Collapse Rows" : "Expand Rows",
onPress: () => toggleResult(expandTarget),
});
}
if (paneRow && canOpenPane) list.push({ id: "open-pane", key: "o", label: "pen pane", onPress: () => openPaneForTool(paneRow) });
if (selectedRow && canUndo(selectedRow)) {
list.push({ id: "undo", key: UNDO_KEY, label: " undo", title: "Undo", onPress: () => void controller.undo(selectedRow.toolCallId) });
}
if (answerTickers.length > 0) list.push({ id: "tickers", key: "t", label: "ickers", onPress: () => void chooseAnswerTicker() });
if (showSidebar) list.push({ id: "conversations", key: "←", label: " conversations", onPress: focusSidebar });
return list;
}, [
planAccess.emailVerified,
answerTickers.length,
canOpenPane,
chooseAnswerTicker,
confirmation,
controller,
deleteSidebarConversation,
expandTarget,
expandedToolCallId,
focusSidebar,
inputFocused,
leaveSidebar,
needsUpgrade,
newConversation,
openPaneForTool,
paneRow,
resultActive,
retryableTurn,
running,
selectedRow,
showSidebar,
sidebarFocused,
sidebarTargetId,
toggleResult,
upgrade,
]);
usePaneFooter(`askg:${paneId}`, () => ({
info: [
...(running
? [{ id: "streaming", parts: [{ text: "Streaming", tone: "positive" as const, bold: true }] }]
: []),
...(confirmation
? [{ id: "confirm", parts: [{ text: "Waiting for approval", tone: "warning" as const }] }]
: []),
...(state.limits && state.limits.turnsRemainingToday >= 0 && !running
? [{
id: "quota",
parts: [{
text: `${state.limits.turnsRemainingToday} questions left today`,
tone: "muted" as const,
}],
}]
: []),
],
hints,
}), [confirmation, hints, running, state.limits]);
if (!planAccess.emailVerified) {
return (
);
}
const bodyWidth = Math.max(24, width - sidebarWidth);
const contentWidth = Math.max(24, bodyWidth - (nativePaneChrome ? 2 : 4));
const lastTurnId = activeTurn(state)?.id ?? null;
return (
{showSidebar ? (
void openConversation(conversationId)}
onFocusRequest={() => {
if (inputFocused) blurInput();
setResultFocused(false);
setSidebarFocused(true);
}}
onNewConversation={newConversation}
onDelete={(conversationId) => void deleteConversation(conversationId)}
/>
) : null}
setSidebarFocused(false)}
>
{state.turns.length === 0 ? (
) : state.turns.map((turn) => (
void controller.undo(toolCallId)}
onRetry={() => void controller.retryTurn(turn.id)}
onUpgrade={upgrade}
/>
))}
{confirmation ? (
controller.resolveConfirmation(confirmation.toolCallId, true)}
onDecline={() => controller.resolveConfirmation(confirmation.toolCallId, false)}
/>
) : null}
{expandedRow && detailHeight > 0 ? (
openPaneForTool(expandedRow)}
/>
) : null}
);
}