Design
This page shows how TestPion is designed, from the brand and the interface down to the engine. It is the reference for anyone (human or AI) adding a feature: new work should look and behave like what is described here.
Principles
--json, MCP tools, llms.txt) and reviewable by people. AI output is always labelled and shown before it runs.tp.* scripts, the runner and the console work the way Postman users expect, and import and export losslessly.Brand


#1FF0FF → #1E88FF → #8B5CFF → #F04FE0 in the wordmark; the interface uses the calmer #2F7BFF → #8B5CFF for primary buttons and active indicators. - Keep the wordmark on navy (
#0C1440) or a dark surface. On light pages, put it on a navy badge. - Leave clear space of at least the height of the "P" around it.
- Generate every size from the sources with
npm run icons -w @testpion/desktop.
- Recolour "Pion" or the gradient, or place the wordmark on a light background without the badge.
- Stretch it, add effects, or type the name as the logo in another font.
- Use the square app icon where there is room for the wordmark.
Sources: apps/desktop/build/logo/testpion-NxN.png (the square app icon, the TP test-tube mark, drawn at 32–1024 px; the original is testpion-logo-original.png), apps/desktop/build/wordmark.svg (wordmark with tagline) and apps/desktop/build/wordmark-nav.svg (wordmark without tagline).
Colour
The interface uses design tokens (OKLCH, in apps/desktop/src/styles.css), with one set per theme. Components use the tokens, never raw colours, so both themes stay consistent.
The palette comes from the logo: neutrals are tinted with its navy, the accent is its electric blue, primary actions use its blue → violet gradient, and AI features use its magenta. The text cursor uses the logo's blue (light theme) or cyan (dark theme), so it is easy to find in every text box.
#060A1A#090F20#101629#141A2F#1B243E#333C58#EEF2FA#9BA4BE#4697FF#4AD496#FABB41#FA686A#F1F3F9#F7F9FC#FFFFFF#FFFFFF#E5EAF5#CCD1DC#0F142A#5B6378#186DE7#05893E#BB6900#DF202EColour carries meaning. HTTP methods have fixed colours (shown on the dark theme), status badges use ok / warn / bad, and the accent marks what is active or primary.
Layout
The app is built from layered surfaces, so it is clear at a glance where you are: the chrome frames the window, sidebars hold navigation, the work area is brightest, and popovers float above everything.
- Density is design tokens only (
styles.css,:root[data-density='compact']):--spacing(what every h- / w- / p- / gap- utility is made of),--text-xs/--text-sm,--control-h/--control-px/--control-pyfor fields;VirtualListscales its row heights by the same ratio (lib/density.ts). A view never sets its own compact sizes. - The navigation rail switches views. It shows five by default (Home, Collections, Tests, Environments, History) and More… for the rest (Debugger, Monitors, Load, AI Lab, Evaluations, Traces, Git); right-click an item to hide, keep or move it (one model,
lib/rail.ts, for the rail, its tooltips, the shortcuts and the palette; saved per computer). Ctrl/⌘+Alt+1…9 follow the shown items. The request editors (REST, GraphQL, gRPC, WebSocket, MCP) are not on the rail: they open from Collections, whose explorer is their one sidebar. The active view gets an accent tile and a gradient indicator; a count (commits to pull) sits on its icon. - Tabs are one shared strip (
DocTabStrip) in every view that opens items: requests, test files, monitors, load tests, evaluations, AI prompts, environments, Debugger sessions (useLibraryTabs+LibraryTabStripfor item lists; the Debugger's live capture is a fixed tab). Tabs have a fixed width; when there are more than fit, the strip scrolls with ‹ › buttons and the count opens All open tabs. A single click in a list opens a preview tab in italics. The right-click menu is the same everywhere (Rename, Close tab, Close other tabs, Close tabs to the right, Close all tabs, plus what the view adds). - The right edge holds one pinnable panel with Info (the item on screen: its variables and their sources, its file and git state, its runs) and the Assistant; unpinned, it is a tab that slides over the content.
- The bottom panel has Console, Logs, Terminal and Problems. The status bar is the tool hub: the environment (the same picker as the top bar), the Problems count, Git and Debugger state, Tools, Terminal, Console and Logs.
- Production is visible: while an environment marked as production is active, a thin frame in its colour runs around the window, and every run shows its environment as a badge.
- Right-click works everywhere it helps: tabs, workspaces, collection items, History, Traces, the Git view's changes. The same actions are in the ⋯ menu for keyboard and touch users, with one word per action (Open, Open in new tab, Open as request, Save as test…, Delete…) and the groups in one order: create · open · run · test · configure · copy · organize · delete.
Typography
Interface text is Inter; code, URLs, JSON and methods are JetBrains Mono. The small text sizes are slightly larger than Tailwind's defaults, because a dense tool is read for hours.
Components
Shared primitives live in apps/desktop/src/components/ui.tsx (Radix UI + Tailwind CSS, in the shadcn/ui style). New screens are built from them.
| Component | Use it for |
|---|---|
Button (primary, default, soft, ghost, danger) | Primary uses the brand gradient; one per area. Danger is only for destructive confirmations. |
Badge | Status and counts: ok, warn, bad, accent, judge (AI evaluations). |
Tabs | Sections of a view; the active tab has the gradient underline and counts in a pill. |
Menu | Dropdown and right-click menus. Every item has an icon and one word per action from src/lib/vocabulary.ts (Open, Open in new tab, Delete… …); groups follow one order (create · open · run · test · configure · copy · organize · delete), and destructive items are red and last, after a separator. The same menu opens from ⋯ and from a right-click. |
| Application menu | The native File / Edit / View / Window / Help menus and the text-field right-click menu use the same lucide icons, prerendered to PNG (npm run menu-icons -w @testpion/desktop after changing scripts/make-menu-icons.cjs): template images on macOS, a light or dark ink on Windows and Linux that follows the system theme. |
| Command palette | Every command shows its icon; Go to commands use the navigation icons, environments their colour. |
Modal | Forms and custom dialogs. Say what will happen, and use a danger button for irreversible actions. |
confirmAction(), ask(), promptText() | Every confirmation, question and text prompt (never the browser's confirm / prompt). A tone icon (info, question, warning, danger, success), a bold message, a muted detail, and buttons named for the action (Delete collection, not OK). Only for what can't be taken back: a delete that can be undone asks nothing (see deleteWithUndo). |
deleteWithUndo(), offerUndo() | A reversible delete (src/lib/undo-delete.ts, src/lib/undo.ts): it happens at once and the shared toast offers Undo for 8 seconds; Ctrl/⌘+Z outside text fields runs the latest offer once. The item comes back with its id, in its place (putBack; collections, environments and datasets from the trash with restoreDeleted, an MCP server with deleteMcpServerWithUndo). |
Segmented | Every switch between modes or views of one thing (a response body's Pretty / Table / Raw, the Console filter, density): labelled words, one radio group (role="radiogroup"), options can be disabled. No hand-made button groups. |
Coachmark | A card anchored to a control with a ring around it (the guided tour): follows scroll and resize, non-modal, Esc / ← / →. |
Empty | Every empty state: an accent icon, one sentence, and the next action. |
Split (sidebar) | Resizable panes; the sidebar pane gets the panel surface. |
Tooltip, IconButton | Every icon-only button has a label and a tooltip. |
OutputActions, OutputHeader (components/output/) | The right end of every response or result bar: the surface's own tools, then Copy, the down arrow and ▾, always last and always the same size. A surface describes what it shows (an OutputSpec) and never builds its own save button. |
Core architecture
More detail: Architecture overview and Execution engine.
Saving outputs
Every download goes one way (Save and export results):
- One registry in
packages/shared/src/output/:formats.ts(every format, its extension and MIME type, and the pure builders: pretty JSON, JSON Lines, CSV with the formula-injection guard, Markdown and HTML tables, JUnit, HAR,cookies.txt, ZIP, a streaming JSON pretty-printer),filename.ts(safe, descriptive file names) andsurfaces.ts(every output that can be saved: its formats, its secrets policy, whether it ships or comes in a later release). The window, the backend, the docs and a completeness test read the same list. - The window (
apps/desktop/src/components/output/) describes each output as anOutputSpec: small formats are built from what it already shows, big ones are named by asource(a spooled response body, a run, the console, a grid) and never pass through it.OutputDownloadis the one control; Ctrl+Shift+S runs the one nearest to the focus. - The backend (
apps/desktop/backend/handlers/output.ts:output.save,output.copy,output.cancel,output.reveal) checks the surface and format against the registry, applies its secrets policy itself (never the caller's word), streams a source from disk into<name>.partwith progress and cancel, renames it when complete, and remembers the folder per workspace and kind of output. In the browser and the cloud version the same call returns the file for the browser to download (up to 100 MB).
The life of a request
The same steps run for a request sent from a tab, by the Collection Runner, by testpion run-collection in CI, or by an AI agent through the MCP server.
Cloud-ready design
TestPion runs on your desktop today, and the same code is meant to run as an online, multi-user service later. Rules for new features:
- The UI talks to the engine only through RPC (
call(...)). No Node or Electron APIs inapps/desktop/src. Desktop-only features (menus, native dialogs, the updater) sit behind backend handlers and have a browser fallback, such as upload and download instead of file dialogs. The browser build (dev.mjs --web) must keep working. - RPC methods are grouped by domain. Each domain has its own module in
apps/desktop/backend/handlers/(app,workspace,collections,requests,grpc,mcp,ai,agents,testing,monitors,debugger,git,terminal, the flow modules …), named<domain>.<action>(col.list,mcp.connect…). TheBackendclass holds the shared state and services (workspace, secrets, logger, sessions) that the handlers use, and it refuses a method name defined twice. A new feature adds its methods to the right module, or a new one, so the RPC surface can be served by any host (Electron IPC, the web bridge, a future server). - The engine is host-agnostic. Files, metadata and secrets go through the
WorkspaceStore,MetaStoreandSecretStoreinterfaces, so a server can plug in a database and object storage. Context such as the workspace and user is passed explicitly, not held in process-wide globals. - Data is multi-user ready: stable ids, a
schemaVersionon every file, and no assumption of one user per machine. Per-user secrets, sharing and permissions are considered when data is added. - Every write says what it changed. Core keeps the app's own writes by path and content fingerprint (
writeOwned,removeOwned… instorage/fsutil.ts); the workspace watcher skips an event only when the file is exactly what the app wrote, so another program's change right after a save is still reported. The backend (own-writes.ts) attributes writes to the RPC that made them and sendsdata.changed { method, kinds, paths }, so every view (and any other client) refreshes only what one of its files changed, whoever wrote it. - Untrusted content gets its own origin. Visualizations run on an isolated origin (
tpviz://on the desktop,/viz/<id>on the bridge; a separate user-content domain in a hosted version) with its own strict security policy, never inside the app's origin. - Files go through RPC, not paths. Picking a file sends its content; saving either uses the native dialog (desktop) or returns a download (
saveOrDownloadin the backend,finishSave/pickTextFileinsrc/lib/files.ts).app.info.nativeDialogstells the UI which one applies. - Safe on a shared server. Anything that sends requests, runs scripts or starts processes has limits, a sandbox, and guards for private networks and local processes that a hosted version can switch on.
- Production consent is a backend boundary.
apps/desktop/backend/production-guard.tslists every RPC method that sends or runs, andBackend.invokepasses every call through it: a data-changing send or any run against an environment marked as production is refused unless the call carriesallowProduction: true, which the window's one shared dialog adds after asking. The consent travels with the call into the engine (allowProductionForinpackages/core/src/net/production-guard.ts), which refuses data-changing steps everywhere else. The CLI's--allow-productionand the MCP server's--allow-productionplusallowProduction: trueper call are the same rule for the other hosts, so no window, web bridge, agent or future cloud client can skip it.
AI-first design
Every feature is designed for three audiences: the person using the app, the AI agent doing the work, and the person reviewing it.
| Surface | What it gives agents | Examples |
|---|---|---|
| CLI | Every workspace action, with --json output (one envelope: { schema_version, command, status, data, errors, warnings, metrics }), --jsonl events, --dry-run, --non-interactive and CI exit codes | testpion import - --json, testpion env list --json | jq .data, testpion workspace delete --yes |
| MCP server | The workspace as tools, redacted, with one contract: paged lists, concise results unless asked, confirm: true to delete or overwrite | list_requests, send_request, run_tests, search_workspace, save_test |
| AGENTS.md | What a coding agent needs about this workspace, written from the live workspace | testpion agents init, agents_init |
| llms.txt | A map of the docs and key facts, written for models | /llms.txt |
| Files | Diff-friendly JSON and YAML to edit and review in git | collections/*.json, tests/**/*.yaml |
| In the app | AI actions that are labelled, show what they send, and propose instead of applying | What will be sent…, Suggest assertions, Generate integration flow with AI, Explain with AI |
Rules for new features:
- Add a CLI command (with
--json, printed through the envelope inpackages/cli/src/output.ts) and, when it is safe, an MCP tool that follows the tool contract. Destructive actions need consent:--yesin the CLI,confirm: true(or the user's yes through MCP elicitation) for a tool; deleting a workspace stays with people. - Keep secrets out of everything an agent can read, and out of files.
- Label AI output, show it for review, and never run or save it on its own.
- Document the feature in these docs and, when agents need it, in
llms.txt.
TestPion's own AI features
The AI features (explain, suggest checks, write tp tests or a flow, fix lint problems …) are one engine module, packages/core/src/ai/, used the same way by every host: the desktop backend (apps/desktop/backend/handlers/ai-tasks.ts, a thin RPC adapter), the CLI (testpion ai …, testpion flow generate), the MCP server (packages/core/src/mcp-server/ai-tools.ts) and, later, a cloud host. The window never talks to a model: it calls ai.preview, ai.runTask, ai.reverifyProposal and ai.applyProposal over RPC.
| Part | Where | What it does |
|---|---|---|
| Task registry | ai/tasks/registry.ts, ai/tasks/prompts.ts | One definition per task (29): id, versioned prompt, tier (small or large model), what its context holds, its output (Markdown or JSON with a schema), how the answer becomes a proposal and which verifiers check it. |
| Task runner | ai/tasks/run.ts (runAiTask) | Route the model (ai/ai-settings.ts: override → TESTPION_AI_* → the tier's model → the assistant's; Local only) → build the context (ai/context.ts, ai/item-context.ts: redaction, privacy level, per-section budgets) → estimate tokens and cost, check the caps → stop here for a dry run (the preview) → call the model (ai/structured.ts: JSON schema where the provider has it, validated, repaired up to twice; truncation detected) → verify and repair → make a proposal → record the call. |
| Proposals | ai/proposal.ts, ai/proposal-apply.ts, ai/proposal-verify.ts | What a task suggests, never applied by the task: items with before/after text and verification badges, kept a day in .local/ai/proposals/; applied (some or all items) through the workspace's own writers, with what each replaced kept for undo; verified again after an edit, a safer form, Run now or Try on mock. Paths stay under tests/. |
| Verifiers | ai/verify.ts, ai/fix-items.ts | checks, script, test-file, flow-refs, flow-vars, flow-mock, json, yaml, and fix (a triage or flaky fix: passes on the failing response, never on any response, compared with the last passing one; the file lints; never a secret). YAML from a model is made safe first (ai/yaml-repair.ts); the test-file format the model is given is generated from the lint's schema and check registry (ai/flow-format.ts), and the tp.* script API from the sandbox itself (ai/script-reference.ts: also the static check that names the right member when a script misuses it). |
| Analyses | ai/run-evidence.ts, ai/triage.ts, ai/triage-run.ts, ai/flaky.ts, ai/flaky-run.ts | Deterministic first, the model to explain: a failed run's failures grouped (same check, status, endpoint) with evidence and the last passing run; a flaky test's signals across runs. The tasks triage-run and analyze-flaky turn the model's causes and pattern into findings (Proposal.findings, with evidence that links its items) and verified fix items; triageRun / analyzeFlaky / attemptFix are what every host calls (RPC ai.triageRun, ai.analyzeFlaky, ai.attemptFix in backend/handlers/ai-triage.ts; CLI ai triage, ai attempt-fix, --triage; MCP ai_triage_run, ai_analyze_flaky, ai_attempt_fix). Without a model they answer with the deterministic findings. |
| Usage ledger | storage/ai-usage.ts | Every call from every surface (app, CLI, MCP, CI) with task, model, tokens, cost, time and outcome, in the workspace's metadata store; the per-call and per-day caps are checked against it before anything is sent. |
| Prompt tests | tests/ai/ | Golden cases per task with the mock provider in npm test; AI_EVAL_LIVE=1 runs them against a real model with a scorecard. |
In the window, every AI button is one component (src/components/ai/AiAction.tsx: the action and What will be sent…), every proposal opens the one review (ProposalReview, with an analysis's findings above its items, or FlowProposalReview for a flow on a canvas), every analysis opens from one dialog (AiAnalysisDialog: Explain and fix failures with AI… on a failed run, Why is this flaky? on a flaky test), and every setup problem (no model, a missing key, Local only, a cap) offers its fix through one helper (src/lib/ai-errors.ts).