diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index c35f76f2..95059cef 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -73,6 +73,23 @@ export interface TaskApiProps { */ readonly nudgeRateLimitPerMinute?: number; + /** + * Per-user per-minute `GET /v1/pending` rate limit. + * + * The default of 10 was sized for the original CLI-only `bgagent pending` + * workflow where users polled by hand. The TUI's adaptive ladder polls + * `/v1/pending` on a 2-30s cadence and a fast first poll naturally lands + * 20-30 calls in the first minute — comfortably above 10. The handler's + * default (`get-pending.ts::PENDING_RATE_LIMIT_PER_MINUTE`) is also 10, + * so omitting this prop preserves the historical behaviour. + * + * Sized for: a single TUI session at 2s sustained cadence (30/min) plus + * room for concurrent CLI `bgagent pending` checks during testing. + * + * @default 60 + */ + readonly pendingRateLimitPerMinute?: number; + /** * The DynamoDB repo config table. When provided, task creation checks * that the target repository is onboarded before accepting the task. @@ -652,12 +669,21 @@ export class TaskApi extends Construct { props.taskEventsTable.grantReadWriteData(denyTaskFn); // GetPendingFn — GET /pending + // Adds `PENDING_RATE_LIMIT_PER_MINUTE` on top of the shared + // approval env so the per-user rate limit is configurable from + // the construct prop (default 60/min — sized to support a + // single TUI session at 2s sustained cadence with headroom for + // concurrent CLI `bgagent pending` calls). Approve/deny don't + // need this var so it stays out of `approvalEnv`. const getPendingFn = new lambda.NodejsFunction(this, 'GetPendingFn', { entry: path.join(handlersDir, 'get-pending.ts'), handler: 'handler', runtime: Runtime.NODEJS_24_X, architecture: Architecture.ARM_64, - environment: approvalEnv, + environment: { + ...approvalEnv, + PENDING_RATE_LIMIT_PER_MINUTE: String(props.pendingRateLimitPerMinute ?? 60), + }, bundling: commonBundling, timeout: Duration.seconds(10), memorySize: 256, diff --git a/cli/.eslintrc.json b/cli/.eslintrc.json index 6d4080b3..7d9266d1 100644 --- a/cli/.eslintrc.json +++ b/cli/.eslintrc.json @@ -150,6 +150,7 @@ "import/no-extraneous-dependencies": [ "error", { + "packageDir": "./", "devDependencies": [ "**/test/**", "**/build-tools/**" @@ -374,6 +375,14 @@ "rules": { "no-console": "off" } + }, + { + "files": [ + "src/bin/**/*.ts" + ], + "rules": { + "license-header/header": "off" + } } ] } diff --git a/cli/docs/recordings/demo-approvals.tape b/cli/docs/recordings/demo-approvals.tape new file mode 100644 index 00000000..54d2d553 --- /dev/null +++ b/cli/docs/recordings/demo-approvals.tape @@ -0,0 +1,47 @@ +# Approval flow — list → detail → approve/deny +# Run: vhs demo-approvals.tape + +Output demo-approvals.gif +Set FontSize 14 +Set Width 1200 +Set Height 700 +Set Padding 20 + +Type "npm run tui" +Enter +Sleep 3s +Type " " +Sleep 1s + +# Go to Approvals +Type "3" +Sleep 1.5s + +# Browse approvals +Down +Sleep 0.5s +Up +Sleep 1s + +# Drill into detail +Enter +Sleep 3s + +# Deny with confirmation +Type "d" +Sleep 1s +# See the confirm dialog +Sleep 1.5s +# Cancel +Type "n" +Sleep 1s + +# Approve instead +Type "a" +Sleep 1.5s + +# Back to list +Escape +Sleep 1s + +Type "q" diff --git a/cli/docs/recordings/demo-full.gif b/cli/docs/recordings/demo-full.gif new file mode 100644 index 00000000..4efaf9c7 Binary files /dev/null and b/cli/docs/recordings/demo-full.gif differ diff --git a/cli/docs/recordings/demo-full.tape b/cli/docs/recordings/demo-full.tape new file mode 100644 index 00000000..be166f05 --- /dev/null +++ b/cli/docs/recordings/demo-full.tape @@ -0,0 +1,115 @@ +# Full TUI Demo — splash → all panels +# Run: vhs demo-full.tape + +Output demo-full.gif +Set FontSize 14 +Set Width 1200 +Set Height 700 +Set Padding 20 + +# Start the TUI +Type "npm run tui" +Enter +Sleep 3s + +# Splash screen visible — press key to dismiss +Type " " +Sleep 1.5s + +# Tasks panel — navigate list +Sleep 1s +Down +Sleep 0.5s +Down +Sleep 0.5s +Up +Sleep 1s + +# Select first task → Watch +Enter +Sleep 3s + +# Watch events streaming in... +Sleep 3s + +# Scroll up through events +Up +Up +Up +Sleep 1s +# Scroll back down +Down +Down +Down +Sleep 1s + +# Nudge the agent +Type "n" +Sleep 0.5s +Type "focus on the unit tests first" +Sleep 0.5s +Enter +Sleep 1.5s + +# Switch to Approvals +Type "3" +Sleep 1.5s + +# Drill into detail +Enter +Sleep 2s + +# Approve +Type "a" +Sleep 1s + +# Back to list +Escape +Sleep 1s + +# Switch to Policies +Type "4" +Sleep 1s + +# View a policy detail +Down +Down +Enter +Sleep 2s + +# Navigate while detail stays open +Down +Sleep 1s +Down +Sleep 1s + +# Close detail +Escape +Sleep 1s + +# Switch to Submit (New Task) +Type "5" +Sleep 1s + +# Select repo +Down +Sleep 0.5s +Enter +Sleep 0.5s + +# Type instructions +Enter +Type "Add integration tests for the payment service" +Enter +Sleep 1s + +# Submit +Down +Down +Down +Enter +Sleep 2s + +# Quit +Type "q" +Sleep 0.5s diff --git a/cli/docs/recordings/demo-splash.tape b/cli/docs/recordings/demo-splash.tape new file mode 100644 index 00000000..85d7f547 --- /dev/null +++ b/cli/docs/recordings/demo-splash.tape @@ -0,0 +1,20 @@ +# Splash screen — just the Peccy animation +# Run: vhs demo-splash.tape + +Output demo-splash.gif +Set FontSize 16 +Set Width 800 +Set Height 500 +Set Padding 20 + +Type "npm run tui" +Enter + +# Let the splash play for the full Peccy animation cycle +Sleep 5s + +# Dismiss +Type " " +Sleep 2s + +Type "q" diff --git a/cli/docs/recordings/demo-watch.gif b/cli/docs/recordings/demo-watch.gif new file mode 100644 index 00000000..20982a57 Binary files /dev/null and b/cli/docs/recordings/demo-watch.gif differ diff --git a/cli/docs/recordings/demo-watch.tape b/cli/docs/recordings/demo-watch.tape new file mode 100644 index 00000000..92ac0150 --- /dev/null +++ b/cli/docs/recordings/demo-watch.tape @@ -0,0 +1,52 @@ +# Watch panel — event streaming + inline approval + nudge +# Run: vhs demo-watch.tape + +Output demo-watch.gif +Set FontSize 14 +Set Width 1200 +Set Height 700 +Set Padding 20 + +Type "npm run tui" +Enter +Sleep 3s +Type " " +Sleep 1s + +# Select a task +Enter +Sleep 5s + +# Events streaming in... +Sleep 5s + +# Scroll up through history +Up +Up +Up +Up +Sleep 2s + +# Back to live +Down +Down +Down +Down +Sleep 2s + +# Approve inline +Type "a" +Sleep 1.5s + +# Nudge +Type "n" +Sleep 0.5s +Type "please add error handling" +Enter +Sleep 2s + +# Back to tasks +Escape +Sleep 1s + +Type "q" diff --git a/cli/jest.tui.config.cjs b/cli/jest.tui.config.cjs new file mode 100644 index 00000000..240b6cee --- /dev/null +++ b/cli/jest.tui.config.cjs @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * Separate Jest config for the TUI panel tests. These need the real + * Ink runtime (which is pure ESM), so we opt them into Jest's + * experimental VM-modules ESM path. Run with: + * + * NODE_OPTIONS=--experimental-vm-modules npx jest --config jest.tui.config.cjs + * + * The main Jest config keeps CommonJS + `moduleNameMapper` for the + * bulk of the test suite; only panel tests that actually mount Ink + * components go through this config. + */ +module.exports = { + rootDir: '.', + testMatch: ['/test/tui-panels/**/*.test.@(ts|tsx)'], + testPathIgnorePatterns: ['/node_modules/'], + // Strip the `.js` suffix TUI sources use (Node16 ESM style) so + // Jest's resolver finds the `.ts` / `.tsx` sources. + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + extensionsToTreatAsEsm: ['.ts', '.tsx'], + transform: { + '^.+\\.[jt]sx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.dev.json', + useESM: true, + }, + ], + }, + // Ink + ink-testing-library are ESM; Jest's default transformIgnorePatterns + // skips node_modules, so we whitelist the ones we need compiled. + transformIgnorePatterns: [ + 'node_modules/(?!(ink|ink-testing-library|chalk|cli-truncate|slice-ansi|string-width|strip-ansi|ansi-regex|ansi-styles|figures|is-fullwidth-code-point|emoji-regex|code-excerpt|indent-string|cli-cursor|cli-boxes|restore-cursor|widest-line|wrap-ansi|type-fest|auto-bind|yoga-layout|@alcalzone/ansi-tokenize|is-unicode-supported|is-in-ci|signal-exit|terminal-size|tagged-tag|xml-naming|uuid)/)', + ], + testEnvironment: 'node', + clearMocks: true, + // Coverage off for panel smokes — the coverage gate in the main config + // already covers the pure logic; panels are about interaction, not + // line-coverage gain. + collectCoverage: false, + // Ink keeps terminal-size polling + raw-mode listeners alive after + // unmount in ways that leak into Jest's worker. We `forceExit` so the + // suite doesn't hang for the 5 s default. Tests themselves assert all + // behaviour before that point; `forceExit` only affects post-pass + // teardown. + forceExit: true, +}; diff --git a/cli/package.json b/cli/package.json index b8b858fe..2cc3010f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -4,17 +4,22 @@ "bgagent": "lib/bin/bgagent.js" }, "scripts": { - "compile": "tsc --build tsconfig.json", + "compile": "tsc --build tsconfig.json && npm run tui:compile", "eslint": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools", - "test": "jest --passWithNoTests --updateSnapshot", + "test": "jest --passWithNoTests --updateSnapshot && npm run test:tui", + "test:tui": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.tui.config.cjs --passWithNoTests", "test:watch": "jest --watch", "build": "npm run compile && npm run test && npm run eslint", + "tui:compile": "tsc --build src/tui/tsconfig.json && node -e \"require('fs').writeFileSync('lib/tui/package.json', JSON.stringify({type:'module'}))\"", + "tui": "npm run tui:compile && node --enable-source-maps lib/tui/index.js", "security:retire": "retire --path . --severity high" }, "devDependencies": { + "@jest/globals": "^30.3.0", "@stylistic/eslint-plugin": "^2", "@types/jest": "^30.0.0", "@types/node": "^25.5.0", + "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "eslint": "^9", @@ -23,6 +28,7 @@ "eslint-plugin-jest": "^29.15.1", "eslint-plugin-jsdoc": "^62.8.1", "eslint-plugin-license-header": "^0.9.0", + "ink-testing-library": "^4.0.0", "jest": "^30.3.0", "jest-junit": "^16", "retire": "^5.4.2", @@ -35,7 +41,13 @@ "@aws-sdk/client-dynamodb": "3.1024.0", "@aws-sdk/client-secrets-manager": "3.1024.0", "@aws-sdk/lib-dynamodb": "3.1024.0", - "commander": "^14.0.3" + "commander": "^14.0.3", + "figures": "^6.1.0", + "ink": "^7.0.1", + "ink-spinner": "^5.0.0", + "react": "^19.2.5", + "sharp": "^0.34.5", + "strip-ansi": "^7.2.0" }, "resolutions": { "eslint-plugin-import/minimatch": "^3.1.2" @@ -63,7 +75,8 @@ "/node_modules/" ], "testPathIgnorePatterns": [ - "/node_modules/" + "/node_modules/", + "/test/tui-panels/" ], "watchPathIgnorePatterns": [ "/node_modules/" @@ -77,6 +90,9 @@ } ] ], + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, "transform": { "^.+\\.[t]sx?$": [ "ts-jest", diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index a3f58cb4..fecc11bc 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -117,10 +117,27 @@ export class ApiClient { // Non-JSON or envelope-less error body — still an HTTP error, still // must carry the status so classification works. Code/request_id // are unavailable at this layer; surface ``HTTP_ERROR`` / empty. + // + // When the server returns a body that is not our envelope + // (API Gateway Cognito-authorizer denial `{"message": "..."}`, + // a WAF page, a bespoke admission-check failure), drop it + // verbatim into the message — diagnosing these otherwise + // requires cross-checking CloudWatch by request_id, which + // most users can't do. Cap at 200 chars so a full HTML page + // doesn't blow up the terminal. + let detail = ''; + if (jsonParseOk) { + // Prefer common body shapes (API Gateway uses `message`). + const errorBody = json as { message?: unknown; Message?: unknown }; + const raw = typeof errorBody.message === 'string' ? errorBody.message + : typeof errorBody.Message === 'string' ? errorBody.Message + : JSON.stringify(json); + if (raw) detail = ` — ${raw.slice(0, 200)}`; + } throw new ApiError( res.status, 'HTTP_ERROR', - `HTTP ${res.status}: ${res.statusText}${jsonParseOk ? '' : ' (non-JSON response)'}`, + `HTTP ${res.status}: ${res.statusText}${jsonParseOk ? '' : ' (non-JSON response)'}${detail}`, '', ); } diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index a8e61c0a..99428892 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node - /** * MIT No Attribution * @@ -18,7 +17,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - import { Command } from 'commander'; import { makeApproveCommand } from '../commands/approve'; import { makeCancelCommand } from '../commands/cancel'; @@ -35,6 +33,7 @@ import { makeSlackCommand } from '../commands/slack'; import { makeStatusCommand } from '../commands/status'; import { makeSubmitCommand } from '../commands/submit'; import { makeTraceCommand } from '../commands/trace'; +import { makeTuiCommand } from '../commands/tui'; import { makeWatchCommand } from '../commands/watch'; import { makeWebhookCommand } from '../commands/webhook'; import { setVerbose } from '../debug'; @@ -71,6 +70,7 @@ program.addCommand(makeSlackCommand()); program.addCommand(makeLinearCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); +program.addCommand(makeTuiCommand()); program.addCommand(makeWebhookCommand()); // Execute the CLI only when run directly. Importing this module (e.g. diff --git a/cli/src/commands/tui.ts b/cli/src/commands/tui.ts new file mode 100644 index 00000000..1377cc31 --- /dev/null +++ b/cli/src/commands/tui.ts @@ -0,0 +1,97 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Command } from 'commander'; +import { CliError } from '../errors'; + +/** Test seam: swap the TUI runner so `commands/tui.ts` can be + * exercised without booting the full Ink runtime. Production + * callers never touch this — the default launches the real TUI. */ +type RunTuiFn = () => Promise; +let runTuiImpl: RunTuiFn | null = null; +export function _setRunTuiForTests(fn: RunTuiFn | null): void { + runTuiImpl = fn; +} + +/** + * `bgagent tui [--mock]` — launch the interactive terminal UI. + * + * The TUI shares auth + config with all other `bgagent` commands + * (Cognito + `~/.bgagent/config.json`), so a user who has already + * run `bgagent login` can jump straight in. Use `--mock` (or + * export `BGAGENT_TUI_MOCK=1`) to run against in-memory fixtures + * — useful for demos without a deployed backend. + * + * The TUI code lives in `cli/src/tui/` and is loaded lazily so the + * heavier Ink + React dependency graph isn't paid on every + * `bgagent` invocation. + */ +export function makeTuiCommand(): Command { + return new Command('tui') + .description('Launch the interactive terminal UI') + .option('--mock', 'Use in-memory mock data instead of the live API (for demos)', false) + .addHelpText( + 'after', + '\nExamples:\n' + + ' $ bgagent tui # live mode — requires `bgagent configure` + `bgagent login`\n' + + ' $ bgagent tui --mock # mock mode — no backend required\n' + + ' $ BGAGENT_TUI_MOCK=1 bgagent tui\n', + ) + .action(async (opts) => { + // Flip the env flag for `DataProvider.pickSourceFromEnv()`. + // Setting it here (rather than constructing a `MockDataSource` + // directly) keeps the TUI launcher source-agnostic — the + // selection stays in one place. + if (opts.mock) { + process.env.BGAGENT_TUI_MOCK = '1'; + } + + try { + if (runTuiImpl) { + await runTuiImpl(); + return; + } + // Lazy-load so non-TUI commands don't pay the Ink/React cost. + // The TUI builds via a separate tsconfig (React JSX + Node16 + // ESM output), so we dynamic-`import()` rather than + // `require()`. The emitted `lib/tui/` carries a + // `package.json` with `"type": "module"` so Node recognizes + // the bundle as ESM. We resolve an absolute file:// URL + // because bare relative paths don't resolve reliably across + // CJS → ESM interop. + // + // The indirection `Function('p', 'return import(p)')` keeps + // TypeScript's CommonJS transpile from rewriting `import()` + // into `require()` — otherwise the ESM graph rejection + // recurs. + const tuiAbsPath = path.resolve(__dirname, '..', 'tui', 'index.js'); + const tuiUrl = pathToFileURL(tuiAbsPath).href; + const dynImport = Function('p', 'return import(p)') as (p: string) => Promise<{ runTui: () => Promise }>; + const tui = await dynImport(tuiUrl); + await tui.runTui(); + } catch (err) { + if (err instanceof Error) { + throw new CliError(`Failed to launch TUI: ${err.message}`); + } + throw err; + } + }); +} diff --git a/cli/src/commands/watch.ts b/cli/src/commands/watch.ts index fccd30a4..77f8fcf1 100644 --- a/cli/src/commands/watch.ts +++ b/cli/src/commands/watch.ts @@ -22,6 +22,7 @@ import { ApiClient } from '../api-client'; import { debug, isVerbose } from '../debug'; import { ApiError } from '../errors'; import { formatJson } from '../format'; +import { formatMilestone } from '../format-milestones'; import { TERMINAL_STATUSES, TaskDetail, TaskEvent } from '../types'; /** @@ -200,6 +201,14 @@ export function renderEvent(event: TaskEvent): string { return `[${time}] ◀ ${tool}${isError}: ${preview}`; } case 'agent_milestone': { + // Cedar HITL §11.1 milestones — share the formatter with the + // TUI so CLI watch and TUI Watch panel never drift on + // user-visible payloads (`approval_timeout_capped`, etc.). The + // formatter returns null for unknown sub-names; in that case we + // fall back to the legacy `:
` rendering so a + // future agent-side milestone never disappears from the stream. + const formatted = formatMilestone(meta); + if (formatted !== null) return `[${time}] ★ ${formatted}`; const milestone = meta.milestone ?? ''; const details = meta.details ?? ''; return `[${time}] ★ ${milestone}${details ? ': ' + details : ''}`; diff --git a/cli/src/format-milestones.ts b/cli/src/format-milestones.ts new file mode 100644 index 00000000..b67ff813 --- /dev/null +++ b/cli/src/format-milestones.ts @@ -0,0 +1,138 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Shared formatter for Cedar HITL `agent_milestone` sub-events + * (§11.1 of `docs/design/CEDAR_HITL_GATES.md`). Used by: + * + * - `tui/components/EventLine.tsx` — Watch panel event stream + * - `commands/watch.ts` — `bgagent watch` plain CLI render + * + * The agent runtime emits every approval-related event as event_type + * `agent_milestone` with the sub-name in `metadata.milestone` (see + * `agent/src/progress_writer.py::_put_approval_milestone`). Both + * surfaces MUST produce identical text so an operator using the CLI + * sees the same severity signal — clip vs cap vs ceiling — that the + * TUI shows; otherwise IMPL-26's surface-promotion goal is half-met. + * + * Returning `null` means "let the caller fall back to its default + * `:
` rendering" — covers any future milestone the + * formatter hasn't been taught about yet without breaking output. + */ + +/** Truncate a long string for inline preview, suffixed with `…`. */ +function trunc(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} + +function mstr(m: Record, key: string, fallback = ''): string { + const v = m[key]; + return typeof v === 'string' ? v : fallback; +} + +function mnum(m: Record, key: string): number | null { + const v = m[key]; + return typeof v === 'number' ? v : null; +} + +function mstrlist(m: Record, key: string): readonly string[] { + const v = m[key]; + if (!Array.isArray(v)) return []; + return v.filter((x): x is string => typeof x === 'string'); +} + +/** Per-milestone preview width for tool input — wider terminals still + * wrap, but the formatter trims aggressively to keep one line per + * event in the CLI watch case. The TUI passes its own truncation + * width via the surrounding render, so this only matters for the + * `approval_requested` sub-event where the input preview is the most + * user-relevant payload. */ +const TOOL_INPUT_PREVIEW_WIDTH = 60; + +export function formatMilestone(metadata: Record): string | null { + const sub = mstr(metadata, 'milestone'); + switch (sub) { + case 'pre_approvals_loaded': { + const count = mnum(metadata, 'count') ?? 0; + const scopes = mstrlist(metadata, 'scopes'); + if (count === 0) return 'No pre-approvals loaded'; + const head = scopes.slice(0, 3).join(', '); + const tail = scopes.length > 3 ? `, +${scopes.length - 3} more` : ''; + return `Pre-approvals loaded: ${count} scope${count === 1 ? '' : 's'}${head ? ` — ${head}${tail}` : ''}`; + } + case 'approval_requested': { + const tool = mstr(metadata, 'tool_name'); + const preview = mstr(metadata, 'input_preview') || mstr(metadata, 'tool_input_preview'); + return `APPROVAL NEEDED: ${tool} — ${trunc(preview, TOOL_INPUT_PREVIEW_WIDTH)}`; + } + case 'approval_granted': + return `Approved (..${mstr(metadata, 'request_id').slice(-4)})${mstr(metadata, 'scope') ? ` scope=${mstr(metadata, 'scope')}` : ''}`; + case 'approval_denied': { + const reason = mstr(metadata, 'reason'); + return `Denied (..${mstr(metadata, 'request_id').slice(-4)})${reason ? ` — ${trunc(reason, 40)}` : ''}`; + } + case 'approval_timed_out': { + const eff = mnum(metadata, 'effective_timeout_s') ?? mnum(metadata, 'timeout_s'); + return `Timed out (..${mstr(metadata, 'request_id').slice(-4)})${eff != null ? ` after ${eff}s` : ''}`; + } + case 'approval_stranded': + return `Stranded (..${mstr(metadata, 'request_id').slice(-4)}) — reconciler: ${mstr(metadata, 'reason') || 'task evicted'}`; + case 'approval_timeout_capped': { + const req = mnum(metadata, 'requested_timeout_s'); + const eff = mnum(metadata, 'effective_timeout_s'); + const reason = mstr(metadata, 'reason'); + const rules = mstrlist(metadata, 'matching_rule_ids'); + const ruleSuffix = rules.length > 0 ? ` (${rules.join(', ')})` : ''; + return `Timeout capped: ${req ?? '?'}s → ${eff ?? '?'}s${reason ? ` (${reason}${ruleSuffix})` : ''}`; + } + case 'approval_ceiling_shrinking': { + const remaining = mnum(metadata, 'maxLifetime_remaining_s'); + const margin = mnum(metadata, 'cleanup_margin_s'); + const usable = remaining != null && margin != null ? remaining - margin : null; + return `Approval window shrinking — ~${usable ?? remaining ?? '?'}s of task lifetime left`; + } + case 'approval_cap_exceeded': { + const count = mnum(metadata, 'count') ?? 0; + const cap = mnum(metadata, 'cap') ?? 0; + return `Approval cap reached: ${count}/${cap} — task halted`; + } + case 'approval_rate_limit_exceeded': { + const rate = mnum(metadata, 'rate') ?? 0; + const limit = mnum(metadata, 'limit') ?? 0; + return `Approval rate limit: ${rate}/min > ${limit}/min`; + } + case 'approval_write_failed': + return `Approval write failed: ${trunc(mstr(metadata, 'error'), 60)}`; + case 'approval_resume_failed': + return `Approval resume failed (..${mstr(metadata, 'request_id').slice(-4)}): ${trunc(mstr(metadata, 'error'), 60)}`; + case 'approval_poll_degraded': { + const fails = mnum(metadata, 'consecutive_failures') ?? 0; + return `Approval polling degraded — ${fails} consecutive failures`; + } + case 'approval_late_win': + return `Late decision won: ${mstr(metadata, 'outcome')} (..${mstr(metadata, 'request_id').slice(-4)}) — ${mstr(metadata, 'reason')}`; + case 'policy_decision': { + const tool = mstr(metadata, 'tool_name'); + const decision = mstr(metadata, 'cached_decision'); + return `Policy cache hit: ${tool} → ${decision}`; + } + default: + return null; + } +} diff --git a/cli/src/mock/data.d.ts b/cli/src/mock/data.d.ts new file mode 100644 index 00000000..8d879f6f --- /dev/null +++ b/cli/src/mock/data.d.ts @@ -0,0 +1,86 @@ +/** + * Mock data for the TUI prototype. + * Simulates the DynamoDB data shapes from TaskTable, TaskEventsTable, + * and TaskApprovalsTable without any real API calls. + */ +export interface RegisteredRepo { + repo: string; + status: 'active' | 'removed'; + default_branch: string; +} +export declare const MOCK_REPOS: RegisteredRepo[]; +export interface TaskSummary { + task_id: string; + status: string; + repo: string; + created_at: string; + task_description: string; + task_type: string; + pr_number: number | null; + issue_number: number | null; + branch_name: string; + cost_usd: number | null; + duration_s: number | null; + turn: number; + max_turns: number | null; +} +export interface TaskEvent { + event_id: string; + task_id: string; + event_type: string; + timestamp: string; + metadata: Record; +} +export interface PendingApproval { + task_id: string; + request_id: string; + tool_name: string; + tool_input_preview: string; + reason: string; + severity: 'HIGH' | 'MEDIUM' | 'LOW'; + matching_rule_ids: string[]; + status: 'PENDING'; + created_at: string; + timeout_s: number; + repo: string; + task_description: string; +} +export interface CedarPolicy { + rule_id: string; + tier: 'hard-deny' | 'hard-gate'; + description: string; + action: string; + condition_summary: string; + severity?: string; + category?: string; + approval_timeout_s?: number; + cedar_source: string; +} +export declare const MOCK_TASKS: TaskSummary[]; +export declare const MOCK_EVENTS: TaskEvent[]; +export declare const MOCK_PENDING_APPROVALS: PendingApproval[]; +/** + * Returns events one at a time to simulate a live watch stream. + * Each call returns the next event or null if exhausted. + */ +export declare class MockEventStream { + private queue; + private index; + constructor(taskId: string); + /** Get next batch of events (simulates polling) */ + poll(afterIndex: number): TaskEvent[]; + get totalEvents(): number; +} +export declare function fetchTasks(): Promise; +export declare function fetchTask(taskId: string): Promise; +export declare function fetchPendingApprovals(): Promise; +export declare function approveRequest(taskId: string, requestId: string, scope?: string): Promise<{ + success: boolean; + message: string; +}>; +export declare function denyRequest(taskId: string, requestId: string, reason: string): Promise<{ + success: boolean; + message: string; +}>; +export declare const MOCK_POLICIES: CedarPolicy[]; +export declare function submitTask(repo: string, description: string): TaskSummary; diff --git a/cli/src/mock/data.js b/cli/src/mock/data.js new file mode 100644 index 00000000..7ab42ab0 --- /dev/null +++ b/cli/src/mock/data.js @@ -0,0 +1,362 @@ +"use strict"; +/** + * Mock data for the TUI prototype. + * Simulates the DynamoDB data shapes from TaskTable, TaskEventsTable, + * and TaskApprovalsTable without any real API calls. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MOCK_POLICIES = exports.MockEventStream = exports.MOCK_PENDING_APPROVALS = exports.MOCK_EVENTS = exports.MOCK_TASKS = exports.MOCK_REPOS = void 0; +exports.fetchTasks = fetchTasks; +exports.fetchTask = fetchTask; +exports.fetchPendingApprovals = fetchPendingApprovals; +exports.approveRequest = approveRequest; +exports.denyRequest = denyRequest; +exports.submitTask = submitTask; +exports.MOCK_REPOS = [ + { repo: 'aws-samples/my-project', status: 'active', default_branch: 'main' }, + { repo: 'aws-samples/billing-service', status: 'active', default_branch: 'main' }, + { repo: 'aws-samples/auth-lib', status: 'active', default_branch: 'develop' }, +]; +// ─── Mock Tasks ───────────────────────────────────────────────────── +const NOW = new Date(); +function minutesAgo(m) { + return new Date(NOW.getTime() - m * 60_000).toISOString(); +} +exports.MOCK_TASKS = [ + { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + status: 'RUNNING', + repo: 'aws-samples/my-project', + created_at: minutesAgo(3), + task_description: 'Add input validation to the /api/users endpoint using zod schemas', + task_type: 'new_task', + pr_number: null, + issue_number: 42, + branch_name: 'agent/input-validation-42', + cost_usd: 0.1847, + duration_s: null, + turn: 3, + max_turns: 8, + }, + { + task_id: '01JBX5QPKR2MN8HW1FS6AY4M2', + status: 'AWAITING_APPROVAL', + repo: 'aws-samples/my-project', + created_at: minutesAgo(15), + task_description: 'Fix the failing unit tests in the auth module and update snapshots', + task_type: 'new_task', + pr_number: null, + issue_number: 38, + branch_name: 'agent/fix-auth-tests-38', + cost_usd: 0.3412, + duration_s: null, + turn: 5, + max_turns: 10, + }, + { + task_id: '01JBX3RTMK7QN2HW9FS4AY8P8', + status: 'COMPLETED', + repo: 'acme-corp/backend-api', + created_at: minutesAgo(62), + task_description: 'Refactor database connection pooling to use pgbouncer', + task_type: 'new_task', + pr_number: 156, + issue_number: 29, + branch_name: 'agent/refactor-db-pool-29', + cost_usd: 0.8923, + duration_s: 2847, + turn: 12, + max_turns: 15, + }, + { + task_id: '01JBX1WQNR3PG7HW5FS2AY6L4', + status: 'FAILED', + repo: 'acme-corp/frontend', + created_at: minutesAgo(120), + task_description: 'Migrate the dashboard from Class components to React hooks', + task_type: 'new_task', + pr_number: null, + issue_number: 55, + branch_name: 'agent/migrate-hooks-55', + cost_usd: 1.2345, + duration_s: 5400, + turn: 15, + max_turns: 15, + }, +]; +// ─── Mock Events (for watch stream) ──────────────────────────────── +let eventCounter = 0; +function eid() { + return `01JBX7EVT${String(++eventCounter).padStart(6, '0')}`; +} +exports.MOCK_EVENTS = [ + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'task_started', timestamp: minutesAgo(3), + metadata: {}, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'turn_start', timestamp: minutesAgo(2.9), + metadata: { turn: 1 }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_call', timestamp: minutesAgo(2.8), + metadata: { tool_name: 'ReadFile', args_preview: 'src/api/users.ts' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_result', timestamp: minutesAgo(2.7), + metadata: { tool_name: 'ReadFile', status: 'success', preview: '1.2KB read' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_call', timestamp: minutesAgo(2.5), + metadata: { tool_name: 'ReadFile', args_preview: 'package.json' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_result', timestamp: minutesAgo(2.4), + metadata: { tool_name: 'ReadFile', status: 'success', preview: '0.8KB read' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'milestone', timestamp: minutesAgo(2.3), + metadata: { message: 'Analyzed codebase structure. Found Express + TypeScript stack.' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'turn_start', timestamp: minutesAgo(2.2), + metadata: { turn: 2 }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_call', timestamp: minutesAgo(2.1), + metadata: { tool_name: 'Bash', args_preview: 'npm install zod' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'approval_requested', timestamp: minutesAgo(2.1), + metadata: { + request_id: '01JBX7RRPK3QW9FM2JD6NX8B1T', + tool_name: 'Bash', + input_preview: 'npm install zod', + reason: 'Shell command execution requires approval', + severity: 'HIGH', + matching_rule_ids: ['bash_exec_gate'], + timeout_s: 600, + }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'approval_granted', timestamp: minutesAgo(1.8), + metadata: { request_id: '01JBX7RRPK3QW9FM2JD6NX8B1T', scope: 'this_call' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_result', timestamp: minutesAgo(1.6), + metadata: { tool_name: 'Bash', status: 'success', preview: 'added 1 package' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_call', timestamp: minutesAgo(1.5), + metadata: { tool_name: 'EditFile', args_preview: 'src/api/users.ts' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'approval_requested', timestamp: minutesAgo(1.5), + metadata: { + request_id: '01JBX7SSPK4RW0GM3KE7OY9C2U', + tool_name: 'EditFile', + input_preview: 'src/api/users.ts — Replace validation with zod schema', + reason: 'File modification requires approval (hard-gate: file_edit_gate)', + severity: 'MEDIUM', + matching_rule_ids: ['file_edit_gate'], + timeout_s: 600, + }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'cost_update', timestamp: minutesAgo(1.4), + metadata: { total_usd: 0.1847, input_tokens: 12400, output_tokens: 3200 }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'turn_start', timestamp: minutesAgo(1.0), + metadata: { turn: 3 }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_call', timestamp: minutesAgo(0.8), + metadata: { tool_name: 'ReadFile', args_preview: 'src/api/middleware/validate.ts' }, + }, + { + event_id: eid(), task_id: exports.MOCK_TASKS[0].task_id, + event_type: 'tool_result', timestamp: minutesAgo(0.7), + metadata: { tool_name: 'ReadFile', status: 'success', preview: '0.4KB read' }, + }, +]; +// ─── Mock Pending Approvals ───────────────────────────────────────── +exports.MOCK_PENDING_APPROVALS = [ + { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + request_id: '01JBX7SSPK4RW0GM3KE7OY9C2U', + tool_name: 'EditFile', + tool_input_preview: 'src/api/users.ts — Replace existing validation (lines 42-58) with zod schema: const userSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email() })', + reason: 'File modification requires approval (hard-gate: file_edit_gate)', + severity: 'MEDIUM', + matching_rule_ids: ['file_edit_gate'], + status: 'PENDING', + created_at: minutesAgo(1.5), + timeout_s: 600, + repo: 'aws-samples/my-project', + task_description: 'Add input validation to the /api/users endpoint using zod schemas', + }, + { + task_id: '01JBX5QPKR2MN8HW1FS6AY4M2', + request_id: '01JBX5TTPK5SW1HN4LF8PZ0D3V', + tool_name: 'Bash', + tool_input_preview: 'npm test -- --updateSnapshot', + reason: 'Shell command execution requires approval (hard-gate: bash_exec_gate)', + severity: 'HIGH', + matching_rule_ids: ['bash_exec_gate'], + status: 'PENDING', + created_at: minutesAgo(0.5), + timeout_s: 600, + repo: 'aws-samples/my-project', + task_description: 'Fix the failing unit tests in the auth module and update snapshots', + }, +]; +// ─── Mock Upcoming Events (for watch simulation) ──────────────────── +/** + * Returns events one at a time to simulate a live watch stream. + * Each call returns the next event or null if exhausted. + */ +class MockEventStream { + queue; + index = 0; + constructor(taskId) { + this.queue = exports.MOCK_EVENTS.filter(e => e.task_id === taskId); + } + /** Get next batch of events (simulates polling) */ + poll(afterIndex) { + // Return 1-3 events at a time to simulate realistic polling + const batch = this.queue.slice(afterIndex, afterIndex + Math.ceil(Math.random() * 3)); + return batch; + } + get totalEvents() { + return this.queue.length; + } +} +exports.MockEventStream = MockEventStream; +// ─── Mock API Functions ───────────────────────────────────────────── +async function fetchTasks() { + // Simulate API latency + await new Promise(r => setTimeout(r, 200)); + return exports.MOCK_TASKS; +} +async function fetchTask(taskId) { + await new Promise(r => setTimeout(r, 150)); + return exports.MOCK_TASKS.find(t => t.task_id === taskId); +} +async function fetchPendingApprovals() { + await new Promise(r => setTimeout(r, 200)); + return exports.MOCK_PENDING_APPROVALS; +} +async function approveRequest(taskId, requestId, scope) { + await new Promise(r => setTimeout(r, 300)); + return { + success: true, + message: `Approved ${requestId.slice(-6)} for task ${taskId.slice(-4)}` + + (scope ? ` (scope: ${scope})` : ''), + }; +} +async function denyRequest(taskId, requestId, reason) { + await new Promise(r => setTimeout(r, 300)); + return { + success: true, + message: `Denied ${requestId.slice(-6)} for task ${taskId.slice(-4)}: ${reason}`, + }; +} +// ─── Mock Cedar Policies ──────────────────────────────────────────── +exports.MOCK_POLICIES = [ + { + rule_id: 'rm_slash', tier: 'hard-deny', + description: 'Block rm -rf / and variants', + action: 'execute_bash', condition_summary: 'command matches *rm -rf /*', + category: 'destructive', + cedar_source: '@tier("hard-deny")\n@rule_id("rm_slash")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*rm -rf /*" };', + }, + { + rule_id: 'write_git_internals', tier: 'hard-deny', + description: 'Block writes to .git/ directory', + action: 'write_file', condition_summary: 'file_path matches .git/*', + category: 'filesystem', + cedar_source: '@tier("hard-deny")\n@rule_id("write_git_internals")\nforbid (principal, action == Agent::Action::"write_file", resource)\n when { context.file_path like ".git/*" };', + }, + { + rule_id: 'drop_table', tier: 'hard-deny', + description: 'Block DROP TABLE commands', + action: 'execute_bash', condition_summary: 'command matches *DROP TABLE*', + category: 'destructive', + cedar_source: '@tier("hard-deny")\n@rule_id("drop_table")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*DROP TABLE*" };', + }, + { + rule_id: 'force_push_main', tier: 'hard-deny', + description: 'Block force-push to main/prod branches', + action: 'execute_bash', condition_summary: 'command matches *git push --force origin main*', + severity: 'high', category: 'destructive', + cedar_source: '@tier("hard-deny")\n@rule_id("force_push_main")\n@severity("high")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*git push --force origin main*" };', + }, + { + rule_id: 'bash_exec_gate', tier: 'hard-gate', + description: 'Shell command execution requires approval', + action: 'execute_bash', condition_summary: 'all bash commands (catch-all)', + severity: 'high', category: 'auth', approval_timeout_s: 600, + cedar_source: '@tier("hard-gate")\n@rule_id("bash_exec_gate")\n@severity("high")\n@approval_timeout_s("600")\nforbid (principal, action == Agent::Action::"execute_bash", resource);', + }, + { + rule_id: 'file_edit_gate', tier: 'hard-gate', + description: 'File modifications require approval', + action: 'write_file', condition_summary: 'all file writes and edits', + severity: 'medium', category: 'filesystem', approval_timeout_s: 600, + cedar_source: '@tier("hard-gate")\n@rule_id("file_edit_gate")\n@severity("medium")\n@approval_timeout_s("600")\nforbid (principal, action == Agent::Action::"write_file", resource);', + }, + { + rule_id: 'deploy_staging', tier: 'hard-gate', + description: 'Terraform/CDK deploy requires approval', + action: 'execute_bash', condition_summary: 'command matches *terraform apply* or *cdk deploy*', + severity: 'high', category: 'destructive', approval_timeout_s: 900, + cedar_source: '@tier("hard-gate")\n@rule_id("deploy_staging")\n@severity("high")\n@approval_timeout_s("900")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*terraform apply*" };', + }, + { + rule_id: 'npm_install_gate', tier: 'hard-gate', + description: 'Package installation requires approval', + action: 'execute_bash', condition_summary: 'command matches *npm install* or *yarn add*', + severity: 'medium', category: 'auth', approval_timeout_s: 300, + cedar_source: '@tier("hard-gate")\n@rule_id("npm_install_gate")\n@severity("medium")\n@approval_timeout_s("300")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*npm install*" };', + }, +]; +// ─── Submit Task Mock ─────────────────────────────────────────────── +function submitTask(repo, description) { + const id = '01JBX9' + Math.random().toString(36).slice(2, 8).toUpperCase(); + const task = { + task_id: id, + status: 'SUBMITTED', + repo, + created_at: new Date().toISOString(), + task_description: description, + task_type: 'new_task', + pr_number: null, + issue_number: null, + branch_name: `agent/${id.slice(-6).toLowerCase()}`, + cost_usd: null, + duration_s: null, + turn: 0, + max_turns: 8, + }; + exports.MOCK_TASKS.push(task); + return task; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGF0YS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImRhdGEudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUFBOzs7O0dBSUc7OztBQTBUSCxnQ0FJQztBQUVELDhCQUdDO0FBRUQsc0RBR0M7QUFFRCx3Q0FTQztBQUVELGtDQVFDO0FBaUVELGdDQW1CQztBQXZhWSxRQUFBLFVBQVUsR0FBcUI7SUFDMUMsRUFBRSxJQUFJLEVBQUUsd0JBQXdCLEVBQUUsTUFBTSxFQUFFLFFBQVEsRUFBRSxjQUFjLEVBQUUsTUFBTSxFQUFFO0lBQzVFLEVBQUUsSUFBSSxFQUFFLDZCQUE2QixFQUFFLE1BQU0sRUFBRSxRQUFRLEVBQUUsY0FBYyxFQUFFLE1BQU0sRUFBRTtJQUNqRixFQUFFLElBQUksRUFBRSxzQkFBc0IsRUFBRSxNQUFNLEVBQUUsUUFBUSxFQUFFLGNBQWMsRUFBRSxTQUFTLEVBQUU7Q0FDOUUsQ0FBQztBQXFERix1RUFBdUU7QUFFdkUsTUFBTSxHQUFHLEdBQUcsSUFBSSxJQUFJLEVBQUUsQ0FBQztBQUN2QixTQUFTLFVBQVUsQ0FBQyxDQUFTO0lBQzNCLE9BQU8sSUFBSSxJQUFJLENBQUMsR0FBRyxDQUFDLE9BQU8sRUFBRSxHQUFHLENBQUMsR0FBRyxNQUFNLENBQUMsQ0FBQyxXQUFXLEVBQUUsQ0FBQztBQUM1RCxDQUFDO0FBRVksUUFBQSxVQUFVLEdBQWtCO0lBQ3ZDO1FBQ0UsT0FBTyxFQUFFLDJCQUEyQjtRQUNwQyxNQUFNLEVBQUUsU0FBUztRQUNqQixJQUFJLEVBQUUsd0JBQXdCO1FBQzlCLFVBQVUsRUFBRSxVQUFVLENBQUMsQ0FBQyxDQUFDO1FBQ3pCLGdCQUFnQixFQUFFLG1FQUFtRTtRQUNyRixTQUFTLEVBQUUsVUFBVTtRQUNyQixTQUFTLEVBQUUsSUFBSTtRQUNmLFlBQVksRUFBRSxFQUFFO1FBQ2hCLFdBQVcsRUFBRSwyQkFBMkI7UUFDeEMsUUFBUSxFQUFFLE1BQU07UUFDaEIsVUFBVSxFQUFFLElBQUk7UUFDaEIsSUFBSSxFQUFFLENBQUM7UUFDUCxTQUFTLEVBQUUsQ0FBQztLQUNiO0lBQ0Q7UUFDRSxPQUFPLEVBQUUsMkJBQTJCO1FBQ3BDLE1BQU0sRUFBRSxtQkFBbUI7UUFDM0IsSUFBSSxFQUFFLHdCQUF3QjtRQUM5QixVQUFVLEVBQUUsVUFBVSxDQUFDLEVBQUUsQ0FBQztRQUMxQixnQkFBZ0IsRUFBRSxvRUFBb0U7UUFDdEYsU0FBUyxFQUFFLFVBQVU7UUFDckIsU0FBUyxFQUFFLElBQUk7UUFDZixZQUFZLEVBQUUsRUFBRTtRQUNoQixXQUFXLEVBQUUseUJBQXlCO1FBQ3RDLFFBQVEsRUFBRSxNQUFNO1FBQ2hCLFVBQVUsRUFBRSxJQUFJO1FBQ2hCLElBQUksRUFBRSxDQUFDO1FBQ1AsU0FBUyxFQUFFLEVBQUU7S0FDZDtJQUNEO1FBQ0UsT0FBTyxFQUFFLDJCQUEyQjtRQUNwQyxNQUFNLEVBQUUsV0FBVztRQUNuQixJQUFJLEVBQUUsdUJBQXVCO1FBQzdCLFVBQVUsRUFBRSxVQUFVLENBQUMsRUFBRSxDQUFDO1FBQzFCLGdCQUFnQixFQUFFLHVEQUF1RDtRQUN6RSxTQUFTLEVBQUUsVUFBVTtRQUNyQixTQUFTLEVBQUUsR0FBRztRQUNkLFlBQVksRUFBRSxFQUFFO1FBQ2hCLFdBQVcsRUFBRSwyQkFBMkI7UUFDeEMsUUFBUSxFQUFFLE1BQU07UUFDaEIsVUFBVSxFQUFFLElBQUk7UUFDaEIsSUFBSSxFQUFFLEVBQUU7UUFDUixTQUFTLEVBQUUsRUFBRTtLQUNkO0lBQ0Q7UUFDRSxPQUFPLEVBQUUsMkJBQTJCO1FBQ3BDLE1BQU0sRUFBRSxRQUFRO1FBQ2hCLElBQUksRUFBRSxvQkFBb0I7UUFDMUIsVUFBVSxFQUFFLFVBQVUsQ0FBQyxHQUFHLENBQUM7UUFDM0IsZ0JBQWdCLEVBQUUsNERBQTREO1FBQzlFLFNBQVMsRUFBRSxVQUFVO1FBQ3JCLFNBQVMsRUFBRSxJQUFJO1FBQ2YsWUFBWSxFQUFFLEVBQUU7UUFDaEIsV0FBVyxFQUFFLHdCQUF3QjtRQUNyQyxRQUFRLEVBQUUsTUFBTTtRQUNoQixVQUFVLEVBQUUsSUFBSTtRQUNoQixJQUFJLEVBQUUsRUFBRTtRQUNSLFNBQVMsRUFBRSxFQUFFO0tBQ2Q7Q0FDRixDQUFDO0FBRUYsc0VBQXNFO0FBRXRFLElBQUksWUFBWSxHQUFHLENBQUMsQ0FBQztBQUNyQixTQUFTLEdBQUc7SUFDVixPQUFPLFlBQVksTUFBTSxDQUFDLEVBQUUsWUFBWSxDQUFDLENBQUMsUUFBUSxDQUFDLENBQUMsRUFBRSxHQUFHLENBQUMsRUFBRSxDQUFDO0FBQy9ELENBQUM7QUFFWSxRQUFBLFdBQVcsR0FBZ0I7SUFDdEM7UUFDRSxRQUFRLEVBQUUsR0FBRyxFQUFFLEVBQUUsT0FBTyxFQUFFLGtCQUFVLENBQUMsQ0FBQyxDQUFDLENBQUMsT0FBTztRQUMvQyxVQUFVLEVBQUUsY0FBYyxFQUFFLFNBQVMsRUFBRSxVQUFVLENBQUMsQ0FBQyxDQUFDO1FBQ3BELFFBQVEsRUFBRSxFQUFFO0tBQ2I7SUFDRDtRQUNFLFFBQVEsRUFBRSxHQUFHLEVBQUUsRUFBRSxPQUFPLEVBQUUsa0JBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxPQUFPO1FBQy9DLFVBQVUsRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFVBQVUsQ0FBQyxHQUFHLENBQUM7UUFDcEQsUUFBUSxFQUFFLEVBQUUsSUFBSSxFQUFFLENBQUMsRUFBRTtLQUN0QjtJQUNEO1FBQ0UsUUFBUSxFQUFFLEdBQUcsRUFBRSxFQUFFLE9BQU8sRUFBRSxrQkFBVSxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU87UUFDL0MsVUFBVSxFQUFFLFdBQVcsRUFBRSxTQUFTLEVBQUUsVUFBVSxDQUFDLEdBQUcsQ0FBQztRQUNuRCxRQUFRLEVBQUUsRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxrQkFBa0IsRUFBRTtLQUN0RTtJQUNEO1FBQ0UsUUFBUSxFQUFFLEdBQUcsRUFBRSxFQUFFLE9BQU8sRUFBRSxrQkFBVSxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU87UUFDL0MsVUFBVSxFQUFFLGFBQWEsRUFBRSxTQUFTLEVBQUUsVUFBVSxDQUFDLEdBQUcsQ0FBQztRQUNyRCxRQUFRLEVBQUUsRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLE1BQU0sRUFBRSxTQUFTLEVBQUUsT0FBTyxFQUFFLFlBQVksRUFBRTtLQUM5RTtJQUNEO1FBQ0UsUUFBUSxFQUFFLEdBQUcsRUFBRSxFQUFFLE9BQU8sRUFBRSxrQkFBVSxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU87UUFDL0MsVUFBVSxFQUFFLFdBQVcsRUFBRSxTQUFTLEVBQUUsVUFBVSxDQUFDLEdBQUcsQ0FBQztRQUNuRCxRQUFRLEVBQUUsRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxjQUFjLEVBQUU7S0FDbEU7SUFDRDtRQUNFLFFBQVEsRUFBRSxHQUFHLEVBQUUsRUFBRSxPQUFPLEVBQUUsa0JBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxPQUFPO1FBQy9DLFVBQVUsRUFBRSxhQUFhLEVBQUUsU0FBUyxFQUFFLFVBQVUsQ0FBQyxHQUFHLENBQUM7UUFDckQsUUFBUSxFQUFFLEVBQUUsU0FBUyxFQUFFLFVBQVUsRUFBRSxNQUFNLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxZQUFZLEVBQUU7S0FDOUU7SUFDRDtRQUNFLFFBQVEsRUFBRSxHQUFHLEVBQUUsRUFBRSxPQUFPLEVBQUUsa0JBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxPQUFPO1FBQy9DLFVBQVUsRUFBRSxXQUFXLEVBQUUsU0FBUyxFQUFFLFVBQVUsQ0FBQyxHQUFHLENBQUM7UUFDbkQsUUFBUSxFQUFFLEVBQUUsT0FBTyxFQUFFLGdFQUFnRSxFQUFFO0tBQ3hGO0lBQ0Q7UUFDRSxRQUFRLEVBQUUsR0FBRyxFQUFFLEVBQUUsT0FBTyxFQUFFLGtCQUFVLENBQUMsQ0FBQyxDQUFDLENBQUMsT0FBTztRQUMvQyxVQUFVLEVBQUUsWUFBWSxFQUFFLFNBQVMsRUFBRSxVQUFVLENBQUMsR0FBRyxDQUFDO1FBQ3BELFFBQVEsRUFBRSxFQUFFLElBQUksRUFBRSxDQUFDLEVBQUU7S0FDdEI7SUFDRDtRQUNFLFFBQVEsRUFBRSxHQUFHLEVBQUUsRUFBRSxPQUFPLEVBQUUsa0JBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxPQUFPO1FBQy9DLFVBQVUsRUFBRSxXQUFXLEVBQUUsU0FBUyxFQUFFLFVBQVUsQ0FBQyxHQUFHLENBQUM7UUFDbkQsUUFBUSxFQUFFLEVBQUUsU0FBUyxFQUFFLE1BQU0sRUFBRSxZQUFZLEVBQUUsaUJBQWlCLEVBQUU7S0FDakU7SUFDRDtRQUNFLFFBQVEsRUFBRSxHQUFHLEVBQUUsRUFBRSxPQUFPLEVBQUUsa0JBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxPQUFPO1FBQy9DLFVBQVUsRUFBRSxvQkFBb0IsRUFBRSxTQUFTLEVBQUUsVUFBVSxDQUFDLEdBQUcsQ0FBQztRQUM1RCxRQUFRLEVBQUU7WUFDUixVQUFVLEVBQUUsNEJBQTRCO1lBQ3hDLFNBQVMsRUFBRSxNQUFNO1lBQ2pCLGFBQWEsRUFBRSxpQkFBaUI7WUFDaEMsTUFBTSxFQUFFLDJDQUEyQztZQUNuRCxRQUFRLEVBQUUsTUFBTTtZQUNoQixpQkFBaUIsRUFBRSxDQUFDLGdCQUFnQixDQUFDO1lBQ3JDLFNBQVMsRUFBRSxHQUFHO1NBQ2Y7S0FDRjtJQUNEO1FBQ0UsUUFBUSxFQUFFLEdBQUcsRUFBRSxFQUFFLE9BQU8sRUFBRSxrQkFBVSxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU87UUFDL0MsVUFBVSxFQUFFLGtCQUFrQixFQUFFLFNBQVMsRUFBRSxVQUFVLENBQUMsR0FBRyxDQUFDO1FBQzFELFFBQVEsRUFBRSxFQUFFLFVBQVUsRUFBRSw0QkFBNEIsRUFBRSxLQUFLLEVBQUUsV0FBVyxFQUFFO0tBQzNFO0lBQ0Q7UUFDRSxRQUFRLEVBQUUsR0FBRyxFQUFFLEVBQUUsT0FBTyxFQUFFLGtCQUFVLENBQUMsQ0FBQyxDQUFDLENBQUMsT0FBTztRQUMvQyxVQUFVLEVBQUUsYUFBYSxFQUFFLFNBQVMsRUFBRSxVQUFVLENBQUMsR0FBRyxDQUFDO1FBQ3JELFFBQVEsRUFBRSxFQUFFLFNBQVMsRUFBRSxNQUFNLEVBQUUsTUFBTSxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsaUJBQWlCLEVBQUU7S0FDL0U7SUFDRDtRQUNFLFFBQVEsRUFBRSxHQUFHLEVBQUUsRUFBRSxPQUFPLEVBQUUsa0JBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxPQUFPO1FBQy9DLFVBQVUsRUFBRSxXQUFXLEVBQUUsU0FBUyxFQUFFLFVBQVUsQ0FBQyxHQUFHLENBQUM7UUFDbkQsUUFBUSxFQUFFLEVBQUUsU0FBUyxFQUFFLFVBQVUsRUFBRSxZQUFZLEVBQUUsa0JBQWtCLEVBQUU7S0FDdEU7SUFDRDtRQUNFLFFBQVEsRUFBRSxHQUFHLEVBQUUsRUFBRSxPQUFPLEVBQUUsa0JBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxPQUFPO1FBQy9DLFVBQVUsRUFBRSxvQkFBb0IsRUFBRSxTQUFTLEVBQUUsVUFBVSxDQUFDLEdBQUcsQ0FBQztRQUM1RCxRQUFRLEVBQUU7WUFDUixVQUFVLEVBQUUsNEJBQTRCO1lBQ3hDLFNBQVMsRUFBRSxVQUFVO1lBQ3JCLGFBQWEsRUFBRSx1REFBdUQ7WUFDdEUsTUFBTSxFQUFFLGlFQUFpRTtZQUN6RSxRQUFRLEVBQUUsUUFBUTtZQUNsQixpQkFBaUIsRUFBRSxDQUFDLGdCQUFnQixDQUFDO1lBQ3JDLFNBQVMsRUFBRSxHQUFHO1NBQ2Y7S0FDRjtJQUNEO1FBQ0UsUUFBUSxFQUFFLEdBQUcsRUFBRSxFQUFFLE9BQU8sRUFBRSxrQkFBVSxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU87UUFDL0MsVUFBVSxFQUFFLGFBQWEsRUFBRSxTQUFTLEVBQUUsVUFBVSxDQUFDLEdBQUcsQ0FBQztRQUNyRCxRQUFRLEVBQUUsRUFBRSxTQUFTLEVBQUUsTUFBTSxFQUFFLFlBQVksRUFBRSxLQUFLLEVBQUUsYUFBYSxFQUFFLElBQUksRUFBRTtLQUMxRTtJQUNEO1FBQ0UsUUFBUSxFQUFFLEdBQUcsRUFBRSxFQUFFLE9BQU8sRUFBRSxrQkFBVSxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU87UUFDL0MsVUFBVSxFQUFFLFlBQVksRUFBRSxTQUFTLEVBQUUsVUFBVSxDQUFDLEdBQUcsQ0FBQztRQUNwRCxRQUFRLEVBQUUsRUFBRSxJQUFJLEVBQUUsQ0FBQyxFQUFFO0tBQ3RCO0lBQ0Q7UUFDRSxRQUFRLEVBQUUsR0FBRyxFQUFFLEVBQUUsT0FBTyxFQUFFLGtCQUFVLENBQUMsQ0FBQyxDQUFDLENBQUMsT0FBTztRQUMvQyxVQUFVLEVBQUUsV0FBVyxFQUFFLFNBQVMsRUFBRSxVQUFVLENBQUMsR0FBRyxDQUFDO1FBQ25ELFFBQVEsRUFBRSxFQUFFLFNBQVMsRUFBRSxVQUFVLEVBQUUsWUFBWSxFQUFFLGdDQUFnQyxFQUFFO0tBQ3BGO0lBQ0Q7UUFDRSxRQUFRLEVBQUUsR0FBRyxFQUFFLEVBQUUsT0FBTyxFQUFFLGtCQUFVLENBQUMsQ0FBQyxDQUFDLENBQUMsT0FBTztRQUMvQyxVQUFVLEVBQUUsYUFBYSxFQUFFLFNBQVMsRUFBRSxVQUFVLENBQUMsR0FBRyxDQUFDO1FBQ3JELFFBQVEsRUFBRSxFQUFFLFNBQVMsRUFBRSxVQUFVLEVBQUUsTUFBTSxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsWUFBWSxFQUFFO0tBQzlFO0NBQ0YsQ0FBQztBQUVGLHVFQUF1RTtBQUUxRCxRQUFBLHNCQUFzQixHQUFzQjtJQUN2RDtRQUNFLE9BQU8sRUFBRSwyQkFBMkI7UUFDcEMsVUFBVSxFQUFFLDRCQUE0QjtRQUN4QyxTQUFTLEVBQUUsVUFBVTtRQUNyQixrQkFBa0IsRUFBRSw0S0FBNEs7UUFDaE0sTUFBTSxFQUFFLGlFQUFpRTtRQUN6RSxRQUFRLEVBQUUsUUFBUTtRQUNsQixpQkFBaUIsRUFBRSxDQUFDLGdCQUFnQixDQUFDO1FBQ3JDLE1BQU0sRUFBRSxTQUFTO1FBQ2pCLFVBQVUsRUFBRSxVQUFVLENBQUMsR0FBRyxDQUFDO1FBQzNCLFNBQVMsRUFBRSxHQUFHO1FBQ2QsSUFBSSxFQUFFLHdCQUF3QjtRQUM5QixnQkFBZ0IsRUFBRSxtRUFBbUU7S0FDdEY7SUFDRDtRQUNFLE9BQU8sRUFBRSwyQkFBMkI7UUFDcEMsVUFBVSxFQUFFLDRCQUE0QjtRQUN4QyxTQUFTLEVBQUUsTUFBTTtRQUNqQixrQkFBa0IsRUFBRSw4QkFBOEI7UUFDbEQsTUFBTSxFQUFFLHVFQUF1RTtRQUMvRSxRQUFRLEVBQUUsTUFBTTtRQUNoQixpQkFBaUIsRUFBRSxDQUFDLGdCQUFnQixDQUFDO1FBQ3JDLE1BQU0sRUFBRSxTQUFTO1FBQ2pCLFVBQVUsRUFBRSxVQUFVLENBQUMsR0FBRyxDQUFDO1FBQzNCLFNBQVMsRUFBRSxHQUFHO1FBQ2QsSUFBSSxFQUFFLHdCQUF3QjtRQUM5QixnQkFBZ0IsRUFBRSxvRUFBb0U7S0FDdkY7Q0FDRixDQUFDO0FBRUYsdUVBQXVFO0FBRXZFOzs7R0FHRztBQUNILE1BQWEsZUFBZTtJQUNsQixLQUFLLENBQWM7SUFDbkIsS0FBSyxHQUFHLENBQUMsQ0FBQztJQUVsQixZQUFZLE1BQWM7UUFDeEIsSUFBSSxDQUFDLEtBQUssR0FBRyxtQkFBVyxDQUFDLE1BQU0sQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxPQUFPLEtBQUssTUFBTSxDQUFDLENBQUM7SUFDN0QsQ0FBQztJQUVELG1EQUFtRDtJQUNuRCxJQUFJLENBQUMsVUFBa0I7UUFDckIsNERBQTREO1FBQzVELE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLFVBQVUsRUFBRSxVQUFVLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsTUFBTSxFQUFFLEdBQUcsQ0FBQyxDQUFDLENBQUMsQ0FBQztRQUN0RixPQUFPLEtBQUssQ0FBQztJQUNmLENBQUM7SUFFRCxJQUFJLFdBQVc7UUFDYixPQUFPLElBQUksQ0FBQyxLQUFLLENBQUMsTUFBTSxDQUFDO0lBQzNCLENBQUM7Q0FDRjtBQWxCRCwwQ0FrQkM7QUFFRCx1RUFBdUU7QUFFaEUsS0FBSyxVQUFVLFVBQVU7SUFDOUIsdUJBQXVCO0lBQ3ZCLE1BQU0sSUFBSSxPQUFPLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxVQUFVLENBQUMsQ0FBQyxFQUFFLEdBQUcsQ0FBQyxDQUFDLENBQUM7SUFDM0MsT0FBTyxrQkFBVSxDQUFDO0FBQ3BCLENBQUM7QUFFTSxLQUFLLFVBQVUsU0FBUyxDQUFDLE1BQWM7SUFDNUMsTUFBTSxJQUFJLE9BQU8sQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLFVBQVUsQ0FBQyxDQUFDLEVBQUUsR0FBRyxDQUFDLENBQUMsQ0FBQztJQUMzQyxPQUFPLGtCQUFVLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLE9BQU8sS0FBSyxNQUFNLENBQUMsQ0FBQztBQUNwRCxDQUFDO0FBRU0sS0FBSyxVQUFVLHFCQUFxQjtJQUN6QyxNQUFNLElBQUksT0FBTyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsVUFBVSxDQUFDLENBQUMsRUFBRSxHQUFHLENBQUMsQ0FBQyxDQUFDO0lBQzNDLE9BQU8sOEJBQXNCLENBQUM7QUFDaEMsQ0FBQztBQUVNLEtBQUssVUFBVSxjQUFjLENBQ2xDLE1BQWMsRUFBRSxTQUFpQixFQUFFLEtBQWM7SUFFakQsTUFBTSxJQUFJLE9BQU8sQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLFVBQVUsQ0FBQyxDQUFDLEVBQUUsR0FBRyxDQUFDLENBQUMsQ0FBQztJQUMzQyxPQUFPO1FBQ0wsT0FBTyxFQUFFLElBQUk7UUFDYixPQUFPLEVBQUUsWUFBWSxTQUFTLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLGFBQWEsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxFQUFFO1lBQzlELENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxZQUFZLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7S0FDN0MsQ0FBQztBQUNKLENBQUM7QUFFTSxLQUFLLFVBQVUsV0FBVyxDQUMvQixNQUFjLEVBQUUsU0FBaUIsRUFBRSxNQUFjO0lBRWpELE1BQU0sSUFBSSxPQUFPLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxVQUFVLENBQUMsQ0FBQyxFQUFFLEdBQUcsQ0FBQyxDQUFDLENBQUM7SUFDM0MsT0FBTztRQUNMLE9BQU8sRUFBRSxJQUFJO1FBQ2IsT0FBTyxFQUFFLFVBQVUsU0FBUyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxhQUFhLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUMsS0FBSyxNQUFNLEVBQUU7S0FDakYsQ0FBQztBQUNKLENBQUM7QUFFRCx1RUFBdUU7QUFFMUQsUUFBQSxhQUFhLEdBQWtCO0lBQzFDO1FBQ0UsT0FBTyxFQUFFLFVBQVUsRUFBRSxJQUFJLEVBQUUsV0FBVztRQUN0QyxXQUFXLEVBQUUsNkJBQTZCO1FBQzFDLE1BQU0sRUFBRSxjQUFjLEVBQUUsaUJBQWlCLEVBQUUsNEJBQTRCO1FBQ3ZFLFFBQVEsRUFBRSxhQUFhO1FBQ3ZCLFlBQVksRUFBRSxnS0FBZ0s7S0FDL0s7SUFDRDtRQUNFLE9BQU8sRUFBRSxxQkFBcUIsRUFBRSxJQUFJLEVBQUUsV0FBVztRQUNqRCxXQUFXLEVBQUUsaUNBQWlDO1FBQzlDLE1BQU0sRUFBRSxZQUFZLEVBQUUsaUJBQWlCLEVBQUUsMEJBQTBCO1FBQ25FLFFBQVEsRUFBRSxZQUFZO1FBQ3RCLFlBQVksRUFBRSx1S0FBdUs7S0FDdEw7SUFDRDtRQUNFLE9BQU8sRUFBRSxZQUFZLEVBQUUsSUFBSSxFQUFFLFdBQVc7UUFDeEMsV0FBVyxFQUFFLDJCQUEyQjtRQUN4QyxNQUFNLEVBQUUsY0FBYyxFQUFFLGlCQUFpQixFQUFFLDhCQUE4QjtRQUN6RSxRQUFRLEVBQUUsYUFBYTtRQUN2QixZQUFZLEVBQUUsb0tBQW9LO0tBQ25MO0lBQ0Q7UUFDRSxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsSUFBSSxFQUFFLFdBQVc7UUFDN0MsV0FBVyxFQUFFLHdDQUF3QztRQUNyRCxNQUFNLEVBQUUsY0FBYyxFQUFFLGlCQUFpQixFQUFFLGdEQUFnRDtRQUMzRixRQUFRLEVBQUUsTUFBTSxFQUFFLFFBQVEsRUFBRSxhQUFhO1FBQ3pDLFlBQVksRUFBRSw4TUFBOE07S0FDN047SUFDRDtRQUNFLE9BQU8sRUFBRSxnQkFBZ0IsRUFBRSxJQUFJLEVBQUUsV0FBVztRQUM1QyxXQUFXLEVBQUUsMkNBQTJDO1FBQ3hELE1BQU0sRUFBRSxjQUFjLEVBQUUsaUJBQWlCLEVBQUUsK0JBQStCO1FBQzFFLFFBQVEsRUFBRSxNQUFNLEVBQUUsUUFBUSxFQUFFLE1BQU0sRUFBRSxrQkFBa0IsRUFBRSxHQUFHO1FBQzNELFlBQVksRUFBRSx1S0FBdUs7S0FDdEw7SUFDRDtRQUNFLE9BQU8sRUFBRSxnQkFBZ0IsRUFBRSxJQUFJLEVBQUUsV0FBVztRQUM1QyxXQUFXLEVBQUUscUNBQXFDO1FBQ2xELE1BQU0sRUFBRSxZQUFZLEVBQUUsaUJBQWlCLEVBQUUsMkJBQTJCO1FBQ3BFLFFBQVEsRUFBRSxRQUFRLEVBQUUsUUFBUSxFQUFFLFlBQVksRUFBRSxrQkFBa0IsRUFBRSxHQUFHO1FBQ25FLFlBQVksRUFBRSx1S0FBdUs7S0FDdEw7SUFDRDtRQUNFLE9BQU8sRUFBRSxnQkFBZ0IsRUFBRSxJQUFJLEVBQUUsV0FBVztRQUM1QyxXQUFXLEVBQUUsd0NBQXdDO1FBQ3JELE1BQU0sRUFBRSxjQUFjLEVBQUUsaUJBQWlCLEVBQUUsbURBQW1EO1FBQzlGLFFBQVEsRUFBRSxNQUFNLEVBQUUsUUFBUSxFQUFFLGFBQWEsRUFBRSxrQkFBa0IsRUFBRSxHQUFHO1FBQ2xFLFlBQVksRUFBRSw0TkFBNE47S0FDM087SUFDRDtRQUNFLE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxJQUFJLEVBQUUsV0FBVztRQUM5QyxXQUFXLEVBQUUsd0NBQXdDO1FBQ3JELE1BQU0sRUFBRSxjQUFjLEVBQUUsaUJBQWlCLEVBQUUsNkNBQTZDO1FBQ3hGLFFBQVEsRUFBRSxRQUFRLEVBQUUsUUFBUSxFQUFFLE1BQU0sRUFBRSxrQkFBa0IsRUFBRSxHQUFHO1FBQzdELFlBQVksRUFBRSw0TkFBNE47S0FDM087Q0FDRixDQUFDO0FBRUYsdUVBQXVFO0FBRXZFLFNBQWdCLFVBQVUsQ0FBQyxJQUFZLEVBQUUsV0FBbUI7SUFDMUQsTUFBTSxFQUFFLEdBQUcsUUFBUSxHQUFHLElBQUksQ0FBQyxNQUFNLEVBQUUsQ0FBQyxRQUFRLENBQUMsRUFBRSxDQUFDLENBQUMsS0FBSyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxXQUFXLEVBQUUsQ0FBQztJQUMzRSxNQUFNLElBQUksR0FBZ0I7UUFDeEIsT0FBTyxFQUFFLEVBQUU7UUFDWCxNQUFNLEVBQUUsV0FBVztRQUNuQixJQUFJO1FBQ0osVUFBVSxFQUFFLElBQUksSUFBSSxFQUFFLENBQUMsV0FBVyxFQUFFO1FBQ3BDLGdCQUFnQixFQUFFLFdBQVc7UUFDN0IsU0FBUyxFQUFFLFVBQVU7UUFDckIsU0FBUyxFQUFFLElBQUk7UUFDZixZQUFZLEVBQUUsSUFBSTtRQUNsQixXQUFXLEVBQUUsU0FBUyxFQUFFLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsV0FBVyxFQUFFLEVBQUU7UUFDbEQsUUFBUSxFQUFFLElBQUk7UUFDZCxVQUFVLEVBQUUsSUFBSTtRQUNoQixJQUFJLEVBQUUsQ0FBQztRQUNQLFNBQVMsRUFBRSxDQUFDO0tBQ2IsQ0FBQztJQUNGLGtCQUFVLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO0lBQ3RCLE9BQU8sSUFBSSxDQUFDO0FBQ2QsQ0FBQyJ9 diff --git a/cli/src/mock/data.ts b/cli/src/mock/data.ts new file mode 100644 index 00000000..9dbdb676 --- /dev/null +++ b/cli/src/mock/data.ts @@ -0,0 +1,518 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Mock data for the TUI prototype. + * Simulates the DynamoDB data shapes from TaskTable, TaskEventsTable, + * and TaskApprovalsTable without any real API calls. + */ + +// ─── Types ────────────────────────────────────────────────────────── + +export interface RegisteredRepo { + repo: string; // "owner/repo" format + status: 'active' | 'removed'; + default_branch: string; +} + +export const MOCK_REPOS: RegisteredRepo[] = [ + { repo: 'aws-samples/my-project', status: 'active', default_branch: 'main' }, + { repo: 'aws-samples/billing-service', status: 'active', default_branch: 'main' }, + { repo: 'aws-samples/auth-lib', status: 'active', default_branch: 'develop' }, +]; + +export interface TaskSummary { + task_id: string; + status: string; + repo: string; + created_at: string; + task_description: string; + task_type: string; + pr_number: number | null; + issue_number: number | null; + branch_name: string; + cost_usd: number | null; + duration_s: number | null; + turn: number; + max_turns: number | null; +} + +export interface TaskEvent { + event_id: string; + task_id: string; + event_type: string; + timestamp: string; + metadata: Record; +} + +export interface PendingApproval { + task_id: string; + request_id: string; + tool_name: string; + tool_input_preview: string; + reason: string; + severity: 'HIGH' | 'MEDIUM' | 'LOW'; + matching_rule_ids: string[]; + status: 'PENDING'; + created_at: string; + timeout_s: number; + repo: string; + task_description: string; +} + +export interface CedarPolicy { + rule_id: string; + tier: 'hard-deny' | 'hard-gate'; + description: string; + action: string; + condition_summary: string; + severity?: string; + category?: string; + approval_timeout_s?: number; + cedar_source: string; +} + +// ─── Mock Tasks ───────────────────────────────────────────────────── + +const NOW = new Date(); +function minutesAgo(m: number): string { + return new Date(NOW.getTime() - m * 60_000).toISOString(); +} + +export const MOCK_TASKS: TaskSummary[] = [ + { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + status: 'RUNNING', + repo: 'aws-samples/my-project', + created_at: minutesAgo(3), + task_description: 'Add input validation to the /api/users endpoint using zod schemas', + task_type: 'new_task', + pr_number: null, + issue_number: 42, + branch_name: 'agent/input-validation-42', + cost_usd: 0.1847, + duration_s: null, + turn: 3, + max_turns: 8, + }, + { + task_id: '01JBX5QPKR2MN8HW1FS6AY4M2', + status: 'AWAITING_APPROVAL', + repo: 'aws-samples/my-project', + created_at: minutesAgo(15), + task_description: 'Fix the failing unit tests in the auth module and update snapshots', + task_type: 'new_task', + pr_number: null, + issue_number: 38, + branch_name: 'agent/fix-auth-tests-38', + cost_usd: 0.3412, + duration_s: null, + turn: 5, + max_turns: 10, + }, + { + task_id: '01JBX3RTMK7QN2HW9FS4AY8P8', + status: 'COMPLETED', + repo: 'acme-corp/backend-api', + created_at: minutesAgo(62), + task_description: 'Refactor database connection pooling to use pgbouncer', + task_type: 'new_task', + pr_number: 156, + issue_number: 29, + branch_name: 'agent/refactor-db-pool-29', + cost_usd: 0.8923, + duration_s: 2847, + turn: 12, + max_turns: 15, + }, + { + task_id: '01JBX1WQNR3PG7HW5FS2AY6L4', + status: 'FAILED', + repo: 'acme-corp/frontend', + created_at: minutesAgo(120), + task_description: 'Migrate the dashboard from Class components to React hooks', + task_type: 'new_task', + pr_number: null, + issue_number: 55, + branch_name: 'agent/migrate-hooks-55', + cost_usd: 1.2345, + duration_s: 5400, + turn: 15, + max_turns: 15, + }, +]; + +// ─── Mock Events (for watch stream) ──────────────────────────────── + +let eventCounter = 0; +function eid(): string { + return `01JBX7EVT${String(++eventCounter).padStart(6, '0')}`; +} + +export const MOCK_EVENTS: TaskEvent[] = [ + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'task_started', + timestamp: minutesAgo(3), + metadata: {}, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'turn_start', + timestamp: minutesAgo(2.9), + metadata: { turn: 1 }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_call', + timestamp: minutesAgo(2.8), + metadata: { tool_name: 'ReadFile', args_preview: 'src/api/users.ts' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_result', + timestamp: minutesAgo(2.7), + metadata: { tool_name: 'ReadFile', status: 'success', preview: '1.2KB read' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_call', + timestamp: minutesAgo(2.5), + metadata: { tool_name: 'ReadFile', args_preview: 'package.json' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_result', + timestamp: minutesAgo(2.4), + metadata: { tool_name: 'ReadFile', status: 'success', preview: '0.8KB read' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'milestone', + timestamp: minutesAgo(2.3), + metadata: { message: 'Analyzed codebase structure. Found Express + TypeScript stack.' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'turn_start', + timestamp: minutesAgo(2.2), + metadata: { turn: 2 }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_call', + timestamp: minutesAgo(2.1), + metadata: { tool_name: 'Bash', args_preview: 'npm install zod' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'approval_requested', + timestamp: minutesAgo(2.1), + metadata: { + request_id: '01JBX7RRPK3QW9FM2JD6NX8B1T', + tool_name: 'Bash', + input_preview: 'npm install zod', + reason: 'Shell command execution requires approval', + severity: 'HIGH', + matching_rule_ids: ['bash_exec_gate'], + timeout_s: 600, + }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'approval_granted', + timestamp: minutesAgo(1.8), + metadata: { request_id: '01JBX7RRPK3QW9FM2JD6NX8B1T', scope: 'this_call' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_result', + timestamp: minutesAgo(1.6), + metadata: { tool_name: 'Bash', status: 'success', preview: 'added 1 package' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_call', + timestamp: minutesAgo(1.5), + metadata: { tool_name: 'EditFile', args_preview: 'src/api/users.ts' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'approval_requested', + timestamp: minutesAgo(1.5), + metadata: { + request_id: '01JBX7SSPK4RW0GM3KE7OY9C2U', + tool_name: 'EditFile', + input_preview: 'src/api/users.ts — Replace validation with zod schema', + reason: 'File modification requires approval (hard-gate: file_edit_gate)', + severity: 'MEDIUM', + matching_rule_ids: ['file_edit_gate'], + timeout_s: 600, + }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'cost_update', + timestamp: minutesAgo(1.4), + metadata: { total_usd: 0.1847, input_tokens: 12400, output_tokens: 3200 }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'turn_start', + timestamp: minutesAgo(1.0), + metadata: { turn: 3 }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_call', + timestamp: minutesAgo(0.8), + metadata: { tool_name: 'ReadFile', args_preview: 'src/api/middleware/validate.ts' }, + }, + { + event_id: eid(), + task_id: MOCK_TASKS[0].task_id, + event_type: 'tool_result', + timestamp: minutesAgo(0.7), + metadata: { tool_name: 'ReadFile', status: 'success', preview: '0.4KB read' }, + }, +]; + +// ─── Mock Pending Approvals ───────────────────────────────────────── + +export const MOCK_PENDING_APPROVALS: PendingApproval[] = [ + { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + request_id: '01JBX7SSPK4RW0GM3KE7OY9C2U', + tool_name: 'EditFile', + tool_input_preview: 'src/api/users.ts — Replace existing validation (lines 42-58) with zod schema: const userSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email() })', + reason: 'File modification requires approval (hard-gate: file_edit_gate)', + severity: 'MEDIUM', + matching_rule_ids: ['file_edit_gate'], + status: 'PENDING', + created_at: minutesAgo(1.5), + timeout_s: 600, + repo: 'aws-samples/my-project', + task_description: 'Add input validation to the /api/users endpoint using zod schemas', + }, + { + task_id: '01JBX5QPKR2MN8HW1FS6AY4M2', + request_id: '01JBX5TTPK5SW1HN4LF8PZ0D3V', + tool_name: 'Bash', + tool_input_preview: 'npm test -- --updateSnapshot', + reason: 'Shell command execution requires approval (hard-gate: bash_exec_gate)', + severity: 'HIGH', + matching_rule_ids: ['bash_exec_gate'], + status: 'PENDING', + created_at: minutesAgo(0.5), + timeout_s: 600, + repo: 'aws-samples/my-project', + task_description: 'Fix the failing unit tests in the auth module and update snapshots', + }, +]; + +// ─── Mock Upcoming Events (for watch simulation) ──────────────────── + +/** + * Returns events one at a time to simulate a live watch stream. + * Each call returns the next event or null if exhausted. + */ +export class MockEventStream { + private queue: TaskEvent[]; + private index = 0; + + constructor(taskId: string) { + this.queue = MOCK_EVENTS.filter(e => e.task_id === taskId); + } + + /** Get next batch of events (simulates polling) */ + poll(afterIndex: number): TaskEvent[] { + // Return 1-3 events at a time to simulate realistic polling + const batch = this.queue.slice(afterIndex, afterIndex + Math.ceil(Math.random() * 3)); + return batch; + } + + get totalEvents(): number { + return this.queue.length; + } +} + +// ─── Mock API Functions ───────────────────────────────────────────── + +export async function fetchTasks(): Promise { + // Simulate API latency + await new Promise(r => setTimeout(r, 200)); + return MOCK_TASKS; +} + +export async function fetchTask(taskId: string): Promise { + await new Promise(r => setTimeout(r, 150)); + return MOCK_TASKS.find(t => t.task_id === taskId); +} + +export async function fetchPendingApprovals(): Promise { + await new Promise(r => setTimeout(r, 200)); + return MOCK_PENDING_APPROVALS; +} + +export async function approveRequest( + taskId: string, requestId: string, scope?: string, +): Promise<{ success: boolean; message: string }> { + await new Promise(r => setTimeout(r, 300)); + return { + success: true, + message: `Approved ${requestId.slice(-6)} for task ${taskId.slice(-4)}` + + (scope ? ` (scope: ${scope})` : ''), + }; +} + +export async function denyRequest( + taskId: string, requestId: string, reason: string, +): Promise<{ success: boolean; message: string }> { + await new Promise(r => setTimeout(r, 300)); + return { + success: true, + message: `Denied ${requestId.slice(-6)} for task ${taskId.slice(-4)}: ${reason}`, + }; +} + +// ─── Mock Cedar Policies ──────────────────────────────────────────── + +export const MOCK_POLICIES: CedarPolicy[] = [ + { + rule_id: 'rm_slash', + tier: 'hard-deny', + description: 'Block rm -rf / and variants', + action: 'execute_bash', + condition_summary: 'command matches *rm -rf /*', + category: 'destructive', + cedar_source: '@tier("hard-deny")\n@rule_id("rm_slash")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*rm -rf /*" };', + }, + { + rule_id: 'write_git_internals', + tier: 'hard-deny', + description: 'Block writes to .git/ directory', + action: 'write_file', + condition_summary: 'file_path matches .git/*', + category: 'filesystem', + cedar_source: '@tier("hard-deny")\n@rule_id("write_git_internals")\nforbid (principal, action == Agent::Action::"write_file", resource)\n when { context.file_path like ".git/*" };', + }, + { + rule_id: 'drop_table', + tier: 'hard-deny', + description: 'Block DROP TABLE commands', + action: 'execute_bash', + condition_summary: 'command matches *DROP TABLE*', + category: 'destructive', + cedar_source: '@tier("hard-deny")\n@rule_id("drop_table")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*DROP TABLE*" };', + }, + { + rule_id: 'force_push_main', + tier: 'hard-deny', + description: 'Block force-push to main/prod branches', + action: 'execute_bash', + condition_summary: 'command matches *git push --force origin main*', + severity: 'high', + category: 'destructive', + cedar_source: '@tier("hard-deny")\n@rule_id("force_push_main")\n@severity("high")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*git push --force origin main*" };', + }, + { + rule_id: 'bash_exec_gate', + tier: 'hard-gate', + description: 'Shell command execution requires approval', + action: 'execute_bash', + condition_summary: 'all bash commands (catch-all)', + severity: 'high', + category: 'auth', + approval_timeout_s: 600, + cedar_source: '@tier("hard-gate")\n@rule_id("bash_exec_gate")\n@severity("high")\n@approval_timeout_s("600")\nforbid (principal, action == Agent::Action::"execute_bash", resource);', + }, + { + rule_id: 'file_edit_gate', + tier: 'hard-gate', + description: 'File modifications require approval', + action: 'write_file', + condition_summary: 'all file writes and edits', + severity: 'medium', + category: 'filesystem', + approval_timeout_s: 600, + cedar_source: '@tier("hard-gate")\n@rule_id("file_edit_gate")\n@severity("medium")\n@approval_timeout_s("600")\nforbid (principal, action == Agent::Action::"write_file", resource);', + }, + { + rule_id: 'deploy_staging', + tier: 'hard-gate', + description: 'Terraform/CDK deploy requires approval', + action: 'execute_bash', + condition_summary: 'command matches *terraform apply* or *cdk deploy*', + severity: 'high', + category: 'destructive', + approval_timeout_s: 900, + cedar_source: '@tier("hard-gate")\n@rule_id("deploy_staging")\n@severity("high")\n@approval_timeout_s("900")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*terraform apply*" };', + }, + { + rule_id: 'npm_install_gate', + tier: 'hard-gate', + description: 'Package installation requires approval', + action: 'execute_bash', + condition_summary: 'command matches *npm install* or *yarn add*', + severity: 'medium', + category: 'auth', + approval_timeout_s: 300, + cedar_source: '@tier("hard-gate")\n@rule_id("npm_install_gate")\n@severity("medium")\n@approval_timeout_s("300")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*npm install*" };', + }, +]; + +// ─── Submit Task Mock ─────────────────────────────────────────────── + +export function submitTask(repo: string, description: string): TaskSummary { + const id = '01JBX9' + Math.random().toString(36).slice(2, 8).toUpperCase(); + const task: TaskSummary = { + task_id: id, + status: 'SUBMITTED', + repo, + created_at: new Date().toISOString(), + task_description: description, + task_type: 'new_task', + pr_number: null, + issue_number: null, + branch_name: `agent/${id.slice(-6).toLowerCase()}`, + cost_usd: null, + duration_s: null, + turn: 0, + max_turns: 8, + }; + MOCK_TASKS.push(task); + return task; +} diff --git a/cli/src/tui/App.tsx b/cli/src/tui/App.tsx new file mode 100644 index 00000000..fcccd8c2 --- /dev/null +++ b/cli/src/tui/App.tsx @@ -0,0 +1,161 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * bgagent TUI — Tier 3 Full-screen tabbed application + * + * Splash: full-size Peccy for 2.5s, then switches to mini Peccy on all tabs. + */ +import { Box, Text, useInput, useApp } from 'ink'; +import React, { useState, useCallback, useEffect } from 'react'; +import HelpBar from './components/HelpBar.js'; +import PeccyIcon from './components/PeccyIcon.js'; +import TabBar, { type PanelId } from './components/TabBar.js'; +import { useEditing, useApprovals } from './context.js'; +import { useData } from './hooks/useData.js'; +import Approvals from './panels/Approvals.js'; +import Policies from './panels/Policies.js'; +import Submit from './panels/Submit.js'; +import TaskList from './panels/TaskList.js'; +import Watch from './panels/Watch.js'; + +const PANELS: PanelId[] = ['tasks', 'watch', 'approvals', 'policies', 'submit']; +const SPLASH_DURATION = 2500; // ms + +const App: React.FC = () => { + const { exit } = useApp(); + const { isEditing, editMode } = useEditing(); + const { approvals } = useApprovals(); + const { snapshot } = useData(); + const [splash, setSplash] = useState(true); + const [ready, setReady] = useState(false); + const [panel, setPanel] = useState('tasks'); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [approvalsInDetail, setApprovalsInDetail] = useState(false); + const tasks = snapshot.tasks; + + // Splash timer — any keypress also dismisses it + useEffect(() => { + const timer = globalThis.setTimeout(() => setSplash(false), SPLASH_DURATION); + return () => clearTimeout(timer); + }, []); + + // Clear screen when transitioning from splash to main to prevent flicker + useEffect(() => { + if (!splash && !ready) { + process.stdout.write('\x1b[2J\x1b[H'); // clear screen + move cursor home + setReady(true); + } + }, [splash, ready]); + + const selectedTask = selectedTaskId + ? snapshot.tasks.find(t => t.task_id === selectedTaskId) + : undefined; + + const hasApproval = panel === 'watch' && selectedTask && + approvals.some(a => a.task_id === selectedTask.task_id); + + useInput(useCallback((input, key) => { + // Any key dismisses splash + if (splash) { setSplash(false); return; } + + if (isEditing) return; + if (input === 'q' && panel !== 'submit') { exit(); return; } + + const panelMap: Record = { + 1: 'tasks', 2: 'watch', 3: 'approvals', 4: 'policies', 5: 'submit', + }; + if (panelMap[input] && panel !== panelMap[input]) { setPanel(panelMap[input]); return; } + if (key.tab && !key.shift) { setPanel(PANELS[(PANELS.indexOf(panel) + 1) % PANELS.length]); return; } + if (key.tab && key.shift) { setPanel(PANELS[(PANELS.indexOf(panel) - 1 + PANELS.length) % PANELS.length]); return; } + }, [panel, isEditing, splash, exit])); + + const handleSelectTask = useCallback((taskId: string) => { + setSelectedTaskId(taskId); + setPanel('watch'); + }, []); + + const handleBack = useCallback(() => setPanel('tasks'), []); + + const handleSubmitted = useCallback((taskId: string) => { + setSelectedTaskId(taskId); + setPanel('watch'); + }, []); + + const handleDetailChange = useCallback((inDetail: boolean) => setApprovalsInDetail(inDetail), []); + + // ── Splash screen ── + if (splash) { + return ( + + + + Autonomous Cloud Coding Agents + + Press any key to continue... + + ); + } + + // Brief clear frame between splash and main + if (!ready) return ; + + // ── Main TUI ── + return ( + + + + {panel === 'tasks' && } + {panel === 'watch' && selectedTask && } + {panel === 'watch' && !selectedTask && ( + + Watch + + No task selected. Press 1 to go to Tasks, then Enter to watch one. + + )} + {panel === 'approvals' && } + {panel === 'policies' && } + {panel === 'submit' && } + + {/* Provider error banner — surfaces rate-limit and other + DataProvider failures to the user rather than swallowing + them silently. Phase A live drive caught the case where a + 429 on /v1/pending was invisible to the TUI; the user kept + opening Approvals expecting fresh data while the provider + was actually getting throttled. */} + {snapshot.error && ( + + + {snapshot.rateLimited ? '⚠ ' : '✗ '} + {snapshot.error} + + {snapshot.rateLimited && ( + + {' '}— next retry in ~{Math.round(snapshot.pollIntervalMs / 1000)}s + + )} + + )} + + + ); +}; + +export default App; diff --git a/cli/src/tui/api/source-mock.ts b/cli/src/tui/api/source-mock.ts new file mode 100644 index 00000000..54d103f9 --- /dev/null +++ b/cli/src/tui/api/source-mock.ts @@ -0,0 +1,175 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Mock `DataSource` — reads synchronously from `./mock/data.ts` + * fixtures and resolves Promises immediately. Used by default so + * `npm run tui` keeps working without a deployed backend. + */ + +import type { ApprovalScope, TaskEvent } from '../../types.js'; +import type { + PendingApprovalView, + PolicyRuleView, + RegisteredRepoView, + TaskRowView, +} from '../data.js'; +import type { DataSource, SubmitTaskInput } from './source.js'; +import { + MOCK_EVENTS, + MOCK_PENDING_APPROVALS, + MOCK_POLICIES_HARD, + MOCK_POLICIES_SOFT, + MOCK_REPOS, + MOCK_TASKS, + submitMockTask, + type CedarPolicyFixture, + type PendingApprovalFixture, + type RegisteredRepoFixture, + type TaskFixture, +} from '../mock/data.js'; + +function normalizeSeverity(s: string | undefined): 'HIGH' | 'MEDIUM' | 'LOW' { + const upper = String(s ?? 'MEDIUM').toUpperCase(); + if (upper === 'HIGH' || upper === 'MEDIUM' || upper === 'LOW') return upper; + return 'MEDIUM'; +} + +function toTaskRowView(t: TaskFixture): TaskRowView { + return { + task_id: t.task_id, + status: t.status, + repo: t.repo, + issue_number: t.issue_number, + task_type: t.task_type, + pr_number: t.pr_number, + task_description: t.task_description ?? '', + branch_name: t.branch_name, + pr_url: t.pr_url, + // Pass through the fixture's channel_source (varied across mock + // tasks so the SOURCE column demo shows all four values). + // Default to 'api' for pre-ChannelSource fixtures. + channel_source: t.channel_source ?? 'api', + created_at: t.created_at, + updated_at: t.updated_at, + cost_usd: t.cost_usd, + duration_s: t.duration_s, + max_turns: t.max_turns, + turns_attempted: t.turns_attempted, + turns_completed: t.turns_completed, + turn: t.turns_completed ?? t.turns_attempted ?? null, + approval_gate_count: t.approval_gate_count, + approval_gate_cap: t.approval_gate_cap, + awaiting_approval_request_id: t.awaiting_approval_request_id, + }; +} + +function toPendingApprovalView(p: PendingApprovalFixture): PendingApprovalView { + return { + task_id: p.task_id, + request_id: p.request_id, + tool_name: p.tool_name, + tool_input_preview: p.tool_input_preview, + severity: normalizeSeverity(p.severity), + reason: p.reason, + created_at: p.created_at, + timeout_s: p.timeout_s, + expires_at: p.expires_at, + matching_rule_ids: p.matching_rule_ids, + repo: p.repo, + task_description: p.task_description, + }; +} + +function toPolicyRuleView(r: CedarPolicyFixture): PolicyRuleView { + return { + rule_id: r.rule_id, + tier: r.tier, + summary: r.summary, + severity: r.severity, + category: r.category, + approval_timeout_s: r.approval_timeout_s, + action: r.action, + condition_summary: r.condition_summary, + cedar_source: r.cedar_source, + }; +} + +function toRegisteredRepoView(r: RegisteredRepoFixture): RegisteredRepoView { + return { repo: r.repo, default_branch: r.default_branch }; +} + +export class MockDataSource implements DataSource { + readonly label = 'mock' as const; + + async listTasks(): Promise { + return MOCK_TASKS.map(toTaskRowView); + } + + async getTaskEvents(taskId: string, opts?: { after?: string }): Promise { + const all = MOCK_EVENTS.filter(e => e.metadata.task_id === taskId); + if (opts?.after) { + // event_ids in the mock fixture are lexicographic ULIDs; simple + // string compare matches the real server's ordering. + return all.filter(e => e.event_id > opts.after!); + } + return all; + } + + async listPending(): Promise { + return MOCK_PENDING_APPROVALS.map(toPendingApprovalView); + } + + async listPolicies(_repoId: string): Promise<{ + hard: PolicyRuleView[]; + soft: PolicyRuleView[]; + }> { + // Mock returns the same policy set regardless of repo — real API + // returns repo-specific bundles. + void _repoId; + return { + hard: MOCK_POLICIES_HARD.map(toPolicyRuleView), + soft: MOCK_POLICIES_SOFT.map(toPolicyRuleView), + }; + } + + async listRegisteredRepos(): Promise { + return MOCK_REPOS.filter(r => r.status === 'active').map(toRegisteredRepoView); + } + + async submitTask(input: SubmitTaskInput): Promise { + return toTaskRowView( + submitMockTask(input.repo, input.task_description, { + approval_timeout_s: input.approval_timeout_s, + initial_approvals: input.initial_approvals, + }), + ); + } + + async approve(_taskId: string, _requestId: string, _scope?: ApprovalScope): Promise { + void _taskId; void _requestId; void _scope; + // Mock: the TuiProvider clears the approval from in-memory state + // in its `approve`/`deny` callbacks. This method exists to satisfy + // the interface contract for the real source. + } + + async deny(_taskId: string, _requestId: string, _reason?: string): Promise { + void _taskId; void _requestId; void _reason; + } +} diff --git a/cli/src/tui/api/source-real.ts b/cli/src/tui/api/source-real.ts new file mode 100644 index 00000000..ef4a447b --- /dev/null +++ b/cli/src/tui/api/source-real.ts @@ -0,0 +1,266 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Real `DataSource` — wraps `ApiClient` from the rest of the CLI. + * + * Hydration strategy: + * + * - `listTasks()` hits `GET /v1/tasks`, then enriches each row with + * the fields the TUI needs from `TaskDetail` (cost, turn counters, + * approval-gate counters). This requires one `getTask` per row — + * acceptable at TUI list sizes (typically < 50 tasks) and keeps + * us using the public contract without plumbing a specialized + * list endpoint. + * + * - `listPending()` hits `GET /v1/pending`, then joins against the + * already-cached task list to add `repo` + `task_description` so + * the Approvals panel can render without an extra round-trip per + * approval. + * + * - `listPolicies(repoId)` hits `GET /v1/repos/{id}/policies`. + * Severity is server-lowercase and PolicyRuleView keeps it that + * way; rendering uppercases on display. + * + * - `listRegisteredRepos()` — there is no public list-repos endpoint + * yet. We derive the set of "known repos" from the user's own task + * list (dedup by repo string). This is adequate for the Submit + * panel's picker; if we later ship a `/v1/repos` endpoint, plug + * it here. + */ + +import { ApiClient } from '../../api-client.js'; +import type { + ApprovalScope, + TaskDetail, + TaskEvent, + TaskSummary, +} from '../../types.js'; +import type { + PendingApprovalView, + PolicyRuleView, + RegisteredRepoView, + TaskRowView, +} from '../data.js'; +import { enrichPendingApproval, type DataSource, type SubmitTaskInput } from './source.js'; + +function toTaskRowView(t: TaskDetail): TaskRowView { + return { + task_id: t.task_id, + status: t.status, + repo: t.repo, + issue_number: t.issue_number, + task_type: t.task_type, + pr_number: t.pr_number, + task_description: t.task_description ?? '', + branch_name: t.branch_name, + pr_url: t.pr_url, + channel_source: t.channel_source, + created_at: t.created_at, + updated_at: t.updated_at, + cost_usd: t.cost_usd, + duration_s: t.duration_s, + max_turns: t.max_turns, + turns_attempted: t.turns_attempted, + turns_completed: t.turns_completed, + turn: t.turns_completed ?? t.turns_attempted ?? null, + approval_gate_count: t.approval_gate_count, + approval_gate_cap: t.approval_gate_cap, + awaiting_approval_request_id: t.awaiting_approval_request_id, + }; +} + +/** Build a light row view from a TaskSummary alone, before the + * full TaskDetail has been hydrated. Fields `TaskSummary` does not + * carry default to null so the UI can flag "loading". */ +function toTaskRowFromSummary(s: TaskSummary): TaskRowView { + return { + task_id: s.task_id, + status: s.status, + repo: s.repo, + issue_number: s.issue_number, + task_type: s.task_type, + pr_number: s.pr_number, + task_description: s.task_description ?? '', + branch_name: s.branch_name, + pr_url: s.pr_url, + // TaskSummary does not carry channel_source. Leave undefined so + // the row renders a "—" until the detail hydration populates it. + channel_source: undefined, + created_at: s.created_at, + updated_at: s.updated_at, + cost_usd: null, + duration_s: null, + max_turns: null, + turns_attempted: null, + turns_completed: null, + turn: null, + approval_gate_count: null, + approval_gate_cap: null, + awaiting_approval_request_id: null, + }; +} + +export class RealDataSource implements DataSource { + readonly label = 'live' as const; + private readonly client: ApiClient; + /** Cached task rows from the most recent `listTasks`, used to hydrate + * `listPending` joins without a second round-trip per approval. */ + private lastTasks: TaskRowView[] = []; + + constructor(client?: ApiClient) { + this.client = client ?? new ApiClient(); + } + + async listTasks(): Promise { + // Drain the first page (keeps this interactive — infinite + // pagination is a Phase 4 follow-up). The `ApiClient.listTasks` + // takes `limit` but the server default + 50 is a reasonable + // upper bound for interactive review. + const page = await this.client.listTasks({ limit: 50 }); + // Hydrate each summary into a full detail so the TUI has the + // approval-gate counters and turn counters. In practice the user + // is watching a handful of active tasks; this keeps the contract + // simple without a specialized endpoint. + const detailed = await Promise.all( + page.data.map(async (s) => { + try { + const detail = await this.client.getTask(s.task_id); + return toTaskRowView(detail); + } catch { + // Partial failure — fall back to the summary-only view + // rather than blanking out the whole list on a single + // getTask error. + return toTaskRowFromSummary(s); + } + }), + ); + this.lastTasks = detailed; + return detailed; + } + + async getTaskEvents(taskId: string, opts?: { after?: string }): Promise { + // Cursor provided → incremental catch-up (mirrors bgagent watch). + // Drains all pages past the cursor so the TUI sees everything the + // agent emitted since the last poll — critical for long-running + // tasks where the tail (pr_created / task_completed) can be far + // past the first 100 events. + if (opts?.after) { + return this.client.catchUpEvents(taskId, opts.after, 100); + } + // No cursor → initial load. Page through the whole stream so the + // user opening Watch on an existing task sees the full history, + // not just the first 100 events. + const collected: TaskEvent[] = []; + let page = await this.client.getTaskEvents(taskId, { limit: 100 }); + collected.push(...page.data); + while (page.pagination.has_more && page.pagination.next_token) { + page = await this.client.getTaskEvents(taskId, { + nextToken: page.pagination.next_token, + limit: 100, + }); + collected.push(...page.data); + } + return collected; + } + + async listPending(): Promise { + const { pending } = await this.client.listPending(); + // Build the repo+description maps from the cached task list. + // If a pending approval references a task we haven't loaded yet + // (rare — requires a race), `enrichPendingApproval` falls back to + // "(unknown)" so the list still renders. + const repoByTaskId = new Map(); + const descByTaskId = new Map(); + for (const t of this.lastTasks) { + repoByTaskId.set(t.task_id, t.repo); + descByTaskId.set(t.task_id, t.task_description); + } + return pending.map((p) => enrichPendingApproval(p, repoByTaskId, descByTaskId)); + } + + async listPolicies(repoId: string): Promise<{ + hard: PolicyRuleView[]; + soft: PolicyRuleView[]; + }> { + if (!repoId) { + return { hard: [], soft: [] }; + } + const resp = await this.client.listPolicies(repoId); + const toView = (r: typeof resp.policies.hard[number], tier: 'hard' | 'soft'): PolicyRuleView => ({ + rule_id: r.rule_id, + tier, + summary: r.summary, + severity: r.severity, + category: r.category, + approval_timeout_s: r.approval_timeout_s, + // action / condition_summary / cedar_source are mock-only. + }); + return { + hard: resp.policies.hard.map((r) => toView(r, 'hard')), + soft: resp.policies.soft.map((r) => toView(r, 'soft')), + }; + } + + async listRegisteredRepos(): Promise { + // No dedicated list-repos endpoint. Derive from the cached task + // list, deduping by repo string. Empty list until `listTasks` + // has run at least once — the DataProvider always runs both. + const seen = new Set(); + const repos: RegisteredRepoView[] = []; + for (const t of this.lastTasks) { + if (!seen.has(t.repo)) { + seen.add(t.repo); + // `default_branch` is not on TaskDetail; we don't have it, + // so show an honest placeholder. The Submit panel's picker + // just needs the owner/repo string. + repos.push({ repo: t.repo, default_branch: '(unknown)' }); + } + } + return repos; + } + + async submitTask(input: SubmitTaskInput): Promise { + const detail = await this.client.createTask({ + repo: input.repo, + ...(input.task_description && { task_description: input.task_description }), + ...(input.issue_number !== undefined && { issue_number: input.issue_number }), + ...(input.pr_number !== undefined && { pr_number: input.pr_number }), + ...(input.task_type && { task_type: input.task_type }), + ...(input.max_turns !== undefined && { max_turns: input.max_turns }), + ...(input.max_budget_usd !== undefined && { max_budget_usd: input.max_budget_usd }), + ...(input.approval_timeout_s !== undefined && { approval_timeout_s: input.approval_timeout_s }), + ...(input.initial_approvals && input.initial_approvals.length > 0 && { + initial_approvals: input.initial_approvals, + }), + ...(input.attachments && input.attachments.length > 0 && { + attachments: input.attachments, + }), + }); + return toTaskRowView(detail); + } + + async approve(taskId: string, requestId: string, scope?: ApprovalScope): Promise { + await this.client.approveTask(taskId, requestId, scope); + } + + async deny(taskId: string, requestId: string, reason?: string): Promise { + await this.client.denyTask(taskId, requestId, reason); + } +} diff --git a/cli/src/tui/api/source.ts b/cli/src/tui/api/source.ts new file mode 100644 index 00000000..c1938840 --- /dev/null +++ b/cli/src/tui/api/source.ts @@ -0,0 +1,121 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Abstract data source for the TUI. + * + * The TUI has historically used synchronous `get*` functions + * (mock-backed). To support a real backend without rewriting every + * panel to `useEffect + async`, the `DataProvider` hydrates a + * source asynchronously and the panels read cached snapshots off a + * React context. Real mode re-hydrates on a polling interval; + * mock mode resolves immediately from fixtures. + */ + +import type { + ApprovalScope, + Attachment, + PendingApprovalSummary, + TaskEvent, +} from '../../types.js'; +import type { + PendingApprovalView, + PolicyRuleView, + RegisteredRepoView, + TaskRowView, +} from '../data.js'; + +/** Initial approvals passed through to the submit path. */ +export interface SubmitTaskInput { + readonly repo: string; + readonly task_description: string; + readonly issue_number?: number; + readonly pr_number?: number; + readonly task_type?: 'new_task' | 'pr_iteration' | 'pr_review'; + readonly max_turns?: number; + readonly max_budget_usd?: number; + readonly approval_timeout_s?: number; + readonly initial_approvals?: readonly ApprovalScope[]; + /** Optional attachments forwarded to the create-task endpoint. + * Mirrors `CreateTaskRequest.attachments`; the TUI populates this + * from clipboard image paste. */ + readonly attachments?: readonly Attachment[]; +} + +/** A source of TUI data — either mock or real. Query methods return + * the viewmodel shapes panels bind to; mutations return the updated + * resource (or a stub in mock mode). */ +export interface DataSource { + /** Human-readable label — used by the TUI to show mock-mode banner. */ + readonly label: 'mock' | 'live'; + + listTasks(): Promise; + /** + * Fetch task events, optionally starting after a known cursor. + * + * When `after` is omitted, returns all events for the task (the + * real source drains pagination via `next_token`; mock returns + * the full fixture). When `after` is passed, returns only events + * strictly greater than that `event_id` — mirrors + * `ApiClient.catchUpEvents` so the TUI can incrementally catch + * up long streams without re-fetching history it has already + * rendered. + */ + getTaskEvents(taskId: string, opts?: { after?: string }): Promise; + listPending(): Promise; + /** Returns empty `{hard:[], soft:[]}` when the caller has not picked + * a specific repo; the Policies panel surfaces a picker. */ + listPolicies(repoId: string): Promise<{ + hard: PolicyRuleView[]; + soft: PolicyRuleView[]; + }>; + listRegisteredRepos(): Promise; + + submitTask(input: SubmitTaskInput): Promise; + approve(taskId: string, requestId: string, scope?: ApprovalScope): Promise; + deny(taskId: string, requestId: string, reason?: string): Promise; +} + +/** Enrich a `PendingApprovalSummary` (wire shape) with `repo` + + * `task_description` drawn from the parent TaskDetail index. Used by + * the real data source where those fields aren't on the pending list + * response (the API intentionally keeps `/v1/pending` small). */ +export function enrichPendingApproval( + p: PendingApprovalSummary, + repoByTaskId: Map, + descByTaskId: Map, +): PendingApprovalView { + const upper = p.severity.toUpperCase(); + const severity: 'HIGH' | 'MEDIUM' | 'LOW' = + upper === 'HIGH' || upper === 'MEDIUM' || upper === 'LOW' ? upper : 'MEDIUM'; + return { + task_id: p.task_id, + request_id: p.request_id, + tool_name: p.tool_name, + tool_input_preview: p.tool_input_preview, + severity, + reason: p.reason, + created_at: p.created_at, + timeout_s: p.timeout_s, + expires_at: p.expires_at, + matching_rule_ids: p.matching_rule_ids, + repo: repoByTaskId.get(p.task_id) ?? '(unknown)', + task_description: descByTaskId.get(p.task_id) ?? '', + }; +} diff --git a/cli/src/tui/components/ApprovalCard.tsx b/cli/src/tui/components/ApprovalCard.tsx new file mode 100644 index 00000000..6d6d1138 --- /dev/null +++ b/cli/src/tui/components/ApprovalCard.tsx @@ -0,0 +1,102 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text } from 'ink'; +import React from 'react'; +import { SEVERITY_COLOR, SEVERITY_LABEL, trunc, fmtDuration } from '../constants.js'; +import { TRUNC_TOOL_INPUT, TRUNC_REASON, TRUNC_DESCRIPTION, type TaskEvent } from '../data.js'; + +interface ApprovalCardProps { + event: TaskEvent; + taskDescription?: string; + repo?: string; + timeoutRemaining?: number; +} + +/** Narrow a `Record` metadata field to string for + * display. Wire `TaskEvent.metadata` is typed `unknown` per value so + * the agent side can evolve without breaking the consumer; the TUI + * always just wants a string for preview. */ +function mstr(m: Record, key: string, fallback = ''): string { + const v = m[key]; + return typeof v === 'string' ? v : fallback; +} + +function mstrlist(m: Record, key: string): readonly string[] { + const v = m[key]; + if (!Array.isArray(v)) return []; + return v.filter((x): x is string => typeof x === 'string'); +} + +const ApprovalCard: React.FC = ({ event, taskDescription, repo, timeoutRemaining }) => { + const m = event.metadata; + const sev = mstr(m, 'severity', 'MEDIUM').toUpperCase(); + const sevColor = SEVERITY_COLOR[sev] ?? 'yellow'; + const sevLabel = SEVERITY_LABEL[sev] ?? sev; + const timeColor = timeoutRemaining != null + ? (timeoutRemaining <= 120 ? 'red' : timeoutRemaining <= 300 ? 'yellow' : undefined) + : undefined; + + return ( + + + {figures.warning} Approval needed + {sevLabel} + + + {(repo || taskDescription) && ( + Task: {repo ?? ''}{taskDescription ? ` — ${trunc(taskDescription, TRUNC_DESCRIPTION)}` : ''} + )} + + + + Wants to: + {mstr(m, 'tool_name')} + {figures.arrowRight} + {trunc(mstr(m, 'input_preview'), TRUNC_TOOL_INPUT)} + + + Why: + {trunc(mstr(m, 'reason'), TRUNC_REASON)} + + {mstrlist(m, 'matching_rule_ids').length > 0 && ( + + Triggered: + {mstrlist(m, 'matching_rule_ids').join(', ')} + + )} + {timeoutRemaining != null && ( + + Timeout: + {fmtDuration(timeoutRemaining)} + {timeoutRemaining <= 120 && {figures.warning}} + + )} + + + [a] Approve + [d] Deny + 3 for full detail + + + ); +}; + +export default ApprovalCard; diff --git a/cli/src/tui/components/DenyReasonInput.tsx b/cli/src/tui/components/DenyReasonInput.tsx new file mode 100644 index 00000000..25fbcf1e --- /dev/null +++ b/cli/src/tui/components/DenyReasonInput.tsx @@ -0,0 +1,69 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Text input for the deny reason. Caps at `DENY_REASON_MAX_LENGTH` + * (the same limit the server enforces server-side). Returns the + * trimmed reason on Enter, or empty string on Enter with no input. + */ + +import figures from 'figures'; +import { Box, Text, useInput } from 'ink'; +import React, { useState, useCallback } from 'react'; +import { DENY_REASON_MAX_LENGTH } from '../../types.js'; + +interface DenyReasonInputProps { + onConfirm: (reason: string) => void; + onCancel: () => void; +} + +const DenyReasonInput: React.FC = ({ onConfirm, onCancel }) => { + const [text, setText] = useState(''); + + useInput(useCallback((input, key) => { + if (key.escape) { onCancel(); return; } + if (key.return) { onConfirm(text.trim()); return; } + if (key.backspace || key.delete) { setText(p => p.slice(0, -1)); return; } + if (input && !key.ctrl && !key.meta) { + // Silently cap at the server limit — pasted walls of text + // get truncated instead of triggering a scary error. + const next = text + input; + setText(next.length > DENY_REASON_MAX_LENGTH ? next.slice(0, DENY_REASON_MAX_LENGTH) : next); + } + }, [text, onConfirm, onCancel])); + + const near = text.length >= DENY_REASON_MAX_LENGTH - 100; + + return ( + + {figures.cross} Deny — optional reason (Enter to send, Esc cancel) + Reason is sanitized + truncated server-side; blank is accepted. + + {figures.pointer} + {text ? {text} : (empty — agent gets denial with no note)} + | + + + {text.length}/{DENY_REASON_MAX_LENGTH} + + + ); +}; + +export default DenyReasonInput; diff --git a/cli/src/tui/components/ErrorBoundary.tsx b/cli/src/tui/components/ErrorBoundary.tsx new file mode 100644 index 00000000..8cf13be9 --- /dev/null +++ b/cli/src/tui/components/ErrorBoundary.tsx @@ -0,0 +1,51 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text } from 'ink'; +import React from 'react'; + +interface Props { children: React.ReactNode } +interface State { error: Error | null } + +class ErrorBoundary extends React.Component { + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + state: State = { error: null }; + + render() { + if (this.state.error) { + return ( + + {figures.cross} Something went wrong + + {this.state.error.message} + {this.state.error.stack?.split('\n').slice(1, 4).join('\n')} + + Press Ctrl+C to exit, then restart with: npm run tui + + ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/cli/src/tui/components/EventLine.tsx b/cli/src/tui/components/EventLine.tsx new file mode 100644 index 00000000..72914266 --- /dev/null +++ b/cli/src/tui/components/EventLine.tsx @@ -0,0 +1,211 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Box, Text } from 'ink'; +import React from 'react'; +import { formatMilestone } from '../../format-milestones.js'; +import { EVENT_COLOR, EVENT_ICON, MILESTONE_COLOR, MILESTONE_ICON, trunc } from '../constants.js'; +import { TRUNC_TOOL_INPUT, type TaskEvent } from '../data.js'; + +/** Narrow a `Record` metadata field to string for + * display. See `ApprovalCard.tsx` for rationale. */ +function mstr(m: Record, key: string, fallback = ''): string { + const v = m[key]; + return typeof v === 'string' ? v : fallback; +} + +function mnum(m: Record, key: string): number | null { + const v = m[key]; + return typeof v === 'number' ? v : null; +} + +function mbool(m: Record, key: string): boolean { + return m[key] === true; +} + +// Cedar HITL milestone formatter is shared with `commands/watch.ts` +// — see `cli/src/format-milestones.ts`. Single source of truth so +// the TUI Watch panel and the plain CLI `bgagent watch` never drift +// on user-visible payloads (`approval_timeout_capped`, etc.). + +/** + * Format a task event for the Watch stream. + * + * Handles both the real agent-side vocabulary emitted by + * `agent/src/progress_writer.py` (``agent_turn``, ``agent_tool_call``, + * ``agent_tool_result``, ``agent_milestone``, ``agent_cost_update``, + * ``agent_error``) and the simpler fixture names used by the TUI + * mock (``turn_start`` / ``tool_call`` / ``tool_result`` / ``milestone`` + * / ``cost_update``). Keeping both in one switch lets the mock demo + * stay pretty while the real stream renders correctly. + * + * Mirrors the formatting logic in ``cli/src/commands/watch.ts::renderEvent`` + * so the TUI and the non-TUI ``bgagent watch`` command surface the + * same information. + */ +function fmt(e: TaskEvent): string { + const m = e.metadata; + switch (e.event_type) { + // ── Lifecycle ───────────────────────────────────────────── + case 'task_started': + return 'Task started'; + case 'task_complete': + case 'task_completed': + return 'Task completed'; + case 'task_failed': + return 'Task failed'; + + // ── Agent runtime (real) ────────────────────────────────── + case 'agent_turn': { + const turn = mnum(m, 'turn') ?? '?'; + const model = mstr(m, 'model'); + const tools = mnum(m, 'tool_calls_count') ?? 0; + const parts = [`Step ${turn}`]; + if (model) parts.push(model); + parts.push(`${tools} tool call${tools === 1 ? '' : 's'}`); + return `${parts.join(' ')} ${'─'.repeat(20)}`; + } + case 'agent_tool_call': + return `${mstr(m, 'tool_name')} ${trunc(mstr(m, 'tool_input_preview'), TRUNC_TOOL_INPUT)}`; + case 'agent_tool_result': { + const tool = mstr(m, 'tool_name'); + const status = mbool(m, 'is_error') ? 'error' : 'success'; + const preview = mstr(m, 'content_preview'); + return `${tool} → ${status} ${preview ? `(${trunc(preview, 30)})` : ''}`; + } + case 'agent_milestone': { + // Approval milestones arrive as `agent_milestone` with a + // `milestone` sub-type (§11.1). Surface that subtype explicitly + // so approval-related events stand out in the stream — the + // dedicated formatter below handles every name in §11.1; falling + // back to the generic sub:details rendering if it returns null + // (covers any future milestone the formatter hasn't been + // taught about yet). + const formatted = formatMilestone(m); + if (formatted !== null) return formatted; + const sub = mstr(m, 'milestone'); + const details = mstr(m, 'details'); + if (sub) return details ? `${sub}: ${details}` : sub; + return details || 'Milestone'; + } + case 'agent_cost_update': { + const cost = mnum(m, 'cost_usd'); + const input = mnum(m, 'input_tokens') ?? 0; + const output = mnum(m, 'output_tokens') ?? 0; + const dollars = cost != null ? `$${cost.toFixed(4)}` : '$?'; + return `Cost: ${dollars} (${input} in / ${output} out)`; + } + case 'agent_error': { + const errType = mstr(m, 'error_type', 'Error'); + const msg = mstr(m, 'message_preview'); + return msg ? `${errType}: ${msg}` : errType; + } + + // ── Mock fixture aliases ────────────────────────────────── + case 'turn_start': + return `Step ${mnum(m, 'turn') ?? '?'} ${'─'.repeat(30)}`; + case 'tool_call': + return `${mstr(m, 'tool_name')} ${trunc(mstr(m, 'args_preview'), TRUNC_TOOL_INPUT)}`; + case 'tool_result': { + const preview = mstr(m, 'preview'); + return `${mstr(m, 'tool_name')} → ${mstr(m, 'status')} ${preview ? `(${trunc(preview, 30)})` : ''}`; + } + case 'milestone': + return mstr(m, 'message', 'Milestone'); + case 'cost_update': + return `Cost: $${(mnum(m, 'total_usd') ?? 0).toFixed(4)}`; + + // ── Cedar HITL milestones ───────────────────────────────── + // These cases fire for either (a) mock-fixture event names that + // bypass the agent_milestone wrapper, or (b) Watch.tsx's + // synthesized pending-approval event. For the live agent-emitted + // path the wrapping is `agent_milestone` + `metadata.milestone = + // ` and rendering goes through `fmtMilestone()` above. + case 'approval_requested': + case 'approval_granted': + case 'approval_denied': + case 'approval_timed_out': + case 'approval_stranded': + case 'approval_timeout_capped': + case 'approval_ceiling_shrinking': + case 'approval_cap_exceeded': + case 'approval_rate_limit_exceeded': + case 'approval_write_failed': + case 'approval_resume_failed': + case 'approval_poll_degraded': + case 'approval_late_win': + case 'pre_approvals_loaded': { + // Reuse the milestone formatter by synthesizing a metadata view + // that already includes the sub-name. Keeps the unwrapped path + // and the wrapped path identical in output. + const synth: Record = { ...m, milestone: e.event_type }; + const formatted = formatMilestone(synth); + return formatted ?? e.event_type; + } + + default: + return e.event_type; + } +} + +/** Effective milestone sub-name for color/icon lookup when the event + * is wrapped in `agent_milestone`. Falls back to `event_type` for + * unwrapped mock fixtures so the existing EVENT_COLOR / EVENT_ICON + * maps still apply. */ +function effectiveMilestoneKey(event: TaskEvent): string | null { + if (event.event_type !== 'agent_milestone') return null; + const sub = event.metadata.milestone; + return typeof sub === 'string' ? sub : null; +} + +const EventLine: React.FC<{ event: TaskEvent }> = ({ event }) => { + const ts = new Date(event.timestamp); + const time = `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}:${String(ts.getSeconds()).padStart(2, '0')}`; + // Live mode: every approval-* milestone arrives as event_type + // `agent_milestone` with the sub-name in metadata. Resolve color + + // icon via the milestone-keyed maps so safety-critical events + // (timeout_capped, cap_exceeded, ceiling_shrinking, ...) get their + // intended yellow/red treatment instead of the generic cyan-star + // fallback that hid the IMPL-26 surface promotion. + const milestoneKey = effectiveMilestoneKey(event); + const c = (milestoneKey && MILESTONE_COLOR[milestoneKey]) + ?? EVENT_COLOR[event.event_type] + ?? 'white'; + const icon = (milestoneKey && MILESTONE_ICON[milestoneKey]) + ?? EVENT_ICON[event.event_type] + ?? '.'; + const isBold = + event.event_type === 'tool_call' + || event.event_type === 'agent_tool_call' + || event.event_type === 'approval_requested' + || milestoneKey === 'approval_requested' + || milestoneKey === 'approval_cap_exceeded'; + + return ( + + {time} + {icon} + + {fmt(event)} + + + ); +}; + +export default React.memo(EventLine); diff --git a/cli/src/tui/components/HelpBar.tsx b/cli/src/tui/components/HelpBar.tsx new file mode 100644 index 00000000..b57205e9 --- /dev/null +++ b/cli/src/tui/components/HelpBar.tsx @@ -0,0 +1,89 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Box, Text } from 'ink'; +import React from 'react'; +import type { PanelId } from './TabBar.js'; +import { SEPARATOR_WIDTH } from '../data.js'; + +interface HelpBarProps { + panel: PanelId; + hasApproval?: boolean; + isEditing?: boolean; + editMode?: 'text' | 'deny-confirm' | 'scope-picker' | null; + inDetail?: boolean; +} + +const K: React.FC<{ k: string; label: string; color?: string }> = ({ k, label, color }) => ( + <>[{k}]{label} +); + +const HelpBar: React.FC = ({ panel, hasApproval, isEditing, editMode, inDetail }) => { + const sep = {'─'.repeat(SEPARATOR_WIDTH)}; + + if (isEditing) { + return ( + + {sep} + + {editMode === 'deny-confirm' ? ( + <> + ) : editMode === 'scope-picker' ? ( + <> + ) : ( + <> + )} + + + ); + } + + const panelHelp: Record = { + tasks: <>, + watch: <> + {hasApproval && <>} + + , + approvals: inDetail ? ( + <> + ) : ( + <> + ), + policies: <>, + submit: <>, + }; + + return ( + + {sep} + + + {panelHelp[panel]} + + + + 1:Tasks 2:Watch 3:Approvals 4:Policies 5:New Task Tab:next q:quit + + + + + ); +}; + +export default HelpBar; diff --git a/cli/src/tui/components/PeccyIcon.tsx b/cli/src/tui/components/PeccyIcon.tsx new file mode 100644 index 00000000..a14aca41 --- /dev/null +++ b/cli/src/tui/components/PeccyIcon.tsx @@ -0,0 +1,96 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Peccy full-size pixel-art icon — animated pupils. + * Uses shared rendering from peccy-shared. + */ +import { Box } from 'ink'; +import React, { useState, useEffect } from 'react'; +import { O, W, K, _, type Pixel, type PupilPos, SEQUENCE, ANIM_INTERVAL, renderPixelGrid } from './peccy-shared.js'; + +// Full Peccy has 3 eye rows → 'down' looks visually different from 'center' +function makeGrid(pos: PupilPos): Pixel[][] { + const top: Pixel[][] = [ + [_, _, _, _, _, K, K, K, _, _, _, _, _], // 0: loop + [_, _, _, _, _, K, _, K, _, _, _, _, _], // 1: loop hole + [_, _, K, K, O, O, O, O, O, K, K, _, _], // 2: head top + [_, K, O, O, O, O, O, O, O, O, O, K, _], // 3: head + ]; + + let eyeRow1: Pixel[]; + let eyeRow2: Pixel[]; + + switch (pos) { + case 'left': + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; + eyeRow2 = [_, K, O, K, W, W, O, K, W, W, O, K, _]; + break; + case 'right': + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; + eyeRow2 = [_, K, O, W, W, K, O, W, W, K, O, K, _]; + break; + case 'down': + // 'down' = pupils at bottom — white top, white mid, pupil bottom + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; // all white + eyeRow2 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; // all white + break; + case 'center': + default: + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; + eyeRow2 = [_, K, O, W, K, W, O, W, K, W, O, K, _]; + break; + } + + // Third eye row: only 'down' has pupils here, others are just orange below + const eyeRow3: Pixel[] = pos === 'down' + ? [_, K, O, W, K, W, O, W, K, W, O, K, _] // pupils at very bottom + : [_, K, O, O, O, O, O, O, O, O, O, K, _]; // orange (below eyes) + + const bottom: Pixel[][] = [ + [_, K, O, O, K, O, O, O, K, O, O, K, _], // 7: smile (symmetric U) + [K, K, O, O, O, K, K, K, O, O, O, K, K], // 8: curve + arms + [K, O, O, O, O, O, O, O, O, O, O, O, K], // 9: arms wide + [_, K, O, O, O, O, O, O, O, O, O, K, _], // 10: body + [_, K, O, O, O, K, K, K, O, O, O, K, _], // 11: legs + [_, _, K, K, K, _, _, _, K, K, K, _, _], // 12: feet + [_, _, _, _, _, _, _, _, _, _, _, _, _], // 13: pad + ]; + + return [...top, eyeRow1, eyeRow2, eyeRow3, ...bottom]; +} + +const PeccyIcon: React.FC = () => { + const [frame, setFrame] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setFrame(f => (f + 1) % SEQUENCE.length); + }, ANIM_INTERVAL); + return () => clearInterval(timer); + }, []); + + return ( + + {renderPixelGrid(makeGrid(SEQUENCE[frame]))} + + ); +}; + +export default PeccyIcon; diff --git a/cli/src/tui/components/PeccyMini.tsx b/cli/src/tui/components/PeccyMini.tsx new file mode 100644 index 00000000..697973ee --- /dev/null +++ b/cli/src/tui/components/PeccyMini.tsx @@ -0,0 +1,98 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * PeccyMini — cropped head + eyes, animated pupils. + * Uses shared rendering from peccy-shared. + * 4 char lines (8 pixel rows). + */ +import { Box } from 'ink'; +import React, { useState, useEffect } from 'react'; +import { O, W, K, _, type Pixel, type PupilPos, SEQUENCE, ANIM_INTERVAL, renderPixelGrid } from './peccy-shared.js'; + +function makeGrid(pos: PupilPos): Pixel[][] { + const top: Pixel[][] = [ + [_, _, _, _, _, K, K, K, _, _, _, _, _], // 0: loop + [_, _, _, _, _, K, _, K, _, _, _, _, _], // 1: loop hole + [_, _, K, K, O, O, O, O, O, K, K, _, _], // 2: head top + [_, K, O, O, O, O, O, O, O, O, O, K, _], // 3: head wide + ]; + + let eyeRow1: Pixel[]; + let eyeRow2: Pixel[]; + + switch (pos) { + case 'left': + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; + eyeRow2 = [_, K, O, K, W, W, O, K, W, W, O, K, _]; + break; + case 'right': + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; + eyeRow2 = [_, K, O, W, W, K, O, W, W, K, O, K, _]; + break; + case 'down': + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; + eyeRow2 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; // all white — pupils in row3 + break; + case 'center': + default: + eyeRow1 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; + eyeRow2 = [_, K, O, W, W, W, O, W, W, W, O, K, _]; // all white — pupils in row3 + break; + } + + // Third eye row: pupils for center/down at bottom, or orange gap for left/right + let eyeRow3: Pixel[]; + switch (pos) { + case 'left': + case 'right': + // pupils already shown in eyeRow2, this is orange below + eyeRow3 = [_, K, O, O, O, O, O, O, O, O, O, K, _]; + break; + case 'down': + case 'center': + default: + eyeRow3 = [_, K, O, W, K, W, O, W, K, W, O, K, _]; // pupils at bottom + break; + } + + // Orange row below — makes bottom pupils render as thin half-height dots + const bottom: Pixel[] = [_, K, O, O, O, O, O, O, O, O, O, K, _]; + + return [...top, eyeRow1, eyeRow2, eyeRow3, bottom]; +} + +const PeccyMini: React.FC = () => { + const [frame, setFrame] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setFrame(f => (f + 1) % SEQUENCE.length); + }, ANIM_INTERVAL); + return () => clearInterval(timer); + }, []); + + return ( + + {renderPixelGrid(makeGrid(SEQUENCE[frame]))} + + ); +}; + +export default PeccyMini; diff --git a/cli/src/tui/components/ScopePicker.tsx b/cli/src/tui/components/ScopePicker.tsx new file mode 100644 index 00000000..8bb7fb2a --- /dev/null +++ b/cli/src/tui/components/ScopePicker.tsx @@ -0,0 +1,198 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Reusable ApprovalScope picker used by Approvals + Watch (for the + * approve action) and Submit (for the initial_approvals seed). + * + * Shows the full 9-variant ApprovalScope vocabulary defined in + * `cli/src/types.ts`: + * + * Short forms: this_call, tool_type_session, tool_group_session, + * all_session + * Prefixed: tool_type:, tool_group:, + * bash_pattern:, write_path:, + * rule: + * + * The prefixed forms open a text-input step where the user types the + * operand. Validation mirrors the CLI's `submit` command (server-side + * parser also rejects invalid forms, but we fail fast locally). + * + * `all_session` is the "nuclear option": the picker requires an + * extra y/n confirmation before returning it (matches the CLI's + * `--yes` guard in `commands/approve.ts`). + */ + +import figures from 'figures'; +import { Box, Text, useInput } from 'ink'; +import React, { useState, useCallback } from 'react'; +import type { ApprovalScope } from '../../types.js'; + +interface ScopeOption { + readonly key: string; + readonly label: string; + /** When set, the scope is a prefix and the picker asks the user + * for the operand before returning. */ + readonly prefix?: 'tool_type' | 'tool_group' | 'bash_pattern' | 'write_path' | 'rule'; + /** Raw short-form scope (used when `prefix` is unset). */ + readonly shortForm?: 'this_call' | 'tool_type_session' | 'tool_group_session' | 'all_session'; +} + +const OPTIONS: readonly ScopeOption[] = [ + { key: 'this_call', label: 'Just this one call', shortForm: 'this_call' }, + { key: 'tool_type_session', label: 'Any call of this tool type, for this session', shortForm: 'tool_type_session' }, + { key: 'tool_group_session', label: 'Any call in this tool group, for this session', shortForm: 'tool_group_session' }, + { key: 'tool_type', label: 'tool_type: (e.g. Bash, Edit)', prefix: 'tool_type' }, + { key: 'tool_group', label: 'tool_group: (e.g. file_write)', prefix: 'tool_group' }, + { key: 'bash_pattern', label: 'bash_pattern: (e.g. npm *)', prefix: 'bash_pattern' }, + { key: 'write_path', label: 'write_path: (e.g. src/**)', prefix: 'write_path' }, + { key: 'rule', label: 'rule: (skip this Cedar rule)', prefix: 'rule' }, + { key: 'all_session', label: `${figures.warning} Full autonomy — approves everything`, shortForm: 'all_session' }, +]; + +interface ScopePickerProps { + onConfirm: (scope: ApprovalScope) => void; + onCancel: () => void; + /** When true, the picker is rendered inline (as an overlay); used + * by Approvals detail view. Pass false to render without extra + * border so it nests inside the calling panel's layout. */ + bordered?: boolean; + heading?: string; +} + +type Step = + | { kind: 'picking' } + | { kind: 'operand'; optionKey: string; prefix: NonNullable; value: string } + | { kind: 'confirm-all-session' }; + +const ScopePicker: React.FC = ({ onConfirm, onCancel, bordered = true, heading = 'Pick a scope' }) => { + const [cursor, setCursor] = useState(0); + const [step, setStep] = useState({ kind: 'picking' }); + + useInput(useCallback((input, key) => { + if (step.kind === 'picking') { + if (key.escape) { onCancel(); return; } + if (key.upArrow) { setCursor(c => Math.max(0, c - 1)); return; } + if (key.downArrow) { setCursor(c => Math.min(OPTIONS.length - 1, c + 1)); return; } + if (key.return || input === ' ') { + const opt = OPTIONS[cursor]; + if (opt.shortForm === 'all_session') { + setStep({ kind: 'confirm-all-session' }); + return; + } + if (opt.prefix) { + setStep({ kind: 'operand', optionKey: opt.key, prefix: opt.prefix, value: '' }); + return; + } + if (opt.shortForm) { + onConfirm(opt.shortForm); + return; + } + } + return; + } + + if (step.kind === 'operand') { + if (key.escape) { setStep({ kind: 'picking' }); return; } + if (key.return) { + const v = step.value.trim(); + if (v.length === 0) return; + // Mirror server-side cap. `INITIAL_APPROVALS_MAX_ENTRY_LENGTH` + // is re-exported from `cli/src/types.ts`, but the composed + // scope is `prefix:operand` — check total length. + const scope = `${step.prefix}:${v}` as ApprovalScope; + if (scope.length > 128) return; + onConfirm(scope); + return; + } + if (key.backspace || key.delete) { + setStep({ ...step, value: step.value.slice(0, -1) }); + return; + } + if (input && !key.ctrl && !key.meta) { + setStep({ ...step, value: step.value + input }); + return; + } + return; + } + + if (step.kind === 'confirm-all-session') { + if (input === 'y' || input === 'Y') { + onConfirm('all_session'); + return; + } + if (key.escape || input === 'n' || input === 'N') { + setStep({ kind: 'picking' }); + return; + } + } + }, [step, cursor, onConfirm, onCancel])); + + const body = + step.kind === 'picking' ? ( + + {heading} + ↑↓ pick, Enter confirm, Esc cancel + + {OPTIONS.map((opt, i) => { + const focused = i === cursor; + const isDanger = opt.shortForm === 'all_session'; + return ( + + {focused ? figures.pointer + ' ' : ' '} + {opt.label} + + ); + })} + + ) : step.kind === 'operand' ? ( + + Enter operand for {step.prefix}: + + {step.prefix}: + {step.value} + | + + Enter to confirm, Esc to go back + {step.value.length > 0 && ( + + Length: {step.prefix.length + 1 + step.value.length}/128 + + )} + + ) : ( + + {figures.warning} all_session grants the agent blanket approval + for every subsequent gate in this task. Are you sure? + + [y] Confirm + [n] Cancel + + + ); + + if (!bordered) return body; + return ( + + {body} + + ); +}; + +export default ScopePicker; diff --git a/cli/src/tui/components/TabBar.tsx b/cli/src/tui/components/TabBar.tsx new file mode 100644 index 00000000..2f807f08 --- /dev/null +++ b/cli/src/tui/components/TabBar.tsx @@ -0,0 +1,117 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text } from 'ink'; +import React from 'react'; +import { useApprovals } from '../context.js'; +import { SEPARATOR_WIDTH, type TaskRowView } from '../data.js'; +import PeccyMini from './PeccyMini.js'; + +export type PanelId = 'tasks' | 'watch' | 'approvals' | 'policies' | 'submit'; + +interface TabBarProps { + active: PanelId; + tasks: TaskRowView[]; +} + +const TabBar: React.FC = ({ active, tasks }) => { + const { approvals } = useApprovals(); + + const activeTasks = tasks.filter(t => + ['RUNNING', 'AWAITING_APPROVAL', 'HYDRATING'].includes(t.status), + ); + + const tabs: { id: PanelId; label: string; badge?: number; badgeColor?: string }[] = [ + { id: 'tasks', label: 'Tasks', badge: activeTasks.length || undefined }, + { id: 'watch', label: 'Watch' }, + { id: 'approvals', label: 'Approvals', badge: approvals.length || undefined, badgeColor: 'magenta' }, + { id: 'policies', label: 'Policies' }, + { id: 'submit', label: 'New Task' }, + ]; + + // Context-aware status badges + const statusParts: React.ReactNode[] = []; + if (activeTasks.length > 0) { + statusParts.push({figures.bullet} {activeTasks.length} active); + } + if (approvals.length > 0) { + statusParts.push({figures.warning} {approvals.length} pending); + } + if (statusParts.length === 0) { + statusParts.push({figures.bullet} idle); + } + + const statusLine = ( + + {statusParts.map((part, i) => ( + + {i > 0 && } + {part} + + ))} + + ); + + const tabStrip = ( + + {tabs.map((tab, i) => { + const isActive = tab.id === active; + const badge = tab.badge ? ` ${tab.badge}` : ''; + return ( + + {i > 0 && } + {isActive ? ( + {tab.label}{badge} + ) : ( + + {tab.label} + {tab.badge && {badge}} + + )} + + ); + })} + + ); + + // Always use PeccyMini (full Peccy is only on splash screen) + return ( + + + + + + + + Autonomous Cloud Coding Agents + + {statusLine} + + + {tabStrip} + + + + {'─'.repeat(SEPARATOR_WIDTH)} + + ); +}; + +export default TabBar; diff --git a/cli/src/tui/components/peccy-shared.ts b/cli/src/tui/components/peccy-shared.ts new file mode 100644 index 00000000..c783ac45 --- /dev/null +++ b/cli/src/tui/components/peccy-shared.ts @@ -0,0 +1,81 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Shared Peccy rendering logic — used by both PeccyIcon and PeccyMini. + */ +import { Box, Text } from 'ink'; +import React from 'react'; + +export const O = '#E8942A'; +export const W = '#FFFFFF'; +export const K = '#222222'; +export const _ = null; + +export type Pixel = string | null; +export type PupilPos = 'left' | 'center' | 'right' | 'down'; + +// ~51s cycle: mostly idle, occasional glances every ~8s +export const SEQUENCE: PupilPos[] = [ + // idle + 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', + // glance right + 'right', 'right', 'right', 'center', + // idle + 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', + // glance left + 'left', 'left', 'left', 'center', + // idle + 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', + // look down + 'down', 'down', 'down', 'down', 'down', 'center', + // idle + 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', + // quick double-take + 'right', 'right', 'right', 'center', 'center', 'left', 'left', 'left', + // idle + 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', 'center', + // look down + 'down', 'down', 'down', 'down', 'down', 'center', +]; + +export const ANIM_INTERVAL = 400; + +/** Convert a pixel grid to half-block rendered Ink elements. */ +export function renderPixelGrid(grid: Pixel[][]): React.ReactNode[] { + const lines: React.ReactNode[] = []; + for (let y = 0; y < grid.length; y += 2) { + const topRow = grid[y] ?? []; + const botRow = grid[y + 1] ?? []; + const maxCols = Math.max(topRow.length, botRow.length); + const cells: React.ReactNode[] = []; + for (let x = 0; x < maxCols; x++) { + const top = topRow[x] ?? null; + const bot = botRow[x] ?? null; + const key = `${y}-${x}`; + if (top && bot && top === bot) cells.push(React.createElement(Text, { key, color: top }, '█')); + else if (top && !bot) cells.push(React.createElement(Text, { key, color: top }, '▀')); + else if (!top && bot) cells.push(React.createElement(Text, { key, color: bot }, '▄')); + else if (top && bot) cells.push(React.createElement(Text, { key, color: top, backgroundColor: bot }, '▀')); + else cells.push(React.createElement(Text, { key }, ' ')); + } + lines.push(React.createElement(Box, { key: `line-${y}` }, ...cells)); + } + return lines; +} diff --git a/cli/src/tui/constants.ts b/cli/src/tui/constants.ts new file mode 100644 index 00000000..fa9017c8 --- /dev/null +++ b/cli/src/tui/constants.ts @@ -0,0 +1,265 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Shared TUI constants — single source of truth for colors, icons, labels. + * Panels import from here instead of redefining their own maps. + */ +import figures from 'figures'; + +// ── Status ────────────────────────────────────────────────────────── + +export const STATUS_COLOR: Record = { + RUNNING: 'cyan', + AWAITING_APPROVAL: 'magenta', + COMPLETED: 'green', + FAILED: 'red', + CANCELLED: 'gray', + SUBMITTED: 'gray', + HYDRATING: 'blue', + FINALIZING: 'yellow', + TIMED_OUT: 'redBright', // distinct from FAILED +}; + +export const STATUS_ICON: Record = { + RUNNING: figures.bullet, + AWAITING_APPROVAL: figures.warning, + COMPLETED: figures.tick, + FAILED: figures.cross, + CANCELLED: figures.line, + SUBMITTED: figures.circle, + HYDRATING: figures.ellipsis, + FINALIZING: figures.arrowRight, + TIMED_OUT: figures.warning, // distinct from FAILED (cross) +}; + +export const STATUS_LABEL: Record = { + RUNNING: 'Running', + AWAITING_APPROVAL: 'Needs approval', + COMPLETED: 'Done', + FAILED: 'Failed', + CANCELLED: 'Cancelled', + SUBMITTED: 'Queued', + HYDRATING: 'Starting up', + FINALIZING: 'Wrapping up', + TIMED_OUT: 'Timed out', +}; + +// ── Event types ───────────────────────────────────────────────────── + +// Keys include both the mock fixture event names and the real +// agent-side names (`agent_turn`, `agent_tool_call`, ...). Keeping +// both in one map lets EventLine handle mixed streams and the mock +// demo without branching. See agent/src/progress_writer.py for the +// authoritative producer-side vocabulary. +export const EVENT_COLOR: Record = { + // Lifecycle + task_started: 'green', + task_complete: 'green', + task_completed: 'green', + task_failed: 'red', + // Agent runtime (real) + agent_turn: 'gray', + agent_tool_call: 'yellow', + agent_tool_result: 'gray', + agent_milestone: 'cyan', + agent_cost_update: 'yellow', + agent_error: 'red', + // Mock fixture aliases + turn_start: 'gray', + tool_call: 'yellow', + tool_result: 'gray', + milestone: 'cyan', + cost_update: 'yellow', + error: 'red', + // Cedar HITL milestones — these names appear on the wire only when + // either (a) mock fixtures unwrap them or (b) Watch.tsx synthesizes a + // pending-approval event. In live mode the event_type is always + // `agent_milestone` and the sub-name lives in `metadata.milestone`; + // see MILESTONE_COLOR / MILESTONE_ICON below for the live mapping. + approval_requested: 'magenta', + approval_granted: 'green', + approval_denied: 'red', + approval_timed_out: 'redBright', + approval_stranded: 'redBright', + nudge_acknowledged: 'cyan', +}; + +// Milestone sub-names emitted as `agent_milestone` by the agent +// runtime (`agent/src/progress_writer.py::_put_approval_milestone`). +// Used by EventLine when `event.event_type === 'agent_milestone'` — +// look up the color/icon by `metadata.milestone` rather than falling +// back to the generic cyan-star treatment that hides the severity +// signal IMPL-26 was specifically meant to surface. +export const MILESTONE_COLOR: Record = { + pre_approvals_loaded: 'cyan', + approval_requested: 'magenta', + approval_granted: 'green', + approval_denied: 'red', + approval_timed_out: 'redBright', + approval_stranded: 'redBright', + approval_write_failed: 'red', + approval_resume_failed: 'red', + approval_poll_degraded: 'yellow', + approval_timeout_capped: 'yellow', + approval_ceiling_shrinking: 'yellow', + approval_cap_exceeded: 'red', + approval_rate_limit_exceeded: 'yellow', + approval_late_win: 'cyan', + policy_decision: 'gray', + nudge_acknowledged: 'cyan', +}; + +export const EVENT_ICON: Record = { + // Lifecycle + task_started: figures.star, + task_complete: figures.tick, + task_completed: figures.tick, + task_failed: figures.cross, + // Agent runtime (real) + agent_turn: figures.line, + agent_tool_call: figures.play, + agent_tool_result: figures.pointer, + agent_milestone: figures.star, + agent_cost_update: '$', + agent_error: figures.cross, + // Mock fixture aliases + turn_start: figures.line, + tool_call: figures.play, + tool_result: figures.pointer, + milestone: figures.star, + cost_update: '$', + error: figures.cross, + // Cedar HITL milestones — see EVENT_COLOR for live-vs-mock notes. + approval_requested: figures.warning, + approval_granted: figures.tick, + approval_denied: figures.cross, + approval_timed_out: figures.cross, + approval_stranded: figures.cross, +}; + +// Companion to MILESTONE_COLOR — icon lookup by milestone sub-name. +export const MILESTONE_ICON: Record = { + pre_approvals_loaded: figures.star, + approval_requested: figures.warning, + approval_granted: figures.tick, + approval_denied: figures.cross, + approval_timed_out: figures.cross, + approval_stranded: figures.cross, + approval_write_failed: figures.cross, + approval_resume_failed: figures.cross, + approval_poll_degraded: figures.warning, + approval_timeout_capped: figures.warning, + approval_ceiling_shrinking: figures.warning, + approval_cap_exceeded: figures.cross, + approval_rate_limit_exceeded: figures.warning, + approval_late_win: figures.star, + policy_decision: figures.bullet, + nudge_acknowledged: figures.arrowRight, +}; + +// ── Severity ──────────────────────────────────────────────────────── +// Consistent casing: keys are always UPPERCASE (matching the data model). + +export const SEVERITY_COLOR: Record = { + HIGH: 'red', + MEDIUM: 'yellow', + LOW: 'green', +}; + +export const SEVERITY_LABEL: Record = { + HIGH: 'High risk', + MEDIUM: 'Medium risk', + LOW: 'Low risk', +}; + +// ── Channel source (submission provenance) ───────────────────────── +// Short labels fit under an 8-char column width without truncation. +// Colors let the user scan "which tasks came from where" at a glance: +// CLI / webhook — neutral (gray / white) +// Slack / Linear — integration-branded hues + +export const CHANNEL_LABEL: Record = { + api: 'CLI', + webhook: 'Hook', + slack: 'Slack', + linear: 'Linear', +}; + +export const CHANNEL_COLOR: Record = { + api: undefined, + webhook: 'gray', + slack: 'magenta', + linear: 'blue', +}; + +// ── Policy tiers (plain-English labels) ───────────────────────────── +// API buckets from GET /repos/{id}/policies are `hard` and `soft`. +// Legacy TUI tiers `hard-deny` / `hard-gate` are kept as aliases so the +// mock fixture and any in-flight callers stay rendering-clean through +// the Phase 1 → Phase 3 transition. + +export const TIER_LABEL: Record = { + 'hard': 'Blocked', + 'soft': 'Requires approval', + 'hard-deny': 'Blocked', + 'hard-gate': 'Requires approval', +}; + +export const TIER_COLOR: Record = { + 'hard': 'red', + 'soft': 'magenta', + 'hard-deny': 'red', + 'hard-gate': 'magenta', +}; + +// ── Pre-approve scopes (plain-English) ────────────────────────────── + +export const SCOPE_LABELS: Record = { + 'tool_type:Read': 'Read-only operations (file reads, searches)', + 'tool_type:Edit': 'File editing (writes and modifications)', + 'tool_type:Bash': 'Shell commands (bash/sh execution)', + 'tool_group:file_write': 'All file write operations', + 'all_session': `${figures.warning} Full autonomy — approves everything`, +}; + +// ── Helpers ───────────────────────────────────────────────────────── + +/** Human-friendly time ago from ISO timestamp. */ +export function timeAgo(iso: string): string { + const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +/** Format seconds as "Xm Ys" or "Ys". */ +export function fmtDuration(totalSeconds: number): string { + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +/** Safe truncation. */ +export function trunc(s: string, maxLen: number): string { + return s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s; +} diff --git a/cli/src/tui/context.tsx b/cli/src/tui/context.tsx new file mode 100644 index 00000000..ae889a7f --- /dev/null +++ b/cli/src/tui/context.tsx @@ -0,0 +1,197 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Shared TUI context — approval state + editing lock. + * + * Approvals are owned by the DataProvider (`hooks/useData.tsx`): it + * polls the source and exposes a `snapshot.approvals` array. The + * approval context here wraps that snapshot with local optimistic + * clearing on approve/deny so the panel redraws instantly rather + * than waiting on the next poll. The mutation itself is forwarded + * to the source via `useData().approve/.deny` (which also triggers + * a refresh). + */ +import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react'; +import type { ApprovalScope } from '../types.js'; +import type { PendingApprovalView } from './data.js'; +import { useData } from './hooks/useData.js'; + +// ── Approval state ────────────────────────────────────────────────── + +/** + * Result of an approve/deny round-trip. Callers MUST distinguish the + * two cases: an `ok: false` result on a human-in-the-loop safety + * control means the API rejected the decision (auth, validation, + * stale request_id, etc.) — the agent is still blocked, the user's + * intent did NOT take effect, and the optimistic-clear has been + * undone so the row reappears in the list. + */ +export type ApprovalResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; + +interface ApprovalActions { + approvals: PendingApprovalView[]; + approve: (requestId: string, scope?: ApprovalScope) => Promise; + deny: (requestId: string, reason?: string) => Promise; +} + +const ApprovalCtx = createContext({ + approvals: [], + approve: async () => ({ ok: false, error: 'no provider' }), + deny: async () => ({ ok: false, error: 'no provider' }), +}); + +export const useApprovals = () => useContext(ApprovalCtx); + +// ── Editing lock ──────────────────────────────────────────────────── + +export type EditMode = 'text' | 'deny-confirm' | 'scope-picker' | null; + +interface EditingState { + isEditing: boolean; + editMode: EditMode; + setEditing: (v: boolean, mode?: EditMode) => void; +} + +const EditingCtx = createContext({ + isEditing: false, + editMode: null, + setEditing: () => {}, +}); + +export const useEditing = () => useContext(EditingCtx); + +// ── Provider ──────────────────────────────────────────────────────── + +export const TuiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { snapshot, approve: sourceApprove, deny: sourceDeny } = useData(); + + // Optimistic suppression list: request_ids that the user just + // approved/denied — filtered out of the view until the next poll + // echoes their absence. + const [optimisticallyCleared, setOptimisticallyCleared] = useState>(new Set()); + const [isEditing, setIsEditing] = useState(false); + const [editMode, setEditMode] = useState(null); + + // Once the server snapshot no longer includes a cleared id, drop it + // from the suppression set so we don't leak memory. + useEffect(() => { + setOptimisticallyCleared(prev => { + if (prev.size === 0) return prev; + const liveIds = new Set(snapshot.approvals.map(a => a.request_id)); + const next = new Set(); + for (const id of prev) { + if (liveIds.has(id)) next.add(id); + } + return next.size === prev.size ? prev : next; + }); + }, [snapshot.approvals]); + + const approvals = useMemo( + () => snapshot.approvals.filter(a => !optimisticallyCleared.has(a.request_id)), + [snapshot.approvals, optimisticallyCleared], + ); + + /** + * Optimistically clear the row from the visible list, then call + * the source. If the call rejects, undo the clear so the row + * reappears and the user can retry — this is the safety property + * that the original fire-and-forget version got wrong: the user + * saw `✓ Approved` even when the API call failed, and the agent + * stayed blocked until timeout. Phase A live drive + * (01KS18SAV6PPR4XVZPAHF2EJF5) caught this on the deployed env; + * the user's tool_type:Bash approval never landed, the row stayed + * PENDING server-side, and the agent timed out waiting. + */ + const undoOptimisticClear = useCallback((requestId: string) => { + setOptimisticallyCleared(prev => { + if (!prev.has(requestId)) return prev; + const n = new Set(prev); + n.delete(requestId); + return n; + }); + }, []); + + const approve = useCallback(async ( + requestId: string, + scope?: ApprovalScope, + ): Promise => { + const pending = snapshot.approvals.find(a => a.request_id === requestId); + if (!pending) return { ok: false, error: 'approval row not found locally' }; + setOptimisticallyCleared(prev => { + const n = new Set(prev); + n.add(requestId); + return n; + }); + try { + await sourceApprove(pending.task_id, requestId, scope); + return { ok: true }; + } catch (err) { + undoOptimisticClear(requestId); + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: msg }; + } + }, [snapshot.approvals, sourceApprove, undoOptimisticClear]); + + const deny = useCallback(async ( + requestId: string, + reason?: string, + ): Promise => { + const pending = snapshot.approvals.find(a => a.request_id === requestId); + if (!pending) return { ok: false, error: 'approval row not found locally' }; + setOptimisticallyCleared(prev => { + const n = new Set(prev); + n.add(requestId); + return n; + }); + try { + await sourceDeny(pending.task_id, requestId, reason); + return { ok: true }; + } catch (err) { + undoOptimisticClear(requestId); + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: msg }; + } + }, [snapshot.approvals, sourceDeny, undoOptimisticClear]); + + const setEditing = useCallback((v: boolean, mode?: EditMode) => { + setIsEditing(v); + setEditMode(v ? (mode ?? 'text') : null); + }, []); + + const approvalValue = useMemo( + () => ({ approvals, approve, deny }), + [approvals, approve, deny], + ); + + const editingValue = useMemo( + () => ({ isEditing, editMode, setEditing }), + [isEditing, editMode, setEditing], + ); + + return ( + + + {children} + + + ); +}; diff --git a/cli/src/tui/data.ts b/cli/src/tui/data.ts new file mode 100644 index 00000000..98a9948a --- /dev/null +++ b/cli/src/tui/data.ts @@ -0,0 +1,121 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * TUI viewmodel types + layout constants. + * + * Panels bind to the view shapes in this module. Runtime data comes + * off `useData()` from `./hooks/useData.tsx`, which picks between + * mock and real sources based on `BGAGENT_TUI_MOCK`. This module + * deliberately has no runtime queries — all I/O lives behind the + * `DataSource` interface in `./api/source.ts`. + */ + +import type { ChannelSource, TaskEvent, TaskStatusType } from '../types.js'; + +// Re-export so components can import from './data.js' exclusively. +export type { ChannelSource, TaskEvent, TaskStatusType }; + +// ─── View shapes panels bind to ───────────────────────────────────── + +/** Row the TaskList + Watch panels render. Pre-joins the fields + * panels need from `TaskDetail` (wire) with display-friendly + * derived fields (`turn` = `turns_completed ?? turns_attempted`). */ +export interface TaskRowView { + readonly task_id: string; + readonly status: TaskStatusType; + readonly repo: string; + readonly issue_number: number | null; + readonly task_type: string; + readonly pr_number: number | null; + readonly task_description: string; + readonly branch_name: string; + readonly pr_url: string | null; + /** Submission provenance (api / webhook / slack / linear). Surfaced + * in the TaskList SOURCE column so users can see which channel + * produced the task. Optional so pre-ChannelSource records and + * already-loaded mock rows don't require a migration. */ + readonly channel_source?: ChannelSource; + readonly created_at: string; + readonly updated_at: string; + readonly cost_usd: number | null; + readonly duration_s: number | null; + readonly max_turns: number | null; + readonly turns_attempted: number | null; + readonly turns_completed: number | null; + /** Current step for the Watch header. Null on pre-DATA-1 records. */ + readonly turn: number | null; + readonly approval_gate_count: number | null; + readonly approval_gate_cap: number | null; + readonly awaiting_approval_request_id: string | null; +} + +/** Approval row rendered by the Approvals + Watch panels. Merges + * `PendingApprovalSummary` (wire) with the parent task's `repo` + + * `task_description`. Severity normalized to UPPERCASE. */ +export interface PendingApprovalView { + readonly task_id: string; + readonly request_id: string; + readonly tool_name: string; + readonly tool_input_preview: string; + readonly severity: 'HIGH' | 'MEDIUM' | 'LOW'; + readonly reason: string; + readonly created_at: string; + readonly timeout_s: number; + readonly expires_at: string; + readonly matching_rule_ids: readonly string[]; + readonly repo: string; + readonly task_description: string; +} + +/** Cedar policy row for the Policies panel. `tier` uses the API + * vocabulary (`hard`/`soft`). `cedar_source` is mock-only — real + * mode leaves it undefined and the panel hides the section. */ +export interface PolicyRuleView { + readonly rule_id: string; + readonly tier: 'hard' | 'soft'; + readonly summary: string; + readonly severity?: string; + readonly category?: string; + readonly approval_timeout_s?: number; + readonly action?: string; + readonly condition_summary?: string; + readonly cedar_source?: string; +} + +/** Registered repo for the Submit panel's repo picker. */ +export interface RegisteredRepoView { + readonly repo: string; + readonly default_branch: string; +} + +// `TaskStatusType` is re-exported above — it now serves as the +// single source of truth for the rendering union too. The previous +// local `TaskStatus` alias was a leftover from before upstream +// extracted the literal union into the shared types contract. + +// ─── Layout constants ─────────────────────────────────────────────── + +export const TERM_WIDTH = 80; +export const SEPARATOR_WIDTH = TERM_WIDTH - 8; +export const TRUNC_DESCRIPTION = 35; +export const TRUNC_DESCRIPTION_LONG = 55; +export const TRUNC_REPO = 24; +export const TRUNC_TOOL_INPUT = 40; +export const TRUNC_REASON = 50; diff --git a/cli/src/tui/hooks/useData.tsx b/cli/src/tui/hooks/useData.tsx new file mode 100644 index 00000000..e7ec1fbb --- /dev/null +++ b/cli/src/tui/hooks/useData.tsx @@ -0,0 +1,385 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * TUI data provider + hook. + * + * Wraps a `DataSource` and exposes a snapshot of tasks, pending + * approvals, registered repos, and policies via React context. + * Polls on a configurable interval (default 2 s) and always + * refreshes on demand via `refresh()`. + * + * Panels call `useData()` to read the snapshot; the + * synchronous-legacy `getTasks()` / `getPendingApprovals()` / etc. + * in `data.ts` still work for mock mode but are bypassed when + * `useData().source.label === 'live'`. + * + * The provider is the single place that picks mock vs real based on + * `BGAGENT_TUI_MOCK`. Setting the env var to anything truthy (the + * literal string `"1"`, `"true"`, or any non-empty string that isn't + * `"0"` / `"false"`) forces mock mode; default is live. + */ + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { ApprovalScope, TaskEvent } from '../../types.js'; +import { MockDataSource } from '../api/source-mock.js'; +import { RealDataSource } from '../api/source-real.js'; +import type { DataSource, SubmitTaskInput } from '../api/source.js'; +import type { + PendingApprovalView, + PolicyRuleView, + RegisteredRepoView, + TaskRowView, +} from '../data.js'; +import { + INITIAL_PENDING_CADENCE, + isRateLimitError, + nextPendingCadence, + type PendingCadenceState, +} from '../utils/pending-cadence.js'; + +export interface DataSnapshot { + tasks: TaskRowView[]; + approvals: PendingApprovalView[]; + repos: RegisteredRepoView[]; + policiesByRepo: Map; + loading: boolean; + error: string | null; + /** Set after a 429 on `/v1/pending`. Cleared on the next successful + * poll. Surfaced by panels (banner + Approvals header) so the + * user understands why approvals may take a few extra seconds to + * appear. */ + rateLimited: boolean; + /** Current refresh cadence in ms — surfaced for tests + a future + * diagnostic toggle. */ + pollIntervalMs: number; + lastRefreshedAt: number | null; +} + +export interface DataActions { + refresh: () => Promise; + /** Force the next `/v1/pending` poll to fire immediately and reset + * the adaptive cadence to fast (3 s). Called when the user + * switches to the Approvals panel — their attention is the signal + * that pending freshness matters again, even if the ladder had + * backed off during idle time on other panels. */ + resetPendingCadence: () => void; + getTaskEvents: (taskId: string, opts?: { after?: string }) => Promise; + loadPolicies: (repoId: string) => Promise; + submitTask: (input: SubmitTaskInput) => Promise; + approve: (taskId: string, requestId: string, scope?: ApprovalScope) => Promise; + deny: (taskId: string, requestId: string, reason?: string) => Promise; +} + +interface DataContextShape extends DataActions { + snapshot: DataSnapshot; + source: DataSource; +} + +const DataContext = createContext(null); + +/** Decide mock-vs-real from env. Honors `BGAGENT_TUI_MOCK=1/true`. + * Defaults to MOCK so `npm run tui` keeps working without a + * deployed backend; the `bgagent tui` subcommand (Phase 4) flips + * the default to LIVE by setting the env before render. */ +function pickSourceFromEnv(): DataSource { + const v = process.env.BGAGENT_TUI_MOCK; + const useMock = v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'; + return useMock ? new MockDataSource() : new RealDataSource(); +} + +// Tasks + repos poll on a fixed cadence — they're not rate-limited and +// the user expects the Tasks list to update within a couple seconds of +// a CLI submit. Only the `/v1/pending` poll uses the adaptive ladder +// in `utils/pending-cadence.ts` because that endpoint IS rate-limited. +// Splitting the two timers fixed a UX regression where backing off the +// pending poll during idle time also delayed Tasks list updates. +const DEFAULT_TASKS_POLL_INTERVAL_MS = 2_000; + +// Note: a hard-coded `pollIntervalMs` is no longer the primary cadence +// driver — see `utils/pending-cadence.ts` for the adaptive ladder. The +// prop below is retained for tests that want a fixed cadence and for +// the `bgagent tui` subcommand to override during diagnostic runs. +// When `pollIntervalMs` is provided, it pins BOTH timers (tasks + +// pending) to that value and disables the adaptive ladder + 429 +// backoff entirely. + +export interface DataProviderProps { + children: React.ReactNode; + /** Inject a specific source (used by tests and by the `bgagent tui` + * subcommand when it wants to force a mode). */ + source?: DataSource; + /** Override the poll cadence to a fixed value. When omitted, the + * provider uses the adaptive `pending-cadence` state machine that + * starts at 3 s, backs off through 5/10/30 s on consecutive empty + * polls, and jumps to 30 s on rate-limit (429) responses. */ + pollIntervalMs?: number; +} + +export const DataProvider: React.FC = ({ + children, + source: sourceOverride, + pollIntervalMs, +}) => { + // `pickSourceFromEnv` reads `process.env` — stable across renders, so a + // `useMemo` with an empty deps list keeps the same instance (and its + // `lastTasks` cache) for the lifetime of the provider. + const source = useMemo( + () => sourceOverride ?? pickSourceFromEnv(), + [sourceOverride], + ); + + const [snapshot, setSnapshot] = useState(() => ({ + tasks: [], + approvals: [], + repos: [], + policiesByRepo: new Map(), + loading: true, + error: null, + rateLimited: false, + pollIntervalMs: pollIntervalMs ?? INITIAL_PENDING_CADENCE.intervalMs, + lastRefreshedAt: null, + })); + + const tasksInFlight = useRef(false); + const pendingInFlight = useRef(false); + const cadenceRef = useRef(INITIAL_PENDING_CADENCE); + /** Bumped whenever something wants the pending timer to wake up + * early (e.g. user switches to Approvals panel). The polling + * effect below watches this ref via a state-bridged counter. */ + const [pendingResetTick, setPendingResetTick] = useState(0); + + /** Refresh the tasks list + registered repos. Always runs at the + * fixed cadence — these endpoints aren't rate-limited and the user + * expects the Tasks panel to reflect CLI-submitted tasks within a + * few seconds. Errors here are surfaced via the snapshot's + * `error` field but do NOT touch the pending cadence. */ + const refreshTasks = useCallback(async () => { + if (tasksInFlight.current) return; + tasksInFlight.current = true; + try { + const [tasks, repos] = await Promise.all([ + source.listTasks(), + source.listRegisteredRepos(), + ]); + setSnapshot((prev) => ({ + ...prev, + tasks, + repos, + loading: false, + // Don't clobber a /pending error message — only clear the + // error if the previous one was a tasks/repos failure. + error: prev.rateLimited ? prev.error : null, + lastRefreshedAt: Date.now(), + })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setSnapshot((prev) => ({ + ...prev, + loading: false, + error: msg, + lastRefreshedAt: Date.now(), + })); + } finally { + tasksInFlight.current = false; + } + }, [source]); + + /** Refresh the pending-approvals list. Adaptive cadence + 429 jump + * live here; this is the only endpoint the rate-limit applies to + * and the only one that needs to back off during idle time. */ + const refreshPending = useCallback(async () => { + if (pendingInFlight.current) return; + pendingInFlight.current = true; + try { + const approvals = await source.listPending(); + if (pollIntervalMs === undefined) { + cadenceRef.current = nextPendingCadence(cadenceRef.current, { + sawPending: approvals.length > 0, + rateLimited: false, + }); + } + setSnapshot((prev) => ({ + ...prev, + approvals, + rateLimited: false, + // Clear a previous rate-limit error on successful poll. + error: prev.rateLimited ? null : prev.error, + pollIntervalMs: + pollIntervalMs ?? cadenceRef.current.intervalMs, + })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const rateLimited = isRateLimitError(err); + if (pollIntervalMs === undefined && rateLimited) { + cadenceRef.current = nextPendingCadence(cadenceRef.current, { + sawPending: false, + rateLimited: true, + }); + } + setSnapshot((prev) => ({ + ...prev, + error: rateLimited + ? 'Rate limit reached on /v1/pending — slowing down polls' + : msg, + rateLimited, + pollIntervalMs: + pollIntervalMs ?? cadenceRef.current.intervalMs, + })); + } finally { + pendingInFlight.current = false; + } + }, [source, pollIntervalMs]); + + /** Convenience wrapper for callers that want both lists fresh + * immediately (submit/approve/deny). Public via the context. */ + const refresh = useCallback(async () => { + await Promise.all([refreshTasks(), refreshPending()]); + }, [refreshTasks, refreshPending]); + + /** Public action: reset the /pending cadence to fast and trigger an + * immediate refresh. Approvals panel calls this on mount/activate + * so the user sees fresh data within a frame even if the ladder + * had backed off to 30 s during idle time. */ + const resetPendingCadence = useCallback(() => { + if (pollIntervalMs !== undefined) return; // pinned mode — no-op + cadenceRef.current = INITIAL_PENDING_CADENCE; + setPendingResetTick((n) => n + 1); + }, [pollIntervalMs]); + + const loadPolicies = useCallback(async (repoId: string) => { + if (!repoId) return; + try { + const policies = await source.listPolicies(repoId); + setSnapshot((prev) => { + const next = new Map(prev.policiesByRepo); + next.set(repoId, policies); + return { ...prev, policiesByRepo: next }; + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setSnapshot((prev) => ({ ...prev, error: msg })); + } + }, [source]); + + const getTaskEvents = useCallback( + (taskId: string, opts?: { after?: string }) => source.getTaskEvents(taskId, opts), + [source], + ); + + const submitTask = useCallback(async (input: SubmitTaskInput): Promise => { + const row = await source.submitTask(input); + // Fire-and-forget refresh so the new task appears in the list. + void refresh(); + return row; + }, [source, refresh]); + + const approve = useCallback(async (taskId: string, requestId: string, scope?: ApprovalScope) => { + await source.approve(taskId, requestId, scope); + void refresh(); + }, [source, refresh]); + + const deny = useCallback(async (taskId: string, requestId: string, reason?: string) => { + await source.deny(taskId, requestId, reason); + void refresh(); + }, [source, refresh]); + + // ── Polling effect: tasks + repos ───────────────────────────── + // Fixed cadence (3 s by default, overridable via `pollIntervalMs` + // for tests/diagnostics). Endpoints aren't rate-limited so the + // adaptive ladder doesn't apply. + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + const tick = async () => { + if (cancelled) return; + await refreshTasks(); + if (cancelled) return; + const ms = pollIntervalMs ?? DEFAULT_TASKS_POLL_INTERVAL_MS; + timer = globalThis.setTimeout(() => { void tick(); }, ms); + }; + void tick(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [refreshTasks, pollIntervalMs]); + + // ── Polling effect: pending approvals ───────────────────────── + // Adaptive cadence (3 → 5 → 10 → 30 s on consecutive empty polls, + // jumps to 30 s on 429). Re-runs the scheduling effect when + // `pendingResetTick` increments — the Approvals panel calls + // `resetPendingCadence()` on mount, which bumps the tick + resets + // `cadenceRef` so the next poll fires at the fast cadence. + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + const tick = async () => { + if (cancelled) return; + await refreshPending(); + if (cancelled) return; + const ms = pollIntervalMs ?? cadenceRef.current.intervalMs; + timer = globalThis.setTimeout(() => { void tick(); }, ms); + }; + void tick(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [refreshPending, pollIntervalMs, pendingResetTick]); + + const value: DataContextShape = useMemo( + () => ({ + snapshot, + source, + refresh, + resetPendingCadence, + getTaskEvents, + loadPolicies, + submitTask, + approve, + deny, + }), + [snapshot, source, refresh, resetPendingCadence, getTaskEvents, loadPolicies, submitTask, approve, deny], + ); + + return ( + + {children} + + ); +}; + +/** Panels read the snapshot here. Throws if used outside the + * provider — the provider wraps `App` in `index.tsx`. */ +export function useData(): DataContextShape { + const ctx = useContext(DataContext); + if (!ctx) { + throw new Error('useData must be used inside '); + } + return ctx; +} diff --git a/cli/src/tui/index.tsx b/cli/src/tui/index.tsx new file mode 100644 index 00000000..dec6fbd9 --- /dev/null +++ b/cli/src/tui/index.tsx @@ -0,0 +1,117 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * TUI bootstrap. Exports `runTui()` for both the standalone + * `npm run tui` dev path and the `bgagent tui` subcommand. + */ +import { pathToFileURL } from 'node:url'; +import { render } from 'ink'; +import React from 'react'; +import type { DataSource } from './api/source.js'; +import App from './App.js'; +import ErrorBoundary from './components/ErrorBoundary.js'; +import { TuiProvider } from './context.js'; +import { DataProvider } from './hooks/useData.js'; + +// ── Alt screen buffer ─────────────────────────────────────────── +// Like vim/htop — no scrollback, clean repaint. +const enterAltScreen = () => { + process.stdout.write('\x1b[?1049h'); // enter alt buffer + process.stdout.write('\x1b[?25l'); // hide cursor +}; + +const leaveAltScreen = () => { + process.stdout.write('\x1b[?25h'); // show cursor + process.stdout.write('\x1b[?1049l'); // leave alt buffer +}; + +export interface RunTuiOptions { + /** Inject a specific data source (used by the subcommand when it + * wants to force mock or live regardless of the env flag). */ + readonly source?: DataSource; +} + +/** Start the TUI and return a Promise that resolves when the user + * exits (Ctrl+C or `q`). Safe to call from a subcommand action. */ +export async function runTui(opts: RunTuiOptions = {}): Promise { + enterAltScreen(); + + const cleanup = () => { leaveAltScreen(); }; + const onSigint = () => { cleanup(); process.exit(0); }; + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigint); + process.on('uncaughtException', (err) => { + cleanup(); + // eslint-disable-next-line no-console -- crash handler intentionally writes to stderr after restoring the terminal + console.error(err); + process.exit(1); + }); + // Phase A live drive (task 01KS18SAV6PPR4XVZPAHF2EJF5) caught the + // case where an async effect — specifically a rejected approve() + // round-trip from a fire-and-forget call site — bubbled out as an + // unhandled rejection and terminated the TUI under Node 20+ default + // behaviour. The fire-and-forget itself is now gone (see + // context.tsx + Approvals.tsx + Watch.tsx) but a defensive handler + // here means a single forgotten `void` somewhere in the future + // produces a logged warning rather than an exit-1 process death + // that drops the user out of the alt-screen mid-approval. Same + // restore-then-error pattern as the uncaughtException case but + // exits cleanly so the parent shell doesn't see a non-zero status + // for what is fundamentally a non-fatal background failure. + process.on('unhandledRejection', (reason) => { + cleanup(); + // eslint-disable-next-line no-console -- crash handler intentionally writes to stderr after restoring the terminal + console.error('Unhandled promise rejection in TUI:', reason); + process.exit(1); + }); + + const { waitUntilExit } = render( + + + + + + + , + ); + + try { + await waitUntilExit(); + } finally { + cleanup(); + process.removeListener('SIGINT', onSigint); + process.removeListener('SIGTERM', onSigint); + } +} + +// Boot standalone when invoked directly via `npm run tui`. +// Works in ESM (TUI is compiled with `module: Node16` + `type: module` +// in the emitted package.json) via `import.meta.url` comparison. +// `require.main === module` would ReferenceError here. +const invokedDirectly = typeof process !== 'undefined' + && process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) { + runTui().catch((err) => { + // eslint-disable-next-line no-console -- standalone-launch crash logger; alt-screen already torn down + console.error(err); + process.exit(1); + }); +} diff --git a/cli/src/tui/mock/data.ts b/cli/src/tui/mock/data.ts new file mode 100644 index 00000000..7b733eb2 --- /dev/null +++ b/cli/src/tui/mock/data.ts @@ -0,0 +1,514 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Mock fixture source for the TUI. + * + * Wire shapes here mirror the real API contract in `cli/src/types.ts` + * (TaskDetail, PendingApprovalSummary, PolicyRuleSummary). The TUI + * data-layer (`tui/data.ts`) adapts either these mocks or live API + * responses into view-models the panels bind to, so the panels never + * need to know which source they came from. + * + * The exception is `CedarPolicyFixture.cedar_source`: the real + * `PolicyRuleSummary` intentionally does NOT expose raw Cedar source. + * We keep it on the fixture so the existing "view source" demo still + * works in mock mode; in real mode the Policies panel hides that pane. + */ + +import type { + ApprovalScope, + PendingApprovalSummary, + PolicyRuleSummary, + TaskDetail, + TaskEvent, + TaskType, +} from '../../types.js'; + +// ─── Fixture-only extensions ───────────────────────────────────────── +// +// These widen the wire shapes with mock-only attributes (Cedar source, +// registered repos). Panels MUST NOT read these directly — they get +// normalized-or-hidden at the data-layer boundary. + +export interface RegisteredRepoFixture { + readonly repo: string; + readonly status: 'active' | 'removed'; + readonly default_branch: string; +} + +/** Mock-only: raw Cedar source for the Policies panel's "view source" + * detail pane. Not on `PolicyRuleSummary` — the live API omits raw + * source from its response (see design §8.1). */ +export interface CedarPolicyFixture extends PolicyRuleSummary { + readonly tier: 'hard' | 'soft'; + readonly action: string; + readonly condition_summary: string; + readonly cedar_source: string; +} + +/** Mock-only: the pending-approval fixture carries `repo` + + * `task_description` so the Approvals list can show them without an + * extra join. In real mode the data-layer hydrates those fields by + * GET-ing the corresponding TaskDetail. */ +export interface PendingApprovalFixture extends PendingApprovalSummary { + readonly repo: string; + readonly task_description: string; + readonly status: 'PENDING'; +} + +/** Mock-only: the task fixture carries enough of TaskDetail for the + * watch/list panels (turns, budget, approval-gate counters). Real + * mode reads the full TaskDetail directly. */ +export type TaskFixture = Pick< + TaskDetail, + | 'task_id' + | 'status' + | 'repo' + | 'issue_number' + | 'task_type' + | 'pr_number' + | 'task_description' + | 'branch_name' + | 'pr_url' + | 'channel_source' + | 'created_at' + | 'updated_at' + | 'cost_usd' + | 'duration_s' + | 'max_turns' + | 'turns_attempted' + | 'turns_completed' + | 'approval_gate_count' + | 'approval_gate_cap' + | 'awaiting_approval_request_id' +>; + +// ─── Fixture data ──────────────────────────────────────────────────── + +export const MOCK_REPOS: readonly RegisteredRepoFixture[] = [ + { repo: 'aws-samples/my-project', status: 'active', default_branch: 'main' }, + { repo: 'aws-samples/billing-service', status: 'active', default_branch: 'main' }, + { repo: 'aws-samples/auth-lib', status: 'active', default_branch: 'develop' }, +]; + +const NOW = new Date(); +function minutesAgo(m: number): string { + return new Date(NOW.getTime() - m * 60_000).toISOString(); +} + +export const MOCK_TASKS: readonly TaskFixture[] = [ + { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + status: 'RUNNING', + repo: 'aws-samples/my-project', + issue_number: 42, + task_type: 'new_task' as TaskType, + pr_number: null, + task_description: 'Add input validation to the /api/users endpoint using zod schemas', + branch_name: 'agent/input-validation-42', + pr_url: null, + channel_source: 'api', + created_at: minutesAgo(3), + updated_at: minutesAgo(0.1), + cost_usd: 0.1847, + duration_s: null, + max_turns: 8, + turns_attempted: 3, + turns_completed: 3, + approval_gate_count: 1, + approval_gate_cap: 50, + awaiting_approval_request_id: null, + }, + { + task_id: '01JBX5QPKR2MN8HW1FS6AY4M2', + status: 'AWAITING_APPROVAL', + repo: 'aws-samples/my-project', + issue_number: 38, + task_type: 'new_task' as TaskType, + pr_number: null, + task_description: 'Fix the failing unit tests in the auth module and update snapshots', + branch_name: 'agent/fix-auth-tests-38', + pr_url: null, + channel_source: 'slack', + created_at: minutesAgo(15), + updated_at: minutesAgo(0.5), + cost_usd: 0.3412, + duration_s: null, + max_turns: 10, + turns_attempted: 5, + turns_completed: 5, + approval_gate_count: 2, + approval_gate_cap: 50, + awaiting_approval_request_id: '01JBX5TTPK5SW1HN4LF8PZ0D3V', + }, + { + task_id: '01JBX3RTMK7QN2HW9FS4AY8P8', + status: 'COMPLETED', + repo: 'acme-corp/backend-api', + issue_number: 29, + task_type: 'new_task' as TaskType, + pr_number: 156, + task_description: 'Refactor database connection pooling to use pgbouncer', + branch_name: 'agent/refactor-db-pool-29', + pr_url: 'https://github.com/acme-corp/backend-api/pull/156', + channel_source: 'linear', + created_at: minutesAgo(62), + updated_at: minutesAgo(15), + cost_usd: 0.8923, + duration_s: 2847, + max_turns: 15, + turns_attempted: 12, + turns_completed: 12, + approval_gate_count: 4, + approval_gate_cap: 50, + awaiting_approval_request_id: null, + }, + { + task_id: '01JBX1WQNR3PG7HW5FS2AY6L4', + status: 'FAILED', + repo: 'acme-corp/frontend', + issue_number: 55, + task_type: 'new_task' as TaskType, + pr_number: null, + task_description: 'Migrate the dashboard from Class components to React hooks', + branch_name: 'agent/migrate-hooks-55', + pr_url: null, + channel_source: 'webhook', + created_at: minutesAgo(120), + updated_at: minutesAgo(30), + cost_usd: 1.2345, + duration_s: 5400, + max_turns: 15, + turns_attempted: 15, + turns_completed: 15, + approval_gate_count: 3, + approval_gate_cap: 50, + awaiting_approval_request_id: null, + }, +]; + +// ─── Mock Events (for watch stream) ────────────────────────────────── + +let eventCounter = 0; +function eid(): string { + return `01JBX7EVT${String(++eventCounter).padStart(6, '0')}`; +} + +export const MOCK_EVENTS: readonly TaskEvent[] = [ + { + event_id: eid(), + event_type: 'task_started', + timestamp: minutesAgo(3), + metadata: { task_id: MOCK_TASKS[0].task_id }, + }, + { + event_id: eid(), + event_type: 'turn_start', + timestamp: minutesAgo(2.9), + metadata: { task_id: MOCK_TASKS[0].task_id, turn: 1 }, + }, + { + event_id: eid(), + event_type: 'tool_call', + timestamp: minutesAgo(2.8), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'ReadFile', args_preview: 'src/api/users.ts' }, + }, + { + event_id: eid(), + event_type: 'tool_result', + timestamp: minutesAgo(2.7), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'ReadFile', status: 'success', preview: '1.2KB read' }, + }, + { + event_id: eid(), + event_type: 'tool_call', + timestamp: minutesAgo(2.5), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'ReadFile', args_preview: 'package.json' }, + }, + { + event_id: eid(), + event_type: 'tool_result', + timestamp: minutesAgo(2.4), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'ReadFile', status: 'success', preview: '0.8KB read' }, + }, + { + event_id: eid(), + event_type: 'milestone', + timestamp: minutesAgo(2.3), + metadata: { task_id: MOCK_TASKS[0].task_id, message: 'Analyzed codebase structure. Found Express + TypeScript stack.' }, + }, + { + event_id: eid(), + event_type: 'turn_start', + timestamp: minutesAgo(2.2), + metadata: { task_id: MOCK_TASKS[0].task_id, turn: 2 }, + }, + { + event_id: eid(), + event_type: 'tool_call', + timestamp: minutesAgo(2.1), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'Bash', args_preview: 'npm install zod' }, + }, + { + event_id: eid(), + event_type: 'approval_requested', + timestamp: minutesAgo(2.1), + metadata: { + task_id: MOCK_TASKS[0].task_id, + request_id: '01JBX7RRPK3QW9FM2JD6NX8B1T', + tool_name: 'Bash', + input_preview: 'npm install zod', + reason: 'Shell command execution requires approval', + severity: 'high', + matching_rule_ids: ['bash_exec_gate'], + timeout_s: 600, + }, + }, + { + event_id: eid(), + event_type: 'approval_granted', + timestamp: minutesAgo(1.8), + metadata: { task_id: MOCK_TASKS[0].task_id, request_id: '01JBX7RRPK3QW9FM2JD6NX8B1T', scope: 'this_call' }, + }, + { + event_id: eid(), + event_type: 'tool_result', + timestamp: minutesAgo(1.6), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'Bash', status: 'success', preview: 'added 1 package' }, + }, + { + event_id: eid(), + event_type: 'tool_call', + timestamp: minutesAgo(1.5), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'EditFile', args_preview: 'src/api/users.ts' }, + }, + { + event_id: eid(), + event_type: 'approval_requested', + timestamp: minutesAgo(1.5), + metadata: { + task_id: MOCK_TASKS[0].task_id, + request_id: '01JBX7SSPK4RW0GM3KE7OY9C2U', + tool_name: 'EditFile', + input_preview: 'src/api/users.ts — Replace validation with zod schema', + reason: 'File modification requires approval (hard-gate: file_edit_gate)', + severity: 'medium', + matching_rule_ids: ['file_edit_gate'], + timeout_s: 600, + }, + }, + { + event_id: eid(), + event_type: 'cost_update', + timestamp: minutesAgo(1.4), + metadata: { task_id: MOCK_TASKS[0].task_id, total_usd: 0.1847, input_tokens: 12400, output_tokens: 3200 }, + }, + { + event_id: eid(), + event_type: 'turn_start', + timestamp: minutesAgo(1.0), + metadata: { task_id: MOCK_TASKS[0].task_id, turn: 3 }, + }, + { + event_id: eid(), + event_type: 'tool_call', + timestamp: minutesAgo(0.8), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'ReadFile', args_preview: 'src/api/middleware/validate.ts' }, + }, + { + event_id: eid(), + event_type: 'tool_result', + timestamp: minutesAgo(0.7), + metadata: { task_id: MOCK_TASKS[0].task_id, tool_name: 'ReadFile', status: 'success', preview: '0.4KB read' }, + }, +]; + +// ─── Mock Pending Approvals ────────────────────────────────────────── + +export const MOCK_PENDING_APPROVALS: readonly PendingApprovalFixture[] = [ + { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + request_id: '01JBX7SSPK4RW0GM3KE7OY9C2U', + tool_name: 'EditFile', + tool_input_preview: 'src/api/users.ts — Replace existing validation (lines 42-58) with zod schema: const userSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email() })', + reason: 'File modification requires approval (hard-gate: file_edit_gate)', + severity: 'medium', + matching_rule_ids: ['file_edit_gate'], + created_at: minutesAgo(1.5), + timeout_s: 600, + expires_at: new Date(NOW.getTime() + (600 - 90) * 1000).toISOString(), + status: 'PENDING', + repo: 'aws-samples/my-project', + task_description: 'Add input validation to the /api/users endpoint using zod schemas', + }, + { + task_id: '01JBX5QPKR2MN8HW1FS6AY4M2', + request_id: '01JBX5TTPK5SW1HN4LF8PZ0D3V', + tool_name: 'Bash', + tool_input_preview: 'npm test -- --updateSnapshot', + reason: 'Shell command execution requires approval (hard-gate: bash_exec_gate)', + severity: 'high', + matching_rule_ids: ['bash_exec_gate'], + created_at: minutesAgo(0.5), + timeout_s: 600, + expires_at: new Date(NOW.getTime() + (600 - 30) * 1000).toISOString(), + status: 'PENDING', + repo: 'aws-samples/my-project', + task_description: 'Fix the failing unit tests in the auth module and update snapshots', + }, +]; + +// ─── Mock Cedar Policies ───────────────────────────────────────────── +// Grouped by the API's `hard`/`soft` buckets. Mock-only fields: +// `action`, `condition_summary`, `cedar_source` — these are not on +// `PolicyRuleSummary` and are hidden in real mode. + +export const MOCK_POLICIES_HARD: readonly CedarPolicyFixture[] = [ + { + rule_id: 'rm_slash', + tier: 'hard', + summary: 'Block rm -rf / and variants', + category: 'destructive', + action: 'execute_bash', + condition_summary: 'command matches *rm -rf /*', + cedar_source: '@tier("hard-deny")\n@rule_id("rm_slash")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*rm -rf /*" };', + }, + { + rule_id: 'write_git_internals', + tier: 'hard', + summary: 'Block writes to .git/ directory', + category: 'filesystem', + action: 'write_file', + condition_summary: 'file_path matches .git/*', + cedar_source: '@tier("hard-deny")\n@rule_id("write_git_internals")\nforbid (principal, action == Agent::Action::"write_file", resource)\n when { context.file_path like ".git/*" };', + }, + { + rule_id: 'drop_table', + tier: 'hard', + summary: 'Block DROP TABLE commands', + category: 'destructive', + action: 'execute_bash', + condition_summary: 'command matches *DROP TABLE*', + cedar_source: '@tier("hard-deny")\n@rule_id("drop_table")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*DROP TABLE*" };', + }, + { + rule_id: 'force_push_main', + tier: 'hard', + summary: 'Block force-push to main/prod branches', + severity: 'high', + category: 'destructive', + action: 'execute_bash', + condition_summary: 'command matches *git push --force origin main*', + cedar_source: '@tier("hard-deny")\n@rule_id("force_push_main")\n@severity("high")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*git push --force origin main*" };', + }, +]; + +export const MOCK_POLICIES_SOFT: readonly CedarPolicyFixture[] = [ + { + rule_id: 'bash_exec_gate', + tier: 'soft', + summary: 'Shell command execution requires approval', + severity: 'high', + category: 'auth', + approval_timeout_s: 600, + action: 'execute_bash', + condition_summary: 'all bash commands (catch-all)', + cedar_source: '@tier("hard-gate")\n@rule_id("bash_exec_gate")\n@severity("high")\n@approval_timeout_s("600")\nforbid (principal, action == Agent::Action::"execute_bash", resource);', + }, + { + rule_id: 'file_edit_gate', + tier: 'soft', + summary: 'File modifications require approval', + severity: 'medium', + category: 'filesystem', + approval_timeout_s: 600, + action: 'write_file', + condition_summary: 'all file writes and edits', + cedar_source: '@tier("hard-gate")\n@rule_id("file_edit_gate")\n@severity("medium")\n@approval_timeout_s("600")\nforbid (principal, action == Agent::Action::"write_file", resource);', + }, + { + rule_id: 'deploy_staging', + tier: 'soft', + summary: 'Terraform/CDK deploy requires approval', + severity: 'high', + category: 'destructive', + approval_timeout_s: 900, + action: 'execute_bash', + condition_summary: 'command matches *terraform apply* or *cdk deploy*', + cedar_source: '@tier("hard-gate")\n@rule_id("deploy_staging")\n@severity("high")\n@approval_timeout_s("900")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*terraform apply*" };', + }, + { + rule_id: 'npm_install_gate', + tier: 'soft', + summary: 'Package installation requires approval', + severity: 'medium', + category: 'auth', + approval_timeout_s: 300, + action: 'execute_bash', + condition_summary: 'command matches *npm install* or *yarn add*', + cedar_source: '@tier("hard-gate")\n@rule_id("npm_install_gate")\n@severity("medium")\n@approval_timeout_s("300")\nforbid (principal, action == Agent::Action::"execute_bash", resource)\n when { context.command like "*npm install*" };', + }, +]; + +export const MOCK_POLICIES: readonly CedarPolicyFixture[] = [ + ...MOCK_POLICIES_HARD, + ...MOCK_POLICIES_SOFT, +]; + +// ─── Mutations (mock-only; no-op on real backend) ──────────────────── + +/** Mock: seed a new task and return its fixture. Real mode calls + * `ApiClient.createTask` directly; the Submit panel does not import + * this. */ +export function submitMockTask( + repo: string, + description: string, + extras?: { + approval_timeout_s?: number; + initial_approvals?: readonly ApprovalScope[]; + }, +): TaskFixture { + void extras; // recorded only for parity — mock doesn't persist it. + const id = '01JBX9' + Math.random().toString(36).slice(2, 8).toUpperCase(); + const t: TaskFixture = { + task_id: id, + status: 'SUBMITTED', + repo, + issue_number: null, + task_type: 'new_task', + pr_number: null, + task_description: description, + branch_name: `agent/${id.slice(-6).toLowerCase()}`, + pr_url: null, + channel_source: 'api', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + cost_usd: null, + duration_s: null, + max_turns: 8, + turns_attempted: 0, + turns_completed: 0, + approval_gate_count: 0, + approval_gate_cap: 50, + awaiting_approval_request_id: null, + }; + (MOCK_TASKS as TaskFixture[]).push(t); + return t; +} diff --git a/cli/src/tui/package.json b/cli/src/tui/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/cli/src/tui/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/cli/src/tui/panels/Approvals.tsx b/cli/src/tui/panels/Approvals.tsx new file mode 100644 index 00000000..b9bb2dad --- /dev/null +++ b/cli/src/tui/panels/Approvals.tsx @@ -0,0 +1,415 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text, useInput } from 'ink'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import type { ApprovalScope } from '../../types.js'; +import DenyReasonInput from '../components/DenyReasonInput.js'; +import ScopePicker from '../components/ScopePicker.js'; +import { SEVERITY_COLOR, SEVERITY_LABEL, trunc, fmtDuration } from '../constants.js'; +import { useApprovals, useEditing } from '../context.js'; +import { SEPARATOR_WIDTH, TRUNC_TOOL_INPUT, TRUNC_REASON, TRUNC_DESCRIPTION_LONG } from '../data.js'; +import { useData } from '../hooks/useData.js'; + +interface ApprovalsProps { + active: boolean; + onDetailChange?: (inDetail: boolean) => void; +} + +const Approvals: React.FC = ({ active, onDetailChange }) => { + const { approvals, approve, deny } = useApprovals(); + const { resetPendingCadence } = useData(); + const { setEditing } = useEditing(); + + // When the panel becomes active, reset the /v1/pending cadence to + // fast (3 s) and trigger an immediate refresh. Without this, after + // sitting idle on Tasks for a while the pending ladder will have + // backed off to 30 s, and the user would see stale approvals on + // entry. This is the targeted-Option-4 piece of the cadence fix. + useEffect(() => { + if (active) resetPendingCadence(); + }, [active, resetPendingCadence]); + const [cursor, setCursor] = useState(0); + const [message, setMessage] = useState(''); + const [confirmDeny, setConfirmDeny] = useState(null); + const [denyReasonFor, setDenyReasonFor] = useState(null); + const [scopePickerFor, setScopePickerFor] = useState(null); + const [detailId, setDetailId] = useState(null); + const [, setTick] = useState(0); + const msgTimer = useRef | null>(null); + + useEffect(() => { + const timer = setInterval(() => setTick(t => t + 1), 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => () => { if (msgTimer.current) clearTimeout(msgTimer.current); }, []); + + const showMessage = useCallback((msg: string) => { + setMessage(msg); + if (msgTimer.current) clearTimeout(msgTimer.current); + msgTimer.current = globalThis.setTimeout(() => setMessage(''), 4000); + }, []); + + const { byTask, flatList } = useMemo(() => { + const grouped = new Map(); + for (const a of approvals) { + const arr = grouped.get(a.task_id) ?? []; + arr.push(a); + grouped.set(a.task_id, arr); + } + return { byTask: grouped, flatList: Array.from(grouped.values()).flat() }; + }, [approvals]); + + useEffect(() => { + if (flatList.length === 0) { setCursor(0); setDetailId(null); } else if (cursor >= flatList.length) {setCursor(flatList.length - 1);} + }, [flatList.length, cursor]); + + // Close detail if the viewed approval got resolved + useEffect(() => { + if (detailId && !flatList.find(a => a.request_id === detailId)) { + setDetailId(null); + } + }, [flatList, detailId]); + + // Notify parent of detail mode changes + useEffect(() => { + onDetailChange?.(!!detailId); + }, [detailId, onDetailChange]); + + useEffect(() => { + if (scopePickerFor) setEditing(true, 'scope-picker'); + else if (denyReasonFor) setEditing(true, 'text'); + else if (confirmDeny) setEditing(true, 'deny-confirm'); + else setEditing(false); + return () => setEditing(false); + }, [confirmDeny, denyReasonFor, scopePickerFor, setEditing]); + + const elapsed = (iso: string): number => Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + + /** Seconds remaining before the approval auto-denies. Prefer the + * server's `expires_at` (authoritative — the server already + * accounts for cap clipping etc.); fall back to derived + * `timeout_s - elapsed(created_at)` only if expires_at is + * missing. */ + const computeRemaining = (expiresAt: string, createdAt: string, timeoutS: number): number => { + if (expiresAt) { + const ms = new Date(expiresAt).getTime() - Date.now(); + return Math.max(0, Math.floor(ms / 1000)); + } + return Math.max(0, timeoutS - elapsed(createdAt)); + }; + + // The approval currently being viewed in detail (or selected in list) + const activeApproval = detailId + ? flatList.find(a => a.request_id === detailId) + : flatList[cursor]; + + useInput(useCallback((input, key) => { + if (!active) return; + + // Child overlays own input while mounted. + if (scopePickerFor || denyReasonFor) return; + + // Deny confirmation — pressing y now opens the reason input. + if (confirmDeny) { + if (input === 'y' || input === 'Y') { + setDenyReasonFor(confirmDeny); + setConfirmDeny(null); return; + } + if (key.escape || input === 'n' || input === 'N') { setConfirmDeny(null); return; } + return; + } + + // Detail view mode + if (detailId) { + if (key.escape) { setDetailId(null); return; } + if (input === 'a' && activeApproval) { + setScopePickerFor(activeApproval.request_id); + return; + } + if (input === 'd' && activeApproval) { setConfirmDeny(activeApproval.request_id); return; } + return; + } + + // List view + if (flatList.length === 0) return; + if (key.upArrow) setCursor(c => Math.max(0, c - 1)); + if (key.downArrow) setCursor(c => Math.min(flatList.length - 1, c + 1)); + + // Enter → detail view + if (key.return && flatList[cursor]) { + setDetailId(flatList[cursor].request_id); + return; + } + + // Quick approve/deny from list → open picker / reason input + const selected = flatList[cursor]; + if (input === 'a' && selected) { setScopePickerFor(selected.request_id); } + if (input === 'd' && selected) { setConfirmDeny(selected.request_id); } + }, [active, flatList, cursor, confirmDeny, detailId, activeApproval, scopePickerFor, denyReasonFor])); + + const handleApproveWithScope = useCallback((scope: ApprovalScope) => { + const a = flatList.find(x => x.request_id === scopePickerFor); + const requestId = scopePickerFor; + setScopePickerFor(null); + setDetailId(null); + if (!a || !requestId) return; + // Await the round-trip so the success message reflects the API's + // actual decision rather than the user's optimistic intent. Phase A + // live drive caught the silent-failure case where the toast lied + // and the agent stayed blocked until timeout. + void (async () => { + const result = await approve(requestId, scope); + if (result.ok) { + showMessage(`${figures.tick} Approved ${a.tool_name} for ..${a.task_id.slice(-4)} (${scope})`); + } else { + showMessage(`${figures.cross} Approve failed for ..${a.task_id.slice(-4)} — ${trunc(result.error, 60)}`); + } + })(); + }, [flatList, scopePickerFor, approve, showMessage]); + + const handleDenyWithReason = useCallback((reason: string) => { + const a = flatList.find(x => x.request_id === denyReasonFor); + const requestId = denyReasonFor; + setDenyReasonFor(null); + setDetailId(null); + if (!a || !requestId) return; + void (async () => { + const result = await deny(requestId, reason || undefined); + if (result.ok) { + showMessage(`${figures.cross} Denied ${a.tool_name} for ..${a.task_id.slice(-4)}${reason ? ` — "${trunc(reason, 30)}"` : ''}`); + } else { + showMessage(`${figures.cross} Deny failed for ..${a.task_id.slice(-4)} — ${trunc(result.error, 60)}`); + } + })(); + }, [flatList, denyReasonFor, deny, showMessage]); + + let renderIdx = 0; + + // ── Detail view ───────────────────────────────────────────────── + + if (detailId && activeApproval) { + const a = activeApproval; + const sev = a.severity; // already UPPERCASE per view-model contract + const remaining = computeRemaining(a.expires_at, a.created_at, a.timeout_s); + const timeColor = remaining <= 120 ? 'red' : remaining <= 300 ? 'yellow' : undefined; + + return ( + + + {figures.warning} Approval Detail + Esc to go back + + + + {/* Severity + timeout header */} + + {figures.warning} {SEVERITY_LABEL[sev] ?? sev} + + Timeout: + {fmtDuration(remaining)} + {remaining <= 120 && {figures.warning}} + + + + + + {/* Task context */} + Task: ..{a.task_id.slice(-4)} {a.repo} + + Goal: + {a.task_description} + + + + + {/* What the agent wants to do */} + + Wants to: + + {a.tool_name} + {figures.arrowRight} + + {a.tool_input_preview} + + + + + {/* Why */} + + Why: + {a.reason} + + + {/* Matching rules */} + {a.matching_rule_ids.length > 0 && ( + <> + + + Triggered by: + {a.matching_rule_ids.join(', ')} + + + )} + + + + + {/* Actions */} + + [a] Approve + [d] Deny + [Esc] Back to list + + + {/* Deny confirmation (overlays in detail view) */} + {confirmDeny && ( + + {figures.warning} Confirm deny? + The agent will be blocked and may not be able to continue. + [y] Add reason [n] Cancel + + )} + + {scopePickerFor && ( + setScopePickerFor(null)} + /> + )} + + {denyReasonFor && ( + setDenyReasonFor(null)} + /> + )} + + {message && !confirmDeny && !scopePickerFor && !denyReasonFor && ( + {message} + )} + + ); + } + + // ── List view ─────────────────────────────────────────────────── + + return ( + + + {figures.warning} Pending Approvals + {flatList.length} pending across {byTask.size} task{byTask.size !== 1 ? 's' : ''} + + + {flatList.length === 0 ? ( + + {figures.tick} No pending approvals. All clear! + Approvals appear here when agents need permission to proceed. + + ) : ( + Array.from(byTask.entries()).map(([taskId, taskApprovals]) => { + const first = taskApprovals[0]; + return ( + + {'─'.repeat(SEPARATOR_WIDTH)} + + Task: + ..{taskId.slice(-4)} + {first.repo} + + + Goal: + {trunc(first.task_description, TRUNC_DESCRIPTION_LONG)} + + + {taskApprovals.map(a => { + const idx = renderIdx++; + const sel = idx === cursor && active; + const sev = a.severity; // already UPPERCASE + const remaining = computeRemaining(a.expires_at, a.created_at, a.timeout_s); + const timeColor = remaining <= 120 ? 'red' : remaining <= 300 ? 'yellow' : undefined; + + return ( + + + {sel ? figures.pointer + ' ' : ' '} + {figures.warning} {SEVERITY_LABEL[sev] ?? sev} + + + Wants to: + {a.tool_name} + {figures.arrowRight} + {trunc(a.tool_input_preview, TRUNC_TOOL_INPUT)} + + + Why: + {trunc(a.reason, TRUNC_REASON)} + + + Timeout: + {fmtDuration(remaining)} + {remaining <= 120 && {figures.warning}} + + {sel && Enter for full detail} + + + ); + })} + + ); + }) + )} + + {confirmDeny && ( + + {figures.warning} Confirm deny? + The agent will be blocked and may not be able to continue. + [y] Add reason [n] Cancel + + )} + + {scopePickerFor && (() => { + const sel = flatList.find(x => x.request_id === scopePickerFor); + return sel ? ( + setScopePickerFor(null)} + /> + ) : null; + })()} + + {denyReasonFor && ( + setDenyReasonFor(null)} + /> + )} + + {message && !confirmDeny && !scopePickerFor && !denyReasonFor && ( + {message} + )} + + ); +}; + +export default Approvals; diff --git a/cli/src/tui/panels/Policies.tsx b/cli/src/tui/panels/Policies.tsx new file mode 100644 index 00000000..1a1591bb --- /dev/null +++ b/cli/src/tui/panels/Policies.tsx @@ -0,0 +1,228 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text, useInput } from 'ink'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { TIER_LABEL, TIER_COLOR, SEVERITY_COLOR, SEVERITY_LABEL, trunc } from '../constants.js'; +import { type PolicyRuleView } from '../data.js'; +import { useData } from '../hooks/useData.js'; + +interface PoliciesProps { active: boolean } + +const Policies: React.FC = ({ active }) => { + const { snapshot, loadPolicies, source } = useData(); + const [cursor, setCursor] = useState(0); + const [showDetail, setShowDetail] = useState(false); + const [repoCursor, setRepoCursor] = useState(0); + /** `null` = repo-picker step; string = viewing that repo's policies. */ + const [selectedRepo, setSelectedRepo] = useState(null); + + const repos = snapshot.repos; + + // Mock source returns identical policies for any repo, so we can + // auto-select the first repo when mounted. Live mode waits for the + // user to pick, so the UI surfaces rule hierarchy per-repo (the API + // returns repo-specific bundles). + useEffect(() => { + if (source.label === 'mock' && repos.length > 0 && selectedRepo === null) { + setSelectedRepo(repos[0].repo); + } + }, [source.label, repos, selectedRepo]); + + // Whenever the selected repo changes, ask the provider to fetch it + // (idempotent — the provider caches by repo_id). + useEffect(() => { + if (selectedRepo) { + void loadPolicies(selectedRepo); + } + }, [selectedRepo, loadPolicies]); + + const policies = selectedRepo + ? snapshot.policiesByRepo.get(selectedRepo) ?? { hard: [], soft: [] } + : { hard: [], soft: [] }; + const { hard: hardPolicies, soft: softPolicies } = policies; + const allOrdered = useMemo( + () => [...hardPolicies, ...softPolicies], + [hardPolicies, softPolicies], + ); + const total = allOrdered.length; + + useInput(useCallback((input, key) => { + if (!active) return; + + // Repo-picker step + if (selectedRepo === null) { + if (repos.length === 0) return; + if (key.upArrow) { setRepoCursor(c => Math.max(0, c - 1)); return; } + if (key.downArrow) { setRepoCursor(c => Math.min(repos.length - 1, c + 1)); return; } + if (key.return || input === ' ') { + setSelectedRepo(repos[repoCursor].repo); + return; + } + return; + } + + // Policies list step + if (key.upArrow) { setCursor(c => Math.max(0, c - 1)); } + if (key.downArrow) { setCursor(c => Math.min(allOrdered.length - 1, c + 1)); } + if (key.return) setShowDetail(d => !d); + if (key.escape) { + if (showDetail) { setShowDetail(false); return; } + // Esc from list → back to repo picker (only useful in live mode + // where there can be multiple repos). + if (source.label === 'live') { + setSelectedRepo(null); + setCursor(0); + } + } + }, [active, selectedRepo, repos, repoCursor, allOrdered.length, showDetail, source.label])); + + // Repo-picker view + if (selectedRepo === null) { + return ( + + + Safety Policies + pick a repo to see its Cedar rules + + {repos.length === 0 ? ( + No repos discovered yet. Submit a task first, or wait for the next refresh. + ) : ( + repos.map((r, i) => { + const focused = i === repoCursor; + return ( + + {focused ? figures.pointer + ' ' : ' '} + {r.repo} + ({r.default_branch}) + + ); + }) + )} + + ); + } + + const selected = allOrdered[cursor]; + let idx = 0; + + const renderPolicy = (p: PolicyRuleView) => { + const i = idx++; + const sel = i === cursor && active; + const tierColor = TIER_COLOR[p.tier]; + const tierIcon = p.tier === 'hard' ? figures.cross : figures.warning; + const sev = (p.severity ?? '').toUpperCase(); + + return ( + + {sel ? figures.pointer + ' ' : ' '} + {tierIcon} + {p.rule_id.padEnd(22)} + {sev ? ( + {(SEVERITY_LABEL[sev] ?? sev).padEnd(14)} + ) : ( + {''.padEnd(14)} + )} + {trunc(p.summary, 40)} + {sel && !showDetail && Enter to view} + + ); + }; + + return ( + + + Safety Policies + {total} rules for + {selectedRepo} + | powered by Cedar + + + + {figures.cross} {TIER_LABEL.hard} + — always prevented, cannot be overridden + + {hardPolicies.length === 0 ? ( + (none) + ) : hardPolicies.map(renderPolicy)} + + + + {figures.warning} {TIER_LABEL.soft} + — agent pauses and asks you first + + {softPolicies.length === 0 ? ( + (none) + ) : softPolicies.map(renderPolicy)} + + {showDetail && selected && ( + + + {selected.rule_id} + ({TIER_LABEL[selected.tier]}) + + {selected.action && ( + Triggers on: {selected.action} + )} + {selected.condition_summary && ( + When: {selected.condition_summary} + )} + {selected.summary && !selected.condition_summary && ( + Summary: {selected.summary} + )} + {selected.severity && (() => { + const sevKey = selected.severity.toUpperCase(); + return ( + + Risk level: + + {SEVERITY_LABEL[sevKey] ?? selected.severity} + + + ); + })()} + {selected.approval_timeout_s && ( + Timeout: {selected.approval_timeout_s}s — auto-denied if no response + )} + {selected.tier === 'soft' && ( + Skip with: --pre-approve rule:{selected.rule_id} + )} + {selected.cedar_source && ( + <> + + Cedar source: + {selected.cedar_source.split('\n').map((line, li) => ( + {line} + ))} + + )} + {!selected.cedar_source && ( + <> + + (Raw Cedar source not exposed by the API — see deployed policies in S3.) + + )} + + )} + + ); +}; + +export default Policies; diff --git a/cli/src/tui/panels/Submit.tsx b/cli/src/tui/panels/Submit.tsx new file mode 100644 index 00000000..6db35e16 --- /dev/null +++ b/cli/src/tui/panels/Submit.tsx @@ -0,0 +1,522 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text, useInput } from 'ink'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + APPROVAL_TIMEOUT_S_DEFAULT, + APPROVAL_TIMEOUT_S_MAX, + APPROVAL_TIMEOUT_S_MIN, + INITIAL_APPROVALS_MAX_ENTRIES, + type ApprovalScope, + type Attachment, +} from '../../types.js'; +import ScopePicker from '../components/ScopePicker.js'; +import { useEditing } from '../context.js'; +import { useData } from '../hooks/useData.js'; +import { useBracketedPaste } from '../utils/bracketed-paste.js'; +import { + readClipboardImage, + shouldShowHintOnce, + type ClipboardReadResult, +} from '../utils/clipboard.js'; + +interface SubmitProps { + active: boolean; + onSubmitted: (taskId: string) => void; +} + +type Field = 'repo' | 'prompt' | 'timeout' | 'approvals' | 'attachments' | 'submit'; +const FIELDS: Field[] = ['repo', 'prompt', 'timeout', 'approvals', 'attachments', 'submit']; + +/** UX cap on number of attachments. Server has no documented cap; + * this is a guard against UI-driven runaway pastes. */ +const MAX_ATTACHMENTS = 10; + +/** Local-only attachment row that pairs the wire shape with a + * display-friendly size hint. The wire shape (`Attachment`) is + * what we forward on submit. */ +interface AttachmentRow { + readonly attachment: Attachment; + readonly sizeBytes: number; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +const Submit: React.FC = ({ active, onSubmitted }) => { + const { setEditing } = useEditing(); + const { snapshot, submitTask } = useData(); + const repos = snapshot.repos; + + const [field, setField] = useState('repo'); + const [repoCursor, setRepoCursor] = useState(0); + const [prompt, setPrompt] = useState(''); + const [timeoutText, setTimeoutText] = useState(String(APPROVAL_TIMEOUT_S_DEFAULT)); + const [preApprovals, setPreApprovals] = useState([]); + const [attachments, setAttachments] = useState([]); + const [showScopePicker, setShowScopePicker] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitted, setSubmitted] = useState(false); + const [editingText, setEditingText] = useState<'prompt' | 'timeout' | null>(null); + /** Transient one-line message under the attachments row. Cleared + * after `TOAST_DURATION_MS`. Used for both success and failure + * signals so the user always gets feedback for a paste action. */ + const [toast, setToast] = useState<{ text: string; tone: 'ok' | 'warn' | 'err' } | null>(null); + const submitTimer = useRef | null>(null); + const toastTimer = useRef | null>(null); + + const fieldIdx = FIELDS.indexOf(field); + const selectedRepo = repos[repoCursor]?.repo ?? ''; + + useEffect(() => { + if (showScopePicker) { + setEditing(true, 'scope-picker'); + } else if (editingText) { + setEditing(true, 'text'); + } else { + setEditing(false); + } + return () => setEditing(false); + }, [editingText, showScopePicker, setEditing]); + + useEffect(() => () => { + if (submitTimer.current) clearTimeout(submitTimer.current); + if (toastTimer.current) clearTimeout(toastTimer.current); + }, []); + + const showToast = useCallback((text: string, tone: 'ok' | 'warn' | 'err') => { + setToast({ text, tone }); + if (toastTimer.current) clearTimeout(toastTimer.current); + toastTimer.current = globalThis.setTimeout(() => setToast(null), 4000); + }, []); + + /** Try to grab an image from the clipboard and append it. Used by + * both the Ctrl+V keybind (manual fallback) and the + * bracketed-paste hook (Cmd+V on macOS). */ + const tryPasteFromClipboard = useCallback(async () => { + if (!active || submitted || showScopePicker) return; + if (attachments.length >= MAX_ATTACHMENTS) { + showToast(`Attachment cap reached (${MAX_ATTACHMENTS})`, 'warn'); + return; + } + let result: ClipboardReadResult; + try { + result = await readClipboardImage(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showToast(`Clipboard read failed: ${msg}`, 'err'); + return; + } + if (result.ok) { + const ext = result.image.mediaType.split('/')[1]; + const filename = `pasted-${Date.now()}.${ext}`; + const att: AttachmentRow = { + attachment: { + type: 'image', + content_type: result.image.mediaType, + data: result.image.base64, + filename, + }, + sizeBytes: result.image.sizeBytes, + }; + setAttachments(prev => [...prev, att]); + showToast( + `${figures.tick} Pasted image (${formatBytes(result.image.sizeBytes)})`, + 'ok', + ); + return; + } + // Failure dispatch — keep messages concise + actionable. + const f = result.failure; + switch (f.kind) { + case 'empty': + showToast('Clipboard is empty', 'warn'); + break; + case 'not_image': + showToast('Clipboard does not contain an image', 'warn'); + break; + case 'too_large': + showToast( + `Image too large: ${formatBytes(f.sizeBytes)} > ${formatBytes(f.maxBytes)}`, + 'err', + ); + break; + case 'tool_missing': + // Hint cap: only show the multi-line install hint once per + // session so a user with a missing tool isn't drowned in + // identical toasts. The shorter "press paste failed" toast + // still fires every time so they know the action did + // something. + if (shouldShowHintOnce(`tool-missing-${f.platform}`)) { + showToast(f.hint, 'err'); + } else { + showToast('Clipboard tool missing — see earlier hint', 'err'); + } + break; + case 'unsupported_platform': + showToast(`Clipboard paste not yet supported on ${f.platform}`, 'err'); + break; + case 'error': + showToast(`Clipboard read error: ${f.message}`, 'err'); + break; + } + }, [active, submitted, showScopePicker, attachments.length, showToast]); + + // Bracketed-paste hook: Cmd+V (macOS native paste action) goes + // through this path. The terminal's paste action emits the + // bracketed-paste start marker; we read the OS clipboard right + // away while it still holds the image. Disabled when the panel + // is inactive so other panels' input isn't intercepted. + useBracketedPaste({ + enabled: active && !submitted, + onPaste: () => { void tryPasteFromClipboard(); }, + }); + + const parsedTimeout = Number(timeoutText); + const timeoutValid = + Number.isInteger(parsedTimeout) + && parsedTimeout >= APPROVAL_TIMEOUT_S_MIN + && parsedTimeout <= APPROVAL_TIMEOUT_S_MAX; + + useInput(useCallback((input, key) => { + if (!active || submitted) return; + // The scope picker owns input while mounted. + if (showScopePicker) return; + + // Ctrl+V — manual paste trigger that works anywhere in the form, + // independently of bracketed-paste support. Useful for terminals + // without bracketed paste, and as a belt-and-suspenders backup. + if (key.ctrl && (input === 'v' || input === 'V')) { + void tryPasteFromClipboard(); + return; + } + + // ── Text editing mode ── + if (editingText === 'prompt') { + if (key.escape || key.return) { setEditingText(null); return; } + if (key.backspace || key.delete) { setPrompt(p => p.slice(0, -1)); return; } + if (input && !key.ctrl && !key.meta) { setPrompt(p => p + input); } + return; + } + if (editingText === 'timeout') { + if (key.escape || key.return) { setEditingText(null); return; } + if (key.backspace || key.delete) { setTimeoutText(p => p.slice(0, -1)); return; } + if (input && /[0-9]/.test(input) && timeoutText.length < 5) { + setTimeoutText(p => p + input); + } + return; + } + + // ── Repo selector ── + if (field === 'repo') { + if (key.upArrow) { + if (repoCursor > 0) setRepoCursor(c => c - 1); + return; + } + if (key.downArrow) { + if (repoCursor < repos.length - 1) setRepoCursor(c => c + 1); + else setField('prompt'); + return; + } + if (input === ' ' || key.return) { setField('prompt'); return; } + return; + } + + // ── Approvals list ── + if (field === 'approvals') { + if (input === '+' || input === 'a') { + if (preApprovals.length >= INITIAL_APPROVALS_MAX_ENTRIES) return; + setShowScopePicker(true); + return; + } + if (input === '-' || input === 'd' || key.delete || key.backspace) { + // Remove the last scope for simplicity. A richer UX would + // let you cursor-pick, but this matches the CLI `--pre-approve` + // repeatable-flag ergonomics. + setPreApprovals(p => p.slice(0, -1)); + return; + } + // fall through to field navigation + } + + // ── Attachments list ── + if (field === 'attachments') { + // `-` or `d` removes last; `r` clears all. `Ctrl+V` (handled + // earlier in the function) adds. Plain `v` does NOT add — the + // user might be trying to type "v" elsewhere, and we've already + // taught them Ctrl+V from the help bar. + if (input === '-' || input === 'd' || key.delete || key.backspace) { + setAttachments(prev => prev.slice(0, -1)); + return; + } + if (input === 'r' || input === 'R') { + setAttachments([]); + return; + } + // fall through to field navigation + } + + // ── General field navigation ── + if (key.downArrow) { + const next = Math.min(fieldIdx + 1, FIELDS.length - 1); + setField(FIELDS[next]); + if (FIELDS[next] === 'repo') setRepoCursor(0); + return; + } + if (key.upArrow) { + const prev = Math.max(fieldIdx - 1, 0); + setField(FIELDS[prev]); + if (FIELDS[prev] === 'repo') setRepoCursor(repos.length - 1); + return; + } + + // Prompt text editing + if (field === 'prompt' && key.return) { setEditingText('prompt'); return; } + // Timeout text editing + if (field === 'timeout' && key.return) { setEditingText('timeout'); return; } + + // Submit + if (field === 'submit' && key.return) { + if (!selectedRepo || !prompt || !timeoutValid) return; + setSubmitted(true); + setSubmitError(null); + void (async () => { + try { + const wireAttachments: readonly Attachment[] = attachments.map(a => a.attachment); + const row = await submitTask({ + repo: selectedRepo, + task_description: prompt, + approval_timeout_s: parsedTimeout, + ...(preApprovals.length > 0 && { initial_approvals: preApprovals }), + ...(wireAttachments.length > 0 && { attachments: wireAttachments }), + }); + submitTimer.current = globalThis.setTimeout(() => onSubmitted(row.task_id), 500); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setSubmitError(msg); + setSubmitted(false); + } + })(); + return; + } + }, [ + active, submitted, editingText, showScopePicker, field, fieldIdx, + repoCursor, repos, prompt, timeoutText, timeoutValid, parsedTimeout, + preApprovals, attachments, selectedRepo, submitTask, onSubmitted, + tryPasteFromClipboard, + ])); + + const handleAddScope = useCallback((scope: ApprovalScope) => { + setPreApprovals(p => (p.includes(scope) ? p : [...p, scope])); + setShowScopePicker(false); + }, []); + + if (submitted && !submitError) { + return ( + + {figures.tick} Task submitted! + Repo: {selectedRepo} | Switching to Watch… + + ); + } + + const cur = (f: Field) => field === f && active ? figures.pointer + ' ' : ' '; + const fc = (f: Field) => field === f && active ? 'cyan' : undefined; + const toastColor = + toast?.tone === 'ok' ? 'green' + : toast?.tone === 'warn' ? 'yellow' + : 'red'; + + return ( + + + New Task + {figures.arrowUp}/{figures.arrowDown} navigate · Enter edit/select · a/+ scope · Ctrl+V paste image + + + {/* Repo selector */} + + + {cur('repo')} + Repository: + {field !== 'repo' && {selectedRepo || '(none)'}} + + {field === 'repo' && ( + + {repos.length === 0 ? ( + No repos discovered yet. Submit via CLI or wait for refresh. + ) : repos.map((r, i) => { + const focused = i === repoCursor; + return ( + + {focused ? figures.pointer + ' ' : ' '} + {r.repo} + ({r.default_branch}) + + ); + })} + + )} + + + {/* Prompt */} + + {cur('prompt')} + Instructions: + {prompt || '(empty)'} + {field === 'prompt' && editingText === 'prompt' && |} + {field === 'prompt' && editingText !== 'prompt' && !prompt && Enter to type} + + + {/* Approval timeout */} + + {cur('timeout')} + Approval timeout: + {timeoutText}s + {field === 'timeout' && editingText === 'timeout' && |} + ({APPROVAL_TIMEOUT_S_MIN}-{APPROVAL_TIMEOUT_S_MAX}s, default {APPROVAL_TIMEOUT_S_DEFAULT}) + {!timeoutValid && {figures.cross} invalid} + + + {/* Pre-approvals */} + + + {cur('approvals')} + Pre-approve: + {preApprovals.length === 0 ? ( + (none — agent asks for everything) + ) : ( + {preApprovals.length} scope{preApprovals.length !== 1 ? 's' : ''} + )} + + {field === 'approvals' && ( + + {preApprovals.length === 0 ? ( + a or + to add a scope + ) : ( + <> + {preApprovals.map(s => ( + + {figures.tick} + {s} + + ))} + a/+ add | d/- remove last | {preApprovals.length}/{INITIAL_APPROVALS_MAX_ENTRIES} + + )} + + )} + + + {/* Attachments */} + + + {cur('attachments')} + Attachments: + {attachments.length === 0 ? ( + (none — Ctrl+V or Cmd+V to paste a screenshot) + ) : ( + {attachments.length}/{MAX_ATTACHMENTS} + )} + + {field === 'attachments' && attachments.length > 0 && ( + + {attachments.map((a, i) => ( + + {figures.tick} + {a.attachment.content_type ?? a.attachment.type} + {formatBytes(a.sizeBytes)} + {a.attachment.filename && ( + {a.attachment.filename} + )} + + ))} + Ctrl+V add | d/- remove last | r reset | {attachments.length}/{MAX_ATTACHMENTS} + + )} + {field === 'attachments' && attachments.length === 0 && ( + + Ctrl+V (or Cmd+V on macOS) to paste an image from clipboard + + )} + + + {toast && ( + + {toast.text.split('\n').map((line, i) => ( + + {line} + + ))} + + )} + + + + {/* Submit button */} + + {cur('submit')} + {'[ Submit Task ]'} + {field === 'submit' && (!selectedRepo || !prompt || !timeoutValid) && ( + {figures.cross} fix fields above first + )} + + + {submitError && ( + + + {figures.cross} Submit failed + + {/* Split on newlines so the ApiClient's multi-line error + messages (status line + server body) all render cleanly + rather than getting trimmed to one line. */} + {submitError.split(/\r?\n/).map((line, i) => ( + + {line} + + ))} + {/[34]0\d: /.test(submitError) && ( + + Diagnostic hints: + • 401 → token expired: run `bgagent login` + • 403 → repo may not be onboarded / GitHub App missing; verify with + `bgagent policies list --repo {selectedRepo}` + • 404 → repo not registered; run onboarding first + + )} + + )} + + {showScopePicker && ( + setShowScopePicker(false)} + /> + )} + + ); +}; + +export default Submit; diff --git a/cli/src/tui/panels/TaskList.tsx b/cli/src/tui/panels/TaskList.tsx new file mode 100644 index 00000000..0a03a9d1 --- /dev/null +++ b/cli/src/tui/panels/TaskList.tsx @@ -0,0 +1,110 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text, useInput } from 'ink'; +import React, { useState, useCallback } from 'react'; +import { CHANNEL_COLOR, CHANNEL_LABEL, STATUS_COLOR, STATUS_ICON, STATUS_LABEL, timeAgo, trunc } from '../constants.js'; +import { TRUNC_DESCRIPTION, TRUNC_REPO, type TaskRowView } from '../data.js'; + +interface TaskListProps { + tasks: TaskRowView[]; + onSelectTask: (taskId: string) => void; + active: boolean; +} + +const TaskList: React.FC = ({ tasks, onSelectTask, active }) => { + const [cursor, setCursor] = useState(0); + + // Clamp cursor if tasks change + const safeCursor = Math.min(cursor, Math.max(0, tasks.length - 1)); + + useInput(useCallback((_input, key) => { + if (!active || tasks.length === 0) return; + if (key.upArrow) setCursor(c => Math.max(0, c - 1)); + if (key.downArrow) setCursor(c => Math.min(tasks.length - 1, c + 1)); + if (key.return) onSelectTask(tasks[safeCursor].task_id); + }, [active, tasks, safeCursor, onSelectTask])); + + if (tasks.length === 0) { + return ( + + Tasks + + No tasks yet. Press 5 to submit one. + + ); + } + + return ( + + + Tasks + {tasks.length} total, {tasks.filter(t => t.status === 'RUNNING').length} running + + + {' '} + {'ID'.padEnd(8)} + {' '} + {'STATUS'.padEnd(20)} + {'SOURCE'.padEnd(8)} + {'REPO'.padEnd(26)} + {'STEP'.padEnd(8)} + {'GATES'.padEnd(8)} + {'AGE'.padEnd(8)} + DESCRIPTION + + {tasks.map((t, i) => { + const sel = i === safeCursor && active; + const sc = STATUS_COLOR[t.status] ?? 'white'; + const si = STATUS_ICON[t.status] ?? '?'; + const sl = STATUS_LABEL[t.status] ?? t.status; + + const gates = t.approval_gate_count != null && t.approval_gate_cap != null + ? `${t.approval_gate_count}/${t.approval_gate_cap}` + : '—'; + // Color turn counter red when approaching the cap (≥80%) + const gateRatio = t.approval_gate_count != null && t.approval_gate_cap + ? t.approval_gate_count / t.approval_gate_cap + : 0; + const gateColor: string | undefined = + gateRatio >= 0.8 ? 'red' : gateRatio >= 0.5 ? 'yellow' : undefined; + const cs = t.channel_source; + const csLabel = cs ? (CHANNEL_LABEL[cs] ?? cs) : '—'; + const csColor = cs ? CHANNEL_COLOR[cs] : undefined; + return ( + + {sel ? figures.pointer + ' ' : ' '} + {'..'+t.task_id.slice(-4)} + {' '} + {`${si} ${sl}`.padEnd(20)} + {csLabel.padEnd(8)} + {trunc(t.repo, TRUNC_REPO).padEnd(26)} + {`${t.turn ?? 0}/~${t.max_turns ?? '?'}`.padEnd(8)} + {gates.padEnd(8)} + {timeAgo(t.created_at).padEnd(8)} + {trunc(t.task_description, TRUNC_DESCRIPTION)} + + ); + })} + + ); +}; + +export default TaskList; diff --git a/cli/src/tui/panels/Watch.tsx b/cli/src/tui/panels/Watch.tsx new file mode 100644 index 00000000..f358669a --- /dev/null +++ b/cli/src/tui/panels/Watch.tsx @@ -0,0 +1,494 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import figures from 'figures'; +import { Box, Text, useInput, useStdout } from 'ink'; +import Spinner from 'ink-spinner'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { TERMINAL_STATUSES, type ApprovalScope } from '../../types.js'; +import ApprovalCard from '../components/ApprovalCard.js'; +import DenyReasonInput from '../components/DenyReasonInput.js'; +import EventLine from '../components/EventLine.js'; +import ScopePicker from '../components/ScopePicker.js'; +import { fmtDuration, STATUS_COLOR, STATUS_LABEL, trunc } from '../constants.js'; +import { useApprovals, useEditing } from '../context.js'; +import { TERM_WIDTH, type TaskRowView, type TaskEvent } from '../data.js'; +import { useData } from '../hooks/useData.js'; +import { INITIAL_POLL_CADENCE, nextCadence, type PollCadenceState } from '../utils/polling.js'; + +/** Broaden `readonly string[]` so callers can test arbitrary strings + * without `as`. Mirrors the usage in `commands/watch.ts`. */ +function isTerminalStatus(status: string): boolean { + return (TERMINAL_STATUSES as readonly string[]).includes(status); +} + +/** Wrap long text to fit within a max width, breaking at word boundaries. */ +function wordWrap(text: string, maxWidth: number): string[] { + const words = text.split(/\s+/); + const lines: string[] = []; + let current = ''; + for (const word of words) { + if (current.length + word.length + 1 > maxWidth && current.length > 0) { + lines.push(current); + current = word; + } else { + current = current ? current + ' ' + word : word; + } + } + if (current) lines.push(current); + return lines; +} + +interface WatchProps { + task: TaskRowView; + active: boolean; + onBack: () => void; +} + +// Chrome lines consumed by non-event content: +// PeccyMini(4) + title/tabs overlap(0, inline) + separator(1) + task status(1) +// + description(2) + scroll indicator(1) + helpbar(2) + approval card(~6 if shown) +// + spinner(1) + margin(2) = ~20 worst case +const CHROME_LINES = 20; + +const Watch: React.FC = ({ task, active, onBack }) => { + const { stdout } = useStdout(); + const termRows = process.stdout.rows || stdout?.rows || 30; + const EVENT_WINDOW = Math.max(5, termRows - CHROME_LINES); + + const { approvals, approve, deny } = useApprovals(); + const { setEditing } = useEditing(); + const { getTaskEvents, source } = useData(); + const [events, setEvents] = useState([]); + const [taskEvents, setTaskEvents] = useState([]); + const [eventIdx, setEventIdx] = useState(0); + const [elapsed, setElapsed] = useState(0); + const [nudging, setNudging] = useState(false); + const [nudgeText, setNudgeText] = useState(''); + const [message, setMessage] = useState(''); + const [confirmDeny, setConfirmDeny] = useState(false); + const [showScopePicker, setShowScopePicker] = useState(false); + const [showDenyInput, setShowDenyInput] = useState(false); + const [scrollOffset, setScrollOffset] = useState(-1); // -1 = auto-follow (show latest) + const msgTimer = useRef | null>(null); + const [, setTick] = useState(0); + + const taskIsTerminal = isTerminalStatus(task.status); + + // Hydrate events on task change. In mock mode the full list resolves + // immediately and the simulated-polling effect below replays it one + // frame at a time. In real mode we refetch on an adaptive cadence + // (500ms → 1s/2s/5s backoff on consecutive empty polls, reset to + // fast on the next non-empty poll) — matches `commands/watch.ts` + // so the TUI's perceived liveness is the same as the CLI's. + // + // When the task is already in a terminal status (COMPLETED / FAILED + // / CANCELLED / TIMED_OUT), we do ONE event hydration and stop — + // no further polling. This mirrors `bgagent watch`'s + // already-terminal short-circuit and avoids burning API calls on + // a task whose stream is closed. The `task.status` dep makes the + // effect re-run when a running task transitions to terminal while + // the Watch panel is mounted. + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + let cadence: PollCadenceState = INITIAL_POLL_CADENCE; + // Cursor is the last seen event_id. On first poll it's null → + // the source drains all pages. On subsequent polls we pass it + // via `after` so the source's `catchUpEvents` only returns the + // new tail. This makes `pr_created` / `task_completed` always + // land even on 300+ event streams, and keeps the user's scroll + // position stable between polls. + let lastEventId: string | null = null; + + // Reset event state when the task changes. If we're re-entering + // the same task, the cursor is fresh (null) → a full reload. + setTaskEvents([]); + + const scheduleNext = (ms: number) => { + if (cancelled) return; + timer = globalThis.setTimeout(() => { void poll(); }, ms); + }; + + const poll = async () => { + if (cancelled) return; + try { + const newEvents = await getTaskEvents( + task.task_id, + lastEventId ? { after: lastEventId } : undefined, + ); + if (cancelled) return; + const sawNew = newEvents.length > 0; + if (sawNew) { + lastEventId = newEvents[newEvents.length - 1].event_id; + // Filter out `approval_decision_recorded` audit events: they + // are written by ApproveTaskFn / DenyTaskFn directly to + // TaskEventsTable and duplicate the agent-side + // `approval_granted` / `approval_denied` milestones the user + // already sees in the stream. Surfacing both is just noise + // from a TUI viewer's perspective; the audit row remains + // queryable via the API for compliance use cases. + const filtered = newEvents.filter( + (e) => e.event_type !== 'approval_decision_recorded', + ); + // Append to existing — dedup by event_id in case the server + // echoes a boundary row (ULIDs are monotonic so in practice + // this is belt-and-suspenders). Stable order is maintained + // because `catchUpEvents` preserves ascending event_id. + setTaskEvents((prev) => { + const seen = new Set(prev.map(e => e.event_id)); + const toAppend = filtered.filter(e => !seen.has(e.event_id)); + return toAppend.length > 0 ? [...prev, ...toAppend] : prev; + }); + } + if (source.label === 'live' && !taskIsTerminal) { + cadence = nextCadence(cadence, sawNew); + scheduleNext(cadence.intervalMs); + } + } catch { + // Surface errors via the data provider's error channel + // (future: inline toast); keep the old events and retry on + // the slowest cadence slot so we don't hammer a degraded + // upstream. Terminal tasks don't retry — their stream is + // closed and retrying is pointless. + if (!cancelled && source.label === 'live' && !taskIsTerminal) { + scheduleNext(5_000); + } + } + }; + + void poll(); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [task.task_id, task.status, getTaskEvents, source.label, taskIsTerminal]); + + const pendingApproval = useMemo(() => { + // Source of truth: the task's `awaiting_approval_request_id`. + // Fall back to a task-id match on the approvals list if the + // task detail is still loading (real-mode race) or if this is + // a pre-Cedar-HITL record missing the field. + const expectedId = task.awaiting_approval_request_id; + const pa = expectedId + ? approvals.find(a => a.request_id === expectedId) + : approvals.find(a => a.task_id === task.task_id); + if (!pa) return null; + // Prefer server `expires_at` for the countdown — authoritative + // once the approval row lands. Fall back to `timeout_s - elapsed` + // on records without it. + const expiresAt = pa.expires_at ? new Date(pa.expires_at).getTime() : null; + const remaining = expiresAt + ? Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) + : Math.max(0, pa.timeout_s - Math.floor((Date.now() - new Date(pa.created_at).getTime()) / 1000)); + return { + event: { + event_id: `synth_${pa.request_id}`, + event_type: 'approval_requested' as const, + timestamp: pa.created_at, + metadata: { + task_id: pa.task_id, + request_id: pa.request_id, + tool_name: pa.tool_name, + input_preview: pa.tool_input_preview, + reason: pa.reason, + severity: pa.severity, + // matching_rule_ids surfaces in ApprovalCard's "Triggered" + // line — closes the asymmetry where Approvals.tsx detail view + // showed the firing rule but the Watch overlay didn't. + matching_rule_ids: [...pa.matching_rule_ids], + }, + } as TaskEvent, + timeoutRemaining: remaining, + requestId: pa.request_id, + toolName: pa.tool_name, + }; + }, [approvals, task.task_id, task.awaiting_approval_request_id]); + + useEffect(() => { + const timer = setInterval(() => setTick(t => t + 1), 1000); + return () => clearInterval(timer); + }, []); + + const showMessage = useCallback((msg: string) => { + setMessage(msg); + if (msgTimer.current) clearTimeout(msgTimer.current); + msgTimer.current = globalThis.setTimeout(() => setMessage(''), 4000); + }, []); + + useEffect(() => () => { if (msgTimer.current) clearTimeout(msgTimer.current); }, []); + + // In mock mode replay events one-at-a-time so the stream looks + // alive. In live mode we already see real events as they land, so + // just flush the whole list and only animate the elapsed counter. + useEffect(() => { + if (source.label === 'live') { + setEvents(taskEvents); + const timer = setInterval(() => setElapsed(p => p + 1), 1000); + return () => clearInterval(timer); + } + const timer = setInterval(() => { + setEventIdx(prev => { + if (prev < taskEvents.length) { + setEvents(taskEvents.slice(0, prev + 1)); + return prev + 1; + } + return prev; + }); + setElapsed(p => p + 1); + }, 600); + return () => clearInterval(timer); + }, [taskEvents, source.label]); + + // Editing lock — pick the most specific lock mode for the help bar + useEffect(() => { + if (showScopePicker) setEditing(true, 'scope-picker'); + else if (showDenyInput) setEditing(true, 'text'); + else if (confirmDeny) setEditing(true, 'deny-confirm'); + else if (nudging) setEditing(true, 'text'); + else setEditing(false); + return () => setEditing(false); + }, [nudging, confirmDeny, showScopePicker, showDenyInput, setEditing]); + + useInput(useCallback((input, key) => { + if (!active) return; + + // Scope picker + deny-input own their own input while mounted. + if (showScopePicker || showDenyInput) return; + + if (confirmDeny) { + if (input === 'y' || input === 'Y') { + if (pendingApproval) { setShowDenyInput(true); } + setConfirmDeny(false); return; + } + if (key.escape || input === 'n' || input === 'N') { setConfirmDeny(false); return; } + return; + } + + if (nudging) { + if (key.escape) { setNudging(false); setNudgeText(''); return; } + if (key.return && nudgeText.length > 0) { showMessage(`${figures.tick} Nudge sent: "${nudgeText}"`); setNudging(false); setNudgeText(''); return; } + if (key.backspace || key.delete) { setNudgeText(p => p.slice(0, -1)); return; } + if (input && !key.ctrl && !key.meta) { setNudgeText(p => p + input); } + return; + } + + // ↑/↓ scroll through events + if (key.upArrow && events.length > EVENT_WINDOW) { + setScrollOffset(prev => { + const current = prev === -1 ? Math.max(0, events.length - EVENT_WINDOW) : prev; + return Math.max(0, current - 1); + }); + return; + } + if (key.downArrow && events.length > EVENT_WINDOW) { + setScrollOffset(prev => { + if (prev === -1) return -1; // already at bottom + const maxOffset = Math.max(0, events.length - EVENT_WINDOW); + const next = prev + 1; + return next >= maxOffset ? -1 : next; // snap back to auto-follow at bottom + }); + return; + } + + if (key.escape) { onBack(); return; } + if (input === 'n') { setNudging(true); return; } + if (input === 'a' && pendingApproval) { setShowScopePicker(true); return; } + if (input === 'd' && pendingApproval) { setConfirmDeny(true); return; } + if ((input === 'a' || input === 'd') && !pendingApproval) { showMessage('No pending approval for this task'); return; } + }, [active, nudging, confirmDeny, showScopePicker, showDenyInput, nudgeText, pendingApproval, approve, deny, onBack, showMessage, events.length])); + + const handleApproveWithScope = useCallback((scope: ApprovalScope) => { + setShowScopePicker(false); + if (!pendingApproval) return; + const { requestId, toolName } = pendingApproval; + // Await the round-trip — see Approvals.tsx for the full rationale. + // Phase A live drive surfaced the silent-failure case where the + // optimistic toast claimed success while the API call had failed + // and the agent stayed blocked. + void (async () => { + const result = await approve(requestId, scope); + if (result.ok) { + showMessage(`${figures.tick} Approved ${toolName} (${scope})`); + } else { + showMessage(`${figures.cross} Approve failed — ${trunc(result.error, 60)}`); + } + })(); + }, [pendingApproval, approve, showMessage]); + + const handleDenyWithReason = useCallback((reason: string) => { + setShowDenyInput(false); + if (!pendingApproval) return; + const { requestId, toolName } = pendingApproval; + void (async () => { + const result = await deny(requestId, reason || undefined); + if (result.ok) { + showMessage(`${figures.cross} Denied ${toolName}${reason ? ` — "${trunc(reason, 30)}"` : ''}`); + } else { + showMessage(`${figures.cross} Deny failed — ${trunc(result.error, 60)}`); + } + })(); + }, [pendingApproval, deny, showMessage]); + + // Mock-mode "replay animation still in flight" indicator. Irrelevant + // in live mode (the stream is either polling or closed — tracked via + // `taskIsTerminal`). Suppressed once the task reaches terminal status + // so a COMPLETED/FAILED task doesn't keep showing a spinner. + const isPolling = !taskIsTerminal && eventIdx < taskEvents.length; + const sc = STATUS_COLOR[task.status] ?? 'white'; + const sl = STATUS_LABEL[task.status] ?? task.status; + + const descMaxWidth = TERM_WIDTH - 10; + const descLines = wordWrap(task.task_description, descMaxWidth); + + // Compute visible event window + const isAutoFollow = scrollOffset === -1; + const visibleStart = isAutoFollow + ? Math.max(0, events.length - EVENT_WINDOW) + : scrollOffset; + const visibleEvents = events.slice(visibleStart, visibleStart + EVENT_WINDOW); + const canScrollUp = visibleStart > 0; + const canScrollDown = !isAutoFollow; + + return ( + + {/* Compact header — single line status */} + + Task + ..{task.task_id.slice(-4)} + + {sl} + {task.repo} Step {task.turn ?? 0}/~{task.max_turns ?? '?'} {fmtDuration(elapsed)} + {task.cost_usd != null && ${task.cost_usd.toFixed(4)}} + + {/* Cedar HITL gate budget — only rendered when we have the + counters (null on pre-Cedar-HITL tasks). */} + {task.approval_gate_count != null && task.approval_gate_cap != null && ( + + Approval gates: + = task.approval_gate_cap * 0.8 ? 'red' + : task.approval_gate_count >= task.approval_gate_cap * 0.5 ? 'yellow' + : undefined + }> + {task.approval_gate_count}/{task.approval_gate_cap} + + used + + )} + {/* PR banner — pinned to the header area so once a PR lands it + stays visible regardless of event-stream scroll position. + `pr_url` is populated by the agent's `pr_created` milestone + and carried on TaskDetail; we just echo it. */} + {task.pr_url && ( + + {figures.tick} PR: + + {task.pr_url} + + )} + {/* Description — word wrapped */} + {descLines.map((line, i) => ( + {line} + ))} + + {/* Scroll indicator */} + {canScrollUp && ( + {figures.arrowUp} {visibleStart} more events above + )} + + {/* Event stream — fixed height window */} + + {events.length === 0 ? ( + Waiting for events… + ) : ( + visibleEvents.map(e => ) + )} + {isPolling && isAutoFollow && events.length > 0 && ( + polling… + )} + {taskIsTerminal && events.length > 0 && ( + + {figures.tick} + Stream closed — task + {sl} + + )} + {canScrollDown && ( + {figures.arrowDown} more events below (↓ to scroll, or keep waiting) + )} + + + {/* Approval card */} + {pendingApproval && !nudging && !confirmDeny && !showScopePicker && !showDenyInput && ( + + )} + + {/* Scope picker (approve) */} + {showScopePicker && pendingApproval && ( + setShowScopePicker(false)} + /> + )} + + {/* Deny confirmation → deny reason input */} + {confirmDeny && ( + + {figures.warning} Confirm deny? + The agent will be blocked and may not be able to continue. + [y] Add reason [n] Cancel + + )} + {showDenyInput && ( + setShowDenyInput(false)} + /> + )} + + {/* Nudge input */} + {nudging && ( + + {figures.arrowRight} Nudge the agent + + {figures.pointer} + {nudgeText ? {nudgeText} : e.g. "focus on the tests first"} + | + + Enter: send Esc: cancel + + )} + + {/* Status message */} + {message && !confirmDeny && ( + {message} + )} + + ); +}; + +export default Watch; diff --git a/cli/src/tui/tsconfig.json b/cli/src/tui/tsconfig.json new file mode 100644 index 00000000..b5d98f8e --- /dev/null +++ b/cli/src/tui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "rootDir": "..", + "outDir": "../../lib", + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "inlineSourceMap": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "types": ["node"] + }, + "include": [ + "./**/*.ts", + "./**/*.tsx", + "../types.ts" + ], + "exclude": [] +} diff --git a/cli/src/tui/utils/bracketed-paste.tsx b/cli/src/tui/utils/bracketed-paste.tsx new file mode 100644 index 00000000..92619c9d --- /dev/null +++ b/cli/src/tui/utils/bracketed-paste.tsx @@ -0,0 +1,158 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Bracketed-paste integration for Ink. + * + * What this does: + * 1. Enables bracketed paste mode on TUI mount by writing + * `\x1b[?2004h` to stdout. Disables it on unmount + on + * common termination signals so the user's shell doesn't + * inherit a wedged terminal. + * 2. Attaches a raw stdin listener that watches for the + * paste-start sequence `\x1b[200~`. When seen, fires the + * `onPaste` callback. The body between start and end + * (`\x1b[201~`) is drained and discarded — for our use + * case (image paste), the body is irrelevant: we read + * the OS clipboard directly via pngpaste/xclip/etc. + * 3. Continues to forward every other byte to whatever input + * consumer Ink already wired up. We do this passively + * (a non-consuming `data` listener) so `useInput` keeps + * working unchanged. + * + * Why side-channel instead of `useInput`: + * Ink's input parser flushes a lone `\x1b` after a 20 ms delay + * to disambiguate "Esc pressed" from "Esc began an arrow key". + * That delay can fragment a paste-start marker depending on + * how the terminal flushes its TTY buffer. By reading raw + * stdin in addition to `useInput`, we always see paste sequences + * in one piece — and `useInput` handles the rest of the input + * grammar correctly. + * + * Cmd+V on macOS: + * Cmd+V triggers the terminal's built-in paste action. With + * bracketed paste enabled, that action emits the start marker + * on stdin *before* the clipboard's text representation. Our + * handler fires at the start marker, reads the OS clipboard + * directly (which still holds the image bytes since the + * single-owner clipboard hasn't moved on yet), and attaches the + * image. The bracketed-paste body is then drained without being + * forwarded to the prompt text input. Net effect: the user's + * muscle-memory Cmd+V "just works" for screenshots. + */ + +import { useEffect, useRef } from 'react'; + +const PASTE_ENABLE = '\x1b[?2004h'; +const PASTE_DISABLE = '\x1b[?2004l'; +const PASTE_START = '\x1b[200~'; +const PASTE_END = '\x1b[201~'; + +export interface BracketedPasteOptions { + /** Fires when a paste sequence begins. Synchronous so the + * handler can spawn its clipboard reader before the user's + * next clipboard mutation could overwrite it. */ + readonly onPaste: () => void; + /** When false, the hook is a no-op. Lets the caller gate the + * feature on platform support / config. */ + readonly enabled?: boolean; +} + +/** Strip start markers from a chunk and report whether at least one + * was seen. Exported for tests. */ +export function processBracketedChunk( + buf: Buffer, + inPaste: boolean, +): { sawStart: boolean; sawEnd: boolean; remainingInPaste: boolean; passthrough: Buffer } { + let sawStart = false; + let sawEnd = false; + const out: Buffer[] = []; + const startBytes = Buffer.from(PASTE_START, 'utf8'); + const endBytes = Buffer.from(PASTE_END, 'utf8'); + let i = 0; + let pasting = inPaste; + while (i < buf.length) { + if (!pasting && buf.length - i >= startBytes.length && buf.subarray(i, i + startBytes.length).equals(startBytes)) { + sawStart = true; + pasting = true; + i += startBytes.length; + continue; + } + if (pasting && buf.length - i >= endBytes.length && buf.subarray(i, i + endBytes.length).equals(endBytes)) { + sawEnd = true; + pasting = false; + i += endBytes.length; + continue; + } + if (!pasting) { + // Forward bytes outside paste regions unchanged. (We don't + // actually use the passthrough buffer in production — Ink's + // own listener already handles them — but we surface it for + // tests so they can verify we don't corrupt non-paste input.) + out.push(buf.subarray(i, i + 1)); + } + // Inside a paste body: drain silently. The image already came + // from the OS clipboard via the onPaste callback. + i += 1; + } + return { sawStart, sawEnd, remainingInPaste: pasting, passthrough: Buffer.concat(out) }; +} + +export function useBracketedPaste(opts: BracketedPasteOptions): void { + const onPasteRef = useRef(opts.onPaste); + onPasteRef.current = opts.onPaste; + + useEffect(() => { + if (opts.enabled === false) return; + + process.stdout.write(PASTE_ENABLE); + + let inPaste = false; + const onData = (chunk: Buffer | string) => { + const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk; + const { sawStart, remainingInPaste } = processBracketedChunk(buf, inPaste); + inPaste = remainingInPaste; + if (sawStart) { + // Fire-and-forget — the handler's own state machine + // decides what to do (probably spawn clipboard reader). + try { + onPasteRef.current(); + } catch { + // Swallow — never let a paste-handler crash kill the TUI. + } + } + }; + + process.stdin.on('data', onData); + + const restore = () => process.stdout.write(PASTE_DISABLE); + const onSignal = () => { restore(); }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + process.on('exit', restore); + + return () => { + process.stdin.off('data', onData); + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + process.removeListener('exit', restore); + restore(); + }; + }, [opts.enabled]); +} diff --git a/cli/src/tui/utils/clipboard.ts b/cli/src/tui/utils/clipboard.ts new file mode 100644 index 00000000..8945399a --- /dev/null +++ b/cli/src/tui/utils/clipboard.ts @@ -0,0 +1,437 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Cross-platform clipboard image reader. + * + * Strategy: spawn the platform's native clipboard tool to write the + * image to a temp file, then read it back. Temp-file approach is + * more robust for binary data than piping through stdout (Windows + * console encoding mangles non-text bytes; macOS osascript only + * exposes a write-to-file path for PNG via AppleScript). + * + * No system tools require user installation: + * + * macOS osascript (always present in /usr/bin/osascript) + * AppleScript: `the clipboard as «class PNGf»` coerces + * any image on the pasteboard into PNG bytes. + * + * Linux xclip OR wl-paste — most distros ship one or the other. + * We probe both (TARGETS query) and pick whichever is + * present. SSH / headless sessions return tool_missing. + * + * Windows powershell.exe + System.Windows.Forms.Clipboard.GetImage(). + * Built into Windows 7+. Reachable from WSL via `/mnt/c` + * interop, no platform branch needed. + * + * BMP fallback (Linux/Windows clipboards sometimes only expose BMP): + * we read the file, sniff the magic bytes, and decode BMP → PNG via + * `sharp` so the wire payload is always one of the API-supported + * formats. + * + * Size cap: 5 MB after any decode/conversion. Oversize returns + * `too_large` with the actual byte count. + */ + +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; + +/** Caller-facing payload for an attached image. */ +export interface ClipboardImage { + /** Raw image bytes — always PNG/JPEG/GIF (BMP is decoded server-side). */ + readonly buffer: Buffer; + /** MIME type, set from magic-byte sniff (post-decode). */ + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/gif'; + /** Convenience for the API contract — base64 of `buffer`. */ + readonly base64: string; + /** Byte length. */ + readonly sizeBytes: number; +} + +/** Why a clipboard read returned no image. */ +export type ClipboardReadFailure = + | { readonly kind: 'empty' } + | { readonly kind: 'not_image' } + | { readonly kind: 'too_large'; readonly sizeBytes: number; readonly maxBytes: number } + | { readonly kind: 'tool_missing'; readonly platform: NodeJS.Platform; readonly hint: string } + | { readonly kind: 'unsupported_platform'; readonly platform: NodeJS.Platform } + | { readonly kind: 'error'; readonly message: string }; + +export type ClipboardReadResult = + | { readonly ok: true; readonly image: ClipboardImage } + | { readonly ok: false; readonly failure: ClipboardReadFailure }; + +/** Default per-image cap. Common API limit for vision attachments. */ +export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024; + +/** Module-level cache so install hints (rare — e.g. headless Linux) + * surface once per session rather than on every Ctrl+V. */ +const hintShownFor = new Set(); + +/** Sniff the leading bytes for a recognized image format. Returns + * null when the buffer doesn't match any. */ +function sniffMediaType(buf: Buffer): + | 'image/png' + | 'image/jpeg' + | 'image/gif' + | 'image/bmp' + | null { + if (buf.length < 4) return null; + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) { + return 'image/png'; + } + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { + return 'image/jpeg'; + } + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) { + return 'image/gif'; + } + if (buf[0] === 0x42 && buf[1] === 0x4d) { + return 'image/bmp'; + } + return null; +} + +interface SpawnResult { + readonly stdout: Buffer; + readonly stderr: string; + readonly exitCode: number | null; + readonly toolMissing: boolean; +} + +/** Async wrapper around spawn that captures stdout. Test seam: tests + * mock `node:child_process.spawn` to control return values. */ +export async function spawnAndCollect( + cmd: string, + args: readonly string[], + opts: { readonly stdin?: string; readonly timeoutMs?: number; readonly shell?: boolean } = {}, +): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, [...args], { + stdio: ['pipe', 'pipe', 'pipe'], + shell: opts.shell ?? false, + }); + const chunks: Buffer[] = []; + let stderr = ''; + let killed = false; + + const timeoutMs = opts.timeoutMs ?? 5_000; + const timer = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + }, timeoutMs); + + child.stdout.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + + child.on('error', (err) => { + clearTimeout(timer); + const toolMissing = (err as NodeJS.ErrnoException).code === 'ENOENT'; + resolve({ + stdout: Buffer.concat(chunks), + stderr: err.message, + exitCode: null, + toolMissing, + }); + }); + + child.on('close', (code) => { + clearTimeout(timer); + resolve({ + stdout: Buffer.concat(chunks), + stderr: killed ? `${stderr}\n(timed out)` : stderr, + exitCode: code, + toolMissing: false, + }); + }); + + if (opts.stdin !== undefined) { + child.stdin.end(opts.stdin); + } else { + child.stdin.end(); + } + }); +} + +/** Generate a fresh temp file path for a clipboard read. We don't + * reuse a fixed name across reads because two TUI panels (or two + * fast successive pastes) could race on the same path. */ +function tempImagePath(): string { + const id = randomBytes(8).toString('hex'); + return join(tmpdir(), `bgagent-tui-clipboard-${id}.png`); +} + +/** Read a temp file, sniff format, decode BMP → PNG via sharp if + * needed, apply the size cap, and emit the caller-facing result. + * The temp file is unlinked on the way out (best-effort). */ +async function fileToImage(path: string, maxBytes: number): Promise { + let raw: Buffer; + try { + raw = await fs.readFile(path); + } catch (err) { + return { + ok: false, + failure: { kind: 'error', message: `temp file read failed: ${(err as Error).message}` }, + }; + } finally { + fs.unlink(path).catch(() => { /* best effort */ }); + } + + if (raw.length === 0) { + return { ok: false, failure: { kind: 'empty' } }; + } + + const sniffed = sniffMediaType(raw); + if (sniffed === null) { + return { ok: false, failure: { kind: 'not_image' } }; + } + + // BMP isn't always accepted by vision APIs (and the wire contract + // expects `image/png` / `image/jpeg` / `image/gif`). Decode BMP + // → PNG inline so the rest of the system never sees BMP. + let buf = raw; + let mediaType: 'image/png' | 'image/jpeg' | 'image/gif'; + if (sniffed === 'image/bmp') { + try { + buf = await sharp(raw).png().toBuffer(); + mediaType = 'image/png'; + } catch (err) { + return { + ok: false, + failure: { kind: 'error', message: `BMP decode failed: ${(err as Error).message}` }, + }; + } + } else { + mediaType = sniffed; + } + + if (buf.length > maxBytes) { + return { + ok: false, + failure: { kind: 'too_large', sizeBytes: buf.length, maxBytes }, + }; + } + + return { + ok: true, + image: { + buffer: buf, + mediaType, + base64: buf.toString('base64'), + sizeBytes: buf.length, + }, + }; +} + +/* ─── Per-platform readers ───────────────────────────────────────── */ + +/** + * macOS: AppleScript via `osascript`. The `«class PNGf»` coercion + * asks the pasteboard for its PNG representation, which the OS + * synthesizes from any image present (including from screenshot + * tools, image apps, and copies of TIFF/JPEG/etc). osascript is + * always at /usr/bin/osascript on macOS — no install needed. + */ +async function readMacOS(maxBytes: number): Promise { + const path = tempImagePath(); + // Two-step AppleScript: coerce clipboard → PNG bytes, then write + // those bytes to the temp file. The `open for access` / + // `close access` dance is the canonical way to write binary data + // from AppleScript. If the clipboard has no image, the coercion + // throws and osascript exits non-zero. + const script = [ + 'set png_data to (the clipboard as «class PNGf»)', + `set fp to open for access POSIX file "${path}" with write permission`, + 'write png_data to fp', + 'close access fp', + ].join('\n'); + + const result = await spawnAndCollect('osascript', ['-e', script]); + if (result.toolMissing) { + return { + ok: false, + failure: { + kind: 'tool_missing', + platform: 'darwin', + hint: 'osascript is missing — this is unusual on macOS. Reinstall macOS dev tools.', + }, + }; + } + if (result.exitCode !== 0) { + // osascript exits non-zero when the clipboard has no image. + // Make sure we don't leave a partially-written temp file + // around if the script created one before failing. + fs.unlink(path).catch(() => { /* best effort */ }); + return { ok: false, failure: { kind: 'empty' } }; + } + return fileToImage(path, maxBytes); +} + +/** Probe whether xclip / wl-paste exposes any image MIME type on + * the clipboard. We use this as a fast pre-check so we don't write + * empty temp files on text-only clipboards. */ +async function linuxHasImage(tool: 'xclip' | 'wl-paste'): Promise { + if (tool === 'xclip') { + const r = await spawnAndCollect('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o']); + if (r.exitCode !== 0) return false; + const targets = r.stdout.toString('utf8'); + return /image\/(png|jpeg|jpg|gif|webp|bmp)/.test(targets); + } + // wl-paste -l lists available types + const r = await spawnAndCollect('wl-paste', ['-l']); + if (r.exitCode !== 0) return false; + const targets = r.stdout.toString('utf8'); + return /image\/(png|jpeg|jpg|gif|webp|bmp)/.test(targets); +} + +async function readLinux(maxBytes: number): Promise { + const isWayland = !!process.env.WAYLAND_DISPLAY; + const isX11 = !!process.env.DISPLAY; + if (!isWayland && !isX11) { + return { + ok: false, + failure: { + kind: 'tool_missing', + platform: 'linux', + hint: 'No display server detected. Clipboard paste requires X11 or Wayland\n(this looks like a headless / SSH session).', + }, + }; + } + + const tool: 'xclip' | 'wl-paste' = isWayland ? 'wl-paste' : 'xclip'; + + // Pre-check: does the clipboard expose any image MIME? + const hasImage = await linuxHasImage(tool).catch(() => false); + if (!hasImage) { + return { ok: false, failure: { kind: 'empty' } }; + } + + // Save with format-fallback chain. Prefer PNG; fall back to BMP + // (some apps — notably Windows-via-WSL2 — only expose BMP). The + // file is written via shell redirect so we use shell:true here. + const path = tempImagePath(); + const escapedPath = path.replace(/"/g, '\\"'); + const cmd = tool === 'xclip' + ? [ + `xclip -selection clipboard -t image/png -o > "${escapedPath}" 2>/dev/null`, + `xclip -selection clipboard -t image/bmp -o > "${escapedPath}" 2>/dev/null`, + ].join(' || ') + : [ + `wl-paste --type image/png > "${escapedPath}" 2>/dev/null`, + `wl-paste --type image/bmp > "${escapedPath}" 2>/dev/null`, + ].join(' || '); + + const result = await spawnAndCollect(cmd, [], { shell: true }); + if (result.toolMissing) { + return { + ok: false, + failure: { + kind: 'tool_missing', + platform: 'linux', + hint: tool === 'wl-paste' + ? 'wl-clipboard is required for clipboard paste on Wayland:\n sudo apt install wl-clipboard' + : 'xclip is required for clipboard paste on X11:\n sudo apt install xclip', + }, + }; + } + // Even if exit code is non-zero, the OR-chain may have produced a + // file via the BMP fallback. Just try to read it. + return fileToImage(path, maxBytes); +} + +/** + * Windows: PowerShell + System.Windows.Forms.Clipboard.GetImage(). + * Saves to a temp file. Reachable from WSL via the `/mnt/c` interop + * since `powershell.exe` is on PATH there too, so no platform + * branch. + */ +async function readWindows(maxBytes: number): Promise { + const path = tempImagePath(); + // Backslash-escape for PowerShell single-quoted string. + const psPath = path.replace(/\\/g, '\\\\').replace(/'/g, "''"); + const script = [ + '$ErrorActionPreference = \'Stop\'', + 'Add-Type -AssemblyName System.Windows.Forms', + 'Add-Type -AssemblyName System.Drawing', + '$img = [System.Windows.Forms.Clipboard]::GetImage()', + 'if ($null -eq $img) { exit 1 }', + `$img.Save('${psPath}', [System.Drawing.Imaging.ImageFormat]::Png)`, + ].join('; '); + + const result = await spawnAndCollect( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + ); + if (result.toolMissing) { + return { + ok: false, + failure: { + kind: 'tool_missing', + platform: 'win32', + hint: 'powershell.exe was not found on PATH (required for clipboard paste).', + }, + }; + } + if (result.exitCode !== 0) { + fs.unlink(path).catch(() => { /* best effort */ }); + return { ok: false, failure: { kind: 'empty' } }; + } + return fileToImage(path, maxBytes); +} + +/** Public API. Returns a result discriminator the caller dispatches + * on. Never throws — every failure mode is explicit. */ +export async function readClipboardImage( + opts: { readonly maxBytes?: number } = {}, +): Promise { + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_IMAGE_BYTES; + switch (process.platform) { + case 'darwin': + return readMacOS(maxBytes); + case 'linux': + return readLinux(maxBytes); + case 'win32': + return readWindows(maxBytes); + default: + return { + ok: false, + failure: { kind: 'unsupported_platform', platform: process.platform }, + }; + } +} + +/** Show an install hint at most once per session per tool. The + * Submit panel uses this to avoid spamming the user with the same + * toast on every Ctrl+V when their setup is missing the tool. */ +export function shouldShowHintOnce(toolKey: string): boolean { + if (hintShownFor.has(toolKey)) return false; + hintShownFor.add(toolKey); + return true; +} + +/** Test-only: clear the hint cache between cases. */ +export function _resetHintCacheForTests(): void { + hintShownFor.clear(); +} diff --git a/cli/src/tui/utils/pending-cadence.ts b/cli/src/tui/utils/pending-cadence.ts new file mode 100644 index 00000000..b2544e89 --- /dev/null +++ b/cli/src/tui/utils/pending-cadence.ts @@ -0,0 +1,116 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Adaptive cadence for the global `/v1/pending` poll inside the TUI's + * DataProvider. Distinct from `polling.ts` (which paces per-task event + * streams) because: + * + * - `/pending` is an account-scoped query that the server explicitly + * rate-limits (see `commands/pending.ts::mapPendingError` — the + * CLI prints "slow down `watch` polls" on 429). The TUI got + * RATE_LIMIT_EXCEEDED in production at the previous 2 s default. + * + * - The use-cases differ. The per-task event poll wants 500 ms when + * events are flowing so the Watch panel feels live. The pending + * poll only needs to surface a new approval row within a few + * seconds — the user's eyes are still on the Approvals panel + * reading the previous gate when the next one fires. + * + * Ladder (ms): 2000 → 5000 → 10000 → 30000. + * - First poll: 2 s (snappy enough that a fresh gate appears before + * the user finishes thinking). Sustained = 30 polls/min, which + * fits inside the server's default `PENDING_RATE_LIMIT_PER_MINUTE` + * of 60/user/min with 2x headroom for concurrent CLI calls. + * - Each consecutive empty poll backs off one slot. + * - On a poll that returns at least one pending row, reset to 2 s + * (an active session warrants more frequent polling). + * - On a poll that hits 429: jump straight to 30 s and stay there + * until the next non-429 poll resets the ladder. This is the key + * recovery property — the rate-limit window typically clears in + * 30-60 s, so a single bad poll shouldn't cascade. + * + * Pure state machine so the cadence is testable without timers. + */ + +export const PENDING_FAST_INTERVAL_MS = 2_000; +export const PENDING_BACKOFF_INTERVALS_MS: readonly number[] = [ + 5_000, + 10_000, + 30_000, +]; +export const PENDING_RATE_LIMITED_INTERVAL_MS = 30_000; + +export interface PendingCadenceState { + readonly intervalMs: number; + readonly consecutiveEmptyPolls: number; + /** Set after a 429 so the next call to `nextPendingCadence` is a + * no-op until something resets the ladder via a successful poll + * with rows. Used by callers as advisory state for UX (e.g. show a + * "rate-limited, slowing down" banner). */ + readonly rateLimited: boolean; +} + +export const INITIAL_PENDING_CADENCE: PendingCadenceState = { + intervalMs: PENDING_FAST_INTERVAL_MS, + consecutiveEmptyPolls: 0, + rateLimited: false, +}; + +export interface PendingPollOutcome { + /** Did the poll return at least one pending row? */ + readonly sawPending: boolean; + /** Did the server return a 429? Takes precedence over `sawPending`. */ + readonly rateLimited: boolean; +} + +export function nextPendingCadence( + state: PendingCadenceState, + outcome: PendingPollOutcome, +): PendingCadenceState { + if (outcome.rateLimited) { + return { + intervalMs: PENDING_RATE_LIMITED_INTERVAL_MS, + consecutiveEmptyPolls: state.consecutiveEmptyPolls, + rateLimited: true, + }; + } + if (outcome.sawPending) { + return INITIAL_PENDING_CADENCE; + } + const nextEmpty = state.consecutiveEmptyPolls + 1; + const idx = Math.min(nextEmpty - 1, PENDING_BACKOFF_INTERVALS_MS.length - 1); + return { + intervalMs: PENDING_BACKOFF_INTERVALS_MS[idx], + consecutiveEmptyPolls: nextEmpty, + rateLimited: false, + }; +} + +/** + * Type-narrow an arbitrary thrown value to `{statusCode: number}` so + * the DataProvider can detect 429s without an `instanceof ApiError` + * import (avoids pulling the full api-client into the TUI source-mock + * tests). Mirrors the shape exported by `cli/src/errors.ts::ApiError`. + */ +export function isRateLimitError(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const sc = (err as { statusCode?: unknown }).statusCode; + return typeof sc === 'number' && sc === 429; +} diff --git a/cli/src/tui/utils/polling.ts b/cli/src/tui/utils/polling.ts new file mode 100644 index 00000000..04bb4399 --- /dev/null +++ b/cli/src/tui/utils/polling.ts @@ -0,0 +1,70 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Adaptive polling cadence for the Watch panel. + * + * Mirrors `commands/watch.ts::nextCadence` (design + * INTERACTIVE_AGENTS.md §5.3): 500 ms while events are arriving, + * backing off through 1 s / 2 s / 5 s on consecutive empty polls, + * resetting to fast on the next poll that delivers events. + * + * Kept in its own module (and as a pure state machine) so the + * TUI-side cadence stays in lockstep with the CLI's without + * re-importing from `commands/watch.ts` — that module has a lot of + * retry / session-flap plumbing the TUI does not need (the + * DataProvider's error state covers degraded polling UX). + */ + +export const POLL_FAST_INTERVAL_MS = 500; +export const BACKOFF_INTERVALS_MS: readonly number[] = [1_000, 2_000, 5_000]; + +export interface PollCadenceState { + readonly intervalMs: number; + readonly consecutiveEmptyPolls: number; +} + +export const INITIAL_POLL_CADENCE: PollCadenceState = { + intervalMs: POLL_FAST_INTERVAL_MS, + consecutiveEmptyPolls: 0, +}; + +/** + * Compute the next cadence from whether the last poll delivered + * events. Pure so the state machine is test-coverable without + * timers. + * + * A single successful poll resets us to the fast cadence; this + * matches the CLI's behaviour. It does NOT carry a session-level + * retry counter — the TUI's DataProvider exposes an `error` channel + * for degraded-upstream UX, and re-running the TUI is a cheap reset. + */ +export function nextCadence(state: PollCadenceState, sawEvents: boolean): PollCadenceState { + if (sawEvents) { + return INITIAL_POLL_CADENCE; + } + const nextEmpty = state.consecutiveEmptyPolls + 1; + // Ladder index is `nextEmpty - 1` (first empty poll picks slot 0 = 1 s). + // After the ladder is exhausted we pin at the cap. + const idx = Math.min(nextEmpty - 1, BACKOFF_INTERVALS_MS.length - 1); + return { + intervalMs: BACKOFF_INTERVALS_MS[idx], + consecutiveEmptyPolls: nextEmpty, + }; +} diff --git a/cli/test/commands/tui.test.ts b/cli/test/commands/tui.test.ts new file mode 100644 index 00000000..c18210f5 --- /dev/null +++ b/cli/test/commands/tui.test.ts @@ -0,0 +1,67 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { _setRunTuiForTests, makeTuiCommand } from '../../src/commands/tui'; + +describe('makeTuiCommand', () => { + let runCalls = 0; + beforeEach(() => { + runCalls = 0; + _setRunTuiForTests(async () => { runCalls += 1; }); + }); + afterEach(() => { + _setRunTuiForTests(null); + }); + + it('registers the `tui` subcommand with expected options', () => { + const cmd = makeTuiCommand(); + expect(cmd.name()).toBe('tui'); + expect(cmd.description()).toMatch(/interactive terminal UI/i); + const names = cmd.options.map(o => o.long); + expect(names).toContain('--mock'); + }); + + it('calls runTui without setting BGAGENT_TUI_MOCK when --mock absent', async () => { + const cmd = makeTuiCommand(); + const original = process.env.BGAGENT_TUI_MOCK; + delete process.env.BGAGENT_TUI_MOCK; + try { + await cmd.parseAsync([], { from: 'user' }); + expect(runCalls).toBe(1); + expect(process.env.BGAGENT_TUI_MOCK).toBeUndefined(); + } finally { + if (original === undefined) delete process.env.BGAGENT_TUI_MOCK; + else process.env.BGAGENT_TUI_MOCK = original; + } + }); + + it('flips BGAGENT_TUI_MOCK=1 when --mock is passed', async () => { + const cmd = makeTuiCommand(); + const original = process.env.BGAGENT_TUI_MOCK; + delete process.env.BGAGENT_TUI_MOCK; + try { + await cmd.parseAsync(['--mock'], { from: 'user' }); + expect(process.env.BGAGENT_TUI_MOCK).toBe('1'); + expect(runCalls).toBe(1); + } finally { + if (original === undefined) delete process.env.BGAGENT_TUI_MOCK; + else process.env.BGAGENT_TUI_MOCK = original; + } + }); +}); diff --git a/cli/test/format-milestones.test.ts b/cli/test/format-milestones.test.ts new file mode 100644 index 00000000..68b02ce0 --- /dev/null +++ b/cli/test/format-milestones.test.ts @@ -0,0 +1,225 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Shared milestone formatter — drives both the TUI Watch panel and + * the plain `bgagent watch` CLI render. Tests assert the §11.1 user- + * visible payloads (IMPL-26 surface promotion) come out identical + * between the two surfaces, and that unknown sub-names degrade + * gracefully so a future agent-side milestone never disappears from + * either stream. + */ + +import { formatMilestone } from '../src/format-milestones'; + +describe('formatMilestone — Cedar HITL §11.1', () => { + it('renders approval_timeout_capped with requested → effective + rule_ids', () => { + expect( + formatMilestone({ + milestone: 'approval_timeout_capped', + request_id: 'req_xyz', + requested_timeout_s: 600, + effective_timeout_s: 300, + reason: 'rule_annotation', + matching_rule_ids: ['write_credentials'], + }), + ).toBe('Timeout capped: 600s → 300s (rule_annotation (write_credentials))'); + }); + + it('renders approval_ceiling_shrinking with usable lifetime budget', () => { + expect( + formatMilestone({ + milestone: 'approval_ceiling_shrinking', + request_id: 'req_xyz', + maxLifetime_remaining_s: 1200, + cleanup_margin_s: 200, + task_default_timeout_s: 300, + }), + ).toBe('Approval window shrinking — ~1000s of task lifetime left'); + }); + + it('renders approval_cap_exceeded as a task-halted signal', () => { + expect( + formatMilestone({ + milestone: 'approval_cap_exceeded', + request_id: 'req_xyz', + count: 50, + cap: 50, + }), + ).toBe('Approval cap reached: 50/50 — task halted'); + }); + + it('renders approval_rate_limit_exceeded with rate vs limit', () => { + expect( + formatMilestone({ + milestone: 'approval_rate_limit_exceeded', + request_id: 'req_xyz', + rate: 25, + limit: 10, + }), + ).toBe('Approval rate limit: 25/min > 10/min'); + }); + + it('renders approval_poll_degraded with consecutive-failure count', () => { + expect( + formatMilestone({ + milestone: 'approval_poll_degraded', + request_id: 'req_xyz', + consecutive_failures: 3, + }), + ).toBe('Approval polling degraded — 3 consecutive failures'); + }); + + it('renders approval_late_win with outcome + reason', () => { + expect( + formatMilestone({ + milestone: 'approval_late_win', + request_id: 'req_xyz', + outcome: 'APPROVED', + reason: 'user decision beat agent timer', + }), + ).toBe('Late decision won: APPROVED (.._xyz) — user decision beat agent timer'); + }); + + it('renders pre_approvals_loaded with scope previews when scopes present', () => { + expect( + formatMilestone({ + milestone: 'pre_approvals_loaded', + count: 2, + scopes: ['tool_type:Bash', 'rule:file_edit_gate'], + }), + ).toBe('Pre-approvals loaded: 2 scopes — tool_type:Bash, rule:file_edit_gate'); + }); + + it('renders pre_approvals_loaded with explicit message on zero count', () => { + expect( + formatMilestone({ + milestone: 'pre_approvals_loaded', + count: 0, + scopes: [], + }), + ).toBe('No pre-approvals loaded'); + // This is the case that bit us in the live drive — `bgagent submit` + // with no `--pre-approve` flags: the milestone fired but the CLI + // watch rendered just `★ pre_approvals_loaded` with no detail. + // Now both surfaces explicitly say "no pre-approvals loaded". + }); + + it('truncates a +N more suffix when more than 3 scopes are loaded', () => { + expect( + formatMilestone({ + milestone: 'pre_approvals_loaded', + count: 5, + scopes: ['a', 'b', 'c', 'd', 'e'], + }), + ).toBe('Pre-approvals loaded: 5 scopes — a, b, c, +2 more'); + }); + + it('renders approval_requested with tool name + truncated input preview', () => { + expect( + formatMilestone({ + milestone: 'approval_requested', + request_id: 'req_xyz', + tool_name: 'Bash', + input_preview: 'git push --force origin main', + reason: 'force-push to main', + severity: 'high', + timeout_s: 600, + }), + ).toBe('APPROVAL NEEDED: Bash — git push --force origin main'); + }); + + it('renders approval_granted with scope when present', () => { + expect( + formatMilestone({ + milestone: 'approval_granted', + request_id: '01KS17GZBSKJ32X9C4MH6ZDJ1T', + scope: 'tool_type:Bash', + decided_at: '2026-05-19T23:00:00Z', + }), + ).toBe('Approved (..DJ1T) scope=tool_type:Bash'); + }); + + it('renders approval_denied with reason when present', () => { + expect( + formatMilestone({ + milestone: 'approval_denied', + request_id: 'req_zzz', + reason: 'too risky for this branch', + decided_at: '2026-05-19T23:01:00Z', + }), + ).toBe('Denied (.._zzz) — too risky for this branch'); + }); + + it('renders approval_timed_out using effective_timeout_s when present', () => { + expect( + formatMilestone({ + milestone: 'approval_timed_out', + request_id: 'req_zzz', + timeout_s: 600, + effective_timeout_s: 300, + }), + ).toBe('Timed out (.._zzz) after 300s'); + }); + + it('renders approval_stranded with reconciler reason', () => { + expect( + formatMilestone({ + milestone: 'approval_stranded', + request_id: 'req_zzz', + age_s: 3600, + reason: 'task evicted from runtime', + }), + ).toBe('Stranded (.._zzz) — reconciler: task evicted from runtime'); + }); + + it('renders approval_write_failed with truncated error', () => { + expect( + formatMilestone({ + milestone: 'approval_write_failed', + request_id: null, + error: 'TransactWriteItems: ConditionalCheckFailedException', + }), + ).toContain('Approval write failed: '); + }); + + it('renders policy_decision (recent-decision-cache hit, IMPL-23)', () => { + expect( + formatMilestone({ + milestone: 'policy_decision', + decision_source: 'recent_decision_cache', + tool_name: 'Bash', + cached_decision: 'denied', + }), + ).toBe('Policy cache hit: Bash → denied'); + }); + + it('returns null for unknown milestone sub-names so caller falls back', () => { + expect( + formatMilestone({ + milestone: 'approval_future_milestone', + details: 'something new', + }), + ).toBeNull(); + }); + + it('returns null when no milestone key is present', () => { + expect(formatMilestone({ details: 'just a generic milestone' })).toBeNull(); + }); +}); diff --git a/cli/test/tui-panels/Approvals.test.tsx b/cli/test/tui-panels/Approvals.test.tsx new file mode 100644 index 00000000..c036cc43 --- /dev/null +++ b/cli/test/tui-panels/Approvals.test.tsx @@ -0,0 +1,180 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { jest } from '@jest/globals'; +import { flush, KEY_ENTER, KEY_ESC } from './_helpers'; +import { renderPanel } from './_render'; +import { MockDataSource } from '../../src/tui/api/source-mock'; +import Approvals from '../../src/tui/panels/Approvals'; + +describe('Approvals panel', () => { + it('shows pending approvals from the mock source', async () => { + const { lastFrame, unmount } = renderPanel( + , + { source: new MockDataSource() }, + ); + // Pending list flows through DataProvider → snapshot → context. + for (let i = 0; i < 4; i += 1) await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Pending Approvals'); + // Mock fixture has two pending rows on two different tasks. + expect(frame).toMatch(/2 pending across 2 tasks/); + // Both tools are surfaced. + expect(frame).toContain('EditFile'); + expect(frame).toContain('Bash'); + unmount(); + }); + + it('opens the scope picker on [a]', async () => { + const { lastFrame, stdin, unmount } = renderPanel( + , + { source: new MockDataSource() }, + ); + for (let i = 0; i < 4; i += 1) await flush(); + stdin.write('a'); + await flush(); + const frame = lastFrame() ?? ''; + // ScopePicker heading is "Approve " per the heading prop. + expect(frame).toContain('Approve EditFile'); + // Full 9-variant scope list is rendered. + expect(frame).toContain('Just this one call'); + expect(frame).toContain('Full autonomy'); + unmount(); + }); + + it('opens the deny confirm on [d], then the reason input on [y]', async () => { + const { lastFrame, stdin, unmount } = renderPanel( + , + { source: new MockDataSource() }, + ); + for (let i = 0; i < 4; i += 1) await flush(); + stdin.write('d'); + await flush(); + let frame = lastFrame() ?? ''; + expect(frame).toContain('Confirm deny'); + stdin.write('y'); + await flush(); + frame = lastFrame() ?? ''; + expect(frame).toContain('optional reason'); + unmount(); + }); + + it('calls source.approve with the chosen scope when the user confirms', async () => { + const source = new MockDataSource(); + const approveSpy = jest.spyOn(source, 'approve'); + const { stdin, unmount } = renderPanel(, { source }); + for (let i = 0; i < 4; i += 1) await flush(); + stdin.write('a'); // opens picker + await flush(); + stdin.write(KEY_ENTER); // picks `this_call` + await flush(); + // Allow the TuiProvider optimistic path + async source call. + for (let i = 0; i < 3; i += 1) await flush(); + expect(approveSpy).toHaveBeenCalledTimes(1); + const call = approveSpy.mock.calls[0]; + // task_id is the first approval in the fixture; scope is 'this_call'. + expect(call[2]).toBe('this_call'); + unmount(); + }); + + it('cancels the scope picker on Escape', async () => { + const { lastFrame, stdin, unmount } = renderPanel( + , + { source: new MockDataSource() }, + ); + for (let i = 0; i < 4; i += 1) await flush(); + stdin.write('a'); + await flush(); + expect(lastFrame() ?? '').toContain('Approve EditFile'); + stdin.write(KEY_ESC); + await flush(); + // Picker gone, back to the list header. + const frame = lastFrame() ?? ''; + expect(frame).toContain('Pending Approvals'); + expect(frame).not.toContain('Approve EditFile'); + unmount(); + }); + + // ── Regression: silent-failure on rejected approve API call ────────── + // Phase A live drive (task 01KS18SAV6PPR4XVZPAHF2EJF5) caught a + // P1 bug: the panel used to render `✓ Approved Bash (tool_type:bash)` + // unconditionally as soon as the user picked a scope, even when the + // underlying `source.approve()` call rejected. The user's intent + // never reached the API, the agent stayed blocked AWAITING_APPROVAL, + // and the user had no way to know — they thought they unblocked the + // gate, walked away, and the approval timed out 5 minutes later. + // + // The fix awaits the round-trip and renders an explicit + // `✗ Approve failed for ... — ` toast on rejection while + // un-clearing the optimistic row removal so the user can retry. + describe('regression: rejected approve does not show success toast', () => { + it('renders an "Approve failed" toast when source.approve rejects', async () => { + const source = new MockDataSource(); + jest.spyOn(source, 'approve').mockRejectedValue( + new Error('500: ApiError: backend exploded'), + ); + const { lastFrame, stdin, unmount } = renderPanel( + , + { source }, + ); + for (let i = 0; i < 4; i += 1) await flush(); + stdin.write('a'); + await flush(); + stdin.write(KEY_ENTER); // pick `this_call` + // Two things must propagate: the awaited approve() and the + // setMessage call inside the .then handler. Several flushes. + for (let i = 0; i < 5; i += 1) await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Approve failed'); + expect(frame).toContain('backend exploded'); + // The success-side string must NOT appear — the user should + // never see a misleading green check after a rejected call. + expect(frame).not.toMatch(/✓ Approved/); + // The row must reappear in the list so the user can retry. + // (Optimistic clear is undone on rejection.) + expect(frame).toContain('Pending Approvals'); + expect(frame).toMatch(/2 pending across 2 tasks/); + unmount(); + }); + + it('renders a "Deny failed" toast when source.deny rejects', async () => { + const source = new MockDataSource(); + jest.spyOn(source, 'deny').mockRejectedValue( + new Error('400: invalid request_id'), + ); + const { lastFrame, stdin, unmount } = renderPanel( + , + { source }, + ); + for (let i = 0; i < 4; i += 1) await flush(); + stdin.write('d'); + await flush(); + stdin.write('y'); // confirm deny → opens reason input + await flush(); + stdin.write(KEY_ENTER); // submit empty reason + for (let i = 0; i < 5; i += 1) await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Deny failed'); + expect(frame).toContain('invalid request_id'); + // No misleading red-cross-Denied message on a rejected call. + expect(frame).not.toMatch(/✗ Denied EditFile/); + unmount(); + }); + }); +}); diff --git a/cli/test/tui-panels/DenyReasonInput.test.tsx b/cli/test/tui-panels/DenyReasonInput.test.tsx new file mode 100644 index 00000000..1fb60f4f --- /dev/null +++ b/cli/test/tui-panels/DenyReasonInput.test.tsx @@ -0,0 +1,82 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { render } from 'ink-testing-library'; +import { flush, KEY_ENTER, KEY_ESC } from './_helpers'; +import DenyReasonInput from '../../src/tui/components/DenyReasonInput'; +import { DENY_REASON_MAX_LENGTH } from '../../src/types'; + +describe('DenyReasonInput', () => { + it('renders prompt + cap hint', () => { + const { lastFrame, unmount } = render( + {}} onCancel={() => {}} />, + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Deny — optional reason'); + expect(frame).toContain(`0/${DENY_REASON_MAX_LENGTH}`); + unmount(); + }); + + it('accepts typed characters and updates the counter', async () => { + const { lastFrame, stdin, unmount } = render( + {}} onCancel={() => {}} />, + ); + stdin.write('no'); + await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('no'); + expect(frame).toContain(`2/${DENY_REASON_MAX_LENGTH}`); + unmount(); + }); + + it('confirms the trimmed reason on Enter', async () => { + let reason: string | null = null; + const { stdin, unmount } = render( + { reason = r; }} onCancel={() => {}} />, + ); + stdin.write('stop'); + await flush(); + stdin.write(KEY_ENTER); + await flush(); + expect(reason).toBe('stop'); + unmount(); + }); + + it('cancels on Escape', async () => { + let cancelled = false; + const { stdin, unmount } = render( + {}} onCancel={() => { cancelled = true; }} />, + ); + stdin.write(KEY_ESC); + await flush(); + expect(cancelled).toBe(true); + unmount(); + }); + + it('confirms empty reason as empty string (agent gets denial with no note)', async () => { + let reason: string | null = null; + const { stdin, unmount } = render( + { reason = r; }} onCancel={() => {}} />, + ); + stdin.write(KEY_ENTER); + await flush(); + expect(reason).toBe(''); + unmount(); + }); +}); diff --git a/cli/test/tui-panels/EventLine.test.tsx b/cli/test/tui-panels/EventLine.test.tsx new file mode 100644 index 00000000..2a32e488 --- /dev/null +++ b/cli/test/tui-panels/EventLine.test.tsx @@ -0,0 +1,206 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * EventLine renders both unwrapped (mock-fixture) and wrapped + * (`agent_milestone` + `metadata.milestone`) approval events. These + * tests assert the IMPL-26 user-visible-timeout milestones (Fix 4) + * are surfaced as readable strings rather than raw event_type names — + * the TUI gap that motivated this change. + */ + +import { render } from 'ink-testing-library'; +import EventLine from '../../src/tui/components/EventLine'; +import type { TaskEvent } from '../../src/tui/data'; + +function makeEvent(partial: Partial & Pick): TaskEvent { + return { + event_id: partial.event_id ?? 'evt_01', + timestamp: partial.timestamp ?? '2026-05-19T14:00:00Z', + event_type: partial.event_type, + metadata: partial.metadata, + }; +} + +describe('EventLine — Cedar HITL milestones', () => { + it('renders approval_timeout_capped from agent_milestone wrapper with requested → effective', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_timeout_capped', + request_id: 'req_xyz', + requested_timeout_s: 600, + effective_timeout_s: 300, + reason: 'rule_annotation', + matching_rule_ids: ['write_credentials'], + }, + }); + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Timeout capped: 600s → 300s'); + expect(frame).toContain('rule_annotation'); + expect(frame).toContain('write_credentials'); + unmount(); + }); + + it('renders approval_ceiling_shrinking with usable lifetime budget', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_ceiling_shrinking', + request_id: 'req_xyz', + maxLifetime_remaining_s: 1200, + cleanup_margin_s: 200, + task_default_timeout_s: 300, + }, + }); + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Approval window shrinking'); + expect(frame).toContain('1000s'); // 1200 - 200 + unmount(); + }); + + it('renders approval_cap_exceeded as a task-halted signal', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_cap_exceeded', + request_id: 'req_xyz', + count: 50, + cap: 50, + }, + }); + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Approval cap reached: 50/50'); + expect(frame).toContain('task halted'); + unmount(); + }); + + it('renders approval_rate_limit_exceeded with rate vs limit', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_rate_limit_exceeded', + request_id: 'req_xyz', + rate: 25, + limit: 10, + }, + }); + const { lastFrame, unmount } = render(); + expect(lastFrame() ?? '').toContain('Approval rate limit: 25/min > 10/min'); + unmount(); + }); + + it('renders approval_poll_degraded with consecutive-failure count', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_poll_degraded', + request_id: 'req_xyz', + consecutive_failures: 3, + }, + }); + const { lastFrame, unmount } = render(); + expect(lastFrame() ?? '').toContain('Approval polling degraded'); + expect(lastFrame() ?? '').toContain('3 consecutive failures'); + unmount(); + }); + + it('renders approval_late_win with outcome + reason', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_late_win', + request_id: 'req_xyz', + outcome: 'APPROVED', + reason: 'user decision beat agent timer', + }, + }); + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Late decision won'); + expect(frame).toContain('APPROVED'); + unmount(); + }); + + it('renders pre_approvals_loaded with scope previews', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'pre_approvals_loaded', + count: 2, + scopes: ['tool_type:Bash', 'rule:file_edit_gate'], + }, + }); + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Pre-approvals loaded: 2 scopes'); + expect(frame).toContain('tool_type:Bash'); + expect(frame).toContain('rule:file_edit_gate'); + unmount(); + }); + + it('renders approval_write_failed with truncated error', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_write_failed', + request_id: null, + error: 'TransactWriteItems: ConditionalCheckFailedException', + }, + }); + const { lastFrame, unmount } = render(); + expect(lastFrame() ?? '').toContain('Approval write failed'); + unmount(); + }); + + it('renders unwrapped mock-fixture approval_timeout_capped identically to wrapped form', () => { + const event = makeEvent({ + event_type: 'approval_timeout_capped', + metadata: { + request_id: 'req_xyz', + requested_timeout_s: 600, + effective_timeout_s: 300, + reason: 'maxLifetime_ceiling', + }, + }); + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Timeout capped: 600s → 300s'); + expect(frame).toContain('maxLifetime_ceiling'); + unmount(); + }); + + it('falls back gracefully on unknown milestone sub-name', () => { + const event = makeEvent({ + event_type: 'agent_milestone', + metadata: { + milestone: 'approval_future_milestone', + details: 'something new', + }, + }); + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('approval_future_milestone'); + expect(frame).toContain('something new'); + unmount(); + }); +}); diff --git a/cli/test/tui-panels/Policies.test.tsx b/cli/test/tui-panels/Policies.test.tsx new file mode 100644 index 00000000..5a79a93c --- /dev/null +++ b/cli/test/tui-panels/Policies.test.tsx @@ -0,0 +1,60 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { flush } from './_helpers'; +import { renderPanel } from './_render'; +import { MockDataSource } from '../../src/tui/api/source-mock'; +import Policies from '../../src/tui/panels/Policies'; + +describe('Policies panel', () => { + it('auto-selects the first repo in mock mode and shows hard + soft rules', async () => { + const { lastFrame, unmount } = renderPanel(, { + source: new MockDataSource(), + }); + // Propagation chain (each step is at least one microtask): + // 1. DataProvider.refresh() → repos populate + // 2. Policies useEffect auto-selects first repo + // 3. loadPolicies(repo) → policiesByRepo map update + // 4. React re-renders with the hard/soft buckets + // Each `flush()` yields 30ms + microtask drain; four is enough + // padding to cover the chain even on slow CI runners. + for (let i = 0; i < 4; i += 1) await flush(); + const frame = lastFrame() ?? ''; + // Title banner. + expect(frame).toContain('Safety Policies'); + // Both tier headings are rendered. + expect(frame).toContain('Blocked'); + expect(frame).toContain('Requires approval'); + // Known rule ids from the mock fixture. + expect(frame).toContain('rm_slash'); + expect(frame).toContain('bash_exec_gate'); + unmount(); + }); + + it('shows the selected repo in the header', async () => { + const { lastFrame, unmount } = renderPanel(, { + source: new MockDataSource(), + }); + for (let i = 0; i < 4; i += 1) await flush(); + const frame = lastFrame() ?? ''; + // Mock fixture has `aws-samples/my-project` as the first active repo. + expect(frame).toContain('aws-samples/my-project'); + unmount(); + }); +}); diff --git a/cli/test/tui-panels/ScopePicker.test.tsx b/cli/test/tui-panels/ScopePicker.test.tsx new file mode 100644 index 00000000..8da6d930 --- /dev/null +++ b/cli/test/tui-panels/ScopePicker.test.tsx @@ -0,0 +1,123 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { render } from 'ink-testing-library'; +import { flush, KEY_DOWN, KEY_ENTER, KEY_ESC } from './_helpers'; +import ScopePicker from '../../src/tui/components/ScopePicker'; +import type { ApprovalScope } from '../../src/types'; + +describe('ScopePicker', () => { + it('renders all 9 scope options on mount', () => { + const { lastFrame, unmount } = render( + {}} onCancel={() => {}} />, + ); + const frame = lastFrame() ?? ''; + // Short forms + expect(frame).toContain('Just this one call'); + expect(frame).toContain('tool type'); + expect(frame).toContain('tool group'); + expect(frame).toContain('Full autonomy'); + // Prefixed forms + expect(frame).toContain('tool_type:'); + expect(frame).toContain('tool_group:'); + expect(frame).toContain('bash_pattern:'); + expect(frame).toContain('write_path:'); + expect(frame).toContain('rule:'); + unmount(); + }); + + it('confirms the short-form scope on Enter', async () => { + let confirmed: ApprovalScope | null = null; + const { stdin, unmount } = render( + { confirmed = s; }} onCancel={() => {}} />, + ); + // The first option is `this_call` — press Enter. + stdin.write(KEY_ENTER); + await flush(); + expect(confirmed).toBe('this_call'); + unmount(); + }); + + it('prompts for operand on prefixed scope, then composes scope:', async () => { + let confirmed: ApprovalScope | null = null; + const { lastFrame, stdin, unmount } = render( + { confirmed = s; }} onCancel={() => {}} />, + ); + // Options (in order): this_call(0), tool_type_session(1), + // tool_group_session(2), tool_type(3). Down 3 times → focus on + // tool_type prefix row. + stdin.write(KEY_DOWN); // ↓ + await flush(); + stdin.write(KEY_DOWN); // ↓ + await flush(); + stdin.write(KEY_DOWN); // ↓ → tool_type prefix + await flush(); + stdin.write(KEY_ENTER); // Enter → operand step + await flush(); + // Frame should now show the operand prompt. + expect(lastFrame() ?? '').toContain('Enter operand for tool_type'); + // Type "Bash" and confirm. + stdin.write('B'); + await flush(); + stdin.write('a'); + await flush(); + stdin.write('s'); + await flush(); + stdin.write('h'); + await flush(); + stdin.write(KEY_ENTER); + await flush(); + expect(confirmed).toBe('tool_type:Bash'); + unmount(); + }); + + it('gates all_session behind a y/n confirmation', async () => { + let confirmed: ApprovalScope | null = null; + const { lastFrame, stdin, unmount } = render( + { confirmed = s; }} onCancel={() => {}} />, + ); + // all_session is the LAST option (index 8). + for (let i = 0; i < 8; i += 1) { + stdin.write(KEY_DOWN); + await flush(); + } + stdin.write(KEY_ENTER); // Enter → confirm step + await flush(); + expect(lastFrame() ?? '').toContain('all_session grants the agent blanket'); + expect(confirmed).toBeNull(); + stdin.write('y'); + await flush(); + expect(confirmed).toBe('all_session'); + unmount(); + }); + + it('cancels on Escape from picker step', async () => { + let cancelled = false; + const { stdin, unmount } = render( + {}} onCancel={() => { cancelled = true; }} />, + ); + // Node's `readline` keypress parser buffers a lone \u001B as + // the start of a multi-byte sequence. Double-escape tells the + // parser "no continuation coming" — ink sees a single escape. + stdin.write(KEY_ESC); + await flush(); + expect(cancelled).toBe(true); + unmount(); + }); +}); diff --git a/cli/test/tui-panels/Submit.test.tsx b/cli/test/tui-panels/Submit.test.tsx new file mode 100644 index 00000000..8c49933b --- /dev/null +++ b/cli/test/tui-panels/Submit.test.tsx @@ -0,0 +1,273 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { jest } from '@jest/globals'; + +const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]); + +// ESM module bindings are read-only — `jest.spyOn` on `import * as` +// fails. Use `unstable_mockModule` (Jest's ESM-compatible mocking +// API) and dynamic-import the modules under test AFTER the mock +// is registered. We type the mock against the real +// `ClipboardReadResult` so each test can hand it any valid +// discriminator without triggering type narrowing. +import type { ClipboardReadResult } from '../../src/tui/utils/clipboard'; + +const clipboardMock = { + readClipboardImage: jest.fn<(opts?: { maxBytes?: number }) => Promise>(), + shouldShowHintOnce: jest.fn<(toolKey: string) => boolean>(), + _resetHintCacheForTests: jest.fn(), + DEFAULT_MAX_IMAGE_BYTES: 5 * 1024 * 1024, +}; +clipboardMock.readClipboardImage.mockResolvedValue({ + ok: false, + failure: { kind: 'not_image' }, +}); +clipboardMock.shouldShowHintOnce.mockReturnValue(true); + +jest.unstable_mockModule('../../src/tui/utils/clipboard', () => clipboardMock); + +const { default: Submit } = await import('../../src/tui/panels/Submit'); +const { renderPanel } = await import('./_render'); +const { flush, KEY_DOWN, KEY_ENTER } = await import('./_helpers'); +const { MockDataSource } = await import('../../src/tui/api/source-mock'); +const { APPROVAL_TIMEOUT_S_DEFAULT, INITIAL_APPROVALS_MAX_ENTRIES } + = await import('../../src/types'); + +/** Move from the repo step through the listed repos, exiting into + * the next field (prompt). The repo step's cursor walks the repo + * list first; we need one ↓ per list entry plus one to exit. */ +async function leaveRepoStep(stdin: { write: (s: string) => void }, repoCount: number) { + for (let i = 0; i < repoCount; i += 1) { + stdin.write(KEY_DOWN); + await flush(); + } +} + +describe('Submit panel', () => { + it('renders all five form fields including the new approval-timeout + pre-approvals', async () => { + const { lastFrame, unmount } = renderPanel( + {}} />, + { source: new MockDataSource() }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('New Task'); + expect(frame).toContain('Repository'); + expect(frame).toContain('Instructions'); + expect(frame).toContain('Approval timeout'); + expect(frame).toContain(`${APPROVAL_TIMEOUT_S_DEFAULT}s`); + expect(frame).toContain('Pre-approve'); + expect(frame).toContain('[ Submit Task ]'); + unmount(); + }); + + it('navigates down through the field order correctly', async () => { + const source = new MockDataSource(); + const repoCount = (await source.listRegisteredRepos()).length; + const { lastFrame, stdin, unmount } = renderPanel( + {}} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + // Walk from repo down to submit — confirms focus moves + // through prompt, timeout, approvals, submit. + await leaveRepoStep(stdin, repoCount); // → prompt + stdin.write(KEY_DOWN); await flush(); // → timeout + stdin.write(KEY_DOWN); await flush(); // → approvals + stdin.write(KEY_DOWN); await flush(); // → submit + const frame = lastFrame() ?? ''; + // Submit button is rendered; the color changes when focused (and + // the panel still renders the button label in both states). + expect(frame).toContain('[ Submit Task ]'); + unmount(); + }); + + it('rejects submit when prompt is empty', async () => { + const source = new MockDataSource(); + const repoCount = (await source.listRegisteredRepos()).length; + const submitSpy = jest.spyOn(source, 'submitTask'); + const { stdin, unmount } = renderPanel( + {}} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + // Navigate to Submit without filling in prompt. + await leaveRepoStep(stdin, repoCount); + stdin.write(KEY_DOWN); await flush(); // → timeout + stdin.write(KEY_DOWN); await flush(); // → approvals + stdin.write(KEY_DOWN); await flush(); // → submit + stdin.write(KEY_ENTER); await flush(); + expect(submitSpy).not.toHaveBeenCalled(); + unmount(); + }); + + it('exposes the approval-scopes help once focused on the pre-approvals field', async () => { + const source = new MockDataSource(); + const repoCount = (await source.listRegisteredRepos()).length; + const { lastFrame, stdin, unmount } = renderPanel( + {}} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + await leaveRepoStep(stdin, repoCount); // → prompt + stdin.write(KEY_DOWN); await flush(); // → timeout + stdin.write(KEY_DOWN); await flush(); // → approvals + const frame = lastFrame() ?? ''; + expect(frame).toContain('a or + to add a scope'); + expect(INITIAL_APPROVALS_MAX_ENTRIES).toBeGreaterThan(0); + unmount(); + }); + + describe('attachments / clipboard paste', () => { + beforeEach(() => { + clipboardMock.readClipboardImage.mockReset(); + clipboardMock.shouldShowHintOnce.mockReset(); + clipboardMock.shouldShowHintOnce.mockImplementation(() => true); + // Default: no image (tests opt-in to a successful read). + clipboardMock.readClipboardImage.mockResolvedValue({ + ok: false as const, + failure: { kind: 'not_image' as const }, + }); + }); + + it('renders the attachments field and Ctrl+V hint', async () => { + const { lastFrame, unmount } = renderPanel( + {}} />, + { source: new MockDataSource() }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Attachments'); + expect(frame).toMatch(/Ctrl\+V/); + unmount(); + }); + + it('appends an image attachment on Ctrl+V when the clipboard has a PNG', async () => { + clipboardMock.readClipboardImage.mockResolvedValue({ + ok: true as const, + image: { + buffer: PNG_BYTES, + mediaType: 'image/png' as const, + base64: PNG_BYTES.toString('base64'), + sizeBytes: PNG_BYTES.length, + }, + }); + const { lastFrame, stdin, unmount } = renderPanel( + {}} />, + { source: new MockDataSource() }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + // Ctrl+V is byte 0x16 in raw mode; ink-testing-library passes + // raw bytes through to useInput. + stdin.write('\x16'); + // The paste pipeline is async (spawn → magic-byte sniff → + // setState). Two flushes covers the microtask drain plus a + // rerender. + await flush(); + await flush(); + const frame = lastFrame() ?? ''; + // The attachment summary shows the count + the green pasted toast. + expect(frame).toMatch(/Attachments/); + expect(frame).toMatch(/Pasted image/); + unmount(); + }); + + it('shows a warning when the clipboard does not contain an image', async () => { + // Default mock already returns not_image — no extra setup needed. + const { lastFrame, stdin, unmount } = renderPanel( + {}} />, + { source: new MockDataSource() }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + stdin.write('\x16'); + await flush(); + await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/does not contain an image/i); + unmount(); + }); + + it('shows the install hint with brew install pngpaste when tool is missing on macOS', async () => { + clipboardMock.readClipboardImage.mockResolvedValue({ + ok: false as const, + failure: { + kind: 'tool_missing' as const, + platform: 'darwin' as const, + hint: 'Install pngpaste for clipboard image paste:\n brew install pngpaste', + }, + }); + const { lastFrame, stdin, unmount } = renderPanel( + {}} />, + { source: new MockDataSource() }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + stdin.write('\x16'); + await flush(); + await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/brew install pngpaste/); + unmount(); + }); + + it('forwards attachments to submitTask on form submit', async () => { + const source = new MockDataSource(); + const repoCount = (await source.listRegisteredRepos()).length; + const submitSpy = jest.spyOn(source, 'submitTask'); + clipboardMock.readClipboardImage.mockResolvedValue({ + ok: true as const, + image: { + buffer: PNG_BYTES, + mediaType: 'image/png' as const, + base64: PNG_BYTES.toString('base64'), + sizeBytes: PNG_BYTES.length, + }, + }); + const { stdin, unmount } = renderPanel( + {}} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + + // Paste an image first. + stdin.write('\x16'); await flush(); await flush(); + + // Walk to prompt, type a description, walk to submit. + await leaveRepoStep(stdin, repoCount); // → prompt + stdin.write(KEY_ENTER); await flush(); // enter prompt edit + stdin.write('do a thing'); await flush(); + stdin.write(KEY_ENTER); await flush(); // exit prompt edit + stdin.write(KEY_DOWN); await flush(); // → timeout + stdin.write(KEY_DOWN); await flush(); // → approvals + stdin.write(KEY_DOWN); await flush(); // → attachments + stdin.write(KEY_DOWN); await flush(); // → submit + stdin.write(KEY_ENTER); await flush(); await flush(); + + expect(submitSpy).toHaveBeenCalled(); + const arg = (submitSpy.mock.calls[0] as unknown as [{ attachments?: unknown[] }])[0]; + expect(arg.attachments).toBeDefined(); + expect(arg.attachments).toHaveLength(1); + expect((arg.attachments as Array<{ type: string; content_type?: string }>)[0]).toMatchObject({ + type: 'image', + content_type: 'image/png', + }); + unmount(); + }); + }); +}); diff --git a/cli/test/tui-panels/TaskList.test.tsx b/cli/test/tui-panels/TaskList.test.tsx new file mode 100644 index 00000000..67b771ff --- /dev/null +++ b/cli/test/tui-panels/TaskList.test.tsx @@ -0,0 +1,117 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { flush, KEY_DOWN, KEY_ENTER } from './_helpers'; +import { renderPanel } from './_render'; +import { MockDataSource } from '../../src/tui/api/source-mock'; +import TaskList from '../../src/tui/panels/TaskList'; + +describe('TaskList panel', () => { + it('renders the column headers including the new GATES + SOURCE columns', async () => { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + const { lastFrame, unmount } = renderPanel( + {}} active />, + { source }, + ); + await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('STATUS'); + expect(frame).toContain('SOURCE'); + expect(frame).toContain('GATES'); + expect(frame).toContain('DESCRIPTION'); + unmount(); + }); + + it('renders channel-source labels in the SOURCE column', async () => { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + const { lastFrame, unmount } = renderPanel( + {}} active />, + { source }, + ); + await flush(); + const frame = lastFrame() ?? ''; + // The mock fixture varies channel_source across the 4 rows so + // users see the column doing something; assert each label is + // actually in the frame. + expect(frame).toContain('CLI'); + expect(frame).toContain('Slack'); + expect(frame).toContain('Linear'); + expect(frame).toContain('Hook'); + unmount(); + }); + + it('renders gate counters in the GATES column', async () => { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + const { lastFrame, unmount } = renderPanel( + {}} active />, + { source }, + ); + await flush(); + const frame = lastFrame() ?? ''; + // Fixture tasks have approval_gate_count/cap, so we expect at + // least one X/50-format entry. + expect(frame).toMatch(/\d+\/50/); + unmount(); + }); + + it('fires onSelectTask with the focused task_id on Enter', async () => { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + let selected: string | null = null; + const { stdin, unmount } = renderPanel( + { selected = id; }} active />, + { source }, + ); + await flush(); + stdin.write(KEY_ENTER); + await flush(); + expect(selected).toBe(tasks[0].task_id); + unmount(); + }); + + it('moves the cursor on down-arrow', async () => { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + let selected: string | null = null; + const { stdin, unmount } = renderPanel( + { selected = id; }} active />, + { source }, + ); + await flush(); + stdin.write(KEY_DOWN); + await flush(); + stdin.write(KEY_ENTER); + await flush(); + expect(selected).toBe(tasks[1].task_id); + unmount(); + }); + + it('shows empty-state hint when there are no tasks', async () => { + const { lastFrame, unmount } = renderPanel( + {}} active />, + ); + await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toContain('No tasks yet'); + unmount(); + }); +}); diff --git a/cli/test/tui-panels/Watch.test.tsx b/cli/test/tui-panels/Watch.test.tsx new file mode 100644 index 00000000..d3c66f8d --- /dev/null +++ b/cli/test/tui-panels/Watch.test.tsx @@ -0,0 +1,135 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { flush, KEY_ESC } from './_helpers'; +import { renderPanel } from './_render'; +import { MockDataSource } from '../../src/tui/api/source-mock'; +import Watch from '../../src/tui/panels/Watch'; + +describe('Watch panel', () => { + async function pickFirstTask() { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + return { source, task: tasks[0] }; + } + + it('renders the task header with status + gate budget', async () => { + const { source, task } = await pickFirstTask(); + const { lastFrame, unmount } = renderPanel( + {}} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + const frame = lastFrame() ?? ''; + // Task id suffix + status label from STATUS_LABEL['RUNNING']. + expect(frame).toContain(`..${task.task_id.slice(-4)}`); + expect(frame).toContain('Running'); + // Gate budget surfaces gate counters when they are non-null. + expect(frame).toContain('Approval gates:'); + expect(frame).toContain(`${task.approval_gate_count}/${task.approval_gate_cap}`); + unmount(); + }); + + it('fires onBack when Escape is pressed outside any overlay', async () => { + const { source, task } = await pickFirstTask(); + let backCalls = 0; + const { stdin, unmount } = renderPanel( + { backCalls += 1; }} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + stdin.write(KEY_ESC); + await flush(); + expect(backCalls).toBe(1); + unmount(); + }); + + it('shows event-stream content from the mock source', async () => { + const { source, task } = await pickFirstTask(); + const { lastFrame, unmount } = renderPanel( + {}} />, + { source }, + ); + // Mock replays events one-at-a-time at 600ms cadence. Wait long + // enough for a few to land. + for (let i = 0; i < 3; i += 1) await flush(); + await new Promise((r) => setTimeout(r, 800)); + await flush(); + const frame = lastFrame() ?? ''; + // A known mock event from the fixture stream. + expect(frame).toMatch(/Task started|ReadFile|Step \d/); + unmount(); + }); + + it('shows the PR banner once task.pr_url is set', async () => { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + // The mock fixture has one COMPLETED task with a pr_url — pick it. + const completed = tasks.find(t => t.pr_url !== null)!; + expect(completed).toBeDefined(); + const { lastFrame, unmount } = renderPanel( + {}} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + const frame = lastFrame() ?? ''; + // Banner renders the full URL so it's clickable via terminal OSC 8 + // in compatible emulators; the test just asserts substring. + expect(frame).toContain('PR:'); + expect(frame).toContain(completed.pr_url ?? ''); + unmount(); + }); + + it('does NOT show the PR banner for tasks without a pr_url', async () => { + const { source, task } = await pickFirstTask(); // RUNNING task, pr_url=null + const { lastFrame, unmount } = renderPanel( + {}} />, + { source }, + ); + for (let i = 0; i < 3; i += 1) await flush(); + const frame = lastFrame() ?? ''; + // "PR:" string must be absent — the banner is gated on pr_url. + expect(frame).not.toContain('PR:'); + unmount(); + }); + + it('opens the scope picker on [a] when an approval is pending', async () => { + const source = new MockDataSource(); + const tasks = await source.listTasks(); + // Use the AWAITING_APPROVAL task (task_id ends in Y4M2 — second + // fixture). The awaiting_approval_request_id is the source of + // truth that the approval card links to. + const awaitingTask = tasks.find(t => t.status === 'AWAITING_APPROVAL')!; + expect(awaitingTask).toBeDefined(); + const { lastFrame, stdin, unmount } = renderPanel( + {}} />, + { source }, + ); + // Extra flushes — the pendingApproval lookup requires the context + // to have loaded the approvals list, which is one more DataProvider + // round-trip past the tasks/repos fan-out. + for (let i = 0; i < 5; i += 1) await flush(); + stdin.write('a'); + await flush(); + const frame = lastFrame() ?? ''; + // Scope picker heading uses the approval's tool name (`Bash`). + expect(frame).toContain('Approve Bash'); + unmount(); + }); +}); diff --git a/cli/test/tui-panels/_helpers.tsx b/cli/test/tui-panels/_helpers.tsx new file mode 100644 index 00000000..8a254211 --- /dev/null +++ b/cli/test/tui-panels/_helpers.tsx @@ -0,0 +1,47 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Helpers for ink-testing-library based panel tests. + * + * `flush()` waits for ink's 20 ms escape-flush timer and then drains + * microtasks — enough time for keypress → React state → rendered + * frame to propagate. + */ + +export async function flush(): Promise { + await new Promise((r) => setTimeout(r, 30)); + await new Promise((r) => setImmediate(r)); +} + +/** Write a key sequence then flush. Avoids boilerplate in tests. */ +export async function press( + stdin: { write: (s: string) => void }, + sequence: string, +): Promise { + stdin.write(sequence); + await flush(); +} + +export const KEY_UP = '\u001B[A'; +export const KEY_DOWN = '\u001B[B'; +export const KEY_LEFT = '\u001B[D'; +export const KEY_RIGHT = '\u001B[C'; +export const KEY_ENTER = '\r'; +export const KEY_ESC = '\u001B\u001B'; diff --git a/cli/test/tui-panels/_render.tsx b/cli/test/tui-panels/_render.tsx new file mode 100644 index 00000000..b809c7b1 --- /dev/null +++ b/cli/test/tui-panels/_render.tsx @@ -0,0 +1,59 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Test wrapper that mounts a TUI panel inside the full provider + * stack (DataProvider + TuiProvider) backed by a mock source. + * Returns the underlying ink-testing-library instance so tests + * can stdin.write, assert lastFrame, and unmount. + * + * The mock source resolves Promises on the next microtask, so a + * single `flush()` after render lands the first snapshot. Tests + * that need to observe the snapshot should call `flush()` once + * before asserting. + */ + +import { render } from 'ink-testing-library'; +import type { ReactElement } from 'react'; +import type { DataSource } from '../../src/tui/api/source'; +import { MockDataSource } from '../../src/tui/api/source-mock'; +import { TuiProvider } from '../../src/tui/context'; +import { DataProvider } from '../../src/tui/hooks/useData'; + +export interface RenderPanelOptions { + /** Override the data source. Defaults to a fresh `MockDataSource`. */ + source?: DataSource; + /** Override the provider's poll interval — set high in tests so the + * loop doesn't fire during the window of interest. */ + pollIntervalMs?: number; +} + +export function renderPanel( + node: ReactElement, + opts: RenderPanelOptions = {}, +): ReturnType { + const source = opts.source ?? new MockDataSource(); + return render( + + + {node} + + , + ); +} diff --git a/cli/test/tui-panels/useData-split-cadence.test.tsx b/cli/test/tui-panels/useData-split-cadence.test.tsx new file mode 100644 index 00000000..7292ddaf --- /dev/null +++ b/cli/test/tui-panels/useData-split-cadence.test.tsx @@ -0,0 +1,146 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Verifies the DataProvider splits tasks/repos polling from pending + * polling. Phase A surfaced a UX regression where backing off the + * /v1/pending cadence (correct, the endpoint is rate-limited) also + * delayed the Tasks list updating after a CLI submit (wrong — that + * endpoint isn't rate-limited and the user expects it live). + * + * The fix moved tasks/repos to a fixed 3 s cadence and kept only + * /pending on the adaptive ladder. This suite asserts the two + * timers fire independently, and that switching to the Approvals + * panel resets the pending cadence to fast. + */ + +import { jest } from '@jest/globals'; +import { Box, Text } from 'ink'; +import { render } from 'ink-testing-library'; +import React, { useEffect } from 'react'; +import { flush } from './_helpers'; +import { MockDataSource } from '../../src/tui/api/source-mock'; +import { DataProvider, useData } from '../../src/tui/hooks/useData'; + +/** Tiny harness component that exposes the data context to the + * surrounding test by reading and rendering a counter for each + * endpoint, and optionally calls `resetPendingCadence` once on + * mount. */ +const Harness: React.FC<{ resetOnMount?: boolean }> = ({ resetOnMount }) => { + const { snapshot, resetPendingCadence } = useData(); + useEffect(() => { + if (resetOnMount) resetPendingCadence(); + }, [resetOnMount, resetPendingCadence]); + return ( + + tasks={snapshot.tasks.length} + pending={snapshot.approvals.length} + err={snapshot.error ?? 'null'} + rl={snapshot.rateLimited ? 'true' : 'false'} + + ); +}; + +describe('DataProvider split cadence', () => { + it('runs tasks and pending refreshes through separate code paths', async () => { + const source = new MockDataSource(); + const tasksSpy = jest.spyOn(source, 'listTasks'); + const pendingSpy = jest.spyOn(source, 'listPending'); + // Use the adaptive ladder (no pollIntervalMs override). Initial + // pending cadence is 3 s; tasks default cadence is 3 s. We don't + // wait long enough to observe a second poll on either — we just + // assert that both fire independently on initial hydration, which + // would not happen if the unified-refresh code path were still + // active and one of them threw. + const { unmount } = render( + + + , + ); + for (let i = 0; i < 3; i += 1) await flush(); + expect(tasksSpy).toHaveBeenCalledTimes(1); + expect(pendingSpy).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('resetPendingCadence triggers an additional /pending poll', async () => { + const source = new MockDataSource(); + const tasksSpy = jest.spyOn(source, 'listTasks'); + const pendingSpy = jest.spyOn(source, 'listPending'); + const { rerender, unmount } = render( + + + , + ); + for (let i = 0; i < 3; i += 1) await flush(); + const tasksBefore = tasksSpy.mock.calls.length; + const pendingBefore = pendingSpy.mock.calls.length; + // Re-render with resetOnMount=true to fire the reset. + rerender( + + + , + ); + for (let i = 0; i < 4; i += 1) await flush(); + // A reset must have produced at least one extra /pending call. + expect(pendingSpy.mock.calls.length).toBeGreaterThan(pendingBefore); + // Tasks call count is unaffected by the reset (it has its own + // timer). Allow ±1 because the timing of the tasks tick relative + // to the rerender is non-deterministic in the test harness; we + // mainly want to confirm the reset doesn't ALSO restart the tasks + // timer (which would be a leak — separate timers must stay + // separate). + expect(Math.abs(tasksSpy.mock.calls.length - tasksBefore)).toBeLessThanOrEqual(2); + unmount(); + }); + + it('a 429 on /pending does not interfere with future /tasks polls', async () => { + const source = new MockDataSource(); + const tasksSpy = jest.spyOn(source, 'listTasks'); + // Synthesize an ApiError-shaped 429 so isRateLimitError() narrows. + const rateLimitErr = Object.assign(new Error('429 RATE_LIMIT_EXCEEDED'), { + statusCode: 429, + errorCode: 'RATE_LIMIT_EXCEEDED', + }); + let pendingCalls = 0; + jest.spyOn(source, 'listPending').mockImplementation(async () => { + pendingCalls += 1; + // First call rate-limits; subsequent calls would succeed but we + // don't wait long enough to observe them. + if (pendingCalls === 1) throw rateLimitErr; + return []; + }); + const { lastFrame, unmount } = render( + + + , + ); + for (let i = 0; i < 5; i += 1) await flush(); + // Tasks endpoint was hit even though pending threw. + expect(tasksSpy).toHaveBeenCalled(); + // Snapshot reflects the rate-limit state from the pending failure. + const frame = lastFrame() ?? ''; + expect(frame).toContain('rl=true'); + expect(frame).toContain('Rate limit reached'); + // tasks count is still rendered — the tasks branch did NOT inherit + // the pending error path. + expect(frame).toMatch(/tasks=\d+/); + unmount(); + }); +}); diff --git a/cli/test/tui/clipboard.test.ts b/cli/test/tui/clipboard.test.ts new file mode 100644 index 00000000..1add3d07 --- /dev/null +++ b/cli/test/tui/clipboard.test.ts @@ -0,0 +1,382 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * clipboard.ts unit tests. + * + * The new clipboard reader writes via the platform clipboard tool + * to a temp file then reads it back, so each platform path is + * exercised by mocking BOTH `node:child_process.spawn` (the tool + * invocation) AND `node:fs/promises` (the temp file read). A small + * recorder pairs them: when the spawn succeeds, the temp path + * passed to spawn (or referenced in argv) gets a fake file body + * staged, and the next `fs.readFile` for that path returns it. + */ + +import { EventEmitter } from 'node:events'; + +// Mock node:fs so we can intercept fs.promises.readFile / .unlink +// without trying to spyOn read-only ESM exports. The factory keeps +// every other export pass-through to the real module. +// +// Module-level mutable refs let each test stub readFile/unlink +// independently — Jest hoists `jest.mock` above imports, so the +// refs must be `var` declarations (or accessed lazily). +let stagedFiles = new Map(); +jest.mock('node:fs', () => { + const actual = jest.requireActual('node:fs') as typeof import('node:fs'); + return { + ...actual, + promises: { + ...actual.promises, + readFile: async (path: string | Buffer | URL) => { + const p = typeof path === 'string' ? path : String(path); + const body = stagedFiles.get(p); + if (body !== undefined) return body; + const err = new Error(`ENOENT: no such file ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }, + unlink: async (_path: unknown) => undefined, + }, + }; +}); + +import { + _resetHintCacheForTests, + readClipboardImage, + shouldShowHintOnce, +} from '../../src/tui/utils/clipboard'; + +// Magic-byte buffers used across multiple tests. +const PNG_HEADER = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, +]); +const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a]); +const GIF_HEADER = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); +const TEXT_BYTES = Buffer.from('hello world', 'utf8'); + +interface FakeSpawnSpec { + /** stdout bytes the fake child emits before close. Used by Linux + * TARGETS-query mocks; image-save mocks usually leave this empty + * (the tool writes to a file). */ + stdout?: Buffer; + /** Exit code emitted on close (default 0). */ + exitCode?: number; + /** Simulate "tool not on PATH" by emitting an ENOENT error. */ + enoent?: boolean; + /** When set, after the spawn closes successfully, the recorder + * installs this body for `fs.readFile` to return on the *next* + * read. Use the path captured from the last spawn's args. */ + fileBody?: Buffer; +} + +interface RecordedSpawn { + cmd: string; + args: string[]; +} + +const recordedSpawns: RecordedSpawn[] = []; + +/** Wire `child_process.spawn` to a programmable fake; the + * `fs.promises.readFile` / `unlink` stubs are installed + * module-level via `jest.mock('node:fs', ...)` above. Returns the + * spawn spy for tests that want to assert call counts. + * + * `fileBody`, when set on a spec, stages bytes for the next + * fs.readFile call — paired with the temp path the spawned tool + * saw in its argv. */ +function mockClipboard(specs: FakeSpawnSpec[]): { spawnSpy: jest.SpyInstance } { + recordedSpawns.length = 0; + stagedFiles.clear(); + const queue = [...specs]; + + const cp = jest.requireActual('node:child_process') as typeof import('node:child_process'); + const spawnSpy = jest.spyOn(cp, 'spawn').mockImplementation((cmd: unknown, args: unknown = []) => { + const cmdStr = String(cmd); + const argsArr = Array.isArray(args) ? args.map(String) : []; + recordedSpawns.push({ cmd: cmdStr, args: argsArr }); + + const spec = queue.shift() ?? { exitCode: 0 }; + + // If the spec provides a fileBody, find the temp path in argv + // (or in shell-string args for Linux) and stage it. + if (spec.fileBody !== undefined) { + const allArgs = `${cmdStr} ${argsArr.join(' ')}`; + const match = allArgs.match(/bgagent-tui-clipboard-[a-f0-9]+\.png/); + if (match) { + // Reconstruct the absolute path: tmpdir + filename. + const fname = match[0]; + // Look for the full path containing this filename — it + // appears either as a quoted string ("/tmp/.../foo.png") + // or as an unquoted argument. + const pathMatch = allArgs.match(new RegExp(`["']?(/[^"'\\s]*${fname.replace('.', '\\.')})["']?`)); + if (pathMatch) { + stagedFiles.set(pathMatch[1], spec.fileBody); + } else { + // Windows path matching, or when path appears as a bare + // arg. Fall back to scanning argv individually for any + // string ending in our filename. + const arg = argsArr.find(a => a.includes(fname)); + if (arg) { + const cleaned = arg.replace(/['"]/g, '').match(new RegExp(`[^"' ]*${fname.replace('.', '\\.')}`)); + if (cleaned) stagedFiles.set(cleaned[0], spec.fileBody); + } + } + } + } + + const stdout = new EventEmitter() as EventEmitter & { on: EventEmitter['on'] }; + const stderr = new EventEmitter(); + const stdin = { end: jest.fn() }; + const child = new EventEmitter() as EventEmitter & { + stdout: typeof stdout; + stderr: typeof stderr; + stdin: typeof stdin; + kill: jest.Mock; + }; + child.stdout = stdout; + child.stderr = stderr; + child.stdin = stdin; + child.kill = jest.fn(); + process.nextTick(() => { + if (spec.enoent) { + const err = new Error('spawn ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + child.emit('error', err); + return; + } + if (spec.stdout && spec.stdout.length > 0) { + stdout.emit('data', spec.stdout); + } + child.emit('close', spec.exitCode ?? 0); + }); + return child as unknown as ReturnType; + }); + + return { spawnSpy }; +} + +describe('clipboard.readClipboardImage — magic-byte sniff', () => { + let originalPlatform: PropertyDescriptor | undefined; + beforeEach(() => { + _resetHintCacheForTests(); + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + afterEach(() => { + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); + jest.restoreAllMocks(); + }); + + it('detects PNG magic bytes', async () => { + mockClipboard([{ exitCode: 0, fileBody: PNG_HEADER }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.image.mediaType).toBe('image/png'); + expect(r.image.sizeBytes).toBe(PNG_HEADER.length); + expect(r.image.base64).toBe(PNG_HEADER.toString('base64')); + } + }); + + it('detects JPEG magic bytes', async () => { + mockClipboard([{ exitCode: 0, fileBody: JPEG_HEADER }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.image.mediaType).toBe('image/jpeg'); + }); + + it('detects GIF magic bytes', async () => { + mockClipboard([{ exitCode: 0, fileBody: GIF_HEADER }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.image.mediaType).toBe('image/gif'); + }); + + it('rejects non-image bytes with not_image', async () => { + mockClipboard([{ exitCode: 0, fileBody: TEXT_BYTES }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.failure.kind).toBe('not_image'); + }); + + it('returns empty when temp file is empty', async () => { + mockClipboard([{ exitCode: 0, fileBody: Buffer.alloc(0) }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.failure.kind).toBe('empty'); + }); +}); + +describe('clipboard.readClipboardImage — macOS (osascript)', () => { + let originalPlatform: PropertyDescriptor | undefined; + beforeEach(() => { + _resetHintCacheForTests(); + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + afterEach(() => { + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); + jest.restoreAllMocks(); + }); + + it('invokes osascript and returns PNG when the clipboard has an image', async () => { + mockClipboard([{ exitCode: 0, fileBody: PNG_HEADER }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(true); + expect(recordedSpawns[0].cmd).toBe('osascript'); + // The script should reference the «class PNGf» AppleScript + // coercion + the temp file path. + expect(recordedSpawns[0].args.join(' ')).toMatch(/«class PNGf»/); + expect(recordedSpawns[0].args.join(' ')).toMatch(/bgagent-tui-clipboard-/); + }); + + it('returns empty when osascript exits non-zero (no image on clipboard)', async () => { + mockClipboard([{ exitCode: 1 }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.failure.kind).toBe('empty'); + }); + + it('returns tool_missing only when osascript itself is missing (very unusual)', async () => { + mockClipboard([{ enoent: true }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(false); + if (!r.ok && r.failure.kind === 'tool_missing') { + expect(r.failure.platform).toBe('darwin'); + expect(r.failure.hint).toMatch(/osascript/i); + } else { + throw new Error(`expected tool_missing; got ${JSON.stringify(r)}`); + } + }); +}); + +describe('clipboard.readClipboardImage — linux', () => { + let originalPlatform: PropertyDescriptor | undefined; + let originalDisplay: string | undefined; + let originalWayland: string | undefined; + beforeEach(() => { + _resetHintCacheForTests(); + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + originalDisplay = process.env.DISPLAY; + originalWayland = process.env.WAYLAND_DISPLAY; + Object.defineProperty(process, 'platform', { value: 'linux' }); + }); + afterEach(() => { + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); + if (originalDisplay === undefined) delete process.env.DISPLAY; + else process.env.DISPLAY = originalDisplay; + if (originalWayland === undefined) delete process.env.WAYLAND_DISPLAY; + else process.env.WAYLAND_DISPLAY = originalWayland; + jest.restoreAllMocks(); + }); + + it('uses xclip and TARGETS-query when DISPLAY is set', async () => { + delete process.env.WAYLAND_DISPLAY; + process.env.DISPLAY = ':0'; + // First spawn = xclip TARGETS query (returns image MIME types). + // Second spawn = shell-redirected save (writes to temp file). + mockClipboard([ + { exitCode: 0, stdout: Buffer.from('TIMESTAMP\nimage/png\nimage/bmp\n') }, + { exitCode: 0, fileBody: PNG_HEADER }, + ]); + const r = await readClipboardImage(); + expect(r.ok).toBe(true); + expect(recordedSpawns[0].cmd).toBe('xclip'); + expect(recordedSpawns[0].args).toContain('TARGETS'); + }); + + it('uses wl-paste when WAYLAND_DISPLAY is set', async () => { + delete process.env.DISPLAY; + process.env.WAYLAND_DISPLAY = 'wayland-0'; + mockClipboard([ + { exitCode: 0, stdout: Buffer.from('image/png\ntext/plain\n') }, + { exitCode: 0, fileBody: PNG_HEADER }, + ]); + const r = await readClipboardImage(); + expect(r.ok).toBe(true); + expect(recordedSpawns[0].cmd).toBe('wl-paste'); + }); + + it('returns empty when TARGETS query reports no image MIME', async () => { + delete process.env.WAYLAND_DISPLAY; + process.env.DISPLAY = ':0'; + mockClipboard([{ exitCode: 0, stdout: Buffer.from('TIMESTAMP\ntext/plain\nUTF8_STRING\n') }]); + const r = await readClipboardImage(); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.failure.kind).toBe('empty'); + }); + + it('returns tool_missing with hint when no display server is detected', async () => { + delete process.env.DISPLAY; + delete process.env.WAYLAND_DISPLAY; + const r = await readClipboardImage(); + expect(r.ok).toBe(false); + if (!r.ok && r.failure.kind === 'tool_missing') { + expect(r.failure.hint).toMatch(/X11 or Wayland/i); + } else { + throw new Error(`expected tool_missing; got ${JSON.stringify(r)}`); + } + }); +}); + +describe('clipboard.shouldShowHintOnce', () => { + beforeEach(() => _resetHintCacheForTests()); + + it('returns true on first call, false on subsequent calls for the same key', () => { + expect(shouldShowHintOnce('foo')).toBe(true); + expect(shouldShowHintOnce('foo')).toBe(false); + expect(shouldShowHintOnce('foo')).toBe(false); + }); + + it('keeps hint cache per-key (different tools fire independently)', () => { + expect(shouldShowHintOnce('mac')).toBe(true); + expect(shouldShowHintOnce('linux')).toBe(true); + expect(shouldShowHintOnce('mac')).toBe(false); + }); + + it('reset clears the cache (test helper)', () => { + shouldShowHintOnce('foo'); + _resetHintCacheForTests(); + expect(shouldShowHintOnce('foo')).toBe(true); + }); +}); + +describe('clipboard.readClipboardImage — unsupported platform', () => { + let originalPlatform: PropertyDescriptor | undefined; + beforeEach(() => { + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + }); + afterEach(() => { + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); + }); + + it('returns unsupported_platform on aix / freebsd / etc.', async () => { + Object.defineProperty(process, 'platform', { value: 'aix' }); + const r = await readClipboardImage(); + expect(r.ok).toBe(false); + if (!r.ok && r.failure.kind === 'unsupported_platform') { + expect(r.failure.platform).toBe('aix'); + } else { + throw new Error(`expected unsupported_platform; got ${JSON.stringify(r)}`); + } + }); +}); diff --git a/cli/test/tui/pending-cadence.test.ts b/cli/test/tui/pending-cadence.test.ts new file mode 100644 index 00000000..8c4fab17 --- /dev/null +++ b/cli/test/tui/pending-cadence.test.ts @@ -0,0 +1,127 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Adaptive `/v1/pending` cadence machine. Phase A live drive surfaced + * the case where the TUI's fixed 2 s pending poll hit the server's + * RATE_LIMIT_EXCEEDED in production; this state machine moves the + * cadence between 3-30 s based on activity and 429 responses. + */ + +import { + INITIAL_PENDING_CADENCE, + PENDING_BACKOFF_INTERVALS_MS, + PENDING_FAST_INTERVAL_MS, + PENDING_RATE_LIMITED_INTERVAL_MS, + isRateLimitError, + nextPendingCadence, +} from '../../src/tui/utils/pending-cadence'; + +describe('nextPendingCadence', () => { + it('starts at the fast interval', () => { + expect(INITIAL_PENDING_CADENCE.intervalMs).toBe(PENDING_FAST_INTERVAL_MS); + expect(INITIAL_PENDING_CADENCE.consecutiveEmptyPolls).toBe(0); + expect(INITIAL_PENDING_CADENCE.rateLimited).toBe(false); + }); + + it('walks the ladder on consecutive empty polls', () => { + let s = INITIAL_PENDING_CADENCE; + for (let i = 0; i < PENDING_BACKOFF_INTERVALS_MS.length; i += 1) { + s = nextPendingCadence(s, { sawPending: false, rateLimited: false }); + expect(s.intervalMs).toBe(PENDING_BACKOFF_INTERVALS_MS[i]); + expect(s.consecutiveEmptyPolls).toBe(i + 1); + expect(s.rateLimited).toBe(false); + } + }); + + it('pins at the slowest slot once the ladder is exhausted', () => { + let s = INITIAL_PENDING_CADENCE; + for (let i = 0; i < 10; i += 1) { + s = nextPendingCadence(s, { sawPending: false, rateLimited: false }); + } + expect(s.intervalMs).toBe( + PENDING_BACKOFF_INTERVALS_MS[PENDING_BACKOFF_INTERVALS_MS.length - 1], + ); + }); + + it('resets to the fast interval when a poll returns at least one row', () => { + let s = INITIAL_PENDING_CADENCE; + s = nextPendingCadence(s, { sawPending: false, rateLimited: false }); + s = nextPendingCadence(s, { sawPending: false, rateLimited: false }); + expect(s.intervalMs).toBeGreaterThan(PENDING_FAST_INTERVAL_MS); + s = nextPendingCadence(s, { sawPending: true, rateLimited: false }); + expect(s.intervalMs).toBe(PENDING_FAST_INTERVAL_MS); + expect(s.consecutiveEmptyPolls).toBe(0); + }); + + it('jumps to the rate-limit interval on 429', () => { + const s = nextPendingCadence(INITIAL_PENDING_CADENCE, { + sawPending: false, + rateLimited: true, + }); + expect(s.intervalMs).toBe(PENDING_RATE_LIMITED_INTERVAL_MS); + expect(s.rateLimited).toBe(true); + }); + + it('clears rate-limited flag on next successful non-429 poll', () => { + const limited = nextPendingCadence(INITIAL_PENDING_CADENCE, { + sawPending: false, + rateLimited: true, + }); + const recovered = nextPendingCadence(limited, { + sawPending: false, + rateLimited: false, + }); + expect(recovered.rateLimited).toBe(false); + }); + + it('rate-limited overrides sawPending when both are true', () => { + // Defensive: in practice the server doesn't return rows with 429, + // but the state machine treats 429 as authoritative. + const s = nextPendingCadence(INITIAL_PENDING_CADENCE, { + sawPending: true, + rateLimited: true, + }); + expect(s.intervalMs).toBe(PENDING_RATE_LIMITED_INTERVAL_MS); + expect(s.rateLimited).toBe(true); + }); +}); + +describe('isRateLimitError', () => { + it('detects an ApiError-shaped object with statusCode 429', () => { + expect(isRateLimitError({ statusCode: 429 })).toBe(true); + }); + + it('rejects other status codes', () => { + expect(isRateLimitError({ statusCode: 500 })).toBe(false); + expect(isRateLimitError({ statusCode: 401 })).toBe(false); + }); + + it('rejects objects without a statusCode field', () => { + expect(isRateLimitError({})).toBe(false); + expect(isRateLimitError(new Error('boom'))).toBe(false); + }); + + it('rejects primitives', () => { + expect(isRateLimitError(null)).toBe(false); + expect(isRateLimitError(undefined)).toBe(false); + expect(isRateLimitError('429')).toBe(false); + expect(isRateLimitError(429)).toBe(false); + }); +}); diff --git a/cli/test/tui/polling.test.ts b/cli/test/tui/polling.test.ts new file mode 100644 index 00000000..0b76cf4a --- /dev/null +++ b/cli/test/tui/polling.test.ts @@ -0,0 +1,60 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + BACKOFF_INTERVALS_MS, + INITIAL_POLL_CADENCE, + POLL_FAST_INTERVAL_MS, + nextCadence, +} from '../../src/tui/utils/polling'; + +describe('nextCadence', () => { + it('stays at fast cadence when events arrive', () => { + const next = nextCadence(INITIAL_POLL_CADENCE, true); + expect(next.intervalMs).toBe(POLL_FAST_INTERVAL_MS); + expect(next.consecutiveEmptyPolls).toBe(0); + }); + + it('walks the backoff ladder on consecutive empty polls', () => { + let state = INITIAL_POLL_CADENCE; + const observed: number[] = []; + for (let i = 0; i < BACKOFF_INTERVALS_MS.length + 2; i += 1) { + state = nextCadence(state, false); + observed.push(state.intervalMs); + } + // After ladder exhaustion, pins at the cap. + expect(observed.slice(0, BACKOFF_INTERVALS_MS.length)).toEqual([...BACKOFF_INTERVALS_MS]); + expect(observed[observed.length - 1]).toBe(BACKOFF_INTERVALS_MS[BACKOFF_INTERVALS_MS.length - 1]); + }); + + it('resets to fast on a single non-empty poll mid-backoff', () => { + let state = nextCadence(INITIAL_POLL_CADENCE, false); // 1s + state = nextCadence(state, false); // 2s + state = nextCadence(state, true); // back to fast + expect(state).toEqual(INITIAL_POLL_CADENCE); + }); + + it('counter increments monotonically during empty streak', () => { + let state = INITIAL_POLL_CADENCE; + for (let i = 1; i <= 5; i += 1) { + state = nextCadence(state, false); + expect(state.consecutiveEmptyPolls).toBe(i); + } + }); +}); diff --git a/cli/test/tui/source-mock.test.ts b/cli/test/tui/source-mock.test.ts new file mode 100644 index 00000000..b2d850a3 --- /dev/null +++ b/cli/test/tui/source-mock.test.ts @@ -0,0 +1,94 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { MockDataSource } from '../../src/tui/api/source-mock'; + +describe('MockDataSource', () => { + const src = new MockDataSource(); + + it('reports mock label', () => { + expect(src.label).toBe('mock'); + }); + + it('lists tasks with normalized view fields', async () => { + const tasks = await src.listTasks(); + expect(tasks.length).toBeGreaterThan(0); + // `turn` is derived: prefer turns_completed, fallback to + // turns_attempted, null on both missing. + for (const t of tasks) { + if (t.turns_completed != null) { + expect(t.turn).toBe(t.turns_completed); + } + } + }); + + it('exposes approval_gate counters on task rows', async () => { + const tasks = await src.listTasks(); + const withGates = tasks.filter( + (t) => t.approval_gate_count != null && t.approval_gate_cap != null, + ); + expect(withGates.length).toBeGreaterThan(0); + }); + + it('lists pending approvals with UPPERCASE severity', async () => { + const pending = await src.listPending(); + expect(pending.length).toBeGreaterThan(0); + for (const a of pending) { + expect(['HIGH', 'MEDIUM', 'LOW']).toContain(a.severity); + } + }); + + it('returns hard + soft policies bucketed', async () => { + const p = await src.listPolicies('aws-samples/my-project'); + expect(p.hard.length).toBeGreaterThan(0); + expect(p.soft.length).toBeGreaterThan(0); + for (const r of p.hard) expect(r.tier).toBe('hard'); + for (const r of p.soft) expect(r.tier).toBe('soft'); + }); + + it('lists registered repos (active only)', async () => { + const repos = await src.listRegisteredRepos(); + expect(repos.length).toBeGreaterThan(0); + for (const r of repos) { + expect(r.repo).toMatch(/^[^/]+\/[^/]+$/); + expect(typeof r.default_branch).toBe('string'); + } + }); + + it('submitTask seeds a new task with sensible defaults', async () => { + const before = (await src.listTasks()).length; + const row = await src.submitTask({ + repo: 'aws-samples/new-repo', + task_description: 'test submission', + approval_timeout_s: 300, + initial_approvals: ['tool_type:Bash'], + }); + expect(row.status).toBe('SUBMITTED'); + expect(row.repo).toBe('aws-samples/new-repo'); + expect(row.approval_gate_count).toBe(0); + expect(row.approval_gate_cap).toBe(50); + const after = (await src.listTasks()).length; + expect(after).toBe(before + 1); + }); + + it('approve/deny are no-ops that satisfy the interface', async () => { + await expect(src.approve('t', 'r', 'this_call')).resolves.toBeUndefined(); + await expect(src.deny('t', 'r', 'nope')).resolves.toBeUndefined(); + }); +}); diff --git a/cli/test/tui/source-real.test.ts b/cli/test/tui/source-real.test.ts new file mode 100644 index 00000000..0db3a477 --- /dev/null +++ b/cli/test/tui/source-real.test.ts @@ -0,0 +1,307 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { ApiClient } from '../../src/api-client'; +import { RealDataSource } from '../../src/tui/api/source-real'; +import type { + GetPendingResponse, + GetPoliciesResponse, + PaginatedResponse, + TaskDetail, + TaskEvent, + TaskSummary, +} from '../../src/types'; + +function taskDetail(overrides: Partial = {}): TaskDetail { + return { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + status: 'RUNNING', + repo: 'aws-samples/foo', + issue_number: null, + task_type: 'new_task', + pr_number: null, + task_description: 'do a thing', + branch_name: 'agent/thing', + session_id: null, + pr_url: null, + error_message: null, + error_classification: null, + prompt_version: null, + channel_source: 'api', + created_at: '2026-05-12T00:00:00Z', + updated_at: '2026-05-12T00:00:00Z', + started_at: null, + completed_at: null, + duration_s: null, + cost_usd: 0.1, + build_passed: null, + max_turns: 8, + max_budget_usd: null, + turns_attempted: 3, + turns_completed: 3, + trace: false, + trace_s3_uri: null, + approval_gate_count: 1, + approval_gate_cap: 50, + awaiting_approval_request_id: null, + ...overrides, + }; +} + +function taskSummary(overrides: Partial = {}): TaskSummary { + return { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + status: 'RUNNING', + repo: 'aws-samples/foo', + issue_number: null, + task_type: 'new_task', + pr_number: null, + task_description: 'do a thing', + branch_name: 'agent/thing', + pr_url: null, + created_at: '2026-05-12T00:00:00Z', + updated_at: '2026-05-12T00:00:00Z', + ...overrides, + }; +} + +function fakeClient(overrides: Partial> = {}): ApiClient { + const client: Partial> = { + listTasks: jest.fn(async (): Promise> => ({ + data: [taskSummary()], + pagination: { next_token: null, has_more: false }, + })), + getTask: jest.fn(async () => taskDetail()), + getTaskEvents: jest.fn(async () => ({ + data: [], + pagination: { next_token: null, has_more: false }, + })), + listPending: jest.fn(async (): Promise => ({ pending: [] })), + listPolicies: jest.fn(async (): Promise => ({ + repo_id: 'aws-samples/foo', + policies: { hard: [], soft: [] }, + })), + createTask: jest.fn(async () => taskDetail()), + approveTask: jest.fn(async () => ({ + task_id: 't', + request_id: 'r', + status: 'APPROVED' as const, + scope: 'this_call' as const, + decided_at: '2026-05-12T00:00:00Z', + })), + denyTask: jest.fn(async () => ({ + task_id: 't', + request_id: 'r', + status: 'DENIED' as const, + decided_at: '2026-05-12T00:00:00Z', + })), + ...overrides, + }; + return client as unknown as ApiClient; +} + +describe('RealDataSource', () => { + it('reports live label', () => { + const src = new RealDataSource(fakeClient()); + expect(src.label).toBe('live'); + }); + + it('listTasks hydrates each summary via getTask', async () => { + const getTask = jest.fn(async () => taskDetail()); + const client = fakeClient({ getTask: getTask as unknown as jest.Mock }); + const src = new RealDataSource(client); + const rows = await src.listTasks(); + expect(rows.length).toBe(1); + expect(rows[0].cost_usd).toBe(0.1); + expect(rows[0].approval_gate_count).toBe(1); + expect(getTask).toHaveBeenCalledTimes(1); + }); + + it('listTasks falls back to summary when getTask fails', async () => { + const getTask = jest.fn(async () => { throw new Error('boom'); }); + const client = fakeClient({ getTask: getTask as unknown as jest.Mock }); + const src = new RealDataSource(client); + const rows = await src.listTasks(); + expect(rows.length).toBe(1); + expect(rows[0].cost_usd).toBeNull(); + expect(rows[0].approval_gate_count).toBeNull(); + expect(rows[0].task_id).toBe('01JBX7QNMR5PG4HW3FS8AY2K9'); + }); + + it('listPending joins repo + description from cached task list', async () => { + const listPending = jest.fn(async (): Promise => ({ + pending: [{ + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + request_id: 'r1', + tool_name: 'Bash', + tool_input_preview: 'npm install', + severity: 'high', + reason: 'bash exec requires approval', + created_at: '2026-05-12T00:00:00Z', + timeout_s: 600, + expires_at: '2026-05-12T00:10:00Z', + matching_rule_ids: ['bash_exec_gate'], + }], + })); + const client = fakeClient({ listPending: listPending as unknown as jest.Mock }); + const src = new RealDataSource(client); + await src.listTasks(); + const pending = await src.listPending(); + expect(pending.length).toBe(1); + expect(pending[0].repo).toBe('aws-samples/foo'); + expect(pending[0].task_description).toBe('do a thing'); + expect(pending[0].severity).toBe('HIGH'); + }); + + it('listPolicies routes to /repos/{id}/policies', async () => { + const listPolicies = jest.fn(async (): Promise => ({ + repo_id: 'aws-samples/foo', + policies: { + hard: [{ rule_id: 'rm_slash', summary: 'blocks rm -rf /', severity: 'high' }], + soft: [{ rule_id: 'bash_exec_gate', summary: 'bash requires approval' }], + }, + })); + const client = fakeClient({ listPolicies: listPolicies as unknown as jest.Mock }); + const src = new RealDataSource(client); + const p = await src.listPolicies('aws-samples/foo'); + expect(listPolicies).toHaveBeenCalledWith('aws-samples/foo'); + expect(p.hard[0].tier).toBe('hard'); + expect(p.soft[0].tier).toBe('soft'); + }); + + it('listPolicies short-circuits on empty repoId', async () => { + const listPolicies = jest.fn(async () => ({ repo_id: '', policies: { hard: [], soft: [] } })); + const client = fakeClient({ listPolicies: listPolicies as unknown as jest.Mock }); + const src = new RealDataSource(client); + const p = await src.listPolicies(''); + expect(p.hard).toEqual([]); + expect(p.soft).toEqual([]); + expect(listPolicies).not.toHaveBeenCalled(); + }); + + it('listRegisteredRepos derives from cached task list (deduped)', async () => { + const listTasks = jest.fn(async (): Promise> => ({ + data: [ + taskSummary({ task_id: 'a', repo: 'aws-samples/foo' }), + taskSummary({ task_id: 'b', repo: 'aws-samples/foo' }), + taskSummary({ task_id: 'c', repo: 'acme/bar' }), + ], + pagination: { next_token: null, has_more: false }, + })); + // `RealDataSource.listTasks` hydrates each summary via `getTask` + // to get the gate counters. Stub it to echo the summary's repo + // so we can observe the dedup behaviour. + const getTask = jest.fn(async (id: string) => { + const repo = id === 'c' ? 'acme/bar' : 'aws-samples/foo'; + return taskDetail({ task_id: id, repo }); + }); + const client = fakeClient({ + listTasks: listTasks as unknown as jest.Mock, + getTask: getTask as unknown as jest.Mock, + }); + const src = new RealDataSource(client); + await src.listTasks(); + const repos = await src.listRegisteredRepos(); + expect(repos.map(r => r.repo).sort()).toEqual(['acme/bar', 'aws-samples/foo']); + }); + + it('submitTask passes approval_timeout_s and initial_approvals through', async () => { + const createTask = jest.fn(async () => taskDetail()); + const client = fakeClient({ createTask: createTask as unknown as jest.Mock }); + const src = new RealDataSource(client); + await src.submitTask({ + repo: 'aws-samples/foo', + task_description: 'do', + approval_timeout_s: 300, + initial_approvals: ['tool_type:Bash', 'rule:bash_exec_gate'], + }); + expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ + repo: 'aws-samples/foo', + task_description: 'do', + approval_timeout_s: 300, + initial_approvals: ['tool_type:Bash', 'rule:bash_exec_gate'], + })); + }); + + it('approve forwards scope to the API client', async () => { + const approveTask = jest.fn(async () => ({ + task_id: 't', + request_id: 'r', + status: 'APPROVED' as const, + scope: 'tool_type:Bash' as const, + decided_at: 'x', + })); + const client = fakeClient({ approveTask: approveTask as unknown as jest.Mock }); + const src = new RealDataSource(client); + await src.approve('t', 'r', 'tool_type:Bash'); + expect(approveTask).toHaveBeenCalledWith('t', 'r', 'tool_type:Bash'); + }); + + it('deny forwards reason to the API client', async () => { + const denyTask = jest.fn(async () => ({ + task_id: 't', + request_id: 'r', + status: 'DENIED' as const, + decided_at: 'x', + })); + const client = fakeClient({ denyTask: denyTask as unknown as jest.Mock }); + const src = new RealDataSource(client); + await src.deny('t', 'r', 'please reconsider'); + expect(denyTask).toHaveBeenCalledWith('t', 'r', 'please reconsider'); + }); + + describe('getTaskEvents pagination', () => { + function ev(id: string): TaskEvent { + return { + event_id: id, + event_type: 'agent_turn', + timestamp: '2026-05-12T00:00:00Z', + metadata: {}, + }; + } + + it('drains all pages on initial load (no cursor)', async () => { + // 3-page response: page1 (2 events), page2 (2 events), page3 (1 event) + const pages: PaginatedResponse[] = [ + { data: [ev('01'), ev('02')], pagination: { next_token: 'tok1', has_more: true } }, + { data: [ev('03'), ev('04')], pagination: { next_token: 'tok2', has_more: true } }, + { data: [ev('05')], pagination: { next_token: null, has_more: false } }, + ]; + const getTaskEvents = jest.fn(async () => pages.shift()!); + const client = fakeClient({ getTaskEvents: getTaskEvents as unknown as jest.Mock }); + const src = new RealDataSource(client); + const events = await src.getTaskEvents('t'); + expect(events.map(e => e.event_id)).toEqual(['01', '02', '03', '04', '05']); + // First call: no cursor. Second/third: next_token. + expect(getTaskEvents).toHaveBeenCalledTimes(3); + const calls = getTaskEvents.mock.calls as unknown as Array<[string, Record]>; + expect(calls[0][1]).toEqual({ limit: 100 }); + expect(calls[1][1]).toEqual({ limit: 100, nextToken: 'tok1' }); + }); + + it('uses catchUpEvents with the cursor on incremental polls', async () => { + const catchUpEvents = jest.fn(async () => [ev('10'), ev('11')]); + const client = fakeClient({ catchUpEvents: catchUpEvents as unknown as jest.Mock }); + const src = new RealDataSource(client); + const events = await src.getTaskEvents('t', { after: '09' }); + expect(events.map(e => e.event_id)).toEqual(['10', '11']); + expect(catchUpEvents).toHaveBeenCalledWith('t', '09', 100); + }); + }); +}); diff --git a/cli/test/tui/source.test.ts b/cli/test/tui/source.test.ts new file mode 100644 index 00000000..9a4ebf5e --- /dev/null +++ b/cli/test/tui/source.test.ts @@ -0,0 +1,67 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { enrichPendingApproval } from '../../src/tui/api/source'; +import type { PendingApprovalSummary } from '../../src/types'; + +describe('enrichPendingApproval', () => { + const base: PendingApprovalSummary = { + task_id: '01JBX7QNMR5PG4HW3FS8AY2K9', + request_id: '01JBX7SSPK4RW0GM3KE7OY9C2U', + tool_name: 'EditFile', + tool_input_preview: 'src/api/users.ts', + severity: 'high', + reason: 'File write requires approval', + created_at: '2026-05-12T00:00:00.000Z', + timeout_s: 600, + expires_at: '2026-05-12T00:10:00.000Z', + matching_rule_ids: ['file_edit_gate'], + }; + + it('normalizes lowercase severity to UPPERCASE for display', () => { + const repoMap = new Map([[base.task_id, 'aws-samples/foo']]); + const descMap = new Map([[base.task_id, 'do a thing']]); + const enriched = enrichPendingApproval(base, repoMap, descMap); + expect(enriched.severity).toBe('HIGH'); + expect(enriched.repo).toBe('aws-samples/foo'); + expect(enriched.task_description).toBe('do a thing'); + }); + + it('defaults to MEDIUM on unknown severity', () => { + const enriched = enrichPendingApproval( + { ...base, severity: 'extreme' as unknown as PendingApprovalSummary['severity'] }, + new Map(), + new Map(), + ); + expect(enriched.severity).toBe('MEDIUM'); + }); + + it('falls back to "(unknown)" repo and empty description when not indexed', () => { + const enriched = enrichPendingApproval(base, new Map(), new Map()); + expect(enriched.repo).toBe('(unknown)'); + expect(enriched.task_description).toBe(''); + }); + + it('preserves matching_rule_ids and expires_at unchanged', () => { + const enriched = enrichPendingApproval(base, new Map(), new Map()); + expect(enriched.matching_rule_ids).toEqual(['file_edit_gate']); + expect(enriched.expires_at).toBe('2026-05-12T00:10:00.000Z'); + expect(enriched.timeout_s).toBe(600); + }); +}); diff --git a/cli/tsconfig.dev.json b/cli/tsconfig.dev.json index da5f7483..80face61 100644 --- a/cli/tsconfig.dev.json +++ b/cli/tsconfig.dev.json @@ -9,7 +9,7 @@ "lib": [ "es2020" ], - "module": "CommonJS", + "module": "ESNext", "noEmitOnError": false, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, @@ -23,6 +23,9 @@ "strictPropertyInitialization": true, "stripInternal": true, "target": "ES2020", + "jsx": "react-jsx", + "moduleResolution": "bundler", + "skipLibCheck": true, "types": [ "node", "jest" @@ -30,7 +33,9 @@ }, "include": [ "src/**/*.ts", - "test/**/*.ts" + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx" ], "exclude": [ "node_modules" diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 28bd0750..2c09b1bc 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -32,5 +32,8 @@ "include": [ "src/**/*.ts" ], - "exclude": [] + "exclude": [ + "src/tui/**/*", + "src/mock/**/*" + ] } diff --git a/yarn.lock b/yarn.lock index 814102fe..4087a727 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@alcalzone/ansi-tokenize@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz#8216cc5f2f34ef4a8266d2407e7e3b70ba20e0f6" + integrity sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^5.0.0" + "@astrojs/check@^0.9.8": version "0.9.8" resolved "https://registry.yarnpkg.com/@astrojs/check/-/check-0.9.8.tgz#d70dcab429a91cc830ded9bfadc716ec0ebd6983" @@ -5115,6 +5123,13 @@ dependencies: undici-types "~7.18.0" +"@types/react@^19.2.14": + version "19.2.14" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" + integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== + dependencies: + csstype "^3.2.2" + "@types/sax@^1.2.1": version "1.2.7" resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.7.tgz#ba5fe7df9aa9c89b6dff7688a19023dd2963091d" @@ -5479,6 +5494,13 @@ ansi-escapes@^4.3.2: dependencies: type-fest "^0.21.3" +ansi-escapes@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz#5395bb74b2150a4a1d6e3c2565f4aeca78d28627" + integrity sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== + dependencies: + environment "^1.0.0" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -5501,7 +5523,7 @@ ansi-styles@^5.2.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -ansi-styles@^6.1.0: +ansi-styles@^6.1.0, ansi-styles@^6.2.1, ansi-styles@^6.2.3: version "6.2.3" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== @@ -5717,6 +5739,11 @@ async-function@^1.0.0: resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== +auto-bind@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-5.0.1.tgz#50d8e63ea5a1dddcb5e5e36451c1a8266ffbb2ae" + integrity sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -5835,7 +5862,7 @@ baseline-browser-mapping@^2.10.12: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz#5a154cc4589193015a274e3d18319b0d76b9224e" integrity sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw== -basic-ftp@^5.0.2, basic-ftp@^5.2.2: +basic-ftp@^5.0.2: version "5.3.1" resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.3.1.tgz#3148ee9af43c0522514a4f973fecb1d3cbb6d71e" integrity sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw== @@ -5985,6 +6012,11 @@ chalk@^4.0.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -6034,6 +6066,31 @@ cjs-module-lexer@^2.1.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca" integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ== +cli-boxes@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-4.0.1.tgz#7267f4ae32ecbb52b5af77fef48be219f666c57c" + integrity sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw== + +cli-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" + integrity sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== + dependencies: + restore-cursor "^4.0.0" + +cli-spinners@^2.7.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== + +cli-truncate@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-6.0.0.tgz#9e7c9b64649e4bd35e6a77919797c30ba7dc0095" + integrity sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA== + dependencies: + slice-ansi "^9.0.0" + string-width "^8.2.0" + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -6053,6 +6110,13 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== +code-excerpt@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/code-excerpt/-/code-excerpt-4.0.0.tgz#2de7d46e98514385cb01f7b3b741320115f4c95e" + integrity sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== + dependencies: + convert-to-spaces "^2.0.1" + collapse-white-space@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz#640257174f9f42c740b40f3b55ee752924feefca" @@ -6120,6 +6184,11 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +convert-to-spaces@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz#61a6c98f8aa626c16b296b862a91412a33bceb6b" + integrity sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== + cookie-es@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/cookie-es/-/cookie-es-1.2.3.tgz#06ca3c5f5f3531684a2059666a361173f74a89c8" @@ -6200,6 +6269,11 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + data-uri-to-buffer@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b" @@ -6446,6 +6520,11 @@ entities@^6.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== +environment@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" + integrity sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + error-ex@^1.3.1: version "1.3.4" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" @@ -6561,6 +6640,11 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" +es-toolkit@^1.45.1: + version "1.46.1" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.46.1.tgz#38ca27191a98a867fc544b81cf1477a68947fb06" + integrity sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ== + esast-util-from-estree@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz#8d1cfb51ad534d2f159dc250e604f3478a79f1ad" @@ -7000,7 +7084,7 @@ fast-wrap-ansi@^0.1.3: dependencies: fast-string-width "^1.1.0" -fast-xml-builder@^1.1.5: +fast-xml-builder@^1.1.4, fast-xml-builder@^1.1.5, fast-xml-builder@^1.1.7: version "1.2.0" resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz#abd2363145a7625d9789ad96da375fabe3cff28c" integrity sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q== @@ -7008,7 +7092,16 @@ fast-xml-builder@^1.1.5: path-expression-matcher "^1.5.0" xml-naming "^0.1.0" -fast-xml-parser@5.5.8, fast-xml-parser@5.7.2, fast-xml-parser@5.7.3, fast-xml-parser@^5.7.0: +fast-xml-parser@5.5.8: + version "5.5.8" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz#929571ed8c5eb96e6d9bd572ba14fc4b84875716" + integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== + dependencies: + fast-xml-builder "^1.1.4" + path-expression-matcher "^1.2.0" + strnum "^2.2.0" + +fast-xml-parser@5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz#fecd0b054c6c132fc03dab994a413da781e0eb9f" integrity sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w== @@ -7018,6 +7111,16 @@ fast-xml-parser@5.5.8, fast-xml-parser@5.7.2, fast-xml-parser@5.7.3, fast-xml-pa path-expression-matcher "^1.5.0" strnum "^2.2.3" +fast-xml-parser@5.7.3: + version "5.7.3" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz#309b04b08d835defc62ab657a0bb340c0e0fbe6a" + integrity sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg== + dependencies: + "@nodable/entities" "^2.1.0" + fast-xml-builder "^1.1.7" + path-expression-matcher "^1.5.0" + strnum "^2.2.3" + fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" @@ -7030,6 +7133,13 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== + dependencies: + is-unicode-supported "^2.0.0" + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -7156,6 +7266,11 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-east-asian-width@^1.3.1, get-east-asian-width@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9" + integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== + get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" @@ -7678,6 +7793,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +indent-string@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-5.0.0.tgz#4fd2980fccaf8622d14c64d694f4cf33c81951a5" + integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -7691,6 +7811,49 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +ink-spinner@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ink-spinner/-/ink-spinner-5.0.0.tgz#32ec318ef8ebb0ace8f595451f8e93280623429f" + integrity sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA== + dependencies: + cli-spinners "^2.7.0" + +ink-testing-library@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/ink-testing-library/-/ink-testing-library-4.0.0.tgz#7071dac9a3d783e7bab2ee05fdf1d01a2cd3bb0d" + integrity sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q== + +ink@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ink/-/ink-7.0.3.tgz#61c44dd1438af38ab0c4423c221495f3fdfb76b7" + integrity sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g== + dependencies: + "@alcalzone/ansi-tokenize" "^0.3.0" + ansi-escapes "^7.3.0" + ansi-styles "^6.2.3" + auto-bind "^5.0.1" + chalk "^5.6.2" + cli-boxes "^4.0.1" + cli-cursor "^4.0.0" + cli-truncate "^6.0.0" + code-excerpt "^4.0.0" + es-toolkit "^1.45.1" + indent-string "^5.0.0" + is-in-ci "^2.0.0" + patch-console "^2.0.0" + react-reconciler "^0.33.0" + scheduler "^0.27.0" + signal-exit "^3.0.7" + slice-ansi "^9.0.0" + stack-utils "^2.0.6" + string-width "^8.2.0" + terminal-size "^4.0.1" + type-fest "^5.5.0" + widest-line "^6.0.0" + wrap-ansi "^10.0.0" + ws "^8.20.0" + yoga-layout "~3.2.1" + inline-style-parser@0.2.7: version "0.2.7" resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz#b1fc68bfc0313b8685745e4464e37f9376b9c909" @@ -7836,6 +7999,13 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-fullwidth-code-point@^5.0.0, is-fullwidth-code-point@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz#046b2a6d4f6b156b2233d3207d4b5a9783999b98" + integrity sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== + dependencies: + get-east-asian-width "^1.3.1" + is-generator-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" @@ -7864,6 +8034,11 @@ is-hexadecimal@^2.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027" integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg== +is-in-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-in-ci/-/is-in-ci-2.0.0.tgz#e4b3471c555b47509a8311869c377c234967079f" + integrity sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w== + is-inside-container@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" @@ -7945,6 +8120,11 @@ is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: dependencies: which-typed-array "^1.1.16" +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + is-weakmap@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" @@ -9488,7 +9668,7 @@ once@^1.3.0: dependencies: wrappy "1" -onetime@^5.1.2: +onetime@^5.1.0, onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -9688,6 +9868,11 @@ parse5@^7.0.0: dependencies: entities "^6.0.0" +patch-console@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/patch-console/-/patch-console-2.0.0.tgz#9023f4665840e66f40e9ce774f904a63167433bb" + integrity sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA== + path-browserify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" @@ -9698,7 +9883,7 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-expression-matcher@^1.5.0: +path-expression-matcher@^1.2.0, path-expression-matcher@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a" integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ== @@ -9778,7 +9963,7 @@ postcss-selector-parser@^6.1.1: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss@^8.4.38, postcss@^8.5.10, postcss@^8.5.6: +postcss@^8.4.38, postcss@^8.5.6: version "8.5.12" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== @@ -9855,6 +10040,18 @@ react-is@^18.3.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-reconciler@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.33.0.tgz#9dd20208d45baa5b0b4701781f858236657f15e1" + integrity sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA== + dependencies: + scheduler "^0.27.0" + +react@^19.2.5: + version "19.2.6" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d" + integrity sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q== + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -10142,6 +10339,14 @@ resolve@^1.22.4: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +restore-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-4.0.0.tgz#519560a4318975096def6e609d44100edaa4ccb9" + integrity sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + retext-latin@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/retext-latin/-/retext-latin-4.0.0.tgz#d02498aa1fd39f1bf00e2ff59b1384c05d0c7ce3" @@ -10259,6 +10464,11 @@ sax@^1.4.1, sax@^1.5.0: resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b" integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA== +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== + semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -10300,7 +10510,7 @@ set-proto@^1.0.0: es-errors "^1.3.0" es-object-atoms "^1.0.0" -sharp@^0.34.0: +sharp@^0.34.0, sharp@^0.34.5: version "0.34.5" resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#b6f148e4b8c61f1797bde11a9d1cfebbae2c57b0" integrity sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg== @@ -10414,7 +10624,7 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.3: +signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -10453,6 +10663,14 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +slice-ansi@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-9.0.0.tgz#8ed38fdc31f16b607a56cf86c3160d2684f1b61b" + integrity sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA== + dependencies: + ansi-styles "^6.2.3" + is-fullwidth-code-point "^5.1.0" + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" @@ -10582,6 +10800,14 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" +string-width@^8.1.0, string-width@^8.2.0: + version "8.2.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.2.1.tgz#165089cfa527cc88fbc23dd73313f5e334af1ea1" + integrity sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA== + dependencies: + get-east-asian-width "^1.5.0" + strip-ansi "^7.1.2" + string.prototype.trim@^1.2.10: version "1.2.10" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" @@ -10629,7 +10855,7 @@ stringify-entities@^4.0.0: dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.1: +strip-ansi@^7.0.1, strip-ansi@^7.1.2, strip-ansi@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== @@ -10656,6 +10882,11 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strnum@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.3.0.tgz#81bfbfef53db8c3217ea62a98c026886ec4a2761" + integrity sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q== + strnum@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.3.tgz#0119fce02749a11bb126a4d686ac5dbdf6e57586" @@ -10725,6 +10956,16 @@ table@^6.9.0: string-width "^4.2.3" strip-ansi "^6.0.1" +tagged-tag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" + integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== + +terminal-size@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/terminal-size/-/terminal-size-4.0.1.tgz#4fe8cb7482aae253f042bcd0c67b61f2dde97901" + integrity sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ== + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -10861,6 +11102,13 @@ type-fest@^4.41.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== +type-fest@^5.5.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.6.0.tgz#502f7a003b7309e96a7e17052cc2ab2c7e5c7a31" + integrity sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA== + dependencies: + tagged-tag "^1.0.0" + typed-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" @@ -11142,10 +11390,15 @@ util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -uuid@^14.0.0, uuid@^8.3.2, uuid@^9.0.1: - version "14.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d" - integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +uuid@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== v8-compile-cache-lib@^3.0.1: version "3.0.1" @@ -11419,6 +11672,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +widest-line@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-6.0.0.tgz#6a8638af837059501d61c5d3bdf72cb934f6c792" + integrity sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA== + dependencies: + string-width "^8.1.0" + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" @@ -11438,6 +11698,15 @@ wordwrap@^1.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz#b83ddcc14dbc5596f1b07e153bf6f863c1acbb57" + integrity sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ== + dependencies: + ansi-styles "^6.2.3" + string-width "^8.2.0" + strip-ansi "^7.1.2" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -11460,6 +11729,11 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@^8.20.0: + version "8.20.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" + integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== + xml-naming@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8" @@ -11502,7 +11776,17 @@ yaml-language-server@~1.20.0: vscode-uri "^3.0.2" yaml "2.7.1" -yaml@1.10.3, yaml@2.7.1, yaml@^2.8.2, yaml@^2.8.3: +yaml@1.10.3: + version "1.10.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3" + integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA== + +yaml@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.1.tgz#44a247d1b88523855679ac7fa7cda6ed7e135cf6" + integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ== + +yaml@^2.8.2: version "2.8.3" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== @@ -11545,6 +11829,11 @@ yocto-queue@^1.2.1: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00" integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== +yoga-layout@~3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-3.2.1.tgz#d2d1ba06f0e81c2eb650c3e5ad8b0b4adde1e843" + integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== + zod@^3.22.4: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"