triagent 0.1.0-alpha13 → 0.1.0-alpha17

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 (34) hide show
  1. package/package.json +3 -3
  2. package/src/cli/config.ts +96 -0
  3. package/src/index.ts +201 -3
  4. package/src/integrations/elasticsearch/client.ts +210 -0
  5. package/src/integrations/grafana/client.ts +186 -0
  6. package/src/integrations/kubernetes/multi-cluster.ts +199 -0
  7. package/src/integrations/kubernetes/types.ts +24 -0
  8. package/src/integrations/loki/client.ts +219 -0
  9. package/src/integrations/prometheus/client.ts +163 -0
  10. package/src/integrations/slack/client.ts +265 -0
  11. package/src/integrations/teams/client.ts +199 -0
  12. package/src/mastra/agents/debugger.ts +152 -108
  13. package/src/mastra/tools/approval-store.ts +180 -0
  14. package/src/mastra/tools/cli.ts +94 -2
  15. package/src/mastra/tools/cost.ts +389 -0
  16. package/src/mastra/tools/logs.ts +210 -0
  17. package/src/mastra/tools/network.ts +253 -0
  18. package/src/mastra/tools/prometheus.ts +221 -0
  19. package/src/mastra/tools/remediation.ts +365 -0
  20. package/src/mastra/tools/runbook.ts +186 -0
  21. package/src/server/routes/history.ts +207 -0
  22. package/src/server/routes/notifications.ts +236 -0
  23. package/src/server/webhook.ts +36 -2
  24. package/src/storage/index.ts +3 -0
  25. package/src/storage/investigation-history.ts +277 -0
  26. package/src/storage/runbook-index.ts +330 -0
  27. package/src/storage/types.ts +72 -0
  28. package/src/tui/app.tsx +492 -76
  29. package/src/tui/components/approval-dialog.tsx +156 -0
  30. package/src/tui/components/approval-modal.tsx +278 -0
  31. package/src/tui/components/index.ts +38 -0
  32. package/src/tui/components/styled-span.tsx +24 -0
  33. package/src/tui/components/timeline.tsx +223 -0
  34. package/src/tui/components/toast.tsx +101 -0
package/src/tui/app.tsx CHANGED
@@ -1,22 +1,74 @@
1
1
  /* @jsxImportSource @opentui/solid */
2
2
  import { render } from "@opentui/solid";
3
- import { createSignal, For, Show, onMount } from "solid-js";
4
- import { createTextAttributes } from "@opentui/core";
3
+ import { createSignal, For, Show, onMount, createMemo, type JSX, type Accessor } from "solid-js";
4
+ import { createTextAttributes, SyntaxStyle, type ThemeTokenStyle } from "@opentui/core";
5
5
  import "opentui-spinner/solid";
6
- import { marked } from "marked";
7
- import { markedTerminal } from "marked-terminal";
6
+ import { createPulse } from "opentui-spinner";
7
+ import { exec } from "child_process";
8
+ import { promisify } from "util";
8
9
  import { getDebuggerAgent, buildIncidentPrompt } from "../mastra/index.js";
9
10
  import type { IncidentInput } from "../mastra/agents/debugger.js";
11
+ import { approvalStore } from "../mastra/tools/approval-store.js";
12
+ import { ToastProvider, toast, toastSuccess, toastError, toastWarning, toastInfo } from "./components/toast.js";
13
+ import { ApprovalDialogProvider, useApprovalDialog, type RiskLevel } from "./components/approval-dialog.js";
14
+ import { StyledSpan } from "./components/styled-span.js";
10
15
 
11
- // Configure marked with terminal renderer
12
- marked.use(markedTerminal() as any);
16
+ const execAsync = promisify(exec);
13
17
 
14
- function renderMarkdown(content: string): string {
15
- try {
16
- return marked(content) as string;
17
- } catch {
18
- return content;
19
- }
18
+ const ATTR_DIM = createTextAttributes({ dim: true });
19
+
20
+ // Markdown syntax highlighting theme (matching opencode's approach)
21
+ const MARKDOWN_SYNTAX_THEME: ThemeTokenStyle[] = [
22
+ // Headings - cyan and bold
23
+ { scope: ["markup.heading", "markup.heading.1", "markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"], style: { foreground: "cyan", bold: true } },
24
+ // Bold/strong - white and bold
25
+ { scope: ["markup.strong"], style: { foreground: "white", bold: true } },
26
+ // Italic/emphasis - white and italic
27
+ { scope: ["markup.italic"], style: { foreground: "white", italic: true } },
28
+ // Inline code - yellow
29
+ { scope: ["markup.raw", "markup.raw.block"], style: { foreground: "yellow" } },
30
+ // Block quotes - gray and italic
31
+ { scope: ["markup.quote"], style: { foreground: "gray", italic: true } },
32
+ // Lists - gray
33
+ { scope: ["markup.list", "markup.list.unchecked", "markup.list.checked"], style: { foreground: "gray" } },
34
+ // Links - cyan with underline
35
+ { scope: ["markup.link", "markup.link.url"], style: { foreground: "cyan", underline: true } },
36
+ { scope: ["markup.link.label", "markup.link.bracket.close"], style: { foreground: "blue" } },
37
+ // Strikethrough - dim
38
+ { scope: ["markup.strikethrough"], style: { foreground: "gray", dim: true } },
39
+ // Code block labels (language names)
40
+ { scope: ["label"], style: { foreground: "gray", dim: true } },
41
+ // Punctuation
42
+ { scope: ["punctuation.special", "punctuation.delimiter"], style: { foreground: "gray" } },
43
+ // Escape sequences
44
+ { scope: ["string.escape"], style: { foreground: "magenta" } },
45
+ ];
46
+
47
+ // Create the SyntaxStyle instance for markdown
48
+ const markdownSyntaxStyle = SyntaxStyle.fromTheme(MARKDOWN_SYNTAX_THEME);
49
+
50
+ // Pending approval state for HITL
51
+ interface PendingApprovalState {
52
+ approvalId: string;
53
+ command: string;
54
+ riskLevel: "low" | "medium" | "high" | "critical";
55
+ selectedOption: number; // 0 = approve, 1 = reject
56
+ }
57
+
58
+ // MarkdownText component using opentui's code component with tree-sitter syntax highlighting
59
+ // This matches opencode's approach to markdown rendering
60
+ function MarkdownText(props: { content: string; streaming?: boolean }): JSX.Element {
61
+ return (
62
+ <code
63
+ filetype="markdown"
64
+ content={props.content.trim()}
65
+ syntaxStyle={markdownSyntaxStyle}
66
+ conceal={true}
67
+ drawUnstyledText={false}
68
+ streaming={props.streaming ?? false}
69
+ fg="white"
70
+ />
71
+ );
20
72
  }
21
73
 
22
74
  interface Message {
@@ -46,11 +98,29 @@ function formatHistoryAsPrompt(history: ConversationMessage[], newMessage: strin
46
98
  return `Previous conversation:\n${historyText}\n\nUser: ${newMessage}`;
47
99
  }
48
100
 
49
- type AppStatus = "idle" | "investigating" | "complete" | "error";
101
+ type AppStatus = "idle" | "investigating" | "awaiting_approval" | "complete" | "error";
50
102
 
51
- const ATTR_DIM = createTextAttributes({ dim: true });
52
103
  const ATTR_BOLD = createTextAttributes({ bold: true });
53
104
 
105
+ // Risk level color mapping
106
+ function getRiskColor(risk: PendingApprovalState["riskLevel"]): string {
107
+ switch (risk) {
108
+ case "low": return "green";
109
+ case "medium": return "yellow";
110
+ case "high": return "red";
111
+ case "critical": return "magenta";
112
+ }
113
+ }
114
+
115
+ function getRiskEmoji(risk: PendingApprovalState["riskLevel"]): string {
116
+ switch (risk) {
117
+ case "low": return "🟢";
118
+ case "medium": return "🟡";
119
+ case "high": return "🟠";
120
+ case "critical": return "🔴";
121
+ }
122
+ }
123
+
54
124
  function buildDisplayCommand(toolName: string, args: unknown): string | undefined {
55
125
  if (!args || typeof args !== "object") return undefined;
56
126
 
@@ -104,6 +174,20 @@ function App() {
104
174
  const [currentTool, setCurrentTool] = createSignal<string | null>(null);
105
175
  const [inputValue, setInputValue] = createSignal("");
106
176
  const [error, setError] = createSignal<string | null>(null);
177
+ const [kubeContext, setKubeContext] = createSignal<string>("loading...");
178
+
179
+ // Fetch kubectl context on mount
180
+ onMount(async () => {
181
+ try {
182
+ const { stdout } = await execAsync("kubectl config current-context");
183
+ setKubeContext(stdout.trim());
184
+ } catch {
185
+ setKubeContext("not connected");
186
+ }
187
+ });
188
+
189
+ // HITL approval state
190
+ const [pendingApproval, setPendingApproval] = createSignal<PendingApprovalState | null>(null);
107
191
 
108
192
  const addMessage = (msg: Omit<Message, "id" | "timestamp">) => {
109
193
  setMessages((prev) => [
@@ -116,10 +200,166 @@ function App() {
116
200
  ]);
117
201
  };
118
202
 
203
+ // Handle approval selection (Y/N or arrow keys)
204
+ const handleApprovalKey = (key: string) => {
205
+ const approval = pendingApproval();
206
+ if (!approval) return;
207
+
208
+ if (key === "ArrowUp" || key === "ArrowDown") {
209
+ // Toggle selection
210
+ setPendingApproval({ ...approval, selectedOption: approval.selectedOption === 0 ? 1 : 0 });
211
+ } else if (key === "Enter") {
212
+ // Submit selection
213
+ const approved = approval.selectedOption === 0;
214
+ handleApprovalDecision(approved);
215
+ } else if (key === "y" || key === "Y") {
216
+ // Quick approve
217
+ handleApprovalDecision(true);
218
+ } else if (key === "n" || key === "N") {
219
+ // Quick reject
220
+ handleApprovalDecision(false);
221
+ }
222
+ };
223
+
224
+ const handleApprovalDecision = async (approved: boolean) => {
225
+ const approval = pendingApproval();
226
+ if (!approval) return;
227
+
228
+ if (approved) {
229
+ // Get approval token from store
230
+ const token = approvalStore.approve(approval.approvalId);
231
+ if (!token) {
232
+ addMessage({
233
+ role: "assistant",
234
+ content: "Approval expired. Please try the operation again.",
235
+ });
236
+ setPendingApproval(null);
237
+ setStatus("complete");
238
+ toastWarning("Approval expired", "Please retry the operation");
239
+ return;
240
+ }
241
+
242
+ // Add approval message to UI
243
+ addMessage({
244
+ role: "user",
245
+ content: `✓ Approved: ${approval.command}`,
246
+ });
247
+ toastSuccess("Command approved");
248
+
249
+ // Continue the agent with the approval token
250
+ setPendingApproval(null);
251
+ const approvalMessage = `User approved the command. The approval token is: ${token}. Please execute the command: ${approval.command} with approvalToken: "${token}"`;
252
+
253
+ // Add to conversation history
254
+ setConversationHistory((prev) => [
255
+ ...prev,
256
+ { role: "user", content: approvalMessage },
257
+ ]);
258
+
259
+ // Continue investigation with the approval
260
+ setStatus("investigating");
261
+ continueWithApproval(approvalMessage);
262
+ } else {
263
+ // Reject
264
+ approvalStore.reject(approval.approvalId);
265
+ addMessage({
266
+ role: "user",
267
+ content: `✗ Rejected: ${approval.command}`,
268
+ });
269
+ addMessage({
270
+ role: "assistant",
271
+ content: "Command rejected by user. How would you like to proceed?",
272
+ });
273
+ setPendingApproval(null);
274
+ setStatus("complete");
275
+ toastWarning("Command rejected");
276
+ }
277
+ };
278
+
279
+ const continueWithApproval = async (message: string) => {
280
+ try {
281
+ const agent = getDebuggerAgent();
282
+ let assistantContent = "";
283
+
284
+ const prompt = formatHistoryAsPrompt(conversationHistory(), message);
285
+
286
+ const stream = await agent.stream(prompt, {
287
+ maxSteps: 20,
288
+ onStepFinish: ({ toolCalls, toolResults }) => {
289
+ if (toolCalls && toolCalls.length > 0) {
290
+ // Mastra wraps tool calls: toolCall.payload contains the actual data
291
+ const toolCallChunk = toolCalls[0] as { payload?: { toolName?: string; args?: unknown } };
292
+ const toolName = toolCallChunk?.payload?.toolName ?? "tool";
293
+ const args = toolCallChunk?.payload?.args ?? {};
294
+
295
+ const command = buildDisplayCommand(toolName, args);
296
+
297
+ setCurrentTool(toolName);
298
+ addMessage({
299
+ role: "tool",
300
+ content: command ? `$ ${command}` : `Executing ${toolName}...`,
301
+ toolName,
302
+ command,
303
+ });
304
+ }
305
+
306
+ // Check for approval requirement
307
+ if (toolResults && toolResults.length > 0) {
308
+ for (const toolResult of toolResults) {
309
+ // Mastra wraps results: toolResult.payload.result contains the actual data
310
+ const tr = toolResult as any;
311
+ const data = tr?.payload?.result ?? tr?.result ?? tr;
312
+
313
+ if (data?.requiresApproval && data?.approvalId) {
314
+ setPendingApproval({
315
+ approvalId: data.approvalId,
316
+ command: data.command || "unknown command",
317
+ riskLevel: (data.riskLevel as PendingApprovalState["riskLevel"]) || "medium",
318
+ selectedOption: 0, // Default to approve
319
+ });
320
+ setStatus("awaiting_approval");
321
+ }
322
+ }
323
+ }
324
+ },
325
+ });
326
+
327
+ for await (const chunk of stream.textStream) {
328
+ assistantContent += chunk;
329
+ }
330
+
331
+ if (status() !== "awaiting_approval") {
332
+ addMessage({
333
+ role: "assistant",
334
+ content: assistantContent,
335
+ });
336
+
337
+ setConversationHistory((prev) => [
338
+ ...prev,
339
+ { role: "assistant", content: assistantContent },
340
+ ]);
341
+
342
+ setStatus("complete");
343
+ toastSuccess("Command executed");
344
+ }
345
+ setCurrentTool(null);
346
+ } catch (err) {
347
+ const errorMsg = err instanceof Error ? err.message : String(err);
348
+ setError(errorMsg);
349
+ setStatus("error");
350
+ toastError("Execution failed", errorMsg);
351
+ addMessage({
352
+ role: "assistant",
353
+ content: `Error: ${errorMsg}`,
354
+ });
355
+ }
356
+ };
357
+
119
358
  const investigate = async (incident: IncidentInput) => {
120
359
  setStatus("investigating");
121
360
  setError(null);
122
361
  setCurrentTool(null);
362
+ toastInfo("Investigation started", incident.title);
123
363
 
124
364
  // Add user message to UI
125
365
  addMessage({
@@ -150,11 +390,14 @@ function App() {
150
390
  // Send the formatted prompt to the agent
151
391
  const stream = await agent.stream(prompt, {
152
392
  maxSteps: 20,
153
- onStepFinish: ({ toolCalls }) => {
393
+ onStepFinish: (stepResult) => {
394
+ const { toolCalls, toolResults } = stepResult;
395
+
154
396
  if (toolCalls && toolCalls.length > 0) {
155
- const toolCall = toolCalls[0] as { toolName?: string; args?: unknown };
156
- const toolName = toolCall.toolName ?? "tool";
157
- const args = toolCall.args ?? {};
397
+ // Mastra wraps tool calls: toolCall.payload contains the actual data
398
+ const toolCallChunk = toolCalls[0] as { payload?: { toolName?: string; args?: unknown } };
399
+ const toolName = toolCallChunk?.payload?.toolName ?? "tool";
400
+ const args = toolCallChunk?.payload?.args ?? {};
158
401
 
159
402
  // Build display command based on tool type
160
403
  const command = buildDisplayCommand(toolName, args);
@@ -167,6 +410,25 @@ function App() {
167
410
  command,
168
411
  });
169
412
  }
413
+
414
+ // Check for approval requirement in tool results
415
+ if (toolResults && toolResults.length > 0) {
416
+ for (const toolResult of toolResults) {
417
+ // Mastra wraps results: toolResult.payload.result contains the actual data
418
+ const tr = toolResult as any;
419
+ const data = tr?.payload?.result ?? tr?.result ?? tr;
420
+
421
+ if (data?.requiresApproval && data?.approvalId) {
422
+ setPendingApproval({
423
+ approvalId: data.approvalId,
424
+ command: data.command || "unknown command",
425
+ riskLevel: (data.riskLevel as PendingApprovalState["riskLevel"]) || "medium",
426
+ selectedOption: 0, // Default to approve
427
+ });
428
+ setStatus("awaiting_approval");
429
+ }
430
+ }
431
+ }
170
432
  },
171
433
  });
172
434
 
@@ -174,24 +436,29 @@ function App() {
174
436
  assistantContent += chunk;
175
437
  }
176
438
 
177
- // Add assistant response to UI
178
- addMessage({
179
- role: "assistant",
180
- content: assistantContent,
181
- });
182
-
183
- // Add assistant response to conversation history
184
- setConversationHistory((prev) => [
185
- ...prev,
186
- { role: "assistant", content: assistantContent },
187
- ]);
188
-
189
- setStatus("complete");
439
+ // Only add response and set complete if not awaiting approval
440
+ if (status() !== "awaiting_approval") {
441
+ // Add assistant response to UI
442
+ addMessage({
443
+ role: "assistant",
444
+ content: assistantContent,
445
+ });
446
+
447
+ // Add assistant response to conversation history
448
+ setConversationHistory((prev) => [
449
+ ...prev,
450
+ { role: "assistant", content: assistantContent },
451
+ ]);
452
+
453
+ setStatus("complete");
454
+ toastSuccess("Investigation complete");
455
+ }
190
456
  setCurrentTool(null);
191
457
  } catch (err) {
192
458
  const errorMsg = err instanceof Error ? err.message : String(err);
193
459
  setError(errorMsg);
194
460
  setStatus("error");
461
+ toastError("Investigation failed", errorMsg);
195
462
  addMessage({
196
463
  role: "assistant",
197
464
  content: `Error: ${errorMsg}`,
@@ -219,6 +486,8 @@ function App() {
219
486
  switch (status()) {
220
487
  case "investigating":
221
488
  return "yellow";
489
+ case "awaiting_approval":
490
+ return "red";
222
491
  case "complete":
223
492
  return "green";
224
493
  case "error":
@@ -232,6 +501,8 @@ function App() {
232
501
  switch (status()) {
233
502
  case "investigating":
234
503
  return currentTool() ? `Running: ${currentTool()}` : "Investigating...";
504
+ case "awaiting_approval":
505
+ return "Awaiting Approval";
235
506
  case "complete":
236
507
  return "Complete";
237
508
  case "error":
@@ -249,16 +520,29 @@ function App() {
249
520
  borderColor="red"
250
521
  paddingLeft={2}
251
522
  paddingRight={2}
252
- flexDirection="row"
253
- justifyContent="space-between"
523
+ paddingTop={1}
524
+ paddingBottom={1}
525
+ flexDirection="column"
254
526
  >
255
- <text fg="red" attributes={ATTR_BOLD}>
256
- TRIAGENT
257
- </text>
258
- <text fg="gray">Kubernetes Debugging Agent</text>
259
- <text fg={getStatusColor()} attributes={ATTR_BOLD}>
260
- [{getStatusText()}]
261
- </text>
527
+ <box flexDirection="row" justifyContent="space-between">
528
+ <box flexDirection="row" gap={2}>
529
+ <text fg="red" attributes={ATTR_BOLD}>
530
+
531
+ </text>
532
+ <text fg="red" attributes={ATTR_BOLD}>
533
+ TRIAGENT
534
+ </text>
535
+ </box>
536
+ <text fg={getStatusColor()} attributes={ATTR_BOLD}>
537
+ [{getStatusText()}]
538
+ </text>
539
+ </box>
540
+ <box flexDirection="row" justifyContent="space-between">
541
+ <text fg="gray">Kubernetes Debugging Agent</text>
542
+ <text fg="cyan">
543
+ cluster: {kubeContext()}
544
+ </text>
545
+ </box>
262
546
  </box>
263
547
 
264
548
  {/* Messages Area */}
@@ -296,10 +580,12 @@ function App() {
296
580
  <Show when={msg.role === "tool"}>
297
581
  <box flexDirection="row" gap={1} alignItems="center">
298
582
  <Show
299
- when={status() === "investigating" && msg.id === messages().filter(m => m.role === "tool").at(-1)?.id}
583
+ when={(status() === "investigating" || status() === "awaiting_approval") && msg.id === messages().filter(m => m.role === "tool").at(-1)?.id}
300
584
  fallback={<text fg="green">✓</text>}
301
585
  >
302
- <spinner name="dots" color="blue" />
586
+ <Show when={status() === "awaiting_approval"} fallback={<spinner name="dots" color={createPulse(["cyan", "blue", "magenta"], 200)} />}>
587
+ <text fg="yellow">⏸</text>
588
+ </Show>
303
589
  </Show>
304
590
  <text fg="blue" attributes={ATTR_DIM}>
305
591
  [{msg.toolName}]
@@ -314,47 +600,162 @@ function App() {
314
600
  <text fg="green" attributes={ATTR_BOLD}>
315
601
  Triagent:
316
602
  </text>
317
- <text fg="white" wrapMode="word">
318
- {renderMarkdown(msg.content)}
319
- </text>
603
+ <MarkdownText content={msg.content} />
320
604
  </box>
321
605
  </Show>
322
606
  </box>
323
607
  )}
324
608
  </For>
609
+
610
+ {/* Approval prompt - Claude Code style */}
611
+ <Show when={pendingApproval()}>
612
+ {(approval: Accessor<PendingApprovalState>) => (
613
+ <box
614
+ flexDirection="column"
615
+ borderStyle="single"
616
+ borderColor={getRiskColor(approval().riskLevel)}
617
+ paddingLeft={1}
618
+ paddingRight={1}
619
+ paddingTop={1}
620
+ paddingBottom={1}
621
+ marginTop={1}
622
+ >
623
+ {/* Header */}
624
+ <box flexDirection="row" gap={1} marginBottom={1}>
625
+ <text fg={getRiskColor(approval().riskLevel)}>
626
+ {getRiskEmoji(approval().riskLevel)}
627
+ </text>
628
+ <text fg="white" attributes={ATTR_BOLD}>
629
+ Write Operation Requires Approval
630
+ </text>
631
+ <text fg="gray" attributes={ATTR_DIM}>
632
+ ({approval().riskLevel} risk)
633
+ </text>
634
+ </box>
635
+
636
+ {/* Command */}
637
+ <box flexDirection="column" marginBottom={1}>
638
+ <text fg="cyan" attributes={ATTR_BOLD}>Command:</text>
639
+ <text fg="yellow">{approval().command}</text>
640
+ </box>
641
+
642
+ {/* Options */}
643
+ <box flexDirection="column" gap={1}>
644
+ <box flexDirection="row" gap={1}>
645
+ <text fg={approval().selectedOption === 0 ? "green" : "gray"}>
646
+ {approval().selectedOption === 0 ? "●" : "○"}
647
+ </text>
648
+ <text
649
+ fg={approval().selectedOption === 0 ? "green" : "white"}
650
+ attributes={approval().selectedOption === 0 ? ATTR_BOLD : undefined}
651
+ >
652
+ Yes, execute this command
653
+ </text>
654
+ </box>
655
+ <box flexDirection="row" gap={1}>
656
+ <text fg={approval().selectedOption === 1 ? "red" : "gray"}>
657
+ {approval().selectedOption === 1 ? "●" : "○"}
658
+ </text>
659
+ <text
660
+ fg={approval().selectedOption === 1 ? "red" : "white"}
661
+ attributes={approval().selectedOption === 1 ? ATTR_BOLD : undefined}
662
+ >
663
+ No, cancel this operation
664
+ </text>
665
+ </box>
666
+ </box>
667
+
668
+ {/* Instructions */}
669
+ <text fg="gray" attributes={ATTR_DIM} marginTop={1}>
670
+ Use ↑↓ to select, Enter to confirm, or press Y/N
671
+ </text>
672
+ </box>
673
+ )}
674
+ </Show>
325
675
  </Show>
326
676
  </box>
327
677
  </scrollbox>
328
678
 
329
679
  {/* Input Area */}
330
- <box
331
- borderStyle="single"
332
- borderColor={status() === "investigating" ? "yellow" : "cyan"}
333
- paddingLeft={1}
334
- paddingRight={1}
335
- flexDirection="row"
336
- gap={1}
680
+ <Show
681
+ when={status() === "awaiting_approval"}
682
+ fallback={
683
+ <box
684
+ borderStyle="single"
685
+ borderColor={status() === "investigating" ? "yellow" : "cyan"}
686
+ paddingLeft={1}
687
+ paddingRight={1}
688
+ flexDirection="row"
689
+ gap={1}
690
+ >
691
+ <text fg="cyan" attributes={ATTR_BOLD}>
692
+ {">"}
693
+ </text>
694
+ <input
695
+ flexGrow={1}
696
+ focused={true}
697
+ value={inputValue()}
698
+ onInput={handleInput}
699
+ onSubmit={handleSubmit}
700
+ placeholder={
701
+ status() === "investigating"
702
+ ? "Investigating..."
703
+ : "Describe the incident..."
704
+ }
705
+ textColor="white"
706
+ placeholderColor="gray"
707
+ focusedTextColor="white"
708
+ focusedBackgroundColor="#1a1a1a"
709
+ />
710
+ </box>
711
+ }
337
712
  >
338
- <text fg="cyan" attributes={ATTR_BOLD}>
339
- {">"}
340
- </text>
341
- <input
342
- flexGrow={1}
343
- focused={true}
344
- value={inputValue()}
345
- onInput={handleInput}
346
- onSubmit={handleSubmit}
347
- placeholder={
348
- status() === "investigating"
349
- ? "Investigating..."
350
- : "Describe the incident..."
351
- }
352
- textColor="white"
353
- placeholderColor="gray"
354
- focusedTextColor="white"
355
- focusedBackgroundColor="#1a1a1a"
356
- />
357
- </box>
713
+ {/* Approval mode input */}
714
+ <box
715
+ borderStyle="single"
716
+ borderColor="red"
717
+ paddingLeft={1}
718
+ paddingRight={1}
719
+ flexDirection="row"
720
+ gap={1}
721
+ >
722
+ <text fg="red" attributes={ATTR_BOLD}>
723
+ ?
724
+ </text>
725
+ <input
726
+ flexGrow={1}
727
+ focused={true}
728
+ value=""
729
+ onInput={(value) => {
730
+ // Handle Y/N keys
731
+ if (value.toLowerCase() === "y") {
732
+ handleApprovalDecision(true);
733
+ } else if (value.toLowerCase() === "n") {
734
+ handleApprovalDecision(false);
735
+ }
736
+ }}
737
+ onKeyDown={(key) => {
738
+ // KeyEvent has a 'name' property
739
+ if (key.name === "up" || key.name === "down") {
740
+ const approval = pendingApproval();
741
+ if (approval) {
742
+ setPendingApproval({ ...approval, selectedOption: approval.selectedOption === 0 ? 1 : 0 });
743
+ }
744
+ } else if (key.name === "return") {
745
+ const approval = pendingApproval();
746
+ if (approval) {
747
+ handleApprovalDecision(approval.selectedOption === 0);
748
+ }
749
+ }
750
+ }}
751
+ placeholder="Press Y to approve, N to reject, or use ↑↓ and Enter"
752
+ textColor="white"
753
+ placeholderColor="gray"
754
+ focusedTextColor="white"
755
+ focusedBackgroundColor="#1a1a1a"
756
+ />
757
+ </box>
758
+ </Show>
358
759
 
359
760
  {/* Footer */}
360
761
  <box
@@ -363,9 +764,18 @@ function App() {
363
764
  flexDirection="row"
364
765
  justifyContent="space-between"
365
766
  >
366
- <text fg="gray" attributes={ATTR_DIM}>
367
- Press Enter to submit | Ctrl+C to quit
368
- </text>
767
+ <Show
768
+ when={status() === "awaiting_approval"}
769
+ fallback={
770
+ <text fg="gray" attributes={ATTR_DIM}>
771
+ Press Enter to submit | Ctrl+C to quit
772
+ </text>
773
+ }
774
+ >
775
+ <text fg="yellow" attributes={ATTR_BOLD}>
776
+ ⚠ Approval required: Y/N or ↑↓ + Enter
777
+ </text>
778
+ </Show>
369
779
  <text fg="gray" attributes={ATTR_DIM}>
370
780
  {messages().length} messages
371
781
  </text>
@@ -380,7 +790,13 @@ export interface TUIHandle {
380
790
  }
381
791
 
382
792
  export async function runTUI(): Promise<TUIHandle> {
383
- await render(() => <App />);
793
+ await render(() => (
794
+ <ToastProvider>
795
+ <ApprovalDialogProvider>
796
+ <App />
797
+ </ApprovalDialogProvider>
798
+ </ToastProvider>
799
+ ));
384
800
 
385
801
  return {
386
802
  shutdown: () => {