privateer-agent 0.1.0 → 0.2.1
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.
- package/README.md +86 -33
- package/package.json +1 -1
- package/src/auth/privateer.ts +71 -1
- package/src/commands/custom.ts +52 -4
- package/src/commands/registry.ts +124 -5
- package/src/components/App.tsx +268 -18
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +21 -1
- package/src/components/ModelPicker.tsx +45 -12
- package/src/components/OptionPicker.tsx +134 -0
- package/src/components/Root.tsx +30 -9
- package/src/components/StatusBar.tsx +11 -1
- package/src/components/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- package/src/components/figures.ts +1 -0
- package/src/components/theme.ts +2 -0
- package/src/config/paths.ts +2 -0
- package/src/context/systemPrompt.ts +9 -0
- package/src/daemon/index.ts +322 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +10 -0
- package/src/main.tsx +43 -1
- package/src/mcp/client.ts +16 -1
- package/src/permissions/gate.ts +5 -0
- package/src/permissions/mode.ts +4 -0
- package/src/permissions/uiGate.ts +4 -3
- package/src/remote/relayClient.ts +161 -6
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +75 -0
- package/src/routines/schema.ts +65 -0
- package/src/routines/store.ts +205 -0
- package/src/routines/toolSelect.ts +48 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +37 -12
- package/src/skills/installer.ts +222 -0
- package/src/skills/loader.ts +88 -0
- package/src/tools/askUser.ts +92 -0
- package/src/tools/context.ts +14 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/routine.ts +110 -0
- package/src/tools/sendFileToClient.ts +55 -0
- package/src/tools/skill.ts +44 -0
- package/src/tools/worktree.ts +145 -0
- package/src/util/images.ts +35 -0
|
@@ -4,6 +4,7 @@ import Spinner from "ink-spinner";
|
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import type { Config, ProviderName } from "../config/schema.ts";
|
|
6
6
|
import { configuredProviders, privateerChannel } from "../providers/resolve.ts";
|
|
7
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
7
8
|
import { PROVIDER_META } from "../providers/catalog.ts";
|
|
8
9
|
import { listModels, zdrPosture, type ModelInfo } from "../providers/models.ts";
|
|
9
10
|
import { theme, POSTURE_COLOR } from "./theme.ts";
|
|
@@ -27,14 +28,18 @@ export function ModelPicker({
|
|
|
27
28
|
onSelect: (spec: string) => void;
|
|
28
29
|
onCancel?: () => void;
|
|
29
30
|
}) {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const entries = useMemo<{ name: ProviderName; ready: boolean }[]>(() => {
|
|
32
|
+
if (providers) return providers.map((name) => ({ name, ready: true }));
|
|
33
|
+
// Ready providers, plus the Privateer account provider even when signed
|
|
34
|
+
// out: an expired machine login wipes the stored credentials, and the
|
|
35
|
+
// account silently vanishing from this list reads as a bug. It stays
|
|
36
|
+
// listed, annotated, and selecting it points at /login.
|
|
37
|
+
return configuredProviders(config).filter((p) => p.ready || p.name === "privateer");
|
|
33
38
|
}, [config, providers]);
|
|
34
39
|
|
|
35
|
-
const [provider, setProvider] = useState<ProviderName | null>(
|
|
40
|
+
const [provider, setProvider] = useState<ProviderName | null>(entries.length === 1 ? entries[0].name : null);
|
|
36
41
|
|
|
37
|
-
if (
|
|
42
|
+
if (entries.length === 0) {
|
|
38
43
|
return (
|
|
39
44
|
<Box flexDirection="column" paddingX={1}>
|
|
40
45
|
<Text color={theme.error}>No providers configured. Run /login to add an API key.</Text>
|
|
@@ -43,7 +48,16 @@ export function ModelPicker({
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
if (provider === null) {
|
|
46
|
-
return <ProviderStage providers={
|
|
51
|
+
return <ProviderStage providers={entries} onPick={setProvider} onCancel={onCancel} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (provider === "privateer" && !hasCredentials()) {
|
|
55
|
+
return (
|
|
56
|
+
<SignedOutStage
|
|
57
|
+
onBack={entries.length > 1 ? () => setProvider(null) : undefined}
|
|
58
|
+
onCancel={onCancel}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
return (
|
|
@@ -51,7 +65,7 @@ export function ModelPicker({
|
|
|
51
65
|
provider={provider}
|
|
52
66
|
config={config}
|
|
53
67
|
onSelect={(id) => onSelect(`${provider}:${id}`)}
|
|
54
|
-
onBack={
|
|
68
|
+
onBack={entries.length > 1 ? () => setProvider(null) : undefined}
|
|
55
69
|
onCancel={onCancel}
|
|
56
70
|
/>
|
|
57
71
|
);
|
|
@@ -62,7 +76,7 @@ function ProviderStage({
|
|
|
62
76
|
onPick,
|
|
63
77
|
onCancel,
|
|
64
78
|
}: {
|
|
65
|
-
providers: ProviderName[];
|
|
79
|
+
providers: { name: ProviderName; ready: boolean }[];
|
|
66
80
|
onPick: (name: ProviderName) => void;
|
|
67
81
|
onCancel?: () => void;
|
|
68
82
|
}) {
|
|
@@ -70,7 +84,7 @@ function ProviderStage({
|
|
|
70
84
|
useInput((input, key) => {
|
|
71
85
|
if (key.upArrow || input === "k") setCursor((c) => (c - 1 + providers.length) % providers.length);
|
|
72
86
|
else if (key.downArrow || input === "j") setCursor((c) => (c + 1) % providers.length);
|
|
73
|
-
else if (key.return) onPick(providers[cursor]);
|
|
87
|
+
else if (key.return) onPick(providers[cursor].name);
|
|
74
88
|
else if (key.escape) onCancel?.();
|
|
75
89
|
});
|
|
76
90
|
|
|
@@ -81,10 +95,11 @@ function ProviderStage({
|
|
|
81
95
|
<Text color={theme.accent}>enter</Text> select{onCancel ? ", esc cancel" : ""}.
|
|
82
96
|
</Text>
|
|
83
97
|
<Box flexDirection="column" marginTop={1}>
|
|
84
|
-
{providers.map((
|
|
85
|
-
<Text key={name} color={i === cursor ? theme.accent : undefined}>
|
|
98
|
+
{providers.map((p, i) => (
|
|
99
|
+
<Text key={p.name} color={i === cursor ? theme.accent : undefined}>
|
|
86
100
|
{i === cursor ? "❯ " : " "}
|
|
87
|
-
{PROVIDER_META[name].label}
|
|
101
|
+
{PROVIDER_META[p.name].label}
|
|
102
|
+
{!p.ready ? <Text color={theme.warning}> — signed out, run /login</Text> : null}
|
|
88
103
|
</Text>
|
|
89
104
|
))}
|
|
90
105
|
</Box>
|
|
@@ -92,6 +107,24 @@ function ProviderStage({
|
|
|
92
107
|
);
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
// The Privateer account provider is listed but signed out (fresh install, or
|
|
111
|
+
// the machine login's TTL lapsed and the credentials were wiped): explain how
|
|
112
|
+
// to get it back instead of failing the model fetch with a raw auth error.
|
|
113
|
+
function SignedOutStage({ onBack, onCancel }: { onBack?: () => void; onCancel?: () => void }) {
|
|
114
|
+
useInput((_input, key) => {
|
|
115
|
+
if (key.escape) (onBack ?? onCancel)?.();
|
|
116
|
+
});
|
|
117
|
+
return (
|
|
118
|
+
<Box flexDirection="column" paddingX={1}>
|
|
119
|
+
<Text color={theme.warning}>Not signed in to your Privateer account.</Text>
|
|
120
|
+
<Text color={theme.dim}>
|
|
121
|
+
Run /login to link this terminal, then pick an account model here.
|
|
122
|
+
{onBack ? " Esc to go back." : onCancel ? " Esc to cancel." : ""}
|
|
123
|
+
</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
95
128
|
function ModelStage({
|
|
96
129
|
provider,
|
|
97
130
|
config,
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { UserQuestion, UserAnswer } from "../tools/askUser.ts";
|
|
4
|
+
import { theme } from "./theme.ts";
|
|
5
|
+
|
|
6
|
+
// Interactive picker shown when the agent calls `ask_user` to choose between
|
|
7
|
+
// competing approaches. The turn is blocked on the human (like the approval prompt).
|
|
8
|
+
// Keys: ↑/↓ move, 1–N jump, Enter confirm, e (or the last row) write a custom answer,
|
|
9
|
+
// Esc dismiss. In multiSelect mode, Space/number toggles and Enter confirms the set.
|
|
10
|
+
export function OptionPicker({
|
|
11
|
+
question,
|
|
12
|
+
onRespond,
|
|
13
|
+
}: {
|
|
14
|
+
question: UserQuestion;
|
|
15
|
+
onRespond: (answer: UserAnswer) => void;
|
|
16
|
+
}) {
|
|
17
|
+
const { options, multiSelect } = question;
|
|
18
|
+
const otherIndex = options.length; // the trailing "write your own" row
|
|
19
|
+
const total = options.length + 1;
|
|
20
|
+
const [cursor, setCursor] = useState(0);
|
|
21
|
+
const [checked, setChecked] = useState<Set<number>>(new Set());
|
|
22
|
+
const [mode, setMode] = useState<"list" | "custom">("list");
|
|
23
|
+
const [draft, setDraft] = useState("");
|
|
24
|
+
|
|
25
|
+
function toggle(i: number) {
|
|
26
|
+
setChecked((prev) => {
|
|
27
|
+
const next = new Set(prev);
|
|
28
|
+
if (next.has(i)) next.delete(i);
|
|
29
|
+
else next.add(i);
|
|
30
|
+
return next;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function confirm() {
|
|
35
|
+
if (cursor === otherIndex) {
|
|
36
|
+
setMode("custom");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (multiSelect) {
|
|
40
|
+
const picks = checked.size > 0 ? [...checked] : [cursor];
|
|
41
|
+
onRespond({ kind: "selected", indices: picks.sort((a, b) => a - b) });
|
|
42
|
+
} else {
|
|
43
|
+
onRespond({ kind: "selected", indices: [cursor] });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
useInput((input, key) => {
|
|
48
|
+
if (mode === "custom") {
|
|
49
|
+
if (key.escape) {
|
|
50
|
+
setMode("list");
|
|
51
|
+
setDraft("");
|
|
52
|
+
} else if (key.return) {
|
|
53
|
+
const text = draft.trim();
|
|
54
|
+
if (text) onRespond({ kind: "custom", text });
|
|
55
|
+
} else if (key.backspace || key.delete) {
|
|
56
|
+
setDraft((d) => d.slice(0, -1));
|
|
57
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
58
|
+
setDraft((d) => d + input);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (key.upArrow) setCursor((c) => (c - 1 + total) % total);
|
|
64
|
+
else if (key.downArrow) setCursor((c) => (c + 1) % total);
|
|
65
|
+
else if (key.escape) onRespond({ kind: "dismissed" });
|
|
66
|
+
else if (input === "e") setMode("custom");
|
|
67
|
+
else if (input >= "1" && input <= "9") {
|
|
68
|
+
const i = Number(input) - 1;
|
|
69
|
+
if (i < options.length) {
|
|
70
|
+
if (multiSelect) {
|
|
71
|
+
setCursor(i);
|
|
72
|
+
toggle(i);
|
|
73
|
+
} else {
|
|
74
|
+
onRespond({ kind: "selected", indices: [i] });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else if (input === " " && multiSelect && cursor < options.length) {
|
|
78
|
+
toggle(cursor);
|
|
79
|
+
} else if (key.return) {
|
|
80
|
+
confirm();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (mode === "custom") {
|
|
85
|
+
return (
|
|
86
|
+
<Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
|
|
87
|
+
<Text bold color={theme.accent}>
|
|
88
|
+
{question.question}
|
|
89
|
+
</Text>
|
|
90
|
+
<Text>
|
|
91
|
+
<Text color={theme.dim}>your answer: </Text>
|
|
92
|
+
{draft}
|
|
93
|
+
<Text color={theme.accent}>▏</Text>
|
|
94
|
+
</Text>
|
|
95
|
+
<Text dimColor>enter submit · esc back to options</Text>
|
|
96
|
+
</Box>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
|
|
102
|
+
<Text bold color={theme.accent}>
|
|
103
|
+
{question.question}
|
|
104
|
+
</Text>
|
|
105
|
+
{options.map((o, i) => {
|
|
106
|
+
const active = i === cursor;
|
|
107
|
+
const box = multiSelect ? (checked.has(i) ? "[x] " : "[ ] ") : "";
|
|
108
|
+
return (
|
|
109
|
+
<Box key={i} flexDirection="column" marginTop={1}>
|
|
110
|
+
<Text color={active ? theme.accent : undefined}>
|
|
111
|
+
<Text color={active ? theme.accent : theme.dim}>{active ? "❯ " : " "}</Text>
|
|
112
|
+
<Text color={theme.dim}>{i + 1}. </Text>
|
|
113
|
+
{box}
|
|
114
|
+
<Text bold={active}>{o.label}</Text>
|
|
115
|
+
</Text>
|
|
116
|
+
{o.description ? <Text dimColor>{" " + o.description}</Text> : null}
|
|
117
|
+
</Box>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
<Box marginTop={1}>
|
|
121
|
+
<Text color={cursor === otherIndex ? theme.accent : undefined}>
|
|
122
|
+
<Text color={cursor === otherIndex ? theme.accent : theme.dim}>{cursor === otherIndex ? "❯ " : " "}</Text>
|
|
123
|
+
<Text color={theme.dim}>e. </Text>
|
|
124
|
+
<Text bold={cursor === otherIndex}>Something else — write your own answer</Text>
|
|
125
|
+
</Text>
|
|
126
|
+
</Box>
|
|
127
|
+
<Text dimColor>
|
|
128
|
+
{multiSelect
|
|
129
|
+
? "↑/↓ move · space toggle · enter confirm · esc skip"
|
|
130
|
+
: "↑/↓ move · 1–" + options.length + " pick · enter confirm · esc skip"}
|
|
131
|
+
</Text>
|
|
132
|
+
</Box>
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/components/Root.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useState } from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
2
3
|
import { App } from "./App.tsx";
|
|
3
4
|
import { Onboarding, type OnboardingResult } from "./Onboarding.tsx";
|
|
4
5
|
import { PrivateerLogin } from "./PrivateerLogin.tsx";
|
|
@@ -30,6 +31,17 @@ export function Root({
|
|
|
30
31
|
const [modelSpec, setModelSpec] = useState(initialModel);
|
|
31
32
|
const [onboarding, setOnboarding] = useState(startInOnboarding);
|
|
32
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
|
+
}
|
|
33
45
|
|
|
34
46
|
// Providers that already have credentials — pre-checked when re-running onboarding.
|
|
35
47
|
const configured = configuredProviders(config)
|
|
@@ -47,9 +59,11 @@ export function Root({
|
|
|
47
59
|
} catch {
|
|
48
60
|
/* non-fatal: keys just won't persist to disk this run */
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
swapScreen(() => {
|
|
63
|
+
setConfig(next);
|
|
64
|
+
setModelSpec(result.defaultModel);
|
|
65
|
+
setOnboarding(false);
|
|
66
|
+
});
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
// After a successful account login, switch to the Privateer-billed model so the
|
|
@@ -68,13 +82,20 @@ export function Root({
|
|
|
68
82
|
} catch {
|
|
69
83
|
/* non-fatal: model choice just won't persist this run */
|
|
70
84
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
swapScreen(() => {
|
|
86
|
+
setConfig(next);
|
|
87
|
+
setModelSpec(spec);
|
|
88
|
+
setLoggingIn(false);
|
|
89
|
+
});
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
if (loggingIn) {
|
|
77
|
-
return
|
|
93
|
+
return (
|
|
94
|
+
<PrivateerLogin
|
|
95
|
+
onComplete={finishLogin}
|
|
96
|
+
onCancel={() => swapScreen(() => setLoggingIn(false))}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
if (onboarding) {
|
|
@@ -88,8 +109,8 @@ export function Root({
|
|
|
88
109
|
config={config}
|
|
89
110
|
cwd={cwd}
|
|
90
111
|
resume={resume}
|
|
91
|
-
onLogin={() => setOnboarding(true)}
|
|
92
|
-
onPrivateerLogin={() => setLoggingIn(true)}
|
|
112
|
+
onLogin={() => swapScreen(() => setOnboarding(true))}
|
|
113
|
+
onPrivateerLogin={() => swapScreen(() => setLoggingIn(true))}
|
|
93
114
|
/>
|
|
94
115
|
);
|
|
95
116
|
}
|
|
@@ -2,7 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { theme, POSTURE_COLOR } from "./theme.ts";
|
|
5
|
-
import { SHIELD } from "./figures.ts";
|
|
5
|
+
import { SHIELD, DOT } from "./figures.ts";
|
|
6
6
|
import { useTerminalWidth } from "./useTerminalWidth.ts";
|
|
7
7
|
import { type UsageTotals } from "../engine/events.ts";
|
|
8
8
|
import type { ZdrState } from "./useZdrShield.ts";
|
|
@@ -30,6 +30,14 @@ function TeeBadge({ tee }: { tee?: TeeState }) {
|
|
|
30
30
|
return <Text color={theme.dim}>{`${SHIELD} TEE? · `}</Text>;
|
|
31
31
|
}
|
|
32
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
|
+
|
|
33
41
|
// Compact token count: 100, 1k, 1m, 1b — one decimal place above 1k, trimmed of
|
|
34
42
|
// trailing ".0", so 1500 → "1.5k" and 2000 → "2k".
|
|
35
43
|
export function formatTokens(n: number): string {
|
|
@@ -86,6 +94,7 @@ export function StatusBar(props: {
|
|
|
86
94
|
custom?: string; // settings-driven status line; overrides the default when set
|
|
87
95
|
zdr?: ZdrState; // OpenRouter ZDR posture for the selected model (default line only)
|
|
88
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)
|
|
89
98
|
}) {
|
|
90
99
|
// Stay clear of the right edge (parent paddingX={1} plus a 2-col safety gap) so
|
|
91
100
|
// the line never reaches the final column and the terminal never reflows it.
|
|
@@ -109,6 +118,7 @@ export function StatusBar(props: {
|
|
|
109
118
|
<Text wrap="truncate-end">
|
|
110
119
|
<ZdrBadge zdr={props.zdr} />
|
|
111
120
|
<TeeBadge tee={props.tee} />
|
|
121
|
+
<RemoteBadge remote={props.remote} />
|
|
112
122
|
<Text color={theme.accent}>⚓ privateer</Text>
|
|
113
123
|
{diag ? <Text color={theme.dim}>{` [${diag}]`}</Text> : null}
|
|
114
124
|
<Text color={theme.dim}> (shift+tab to cycle)</Text>
|
|
@@ -21,6 +21,10 @@ function summarizeInput(name: string, input: unknown): string {
|
|
|
21
21
|
return String(o.pattern ?? "") + (o.glob ? ` (${o.glob})` : "");
|
|
22
22
|
case "task":
|
|
23
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}` : "");
|
|
24
28
|
default:
|
|
25
29
|
try {
|
|
26
30
|
return JSON.stringify(o);
|
|
@@ -102,20 +102,26 @@ export function EntryView({
|
|
|
102
102
|
// Split off a trailing `recap:` line so it can render dimmed below the
|
|
103
103
|
// response body. The model is asked to end each turn with one such line.
|
|
104
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;
|
|
105
109
|
// ⏺ bullet in its own column so wrapped lines align under the text.
|
|
106
110
|
return (
|
|
107
111
|
<Box marginTop={1} flexDirection="column">
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
+
{hasBody && (
|
|
113
|
+
<Box>
|
|
114
|
+
<Text color={theme.accent}>{BULLET} </Text>
|
|
115
|
+
<Box width={bodyWidth}>
|
|
116
|
+
<Markdown text={body} />
|
|
117
|
+
</Box>
|
|
112
118
|
</Box>
|
|
113
|
-
|
|
119
|
+
)}
|
|
114
120
|
{recap && (
|
|
115
|
-
<Box marginTop={1}>
|
|
121
|
+
<Box marginTop={hasBody ? 1 : 0}>
|
|
116
122
|
<Text color={theme.dim}>{" "}</Text>
|
|
117
123
|
<Box width={bodyWidth}>
|
|
118
|
-
<Text color=
|
|
124
|
+
<Text color={theme.dim} dimColor italic>
|
|
119
125
|
{recap}
|
|
120
126
|
</Text>
|
|
121
127
|
</Box>
|
|
@@ -127,6 +133,7 @@ export function EntryView({
|
|
|
127
133
|
case "thinking": {
|
|
128
134
|
// The model's reasoning, rendered dimmed under a thinking mark. When
|
|
129
135
|
// collapsed (Ctrl+O), show just a one-line summary instead of the full text.
|
|
136
|
+
if (entry.text.trim() === "") return null;
|
|
130
137
|
if (collapsed) {
|
|
131
138
|
const lineCount = entry.text.trim() === "" ? 0 : entry.text.trim().split("\n").length;
|
|
132
139
|
return (
|
|
@@ -10,4 +10,5 @@ export const POINTER = "❯"; // selection pointer in menus and the prompt caret
|
|
|
10
10
|
export const FAST_FORWARD = "⏵⏵"; // marks an "on" permission mode below the prompt
|
|
11
11
|
export const PAUSE = "⏸"; // marks plan mode (paused execution) below the prompt
|
|
12
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
|
|
13
14
|
export const DOWN = "↓"; // U+2193 — output tokens streaming down from the model (spinner)
|
package/src/components/theme.ts
CHANGED
|
@@ -50,6 +50,8 @@ const TOOL_DISPLAY: Record<string, string> = {
|
|
|
50
50
|
task: "Task",
|
|
51
51
|
web_fetch: "WebFetch",
|
|
52
52
|
web_search: "WebSearch",
|
|
53
|
+
ask_user: "AskUser",
|
|
54
|
+
worktree: "Worktree",
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
export const toolDisplayName = (name: string): string => TOOL_DISPLAY[name] ?? name;
|
package/src/config/paths.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface ScopePaths {
|
|
|
35
35
|
settingsLocal: string; // settings.local.json — gitignored local overrides
|
|
36
36
|
commands: string; // commands/ — custom slash commands (M2)
|
|
37
37
|
agents: string; // agents/ — custom subagents (M4)
|
|
38
|
+
skills: string; // skills/ — agent skills, one SKILL.md directory each
|
|
38
39
|
outputStyles: string; // output-styles/ — persona prompts (M2)
|
|
39
40
|
mcp: string; // mcp.json — MCP server declarations (M4)
|
|
40
41
|
}
|
|
@@ -47,6 +48,7 @@ function scopePaths(dir: string): ScopePaths {
|
|
|
47
48
|
settingsLocal: join(dir, "settings.local.json"),
|
|
48
49
|
commands: join(dir, "commands"),
|
|
49
50
|
agents: join(dir, "agents"),
|
|
51
|
+
skills: join(dir, "skills"),
|
|
50
52
|
outputStyles: join(dir, "output-styles"),
|
|
51
53
|
mcp: join(dir, "mcp.json"),
|
|
52
54
|
};
|
|
@@ -51,6 +51,15 @@ before doing it.
|
|
|
51
51
|
work is attributed to Privateer as a co-author:
|
|
52
52
|
Co-Authored-By: Privateer <291203302+privateer-first-mate@users.noreply.github.com>
|
|
53
53
|
- Use 'web_fetch' to read a known URL when the user provides one or you need current docs.
|
|
54
|
+
- When the implementation direction is genuinely ambiguous and the choice materially shapes the \
|
|
55
|
+
work — a structural/architectural fork, which library or data model to adopt, the scope of a \
|
|
56
|
+
change — don't silently guess. Lay out the realistic approaches and call 'ask_user' to let the \
|
|
57
|
+
user pick (2–4 options, most-recommended first, each with its trade-offs). Reserve it for \
|
|
58
|
+
consequential forks: for small, reversible choices, just make the call and proceed. After the \
|
|
59
|
+
user chooses, build that direction without re-litigating it.
|
|
60
|
+
- To try a risky or exploratory approach without disturbing the main tree, use 'worktree' to spin \
|
|
61
|
+
up an isolated branch + working copy, do the work there, and let the user compare its diff before \
|
|
62
|
+
keeping or discarding it.
|
|
54
63
|
- Mutating actions (write/edit/bash) may require user approval; that's expected — proceed and let \
|
|
55
64
|
the gate handle it.`;
|
|
56
65
|
|