From 506859478d26557a43786a783bba9ddfe101cf61 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Fri, 1 May 2026 15:02:15 -0500 Subject: [PATCH 1/2] [feat]: enhance TUI workflow --- README.md | 3 + package-lock.json | 108 +++++++++++++ package.json | 4 + src/core/auditEngine.ts | 174 ++++++++++++++------ src/core/types.ts | 61 +++++++ src/tools/registry.ts | 11 +- src/tui/App.tsx | 326 ++++++++++++++++++++++++++++++++++++-- src/tui/components.tsx | 168 ++++++++++++++++++++ src/tui/preflight.ts | 47 ++++++ tests/auditEngine.test.ts | 66 ++++++++ tests/tui.test.tsx | 209 ++++++++++++++++++++++++ vitest.config.ts | 2 +- 12 files changed, 1115 insertions(+), 64 deletions(-) create mode 100644 src/tui/components.tsx create mode 100644 src/tui/preflight.ts create mode 100644 tests/tui.test.tsx diff --git a/README.md b/README.md index 26f3184..c61f291 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,14 @@ It profiles application repositories, runs registered deterministic security too npm install npm run build npm run dev -- init +npm run dev npm run dev -- audit . ``` By default, LLM runtimes are disabled and scanner tools are detected from your local `PATH`. Enable runtimes in `.secflow/config.yaml` and pass `--approve-context` when you want a curated, redacted context package sent to a configured provider or local agent CLI. +Running `npm run dev` launches the interactive TUI audit wizard. It walks through target selection, preflight checks, live audit progress, optional LLM context approval, and results/report review. + ## Commands ```bash diff --git a/package-lock.json b/package-lock.json index 52bca6e..83c1c44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "commander": "^14.0.2", "execa": "^9.6.0", "ink": "^6.5.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "react": "^19.2.0", "yaml": "^2.8.1", "zod": "^4.1.12" @@ -21,6 +24,7 @@ "devDependencies": { "@types/node": "^24.10.1", "@types/react": "^19.2.6", + "ink-testing-library": "^4.0.0", "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^4.0.13" @@ -1140,6 +1144,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-truncate": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", @@ -1515,6 +1531,86 @@ } } }, + "node_modules/ink-select-input": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", + "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", + "license": "MIT", + "dependencies": { + "figures": "^6.1.0", + "to-rotated": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink-text-input/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ink/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -2358,6 +2454,18 @@ "node": ">=14.0.0" } }, + "node_modules/to-rotated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", + "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index e5244e4..b81dd5b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "commander": "^14.0.2", "execa": "^9.6.0", "ink": "^6.5.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "react": "^19.2.0", "yaml": "^2.8.1", "zod": "^4.1.12" @@ -32,6 +35,7 @@ "devDependencies": { "@types/node": "^24.10.1", "@types/react": "^19.2.6", + "ink-testing-library": "^4.0.0", "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^4.0.13" diff --git a/src/core/auditEngine.ts b/src/core/auditEngine.ts index ff8d401..33a78a2 100644 --- a/src/core/auditEngine.ts +++ b/src/core/auditEngine.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import type {AuditRun, LlmResponse, LlmTask, NormalizedFinding, SecFlowConfig} from './types.js'; +import type {AuditEvent, AuditRun, AuditStep, ContextPreview, LlmResponse, LlmTask, NormalizedFinding, SecFlowConfig} from './types.js'; import {extractBusinessWorkflowModel} from './business.js'; import {buildContextPackage, contextSizeBytes, redactContext} from './context.js'; import {collectFindings} from './findings.js'; @@ -14,59 +14,86 @@ export interface AuditOptions { targetPath: string; config: SecFlowConfig; contextApproved?: boolean; + approveContext?: (preview: ContextPreview) => boolean | Promise; + onEvent?: (event: AuditEvent) => void; runtime?: string; } export async function runAudit(options: AuditOptions): Promise { - const targetPath = path.resolve(options.targetPath); - const runId = new Date().toISOString().replace(/[:.]/g, '-'); - const runDir = path.join(targetPath, options.config.outputs.directory, 'runs', runId); - await ensureDir(runDir); + try { + const targetPath = path.resolve(options.targetPath); + emit(options, 'step:start', 'initialize', `Preparing audit for ${targetPath}.`); + const runId = new Date().toISOString().replace(/[:.]/g, '-'); + const runDir = path.join(targetPath, options.config.outputs.directory, 'runs', runId); + await ensureDir(runDir); + emit(options, 'step:complete', 'initialize', `Run directory created.`, {runId, runDir}); - const profile = await profileRepository(targetPath); - await writeJson(path.join(runDir, 'repo-profile.json'), profile); + emit(options, 'step:start', 'profile', 'Profiling repository.'); + const profile = await profileRepository(targetPath); + await writeJson(path.join(runDir, 'repo-profile.json'), profile); + emit(options, 'step:complete', 'profile', `Profiled ${profile.fileCount} files.`, {fileCount: profile.fileCount}); - const business = await extractBusinessWorkflowModel(targetPath, profile); - await writeJson(path.join(runDir, 'business-workflow.json'), business); + emit(options, 'step:start', 'business-workflows', 'Extracting business workflow signals.'); + const business = await extractBusinessWorkflowModel(targetPath, profile); + await writeJson(path.join(runDir, 'business-workflow.json'), business); + emit(options, 'step:complete', 'business-workflows', `Extracted ${business.risks.length} business logic hypotheses.`, {riskCount: business.risks.length}); - const toolResults = await runConfiguredTools(targetPath, runDir, options.config); - await writeJson(path.join(runDir, 'tool-results.json'), toolResults); + emit(options, 'step:start', 'tools', 'Running registered deterministic security tools.'); + const toolResults = await runConfiguredTools(targetPath, runDir, options.config, (result) => { + options.onEvent?.({type: 'tool:complete', step: 'tools', timestamp: new Date().toISOString(), result}); + }); + await writeJson(path.join(runDir, 'tool-results.json'), toolResults); + emit(options, 'step:complete', 'tools', `Completed ${toolResults.length} tool checks.`, {toolCount: toolResults.length}); - const findings = collectFindings(toolResults, business); - await writeJson(path.join(runDir, 'normalized-findings.json'), findings); + const findings = collectFindings(toolResults, business); + await writeJson(path.join(runDir, 'normalized-findings.json'), findings); - const llmResponses = await maybeInvokeLlm(options, targetPath, profile, business, findings, runDir); - await writeJson(path.join(runDir, 'llm-responses.json'), llmResponses); + const llmResponses = await maybeInvokeLlm(options, targetPath, profile, business, findings, runDir); + await writeJson(path.join(runDir, 'llm-responses.json'), llmResponses); - const reportWithoutPaths = { - runId, - targetPath, - runDir, - profile, - business, - toolResults, - findings, - llmResponses - }; - const reportPath = path.join(runDir, 'report.md'); - const sarifPath = path.join(runDir, 'report.sarif'); - await writeText(reportPath, renderMarkdownReport(reportWithoutPaths)); - await writeJson(sarifPath, renderSarif(findings)); - await writeJson(path.join(runDir, 'manifest.json'), { - runId, - targetPath, - generatedAt: new Date().toISOString(), - reportPath, - sarifPath, - findingCount: findings.length, - llmInvoked: llmResponses.length > 0 - }); + emit(options, 'step:start', 'reports', 'Writing Markdown, JSON, and SARIF reports.'); + const reportWithoutPaths = { + runId, + targetPath, + runDir, + profile, + business, + toolResults, + findings, + llmResponses + }; + const reportPath = path.join(runDir, 'report.md'); + const sarifPath = path.join(runDir, 'report.sarif'); + await writeText(reportPath, renderMarkdownReport(reportWithoutPaths)); + await writeJson(sarifPath, renderSarif(findings)); + await writeJson(path.join(runDir, 'manifest.json'), { + runId, + targetPath, + generatedAt: new Date().toISOString(), + reportPath, + sarifPath, + findingCount: findings.length, + llmInvoked: llmResponses.length > 0 + }); + emit(options, 'step:complete', 'reports', 'Reports written.', {reportPath, sarifPath}); - return { - ...reportWithoutPaths, - reportPath, - sarifPath - }; + const run = { + ...reportWithoutPaths, + reportPath, + sarifPath + }; + options.onEvent?.({type: 'run:complete', step: 'complete', timestamp: new Date().toISOString(), run}); + return run; + } catch (error) { + options.onEvent?.({ + type: 'error', + step: 'error', + timestamp: new Date().toISOString(), + message: error instanceof Error ? error.message : String(error), + error + }); + throw error; + } } async function maybeInvokeLlm( @@ -81,23 +108,47 @@ async function maybeInvokeLlm( const runtime = options.runtime ?? options.config.defaultRuntime; if (!runtime) { await writeJson(path.join(runDir, 'llm-skip.json'), {reason: 'No default runtime configured.'}); - return responses; - } - if (options.config.context.requireApproval && !options.contextApproved) { - await writeJson(path.join(runDir, 'llm-skip.json'), {reason: 'Context approval was required but not provided.'}); + options.onEvent?.({type: 'llm:skipped', step: 'llm', timestamp: new Date().toISOString(), reason: 'No default runtime configured.'}); return responses; } + emit(options, 'step:start', 'context-preview', 'Building LLM context preview.'); const registry = await PromptRegistry.fromDirectory(targetPath, options.config.prompts.directory); registry.validateRequired(options.config.prompts.required); const context = redactContext(buildContextPackage(profile, business, findings), options.config.context.redactions); const size = contextSizeBytes(context); - await writeJson(path.join(runDir, 'llm-context-preview.json'), {sizeBytes: size, context}); + const contextPath = path.join(runDir, 'llm-context-preview.json'); + await writeJson(contextPath, {sizeBytes: size, context}); + const preview: ContextPreview = { + runtime, + promptId: 'report-synthesis', + sizeBytes: size, + maxBytes: options.config.context.maxBytes, + requireApproval: options.config.context.requireApproval, + redactionPatternCount: options.config.context.redactions.length, + contextPath + }; + options.onEvent?.({type: 'context:preview', step: 'context-preview', timestamp: new Date().toISOString(), preview}); + emit(options, 'step:complete', 'context-preview', `LLM context preview is ${size} bytes.`, {sizeBytes: size, contextPath}); if (size > options.config.context.maxBytes) { await writeJson(path.join(runDir, 'llm-skip.json'), {reason: `Context package exceeded ${options.config.context.maxBytes} bytes.`, sizeBytes: size}); + options.onEvent?.({ + type: 'llm:skipped', + step: 'llm', + timestamp: new Date().toISOString(), + reason: `Context package exceeded ${options.config.context.maxBytes} bytes.` + }); return responses; } + const approved = await resolveContextApproval(options, preview); + if (options.config.context.requireApproval && !approved) { + await writeJson(path.join(runDir, 'llm-skip.json'), {reason: 'Context approval was required but not provided.', preview}); + options.onEvent?.({type: 'llm:skipped', step: 'llm', timestamp: new Date().toISOString(), reason: 'Context approval was required but not provided.'}); + return responses; + } + + emit(options, 'step:start', 'llm', `Invoking LLM runtime ${runtime}.`); const task: LlmTask = { id: 'report-synthesis', promptId: 'report-synthesis', @@ -114,5 +165,32 @@ async function maybeInvokeLlm( if (response) { responses.push(response); } + emit(options, 'step:complete', 'llm', response ? `Runtime ${runtime} completed.` : `Runtime ${runtime} was not invoked.`); return responses; } + +export function createAuditRunner(options: AuditOptions): {run: () => Promise} { + return { + run: () => runAudit(options) + }; +} + +async function resolveContextApproval(options: AuditOptions, preview: ContextPreview): Promise { + if (!options.config.context.requireApproval) { + return true; + } + if (options.approveContext) { + return Boolean(await options.approveContext(preview)); + } + return Boolean(options.contextApproved); +} + +function emit(options: AuditOptions, type: 'step:start' | 'step:complete', step: AuditStep, message: string, data?: Record): void { + options.onEvent?.({ + type, + step, + message, + timestamp: new Date().toISOString(), + data + }); +} diff --git a/src/core/types.ts b/src/core/types.ts index 3e44a1a..b70fa98 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -160,3 +160,64 @@ export interface AuditRun { reportPath: string; sarifPath: string; } + +export type AuditStep = + | 'initialize' + | 'profile' + | 'business-workflows' + | 'tools' + | 'context-preview' + | 'llm' + | 'reports' + | 'complete' + | 'error'; + +export interface ContextPreview { + runtime: string; + promptId: string; + sizeBytes: number; + maxBytes: number; + requireApproval: boolean; + redactionPatternCount: number; + contextPath: string; +} + +export type AuditEvent = + | { + type: 'step:start' | 'step:complete'; + step: AuditStep; + message: string; + timestamp: string; + data?: Record; + } + | { + type: 'tool:complete'; + step: 'tools'; + timestamp: string; + result: ToolRunResult; + } + | { + type: 'context:preview'; + step: 'context-preview'; + timestamp: string; + preview: ContextPreview; + } + | { + type: 'llm:skipped'; + step: 'llm'; + timestamp: string; + reason: string; + } + | { + type: 'run:complete'; + step: 'complete'; + timestamp: string; + run: AuditRun; + } + | { + type: 'error'; + step: 'error'; + timestamp: string; + message: string; + error?: unknown; + }; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 5f7a5b8..872bacf 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -6,14 +6,21 @@ import {commandExists, commandVersion} from '../util/process.js'; export const builtInToolAdapters = [semgrepAdapter, trivyAdapter, joernAdapter]; -export async function runConfiguredTools(targetPath: string, runDir: string, config: SecFlowConfig): Promise { +export async function runConfiguredTools( + targetPath: string, + runDir: string, + config: SecFlowConfig, + onToolComplete?: (result: ToolRunResult) => void +): Promise { const results: ToolRunResult[] = []; for (const adapter of builtInToolAdapters) { const toolConfig = config.tools[adapter.name]; if (!toolConfig) { continue; } - results.push(await adapter.run(targetPath, runDir, toolConfig)); + const result = await adapter.run(targetPath, runDir, toolConfig); + results.push(result); + onToolComplete?.(result); } return results; } diff --git a/src/tui/App.tsx b/src/tui/App.tsx index c1e6649..96cdc03 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1,26 +1,326 @@ -import React from 'react'; -import {Box, Text} from 'ink'; +import React, {useRef, useState} from 'react'; +import path from 'node:path'; +import {Box, Text, useApp, useInput} from 'ink'; +import SelectInput from 'ink-select-input'; +import TextInput from 'ink-text-input'; +import Spinner from 'ink-spinner'; +import type {AuditEvent, AuditRun, ContextPreview} from '../core/types.js'; +import {runAudit as defaultRunAudit, type AuditOptions} from '../core/auditEngine.js'; +import {ContextApprovalSummary, EventLog, PreflightSummary, ResultsSummary, Section} from './components.js'; +import {loadPreflightData, type PreflightData} from './preflight.js'; + +type Screen = 'home' | 'commands' | 'target' | 'preflight-loading' | 'preflight' | 'running' | 'context-approval' | 'results' | 'error' | 'confirm-exit'; +type HomeAction = 'start' | 'commands' | 'quit'; +type PreflightAction = 'run' | 'back' | 'quit'; +type ApprovalAction = 'approve' | 'skip'; +type ConfirmExitAction = 'stay' | 'exit'; export interface AppProps { cwd: string; + loadPreflight?: (cwd: string, targetPath: string) => Promise; + runAudit?: (options: AuditOptions) => Promise; } -export function App({cwd}: AppProps): React.ReactElement { +export function App({cwd, loadPreflight = loadPreflightData, runAudit = defaultRunAudit}: AppProps): React.ReactElement { + const {exit} = useApp(); + const [screen, setScreen] = useState('home'); + const [targetPath, setTargetPath] = useState(cwd); + const [preflight, setPreflight] = useState(); + const [events, setEvents] = useState([]); + const [run, setRun] = useState(); + const [error, setError] = useState(); + const [approvalRequest, setApprovalRequest] = useState<{preview: ContextPreview; resolve: (approved: boolean) => void} | undefined>(); + const [selectionIndexes, setSelectionIndexes] = useState>({}); + const activeRun = useRef(false); + + useInput((input, key) => { + if (key.tab || input === '\t') { + advanceSelection(screen, setSelectionIndexes); + } + if (input === 'q') { + if (activeRun.current) { + setScreen('confirm-exit'); + } else { + exit(); + } + } + if (key.escape) { + if (screen === 'target') setScreen('home'); + if (screen === 'commands') setScreen('home'); + if (screen === 'preflight') setScreen('target'); + if (screen === 'results' || screen === 'error') setScreen('home'); + if (screen === 'running' || screen === 'context-approval') setScreen('confirm-exit'); + if (screen === 'confirm-exit') setScreen(activeRun.current ? 'running' : 'home'); + } + }); + + async function preparePreflight(value: string): Promise { + const requestedPath = value.trim() || cwd; + setTargetPath(requestedPath); + setError(undefined); + setScreen('preflight-loading'); + try { + setPreflight(await loadPreflight(cwd, requestedPath)); + setScreen('preflight'); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + setScreen('error'); + } + } + + async function startAudit(): Promise { + if (!preflight) { + return; + } + activeRun.current = true; + setEvents([]); + setRun(undefined); + setApprovalRequest(undefined); + setError(undefined); + setScreen('running'); + + try { + const completedRun = await runAudit({ + targetPath: preflight.targetPath, + config: preflight.config, + runtime: preflight.defaultRuntime, + onEvent: (event) => setEvents((current) => [...current, event]), + approveContext: (preview) => + new Promise((resolve) => { + setApprovalRequest({preview, resolve}); + setScreen('context-approval'); + }) + }); + setRun(completedRun); + setScreen('results'); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + setScreen('error'); + } finally { + activeRun.current = false; + setApprovalRequest(undefined); + } + } + + function resolveApproval(approved: boolean): void { + approvalRequest?.resolve(approved); + setApprovalRequest(undefined); + setScreen('running'); + } + return ( +
+ {screen === 'home' && handleHomeAction(action, setScreen, exit)} />} + {screen === 'commands' && } + {screen === 'target' && } + {screen === 'preflight-loading' && } + {screen === 'preflight' && preflight && handlePreflightAction(action, startAudit, setScreen, exit)} />} + {screen === 'running' && } + {screen === 'context-approval' && approvalRequest && } + {screen === 'results' && run && (action === 'home' ? setScreen('home') : exit())} />} + {screen === 'error' && } + {screen === 'confirm-exit' && handleExitConfirmation(action, approvalRequest?.resolve, setScreen, exit)} />} + Enter selects. Tab or arrows move. Esc backs out. q quits. + + ); +} + +function Header({cwd, screen}: {cwd: string; screen: Screen}): React.ReactElement { + return ( + SecFlow - Application security LLM harness for repository profiling, deterministic scanners, and business logic analysis. - Workspace: {cwd} - - Useful commands - secflow init - secflow audit . --approve-context - secflow tools doctor - secflow models list - secflow playbooks validate playbooks/default-audit.yaml - + Application security audit workflow for repository profiling, scanners, and business logic analysis. + + Workspace: {cwd} / Screen: {screen} + ); } + +export function HomeScreen({onSelect, initialIndex = 0}: {onSelect: (action: HomeAction) => void; initialIndex?: number}): React.ReactElement { + return ( +
+ + key={`home-${initialIndex}`} + initialIndex={initialIndex} + items={[ + {label: 'Start audit wizard', value: 'start'}, + {label: 'View command reference', value: 'commands'}, + {label: 'Quit', value: 'quit'} + ]} + onSelect={(item) => onSelect(item.value)} + /> +
+ ); +} + +export function CommandsScreen(): React.ReactElement { + return ( +
+ secflow init + secflow audit . --approve-context + secflow tools doctor + secflow models list + secflow playbooks validate playbooks/default-audit.yaml +
+ ); +} + +export function TargetScreen({value, onChange, onSubmit}: {value: string; onChange: (value: string) => void; onSubmit: (value: string) => void}): React.ReactElement { + return ( +
+ Enter the repository path to audit. + + Path: + + +
+ ); +} + +export function LoadingScreen({label}: {label: string}): React.ReactElement { + return ( +
+ + + + {' '} + {label} + +
+ ); +} + +export function PreflightScreen({data, onSelect, initialIndex = 0}: {data: PreflightData; onSelect: (action: PreflightAction) => void; initialIndex?: number}): React.ReactElement { + return ( + <> + +
+ + key={`preflight-${initialIndex}`} + initialIndex={initialIndex} + items={[ + {label: 'Run audit', value: 'run'}, + {label: 'Back to target path', value: 'back'}, + {label: 'Quit', value: 'quit'} + ]} + onSelect={(item) => onSelect(item.value)} + /> +
+ + ); +} + +export function RunningScreen({events}: {events: AuditEvent[]}): React.ReactElement { + return ( + <> + + + + ); +} + +export function ContextApprovalScreen({preview, onSelect, initialIndex = 0}: {preview: ContextPreview; onSelect: (approved: boolean) => void; initialIndex?: number}): React.ReactElement { + return ( + <> + +
+ + key={`approval-${initialIndex}`} + initialIndex={initialIndex} + items={[ + {label: 'Approve LLM runtime call', value: 'approve'}, + {label: 'Skip LLM runtime call', value: 'skip'} + ]} + onSelect={(item) => onSelect(item.value === 'approve')} + /> +
+ + ); +} + +export function ResultsScreen({run, onSelect, initialIndex = 0}: {run: AuditRun; onSelect: (action: 'home' | 'quit') => void; initialIndex?: number}): React.ReactElement { + return ( + <> + +
+ + key={`results-${initialIndex}`} + initialIndex={initialIndex} + items={[ + {label: 'Back home', value: 'home'}, + {label: 'Quit', value: 'quit'} + ]} + onSelect={(item) => onSelect(item.value)} + /> +
+ + ); +} + +export function ErrorScreen({message}: {message: string}): React.ReactElement { + return ( +
+ {message} +
+ ); +} + +export function ConfirmExitScreen({onSelect, initialIndex = 0}: {onSelect: (action: ConfirmExitAction) => void; initialIndex?: number}): React.ReactElement { + return ( +
+ An audit is active or waiting for approval. + + key={`confirm-${initialIndex}`} + initialIndex={initialIndex} + items={[ + {label: 'Keep running', value: 'stay'}, + {label: 'Exit SecFlow', value: 'exit'} + ]} + onSelect={(item) => onSelect(item.value)} + /> +
+ ); +} + +function handleHomeAction(action: HomeAction, setScreen: (screen: Screen) => void, exit: () => void): void { + if (action === 'start') setScreen('target'); + if (action === 'commands') setScreen('commands'); + if (action === 'quit') exit(); +} + +function handlePreflightAction(action: PreflightAction, startAudit: () => void, setScreen: (screen: Screen) => void, exit: () => void): void { + if (action === 'run') void startAudit(); + if (action === 'back') setScreen('target'); + if (action === 'quit') exit(); +} + +function handleExitConfirmation(action: ConfirmExitAction, resolveApproval: ((approved: boolean) => void) | undefined, setScreen: (screen: Screen) => void, exit: () => void): void { + if (action === 'stay') { + setScreen(resolveApproval ? 'context-approval' : 'running'); + return; + } + resolveApproval?.(false); + exit(); +} + +function advanceSelection(screen: Screen, setSelectionIndexes: React.Dispatch>>): void { + const limits: Partial> = { + home: 3, + preflight: 3, + 'context-approval': 2, + results: 2, + 'confirm-exit': 2 + }; + const limit = limits[screen]; + if (!limit) { + return; + } + setSelectionIndexes((current) => ({ + ...current, + [screen]: ((current[screen] ?? 0) + 1) % limit + })); +} diff --git a/src/tui/components.tsx b/src/tui/components.tsx new file mode 100644 index 0000000..fa70b94 --- /dev/null +++ b/src/tui/components.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import {Box, Text} from 'ink'; +import type {AuditEvent, AuditRun, ContextPreview, NormalizedFinding, ToolRunResult} from '../core/types.js'; +import type {PreflightData} from './preflight.js'; + +export function Section({title, children}: {title: string; children: React.ReactNode}): React.ReactElement { + return ( + + + {title} + + {children} + + ); +} + +export function StatusRow({label, value, color = 'white'}: {label: string; value: string | number; color?: string}): React.ReactElement { + return ( + + {label}: + {String(value)} + + ); +} + +export function PreflightSummary({data}: {data: PreflightData}): React.ReactElement { + return ( + <> +
+ + + + +
+
+ {data.tooling.map((row) => ( + + ))} + {data.runtimes.map((row) => ( + + ))} +
+ {data.warnings.length > 0 && ( +
+ {data.warnings.map((warning) => ( + + {warning} + + ))} +
+ )} + + ); +} + +export function EventLog({events}: {events: AuditEvent[]}): React.ReactElement { + const visible = events.slice(-12); + return ( +
+ {visible.length === 0 ? ( + Waiting to start... + ) : ( + visible.map((event, index) => {renderEvent(event)}) + )} +
+ ); +} + +export function ContextApprovalSummary({preview}: {preview: ContextPreview}): React.ReactElement { + return ( +
+ + + + + +
+ ); +} + +export function ResultsSummary({run}: {run: AuditRun}): React.ReactElement { + const scannerFindings = run.findings.filter((finding) => finding.source !== 'business-logic'); + const businessFindings = run.findings.filter((finding) => finding.source === 'business-logic'); + const skippedTools = run.toolResults.filter((result) => result.skipped); + + return ( + <> +
+ + + 0 ? 'yellow' : 'green'} /> + 0 ? 'yellow' : 'green'} /> + 0 ? 'invoked' : 'not invoked'} /> + + +
+ + + {skippedTools.length > 0 && ( +
+ {skippedTools.map((result) => ( + + {result.tool}: {result.message} + + ))} +
+ )} + + ); +} + +export function FindingSummary({title, findings}: {title: string; findings: NormalizedFinding[]}): React.ReactElement { + return ( +
+ {findings.length === 0 ? ( + No findings in this category. + ) : ( + findings.slice(0, 5).map((finding) => ( + + {finding.severity} {finding.title} + + )) + )} +
+ ); +} + +export function ToolOutcomeSummary({results}: {results: ToolRunResult[]}): React.ReactElement { + return ( +
+ {results.length === 0 ? ( + No tool results. + ) : ( + results.map((result) => ( + + {result.tool}: {result.skipped ? 'skipped' : 'ran'} / {result.findings.length} findings + + )) + )} +
+ ); +} + +function renderEvent(event: AuditEvent): string { + if (event.type === 'tool:complete') { + return `${event.result.tool}: ${event.result.skipped ? 'skipped' : 'completed'} (${event.result.findings.length} findings)`; + } + if (event.type === 'context:preview') { + return `Context preview ready for ${event.preview.runtime}: ${event.preview.sizeBytes} bytes`; + } + if (event.type === 'llm:skipped') { + return `LLM skipped: ${event.reason}`; + } + if (event.type === 'run:complete') { + return `Run complete: ${event.run.findings.length} findings`; + } + if (event.type === 'error') { + return `Error: ${event.message}`; + } + return event.message; +} + +function severityColor(severity: NormalizedFinding['severity']): string { + if (severity === 'critical' || severity === 'high') return 'red'; + if (severity === 'medium') return 'yellow'; + if (severity === 'low') return 'blue'; + return 'gray'; +} diff --git a/src/tui/preflight.ts b/src/tui/preflight.ts new file mode 100644 index 0000000..ce1c3d2 --- /dev/null +++ b/src/tui/preflight.ts @@ -0,0 +1,47 @@ +import path from 'node:path'; +import type {SecFlowConfig} from '../core/types.js'; +import {loadConfig} from '../core/config.js'; +import {listRuntimeSummaries} from '../llm/runtimeRegistry.js'; +import {inspectTooling} from '../tools/registry.js'; + +export type ToolingStatus = Awaited>[number]; +export type RuntimeSummary = ReturnType[number]; + +export interface PreflightData { + targetPath: string; + config: SecFlowConfig; + configPath?: string; + tooling: ToolingStatus[]; + runtimes: RuntimeSummary[]; + defaultRuntime?: string; + warnings: string[]; +} + +export async function loadPreflightData(cwd: string, targetPath: string): Promise { + const absoluteTarget = path.resolve(cwd, targetPath); + const {config, path: configPath} = await loadConfig(cwd); + const tooling = await inspectTooling(config); + const runtimes = listRuntimeSummaries(config); + const warnings: string[] = []; + + const missingEnabledTools = tooling.filter((row) => row.enabled && !row.available && ['semgrep', 'trivy', 'joern'].includes(row.name)); + if (missingEnabledTools.length > 0) { + warnings.push(`Missing enabled scanners: ${missingEnabledTools.map((row) => row.name).join(', ')}`); + } + if (!config.defaultRuntime) { + warnings.push('No default LLM runtime is configured; audit will run local-only.'); + } + if (config.context.requireApproval) { + warnings.push('LLM context approval is required before runtime invocation.'); + } + + return { + targetPath: absoluteTarget, + config, + configPath, + tooling, + runtimes, + defaultRuntime: config.defaultRuntime, + warnings + }; +} diff --git a/tests/auditEngine.test.ts b/tests/auditEngine.test.ts index 0512f8b..8f60e29 100644 --- a/tests/auditEngine.test.ts +++ b/tests/auditEngine.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import {describe, expect, it} from 'vitest'; import {defaultConfig} from '../src/core/defaults.js'; import {runAudit} from '../src/core/auditEngine.js'; +import type {AuditEvent} from '../src/core/types.js'; describe('audit engine', () => { it('produces a local report without scanners or LLM runtimes', async () => { @@ -32,4 +33,69 @@ describe('audit engine', () => { expect(await readFile(run.reportPath, 'utf8')).toContain('Business Logic Analysis'); expect(await readFile(run.sarifPath, 'utf8')).toContain('SecFlow'); }); + + it('emits audit events in order for a local-only run', async () => { + const targetPath = await createTarget(); + const events: AuditEvent[] = []; + const run = await runAudit({targetPath, config: missingToolConfig(), contextApproved: false, onEvent: (event) => events.push(event)}); + + expect(run.findings.length).toBeGreaterThan(0); + expect(events.map((event) => event.type)).toContain('run:complete'); + expect(events.find((event) => event.type === 'llm:skipped' && event.reason === 'No default runtime configured.')).toBeTruthy(); + expect(stepStarts(events)).toEqual(['initialize', 'profile', 'business-workflows', 'tools', 'reports']); + }); + + it('emits context preview and skips LLM when approval is denied', async () => { + const targetPath = await createTarget(); + const events: AuditEvent[] = []; + const config = { + ...missingToolConfig(), + defaultRuntime: 'openai', + providers: { + ...defaultConfig.providers, + openai: {...defaultConfig.providers.openai!, enabled: true} + } + }; + + const run = await runAudit({ + targetPath, + config, + approveContext: () => false, + onEvent: (event) => events.push(event) + }); + + expect(run.llmResponses).toHaveLength(0); + expect(events.some((event) => event.type === 'context:preview')).toBe(true); + expect(events.find((event) => event.type === 'llm:skipped' && event.reason.includes('approval'))).toBeTruthy(); + }); }); + +async function createTarget(): Promise { + const targetPath = await mkdtemp(path.join(os.tmpdir(), 'secflow-audit-')); + await writeFile( + path.join(targetPath, 'routes.ts'), + ` + export function approveInvoice(req) { + sendEmail(req.body.email); + return { ok: true }; + } + `, + 'utf8' + ); + return targetPath; +} + +function missingToolConfig() { + return { + ...defaultConfig, + tools: { + semgrep: {...defaultConfig.tools.semgrep!, command: 'definitely-missing-semgrep'}, + trivy: {...defaultConfig.tools.trivy!, command: 'definitely-missing-trivy'}, + joern: {...defaultConfig.tools.joern!, command: 'definitely-missing-joern'} + } + }; +} + +function stepStarts(events: AuditEvent[]): string[] { + return events.filter((event) => event.type === 'step:start').map((event) => event.step); +} diff --git a/tests/tui.test.tsx b/tests/tui.test.tsx new file mode 100644 index 0000000..8ab3307 --- /dev/null +++ b/tests/tui.test.tsx @@ -0,0 +1,209 @@ +import React from 'react'; +import {describe, expect, it} from 'vitest'; +import {render} from 'ink-testing-library'; +import {App, ContextApprovalScreen, HomeScreen, PreflightScreen, ResultsScreen, RunningScreen, TargetScreen} from '../src/tui/App.js'; +import type {AuditEvent, AuditRun, ContextPreview} from '../src/core/types.js'; +import type {PreflightData} from '../src/tui/preflight.js'; +import {defaultConfig} from '../src/core/defaults.js'; + +const cwd = process.cwd(); + +describe('TUI screens', () => { + it('renders the home screen', () => { + const instance = render( undefined} />); + expect(instance.lastFrame()).toContain('Start audit wizard'); + }); + + it('renders the target screen', () => { + const instance = render( undefined} onSubmit={() => undefined} />); + expect(instance.lastFrame()).toContain('Target Repository'); + }); + + it('renders the preflight screen', () => { + const instance = render( undefined} />); + expect(instance.lastFrame()).toContain('Preflight'); + expect(instance.lastFrame()).toContain('Run audit'); + }); + + it('renders running progress', () => { + const events: AuditEvent[] = [{type: 'step:start', step: 'profile', message: 'Profiling repository.', timestamp: new Date().toISOString()}]; + const instance = render(); + expect(instance.lastFrame()).toContain('Profiling repository'); + }); + + it('renders context approval', () => { + const instance = render( undefined} />); + expect(instance.lastFrame()).toContain('LLM Context Approval'); + expect(instance.lastFrame()).toContain('Approve LLM runtime call'); + }); + + it('renders results', () => { + const instance = render( undefined} />); + expect(instance.lastFrame()).toContain('Results'); + expect(instance.lastFrame()).toContain('report.md'); + }); +}); + +describe('TUI interactions', () => { + it('starts an audit and reaches results', async () => { + const instance = render( + fakePreflight()} + runAudit={async (options) => { + options.onEvent?.({type: 'step:start', step: 'profile', message: 'Profiling repository.', timestamp: new Date().toISOString()}); + return fakeRun(); + }} + /> + ); + + instance.stdin.write('\r'); + await waitForFrame(instance, 'Target Repository'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'Run audit'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'Results'); + }); + + it('skips LLM context approval and completes', async () => { + const instance = render( + fakePreflight({defaultRuntime: 'openai'})} + runAudit={async (options) => { + const approved = await options.approveContext?.(fakePreview()); + options.onEvent?.({ + type: 'llm:skipped', + step: 'llm', + timestamp: new Date().toISOString(), + reason: approved ? 'approved' : 'skipped by user' + }); + return fakeRun(); + }} + /> + ); + + instance.stdin.write('\r'); + await waitForFrame(instance, 'Target Repository'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'Run audit'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'LLM Context Approval'); + instance.stdin.write('\t'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'Results'); + }); + + it('confirms quit during context approval', async () => { + const instance = render( + fakePreflight({defaultRuntime: 'openai'})} + runAudit={async (options) => { + await options.approveContext?.(fakePreview()); + return fakeRun(); + }} + /> + ); + + instance.stdin.write('\r'); + await waitForFrame(instance, 'Target Repository'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'Run audit'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'LLM Context Approval'); + instance.stdin.write('q'); + await waitForFrame(instance, 'Exit Active Run?'); + instance.stdin.write('\r'); + await waitForFrame(instance, 'LLM Context Approval'); + }); +}); + +function fakePreflight(overrides: Partial = {}): PreflightData { + return { + targetPath: cwd, + config: defaultConfig, + configPath: undefined, + tooling: [ + {name: 'semgrep', command: 'semgrep', enabled: true, available: false}, + {name: 'trivy', command: 'trivy', enabled: true, available: false} + ], + runtimes: [{name: 'openai', kind: 'openai', enabled: false, model: 'gpt-test', auth: 'OPENAI_API_KEY'}], + defaultRuntime: undefined, + warnings: ['No default LLM runtime is configured; audit will run local-only.'], + ...overrides + }; +} + +function fakePreview(): ContextPreview { + return { + runtime: 'openai', + promptId: 'report-synthesis', + sizeBytes: 512, + maxBytes: 1024, + requireApproval: true, + redactionPatternCount: 2, + contextPath: `${cwd}/.secflow/runs/test/llm-context-preview.json` + }; +} + +function fakeRun(): AuditRun { + return { + runId: 'test-run', + targetPath: cwd, + runDir: `${cwd}/.secflow/runs/test-run`, + profile: { + targetPath: cwd, + generatedAt: new Date().toISOString(), + fileCount: 2, + totalBytes: 100, + extensions: {'.ts': 2}, + manifests: ['package.json'], + securityRelevantFiles: ['src/routes.ts'], + likelyFrameworks: ['Node.js'], + notableDirectories: ['src'], + sampledFiles: [] + }, + business: { + generatedAt: new Date().toISOString(), + actors: ['user'], + roles: ['admin'], + assets: ['account'], + trustBoundaries: [], + entryPoints: ['route'], + stateTransitions: ['approve'], + permissionChecks: [], + moneyOrDataMovement: [], + approvalFlows: [], + externalSideEffects: [], + reviewQuestions: ['Who can approve invoices?'], + risks: [] + }, + toolResults: [{tool: 'semgrep', command: 'semgrep', available: false, skipped: true, durationMs: 0, message: 'missing', findings: []}], + findings: [ + { + id: 'business-logic:test', + source: 'business-logic', + title: 'Approval bypass', + severity: 'high', + confidence: 0.7, + description: 'Approval may be bypassed.', + evidence: ['src/routes.ts'], + recommendation: 'Add authorization tests.' + } + ], + llmResponses: [], + reportPath: `${cwd}/.secflow/runs/test-run/report.md`, + sarifPath: `${cwd}/.secflow/runs/test-run/report.sarif` + }; +} + +async function waitForFrame(instance: ReturnType, text: string): Promise { + for (let index = 0; index < 50; index += 1) { + if (instance.lastFrame()?.includes(text)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`Timed out waiting for frame containing "${text}". Last frame:\n${instance.lastFrame()}`); +} diff --git a/vitest.config.ts b/vitest.config.ts index bbe3c5a..e2e3fe2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], restoreMocks: true } }); From 4589cc53d78a1bb26ca880937a3742f2cf32749a Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Fri, 1 May 2026 15:48:42 -0500 Subject: [PATCH 2/2] updated TUI styling and functionality --- README.md | 4 + src/index.tsx | 7 +- src/tui/App.tsx | 160 +++++++++++++++++++++++++++----------- src/tui/components.tsx | 167 +++++++++++++++++++++++++++++++--------- src/tui/layout.tsx | 70 +++++++++++++++++ src/tui/render.tsx | 169 +++++++++++++++++++++++++++++++++++++++++ src/tui/theme.ts | 42 ++++++++++ tests/tui.test.tsx | 32 +++++--- vitest.config.ts | 1 + 9 files changed, 560 insertions(+), 92 deletions(-) create mode 100644 src/tui/layout.tsx create mode 100644 src/tui/render.tsx create mode 100644 src/tui/theme.ts diff --git a/README.md b/README.md index c61f291..df3a64f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ By default, LLM runtimes are disabled and scanner tools are detected from your l Running `npm run dev` launches the interactive TUI audit wizard. It walks through target selection, preflight checks, live audit progress, optional LLM context approval, and results/report review. +## WSL Terminal Note + +When running the TUI in WSL through Windows Terminal, there is a known unresolved issue where terminal input mode can remain in an odd state after exit. SecFlow attempts to reset the known modes on shutdown, but this does not fully resolve the issue in all WSL setups. If arrow keys start printing raw sequences or input behaves strangely, open a new terminal tab. + ## Commands ```bash diff --git a/src/index.tsx b/src/index.tsx index f77a84b..4661b91 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,13 +1,12 @@ #!/usr/bin/env node import React from 'react'; import {Command} from 'commander'; -import {render} from 'ink'; import {auditCommand} from './cli/audit.js'; import {doctorCommand} from './cli/doctor.js'; import {initProject} from './cli/init.js'; import {modelsCommand} from './cli/models.js'; import {validatePlaybookCommand} from './cli/playbooks.js'; -import {App} from './tui/App.js'; +import {renderFullscreenTui} from './tui/render.js'; const program = new Command(); @@ -15,8 +14,8 @@ program .name('secflow') .description('LLM harness for application security engineers and application defenders') .version('0.1.0') - .action(() => { - render(); + .action(async () => { + await renderFullscreenTui(process.cwd()); }); program diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 96cdc03..b4b2f27 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -6,8 +6,10 @@ import TextInput from 'ink-text-input'; import Spinner from 'ink-spinner'; import type {AuditEvent, AuditRun, ContextPreview} from '../core/types.js'; import {runAudit as defaultRunAudit, type AuditOptions} from '../core/auditEngine.js'; -import {ContextApprovalSummary, EventLog, PreflightSummary, ResultsSummary, Section} from './components.js'; +import {ContextApprovalSummary, EventLog, PreflightSummary, ProgressRail, ResultsSummary, Section} from './components.js'; +import {fitText, TerminalSizeProvider, useTerminalSize} from './layout.js'; import {loadPreflightData, type PreflightData} from './preflight.js'; +import {theme} from './theme.js'; type Screen = 'home' | 'commands' | 'target' | 'preflight-loading' | 'preflight' | 'running' | 'context-approval' | 'results' | 'error' | 'confirm-exit'; type HomeAction = 'start' | 'commands' | 'quit'; @@ -17,12 +19,30 @@ type ConfirmExitAction = 'stay' | 'exit'; export interface AppProps { cwd: string; + screenMode?: 'alternate' | 'viewport'; loadPreflight?: (cwd: string, targetPath: string) => Promise; runAudit?: (options: AuditOptions) => Promise; } -export function App({cwd, loadPreflight = loadPreflightData, runAudit = defaultRunAudit}: AppProps): React.ReactElement { +export function App({cwd, screenMode = 'viewport', loadPreflight = loadPreflightData, runAudit = defaultRunAudit}: AppProps): React.ReactElement { + return ( + + + + ); +} + +interface AppBodyProps { + cwd: string; + screenMode: 'alternate' | 'viewport'; + loadPreflight: (cwd: string, targetPath: string) => Promise; + runAudit: (options: AuditOptions) => Promise; +} + +function AppBody({cwd, screenMode, loadPreflight, runAudit}: AppBodyProps): React.ReactElement { const {exit} = useApp(); + const terminal = useTerminalSize(); + const fullHeight = screenMode === 'alternate'; const [screen, setScreen] = useState('home'); const [targetPath, setTargetPath] = useState(cwd); const [preflight, setPreflight] = useState(); @@ -37,6 +57,9 @@ export function App({cwd, loadPreflight = loadPreflightData, runAudit = defaultR if (key.tab || input === '\t') { advanceSelection(screen, setSelectionIndexes); } + if ((key.return || input === '\r' || input === '\n') && screen === 'target') { + void preparePreflight(targetPath); + } if (input === 'q') { if (activeRun.current) { setScreen('confirm-exit'); @@ -109,51 +132,83 @@ export function App({cwd, loadPreflight = loadPreflightData, runAudit = defaultR } return ( - -
- {screen === 'home' && handleHomeAction(action, setScreen, exit)} />} - {screen === 'commands' && } - {screen === 'target' && } - {screen === 'preflight-loading' && } - {screen === 'preflight' && preflight && handlePreflightAction(action, startAudit, setScreen, exit)} />} - {screen === 'running' && } - {screen === 'context-approval' && approvalRequest && } - {screen === 'results' && run && (action === 'home' ? setScreen('home') : exit())} />} - {screen === 'error' && } - {screen === 'confirm-exit' && handleExitConfirmation(action, approvalRequest?.resolve, setScreen, exit)} />} - Enter selects. Tab or arrows move. Esc backs out. q quits. + + +
+ {terminal.rows < 18 ? Terminal is short; expand it for the full audit view. : null} + {screen === 'home' && handleHomeAction(action, setScreen, exit)} />} + {screen === 'commands' && } + {screen === 'target' && } + {screen === 'preflight-loading' && } + {screen === 'preflight' && preflight && handlePreflightAction(action, startAudit, setScreen, exit)} />} + {screen === 'running' && } + {screen === 'context-approval' && approvalRequest && } + {screen === 'results' && run && (action === 'home' ? setScreen('home') : exit())} />} + {screen === 'error' && } + {screen === 'confirm-exit' && handleExitConfirmation(action, approvalRequest?.resolve, setScreen, exit)} />} + +