@makefinks/daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Component for displaying token usage statistics.
3
+ */
4
+
5
+ import { COLORS } from "../ui/constants";
6
+ import { formatTokenCount } from "../utils/formatters";
7
+ import { calculateCost, formatCost, formatContextUsage, type ModelMetadata } from "../utils/model-metadata";
8
+ import type { TokenUsage } from "../types";
9
+
10
+ interface TokenUsageDisplayProps {
11
+ usage: TokenUsage;
12
+ modelMetadata?: ModelMetadata | null;
13
+ }
14
+
15
+ export function TokenUsageDisplay({ usage, modelMetadata }: TokenUsageDisplayProps) {
16
+ const mainPromptTokens = usage.promptTokens;
17
+ const mainCompletionTokens = usage.completionTokens;
18
+ const mainTotalTokens = mainPromptTokens + mainCompletionTokens;
19
+
20
+ // Calculate cost if we have pricing info
21
+ const cost =
22
+ typeof usage.cost === "number"
23
+ ? usage.cost
24
+ : modelMetadata?.pricing
25
+ ? calculateCost(
26
+ mainPromptTokens + (usage.subagentPromptTokens ?? 0),
27
+ mainCompletionTokens + (usage.subagentCompletionTokens ?? 0),
28
+ modelMetadata.pricing,
29
+ usage.cachedInputTokens
30
+ )
31
+ : null;
32
+
33
+ // Calculate context usage percentage
34
+ const contextTotalTokens = mainTotalTokens;
35
+ const contextUsage = modelMetadata?.contextLength
36
+ ? formatContextUsage(contextTotalTokens, modelMetadata.contextLength)
37
+ : null;
38
+
39
+ return (
40
+ <box
41
+ height={1}
42
+ width="100%"
43
+ flexShrink={0}
44
+ flexDirection="row"
45
+ justifyContent="center"
46
+ alignItems="center"
47
+ >
48
+ <text>
49
+ {/* Context usage: X/Y (Z%) */}
50
+ {modelMetadata?.contextLength && (
51
+ <>
52
+ <span fg={COLORS.TOKEN_USAGE_LABEL}>Tokens: </span>
53
+ <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(contextTotalTokens)}</span>
54
+ <span fg={COLORS.TOKEN_USAGE_LABEL}>/</span>
55
+ <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(modelMetadata.contextLength)}</span>
56
+ <span fg={COLORS.REASONING_DIM}> ({contextUsage})</span>
57
+ <span fg={COLORS.TOKEN_USAGE_LABEL}> · </span>
58
+ </>
59
+ )}
60
+
61
+ {/* Token breakdown: in/out */}
62
+ <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(mainPromptTokens)} in</span>
63
+ <span fg={COLORS.TOKEN_USAGE_LABEL}> / </span>
64
+ <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(mainCompletionTokens)} out</span>
65
+
66
+ {/* Reasoning tokens */}
67
+ {usage.reasoningTokens !== undefined && usage.reasoningTokens > 0 && (
68
+ <>
69
+ <span fg={COLORS.TOKEN_USAGE_LABEL}> / </span>
70
+ <span fg={COLORS.REASONING_DIM}>{formatTokenCount(usage.reasoningTokens)} reasoning</span>
71
+ </>
72
+ )}
73
+
74
+ {/* Cached tokens */}
75
+ {usage.cachedInputTokens !== undefined && usage.cachedInputTokens > 0 && (
76
+ <>
77
+ <span fg={COLORS.TOKEN_USAGE_LABEL}> / </span>
78
+ <span fg={COLORS.DAEMON_LABEL}>{formatTokenCount(usage.cachedInputTokens)} cached</span>
79
+ </>
80
+ )}
81
+
82
+ {/* Cost */}
83
+ {cost !== null && (
84
+ <>
85
+ <span fg={COLORS.TOKEN_USAGE_LABEL}> · </span>
86
+ <span fg={COLORS.TYPING_PROMPT}>{formatCost(cost)}</span>
87
+ </>
88
+ )}
89
+ </text>
90
+ </box>
91
+ );
92
+ }
@@ -0,0 +1,113 @@
1
+ import { useMemo } from "react";
2
+ import { COLORS } from "../ui/constants";
3
+ import type { ToolCall } from "../types";
4
+ import {
5
+ getToolLayout,
6
+ defaultToolLayout,
7
+ getDefaultAbbreviation,
8
+ ToolHeaderView,
9
+ ToolBodyView,
10
+ ResultPreviewView,
11
+ ErrorPreviewView,
12
+ getStatusBorderColor,
13
+ } from "./tool-layouts";
14
+ import { ApprovalPicker } from "./ApprovalPicker";
15
+ import { useToolApprovalForCall } from "../hooks/use-tool-approval";
16
+
17
+ interface ToolCallViewProps {
18
+ call: ToolCall;
19
+ result?: unknown;
20
+ showOutput?: boolean;
21
+ }
22
+
23
+ function ApprovalResultBadge({ result }: { result: "approved" | "denied" }) {
24
+ const isApproved = result === "approved";
25
+ const color = isApproved ? COLORS.STATUS_COMPLETED : COLORS.STATUS_FAILED;
26
+ const label = isApproved ? "APPROVED" : "DENIED";
27
+
28
+ return (
29
+ <box marginTop={1}>
30
+ <text>
31
+ <span fg={color}>
32
+ {">> "}
33
+ {label}
34
+ </span>
35
+ </text>
36
+ </box>
37
+ );
38
+ }
39
+
40
+ export function ToolCallView({ call, result, showOutput = true }: ToolCallViewProps) {
41
+ const layout = getToolLayout(call.name) ?? defaultToolLayout;
42
+ const isAwaitingApproval = call.status === "awaiting_approval";
43
+ const isRunning = call.status === "running" || call.status === "streaming";
44
+ const isFailed = call.status === "failed";
45
+
46
+ const { needsApproval, isActive, approve, deny, approveAll, denyAll } = useToolApprovalForCall(
47
+ call.toolCallId
48
+ );
49
+
50
+ const header = useMemo(() => layout.getHeader?.(call.input, result) ?? null, [call.input, result, layout]);
51
+
52
+ const body = useMemo(
53
+ () => layout.getBody?.(call.input, result, call) ?? null,
54
+ [call.input, result, call, layout]
55
+ );
56
+
57
+ const resultPreviewLines = useMemo(() => {
58
+ if (!showOutput) return null;
59
+ return layout.formatResult?.(result) ?? null;
60
+ }, [result, showOutput, layout]);
61
+
62
+ const toolColor =
63
+ call.status === "completed"
64
+ ? COLORS.STATUS_COMPLETED
65
+ : isAwaitingApproval
66
+ ? COLORS.STATUS_APPROVAL
67
+ : COLORS.TOOLS;
68
+ const toolName = layout.abbreviation ?? getDefaultAbbreviation(call.name);
69
+ const borderColor = getStatusBorderColor(call.status);
70
+
71
+ const customBody = useMemo(() => {
72
+ if (!layout.renderBody) return null;
73
+ return layout.renderBody({ call, result, showOutput });
74
+ }, [layout, call, result, showOutput]);
75
+
76
+ return (
77
+ <box
78
+ flexDirection="column"
79
+ backgroundColor={COLORS.TOOL_INPUT_BG}
80
+ borderStyle="single"
81
+ borderColor={borderColor}
82
+ paddingLeft={1}
83
+ paddingRight={1}
84
+ paddingTop={0}
85
+ paddingBottom={0}
86
+ width="100%"
87
+ >
88
+ <ToolHeaderView toolName={toolName} header={header} isRunning={isRunning} toolColor={toolColor} />
89
+
90
+ {customBody}
91
+
92
+ {!customBody && body && <ToolBodyView body={body} />}
93
+
94
+ {needsApproval && (
95
+ <ApprovalPicker
96
+ onApprove={approve}
97
+ onDeny={deny}
98
+ onApproveAll={approveAll}
99
+ onDenyAll={denyAll}
100
+ focused={isActive}
101
+ />
102
+ )}
103
+
104
+ {showOutput && resultPreviewLines && resultPreviewLines.length > 0 && (
105
+ <ResultPreviewView lines={resultPreviewLines} />
106
+ )}
107
+
108
+ {isFailed && call.error && <ErrorPreviewView error={call.error} />}
109
+
110
+ {call.approvalResult && <ApprovalResultBadge result={call.approvalResult} />}
111
+ </box>
112
+ );
113
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Shared typing input row used in both initial and conversation layouts.
3
+ * Ensures the OpenTUI input has a usable width and supports multi-line text.
4
+ */
5
+
6
+ import type { KeyBinding, TextareaRenderable, PasteEvent, KeyEvent } from "@opentui/core";
7
+ import { useRef, type RefObject } from "react";
8
+ import { COLORS } from "../ui/constants";
9
+ import { debug } from "../utils/debug-logger";
10
+ import { pasteClipboardIntoTextarea } from "../utils/paste";
11
+
12
+ export interface TypingInputBarProps {
13
+ onSubmit: () => void;
14
+ onContentChange?: (value: string) => void;
15
+ onHistoryUp?: () => void;
16
+ onHistoryDown?: () => void;
17
+ placeholder?: string;
18
+ width?: number | "auto" | `${number}%`;
19
+ maxWidth?: number | "auto" | `${number}%`;
20
+ minWidth?: number | "auto" | `${number}%`;
21
+ height?: number;
22
+ textareaRef?: RefObject<TextareaRenderable | null>;
23
+ keyBindings?: KeyBinding[];
24
+ }
25
+
26
+ export function TypingInputBar({
27
+ onSubmit,
28
+ onContentChange,
29
+ onHistoryUp,
30
+ onHistoryDown,
31
+ placeholder = "",
32
+ width = "100%",
33
+ maxWidth = "100%",
34
+ minWidth = 20,
35
+ height = 4,
36
+ textareaRef,
37
+ keyBindings = [
38
+ { name: "return", action: "submit" },
39
+ { name: "linefeed", action: "newline" },
40
+ ],
41
+ }: TypingInputBarProps) {
42
+ const localRef = useRef<TextareaRenderable | null>(null);
43
+ const activeRef = textareaRef ?? localRef;
44
+
45
+ const handleContentChange = () => {
46
+ const text = activeRef.current?.plainText ?? "";
47
+ onContentChange?.(text);
48
+ };
49
+
50
+ const handlePaste = (event: PasteEvent) => {
51
+ debug.log("[TypingInputBar] onPaste received", {
52
+ textLength: event.text.length,
53
+ textPreview: event.text.slice(0, 50),
54
+ defaultPrevented: event.defaultPrevented,
55
+ });
56
+ if (!event.text.trim()) {
57
+ event.preventDefault();
58
+ void pasteClipboardIntoTextarea(activeRef.current, { source: "typing-onPaste" });
59
+ }
60
+ // Otherwise, let the textarea handle it
61
+ };
62
+
63
+ const handleKeyDown = (key: KeyEvent) => {
64
+ if (key.eventType !== "press") return;
65
+
66
+ if (key.name === "up" && onHistoryUp) {
67
+ key.preventDefault();
68
+ onHistoryUp();
69
+ return;
70
+ }
71
+ if (key.name === "down" && onHistoryDown) {
72
+ key.preventDefault();
73
+ onHistoryDown();
74
+ return;
75
+ }
76
+
77
+ if (key.name !== "v") return;
78
+ if (!(key.ctrl || key.meta || key.super)) return;
79
+ key.preventDefault();
80
+ void pasteClipboardIntoTextarea(activeRef.current, { source: "typing-shortcut" });
81
+ };
82
+
83
+ return (
84
+ <box flexDirection="column" width={width} maxWidth={maxWidth} minWidth={minWidth}>
85
+ <box
86
+ border={true}
87
+ borderStyle="single"
88
+ borderColor="#3f4651"
89
+ backgroundColor="#0d0d14"
90
+ flexDirection="row"
91
+ alignItems="stretch"
92
+ width="100%"
93
+ height={height}
94
+ paddingLeft={1}
95
+ paddingRight={1}
96
+ >
97
+ <box paddingTop={0} paddingRight={2}>
98
+ <text>
99
+ <span fg="#6b7280">{">"}</span>
100
+ </text>
101
+ </box>
102
+ <textarea
103
+ ref={activeRef}
104
+ placeholder={placeholder}
105
+ focused={true}
106
+ wrapMode="word"
107
+ keyBindings={keyBindings}
108
+ onContentChange={handleContentChange}
109
+ onPaste={handlePaste}
110
+ onKeyDown={handleKeyDown}
111
+ onSubmit={() => onSubmit()}
112
+ width="100%"
113
+ height="100%"
114
+ style={{
115
+ backgroundColor: "transparent",
116
+ focusedBackgroundColor: "transparent",
117
+ textColor: "#9ca3af",
118
+ focusedTextColor: "#e5e7eb",
119
+ cursorColor: COLORS.TYPING_PROMPT,
120
+ cursorStyle: { style: "block", blinking: true },
121
+ }}
122
+ />
123
+ </box>
124
+ <box justifyContent="center" width="100%" marginTop={0}>
125
+ <text>
126
+ <span fg="#4b5563">Ctrl+V to paste · Ctrl+J for new line</span>
127
+ </text>
128
+ </box>
129
+ </box>
130
+ );
131
+ }
@@ -0,0 +1,120 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { COLORS } from "../../ui/constants";
3
+ import type { ToolHeader, ToolBody, ToolBodyLine } from "./types";
4
+ import type { ToolCallStatus } from "../../types";
5
+
6
+ interface ToolHeaderViewProps {
7
+ toolName: string;
8
+ header: ToolHeader | null;
9
+ isRunning: boolean;
10
+ toolColor: string;
11
+ }
12
+
13
+ export function ToolHeaderView({ toolName, header, isRunning, toolColor }: ToolHeaderViewProps) {
14
+ return (
15
+ <box flexDirection="row" alignItems="center" justifyContent="space-between" width="100%">
16
+ <text>
17
+ <span fg={toolColor}>{"↯ "}</span>
18
+ <span fg={toolColor}>{toolName}</span>
19
+ {header?.primary && <span fg={COLORS.TOOL_INPUT_TEXT}>{` ${header.primary}`}</span>}
20
+ {header?.secondary && (
21
+ <span
22
+ fg={COLORS.REASONING_DIM}
23
+ attributes={header.secondaryStyle === "italic" ? TextAttributes.ITALIC : TextAttributes.NONE}
24
+ >
25
+ {` ${header.secondary}`}
26
+ </span>
27
+ )}
28
+ </text>
29
+ {isRunning && <spinner name="dots" color={COLORS.STATUS_RUNNING} />}
30
+ </box>
31
+ );
32
+ }
33
+
34
+ interface ToolBodyViewProps {
35
+ body: ToolBody;
36
+ }
37
+
38
+ function getLineColor(line: ToolBodyLine): string {
39
+ if (line.color) return line.color;
40
+ if (line.status) {
41
+ switch (line.status) {
42
+ case "running":
43
+ return COLORS.STATUS_RUNNING;
44
+ case "completed":
45
+ return COLORS.STATUS_COMPLETED;
46
+ case "failed":
47
+ return COLORS.STATUS_FAILED;
48
+ default:
49
+ return COLORS.STATUS_PENDING;
50
+ }
51
+ }
52
+ return COLORS.TOOL_INPUT_TEXT;
53
+ }
54
+
55
+ export function ToolBodyView({ body }: ToolBodyViewProps) {
56
+ return (
57
+ <box flexDirection="column" paddingLeft={2} marginTop={0}>
58
+ {body.lines.map((line, idx) => (
59
+ <box key={idx} flexDirection="row" alignItems="center">
60
+ {line.status === "running" ? (
61
+ <spinner name="dots" color={getLineColor(line)} />
62
+ ) : line.icon ? (
63
+ <text>
64
+ <span fg={getLineColor(line)}>{line.icon}</span>
65
+ </text>
66
+ ) : null}
67
+ <text marginLeft={line.icon || line.status === "running" ? 1 : 0}>
68
+ <span fg={getLineColor(line)} attributes={line.attributes ?? TextAttributes.NONE}>
69
+ {line.text}
70
+ </span>
71
+ </text>
72
+ </box>
73
+ ))}
74
+ </box>
75
+ );
76
+ }
77
+
78
+ interface ResultPreviewViewProps {
79
+ lines: string[];
80
+ }
81
+
82
+ export function ResultPreviewView({ lines }: ResultPreviewViewProps) {
83
+ return (
84
+ <box flexDirection="column" paddingLeft={2}>
85
+ {lines.map((line, idx) => (
86
+ <text key={idx}>
87
+ <span fg={COLORS.REASONING_DIM}>{`› ${line}`}</span>
88
+ </text>
89
+ ))}
90
+ </box>
91
+ );
92
+ }
93
+
94
+ interface ErrorPreviewViewProps {
95
+ error: string;
96
+ maxLength?: number;
97
+ }
98
+
99
+ export function ErrorPreviewView({ error, maxLength = 120 }: ErrorPreviewViewProps) {
100
+ const displayError = error.length > maxLength ? `${error.slice(0, maxLength)}…` : error;
101
+
102
+ return (
103
+ <box flexDirection="column" paddingLeft={2}>
104
+ <text>
105
+ <span fg={COLORS.STATUS_FAILED}>{`⚠ ${displayError}`}</span>
106
+ </text>
107
+ </box>
108
+ );
109
+ }
110
+
111
+ export function getStatusBorderColor(status: ToolCallStatus | undefined): string {
112
+ switch (status) {
113
+ case "completed":
114
+ return COLORS.TOOL_INPUT_BORDER;
115
+ case "failed":
116
+ return COLORS.STATUS_FAILED;
117
+ default:
118
+ return COLORS.TOOL_INPUT_BORDER;
119
+ }
120
+ }
@@ -0,0 +1,9 @@
1
+ import type { ToolLayoutConfig } from "./types";
2
+
3
+ export const defaultToolLayout: ToolLayoutConfig = {
4
+ abbreviation: "tool",
5
+ };
6
+
7
+ export function getDefaultAbbreviation(toolName: string): string {
8
+ return toolName.length > 8 ? toolName.slice(0, 8) : toolName;
9
+ }
@@ -0,0 +1,22 @@
1
+ export type {
2
+ ToolLayoutConfig,
3
+ ToolHeader,
4
+ ToolBody,
5
+ ToolBodyLine,
6
+ ToolLayoutRenderProps,
7
+ ToolLayoutRegistry,
8
+ } from "./types";
9
+
10
+ export { registerToolLayout, getToolLayout, hasToolLayout, registry } from "./registry";
11
+
12
+ export { defaultToolLayout, getDefaultAbbreviation } from "./defaults";
13
+
14
+ export {
15
+ ToolHeaderView,
16
+ ToolBodyView,
17
+ ResultPreviewView,
18
+ ErrorPreviewView,
19
+ getStatusBorderColor,
20
+ } from "./components";
21
+
22
+ import "./layouts";
@@ -0,0 +1,110 @@
1
+ import type { ToolLayoutConfig, ToolHeader, ToolBody } from "../types";
2
+ import { registerToolLayout } from "../registry";
3
+
4
+ type UnknownRecord = Record<string, unknown>;
5
+
6
+ function isRecord(value: unknown): value is UnknownRecord {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ interface BashInput {
11
+ command: string;
12
+ description: string;
13
+ }
14
+
15
+ function extractBashInput(input: unknown): BashInput | null {
16
+ if (!isRecord(input)) return null;
17
+ if (!("command" in input) || typeof input.command !== "string") return null;
18
+ const description =
19
+ "description" in input && typeof input.description === "string" ? input.description : "";
20
+ return { command: input.command, description };
21
+ }
22
+
23
+ function pickFirstNonEmpty(...parts: Array<string | undefined | null>): string {
24
+ for (const part of parts) {
25
+ if (typeof part === "string" && part.trim().length > 0) return part;
26
+ }
27
+ return "";
28
+ }
29
+
30
+ function formatBashResult(result: unknown): string[] | null {
31
+ if (!isRecord(result)) return null;
32
+ const success = "success" in result ? result.success : undefined;
33
+ const exitCode =
34
+ typeof result.exitCode === "number" || result.exitCode === null ? result.exitCode : undefined;
35
+
36
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
37
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
38
+ const error = typeof result.error === "string" ? result.error : "";
39
+
40
+ const body = pickFirstNonEmpty(stdout, stderr, error);
41
+ if (!body) {
42
+ if (typeof success === "boolean") {
43
+ const line = `success=${success}${exitCode !== undefined ? ` exit=${String(exitCode)}` : ""}`;
44
+ return [line];
45
+ }
46
+ return null;
47
+ }
48
+
49
+ const label = stdout.trim().length > 0 ? "stdout" : stderr.trim().length > 0 ? "stderr" : "error";
50
+ const meta =
51
+ typeof success === "boolean" || exitCode !== undefined
52
+ ? ` (${typeof success === "boolean" ? `success=${success}` : ""}${exitCode !== undefined ? `${typeof success === "boolean" ? " " : ""}exit=${String(exitCode)}` : ""})`
53
+ : "";
54
+
55
+ const MAX_LINES = 4;
56
+ const MAX_CHARS_PER_LINE = 160;
57
+ const lines = body
58
+ .split("\n")
59
+ .map((l) => l.trimEnd())
60
+ .filter((l) => l.length > 0)
61
+ .slice(0, MAX_LINES)
62
+ .map((l) => (l.length > MAX_CHARS_PER_LINE ? `${l.slice(0, MAX_CHARS_PER_LINE - 1)}…` : l));
63
+
64
+ if (lines.length === 0) return [`${label}${meta}: (empty)`];
65
+ lines[0] = `${label}${meta}: ${lines[0]}`;
66
+ return lines;
67
+ }
68
+
69
+ export const bashLayout: ToolLayoutConfig = {
70
+ abbreviation: "bash",
71
+
72
+ getHeader: (input): ToolHeader | null => {
73
+ const bashInput = extractBashInput(input);
74
+ if (!bashInput) return null;
75
+ return {
76
+ secondary: bashInput.description,
77
+ secondaryStyle: "italic",
78
+ };
79
+ },
80
+
81
+ getBody: (input): ToolBody | null => {
82
+ const bashInput = extractBashInput(input);
83
+ if (!bashInput) return null;
84
+
85
+ const command = bashInput.command;
86
+ const lines = command.split("\n");
87
+ const isMultiLine = lines.length > 1;
88
+ const MAX_DISPLAY_LENGTH = 120;
89
+
90
+ let displayText: string;
91
+ if (isMultiLine) {
92
+ const firstLine = lines[0]?.trimEnd() ?? "";
93
+ const truncatedFirst =
94
+ firstLine.length > MAX_DISPLAY_LENGTH ? `${firstLine.slice(0, MAX_DISPLAY_LENGTH - 1)}…` : firstLine;
95
+ displayText = `${truncatedFirst} (+${lines.length - 1} more lines)`;
96
+ } else if (command.length > MAX_DISPLAY_LENGTH) {
97
+ displayText = `${command.slice(0, MAX_DISPLAY_LENGTH - 1)}…`;
98
+ } else {
99
+ displayText = command;
100
+ }
101
+
102
+ return {
103
+ lines: [{ text: displayText }],
104
+ };
105
+ },
106
+
107
+ formatResult: formatBashResult,
108
+ };
109
+
110
+ registerToolLayout("runBash", bashLayout);
@@ -0,0 +1,98 @@
1
+ import { COLORS } from "../../../ui/constants";
2
+ import { registerToolLayout } from "../registry";
3
+ import type { ToolHeader, ToolLayoutConfig, ToolLayoutRenderProps } from "../types";
4
+
5
+ type UnknownRecord = Record<string, unknown>;
6
+
7
+ function isRecord(value: unknown): value is UnknownRecord {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+
11
+ interface GroundingSource {
12
+ url: string;
13
+ quote: string;
14
+ textFragment?: string;
15
+ }
16
+
17
+ interface GroundedStatement {
18
+ id: string;
19
+ statement: string;
20
+ source: GroundingSource;
21
+ }
22
+
23
+ interface GroundingInput {
24
+ action: "set" | "append";
25
+ items: GroundedStatement[];
26
+ }
27
+
28
+ function extractGroundingInput(input: unknown): GroundingInput | null {
29
+ if (!isRecord(input)) return null;
30
+ if (!("items" in input) || !Array.isArray(input.items)) return null;
31
+
32
+ const items = input.items as GroundedStatement[];
33
+ const action =
34
+ "action" in input && (input.action === "set" || input.action === "append")
35
+ ? (input.action as "set" | "append")
36
+ : "append";
37
+
38
+ return { action, items };
39
+ }
40
+
41
+ function GroundingBody({ call }: ToolLayoutRenderProps) {
42
+ const input = extractGroundingInput(call.input);
43
+ if (!input || input.items.length === 0) return null;
44
+
45
+ const MAX_ITEMS = 4;
46
+ const visibleItems = input.items.slice(0, MAX_ITEMS);
47
+ const remainingCount = input.items.length - MAX_ITEMS;
48
+
49
+ return (
50
+ <box flexDirection="column" paddingLeft={2} marginTop={1}>
51
+ {visibleItems.map((item, idx) => {
52
+ let domain = "";
53
+ try {
54
+ const url = new URL(item.source.url);
55
+ domain = url.hostname;
56
+ } catch {
57
+ domain = item.source.url;
58
+ }
59
+
60
+ const MAX_LEN = 90;
61
+ const statement =
62
+ item.statement.length > MAX_LEN ? item.statement.slice(0, MAX_LEN) + "..." : item.statement;
63
+
64
+ return (
65
+ <box key={idx} flexDirection="column">
66
+ <text>
67
+ <span fg={COLORS.MENU_TEXT}>{statement}</span>
68
+ </text>
69
+ <text>
70
+ <span fg={COLORS.TOOLS}> └─ {domain}</span>
71
+ </text>
72
+ </box>
73
+ );
74
+ })}
75
+ {remainingCount > 0 && (
76
+ <text>
77
+ <span fg={COLORS.TOOLS}> + {remainingCount} more statements</span>
78
+ </text>
79
+ )}
80
+ </box>
81
+ );
82
+ }
83
+
84
+ export const groundingLayout: ToolLayoutConfig = {
85
+ abbreviation: "grounding",
86
+
87
+ getHeader: (input): ToolHeader | null => {
88
+ const data = extractGroundingInput(input);
89
+ if (!data) return null;
90
+ return {
91
+ secondary: `${data.action} ${data.items.length} item${data.items.length === 1 ? "" : "s"}`,
92
+ };
93
+ },
94
+
95
+ renderBody: GroundingBody,
96
+ };
97
+
98
+ registerToolLayout("groundingManager", groundingLayout);