diff --git a/tests/tui/render-ink.mjs b/tests/tui/render-ink.mjs index aca4f35f7..d14cd7716 100644 --- a/tests/tui/render-ink.mjs +++ b/tests/tui/render-ink.mjs @@ -36,8 +36,11 @@ export async function renderInk(element, options = {}) { const stdin = options.stdin || createInputTty(); const stdout = options.stdout || createOutputTty(options); let buffer = ""; + const frames = []; stdout.on("data", (chunk) => { - buffer += chunk.toString("utf8"); + const text = chunk.toString("utf8"); + buffer += text; + frames.push(text); }); const app = render(element, { @@ -59,6 +62,13 @@ export async function renderInk(element, options = {}) { text() { return stripAnsi(buffer).replace(/\r/g, ""); }, + latestText() { + for (let i = frames.length - 1; i >= 0; i--) { + const stripped = stripAnsi(frames[i]).replace(/\r/g, ""); + if (stripped.trim().length > 0) return stripped; + } + return stripAnsi(buffer).replace(/\r/g, ""); + }, async press(chars, waitMs = 40) { stdin.write(chars); await delay(waitMs); @@ -68,4 +78,4 @@ export async function renderInk(element, options = {}) { await delay(waitMs); }, }; -} \ No newline at end of file +} diff --git a/tests/tui/screens.test.mjs b/tests/tui/screens.test.mjs index 1ba681f81..d74dc79f8 100644 --- a/tests/tui/screens.test.mjs +++ b/tests/tui/screens.test.mjs @@ -73,9 +73,9 @@ describe("tui screen rendering", () => { { columns: 220 }, ); - expect(view.text()).toContain("Runtime Snapshot"); - expect(view.text()).toContain("Active Sessions: 1"); - expect(view.text()).toContain("Investigate failing build"); + expect(view.latestText()).toContain("Runtime Snapshot"); + expect(view.latestText()).toContain("Active Sessions: 1"); + expect(view.latestText()).toContain("Investigate failing build"); await view.unmount(); }); @@ -86,10 +86,10 @@ describe("tui screen rendering", () => { { columns: 220 }, ); - expect(view.text()).toContain("[F]ilter: (title, tag, id)"); - expect(view.text()).toContain("TODO (1)"); - expect(view.text()).toContain("Review PR #404"); - expect(view.text()).toContain("DONE (1)"); + expect(view.latestText()).toContain("[F]ilter: (title, tag, id)"); + expect(view.latestText()).toContain("TODO (1)"); + expect(view.latestText()).toContain("Review PR #404"); + expect(view.latestText()).toContain("DONE (1)"); await view.unmount(); }); @@ -107,14 +107,14 @@ describe("tui screen rendering", () => { { columns: 220 }, ); - await waitFor(() => view.text().includes("Backoff queue (1)")); - expect(view.text()).toContain("Investigate fa"); + await waitFor(() => view.latestText().includes("Backoff queue (1)")); + expect(view.latestText()).toContain("Investigate fa"); await view.press("l"); - await waitFor(() => view.text().includes("Logs")); + await waitFor(() => view.latestText().includes("Logs")); - expect(view.text()).toContain("Loaded logs for session-"); - expect(view.text()).toContain("assistant"); + expect(view.latestText()).toContain("Loaded logs for session-"); + expect(view.latestText()).toContain("assistant"); await view.unmount(); }); @@ -138,16 +138,86 @@ describe("tui screen rendering", () => { bridge.emit("sessions:update", { sessions: sessionsFixture }); bridge.emit("task:create", tasksFixture[0]); await view.press(" ", 40); - await waitFor(() => view.text().includes("Runtime Snapshot")); + await waitFor(() => view.latestText().includes("Runtime Snapshot")); await view.press("2"); - await waitFor(() => view.text().includes("[F]ilter: (title, tag, id)")); + await waitFor(() => view.latestText().includes("[F]ilter: (title, tag, id)")); await view.press("3"); - await waitFor(() => view.text().includes("Backoff queue")); + await waitFor(() => view.latestText().includes("Backoff queue")); await view.unmount(); expect(bridge.connect).toHaveBeenCalled(); expect(bridge.disconnect).toHaveBeenCalled(); }); + it("shows the always-visible footer help and toggles the help overlay", async () => { + const bridge = createMockBridge(); + const view = await renderInk( + React.createElement(App, { + host: "127.0.0.1", + port: 3080, + connectOnly: true, + initialScreen: "status", + refreshMs: 2000, + wsClient: bridge, + }), + { columns: 220, rows: 20 }, + ); + + bridge.emit("connect", {}); + bridge.emit("stats", monitorStatsFixture); + bridge.emit("sessions:update", { sessions: sessionsFixture }); + bridge.emit("task:create", tasksFixture[0]); + await view.press(" ", 40); + await waitFor(() => view.text().includes("? Help")); + expect(view.text()).toContain("[1] Status"); + + await view.press("?"); + await waitFor(() => view.text().includes("Keyboard Shortcuts"), { timeoutMs: 3000 }); + expect(view.text()).toContain("Global"); + expect(view.text()).toContain("Agents screen"); + expect(view.text()).toContain("Modals"); + + await view.press("?"); + await view.press(" ", 40); + + await view.unmount(); + }); + + it("updates footer hints when task form focus changes", async () => { + const bridge = createMockBridge(); + const view = await renderInk( + React.createElement(App, { + host: "127.0.0.1", + port: 3080, + connectOnly: true, + initialScreen: "tasks", + refreshMs: 2000, + wsClient: bridge, + }), + { columns: 220, rows: 20 }, + ); + + bridge.emit("connect", {}); + bridge.emit("task:create", tasksFixture[0]); + await view.press(" ", 40); + await waitFor(() => view.latestText().includes("[F]ilter: (title, tag, id)")); + expect(view.latestText()).toContain("N New"); + expect(view.latestText()).toContain("? Help"); + + const baseline = view.latestText(); + await view.press("n"); + await waitFor(() => view.latestText().includes("New Task")); + const updated = view.latestText().slice(baseline.length); + expect(updated).toContain("Ctrl+S Save"); + expect(updated).toContain("Esc Cancel"); + expect(updated).not.toContain("N New | E Edit | D Delete"); + + await view.unmount(); + }); }); + + + + + diff --git a/tui/app.mjs b/tui/app.mjs index 3dff4c9a8..8de83fa53 100644 --- a/tui/app.mjs +++ b/tui/app.mjs @@ -11,6 +11,7 @@ const Box = ink.Box ?? ink.default?.Box; const Text = ink.Text ?? ink.default?.Text; const useApp = ink.useApp ?? ink.default?.useApp; const useInput = ink.useInput ?? ink.default?.useInput; +const useStdout = ink.useStdout ?? ink.default?.useStdout; import wsBridgeFactory from "./lib/ws-bridge.mjs"; import { getNextScreenForInput } from "./lib/navigation.mjs"; @@ -21,12 +22,16 @@ import LogsScreen from "./screens/logs.mjs"; import StatusScreen from "./screens/status.mjs"; import { readTuiHeaderConfig } from "./lib/header-config.mjs"; import { listTasksFromApi } from "../ui/tui/tasks-screen-helpers.js"; +import HelpScreen, { getFooterHints, SHORTCUT_GROUPS } from "../ui/tui/HelpScreen.js"; import { appendLogEntry, createDefaultLogsFilterState, ensureLogSource, } from "../ui/tui/logs-screen-helpers.js"; +const CLI_SHORTCUT_TITLES = new Set(["Global", "Tasks screen", "Agents screen", "Modals"]); +const CLI_SHORTCUT_GROUPS = SHORTCUT_GROUPS.filter((g) => CLI_SHORTCUT_TITLES.has(g.title)); + const html = htm.bind(React.createElement); const SCREENS = { @@ -70,6 +75,7 @@ function upsertById(items = [], nextItem) { export default function App({ host, port, connectOnly, initialScreen, refreshMs, wsClient }) { const { exit } = useApp(); + const { stdout } = useStdout(); const [screen, setScreen] = useState(initialScreen || "status"); const [connected, setConnected] = useState(false); const [connectionState, setConnectionState] = useState("offline"); @@ -80,6 +86,9 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs, const [logsFilterState, setLogsFilterState] = useState(createDefaultLogsFilterState()); const [error, setError] = useState(null); const [screenInputLocked, setScreenInputLocked] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const [helpScrollOffset, setHelpScrollOffset] = useState(0); + const [footerHints, setFooterHints] = useState(() => getFooterHints(initialScreen || "status")); const [refreshCountdownSec, setRefreshCountdownSec] = useState( Math.max(0, Math.ceil(Number(refreshMs || 2000) / 1000)), ); @@ -217,6 +226,14 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs, }; }, [bridge, refreshMs]); + useEffect(() => { + if (helpOpen) { + setFooterHints(getFooterHints(screen, { helpOpen: true })); + return; + } + setFooterHints(getFooterHints(screen)); + }, [screen, helpOpen]); + useEffect(() => { const intervalId = setInterval(() => { setRefreshCountdownSec((previous) => Math.max(0, previous - 1)); @@ -224,21 +241,61 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs, return () => clearInterval(intervalId); }, []); - const handleInput = useCallback((input) => { + const helpRows = Math.max(6, (stdout?.rows || 24) - 5); + const helpRowCount = CLI_SHORTCUT_GROUPS.reduce((totalRows, group, index, groups) => { + if (index % 2 === 1) return totalRows; + const right = groups[index + 1]; + const pairHeight = 1 + Math.max(group.items.length, right?.items?.length || 0); + return totalRows + pairHeight; + }, 0); + const maxHelpScrollOffset = Math.max(0, helpRowCount - helpRows); + + const handleInput = useCallback((input, key) => { + if (input === "?") { + setHelpOpen((current) => { + const opening = !current; + if (opening) { + setHelpScrollOffset(0); + setFooterHints(getFooterHints(screen, { helpOpen: true })); + } else { + setFooterHints(getFooterHints(screen)); + } + return opening; + }); + return; + } + if (helpOpen) { + if (key?.escape) { + setHelpOpen(false); + setHelpScrollOffset(0); + setFooterHints(getFooterHints(screen)); + return; + } + if (key?.upArrow) { + setHelpScrollOffset((current) => Math.max(0, current - 1)); + return; + } + if (key?.downArrow) { + setHelpScrollOffset((current) => Math.min(maxHelpScrollOffset, current + 1)); + return; + } + return; + } if (input === "q") { exit(); return; } setScreen((current) => getNextScreenForInput(current, input)); - }, [exit]); + }, [exit, helpOpen, maxHelpScrollOffset, screen]); - useInput((input) => { - if (screenInputLocked) return; - handleInput(input); + useInput((input, key) => { + if (screenInputLocked && !helpOpen && input !== "?") return; + handleInput(input, key); }); const ScreenComponent = SCREENS[screen] || StatusScreen; const screenStats = screen === "status" ? stats : undefined; + const footerText = (footerHints || []).map(([keysLabel, description]) => `${keysLabel} ${description}`).join(" | "); return html` <${Box} flexDirection="column" minHeight=${0}> @@ -273,7 +330,22 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs, onTasksChange=${setTasks} onLogsFilterStateChange=${setLogsFilterState} onInputCaptureChange=${setScreenInputLocked} + onFooterHintsChange=${setFooterHints} /> + ${helpOpen + ? html` + <${Box} flexDirection="column" marginTop=${1}> + <${HelpScreen} + scrollOffset=${helpScrollOffset} + maxRows=${helpRows} + groups=${CLI_SHORTCUT_GROUPS} + /> + + ` + : null} + + <${Box} paddingX=${1}> + <${Text} dimColor>${footerText} `; diff --git a/tui/screens/agents.mjs b/tui/screens/agents.mjs index 7e6d93ad6..730852714 100644 --- a/tui/screens/agents.mjs +++ b/tui/screens/agents.mjs @@ -1,5 +1,6 @@ import React from "react"; import htm from "htm"; +import { getFooterHints } from "../../ui/tui/HelpScreen.js"; import * as ink from "ink"; const Box = ink.Box ?? ink.default?.Box; @@ -93,7 +94,7 @@ function detailLines(sessionPayload) { ]; } -export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080, sessions, stats = null }) { +export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080, sessions, stats = null, onFooterHintsChange }) { const resolvedHost = wsBridge?.host || host; const resolvedPort = wsBridge?.port || port; const { stdout } = useStdout(); @@ -331,6 +332,16 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 } }); + React.useEffect(() => { + if (typeof onFooterHintsChange !== "function") return; + onFooterHintsChange(getFooterHints("agents", { + confirmKill, + detailOpen: Boolean(detailView), + logsOpen: logLines.length > 0, + diffOpen: Boolean(diffView), + })); + }, [confirmKill, detailView, diffView, logLines.length, onFooterHintsChange]); + const eventWidth = Math.max(12, (stdout?.columns || 120) - FIXED_TABLE_WIDTH); const backoffMessageWidth = Math.max(20, (stdout?.columns || 120) - 34); @@ -438,14 +449,7 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 : html`<${Text} dimColor>No changed files`} ` - : null} - - <${Box} marginTop=${1} borderStyle="single" paddingX=${1}> - <${Text} dimColor> - [K]ill session [P]ause [R]esume [L]ogs [D]iff [C]opy ID [Enter] Detail - - - ${statusLine + : null}${statusLine ? html` <${Box} marginTop=${1}> <${Text} color="yellow">${statusLine} diff --git a/ui/tui/App.js b/ui/tui/App.js index 476036d56..0255e47fa 100644 --- a/ui/tui/App.js +++ b/ui/tui/App.js @@ -18,6 +18,7 @@ import { MIN_TERMINAL_SIZE, TAB_ORDER, } from "./constants.js"; +import HelpScreen, { getFooterHints, SHORTCUT_GROUPS } from "./HelpScreen.js"; import { useWebSocket } from "./useWebSocket.js"; import { useTasks } from "./useTasks.js"; import { useWorkflows } from "./useWorkflows.js"; @@ -143,9 +144,25 @@ function ScreenFrame({ title, subtitle, children }) { `; } +function FooterHints({ hints, width }) { + const text = (Array.isArray(hints) ? hints : []) + .map(([keysLabel, description]) => `${keysLabel} ${description}`.trim()) + .join(" | "); + const clipped = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text; + + return html` + <${Box} marginTop=${1}> + <${Text} color=${ANSI_COLORS.accent}>${clipped} + + `; +} + export default function App({ config, configDir, host, port, protocol = "ws", initialScreen = "agents", terminalSize }) { const { exit } = useApp(); const [activeTab, setActiveTab] = useState(initialScreen); + const [helpOpen, setHelpOpen] = useState(false); + const [helpScrollOffset, setHelpScrollOffset] = useState(0); + const [footerHints, setFooterHints] = useState(() => getFooterHints(initialScreen)); const wsState = useWebSocket({ host, port, configDir, protocol }); const taskState = useTasks(); const workflowState = useWorkflows(config); @@ -155,7 +172,46 @@ export default function App({ config, configDir, host, port, protocol = "ws", in [taskState.tasks, wsState.tasks], ); + useEffect(() => { + if (!helpOpen) { + setFooterHints(getFooterHints(activeTab)); + } + }, [activeTab, helpOpen]); + + const helpMaxRows = Math.max(3, terminalSize.rows - 8); + const helpRowCount = SHORTCUT_GROUPS.reduce((totalRows, group, index, groups) => { + if (index % 2 === 1) return totalRows; + const right = groups[index + 1]; + const pairHeight = 1 + Math.max(group.items.length, right?.items?.length || 0); + return totalRows + pairHeight; + }, 0); + const maxHelpScrollOffset = Math.max(0, helpRowCount - helpMaxRows); + useInput((input, key) => { + if (helpOpen) { + if (input === "?" || key.escape) { + setHelpOpen(false); + setHelpScrollOffset(0); + return; + } + if (key.upArrow) { + setHelpScrollOffset((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow) { + setHelpScrollOffset((current) => Math.min(maxHelpScrollOffset, current + 1)); + return; + } + return; + } + + if (input === "?") { + setHelpOpen(true); + setHelpScrollOffset(0); + setFooterHints(getFooterHints(activeTab, { helpOpen: true })); + return; + } + if (input === KEY_BINDINGS.q) { exit(); return; @@ -300,7 +356,17 @@ export default function App({ config, configDir, host, port, protocol = "ws", in stats=${wsState.stats} terminalSize=${terminalSize} /> - ${body} + <${Box} flexDirection="column" flexGrow=${1}> + ${body} + + ${helpOpen + ? html` + <${Box} marginTop=${1} flexGrow=${1}> + <${HelpScreen} scrollOffset=${helpScrollOffset} maxRows=${helpMaxRows} /> + + ` + : null} + <${FooterHints} hints=${helpOpen ? getFooterHints(activeTab, { helpOpen: true }) : footerHints} width=${terminalSize.columns} /> `; } diff --git a/ui/tui/HelpScreen.js b/ui/tui/HelpScreen.js new file mode 100644 index 000000000..0506394ac --- /dev/null +++ b/ui/tui/HelpScreen.js @@ -0,0 +1,169 @@ +import React from "react"; +import htm from "htm"; +import { Box, Text } from "ink"; + +const html = htm.bind(React.createElement); + +export const SHORTCUT_GROUPS = [ + { + title: "Global", + items: [ + ["1 / 2 / 3", "Switch primary screens"], + ["A / T / L / W / X / S", "Jump to a screen by shortcut"], + ["Tab / Shift+Tab", "Cycle screens"], + ["?", "Toggle keyboard help overlay"], + ["Q", "Quit the TUI"], + ], + }, + { + title: "Agents screen", + items: [ + ["↑ / ↓", "Move session selection"], + ["Enter", "Open session detail"], + ["L", "Open session logs"], + ["D", "Open session diff"], + ["K", "Queue session termination"], + ["B", "Inspect backoff queue"], + ["Esc", "Close detail panes / cancel kill"], + ], + }, + { + title: "Tasks screen", + items: [ + ["↑ / ↓ / ← / →", "Move task selection"], + ["N", "Create task"], + ["E / Enter", "Edit selected task"], + ["D", "Delete selected task"], + ["F", "Open filter"], + ["V", "Toggle kanban/list view"], + ["[ / ]", "Move task between statuses"], + ], + }, + { + title: "Logs screen", + items: [ + ["L", "Open session logs"], + ["Esc", "Close logs"], + ], + }, + { + title: "Telemetry screen", + items: [ + ["1", "Open status metrics"], + ["B", "Inspect retry/backoff queue"], + ], + }, + { + title: "Workflows screen", + items: [ + ["1", "Open workflow snapshot"], + ["2", "Open tasks"], + ], + }, + { + title: "Settings screen", + items: [ + ["1 / 2 / 3", "Switch top-level views"], + ["Q", "Quit"], + ], + }, + { + title: "Modals", + items: [ + ["Esc", "Dismiss current modal"], + ["?", "Close help overlay"], + ["Ctrl+S", "Save active form"], + ["Tab / Shift+Tab", "Move between form fields"], + ["← / →", "Change select field value"], + ["Y / N", "Confirm or cancel destructive prompts"], + ], + }, +]; + +export function getFooterHints(screen, context = {}) { + if (context.helpOpen) { + return [ + ["?", "Close help"], + ["Esc", "Dismiss overlay"], + ["↑/↓", "Scroll list"], + ]; + } + + if (screen === "tasks") { + if (context.deletePrompt) { + return [["Y", "Confirm delete"], ["N", "Keep task"], ["Esc", "Cancel"], ["?", "Help"]]; + } + if (context.formMode) { + return [["Ctrl+S", "Save"], ["Esc", "Cancel"], ["Tab", "Next field"], ["Shift+Tab", "Prev field"], ["?", "Help"]]; + } + if (context.filterOpen) { + return [["Type", "Filter tasks"], ["Enter", "Apply"], ["Backspace", "Delete char"], ["Esc", "Close"], ["?", "Help"]]; + } + return [["N", "New"], ["E", "Edit"], ["D", "Delete"], ["F", "Filter"], ["?", "Help"]]; + } + + if (screen === "agents") { + if (context.confirmKill) { + return [["Y", "Kill session"], ["N", "Keep session"], ["Esc", "Cancel"], ["?", "Help"]]; + } + if (context.detailOpen || context.logsOpen || context.diffOpen) { + return [["Esc", "Close pane"], ["↑/↓", "Change session"], ["L", "Logs"], ["D", "Diff"], ["?", "Help"]]; + } + return [["↑/↓", "Move"], ["Enter", "Detail"], ["L", "Logs"], ["K", "Kill"], ["?", "Help"]]; + } + + return [["1/2/3", "Switch screens"], ["?", "Help"], ["Q", "Quit"]]; +} + +export default function HelpScreen({ groups = SHORTCUT_GROUPS, scrollOffset = 0, maxRows = 20 }) { + const columns = [[], []]; + groups.forEach((group, index) => { + columns[index % 2].push(group); + }); + + const keyWidth = Math.max( + 12, + ...groups.flatMap((group) => group.items.map(([keysLabel]) => String(keysLabel || "").length)), + ); + + const renderGroup = (group) => html` + <${Box} key=${group.title} flexDirection="column" marginBottom=${1}> + <${Text} bold>${group.title} + ${group.items.map(([keysLabel, description]) => html` + <${Box} key=${`${group.title}-${keysLabel}`}> + <${Text} color="cyan">${String(keysLabel || "").padEnd(keyWidth, " ")} + <${Text}> ${description} + + `)} + + `; + + const pairedRows = []; + for (let index = 0; index < Math.max(columns[0].length, columns[1].length); index += 1) { + pairedRows.push([columns[0][index] || null, columns[1][index] || null]); + } + + const visibleRows = pairedRows.slice(scrollOffset, scrollOffset + Math.max(1, maxRows)); + + return html` + <${Box} flexDirection="column" flexGrow=${1} borderStyle="double" paddingX=${1} paddingY=${0}> + <${Text} bold>Keyboard Shortcuts + <${Text} dimColor>Press [?] or [Esc] to close • Use ↑/↓ to scroll + <${Box} flexDirection="column" flexGrow=${1}> + ${visibleRows.map(([left, right], index) => html` + <${Box} key=${index} marginTop=${1}> + <${Box} flexDirection="column" width="50%" paddingRight=${1}> + ${left ? renderGroup(left) : null} + + <${Box} flexDirection="column" width="50%" paddingLeft=${1}> + ${right ? renderGroup(right) : null} + + + `)} + + ${pairedRows.length > visibleRows.length + ? html`<${Text} dimColor>Showing ${scrollOffset + 1}-${Math.min(pairedRows.length, scrollOffset + visibleRows.length)} of ${pairedRows.length} rows` + : null} + + `; +} diff --git a/ui/tui/TasksScreen.js b/ui/tui/TasksScreen.js index bf6114958..3227f05c0 100644 --- a/ui/tui/TasksScreen.js +++ b/ui/tui/TasksScreen.js @@ -10,6 +10,7 @@ const Box = ink.Box ?? ink.default?.Box; const Text = ink.Text ?? ink.default?.Text; const useInput = ink.useInput ?? ink.default?.useInput; const useStdout = ink.useStdout ?? ink.default?.useStdout; +import { getFooterHints } from "./HelpScreen.js"; import { buildBoardColumns, @@ -127,16 +128,12 @@ function TaskForm({ mode, formState, activeFieldIndex, validationErrors, busy }) value=${formState[field.key] || ""} error=${validationErrors[field.key]} /> - `)} - <${Text} dimColor> - [Tab] Next [Shift+Tab] Prev [Left/Right] Select [Ctrl+S] Save [Esc] Cancel - - ${busy ? html`<${Text} color="yellow">Saving...` : null} + `)}${busy ? html`<${Text} color="yellow">Saving...` : null} `; } -export default function TasksScreen({ tasks = [], onTasksChange, onInputCaptureChange }) { +export default function TasksScreen({ tasks = [], onTasksChange, onInputCaptureChange, onFooterHintsChange }) { const { stdout } = useStdout(); const terminalWidth = stdout?.columns || process.stdout.columns || 120; const [preferredView, setPreferredView] = useState("kanban"); @@ -271,6 +268,15 @@ export default function TasksScreen({ tasks = [], onTasksChange, onInputCaptureC } } + + useEffect(() => { + if (typeof onFooterHintsChange !== "function") return; + onFooterHintsChange(getFooterHints("tasks", { + formMode, + filterOpen, + deletePrompt, + })); + }, [deletePrompt, filterOpen, formMode, onFooterHintsChange]); async function moveSelectedTask(direction) { if (!selectedTask || busy) return; const currentIndex = STATUS_MOVE_ORDER.indexOf(selectedTask.statusDisplay || "todo"); @@ -547,12 +553,6 @@ export default function TasksScreen({ tasks = [], onTasksChange, onInputCaptureC : html`<${Text} dimColor>No matching tasks`} `} - - <${Box} marginTop=${1} paddingX=${1} borderStyle="single"> - <${Text} dimColor> - [Arrows] Navigate [Enter] Edit [N] New [E] Edit [D] Delete [F] Filter [V] View [[]/[]] Move - - ${selectedTask ? html` <${Box} marginTop=${1} paddingX=${1} flexDirection="column" borderStyle="single"> @@ -571,3 +571,5 @@ export default function TasksScreen({ tasks = [], onTasksChange, onInputCaptureC `; } + +