mirai-cli 1.0.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 (46) hide show
  1. package/bin/config.ts +99 -0
  2. package/bin/mirai.js +17 -0
  3. package/bin/mirai.ts +4 -0
  4. package/bin/provider.ts +149 -0
  5. package/bin/router.ts +134 -0
  6. package/dist/mirai.mjs +28316 -0
  7. package/package.json +29 -0
  8. package/src/app/index.tsx +274 -0
  9. package/src/components/chat.tsx +254 -0
  10. package/src/components/dialog/help-dialog.tsx +101 -0
  11. package/src/components/dialog/index.ts +3 -0
  12. package/src/components/dialog/provider.tsx +96 -0
  13. package/src/components/header/index.tsx +78 -0
  14. package/src/components/input/command-palette.tsx +129 -0
  15. package/src/components/input/commands.ts +46 -0
  16. package/src/components/input/index.tsx +284 -0
  17. package/src/components/matrix-rain/index.tsx +122 -0
  18. package/src/components/permission-modal.tsx +66 -0
  19. package/src/components/scroll-bar/index.tsx +56 -0
  20. package/src/components/status-bar/index.tsx +43 -0
  21. package/src/components/tool-result.tsx +11 -0
  22. package/src/hooks/use-chat.ts +208 -0
  23. package/src/hooks/use-mouse.tsx +121 -0
  24. package/src/hooks/use-permission.ts +35 -0
  25. package/src/hooks/use-runtime.ts +99 -0
  26. package/src/hooks/use-scroll-bar-drag.ts +115 -0
  27. package/src/hooks/use-scroll.ts +70 -0
  28. package/src/index.ts +39 -0
  29. package/src/renderers/builtins/BashResult.tsx +65 -0
  30. package/src/renderers/builtins/EditFileResult.tsx +69 -0
  31. package/src/renderers/builtins/GenericToolResult.tsx +39 -0
  32. package/src/renderers/builtins/GlobSearchResult.tsx +40 -0
  33. package/src/renderers/builtins/GrepSearchResult.tsx +49 -0
  34. package/src/renderers/builtins/ReadFileResult.tsx +54 -0
  35. package/src/renderers/builtins/WriteFileResult.tsx +24 -0
  36. package/src/renderers/constants.ts +7 -0
  37. package/src/renderers/register-builtins.ts +27 -0
  38. package/src/renderers/registry.ts +37 -0
  39. package/src/renderers/status.ts +22 -0
  40. package/src/renderers/utils.ts +70 -0
  41. package/src/services/hit-test.ts +49 -0
  42. package/src/services/mouse-input.ts +237 -0
  43. package/src/services/scroll-registry.ts +64 -0
  44. package/src/services/tui-permission-provider.ts +35 -0
  45. package/src/theme.ts +38 -0
  46. package/tsconfig.json +27 -0
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "mirai-cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "mirai": "./dist/mirai.mjs"
7
+ },
8
+ "scripts": {
9
+ "build": "esbuild bin/mirai.ts --bundle --platform=node --format=esm --outfile=dist/mirai.mjs --external:react-devtools-core --external:yoga-layout --external:ink --external:react --external:ink-big-text --external:ink-gradient --external:ink-spinner --external:@inkjs/ui --banner:js=\"import{createRequire}from'node:module';globalThis.require=createRequire(import.meta.url)\" --tsconfig=tsconfig.json",
10
+ "start": "tsx src/index.ts",
11
+ "dev": "tsc --watch"
12
+ },
13
+ "dependencies": {
14
+ "ink": "^7.0.0",
15
+ "react": "^19.0.0",
16
+ "@inkjs/ui": "^2.0.0",
17
+ "ink-scroll-view": "^0.3.0",
18
+ "ink-big-text": "^2.0.0",
19
+ "ink-gradient": "^4.0.0",
20
+ "ink-spinner": "^5.0.0",
21
+ "js-yaml": "^4.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.0.0",
25
+ "@types/node": "^20.0.0",
26
+ "@types/react": "^19.0.0",
27
+ "@types/js-yaml": "^4.0.0"
28
+ }
29
+ }
@@ -0,0 +1,274 @@
1
+ import { Box, useApp, useInput } from "ink";
2
+ import {
3
+ useState,
4
+ useRef,
5
+ useLayoutEffect,
6
+ useEffect,
7
+ useCallback,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { ScrollView } from "ink-scroll-view";
11
+ import Header from "../components/header/index.js";
12
+ import ChatArea from "../components/chat.js";
13
+ import StatusBar from "../components/status-bar/index.js";
14
+ import Input from "../components/input/index.js";
15
+ import ScrollBar from "../components/scroll-bar/index.js";
16
+ import MatrixRain from "../components/matrix-rain/index.js";
17
+ import PermissionModal from "../components/permission-modal.js";
18
+ import { useWindowSize, useScroll } from "../hooks/use-scroll.js";
19
+ import { MouseStateProvider } from "../hooks/use-mouse.js";
20
+ import { mouseInput } from "../services/mouse-input.js";
21
+ import { scrollRegistry } from "../services/scroll-registry.js";
22
+ import { useRuntimeChat } from "../hooks/use-runtime.js";
23
+ import { usePermission } from "../hooks/use-permission.js";
24
+ import { CONTEXT_WINDOW } from "@mirai/core/constants";
25
+ import { registerBuiltinRenderers } from "../renderers/register-builtins.js";
26
+ import {
27
+ DialogProvider,
28
+ HelpDialog,
29
+ type DialogBridge,
30
+ } from "../components/dialog/index.js";
31
+ import { PermissionMode, PermissionResponse } from "@mirai/permission";
32
+ import type { ExecutionContext } from "@mirai/runtime";
33
+ import type { Command } from "../components/input/commands.js";
34
+ import { BUILTIN_COMMANDS } from "../components/input/commands.js";
35
+
36
+ interface AppProps {
37
+ model?: string;
38
+ }
39
+
40
+ export default function App({ model }: AppProps) {
41
+ const { exit } = useApp();
42
+ const scrollRef = useRef<any>(null);
43
+ const chatContainerRef = useRef<any>(null);
44
+ const { rows: terminalRows } = useWindowSize();
45
+ const { scrollPos, scrollHeight, isNearBottom, handleScroll, updatePos } =
46
+ useScroll(scrollRef, terminalRows);
47
+
48
+ // Register parent scroll region
49
+ useEffect(() => {
50
+ const unreg = scrollRegistry.register({
51
+ id: "chat",
52
+ zIndex: 0,
53
+ getRect: () => {
54
+ if (!chatContainerRef.current) return null;
55
+ const node = (chatContainerRef.current as any).yogaNode;
56
+ if (!node) return null;
57
+ return {
58
+ left: node.getComputedLeft?.() ?? 0,
59
+ top: node.getComputedTop?.() ?? 0,
60
+ width: node.getComputedWidth?.() ?? 0,
61
+ height: node.getComputedHeight?.() ?? 0,
62
+ };
63
+ },
64
+ onScroll: (delta) => {
65
+ const ref = scrollRef.current;
66
+ if (!ref) return false;
67
+ const offset = ref.getScrollOffset();
68
+ const content = ref.getContentHeight();
69
+ const viewport = ref.getViewportHeight();
70
+ if (!content || content <= viewport) return false;
71
+ const steps = -delta * 3;
72
+ const clamped =
73
+ steps > 0
74
+ ? Math.min(steps, content - viewport - offset)
75
+ : Math.max(steps, -offset);
76
+ if (clamped !== 0) {
77
+ ref.scrollBy(clamped);
78
+ return false; // consumed
79
+ }
80
+ return true; // at edge → bubble
81
+ },
82
+ });
83
+ return unreg;
84
+ }, [scrollRef]);
85
+
86
+ // Global wheel handler: dispatch via ScrollRegistry
87
+ useEffect(() => {
88
+ const unsub = mouseInput.on((state) => {
89
+ if (state.wheelDelta === 0) return;
90
+ scrollRegistry.dispatch(state.x, state.y, state.wheelDelta);
91
+ });
92
+ return unsub;
93
+ }, []);
94
+
95
+ const permission = usePermission(PermissionMode.WORKSPACE_WRITE);
96
+ const {
97
+ messages,
98
+ isStreaming,
99
+ error,
100
+ toolFeed,
101
+ tokensUsed,
102
+ cost,
103
+ startTime,
104
+ submit,
105
+ clearMessages,
106
+ } = useRuntimeChat({
107
+ permissionProvider: permission.provider,
108
+ ruleStore: permission.ruleStore,
109
+ });
110
+
111
+ const modeRef = useRef(permission.mode);
112
+ modeRef.current = permission.mode;
113
+
114
+ const [showMatrix, setShowMatrix] = useState(true);
115
+ const dialogBridgeRef = useRef<DialogBridge>({
116
+ show: () => {},
117
+ isActive: false,
118
+ });
119
+
120
+ useEffect(() => {
121
+ void registerBuiltinRenderers();
122
+ }, []);
123
+
124
+ const handlePermissionResolve = useCallback(
125
+ (response: PermissionResponse) => {
126
+ permission.provider.resolve(response);
127
+ },
128
+ [permission.provider],
129
+ );
130
+
131
+ const handleSubmit = useCallback(
132
+ (prompt: string) => {
133
+ const ctx: ExecutionContext = {
134
+ mode: modeRef.current,
135
+ requestId: crypto.randomUUID(),
136
+ timestamp: Date.now(),
137
+ };
138
+ submit(prompt, ctx);
139
+ },
140
+ [submit],
141
+ );
142
+
143
+ const handleCommand = useCallback(
144
+ (cmd: Command) => {
145
+ switch (cmd.type) {
146
+ case "mode.set":
147
+ permission.setMode(cmd.value);
148
+ break;
149
+ case "system.clear":
150
+ clearMessages();
151
+ break;
152
+ case "help.show":
153
+ dialogBridgeRef.current.show(() => <HelpDialog />);
154
+ break;
155
+ }
156
+ },
157
+ [permission.setMode, clearMessages, submit],
158
+ );
159
+
160
+ // Scroll acceleration
161
+ const scrollAccel = useRef({ dir: 0, step: 1, time: 0 });
162
+
163
+ useInput((input, key) => {
164
+ if (dialogBridgeRef.current.isActive) return;
165
+ if (key.ctrl && input === "d") exit();
166
+ const ref = scrollRef.current;
167
+ if (!ref) return;
168
+ const offset = ref.getScrollOffset();
169
+ const content = ref.getContentHeight();
170
+ const viewport = ref.getViewportHeight();
171
+ const now = Date.now();
172
+ const a = scrollAccel.current;
173
+
174
+ if (key.upArrow && offset > 0) {
175
+ const step =
176
+ a.dir === -1 && now - a.time < 300 ? Math.min(a.step + 1, 15) : 1;
177
+ a.dir = -1;
178
+ a.step = step;
179
+ a.time = now;
180
+ ref.scrollBy(-Math.min(step, offset));
181
+ return;
182
+ }
183
+ if (key.downArrow && offset + viewport < content) {
184
+ const step =
185
+ a.dir === 1 && now - a.time < 300 ? Math.min(a.step + 1, 15) : 1;
186
+ a.dir = 1;
187
+ a.step = step;
188
+ a.time = now;
189
+ ref.scrollBy(Math.min(step, content - viewport - offset));
190
+ return;
191
+ }
192
+ if (key.upArrow || key.downArrow) return;
193
+
194
+ if (key.pageUp && offset > 0) ref.scrollBy(-Math.min(10, offset));
195
+ if (key.pageDown && offset + viewport < content)
196
+ ref.scrollBy(Math.min(10, content - viewport - offset));
197
+ if (!key.upArrow && !key.downArrow) {
198
+ a.dir = 0;
199
+ a.step = 1;
200
+ }
201
+ });
202
+
203
+ // Auto-scroll logic:
204
+ useLayoutEffect(() => {
205
+ if (!scrollRef.current) return;
206
+ if (isNearBottom) {
207
+ scrollRef.current.scrollToBottom();
208
+ }
209
+ }, [messages, isNearBottom]);
210
+
211
+ return (
212
+ <MouseStateProvider>
213
+ <Box height="100%">
214
+ <DialogProvider dialogRef={dialogBridgeRef}>
215
+ {showMatrix && (
216
+ <Box
217
+ position="absolute"
218
+ top={0}
219
+ left={0}
220
+ width="100%"
221
+ height="100%"
222
+ >
223
+ <MatrixRain />
224
+ </Box>
225
+ )}
226
+ <Box
227
+ flexDirection="column"
228
+ marginLeft={2}
229
+ marginRight={2}
230
+ height="100%"
231
+ >
232
+ <Box flexDirection="row">
233
+ <Box ref={chatContainerRef} height={scrollHeight}>
234
+ <ScrollView
235
+ ref={scrollRef}
236
+ onScroll={handleScroll}
237
+ onContentHeightChange={updatePos}
238
+ >
239
+ <Header />
240
+ <ChatArea
241
+ messages={messages}
242
+ isStreaming={isStreaming}
243
+ error={error}
244
+ toolFeed={toolFeed}
245
+ />
246
+ </ScrollView>
247
+ </Box>
248
+ <ScrollBar scrollPos={scrollPos} scrollRef={scrollRef} />
249
+ </Box>
250
+ {permission.pendingRequest && (
251
+ <PermissionModal
252
+ request={permission.pendingRequest}
253
+ onResolve={handlePermissionResolve}
254
+ />
255
+ )}
256
+ <StatusBar
257
+ model={model ?? "qwen3.5:4b"}
258
+ tokensUsed={tokensUsed}
259
+ totalTokens={CONTEXT_WINDOW}
260
+ cost={cost}
261
+ startTime={startTime}
262
+ />
263
+ <Input
264
+ onSubmit={handleSubmit}
265
+ onCommand={handleCommand}
266
+ disabled={isStreaming}
267
+ onToggleMatrix={() => setShowMatrix((v) => !v)}
268
+ />
269
+ </Box>
270
+ </DialogProvider>
271
+ </Box>
272
+ </MouseStateProvider>
273
+ );
274
+ }
@@ -0,0 +1,254 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { Alert } from "@inkjs/ui";
3
+ import { useState, useEffect } from "react";
4
+ import type { ContentBlock, Message, ToolEntry, ToolResultBlock } from "@mirai/core/types";
5
+ import { theme } from "../theme.js";
6
+ import { ToolResultView } from "./tool-result.js";
7
+ import { STATUS_CONFIG } from "../renderers/status.js";
8
+
9
+ /* ─── Props ─── */
10
+
11
+ interface Props {
12
+ messages: Message[];
13
+ isStreaming: boolean;
14
+ error: string | null;
15
+ toolFeed: ToolEntry[];
16
+ }
17
+
18
+ /* ─── Block renderers ─── */
19
+
20
+ function TextBlock({ text }: { text: string }) {
21
+ return <Text color={theme.text.primary}>{text}</Text>;
22
+ }
23
+
24
+ function ThinkingBlock({
25
+ thinking,
26
+ expanded,
27
+ durationMs,
28
+ }: {
29
+ thinking: string;
30
+ expanded: boolean;
31
+ durationMs?: number;
32
+ }) {
33
+ const duration = durationMs != null ? ` ${durationMs}ms` : "";
34
+ return (
35
+ <Box
36
+ flexDirection="column"
37
+ minWidth="100%"
38
+ maxWidth="100%"
39
+ marginTop={1}
40
+ marginBottom={1}
41
+ >
42
+ <Box backgroundColor={theme.bg.alt} paddingX={1}>
43
+ <Text color={theme.role.thinking} dimColor>
44
+ {`Thinking (${thinking.length} ký tự)${duration}`}
45
+ </Text>
46
+ </Box>
47
+ {expanded && (
48
+ <Box backgroundColor={theme.bg.surface} flexDirection="column">
49
+ <Box marginX={2} marginY={1} borderLeft>
50
+ <Text dimColor color={theme.text.dim}>
51
+ {thinking}
52
+ </Text>
53
+ </Box>
54
+ </Box>
55
+ )}
56
+ </Box>
57
+ );
58
+ }
59
+
60
+ function ToolBlock({ block }: { block: ContentBlock }) {
61
+ if (block.type === "tool_use") {
62
+ return (
63
+ <Box flexDirection="column" minWidth="100%" maxWidth="100%" marginTop={1} marginBottom={1}>
64
+ <Box backgroundColor={theme.bg.alt} paddingX={1}>
65
+ <Text dimColor color={theme.role.thinking}>
66
+ 🔧 {block.name}
67
+ </Text>
68
+ </Box>
69
+ <Box backgroundColor={theme.bg.surface} flexDirection="column">
70
+ <Box marginX={2} marginY={1} borderLeft>
71
+ <Text dimColor color={theme.text.dim}>
72
+ {JSON.stringify(block.input, null, 2)}
73
+ </Text>
74
+ </Box>
75
+ </Box>
76
+ </Box>
77
+ );
78
+ }
79
+ if (block.type === "tool_result") {
80
+ const st = STATUS_CONFIG[block.status] ?? STATUS_CONFIG.success;
81
+ return (
82
+ <Box flexDirection="column" minWidth="100%" maxWidth="100%" marginTop={1} marginBottom={1}>
83
+ <Box backgroundColor={theme.bg.alt} paddingX={1}>
84
+ <Text dimColor color={st.color}>
85
+ {st.icon} {block.toolName} ({block.metadata.durationMs}ms)
86
+ </Text>
87
+ </Box>
88
+ <Box backgroundColor={theme.bg.surface} flexDirection="column">
89
+ <Box marginX={2} marginY={1} borderLeft>
90
+ <ToolResultView block={block as ToolResultBlock} />
91
+ </Box>
92
+ </Box>
93
+ </Box>
94
+ );
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /* ─── Message item ─── */
100
+
101
+ function MessageItem({
102
+ msg,
103
+ msgIdx,
104
+ isBlockExpanded: isExpandedFn,
105
+ }: {
106
+ msg: Message;
107
+ msgIdx: number;
108
+ isBlockExpanded: (blockIdx: number) => boolean;
109
+ }) {
110
+ const icon = msg.role === "user" ? "❯" : "✦";
111
+ const iconColor =
112
+ msg.role === "user" ? theme.role.user : theme.role.assistant;
113
+
114
+ return (
115
+ <Box marginTop={1} flexDirection="column">
116
+ {msg.content.length > 0 ? (
117
+ <Box>
118
+ <Box flexShrink={0} marginRight={1}>
119
+ <Text bold color={iconColor}>
120
+ {icon}
121
+ </Text>
122
+ </Box>
123
+ <Box flexDirection="column">
124
+ {msg.content.map((block, i) => {
125
+ switch (block.type) {
126
+ case "text":
127
+ return <TextBlock key={i} text={block.text} />;
128
+ case "thinking":
129
+ return (
130
+ <ThinkingBlock
131
+ key={i}
132
+ thinking={block.thinking}
133
+ expanded={isExpandedFn(i)}
134
+ durationMs={block.durationMs}
135
+ />
136
+ );
137
+ case "tool_use":
138
+ case "tool_result":
139
+ return <ToolBlock key={i} block={block} />;
140
+ default:
141
+ return null;
142
+ }
143
+ })}
144
+ </Box>
145
+ </Box>
146
+ ) : null}
147
+ </Box>
148
+ );
149
+ }
150
+
151
+ /* ─── Faces animation ─── */
152
+
153
+ const FACES = [
154
+ "(´・ω・`)",
155
+ "(`・ω・´)",
156
+ "( ̄▽ ̄*)ゞ",
157
+ "(·_·)",
158
+ "(。◕‿‿。)",
159
+ "(/ω\)",
160
+ "(っ˘ω˘ς)",
161
+ "(─‿─)",
162
+ ];
163
+
164
+ function LiveIndicator({
165
+ isStreaming,
166
+ hasOutput,
167
+ }: {
168
+ isStreaming: boolean;
169
+ hasOutput: boolean;
170
+ }) {
171
+ const [faceIdx, setFaceIdx] = useState(0);
172
+ useEffect(() => {
173
+ if (!isStreaming || hasOutput) return;
174
+ const t = setInterval(() => setFaceIdx((i) => (i + 1) % FACES.length), 600);
175
+ return () => clearInterval(t);
176
+ }, [isStreaming, hasOutput]);
177
+
178
+ if (!isStreaming) return null;
179
+ if (hasOutput) return null;
180
+
181
+ return (
182
+ <Box marginTop={1}>
183
+ <Box flexShrink={0} marginRight={1}>
184
+ <Text bold color={theme.role.assistant}>
185
+
186
+ </Text>
187
+ </Box>
188
+ <Box>
189
+ <Text color={theme.role.assistant}>{FACES[faceIdx]}</Text>
190
+ </Box>
191
+ </Box>
192
+ );
193
+ }
194
+
195
+ /* ─── Main component ─── */
196
+
197
+ export default function ChatArea({
198
+ messages,
199
+ isStreaming,
200
+ error,
201
+ toolFeed,
202
+ }: Props) {
203
+ const [allExpanded, setAllExpanded] = useState(false);
204
+
205
+ const isDefaultExpanded = (msgIdx: number) =>
206
+ !allExpanded && msgIdx === messages.length - 1;
207
+
208
+ const isBlockExpanded = (msgIdx: number, blockIdx: number) =>
209
+ allExpanded || isDefaultExpanded(msgIdx);
210
+
211
+ useInput((input, key) => {
212
+ // Alt+t = toggle all thinking blocks
213
+ if (input === "t" && key.meta) {
214
+ setAllExpanded((v) => !v);
215
+ }
216
+ });
217
+
218
+ const lastAssistant =
219
+ messages.length > 0 && messages[messages.length - 1].role === "assistant"
220
+ ? messages[messages.length - 1]
221
+ : null;
222
+ const hasStreamOutput = (lastAssistant?.content.length ?? 0) > 0;
223
+
224
+ return (
225
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
226
+ {messages.map((msg, i) => (
227
+ <MessageItem
228
+ key={i}
229
+ msg={msg}
230
+ msgIdx={i}
231
+ isBlockExpanded={(b) => isBlockExpanded(i, b)}
232
+ />
233
+ ))}
234
+
235
+ <LiveIndicator isStreaming={isStreaming} hasOutput={hasStreamOutput} />
236
+
237
+ {toolFeed.length > 0 && (
238
+ <Box marginTop={1} flexDirection="column">
239
+ {toolFeed.slice(-3).map((t, i) => (
240
+ <Text key={i} color={theme.text.muted}>
241
+ {t.icon} {t.name} ({t.duration}ms)
242
+ </Text>
243
+ ))}
244
+ </Box>
245
+ )}
246
+
247
+ {error && (
248
+ <Box marginTop={1}>
249
+ <Alert variant="error">{error}</Alert>
250
+ </Box>
251
+ )}
252
+ </Box>
253
+ );
254
+ }
@@ -0,0 +1,101 @@
1
+ import { Box, Text, Newline, useInput } from "ink";
2
+ import { useRef, useEffect } from "react";
3
+ import { ScrollView } from "ink-scroll-view";
4
+ import { scrollRegistry } from "../../services/scroll-registry.js";
5
+ import { theme } from "../../theme.js";
6
+ import { useDialog } from "./provider.js";
7
+ import { BUILTIN_COMMANDS } from "../input/commands.js";
8
+
9
+ export default function HelpDialog() {
10
+ const dialog = useDialog();
11
+ const scrollRef = useRef<any>(null);
12
+ const containerRef = useRef<any>(null);
13
+
14
+ useInput((input, key) => {
15
+ if (key.escape || key.return || input === "q") {
16
+ dialog.close();
17
+ }
18
+ });
19
+
20
+ useEffect(() => {
21
+ const unreg = scrollRegistry.register({
22
+ id: "help-dialog",
23
+ zIndex: 100,
24
+ getRect: () => {
25
+ if (!containerRef.current) return null;
26
+ const node = (containerRef.current as any).yogaNode;
27
+ if (!node) return null;
28
+ return {
29
+ left: node.getComputedLeft?.() ?? 0,
30
+ top: node.getComputedTop?.() ?? 0,
31
+ width: node.getComputedWidth?.() ?? 0,
32
+ height: node.getComputedHeight?.() ?? 0,
33
+ };
34
+ },
35
+ onScroll: (delta) => {
36
+ const ref = scrollRef.current;
37
+ if (!ref) return false;
38
+ const offset = ref.getScrollOffset();
39
+ const content = ref.getContentHeight();
40
+ const viewport = ref.getViewportHeight();
41
+ if (!content || content <= viewport) return false;
42
+ const steps = -delta * 3;
43
+ const clamped =
44
+ steps > 0
45
+ ? Math.min(steps, content - viewport - offset)
46
+ : Math.max(steps, -offset);
47
+ if (clamped !== 0) {
48
+ ref.scrollBy(clamped);
49
+ return false;
50
+ }
51
+ return true;
52
+ },
53
+ });
54
+ return unreg;
55
+ }, []);
56
+
57
+ return (
58
+ <Box
59
+ ref={containerRef}
60
+ width="100%"
61
+ height="100%"
62
+ justifyContent="center"
63
+ alignItems="center"
64
+ >
65
+ <Box
66
+ backgroundColor={theme.bg.surface}
67
+ borderStyle="round"
68
+ borderColor={theme.role.user}
69
+ paddingX={1}
70
+ paddingY={1}
71
+ maxHeight="50%"
72
+ width="60%"
73
+ >
74
+ <ScrollView ref={scrollRef}>
75
+ <Text bold color={theme.role.user}>
76
+ Available Commands
77
+ </Text>
78
+ <Text>
79
+ <Newline />
80
+ </Text>
81
+ {BUILTIN_COMMANDS.map((c) => (
82
+ <Box key={c.command}>
83
+ <Text>
84
+ {" "}
85
+ <Text bold color={theme.role.user}>
86
+ {c.command}
87
+ </Text>
88
+ {" "}
89
+ <Text dimColor>{c.description}</Text>
90
+ </Text>
91
+ </Box>
92
+ ))}
93
+ <Text>
94
+ <Newline />
95
+ </Text>
96
+ <Text dimColor>Press Esc / q / Enter to close</Text>
97
+ </ScrollView>
98
+ </Box>
99
+ </Box>
100
+ );
101
+ }
@@ -0,0 +1,3 @@
1
+ export { DialogProvider, useDialog } from "./provider.js";
2
+ export type { DialogBridge } from "./provider.js";
3
+ export { default as HelpDialog } from "./help-dialog.js";