privateer-agent 0.2.1 → 0.3.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 (111) hide show
  1. package/README.md +135 -443
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -1,116 +0,0 @@
1
- import React, { useState } from "react";
2
- import { useStdout } from "ink";
3
- import { App } from "./App.tsx";
4
- import { Onboarding, type OnboardingResult } from "./Onboarding.tsx";
5
- import { PrivateerLogin } from "./PrivateerLogin.tsx";
6
- import { PROVIDER_META } from "../providers/catalog.ts";
7
- import type { Config, ProviderName } from "../config/schema.ts";
8
- import { saveGlobalConfig } from "../config/load.ts";
9
- import { configuredProviders } from "../providers/resolve.ts";
10
- import type { PrivateerUser } from "../auth/privateer.ts";
11
- import type { SessionData } from "../memory/store.ts";
12
-
13
- // Top-level state machine: shows the onboarding flow (provider selection + key entry)
14
- // when needed, otherwise the main App. Onboarding can be re-entered from the app via
15
- // the /login command. Keeping config in state lets newly-saved keys take effect
16
- // immediately without a restart.
17
- export function Root({
18
- config: initialConfig,
19
- modelSpec: initialModel,
20
- cwd,
21
- resume,
22
- startInOnboarding,
23
- }: {
24
- config: Config;
25
- modelSpec: string;
26
- cwd: string;
27
- resume?: SessionData | null;
28
- startInOnboarding: boolean;
29
- }) {
30
- const [config, setConfig] = useState<Config>(initialConfig);
31
- const [modelSpec, setModelSpec] = useState(initialModel);
32
- const [onboarding, setOnboarding] = useState(startInOnboarding);
33
- const [loggingIn, setLoggingIn] = useState(false);
34
- const { stdout } = useStdout();
35
-
36
- // The App's banner/transcript lives in Ink's <Static> region, which stays in the
37
- // terminal scrollback even after the component unmounts. Swapping screens (login,
38
- // onboarding) and back therefore stacks a second banner under the stale one. Wipe
39
- // screen + scrollback around every top-level swap so each view starts on a clean
40
- // page — same trick App uses when the terminal is resized.
41
- function swapScreen(update: () => void) {
42
- stdout?.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback, home cursor
43
- update();
44
- }
45
-
46
- // Providers that already have credentials — pre-checked when re-running onboarding.
47
- const configured = configuredProviders(config)
48
- .filter((p) => p.ready)
49
- .map((p) => p.name as ProviderName);
50
-
51
- function finish(result: OnboardingResult) {
52
- const next: Config = {
53
- ...config,
54
- defaultModel: result.defaultModel,
55
- providers: { ...config.providers, ...result.providers },
56
- };
57
- try {
58
- saveGlobalConfig(next);
59
- } catch {
60
- /* non-fatal: keys just won't persist to disk this run */
61
- }
62
- swapScreen(() => {
63
- setConfig(next);
64
- setModelSpec(result.defaultModel);
65
- setOnboarding(false);
66
- });
67
- }
68
-
69
- // After a successful account login, switch to the Privateer-billed model so the
70
- // session immediately uses the account (the provider is now "ready" because
71
- // credentials exist on disk). Add a providers.privateer entry so the App's
72
- // remount key changes and the new model resolves.
73
- function finishLogin(_user: PrivateerUser) {
74
- const spec = PROVIDER_META.privateer.defaultModel;
75
- const next: Config = {
76
- ...config,
77
- defaultModel: spec,
78
- providers: { ...config.providers, privateer: config.providers.privateer ?? {} },
79
- };
80
- try {
81
- saveGlobalConfig(next);
82
- } catch {
83
- /* non-fatal: model choice just won't persist this run */
84
- }
85
- swapScreen(() => {
86
- setConfig(next);
87
- setModelSpec(spec);
88
- setLoggingIn(false);
89
- });
90
- }
91
-
92
- if (loggingIn) {
93
- return (
94
- <PrivateerLogin
95
- onComplete={finishLogin}
96
- onCancel={() => swapScreen(() => setLoggingIn(false))}
97
- />
98
- );
99
- }
100
-
101
- if (onboarding) {
102
- return <Onboarding initialSelected={configured} onComplete={finish} />;
103
- }
104
-
105
- return (
106
- <App
107
- key={modelSpec + Object.keys(config.providers).join(",")}
108
- model={modelSpec}
109
- config={config}
110
- cwd={cwd}
111
- resume={resume}
112
- onLogin={() => swapScreen(() => setOnboarding(true))}
113
- onPrivateerLogin={() => swapScreen(() => setLoggingIn(true))}
114
- />
115
- );
116
- }
@@ -1,64 +0,0 @@
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
- }
@@ -1,131 +0,0 @@
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, DOT } 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
- // Remote-access badge: a green "● remote" segment shown while /remote-access is on,
34
- // signalling the Privateer app can drive this terminal (send prompts / approve its
35
- // tool calls). Nothing at all when remote access is off.
36
- function RemoteBadge({ remote }: { remote?: boolean }) {
37
- if (!remote) return null;
38
- return <Text color={theme.success}>{`${DOT} remote · `}</Text>;
39
- }
40
-
41
- // Compact token count: 100, 1k, 1m, 1b — one decimal place above 1k, trimmed of
42
- // trailing ".0", so 1500 → "1.5k" and 2000 → "2k".
43
- export function formatTokens(n: number): string {
44
- const units: [number, string][] = [
45
- [1e9, "b"],
46
- [1e6, "m"],
47
- [1e3, "k"],
48
- ];
49
- for (const [size, suffix] of units) {
50
- if (n >= size) return `${(n / size).toFixed(1).replace(/\.0$/, "")}${suffix}`;
51
- }
52
- return `${n}`;
53
- }
54
-
55
- // Human-readable elapsed time from milliseconds: 8200 → "8s", 83000 → "1m 23s",
56
- // 3723000 → "1h 2m". Drops zero-valued leading units so short turns stay terse.
57
- export function formatDuration(ms: number): string {
58
- const total = Math.max(0, Math.round(ms / 1000));
59
- const h = Math.floor(total / 3600);
60
- const m = Math.floor((total % 3600) / 60);
61
- const s = total % 60;
62
- if (h > 0) return `${h}h ${m}m`;
63
- if (m > 0) return `${m}m ${s}s`;
64
- return `${s}s`;
65
- }
66
-
67
- // The footer line rendered directly under the prompt box. The headline is a
68
- // Claude-Code-style context-window gauge ("how full is the window right now"),
69
- // not the cumulative billed total — a one-word message barely moves it. The gauge
70
- // sits in a leading bracket so it survives right-edge truncation; model · cwd
71
- // follow and clip first. Cost accounting (cache hits, last-turn / session totals)
72
- // is intentionally not here — it lives in `/context`. The active permission mode
73
- // is shown separately by <ModeHint>.
74
- //
75
- // Both sides truncate (never wrap) and the row is bounded a few columns short of
76
- // the terminal so it always stays a single physical line — see useTerminalWidth.
77
-
78
- // "84k/120k · 70%" when a budget is set, else a bare "84k ctx".
79
- function formatContext(ctx?: { used: number; budget: number }): string {
80
- if (!ctx) return "";
81
- if (ctx.budget > 0) {
82
- const pct = Math.round((ctx.used / ctx.budget) * 100);
83
- return `${formatTokens(ctx.used)}/${formatTokens(ctx.budget)} · ${pct}%`;
84
- }
85
- return `${formatTokens(ctx.used)} ctx`;
86
- }
87
-
88
- export function StatusBar(props: {
89
- modelSpec: string;
90
- cwd: string;
91
- usage: UsageTotals;
92
- context?: { used: number; budget: number };
93
- lastTurn?: UsageTotals;
94
- custom?: string; // settings-driven status line; overrides the default when set
95
- zdr?: ZdrState; // OpenRouter ZDR posture for the selected model (default line only)
96
- tee?: TeeState; // NEAR AI TEE attestation posture for the selected model (default line only)
97
- remote?: boolean; // /remote-access is on — the app can drive this terminal (default line only)
98
- }) {
99
- // Stay clear of the right edge (parent paddingX={1} plus a 2-col safety gap) so
100
- // the line never reaches the final column and the terminal never reflows it.
101
- const width = Math.max(20, useTerminalWidth() - 4);
102
- if (props.custom) {
103
- return (
104
- <Box marginTop={1} width={width}>
105
- <Text color={theme.dim} wrap="truncate-end">
106
- {props.custom}
107
- </Text>
108
- </Box>
109
- );
110
- }
111
- // The bar carries only the context-window gauge ("how full is the window right
112
- // now") plus model · cwd. The cumulative cost accounting — cache hits, last-turn
113
- // and session billed totals — lives in `/context`, so the always-on line stays
114
- // quiet and the live output count belongs to the spinner. See effectiveTokens.
115
- const diag = formatContext(props.context);
116
- return (
117
- <Box marginTop={1} width={width}>
118
- <Text wrap="truncate-end">
119
- <ZdrBadge zdr={props.zdr} />
120
- <TeeBadge tee={props.tee} />
121
- <RemoteBadge remote={props.remote} />
122
- <Text color={theme.accent}>⚓ privateer</Text>
123
- {diag ? <Text color={theme.dim}>{` [${diag}]`}</Text> : null}
124
- <Text color={theme.dim}> (shift+tab to cycle)</Text>
125
- <Text color={theme.dim}>
126
- {` · ${props.modelSpec} · ${basename(props.cwd) || props.cwd}`}
127
- </Text>
128
- </Text>
129
- </Box>
130
- );
131
- }
@@ -1,36 +0,0 @@
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
- }
@@ -1,113 +0,0 @@
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
- case "ask_user":
25
- return String(o.question ?? "");
26
- case "worktree":
27
- return String(o.action ?? "") + (o.name ? ` ${o.name}` : "");
28
- default:
29
- try {
30
- return JSON.stringify(o);
31
- } catch {
32
- return "";
33
- }
34
- }
35
- }
36
-
37
- // Show the first few lines of tool output so the transcript stays compact.
38
- function previewOutput(text: string, maxLines = 6): { lines: string[]; more: number } {
39
- const all = text.replace(/\n+$/, "").split("\n");
40
- return { lines: all.slice(0, maxLines), more: Math.max(0, all.length - maxLines) };
41
- }
42
-
43
- // Red/green line diff rendered from an edit tool's input (no engine data needed).
44
- function EditDiff({ input }: { input: unknown }) {
45
- const o = (input ?? {}) as Record<string, unknown>;
46
- const removed = String(o.old_string ?? "").split("\n");
47
- const added = String(o.new_string ?? "").split("\n");
48
- return (
49
- <Box flexDirection="column" marginLeft={2}>
50
- <Text color={theme.dim}>{TREE} </Text>
51
- {removed.map((l, i) => (
52
- <Text key={`r${i}`} color={theme.diffRemoved}>
53
- {" - "}
54
- {l}
55
- </Text>
56
- ))}
57
- {added.map((l, i) => (
58
- <Text key={`a${i}`} color={theme.diffAdded}>
59
- {" + "}
60
- {l}
61
- </Text>
62
- ))}
63
- </Box>
64
- );
65
- }
66
-
67
- export function ToolCallView({ entry, verbose }: { entry: ToolEntry; verbose?: boolean }) {
68
- const summary = summarizeInput(entry.name, entry.input);
69
- const body = entry.status === "error" ? entry.error ?? "" : entry.output ?? "";
70
- const { lines, more } = previewOutput(body, verbose ? Number.MAX_SAFE_INTEGER : 6);
71
- const isEditDiff = entry.name === "edit" && entry.status !== "error";
72
-
73
- return (
74
- <Box flexDirection="column" marginTop={1}>
75
- <Box gap={1}>
76
- {entry.status === "running" ? (
77
- <Text color={theme.accent}>
78
- <Spinner type="dots" />
79
- </Text>
80
- ) : (
81
- <Text color={entry.status === "error" ? theme.error : theme.accent}>{BULLET}</Text>
82
- )}
83
- <Text>
84
- <Text bold color={theme.accent}>
85
- {toolDisplayName(entry.name)}
86
- </Text>
87
- <Text color={theme.dim}>({summary})</Text>
88
- </Text>
89
- </Box>
90
-
91
- {entry.status !== "running" &&
92
- (isEditDiff ? (
93
- <EditDiff input={entry.input} />
94
- ) : (
95
- body.trim() !== "" && (
96
- <Box flexDirection="column" marginLeft={2}>
97
- {lines.map((l, i) => (
98
- <Text
99
- key={i}
100
- color={entry.status === "error" ? theme.error : theme.dim}
101
- dimColor={entry.status !== "error"}
102
- >
103
- {i === 0 ? `${TREE} ` : " "}
104
- {l}
105
- </Text>
106
- ))}
107
- {more > 0 && <Text color={theme.dim}>{" "}… (+{more} more lines)</Text>}
108
- </Box>
109
- )
110
- ))}
111
- </Box>
112
- );
113
- }
@@ -1,210 +0,0 @@
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
- const hasBody = body.trim().length > 0;
106
- // Nothing to paint: a whitespace-only entry (possible in transcripts
107
- // persisted before the turn loop filtered them) would render a bare ⏺.
108
- if (!hasBody && !recap) return null;
109
- // ⏺ bullet in its own column so wrapped lines align under the text.
110
- return (
111
- <Box marginTop={1} flexDirection="column">
112
- {hasBody && (
113
- <Box>
114
- <Text color={theme.accent}>{BULLET} </Text>
115
- <Box width={bodyWidth}>
116
- <Markdown text={body} />
117
- </Box>
118
- </Box>
119
- )}
120
- {recap && (
121
- <Box marginTop={hasBody ? 1 : 0}>
122
- <Text color={theme.dim}>{" "}</Text>
123
- <Box width={bodyWidth}>
124
- <Text color={theme.dim} dimColor italic>
125
- {recap}
126
- </Text>
127
- </Box>
128
- </Box>
129
- )}
130
- </Box>
131
- );
132
- }
133
- case "thinking": {
134
- // The model's reasoning, rendered dimmed under a thinking mark. When
135
- // collapsed (Ctrl+O), show just a one-line summary instead of the full text.
136
- if (entry.text.trim() === "") return null;
137
- if (collapsed) {
138
- const lineCount = entry.text.trim() === "" ? 0 : entry.text.trim().split("\n").length;
139
- return (
140
- <Box marginTop={1}>
141
- <Text color={theme.dim} dimColor>
142
- {WELCOME} Thinking{lineCount ? ` (${lineCount} lines)` : ""} — Ctrl+O to expand
143
- </Text>
144
- </Box>
145
- );
146
- }
147
- return (
148
- <Box marginTop={1}>
149
- <Text color={theme.dim}>{WELCOME} </Text>
150
- <Box width={bodyWidth}>
151
- <Text color={theme.dim} dimColor>
152
- {entry.text}
153
- </Text>
154
- </Box>
155
- </Box>
156
- );
157
- }
158
- case "tool":
159
- return <ToolCallView entry={entry} verbose={verbose} />;
160
- case "notice":
161
- return (
162
- <Box marginTop={1} flexDirection="column">
163
- {entry.text.split("\n").map((l, i) => (
164
- <Text
165
- key={i}
166
- color={entry.tone === "error" ? theme.error : theme.dim}
167
- dimColor={entry.tone !== "error"}
168
- >
169
- {l}
170
- </Text>
171
- ))}
172
- {entry.hint &&
173
- entry.hint.split("\n").map((l, i) => (
174
- <Text key={`hint-${i}`} color={theme.dim} dimColor>
175
- {l}
176
- </Text>
177
- ))}
178
- </Box>
179
- );
180
- }
181
- }
182
-
183
- // Render one grouped row: a fanned-out agent block, or any other single entry.
184
- export function RowView({
185
- row,
186
- verbose,
187
- collapsed,
188
- }: {
189
- row: Row;
190
- verbose?: boolean;
191
- collapsed?: boolean;
192
- }) {
193
- return row.kind === "agent-group" ? (
194
- <AgentGroupView agents={row.agents} collapsed={collapsed} />
195
- ) : (
196
- <EntryView entry={row} verbose={verbose} collapsed={collapsed} />
197
- );
198
- }
199
-
200
- // Render a list of finalized entries (used inside Ink's <Static> for the committed
201
- // transcript) — kept as a plain map so the same RowView powers live rendering too.
202
- export function Transcript({ entries }: { entries: Entry[] }) {
203
- return (
204
- <Box flexDirection="column">
205
- {groupRows(entries).map((row, i) => (
206
- <RowView key={i} row={row} />
207
- ))}
208
- </Box>
209
- );
210
- }
@@ -1,14 +0,0 @@
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 DOT = "●"; // U+25CF — "live" marker; the remote-access badge in the status bar
14
- export const DOWN = "↓"; // U+2193 — output tokens streaming down from the model (spinner)