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,95 @@
1
+ import React, { useState } from "react";
2
+ import { App } from "./App.tsx";
3
+ import { Onboarding, type OnboardingResult } from "./Onboarding.tsx";
4
+ import { PrivateerLogin } from "./PrivateerLogin.tsx";
5
+ import { PROVIDER_META } from "../providers/catalog.ts";
6
+ import type { Config, ProviderName } from "../config/schema.ts";
7
+ import { saveGlobalConfig } from "../config/load.ts";
8
+ import { configuredProviders } from "../providers/resolve.ts";
9
+ import type { PrivateerUser } from "../auth/privateer.ts";
10
+ import type { SessionData } from "../memory/store.ts";
11
+
12
+ // Top-level state machine: shows the onboarding flow (provider selection + key entry)
13
+ // when needed, otherwise the main App. Onboarding can be re-entered from the app via
14
+ // the /login command. Keeping config in state lets newly-saved keys take effect
15
+ // immediately without a restart.
16
+ export function Root({
17
+ config: initialConfig,
18
+ modelSpec: initialModel,
19
+ cwd,
20
+ resume,
21
+ startInOnboarding,
22
+ }: {
23
+ config: Config;
24
+ modelSpec: string;
25
+ cwd: string;
26
+ resume?: SessionData | null;
27
+ startInOnboarding: boolean;
28
+ }) {
29
+ const [config, setConfig] = useState<Config>(initialConfig);
30
+ const [modelSpec, setModelSpec] = useState(initialModel);
31
+ const [onboarding, setOnboarding] = useState(startInOnboarding);
32
+ const [loggingIn, setLoggingIn] = useState(false);
33
+
34
+ // Providers that already have credentials — pre-checked when re-running onboarding.
35
+ const configured = configuredProviders(config)
36
+ .filter((p) => p.ready)
37
+ .map((p) => p.name as ProviderName);
38
+
39
+ function finish(result: OnboardingResult) {
40
+ const next: Config = {
41
+ ...config,
42
+ defaultModel: result.defaultModel,
43
+ providers: { ...config.providers, ...result.providers },
44
+ };
45
+ try {
46
+ saveGlobalConfig(next);
47
+ } catch {
48
+ /* non-fatal: keys just won't persist to disk this run */
49
+ }
50
+ setConfig(next);
51
+ setModelSpec(result.defaultModel);
52
+ setOnboarding(false);
53
+ }
54
+
55
+ // After a successful account login, switch to the Privateer-billed model so the
56
+ // session immediately uses the account (the provider is now "ready" because
57
+ // credentials exist on disk). Add a providers.privateer entry so the App's
58
+ // remount key changes and the new model resolves.
59
+ function finishLogin(_user: PrivateerUser) {
60
+ const spec = PROVIDER_META.privateer.defaultModel;
61
+ const next: Config = {
62
+ ...config,
63
+ defaultModel: spec,
64
+ providers: { ...config.providers, privateer: config.providers.privateer ?? {} },
65
+ };
66
+ try {
67
+ saveGlobalConfig(next);
68
+ } catch {
69
+ /* non-fatal: model choice just won't persist this run */
70
+ }
71
+ setConfig(next);
72
+ setModelSpec(spec);
73
+ setLoggingIn(false);
74
+ }
75
+
76
+ if (loggingIn) {
77
+ return <PrivateerLogin onComplete={finishLogin} onCancel={() => setLoggingIn(false)} />;
78
+ }
79
+
80
+ if (onboarding) {
81
+ return <Onboarding initialSelected={configured} onComplete={finish} />;
82
+ }
83
+
84
+ return (
85
+ <App
86
+ key={modelSpec + Object.keys(config.providers).join(",")}
87
+ model={modelSpec}
88
+ config={config}
89
+ cwd={cwd}
90
+ resume={resume}
91
+ onLogin={() => setOnboarding(true)}
92
+ onPrivateerLogin={() => setLoggingIn(true)}
93
+ />
94
+ );
95
+ }
@@ -0,0 +1,64 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { theme } from "./theme.ts";
4
+ import { POINTER } from "./figures.ts";
5
+ import type { SessionMeta } from "../memory/store.ts";
6
+
7
+ function ago(iso: string): string {
8
+ const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
9
+ if (s < 60) return `${s}s ago`;
10
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
11
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
12
+ return `${Math.round(s / 86400)}d ago`;
13
+ }
14
+
15
+ // Lists past sessions (newest first). Move with ↑/↓ (or j/k); Enter resumes the
16
+ // selected session, Esc cancels.
17
+ export function SessionPicker({
18
+ sessions,
19
+ onResume,
20
+ onCancel,
21
+ }: {
22
+ sessions: SessionMeta[];
23
+ onResume: (id: string) => void;
24
+ onCancel: () => void;
25
+ }) {
26
+ const [sel, setSel] = useState(0);
27
+
28
+ useInput((input, key) => {
29
+ if (key.escape) return void onCancel();
30
+ if (sessions.length === 0) return;
31
+ if (key.upArrow || input === "k") return void setSel((s) => (s - 1 + sessions.length) % sessions.length);
32
+ if (key.downArrow || input === "j") return void setSel((s) => (s + 1) % sessions.length);
33
+ if (key.return) return void onResume(sessions[Math.min(sel, sessions.length - 1)].id);
34
+ });
35
+
36
+ if (sessions.length === 0) {
37
+ return (
38
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={1}>
39
+ <Text color={theme.dim}>No saved sessions yet for this project. Esc to close.</Text>
40
+ </Box>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={1}>
46
+ <Text color={theme.accent}>Resume a session</Text>
47
+ {sessions.map((s, i) => {
48
+ const active = i === Math.min(sel, sessions.length - 1);
49
+ return (
50
+ <Box key={s.id} gap={1}>
51
+ <Text color={active ? theme.accent : theme.dim}>{active ? POINTER : " "}</Text>
52
+ <Text color={active ? theme.accent : undefined}>{s.preview}</Text>
53
+ <Text color={theme.dim}>
54
+ ({ago(s.updatedAt)}, {s.messageCount} msg{s.messageCount === 1 ? "" : "s"})
55
+ </Text>
56
+ </Box>
57
+ );
58
+ })}
59
+ <Text color={theme.dim}>
60
+ <Text color={theme.accent}>↑↓</Text> move · <Text color={theme.accent}>enter</Text> resume · esc cancel
61
+ </Text>
62
+ </Box>
63
+ );
64
+ }
@@ -0,0 +1,121 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { basename } from "node:path";
4
+ import { theme, POSTURE_COLOR } from "./theme.ts";
5
+ import { SHIELD } from "./figures.ts";
6
+ import { useTerminalWidth } from "./useTerminalWidth.ts";
7
+ import { type UsageTotals } from "../engine/events.ts";
8
+ import type { ZdrState } from "./useZdrShield.ts";
9
+ import type { TeeState } from "./useTeeShield.ts";
10
+
11
+ // OpenRouter ZDR shield: a colored "⛉ ZDR" segment summarizing the selected model's
12
+ // zero-data-retention posture. Dim "⛉ ZDR?" while loading or when the posture is
13
+ // unknown (no key / fetch error); nothing at all for non-OpenRouter models.
14
+ function ZdrBadge({ zdr }: { zdr?: ZdrState }) {
15
+ if (!zdr || zdr.kind === "hidden") return null;
16
+ if (zdr.kind === "ready") {
17
+ return <Text color={POSTURE_COLOR[zdr.posture]}>{`${SHIELD} ZDR · `}</Text>;
18
+ }
19
+ return <Text color={theme.dim}>{`${SHIELD} ZDR? · `}</Text>;
20
+ }
21
+
22
+ // NEAR AI TEE shield: a colored "⛉ TEE" segment summarizing whether the selected
23
+ // model's confidential-inference enclave attested successfully. Dim "⛉ TEE?" while
24
+ // loading or when unknown (no key / fetch error); nothing for non-NEAR models.
25
+ function TeeBadge({ tee }: { tee?: TeeState }) {
26
+ if (!tee || tee.kind === "hidden") return null;
27
+ if (tee.kind === "ready") {
28
+ return <Text color={POSTURE_COLOR[tee.posture]}>{`${SHIELD} TEE · `}</Text>;
29
+ }
30
+ return <Text color={theme.dim}>{`${SHIELD} TEE? · `}</Text>;
31
+ }
32
+
33
+ // Compact token count: 100, 1k, 1m, 1b — one decimal place above 1k, trimmed of
34
+ // trailing ".0", so 1500 → "1.5k" and 2000 → "2k".
35
+ export function formatTokens(n: number): string {
36
+ const units: [number, string][] = [
37
+ [1e9, "b"],
38
+ [1e6, "m"],
39
+ [1e3, "k"],
40
+ ];
41
+ for (const [size, suffix] of units) {
42
+ if (n >= size) return `${(n / size).toFixed(1).replace(/\.0$/, "")}${suffix}`;
43
+ }
44
+ return `${n}`;
45
+ }
46
+
47
+ // Human-readable elapsed time from milliseconds: 8200 → "8s", 83000 → "1m 23s",
48
+ // 3723000 → "1h 2m". Drops zero-valued leading units so short turns stay terse.
49
+ export function formatDuration(ms: number): string {
50
+ const total = Math.max(0, Math.round(ms / 1000));
51
+ const h = Math.floor(total / 3600);
52
+ const m = Math.floor((total % 3600) / 60);
53
+ const s = total % 60;
54
+ if (h > 0) return `${h}h ${m}m`;
55
+ if (m > 0) return `${m}m ${s}s`;
56
+ return `${s}s`;
57
+ }
58
+
59
+ // The footer line rendered directly under the prompt box. The headline is a
60
+ // Claude-Code-style context-window gauge ("how full is the window right now"),
61
+ // not the cumulative billed total — a one-word message barely moves it. The gauge
62
+ // sits in a leading bracket so it survives right-edge truncation; model · cwd
63
+ // follow and clip first. Cost accounting (cache hits, last-turn / session totals)
64
+ // is intentionally not here — it lives in `/context`. The active permission mode
65
+ // is shown separately by <ModeHint>.
66
+ //
67
+ // Both sides truncate (never wrap) and the row is bounded a few columns short of
68
+ // the terminal so it always stays a single physical line — see useTerminalWidth.
69
+
70
+ // "84k/120k · 70%" when a budget is set, else a bare "84k ctx".
71
+ function formatContext(ctx?: { used: number; budget: number }): string {
72
+ if (!ctx) return "";
73
+ if (ctx.budget > 0) {
74
+ const pct = Math.round((ctx.used / ctx.budget) * 100);
75
+ return `${formatTokens(ctx.used)}/${formatTokens(ctx.budget)} · ${pct}%`;
76
+ }
77
+ return `${formatTokens(ctx.used)} ctx`;
78
+ }
79
+
80
+ export function StatusBar(props: {
81
+ modelSpec: string;
82
+ cwd: string;
83
+ usage: UsageTotals;
84
+ context?: { used: number; budget: number };
85
+ lastTurn?: UsageTotals;
86
+ custom?: string; // settings-driven status line; overrides the default when set
87
+ zdr?: ZdrState; // OpenRouter ZDR posture for the selected model (default line only)
88
+ tee?: TeeState; // NEAR AI TEE attestation posture for the selected model (default line only)
89
+ }) {
90
+ // Stay clear of the right edge (parent paddingX={1} plus a 2-col safety gap) so
91
+ // the line never reaches the final column and the terminal never reflows it.
92
+ const width = Math.max(20, useTerminalWidth() - 4);
93
+ if (props.custom) {
94
+ return (
95
+ <Box marginTop={1} width={width}>
96
+ <Text color={theme.dim} wrap="truncate-end">
97
+ {props.custom}
98
+ </Text>
99
+ </Box>
100
+ );
101
+ }
102
+ // The bar carries only the context-window gauge ("how full is the window right
103
+ // now") plus model · cwd. The cumulative cost accounting — cache hits, last-turn
104
+ // and session billed totals — lives in `/context`, so the always-on line stays
105
+ // quiet and the live output count belongs to the spinner. See effectiveTokens.
106
+ const diag = formatContext(props.context);
107
+ return (
108
+ <Box marginTop={1} width={width}>
109
+ <Text wrap="truncate-end">
110
+ <ZdrBadge zdr={props.zdr} />
111
+ <TeeBadge tee={props.tee} />
112
+ <Text color={theme.accent}>⚓ privateer</Text>
113
+ {diag ? <Text color={theme.dim}>{` [${diag}]`}</Text> : null}
114
+ <Text color={theme.dim}> (shift+tab to cycle)</Text>
115
+ <Text color={theme.dim}>
116
+ {` · ${props.modelSpec} · ${basename(props.cwd) || props.cwd}`}
117
+ </Text>
118
+ </Text>
119
+ </Box>
120
+ );
121
+ }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { TodoItem } from "../tools/todoStore.ts";
4
+ import { theme } from "./theme.ts";
5
+
6
+ const MARK: Record<TodoItem["status"], string> = {
7
+ completed: "✔",
8
+ in_progress: "▸",
9
+ pending: "○",
10
+ };
11
+
12
+ // The live task panel, rendered above the status bar: completed items
13
+ // dimmed/struck, the in-progress item highlighted. Hidden when there are no todos.
14
+ export function TodoPanel({ todos }: { todos: TodoItem[] }) {
15
+ if (todos.length === 0) return null;
16
+ const done = todos.filter((t) => t.status === "completed").length;
17
+
18
+ return (
19
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.dim} paddingX={1}>
20
+ <Text dimColor>
21
+ Tasks {done}/{todos.length}
22
+ </Text>
23
+ {todos.map((t, i) => {
24
+ const label = t.status === "in_progress" ? t.activeForm ?? t.content : t.content;
25
+ const color =
26
+ t.status === "in_progress" ? theme.accent : t.status === "completed" ? theme.success : undefined;
27
+ return (
28
+ <Text key={i} color={color} dimColor={t.status === "pending"}>
29
+ {MARK[t.status]}{" "}
30
+ <Text strikethrough={t.status === "completed"}>{label}</Text>
31
+ </Text>
32
+ );
33
+ })}
34
+ </Box>
35
+ );
36
+ }
@@ -0,0 +1,109 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import type { ToolEntry } from "./types.ts";
5
+ import { theme, toolDisplayName } from "./theme.ts";
6
+ import { BULLET, TREE } from "./figures.ts";
7
+
8
+ // A one-line summary of a tool's input, picking the most relevant field per tool.
9
+ function summarizeInput(name: string, input: unknown): string {
10
+ const o = (input ?? {}) as Record<string, unknown>;
11
+ switch (name) {
12
+ case "read":
13
+ case "write":
14
+ case "edit":
15
+ return String(o.path ?? "");
16
+ case "bash":
17
+ return String(o.command ?? "");
18
+ case "glob":
19
+ return String(o.pattern ?? "");
20
+ case "grep":
21
+ return String(o.pattern ?? "") + (o.glob ? ` (${o.glob})` : "");
22
+ case "task":
23
+ return String(o.description ?? o.subagent_type ?? "");
24
+ default:
25
+ try {
26
+ return JSON.stringify(o);
27
+ } catch {
28
+ return "";
29
+ }
30
+ }
31
+ }
32
+
33
+ // Show the first few lines of tool output so the transcript stays compact.
34
+ function previewOutput(text: string, maxLines = 6): { lines: string[]; more: number } {
35
+ const all = text.replace(/\n+$/, "").split("\n");
36
+ return { lines: all.slice(0, maxLines), more: Math.max(0, all.length - maxLines) };
37
+ }
38
+
39
+ // Red/green line diff rendered from an edit tool's input (no engine data needed).
40
+ function EditDiff({ input }: { input: unknown }) {
41
+ const o = (input ?? {}) as Record<string, unknown>;
42
+ const removed = String(o.old_string ?? "").split("\n");
43
+ const added = String(o.new_string ?? "").split("\n");
44
+ return (
45
+ <Box flexDirection="column" marginLeft={2}>
46
+ <Text color={theme.dim}>{TREE} </Text>
47
+ {removed.map((l, i) => (
48
+ <Text key={`r${i}`} color={theme.diffRemoved}>
49
+ {" - "}
50
+ {l}
51
+ </Text>
52
+ ))}
53
+ {added.map((l, i) => (
54
+ <Text key={`a${i}`} color={theme.diffAdded}>
55
+ {" + "}
56
+ {l}
57
+ </Text>
58
+ ))}
59
+ </Box>
60
+ );
61
+ }
62
+
63
+ export function ToolCallView({ entry, verbose }: { entry: ToolEntry; verbose?: boolean }) {
64
+ const summary = summarizeInput(entry.name, entry.input);
65
+ const body = entry.status === "error" ? entry.error ?? "" : entry.output ?? "";
66
+ const { lines, more } = previewOutput(body, verbose ? Number.MAX_SAFE_INTEGER : 6);
67
+ const isEditDiff = entry.name === "edit" && entry.status !== "error";
68
+
69
+ return (
70
+ <Box flexDirection="column" marginTop={1}>
71
+ <Box gap={1}>
72
+ {entry.status === "running" ? (
73
+ <Text color={theme.accent}>
74
+ <Spinner type="dots" />
75
+ </Text>
76
+ ) : (
77
+ <Text color={entry.status === "error" ? theme.error : theme.accent}>{BULLET}</Text>
78
+ )}
79
+ <Text>
80
+ <Text bold color={theme.accent}>
81
+ {toolDisplayName(entry.name)}
82
+ </Text>
83
+ <Text color={theme.dim}>({summary})</Text>
84
+ </Text>
85
+ </Box>
86
+
87
+ {entry.status !== "running" &&
88
+ (isEditDiff ? (
89
+ <EditDiff input={entry.input} />
90
+ ) : (
91
+ body.trim() !== "" && (
92
+ <Box flexDirection="column" marginLeft={2}>
93
+ {lines.map((l, i) => (
94
+ <Text
95
+ key={i}
96
+ color={entry.status === "error" ? theme.error : theme.dim}
97
+ dimColor={entry.status !== "error"}
98
+ >
99
+ {i === 0 ? `${TREE} ` : " "}
100
+ {l}
101
+ </Text>
102
+ ))}
103
+ {more > 0 && <Text color={theme.dim}>{" "}… (+{more} more lines)</Text>}
104
+ </Box>
105
+ )
106
+ ))}
107
+ </Box>
108
+ );
109
+ }
@@ -0,0 +1,203 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { Entry, Row, ToolEntry } from "./types.ts";
4
+ import { ToolCallView } from "./ToolCallView.tsx";
5
+ import { AgentGroupView } from "./AgentGroupView.tsx";
6
+ import { theme } from "./theme.ts";
7
+ import { Markdown } from "./Markdown.tsx";
8
+ import { BULLET, WELCOME } from "./figures.ts";
9
+ import { useTerminalWidth } from "./useTerminalWidth.ts";
10
+
11
+ // Width of the marker gutter ("⏺ ", "✻ ", or two-space indent) that sits left of a
12
+ // wrapped body. Subtracting it from the terminal width gives the body a *definite*
13
+ // column count, which is what keeps wrapping on word boundaries: a flex body with no
14
+ // fixed width lets Yoga measure the text's intrinsic size and then shrink the box
15
+ // below a word's length, at which point Ink falls back to breaking mid-word.
16
+ const GUTTER = 2;
17
+
18
+ // Visual height of `text` once wrapped to `cols` columns — `\n`-lines plus the
19
+ // extra rows each long line wraps onto.
20
+ export function visualRows(text: string, cols: number): number {
21
+ let rows = 0;
22
+ for (const ln of text.split("\n")) rows += Math.max(1, Math.ceil(ln.length / Math.max(1, cols)));
23
+ return rows;
24
+ }
25
+
26
+ // Trim a still-streaming block to roughly its last `maxRows` wrapped rows so the live
27
+ // region stays within the terminal. The hidden prefix isn't lost: the full text is
28
+ // what gets committed to <Static> when the turn ends, so it lands intact in
29
+ // scrollback. Returns the text unchanged when it already fits.
30
+ export function clampStreamingText(text: string, maxRows: number, cols: number): string {
31
+ const lines = text.split("\n");
32
+ const width = Math.max(1, cols);
33
+ let rows = 0;
34
+ let i = lines.length;
35
+ while (i > 0) {
36
+ const r = Math.max(1, Math.ceil(lines[i - 1].length / width));
37
+ if (rows + r > maxRows - 1) break; // reserve one row for the "…hidden" marker
38
+ rows += r;
39
+ i--;
40
+ }
41
+ if (i === 0) return text;
42
+ return `⋮ ${i} earlier line${i === 1 ? "" : "s"} hidden — shown in full when complete\n${lines.slice(i).join("\n")}`;
43
+ }
44
+
45
+ // Collapse runs of two-or-more consecutive `task` tool entries (sub-agents the model
46
+ // fanned out in one turn) into a single grouped row, leaving everything else as-is. A
47
+ // lone task stays a normal tool call. Grouping is purely a render concern, so it runs
48
+ // over the entry list at paint time rather than mutating the transcript.
49
+ export function groupRows(entries: Entry[]): Row[] {
50
+ const rows: Row[] = [];
51
+ let run: ToolEntry[] = [];
52
+ const flush = () => {
53
+ if (run.length >= 2) rows.push({ kind: "agent-group", agents: run });
54
+ else rows.push(...run);
55
+ run = [];
56
+ };
57
+ for (const e of entries) {
58
+ if (e.kind === "tool" && e.name === "task") run.push(e);
59
+ else {
60
+ flush();
61
+ rows.push(e);
62
+ }
63
+ }
64
+ flush();
65
+ return rows;
66
+ }
67
+
68
+ // Pull a trailing `recap: …` line off an assistant message so it can be styled
69
+ // separately. Only the last line is considered, and only if it starts with the
70
+ // marker; otherwise the whole text is the body and there's no recap.
71
+ function splitRecap(text: string): { body: string; recap?: string } {
72
+ const trimmed = text.replace(/\s+$/, "");
73
+ const nl = trimmed.lastIndexOf("\n");
74
+ const lastLine = trimmed.slice(nl + 1);
75
+ if (/^recap:\s*/i.test(lastLine)) {
76
+ return { body: trimmed.slice(0, nl < 0 ? 0 : nl).replace(/\s+$/, ""), recap: lastLine };
77
+ }
78
+ return { body: text };
79
+ }
80
+
81
+ export function EntryView({
82
+ entry,
83
+ verbose,
84
+ collapsed,
85
+ }: {
86
+ entry: Entry;
87
+ verbose?: boolean;
88
+ collapsed?: boolean;
89
+ }) {
90
+ const cols = useTerminalWidth();
91
+ // Definite body width so wrapped text breaks on word boundaries (see GUTTER).
92
+ const bodyWidth = Math.max(20, cols - GUTTER);
93
+ switch (entry.kind) {
94
+ case "user":
95
+ return (
96
+ <Box marginTop={1}>
97
+ <Text color={theme.dim}>{"> "}</Text>
98
+ <Text color={theme.dim}>{entry.text}</Text>
99
+ </Box>
100
+ );
101
+ case "assistant": {
102
+ // Split off a trailing `recap:` line so it can render dimmed below the
103
+ // response body. The model is asked to end each turn with one such line.
104
+ const { body, recap } = splitRecap(entry.text);
105
+ // ⏺ bullet in its own column so wrapped lines align under the text.
106
+ return (
107
+ <Box marginTop={1} flexDirection="column">
108
+ <Box>
109
+ <Text color={theme.accent}>{BULLET} </Text>
110
+ <Box width={bodyWidth}>
111
+ <Markdown text={body} />
112
+ </Box>
113
+ </Box>
114
+ {recap && (
115
+ <Box marginTop={1}>
116
+ <Text color={theme.dim}>{" "}</Text>
117
+ <Box width={bodyWidth}>
118
+ <Text color="white">
119
+ {recap}
120
+ </Text>
121
+ </Box>
122
+ </Box>
123
+ )}
124
+ </Box>
125
+ );
126
+ }
127
+ case "thinking": {
128
+ // The model's reasoning, rendered dimmed under a thinking mark. When
129
+ // collapsed (Ctrl+O), show just a one-line summary instead of the full text.
130
+ if (collapsed) {
131
+ const lineCount = entry.text.trim() === "" ? 0 : entry.text.trim().split("\n").length;
132
+ return (
133
+ <Box marginTop={1}>
134
+ <Text color={theme.dim} dimColor>
135
+ {WELCOME} Thinking{lineCount ? ` (${lineCount} lines)` : ""} — Ctrl+O to expand
136
+ </Text>
137
+ </Box>
138
+ );
139
+ }
140
+ return (
141
+ <Box marginTop={1}>
142
+ <Text color={theme.dim}>{WELCOME} </Text>
143
+ <Box width={bodyWidth}>
144
+ <Text color={theme.dim} dimColor>
145
+ {entry.text}
146
+ </Text>
147
+ </Box>
148
+ </Box>
149
+ );
150
+ }
151
+ case "tool":
152
+ return <ToolCallView entry={entry} verbose={verbose} />;
153
+ case "notice":
154
+ return (
155
+ <Box marginTop={1} flexDirection="column">
156
+ {entry.text.split("\n").map((l, i) => (
157
+ <Text
158
+ key={i}
159
+ color={entry.tone === "error" ? theme.error : theme.dim}
160
+ dimColor={entry.tone !== "error"}
161
+ >
162
+ {l}
163
+ </Text>
164
+ ))}
165
+ {entry.hint &&
166
+ entry.hint.split("\n").map((l, i) => (
167
+ <Text key={`hint-${i}`} color={theme.dim} dimColor>
168
+ {l}
169
+ </Text>
170
+ ))}
171
+ </Box>
172
+ );
173
+ }
174
+ }
175
+
176
+ // Render one grouped row: a fanned-out agent block, or any other single entry.
177
+ export function RowView({
178
+ row,
179
+ verbose,
180
+ collapsed,
181
+ }: {
182
+ row: Row;
183
+ verbose?: boolean;
184
+ collapsed?: boolean;
185
+ }) {
186
+ return row.kind === "agent-group" ? (
187
+ <AgentGroupView agents={row.agents} collapsed={collapsed} />
188
+ ) : (
189
+ <EntryView entry={row} verbose={verbose} collapsed={collapsed} />
190
+ );
191
+ }
192
+
193
+ // Render a list of finalized entries (used inside Ink's <Static> for the committed
194
+ // transcript) — kept as a plain map so the same RowView powers live rendering too.
195
+ export function Transcript({ entries }: { entries: Entry[] }) {
196
+ return (
197
+ <Box flexDirection="column">
198
+ {groupRows(entries).map((row, i) => (
199
+ <RowView key={i} row={row} />
200
+ ))}
201
+ </Box>
202
+ );
203
+ }
@@ -0,0 +1,13 @@
1
+ // Unicode figures used as markers and status glyphs across the TUI.
2
+ export const BULLET = "⏺"; // U+23FA — prefixes assistant messages and tool calls
3
+ export const TREE = "⎿"; // U+23BF — connects a tool's result under its call
4
+ export const BRANCH = "├"; // tee — a non-final child in the grouped agents tree
5
+ export const CORNER = "└"; // elbow — the final child in a tree
6
+ export const VLINE = "│"; // vertical — continues the trunk past a non-final child
7
+ export const WELCOME = "✻"; // U+273B — teardrop-asterisk welcome mark
8
+ export const EFFORT = { low: "○", medium: "◐", high: "●", max: "◉" } as const;
9
+ export const POINTER = "❯"; // selection pointer in menus and the prompt caret
10
+ export const FAST_FORWARD = "⏵⏵"; // marks an "on" permission mode below the prompt
11
+ export const PAUSE = "⏸"; // marks plan mode (paused execution) below the prompt
12
+ export const SHIELD = "⛉"; // U+26C9 — OpenRouter ZDR posture marker in the status bar
13
+ export const DOWN = "↓"; // U+2193 — output tokens streaming down from the model (spinner)