privateer-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +474 -0
  3. package/bin/privateer.mjs +11 -0
  4. package/package.json +74 -0
  5. package/src/agents/loader.ts +49 -0
  6. package/src/auth/privateer.ts +393 -0
  7. package/src/commands/custom.ts +75 -0
  8. package/src/commands/registry.ts +499 -0
  9. package/src/components/AgentGroupView.tsx +104 -0
  10. package/src/components/App.tsx +1376 -0
  11. package/src/components/ApprovalPrompt.tsx +38 -0
  12. package/src/components/Banner.tsx +58 -0
  13. package/src/components/Markdown.tsx +183 -0
  14. package/src/components/ModeHint.tsx +40 -0
  15. package/src/components/ModelPicker.tsx +269 -0
  16. package/src/components/Onboarding.tsx +203 -0
  17. package/src/components/PlanConfirm.tsx +37 -0
  18. package/src/components/PrivateerLogin.tsx +109 -0
  19. package/src/components/PromptInput.tsx +602 -0
  20. package/src/components/RewindPicker.tsx +69 -0
  21. package/src/components/Root.tsx +95 -0
  22. package/src/components/SessionPicker.tsx +64 -0
  23. package/src/components/StatusBar.tsx +121 -0
  24. package/src/components/TodoPanel.tsx +36 -0
  25. package/src/components/ToolCallView.tsx +109 -0
  26. package/src/components/Transcript.tsx +203 -0
  27. package/src/components/figures.ts +13 -0
  28. package/src/components/promptModel.ts +73 -0
  29. package/src/components/spinnerVerbs.ts +46 -0
  30. package/src/components/theme.ts +55 -0
  31. package/src/components/types.ts +34 -0
  32. package/src/components/useTeeShield.ts +104 -0
  33. package/src/components/useTerminalWidth.ts +24 -0
  34. package/src/components/useZdrShield.ts +126 -0
  35. package/src/config/load.ts +115 -0
  36. package/src/config/paths.ts +61 -0
  37. package/src/config/schema.ts +94 -0
  38. package/src/context/outputStyles.ts +42 -0
  39. package/src/context/projectInfo.ts +59 -0
  40. package/src/context/systemPrompt.ts +167 -0
  41. package/src/engine/QueryEngine.ts +399 -0
  42. package/src/engine/errors.ts +197 -0
  43. package/src/engine/events.ts +74 -0
  44. package/src/engine/router.ts +165 -0
  45. package/src/hooks/engine.ts +155 -0
  46. package/src/main.tsx +167 -0
  47. package/src/mcp/client.ts +236 -0
  48. package/src/mcp/oauth.ts +245 -0
  49. package/src/memory/auto.ts +146 -0
  50. package/src/memory/checkpoints.ts +227 -0
  51. package/src/memory/store.ts +127 -0
  52. package/src/permissions/danger.ts +56 -0
  53. package/src/permissions/gate.ts +38 -0
  54. package/src/permissions/mode.ts +39 -0
  55. package/src/permissions/protected.ts +29 -0
  56. package/src/permissions/uiGate.ts +73 -0
  57. package/src/providers/attestation.ts +149 -0
  58. package/src/providers/capabilities.ts +104 -0
  59. package/src/providers/catalog.ts +66 -0
  60. package/src/providers/models.ts +183 -0
  61. package/src/providers/registry.ts +71 -0
  62. package/src/providers/resolve.ts +78 -0
  63. package/src/remote/relayClient.ts +283 -0
  64. package/src/session.ts +264 -0
  65. package/src/tools/bash.ts +98 -0
  66. package/src/tools/context.ts +114 -0
  67. package/src/tools/edit.ts +67 -0
  68. package/src/tools/exec.ts +60 -0
  69. package/src/tools/glob.ts +39 -0
  70. package/src/tools/grep.ts +86 -0
  71. package/src/tools/index.ts +69 -0
  72. package/src/tools/memory.ts +53 -0
  73. package/src/tools/processRegistry.ts +77 -0
  74. package/src/tools/read.ts +42 -0
  75. package/src/tools/saveAttachment.ts +53 -0
  76. package/src/tools/task.ts +52 -0
  77. package/src/tools/todo.ts +36 -0
  78. package/src/tools/todoStore.ts +31 -0
  79. package/src/tools/walk.ts +44 -0
  80. package/src/tools/web.ts +145 -0
  81. package/src/tools/write.ts +40 -0
  82. package/src/util/attachmentStore.ts +72 -0
  83. package/src/util/images.ts +343 -0
  84. package/src/util/limit.ts +32 -0
  85. package/src/util/redact.ts +44 -0
  86. package/src/version.ts +13 -0
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import type { PermissionRequest } from "../permissions/gate.ts";
4
+ import type { AskOutcome } from "../permissions/uiGate.ts";
5
+ import { theme } from "./theme.ts";
6
+
7
+ // Interactive approval shown when a tool needs permission. Keys: y allow once,
8
+ // a allow always, n / esc deny.
9
+ export function ApprovalPrompt({
10
+ req,
11
+ onRespond,
12
+ }: {
13
+ req: PermissionRequest;
14
+ onRespond: (outcome: AskOutcome) => void;
15
+ }) {
16
+ useInput((input, key) => {
17
+ const c = input.toLowerCase();
18
+ if (c === "y") onRespond("allow");
19
+ else if (c === "a") onRespond("always");
20
+ else if (c === "n" || key.escape) onRespond("deny");
21
+ });
22
+
23
+ return (
24
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.warning} paddingX={1}>
25
+ <Text>
26
+ <Text bold color={theme.warning}>
27
+ {req.title}
28
+ </Text>
29
+ <Text dimColor> ({req.tool})</Text>
30
+ </Text>
31
+ <Text>{req.detail}</Text>
32
+ <Text dimColor>
33
+ <Text color={theme.success}>y</Text> allow · <Text color={theme.success}>a</Text> always ·{" "}
34
+ <Text color={theme.error}>n</Text> deny
35
+ </Text>
36
+ </Box>
37
+ );
38
+ }
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import os from "node:os";
3
+ import { Box, Text } from "ink";
4
+ import { VERSION } from "../version.ts";
5
+ import { theme } from "./theme.ts";
6
+ import { WELCOME } from "./figures.ts";
7
+
8
+ // Collapse the user's home directory to ~ for a compact path display.
9
+ function shortenPath(cwd: string): string {
10
+ const home = os.homedir();
11
+ return cwd === home || cwd.startsWith(home + "/")
12
+ ? "~" + cwd.slice(home.length)
13
+ : cwd;
14
+ }
15
+
16
+ // Anchor motif rendered in ASCII — the Privateer mark (ring, stock, shank, flukes).
17
+ const ANCHOR = [
18
+ " .-. ",
19
+ " '_' ",
20
+ " --|-- ",
21
+ " | ",
22
+ " \\ | / ",
23
+ " \\_|_/ ",
24
+ ];
25
+
26
+ export function Banner({ model }: { model: string }) {
27
+ return (
28
+ <Box flexDirection="column">
29
+ <Box
30
+ borderStyle="round"
31
+ borderColor={theme.accent}
32
+ paddingX={1}
33
+ flexDirection="row"
34
+ gap={2}
35
+ >
36
+ <Box flexDirection="column">
37
+ {ANCHOR.map((line, i) => (
38
+ <Text key={i} color={theme.accent}>
39
+ {line}
40
+ </Text>
41
+ ))}
42
+ </Box>
43
+ <Box flexDirection="column" justifyContent="center">
44
+ <Text bold color={theme.accent}>
45
+ {WELCOME} PRIVATEER
46
+ </Text>
47
+ <Text color={theme.dim}>bring your own model · v{VERSION}</Text>
48
+ <Text> </Text>
49
+ <Text>
50
+ model <Text color={theme.accent}>{model}</Text>
51
+ </Text>
52
+ <Text color={theme.dim}>type a prompt · /help for commands</Text>
53
+ <Text color={theme.accent}>{shortenPath(process.cwd())}</Text>
54
+ </Box>
55
+ </Box>
56
+ </Box>
57
+ );
58
+ }
@@ -0,0 +1,183 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { theme } from "./theme.ts";
4
+
5
+ // A small, dependency-free markdown renderer for the terminal. It exists because
6
+ // assistant output is streamed in incrementally (App.tsx concatenates deltas), so a
7
+ // renderer that emits ANSI strings (marked-terminal et al.) would fight Ink's layout
8
+ // and reflow oddly on partial input. Instead we parse the text into Ink <Box>/<Text>
9
+ // nodes on every frame — the parse is a pure function of the current text, so half-
10
+ // finished markdown simply renders as plain-ish text until the closing marker arrives.
11
+ //
12
+ // Supported: ATX headings, fenced code blocks, blockquotes, unordered/ordered lists,
13
+ // horizontal rules, and inline bold/italic/code/links. Anything unrecognized falls
14
+ // through as a normal paragraph, so we never lose content.
15
+
16
+ // --- Inline formatting -----------------------------------------------------------
17
+
18
+ // One regex, alternation tried left-to-right at each position: code spans first so we
19
+ // never reparse markdown inside `code`, then bold before italic so `**x**` wins over
20
+ // `*x*`. Each construct forbids its own delimiter inside, which keeps it simple and
21
+ // means an unterminated marker (mid-stream) just renders literally until it closes.
22
+ const INLINE_RE =
23
+ /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*\n]+\*)|(\b_[^_\n]+_\b)|(\[[^\]]+\]\([^)]+\))/g;
24
+
25
+ function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
26
+ const nodes: React.ReactNode[] = [];
27
+ let last = 0;
28
+ let m: RegExpExecArray | null;
29
+ let i = 0;
30
+ INLINE_RE.lastIndex = 0;
31
+ while ((m = INLINE_RE.exec(text)) !== null) {
32
+ if (m.index > last) nodes.push(text.slice(last, m.index));
33
+ const tok = m[0];
34
+ const key = `${keyPrefix}-${i++}`;
35
+ if (m[1]) {
36
+ // `inline code`
37
+ nodes.push(
38
+ <Text key={key} color={theme.accent}>
39
+ {tok.slice(1, -1)}
40
+ </Text>,
41
+ );
42
+ } else if (m[2] || m[3]) {
43
+ // **bold** or __bold__
44
+ nodes.push(
45
+ <Text key={key} bold>
46
+ {tok.slice(2, -2)}
47
+ </Text>,
48
+ );
49
+ } else if (m[4] || m[5]) {
50
+ // *italic* or _italic_
51
+ nodes.push(
52
+ <Text key={key} italic>
53
+ {tok.slice(1, -1)}
54
+ </Text>,
55
+ );
56
+ } else {
57
+ // [label](url) — show the label, underlined in the accent hue.
58
+ const label = tok.slice(1, tok.indexOf("]"));
59
+ nodes.push(
60
+ <Text key={key} color={theme.accent} underline>
61
+ {label}
62
+ </Text>,
63
+ );
64
+ }
65
+ last = m.index + tok.length;
66
+ }
67
+ if (last < text.length) nodes.push(text.slice(last));
68
+ return nodes;
69
+ }
70
+
71
+ // --- Block parsing ---------------------------------------------------------------
72
+
73
+ const FENCE_RE = /^\s*```/;
74
+ const HEADING_RE = /^(#{1,6})\s+(.*)$/;
75
+ const HR_RE = /^\s*([-*_])(\s*\1){2,}\s*$/;
76
+ const QUOTE_RE = /^>\s?(.*)$/;
77
+ const UL_RE = /^(\s*)[-*+]\s+(.*)$/;
78
+ const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
79
+
80
+ export function Markdown({ text }: { text: string }) {
81
+ const lines = text.split("\n");
82
+ const blocks: React.ReactNode[] = [];
83
+ let para: string[] = [];
84
+ let bi = 0;
85
+
86
+ const flushPara = () => {
87
+ if (para.length === 0) return;
88
+ const joined = para.join("\n");
89
+ blocks.push(
90
+ <Text key={`p-${bi++}`}>{renderInline(joined, `p-${bi}`)}</Text>,
91
+ );
92
+ para = [];
93
+ };
94
+
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const line = lines[i];
97
+
98
+ // Fenced code block: accumulate verbatim until the closing fence (or EOF, so a
99
+ // still-streaming block renders instead of swallowing the rest of the message).
100
+ if (FENCE_RE.test(line)) {
101
+ flushPara();
102
+ const code: string[] = [];
103
+ i++;
104
+ for (; i < lines.length && !FENCE_RE.test(lines[i]); i++) code.push(lines[i]);
105
+ blocks.push(
106
+ <Box key={`code-${bi++}`} flexDirection="column" paddingLeft={2}>
107
+ {(code.length ? code : [""]).map((c, j) => (
108
+ <Text key={j} color={theme.accent} dimColor>
109
+ {c || " "}
110
+ </Text>
111
+ ))}
112
+ </Box>,
113
+ );
114
+ continue;
115
+ }
116
+
117
+ if (line.trim() === "") {
118
+ flushPara();
119
+ continue;
120
+ }
121
+
122
+ const hr = HR_RE.test(line);
123
+ if (hr) {
124
+ flushPara();
125
+ blocks.push(
126
+ <Text key={`hr-${bi++}`} color={theme.dim} dimColor>
127
+ {"─".repeat(40)}
128
+ </Text>,
129
+ );
130
+ continue;
131
+ }
132
+
133
+ const heading = line.match(HEADING_RE);
134
+ if (heading) {
135
+ flushPara();
136
+ blocks.push(
137
+ <Text key={`h-${bi++}`} color={theme.accent} bold>
138
+ {renderInline(heading[2], `h-${bi}`)}
139
+ </Text>,
140
+ );
141
+ continue;
142
+ }
143
+
144
+ const quote = line.match(QUOTE_RE);
145
+ if (quote) {
146
+ flushPara();
147
+ blocks.push(
148
+ <Box key={`q-${bi++}`}>
149
+ <Text color={theme.dim}>{"▏ "}</Text>
150
+ <Box flexGrow={1}>
151
+ <Text color={theme.dim} dimColor>
152
+ {renderInline(quote[1], `q-${bi}`)}
153
+ </Text>
154
+ </Box>
155
+ </Box>,
156
+ );
157
+ continue;
158
+ }
159
+
160
+ const ul = line.match(UL_RE);
161
+ const ol = line.match(OL_RE);
162
+ if (ul || ol) {
163
+ flushPara();
164
+ const indent = (ul ? ul[1] : ol![1]).length;
165
+ const marker = ul ? "•" : `${ol![2]}.`;
166
+ const content = ul ? ul[2] : ol![3];
167
+ blocks.push(
168
+ <Box key={`li-${bi++}`} paddingLeft={indent}>
169
+ <Text color={theme.accent}>{marker} </Text>
170
+ <Box flexGrow={1}>
171
+ <Text>{renderInline(content, `li-${bi}`)}</Text>
172
+ </Box>
173
+ </Box>,
174
+ );
175
+ continue;
176
+ }
177
+
178
+ para.push(line);
179
+ }
180
+ flushPara();
181
+
182
+ return <Box flexDirection="column">{blocks}</Box>;
183
+ }
@@ -0,0 +1,40 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { PermissionMode } from "../config/schema.ts";
4
+ import { theme, MODE_COLOR } from "./theme.ts";
5
+ import { FAST_FORWARD, PAUSE } from "./figures.ts";
6
+ import { useTerminalWidth } from "./useTerminalWidth.ts";
7
+
8
+ // The footer rendered directly under the prompt box (Claude Code style): the
9
+ // active permission mode in its accent color on the left, and a shortcuts hint
10
+ // on the right. Default mode is the resting state, so the left side shows
11
+ // nothing — but the shortcuts hint always stays pinned to the bottom right.
12
+ const MODE_LABEL: Record<PermissionMode, { marker: string; text: string } | null> = {
13
+ default: null,
14
+ acceptEdits: { marker: FAST_FORWARD, text: "accept edits on" },
15
+ bypass: { marker: FAST_FORWARD, text: "bypass permissions on" },
16
+ plan: { marker: PAUSE, text: "plan mode on" },
17
+ };
18
+
19
+ export function ModeHint({ mode, collapsed }: { mode: PermissionMode; collapsed?: boolean }) {
20
+ // Match StatusBar: stay a few columns clear of the right edge so the row never
21
+ // reaches the final column and the terminal never reflows it.
22
+ const width = Math.max(20, useTerminalWidth() - 4);
23
+ const label = MODE_LABEL[mode];
24
+ return (
25
+ <Box width={width} justifyContent="space-between">
26
+ <Box flexShrink={1} minWidth={0}>
27
+ {label && (
28
+ <Text color={MODE_COLOR[mode]} wrap="truncate-end">
29
+ {label.marker} {label.text}
30
+ </Text>
31
+ )}
32
+ </Box>
33
+ <Box flexShrink={0}>
34
+ <Text color={theme.dim} wrap="truncate-end">
35
+ {`/help · esc interrupts · Ctrl+O ${collapsed ? "expand" : "collapse"}`}
36
+ </Text>
37
+ </Box>
38
+ </Box>
39
+ );
40
+ }
@@ -0,0 +1,269 @@
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import TextInput from "ink-text-input";
5
+ import type { Config, ProviderName } from "../config/schema.ts";
6
+ import { configuredProviders, privateerChannel } from "../providers/resolve.ts";
7
+ import { PROVIDER_META } from "../providers/catalog.ts";
8
+ import { listModels, zdrPosture, type ModelInfo } from "../providers/models.ts";
9
+ import { theme, POSTURE_COLOR } from "./theme.ts";
10
+ import { SHIELD } from "./figures.ts";
11
+ import { useZdrAccount, type ZdrAccountState } from "./useZdrShield.ts";
12
+
13
+ const PAGE = 8; // visible rows in the scrolling model list
14
+
15
+ // A two-stage picker: choose a configured provider, then choose one of the models it
16
+ // actually offers (fetched live with the user's key). Returns a "provider:model" spec.
17
+ // Reused by the /model command and the onboarding flow. Esc cancels (if onCancel given).
18
+ export function ModelPicker({
19
+ config,
20
+ providers,
21
+ onSelect,
22
+ onCancel,
23
+ }: {
24
+ config: Config;
25
+ // Restrict the provider stage to these names; defaults to all ready providers.
26
+ providers?: ProviderName[];
27
+ onSelect: (spec: string) => void;
28
+ onCancel?: () => void;
29
+ }) {
30
+ const ready = useMemo(() => {
31
+ const offered = providers ?? configuredProviders(config).filter((p) => p.ready).map((p) => p.name);
32
+ return offered as ProviderName[];
33
+ }, [config, providers]);
34
+
35
+ const [provider, setProvider] = useState<ProviderName | null>(ready.length === 1 ? ready[0] : null);
36
+
37
+ if (ready.length === 0) {
38
+ return (
39
+ <Box flexDirection="column" paddingX={1}>
40
+ <Text color={theme.error}>No providers configured. Run /login to add an API key.</Text>
41
+ </Box>
42
+ );
43
+ }
44
+
45
+ if (provider === null) {
46
+ return <ProviderStage providers={ready} onPick={setProvider} onCancel={onCancel} />;
47
+ }
48
+
49
+ return (
50
+ <ModelStage
51
+ provider={provider}
52
+ config={config}
53
+ onSelect={(id) => onSelect(`${provider}:${id}`)}
54
+ onBack={ready.length > 1 ? () => setProvider(null) : undefined}
55
+ onCancel={onCancel}
56
+ />
57
+ );
58
+ }
59
+
60
+ function ProviderStage({
61
+ providers,
62
+ onPick,
63
+ onCancel,
64
+ }: {
65
+ providers: ProviderName[];
66
+ onPick: (name: ProviderName) => void;
67
+ onCancel?: () => void;
68
+ }) {
69
+ const [cursor, setCursor] = useState(0);
70
+ useInput((input, key) => {
71
+ if (key.upArrow || input === "k") setCursor((c) => (c - 1 + providers.length) % providers.length);
72
+ else if (key.downArrow || input === "j") setCursor((c) => (c + 1) % providers.length);
73
+ else if (key.return) onPick(providers[cursor]);
74
+ else if (key.escape) onCancel?.();
75
+ });
76
+
77
+ return (
78
+ <Box flexDirection="column" paddingX={1}>
79
+ <Text color={theme.dim}>
80
+ Pick a provider — <Text color={theme.accent}>↑↓</Text> move,{" "}
81
+ <Text color={theme.accent}>enter</Text> select{onCancel ? ", esc cancel" : ""}.
82
+ </Text>
83
+ <Box flexDirection="column" marginTop={1}>
84
+ {providers.map((name, i) => (
85
+ <Text key={name} color={i === cursor ? theme.accent : undefined}>
86
+ {i === cursor ? "❯ " : " "}
87
+ {PROVIDER_META[name].label}
88
+ </Text>
89
+ ))}
90
+ </Box>
91
+ </Box>
92
+ );
93
+ }
94
+
95
+ function ModelStage({
96
+ provider,
97
+ config,
98
+ onSelect,
99
+ onBack,
100
+ onCancel,
101
+ }: {
102
+ provider: ProviderName;
103
+ config: Config;
104
+ onSelect: (id: string) => void;
105
+ onBack?: () => void;
106
+ onCancel?: () => void;
107
+ }) {
108
+ const [models, setModels] = useState<ModelInfo[] | null>(null);
109
+ const [error, setError] = useState<string | null>(null);
110
+ const [filter, setFilter] = useState("");
111
+ const [cursor, setCursor] = useState(0);
112
+ // OpenRouter only: the account's ZDR snapshot, used to color a per-model badge.
113
+ const zdrAccount = useZdrAccount(provider, config);
114
+ const zdrEnforced = Boolean(config.providers.openrouter?.enforceZdr);
115
+ // Privateer: every model carries a privacy channel (TEE or ZDR) derived from its
116
+ // id, surfaced as a per-row badge so the channel is visible before you pick.
117
+ const isPrivateer = provider === "privateer";
118
+
119
+ useEffect(() => {
120
+ let alive = true;
121
+ setModels(null);
122
+ setError(null);
123
+ listModels(provider, config.providers[provider] ?? {})
124
+ .then((m) => alive && setModels(m))
125
+ .catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
126
+ return () => {
127
+ alive = false;
128
+ };
129
+ }, [provider]);
130
+
131
+ const filtered = useMemo(() => {
132
+ if (!models) return [];
133
+ const q = filter.trim().toLowerCase();
134
+ if (!q) return models;
135
+ return models.filter(
136
+ (m) => m.id.toLowerCase().includes(q) || (m.label?.toLowerCase().includes(q) ?? false),
137
+ );
138
+ }, [models, filter]);
139
+
140
+ // Keep the cursor in range as the filter narrows the list.
141
+ useEffect(() => {
142
+ setCursor((c) => Math.min(c, Math.max(0, filtered.length - 1)));
143
+ }, [filtered.length]);
144
+
145
+ useInput((_input, key) => {
146
+ if (key.escape) {
147
+ onBack ? onBack() : onCancel?.();
148
+ return;
149
+ }
150
+ if (!filtered.length) return;
151
+ if (key.upArrow) setCursor((c) => (c - 1 + filtered.length) % filtered.length);
152
+ else if (key.downArrow) setCursor((c) => (c + 1) % filtered.length);
153
+ else if (key.return) onSelect(filtered[cursor].id);
154
+ });
155
+
156
+ const label = PROVIDER_META[provider].label;
157
+
158
+ if (error !== null) {
159
+ return (
160
+ <Box flexDirection="column" paddingX={1}>
161
+ <Text color={theme.error}>Couldn't fetch {label} models: {error}</Text>
162
+ <Text color={theme.dim}>
163
+ Check the API key with /login, or set a model directly: /model {provider}:&lt;id&gt;.
164
+ {onBack ? " Esc to go back." : ""}
165
+ </Text>
166
+ </Box>
167
+ );
168
+ }
169
+
170
+ if (models === null) {
171
+ return (
172
+ <Box paddingX={1} gap={1}>
173
+ <Text color={theme.accent}>
174
+ <Spinner type="dots" />
175
+ </Text>
176
+ <Text color={theme.dim}>Fetching {label} models…</Text>
177
+ </Box>
178
+ );
179
+ }
180
+
181
+ // Window the list around the cursor so long catalogs (OpenRouter has hundreds) scroll.
182
+ const start = Math.max(0, Math.min(cursor - Math.floor(PAGE / 2), Math.max(0, filtered.length - PAGE)));
183
+ const view = filtered.slice(start, start + PAGE);
184
+
185
+ return (
186
+ <Box flexDirection="column" paddingX={1}>
187
+ <Text color={theme.dim}>
188
+ {label} — <Text color={theme.accent}>{filtered.length}</Text> models. Type to filter,{" "}
189
+ <Text color={theme.accent}>↑↓</Text> move, <Text color={theme.accent}>enter</Text> select
190
+ {onBack ? ", esc back" : onCancel ? ", esc cancel" : ""}.
191
+ </Text>
192
+ {isPrivateer ? <PrivateerLegend /> : <ZdrLegend state={zdrAccount} enforced={zdrEnforced} />}
193
+ <Box marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
194
+ <Text color={theme.accent}>{"/ "}</Text>
195
+ <TextInput value={filter} onChange={setFilter} placeholder="filter models…" />
196
+ </Box>
197
+ <Box flexDirection="column" marginTop={1}>
198
+ {filtered.length === 0 ? (
199
+ <Text color={theme.dim}>No models match "{filter}".</Text>
200
+ ) : (
201
+ view.map((m, i) => {
202
+ const idx = start + i;
203
+ const active = idx === cursor;
204
+ // Privateer: label the privacy channel (TEE/ZDR) from the model id.
205
+ // OpenRouter: color a bare shield by the account's per-model ZDR posture.
206
+ const channel = isPrivateer ? privateerChannel(m.id) : null;
207
+ const posture =
208
+ !isPrivateer && zdrAccount.kind === "ready"
209
+ ? zdrPosture(m.id, zdrAccount.account, zdrEnforced)
210
+ : null;
211
+ return (
212
+ <Text key={m.id} color={active ? theme.accent : undefined}>
213
+ {active ? "❯ " : " "}
214
+ {channel ? (
215
+ <Text color={theme.success}>{`${SHIELD} ${channel === "tee" ? "TEE" : "ZDR"} `}</Text>
216
+ ) : posture ? (
217
+ <Text color={POSTURE_COLOR[posture]}>{`${SHIELD} `}</Text>
218
+ ) : null}
219
+ {m.id}
220
+ {m.label && m.label !== m.id ? <Text color={theme.dim}> — {m.label}</Text> : null}
221
+ </Text>
222
+ );
223
+ })
224
+ )}
225
+ </Box>
226
+ </Box>
227
+ );
228
+ }
229
+
230
+ // One-line key for the per-model ⛉ badge, shown only for OpenRouter. While the
231
+ // account snapshot loads we say so; if it can't be fetched (no key / error) we stay
232
+ // silent rather than imply a verdict — the rows simply render without a badge.
233
+ // With enforcement off, ZDR-capable models read yellow; /zdr flips it on so they
234
+ // go green (and non-ZDR models become red/unusable).
235
+ // One-line key for the Privateer ⛉ TEE/ZDR badges. Both channels keep your prompts
236
+ // private — TEE runs the model in a confidential enclave (attestable via /verify),
237
+ // ZDR routes through zero-data-retention endpoints — so both render green.
238
+ function PrivateerLegend() {
239
+ return (
240
+ <Text color={theme.dim}>
241
+ <Text color={POSTURE_COLOR.green}>{`${SHIELD} TEE`}</Text> confidential enclave (attestable){" "}
242
+ <Text color={POSTURE_COLOR.green}>{`${SHIELD} ZDR`}</Text> zero data retention
243
+ </Text>
244
+ );
245
+ }
246
+
247
+ function ZdrLegend({ state, enforced }: { state: ZdrAccountState; enforced: boolean }) {
248
+ if (state.kind === "idle" || state.kind === "error") return null;
249
+ if (state.kind === "loading") {
250
+ return <Text color={theme.dim}>{`${SHIELD} ZDR — checking your account…`}</Text>;
251
+ }
252
+ return (
253
+ <Text color={theme.dim}>
254
+ {enforced ? (
255
+ <>
256
+ <Text color={POSTURE_COLOR.green}>{SHIELD}</Text> ZDR enforced{" "}
257
+ <Text color={POSTURE_COLOR.red}>{SHIELD}</Text> no ZDR endpoint — unusable
258
+ {" "}(/zdr to relax)
259
+ </>
260
+ ) : (
261
+ <>
262
+ <Text color={POSTURE_COLOR.yellow}>{SHIELD}</Text> ZDR available{" "}
263
+ <Text color={POSTURE_COLOR.red}>{SHIELD}</Text> data retained{" "}
264
+ (/zdr to enforce → green)
265
+ </>
266
+ )}
267
+ </Text>
268
+ );
269
+ }