@stigmer/ink 0.0.88
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/LICENSE +190 -0
- package/app/SessionApp.d.ts +42 -0
- package/app/SessionApp.d.ts.map +1 -0
- package/app/SessionApp.js +38 -0
- package/app/SessionApp.js.map +1 -0
- package/app/SessionView.d.ts +32 -0
- package/app/SessionView.d.ts.map +1 -0
- package/app/SessionView.js +58 -0
- package/app/SessionView.js.map +1 -0
- package/cli/stigmer-ink.d.ts +3 -0
- package/cli/stigmer-ink.d.ts.map +1 -0
- package/cli/stigmer-ink.js +117 -0
- package/cli/stigmer-ink.js.map +1 -0
- package/components/ApprovalPrompt.d.ts +21 -0
- package/components/ApprovalPrompt.d.ts.map +1 -0
- package/components/ApprovalPrompt.js +60 -0
- package/components/ApprovalPrompt.js.map +1 -0
- package/components/ExecutionProgress.d.ts +16 -0
- package/components/ExecutionProgress.d.ts.map +1 -0
- package/components/ExecutionProgress.js +47 -0
- package/components/ExecutionProgress.js.map +1 -0
- package/components/FollowUpInput.d.ts +19 -0
- package/components/FollowUpInput.d.ts.map +1 -0
- package/components/FollowUpInput.js +27 -0
- package/components/FollowUpInput.js.map +1 -0
- package/components/MessageEntry.d.ts +18 -0
- package/components/MessageEntry.d.ts.map +1 -0
- package/components/MessageEntry.js +42 -0
- package/components/MessageEntry.js.map +1 -0
- package/components/MessageThread.d.ts +31 -0
- package/components/MessageThread.d.ts.map +1 -0
- package/components/MessageThread.js +146 -0
- package/components/MessageThread.js.map +1 -0
- package/components/SubAgentBlock.d.ts +19 -0
- package/components/SubAgentBlock.d.ts.map +1 -0
- package/components/SubAgentBlock.js +73 -0
- package/components/SubAgentBlock.js.map +1 -0
- package/components/TodoList.d.ts +17 -0
- package/components/TodoList.d.ts.map +1 -0
- package/components/TodoList.js +43 -0
- package/components/TodoList.js.map +1 -0
- package/components/ToolCallGroup.d.ts +20 -0
- package/components/ToolCallGroup.d.ts.map +1 -0
- package/components/ToolCallGroup.js +51 -0
- package/components/ToolCallGroup.js.map +1 -0
- package/components/ToolCallItem.d.ts +14 -0
- package/components/ToolCallItem.d.ts.map +1 -0
- package/components/ToolCallItem.js +33 -0
- package/components/ToolCallItem.js.map +1 -0
- package/components/UsageWidget.d.ts +16 -0
- package/components/UsageWidget.d.ts.map +1 -0
- package/components/UsageWidget.js +18 -0
- package/components/UsageWidget.js.map +1 -0
- package/index.d.ts +16 -0
- package/index.d.ts.map +1 -0
- package/index.js +21 -0
- package/index.js.map +1 -0
- package/markdown.d.ts +21 -0
- package/markdown.d.ts.map +1 -0
- package/markdown.js +44 -0
- package/markdown.js.map +1 -0
- package/package.json +48 -0
- package/provider.d.ts +46 -0
- package/provider.d.ts.map +1 -0
- package/provider.js +33 -0
- package/provider.js.map +1 -0
- package/src/__tests__/components.test.tsx +162 -0
- package/src/__tests__/markdown.test.ts +46 -0
- package/src/app/SessionApp.tsx +74 -0
- package/src/app/SessionView.tsx +164 -0
- package/src/cli/stigmer-ink.tsx +148 -0
- package/src/components/ApprovalPrompt.tsx +139 -0
- package/src/components/ExecutionProgress.tsx +75 -0
- package/src/components/FollowUpInput.tsx +70 -0
- package/src/components/MessageEntry.tsx +80 -0
- package/src/components/MessageThread.tsx +264 -0
- package/src/components/SubAgentBlock.tsx +146 -0
- package/src/components/TodoList.tsx +75 -0
- package/src/components/ToolCallGroup.tsx +92 -0
- package/src/components/ToolCallItem.tsx +74 -0
- package/src/components/UsageWidget.tsx +35 -0
- package/src/index.ts +28 -0
- package/src/markdown.ts +48 -0
- package/src/provider.tsx +62 -0
- package/src/types/marked-terminal.d.ts +19 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
4
|
+
import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
|
+
import { renderMarkdown } from "../markdown.js";
|
|
6
|
+
|
|
7
|
+
/** Props for {@link MessageEntry}. */
|
|
8
|
+
export interface MessageEntryProps {
|
|
9
|
+
/** The agent message to render. */
|
|
10
|
+
readonly message: AgentMessage;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders a single message in the terminal conversation thread.
|
|
15
|
+
*
|
|
16
|
+
* - `MESSAGE_HUMAN` — plain text prefixed with a "You" indicator
|
|
17
|
+
* - `MESSAGE_AI` — markdown rendered to ANSI-styled terminal output,
|
|
18
|
+
* with a cursor indicator while streaming
|
|
19
|
+
* - `MESSAGE_SYSTEM` — dimmed text
|
|
20
|
+
* - `MESSAGE_TOOL` / `UNSPECIFIED` — renders nothing (tool results
|
|
21
|
+
* are handled by {@link ToolCallGroup})
|
|
22
|
+
*/
|
|
23
|
+
export function MessageEntry({ message }: MessageEntryProps) {
|
|
24
|
+
switch (message.type) {
|
|
25
|
+
case MessageType.MESSAGE_HUMAN:
|
|
26
|
+
return <HumanMessage content={message.content} />;
|
|
27
|
+
case MessageType.MESSAGE_AI:
|
|
28
|
+
return (
|
|
29
|
+
<AiMessage content={message.content} isStreaming={message.isStreaming} />
|
|
30
|
+
);
|
|
31
|
+
case MessageType.MESSAGE_SYSTEM:
|
|
32
|
+
return <SystemMessage content={message.content} />;
|
|
33
|
+
default:
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function HumanMessage({ content }: { content: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
41
|
+
<Text bold color="cyan">
|
|
42
|
+
You
|
|
43
|
+
</Text>
|
|
44
|
+
<Text>{content}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function AiMessage({
|
|
50
|
+
content,
|
|
51
|
+
isStreaming,
|
|
52
|
+
}: {
|
|
53
|
+
content: string;
|
|
54
|
+
isStreaming: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
const rendered = useMemo(() => {
|
|
57
|
+
if (!content.trim()) return "";
|
|
58
|
+
return renderMarkdown(content);
|
|
59
|
+
}, [content]);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
63
|
+
<Text bold color="green">
|
|
64
|
+
Agent
|
|
65
|
+
</Text>
|
|
66
|
+
{rendered ? <Text>{rendered}</Text> : null}
|
|
67
|
+
{isStreaming && !rendered && <Text dimColor>Thinking...</Text>}
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function SystemMessage({ content }: { content: string }) {
|
|
73
|
+
return (
|
|
74
|
+
<Box paddingLeft={1}>
|
|
75
|
+
<Text dimColor italic>
|
|
76
|
+
{content}
|
|
77
|
+
</Text>
|
|
78
|
+
</Box>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text, Static } from "ink";
|
|
3
|
+
import { create } from "@bufbuild/protobuf";
|
|
4
|
+
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
5
|
+
import type {
|
|
6
|
+
AgentMessage,
|
|
7
|
+
ToolCall,
|
|
8
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
9
|
+
import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
10
|
+
import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
11
|
+
import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
12
|
+
import {
|
|
13
|
+
ApprovalAction,
|
|
14
|
+
ExecutionPhase,
|
|
15
|
+
MessageType,
|
|
16
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
17
|
+
import { isTerminalPhase } from "@stigmer/react";
|
|
18
|
+
import { MessageEntry } from "./MessageEntry.js";
|
|
19
|
+
import { ToolCallGroup } from "./ToolCallGroup.js";
|
|
20
|
+
import { SubAgentBlock } from "./SubAgentBlock.js";
|
|
21
|
+
import { ExecutionProgress } from "./ExecutionProgress.js";
|
|
22
|
+
import { ApprovalPrompt } from "./ApprovalPrompt.js";
|
|
23
|
+
|
|
24
|
+
/** Props for {@link MessageThread}. */
|
|
25
|
+
export interface MessageThreadProps {
|
|
26
|
+
/** Completed executions in chronological order. */
|
|
27
|
+
readonly executions: readonly AgentExecution[];
|
|
28
|
+
/** The currently streaming execution (appended after `executions`). */
|
|
29
|
+
readonly activeStreamExecution?: AgentExecution | null;
|
|
30
|
+
/** Optimistic user message shown before the stream delivers it. */
|
|
31
|
+
readonly pendingUserMessage?: string | null;
|
|
32
|
+
/** Callback for approval actions. Shows approval UI when provided. */
|
|
33
|
+
readonly onApprovalSubmit?: (
|
|
34
|
+
toolCallId: string,
|
|
35
|
+
action: ApprovalAction,
|
|
36
|
+
) => void;
|
|
37
|
+
/** Set of tool call IDs currently being submitted for approval. */
|
|
38
|
+
readonly submittingApprovalIds?: ReadonlySet<string>;
|
|
39
|
+
/** Whether tool call groups should be rendered in expanded mode. Toggled via Ctrl+O. */
|
|
40
|
+
readonly expandToolCalls?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type ThreadItem =
|
|
44
|
+
| { readonly kind: "message"; readonly message: AgentMessage; readonly key: string }
|
|
45
|
+
| { readonly kind: "tool-group"; readonly toolCalls: readonly ToolCall[]; readonly key: string }
|
|
46
|
+
| { readonly kind: "sub-agent"; readonly subAgent: SubAgentExecution; readonly key: string }
|
|
47
|
+
| { readonly kind: "phase"; readonly phase: ExecutionPhase; readonly key: string }
|
|
48
|
+
| { readonly kind: "pending-message"; readonly content: string; readonly key: string }
|
|
49
|
+
| { readonly kind: "approval"; readonly pendingApproval: PendingApproval; readonly key: string };
|
|
50
|
+
|
|
51
|
+
function buildThreadItems(
|
|
52
|
+
executions: readonly AgentExecution[],
|
|
53
|
+
activeStreamExecution: AgentExecution | null | undefined,
|
|
54
|
+
pendingUserMessage: string | null | undefined,
|
|
55
|
+
includeApprovals: boolean,
|
|
56
|
+
): ThreadItem[] {
|
|
57
|
+
const items: ThreadItem[] = [];
|
|
58
|
+
const allExecutions = activeStreamExecution
|
|
59
|
+
? [...executions, activeStreamExecution]
|
|
60
|
+
: executions;
|
|
61
|
+
|
|
62
|
+
for (let ei = 0; ei < allExecutions.length; ei++) {
|
|
63
|
+
const exec = allExecutions[ei];
|
|
64
|
+
const messages = exec.status?.messages ?? [];
|
|
65
|
+
const subAgents = exec.status?.subAgentExecutions ?? [];
|
|
66
|
+
|
|
67
|
+
const specMessage = exec.spec?.message;
|
|
68
|
+
if (specMessage && specMessage !== "execute") {
|
|
69
|
+
const humanMsg = create(AgentMessageSchema);
|
|
70
|
+
humanMsg.type = MessageType.MESSAGE_HUMAN;
|
|
71
|
+
humanMsg.content = specMessage;
|
|
72
|
+
items.push({ kind: "message", message: humanMsg, key: `e${ei}-spec` });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (let mi = 0; mi < messages.length; mi++) {
|
|
76
|
+
const msg = messages[mi];
|
|
77
|
+
if (msg.type === MessageType.MESSAGE_TOOL) continue;
|
|
78
|
+
|
|
79
|
+
const isEmptyAi =
|
|
80
|
+
msg.type === MessageType.MESSAGE_AI && !msg.content.trim();
|
|
81
|
+
|
|
82
|
+
if (!isEmptyAi) {
|
|
83
|
+
items.push({ kind: "message", message: msg, key: `e${ei}-m${mi}` });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (msg.type === MessageType.MESSAGE_AI && msg.toolCalls.length > 0) {
|
|
87
|
+
// Split tool calls: "task" tools become sub-agent blocks,
|
|
88
|
+
// everything else goes into a regular tool group.
|
|
89
|
+
const regularTools = msg.toolCalls.filter((tc) => tc.name !== "task");
|
|
90
|
+
const taskTools = msg.toolCalls.filter((tc) => tc.name === "task");
|
|
91
|
+
|
|
92
|
+
if (regularTools.length > 0) {
|
|
93
|
+
items.push({
|
|
94
|
+
kind: "tool-group",
|
|
95
|
+
toolCalls: regularTools,
|
|
96
|
+
key: `e${ei}-m${mi}-tc`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (let ti = 0; ti < taskTools.length; ti++) {
|
|
101
|
+
const matchedSub = subAgents.find((sa) => sa.id === taskTools[ti].id);
|
|
102
|
+
if (matchedSub) {
|
|
103
|
+
items.push({
|
|
104
|
+
kind: "sub-agent",
|
|
105
|
+
subAgent: matchedSub,
|
|
106
|
+
key: `e${ei}-m${mi}-sa-${ti}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lastExec = allExecutions[allExecutions.length - 1];
|
|
115
|
+
const lastPhase =
|
|
116
|
+
lastExec?.status?.phase ?? ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
isTerminalPhase(lastPhase) &&
|
|
120
|
+
lastPhase !== ExecutionPhase.EXECUTION_COMPLETED
|
|
121
|
+
) {
|
|
122
|
+
items.push({ kind: "phase", phase: lastPhase, key: `phase-${lastPhase}` });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (includeApprovals) {
|
|
126
|
+
const approvals = lastExec?.status?.pendingApprovals ?? [];
|
|
127
|
+
for (let ai = 0; ai < approvals.length; ai++) {
|
|
128
|
+
items.push({
|
|
129
|
+
kind: "approval",
|
|
130
|
+
pendingApproval: approvals[ai],
|
|
131
|
+
key: `approval-${approvals[ai].toolCallId || ai}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (pendingUserMessage) {
|
|
137
|
+
const alreadySynthesized = lastExec?.spec?.message === pendingUserMessage;
|
|
138
|
+
if (!alreadySynthesized) {
|
|
139
|
+
items.push({
|
|
140
|
+
kind: "pending-message",
|
|
141
|
+
content: pendingUserMessage,
|
|
142
|
+
key: "pending-user-msg",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return items;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Renders a continuous conversation thread from one or more
|
|
152
|
+
* `AgentExecution` snapshots in the terminal.
|
|
153
|
+
*
|
|
154
|
+
* Composes {@link MessageEntry}, {@link ToolCallGroup},
|
|
155
|
+
* {@link ExecutionProgress}, and {@link ApprovalPrompt} into a
|
|
156
|
+
* scrolling terminal log.
|
|
157
|
+
*
|
|
158
|
+
* Historical items are rendered via Ink's `<Static>` component so
|
|
159
|
+
* they are written once and don't re-render, keeping terminal
|
|
160
|
+
* output efficient for long conversations.
|
|
161
|
+
*/
|
|
162
|
+
export function MessageThread({
|
|
163
|
+
executions,
|
|
164
|
+
activeStreamExecution,
|
|
165
|
+
pendingUserMessage,
|
|
166
|
+
onApprovalSubmit,
|
|
167
|
+
submittingApprovalIds,
|
|
168
|
+
expandToolCalls = false,
|
|
169
|
+
}: MessageThreadProps) {
|
|
170
|
+
const includeApprovals = onApprovalSubmit != null;
|
|
171
|
+
const items = useMemo(
|
|
172
|
+
() =>
|
|
173
|
+
buildThreadItems(
|
|
174
|
+
executions,
|
|
175
|
+
activeStreamExecution,
|
|
176
|
+
pendingUserMessage,
|
|
177
|
+
includeApprovals,
|
|
178
|
+
),
|
|
179
|
+
[executions, activeStreamExecution, pendingUserMessage, includeApprovals],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const historyItems = activeStreamExecution ? items.slice(0, -getActiveCount(items)) : items;
|
|
183
|
+
const liveItems = activeStreamExecution ? items.slice(items.length - getActiveCount(items)) : [];
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<Box flexDirection="column">
|
|
187
|
+
<Static items={historyItems}>
|
|
188
|
+
{(item) => (
|
|
189
|
+
<Box key={item.key} flexDirection="column" marginBottom={1}>
|
|
190
|
+
{renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls)}
|
|
191
|
+
</Box>
|
|
192
|
+
)}
|
|
193
|
+
</Static>
|
|
194
|
+
|
|
195
|
+
{liveItems.map((item) => (
|
|
196
|
+
<Box key={item.key} flexDirection="column" marginBottom={1}>
|
|
197
|
+
{renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls)}
|
|
198
|
+
</Box>
|
|
199
|
+
))}
|
|
200
|
+
</Box>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getActiveCount(items: ThreadItem[]): number {
|
|
205
|
+
let count = 0;
|
|
206
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
207
|
+
const item = items[i];
|
|
208
|
+
if (
|
|
209
|
+
item.kind === "approval" ||
|
|
210
|
+
item.kind === "pending-message" ||
|
|
211
|
+
item.kind === "phase"
|
|
212
|
+
) {
|
|
213
|
+
count++;
|
|
214
|
+
} else if (
|
|
215
|
+
item.kind === "message" &&
|
|
216
|
+
item.message.type === MessageType.MESSAGE_AI &&
|
|
217
|
+
item.message.isStreaming
|
|
218
|
+
) {
|
|
219
|
+
count++;
|
|
220
|
+
break;
|
|
221
|
+
} else {
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return count;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderItem(
|
|
229
|
+
item: ThreadItem,
|
|
230
|
+
onApprovalSubmit?: (toolCallId: string, action: ApprovalAction) => void,
|
|
231
|
+
submittingApprovalIds?: ReadonlySet<string>,
|
|
232
|
+
expandToolCalls?: boolean,
|
|
233
|
+
): React.ReactNode {
|
|
234
|
+
switch (item.kind) {
|
|
235
|
+
case "message":
|
|
236
|
+
return <MessageEntry message={item.message} />;
|
|
237
|
+
case "tool-group":
|
|
238
|
+
return <ToolCallGroup toolCalls={item.toolCalls} defaultExpanded={expandToolCalls} />;
|
|
239
|
+
case "sub-agent":
|
|
240
|
+
return <SubAgentBlock subAgent={item.subAgent} defaultExpanded={expandToolCalls} />;
|
|
241
|
+
case "phase":
|
|
242
|
+
return <ExecutionProgress phase={item.phase} />;
|
|
243
|
+
case "approval":
|
|
244
|
+
return onApprovalSubmit ? (
|
|
245
|
+
<ApprovalPrompt
|
|
246
|
+
pendingApproval={item.pendingApproval}
|
|
247
|
+
onSubmit={(action) =>
|
|
248
|
+
onApprovalSubmit(item.pendingApproval.toolCallId, action)
|
|
249
|
+
}
|
|
250
|
+
isSubmitting={
|
|
251
|
+
submittingApprovalIds?.has(item.pendingApproval.toolCallId) ?? false
|
|
252
|
+
}
|
|
253
|
+
/>
|
|
254
|
+
) : null;
|
|
255
|
+
case "pending-message":
|
|
256
|
+
return (
|
|
257
|
+
<Box paddingLeft={1}>
|
|
258
|
+
<Text dimColor italic>
|
|
259
|
+
{item.content}
|
|
260
|
+
</Text>
|
|
261
|
+
</Box>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
4
|
+
import { SubAgentStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
|
+
import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
6
|
+
import { MessageEntry } from "./MessageEntry.js";
|
|
7
|
+
import { ToolCallGroup } from "./ToolCallGroup.js";
|
|
8
|
+
|
|
9
|
+
/** Props for {@link SubAgentBlock}. */
|
|
10
|
+
export interface SubAgentBlockProps {
|
|
11
|
+
/** The sub-agent execution data. */
|
|
12
|
+
readonly subAgent: SubAgentExecution;
|
|
13
|
+
/** Whether this block starts expanded. */
|
|
14
|
+
readonly defaultExpanded?: boolean;
|
|
15
|
+
/** Whether this component can receive keyboard focus for toggling. */
|
|
16
|
+
readonly isFocused?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STATUS_GLYPHS: Record<number, { glyph: string; color: string }> = {
|
|
20
|
+
[SubAgentStatus.SUB_AGENT_IN_PROGRESS]: { glyph: "⟳", color: "cyan" },
|
|
21
|
+
[SubAgentStatus.SUB_AGENT_COMPLETED]: { glyph: "✓", color: "green" },
|
|
22
|
+
[SubAgentStatus.SUB_AGENT_FAILED]: { glyph: "✗", color: "red" },
|
|
23
|
+
[SubAgentStatus.SUB_AGENT_PENDING]: { glyph: "○", color: "gray" },
|
|
24
|
+
[SubAgentStatus.SUB_AGENT_CANCELLED]: { glyph: "⊘", color: "yellow" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function statusDisplay(status: SubAgentStatus): { glyph: string; color: string } {
|
|
28
|
+
return STATUS_GLYPHS[status] ?? { glyph: "·", color: "gray" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatDuration(startedAt: string, completedAt: string): string | null {
|
|
32
|
+
if (!startedAt) return null;
|
|
33
|
+
const start = new Date(startedAt).getTime();
|
|
34
|
+
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
35
|
+
const ms = end - start;
|
|
36
|
+
if (ms < 1000) return `${ms}ms`;
|
|
37
|
+
const s = Math.round(ms / 1000);
|
|
38
|
+
if (s < 60) return `${s}s`;
|
|
39
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Renders a sub-agent execution as a collapsible block in the terminal.
|
|
44
|
+
*
|
|
45
|
+
* Shows a summary line with status glyph, name/subject, and duration.
|
|
46
|
+
* When expanded, renders the sub-agent's internal message thread using
|
|
47
|
+
* the same MessageEntry and ToolCallGroup components as the main thread.
|
|
48
|
+
*/
|
|
49
|
+
export function SubAgentBlock({
|
|
50
|
+
subAgent,
|
|
51
|
+
defaultExpanded = false,
|
|
52
|
+
isFocused = false,
|
|
53
|
+
}: SubAgentBlockProps) {
|
|
54
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
55
|
+
|
|
56
|
+
useInput(
|
|
57
|
+
(input, key) => {
|
|
58
|
+
if (key.return || input === " ") {
|
|
59
|
+
setExpanded((e) => !e);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ isActive: isFocused },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const { glyph, color } = statusDisplay(subAgent.status);
|
|
66
|
+
const label = subAgent.subject || subAgent.name || "Sub-agent";
|
|
67
|
+
const duration = formatDuration(subAgent.startedAt, subAgent.completedAt);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
71
|
+
<Box gap={1}>
|
|
72
|
+
<Text color={color}>{glyph}</Text>
|
|
73
|
+
<Text bold color="magenta">↳</Text>
|
|
74
|
+
<Text bold>{label}</Text>
|
|
75
|
+
{duration && <Text dimColor>({duration})</Text>}
|
|
76
|
+
<Text dimColor>{expanded ? "▾" : "▸"}</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
|
|
79
|
+
{expanded && (
|
|
80
|
+
<Box flexDirection="column" paddingLeft={3} marginTop={1}>
|
|
81
|
+
{subAgent.input && (
|
|
82
|
+
<Box marginBottom={1}>
|
|
83
|
+
<Text dimColor italic wrap="truncate-end">
|
|
84
|
+
Task: {subAgent.input.length > 120
|
|
85
|
+
? subAgent.input.slice(0, 120) + "…"
|
|
86
|
+
: subAgent.input}
|
|
87
|
+
</Text>
|
|
88
|
+
</Box>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{renderSubAgentMessages(subAgent)}
|
|
92
|
+
|
|
93
|
+
{subAgent.error && (
|
|
94
|
+
<Box marginTop={1}>
|
|
95
|
+
<Text color="red">Error: {subAgent.error}</Text>
|
|
96
|
+
</Box>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{subAgent.output && subAgent.status === SubAgentStatus.SUB_AGENT_COMPLETED && (
|
|
100
|
+
<Box marginTop={1}>
|
|
101
|
+
<Text dimColor>Result: </Text>
|
|
102
|
+
<Text wrap="truncate-end">
|
|
103
|
+
{subAgent.output.length > 200
|
|
104
|
+
? subAgent.output.slice(0, 200) + "…"
|
|
105
|
+
: subAgent.output}
|
|
106
|
+
</Text>
|
|
107
|
+
</Box>
|
|
108
|
+
)}
|
|
109
|
+
</Box>
|
|
110
|
+
)}
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderSubAgentMessages(subAgent: SubAgentExecution): React.ReactNode {
|
|
116
|
+
const messages = subAgent.messages ?? [];
|
|
117
|
+
if (messages.length === 0) return null;
|
|
118
|
+
|
|
119
|
+
const elements: React.ReactNode[] = [];
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < messages.length; i++) {
|
|
122
|
+
const msg = messages[i];
|
|
123
|
+
if (msg.type === MessageType.MESSAGE_TOOL) continue;
|
|
124
|
+
|
|
125
|
+
const isEmptyAi =
|
|
126
|
+
msg.type === MessageType.MESSAGE_AI && !msg.content.trim();
|
|
127
|
+
|
|
128
|
+
if (!isEmptyAi) {
|
|
129
|
+
elements.push(
|
|
130
|
+
<Box key={`sub-m${i}`} marginBottom={1}>
|
|
131
|
+
<MessageEntry message={msg} />
|
|
132
|
+
</Box>,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (msg.type === MessageType.MESSAGE_AI && msg.toolCalls.length > 0) {
|
|
137
|
+
elements.push(
|
|
138
|
+
<Box key={`sub-m${i}-tc`} marginBottom={1}>
|
|
139
|
+
<ToolCallGroup toolCalls={msg.toolCalls} />
|
|
140
|
+
</Box>,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return <>{elements}</>;
|
|
146
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { TodoItem } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/todo_pb";
|
|
4
|
+
import { TodoStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
|
+
|
|
6
|
+
/** Props for {@link TodoList}. */
|
|
7
|
+
export interface TodoListProps {
|
|
8
|
+
/** Map of todo item IDs to their current state, as provided by the execution status. */
|
|
9
|
+
readonly todos: { readonly [key: string]: TodoItem };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STATUS_SORT_ORDER: ReadonlyMap<TodoStatus, number> = new Map([
|
|
13
|
+
[TodoStatus.TODO_IN_PROGRESS, 0],
|
|
14
|
+
[TodoStatus.TODO_PENDING, 1],
|
|
15
|
+
[TodoStatus.TODO_COMPLETED, 2],
|
|
16
|
+
[TodoStatus.TODO_CANCELLED, 3],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const STATUS_DISPLAY: Record<number, { glyph: string; color: string }> = {
|
|
20
|
+
[TodoStatus.TODO_PENDING]: { glyph: "○", color: "gray" },
|
|
21
|
+
[TodoStatus.TODO_IN_PROGRESS]: { glyph: "●", color: "cyan" },
|
|
22
|
+
[TodoStatus.TODO_COMPLETED]: { glyph: "✓", color: "green" },
|
|
23
|
+
[TodoStatus.TODO_CANCELLED]: { glyph: "⊘", color: "gray" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Renders a sorted checklist of todo items in the terminal.
|
|
28
|
+
*
|
|
29
|
+
* Items are sorted by activity: in-progress first, then pending,
|
|
30
|
+
* completed, and cancelled. Terminal equivalent of the web SDK's
|
|
31
|
+
* TodoList component.
|
|
32
|
+
*/
|
|
33
|
+
export function TodoList({ todos }: TodoListProps) {
|
|
34
|
+
const sortedTodos = useMemo(() => {
|
|
35
|
+
const items = Object.values(todos);
|
|
36
|
+
if (items.length === 0) return [];
|
|
37
|
+
return items
|
|
38
|
+
.slice()
|
|
39
|
+
.sort(
|
|
40
|
+
(a, b) =>
|
|
41
|
+
(STATUS_SORT_ORDER.get(a.status) ?? 4) -
|
|
42
|
+
(STATUS_SORT_ORDER.get(b.status) ?? 4),
|
|
43
|
+
);
|
|
44
|
+
}, [todos]);
|
|
45
|
+
|
|
46
|
+
if (sortedTodos.length === 0) return null;
|
|
47
|
+
|
|
48
|
+
const completed = sortedTodos.filter(
|
|
49
|
+
(t) => t.status === TodoStatus.TODO_COMPLETED,
|
|
50
|
+
).length;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Box flexDirection="column">
|
|
54
|
+
<Box gap={1} marginBottom={1}>
|
|
55
|
+
<Text dimColor bold>Tasks</Text>
|
|
56
|
+
<Text dimColor>({completed}/{sortedTodos.length})</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
{sortedTodos.map((item) => {
|
|
59
|
+
const display = STATUS_DISPLAY[item.status] ?? { glyph: "·", color: "gray" };
|
|
60
|
+
const isCancelled = item.status === TodoStatus.TODO_CANCELLED;
|
|
61
|
+
return (
|
|
62
|
+
<Box key={item.id} gap={1} paddingLeft={1}>
|
|
63
|
+
<Text color={display.color}>{display.glyph}</Text>
|
|
64
|
+
<Text
|
|
65
|
+
dimColor={isCancelled}
|
|
66
|
+
strikethrough={isCancelled}
|
|
67
|
+
>
|
|
68
|
+
{item.content}
|
|
69
|
+
</Text>
|
|
70
|
+
</Box>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</Box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
4
|
+
import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
|
+
import { ToolCallItem } from "./ToolCallItem.js";
|
|
6
|
+
|
|
7
|
+
/** Props for {@link ToolCallGroup}. */
|
|
8
|
+
export interface ToolCallGroupProps {
|
|
9
|
+
/** Tool calls in this group, ordered by invocation time. */
|
|
10
|
+
readonly toolCalls: readonly ToolCall[];
|
|
11
|
+
/** Whether this group is currently focused for keyboard interaction. */
|
|
12
|
+
readonly isFocused?: boolean;
|
|
13
|
+
/** Whether to start in expanded mode (driven by Ctrl+O global toggle). */
|
|
14
|
+
readonly defaultExpanded?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type AggregateStatus = "running" | "failed" | "completed" | "pending";
|
|
18
|
+
|
|
19
|
+
function deriveAggregateStatus(toolCalls: readonly ToolCall[]): AggregateStatus {
|
|
20
|
+
let hasRunning = false;
|
|
21
|
+
let hasFailed = false;
|
|
22
|
+
|
|
23
|
+
for (const tc of toolCalls) {
|
|
24
|
+
if (tc.status === ToolCallStatus.TOOL_CALL_RUNNING) hasRunning = true;
|
|
25
|
+
if (tc.status === ToolCallStatus.TOOL_CALL_FAILED) hasFailed = true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (hasRunning) return "running";
|
|
29
|
+
if (hasFailed) return "failed";
|
|
30
|
+
const allDone = toolCalls.every(
|
|
31
|
+
(tc) =>
|
|
32
|
+
tc.status === ToolCallStatus.TOOL_CALL_COMPLETED ||
|
|
33
|
+
tc.status === ToolCallStatus.TOOL_CALL_FAILED,
|
|
34
|
+
);
|
|
35
|
+
return allDone ? "completed" : "pending";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STATUS_STYLE: Record<AggregateStatus, { symbol: string; color?: string }> = {
|
|
39
|
+
running: { symbol: "⠋", color: "yellow" },
|
|
40
|
+
completed: { symbol: "✓", color: "green" },
|
|
41
|
+
failed: { symbol: "✗", color: "red" },
|
|
42
|
+
pending: { symbol: "○" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders a collapsible group of tool calls with an aggregate status.
|
|
47
|
+
*
|
|
48
|
+
* When collapsed, shows a summary line with tool count and status.
|
|
49
|
+
* When expanded, renders each tool call via {@link ToolCallItem}.
|
|
50
|
+
*
|
|
51
|
+
* Press Enter or Space to toggle expansion when `isFocused` is true.
|
|
52
|
+
*/
|
|
53
|
+
export function ToolCallGroup({
|
|
54
|
+
toolCalls,
|
|
55
|
+
isFocused = false,
|
|
56
|
+
defaultExpanded = false,
|
|
57
|
+
}: ToolCallGroupProps) {
|
|
58
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
59
|
+
const status = deriveAggregateStatus(toolCalls);
|
|
60
|
+
const style = STATUS_STYLE[status];
|
|
61
|
+
|
|
62
|
+
useInput(
|
|
63
|
+
(_input, key) => {
|
|
64
|
+
if (key.return || _input === " ") {
|
|
65
|
+
setExpanded((prev) => !prev);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ isActive: isFocused },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const summary =
|
|
72
|
+
toolCalls.length === 1
|
|
73
|
+
? toolCalls[0].name
|
|
74
|
+
: `${toolCalls.length} tool calls`;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
78
|
+
<Box gap={1}>
|
|
79
|
+
<Text color={style.color}>{style.symbol}</Text>
|
|
80
|
+
<Text dimColor>{expanded ? "▼" : "▶"}</Text>
|
|
81
|
+
<Text>{summary}</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
{expanded && (
|
|
84
|
+
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
|
85
|
+
{toolCalls.map((tc) => (
|
|
86
|
+
<ToolCallItem key={tc.id} toolCall={tc} expanded />
|
|
87
|
+
))}
|
|
88
|
+
</Box>
|
|
89
|
+
)}
|
|
90
|
+
</Box>
|
|
91
|
+
);
|
|
92
|
+
}
|