Skip to content

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 ​

Local-first
Everything runs on your machine. Workspaces are plain files you can commit; nothing is sent anywhere unless a request you make sends it.
One engine
The desktop app, the CLI and the MCP server call the same core engine, so a test behaves the same in the app, in CI and for an AI agent.
AI-first
Every feature is usable by AI agents (CLI with --json, MCP tools, llms.txt) and reviewable by people. AI output is always labelled and shown before it runs.
Safe by default
Secrets live in the OS credential store, are redacted from logs, traces, the console and exports, and are never written to workspace files.
Friendly
Clear surfaces, colour that carries meaning (methods, status), plain-language messages, and a next step in every empty state and error.
Cloud-ready
A hosted, multi-user version is planned. The UI only talks to the engine through RPC, the engine is host-agnostic behind storage and secret interfaces, and anything that sends requests or runs code must be safe on a shared server.
Postman-familiar
Collections, environments, tp.* scripts, the runner and the console work the way Postman users expect, and import and export losslessly.

Brand ​

The TestPion wordmark: gradient Test, white Pion, a four-point sparkle, and the tagline Connect every protocol
Wordmark
TestPion wordmark
The logo everywhere a name is shown: the app's top bar, the website, the README. "Test" uses the brand gradient, "Pion" is white, and the sparkle sits top right.
App icon
TestPion app icon
The same logo, stacked to fit a square: the app, taskbar, title bar, installer and dock icon, and the favicon.
Brand gradient
#1FF0FF → #1E88FF → #8B5CFF → #F04FE0 in the wordmark; the interface uses the calmer #2F7BFF → #8B5CFF for primary buttons and active indicators.
Do
  • 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.
Don't
  • 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.

Dark theme
chrome#060A1A
panel#090F20
bg#101629
popover#141A2F
hover#1B243E
line-strong#333C58
fg#EEF2FA
muted#9BA4BE
accent#4697FF
ok#4AD496
warn#FABB41
bad#FA686A
Light theme
chrome#F1F3F9
panel#F7F9FC
bg#FFFFFF
popover#FFFFFF
hover#E5EAF5
line-strong#CCD1DC
fg#0F142A
muted#5B6378
accent#186DE7
ok#05893E
warn#BB6900
bad#DF202E

Colour 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.

GETPOSTPUTPATCHDELETE200 OK302 Found500 Errorcurrent

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.

Workspace ▾Search requests, tests, tools…Development ▾⌘K · AIHomeCollectionsDebuggerTestsMonitorsLoadAI LabEvaluationsEnvironmentsHistoryTracesGitCollections explorer▾ Veterinary APIGETList patientsPOSTCreate patientGETList patientsPOST Create…+8 ▾GET{{baseUrl}}/patientsSendParams Auth Headers Body Scripts Tests200 OK65 ms · 138 B · 3 passed{ "items": [ … ], "total": 2 }Info · AssistantConsoleLogs Terminal Problems12:03 GET http://127.0.0.1:4010/patients 200 65 ms (secrets redacted)● Development 0 MCP connected Idle⊗ 0 problems · Tools · Terminal · Console · LogsPopover / menu / dialogRename…Duplicate…Delete…
chrome: top bar, navigation rail, status bar · panel: sidebars, tab bar, bottom panel, right-edge panel · bg: the work area · popover: menus and dialogs
  • 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-py for fields; VirtualList scales 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 + LibraryTabStrip for 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 ​

Page title · Inter 600 · 28px
Veterinary API (example)
Section · Inter 600 · 15px
Recent requests
Body · Inter 400 · 14px (0.9rem)
Build, test and debug REST, GraphQL, MCP and AI APIs. Everything stays on this computer.
Small · Inter 400 · 12.8px (0.8rem), muted
Requests you send appear here.
Code · JetBrains Mono · 13px
tp.expect(tp.response.code).to.equal(200);

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.

SendSaveImportCancelDelete workspace
ComponentUse it for
Button (primary, default, soft, ghost, danger)Primary uses the brand gradient; one per area. Danger is only for destructive confirmations.
BadgeStatus and counts: ok, warn, bad, accent, judge (AI evaluations).
TabsSections of a view; the active tab has the gradient underline and counts in a pill.
MenuDropdown 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 menuThe 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 paletteEvery command shows its icon; Go to commands use the navigation icons, environments their colour.
ModalForms 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).
SegmentedEvery 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.
CoachmarkA card anchored to a control with a ring around it (the guided tour): follows scroll and resize, non-modal, Esc / ← / →.
EmptyEvery empty state: an accent icon, one sentence, and the next action.
Split (sidebar)Resizable panes; the sidebar pane gets the panel surface.
Tooltip, IconButtonEvery 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 ​

Desktop appElectron · React 19 · Tailwind v4renderer ↔ backend over RPC (no network in UI)testpion CLItest · run-collection · import · env …--json envelope · --jsonl · CI exit codesMCP server → AI agentssend_request · run_collectionrun_tests · search_workspace · flow_*@testpion/core: the single execution engineProtocolsHTTP · GraphQL · gRPC · realtime · MCPScripts (sandbox)tp.* in QuickJS/WASMVariables & authscopes · secrets · OAuth · JWTAI providersLLMs · agent loop · RAGRunnercollections · flows · retriesChecks & evaluatorsassertions · LLM-as-judgeTracer & redactorspans · secrets never leakImport / exportPostman · OpenAPI · HAR · cURLWorkspace filescollections · environments · tests (git-friendly)SQLite metadatahistory · runs · traces indexSecret storeDPAPI · Keychain · Secret Service
Three front doors, one engine: the app, the CLI and AI agents get the same results.

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) and surfaces.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 an OutputSpec: small formats are built from what it already shows, big ones are named by a source (a spooled response body, a run, the console, a grid) and never pass through it. OutputDownload is 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>.part with 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 ​

1 Resolve{{variables}}env · collection2 Pre-requestcollection → folder→ request scripts3 Authinherited authcookie jar4 Sendstream body to disktimings5 Post-responsetp.test · visualizerset variables6 Checksassertionsevaluators7 Recordtrace · historyconsoleThe redactor runs on everything that is recorded.Sensitive headers and fields and known secret values become [REDACTED] in traces, history, the console, reports and MCP results.

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 in apps/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 …). The Backend class 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, MetaStore and SecretStore interfaces, 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 schemaVersion on 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 … in storage/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 sends data.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 (saveOrDownload in the backend, finishSave / pickTextFile in src/lib/files.ts). app.info.nativeDialogs tells 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.ts lists every RPC method that sends or runs, and Backend.invoke passes every call through it: a data-changing send or any run against an environment marked as production is refused unless the call carries allowProduction: true, which the window's one shared dialog adds after asking. The consent travels with the call into the engine (allowProductionFor in packages/core/src/net/production-guard.ts), which refuses data-changing steps everywhere else. The CLI's --allow-production and the MCP server's --allow-production plus allowProduction: true per 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.

SurfaceWhat it gives agentsExamples
CLIEvery workspace action, with --json output (one envelope: { schema_version, command, status, data, errors, warnings, metrics }), --jsonl events, --dry-run, --non-interactive and CI exit codestestpion import - --json, testpion env list --json | jq .data, testpion workspace delete --yes
MCP serverThe workspace as tools, redacted, with one contract: paged lists, concise results unless asked, confirm: true to delete or overwritelist_requests, send_request, run_tests, search_workspace, save_test
AGENTS.mdWhat a coding agent needs about this workspace, written from the live workspacetestpion agents init, agents_init
llms.txtA map of the docs and key facts, written for models/llms.txt
FilesDiff-friendly JSON and YAML to edit and review in gitcollections/*.json, tests/**/*.yaml
In the appAI actions that are labelled, show what they send, and propose instead of applyingWhat 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 in packages/cli/src/output.ts) and, when it is safe, an MCP tool that follows the tool contract. Destructive actions need consent: --yes in 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.

PartWhereWhat it does
Task registryai/tasks/registry.ts, ai/tasks/prompts.tsOne 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 runnerai/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.
Proposalsai/proposal.ts, ai/proposal-apply.ts, ai/proposal-verify.tsWhat 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/.
Verifiersai/verify.ts, ai/fix-items.tschecks, 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).
Analysesai/run-evidence.ts, ai/triage.ts, ai/triage-run.ts, ai/flaky.ts, ai/flaky-run.tsDeterministic 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 ledgerstorage/ai-usage.tsEvery 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 teststests/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).