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.
- package/bin/config.ts +99 -0
- package/bin/mirai.js +17 -0
- package/bin/mirai.ts +4 -0
- package/bin/provider.ts +149 -0
- package/bin/router.ts +134 -0
- package/dist/mirai.mjs +28316 -0
- package/package.json +29 -0
- package/src/app/index.tsx +274 -0
- package/src/components/chat.tsx +254 -0
- package/src/components/dialog/help-dialog.tsx +101 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/dialog/provider.tsx +96 -0
- package/src/components/header/index.tsx +78 -0
- package/src/components/input/command-palette.tsx +129 -0
- package/src/components/input/commands.ts +46 -0
- package/src/components/input/index.tsx +284 -0
- package/src/components/matrix-rain/index.tsx +122 -0
- package/src/components/permission-modal.tsx +66 -0
- package/src/components/scroll-bar/index.tsx +56 -0
- package/src/components/status-bar/index.tsx +43 -0
- package/src/components/tool-result.tsx +11 -0
- package/src/hooks/use-chat.ts +208 -0
- package/src/hooks/use-mouse.tsx +121 -0
- package/src/hooks/use-permission.ts +35 -0
- package/src/hooks/use-runtime.ts +99 -0
- package/src/hooks/use-scroll-bar-drag.ts +115 -0
- package/src/hooks/use-scroll.ts +70 -0
- package/src/index.ts +39 -0
- package/src/renderers/builtins/BashResult.tsx +65 -0
- package/src/renderers/builtins/EditFileResult.tsx +69 -0
- package/src/renderers/builtins/GenericToolResult.tsx +39 -0
- package/src/renderers/builtins/GlobSearchResult.tsx +40 -0
- package/src/renderers/builtins/GrepSearchResult.tsx +49 -0
- package/src/renderers/builtins/ReadFileResult.tsx +54 -0
- package/src/renderers/builtins/WriteFileResult.tsx +24 -0
- package/src/renderers/constants.ts +7 -0
- package/src/renderers/register-builtins.ts +27 -0
- package/src/renderers/registry.ts +37 -0
- package/src/renderers/status.ts +22 -0
- package/src/renderers/utils.ts +70 -0
- package/src/services/hit-test.ts +49 -0
- package/src/services/mouse-input.ts +237 -0
- package/src/services/scroll-registry.ts +64 -0
- package/src/services/tui-permission-provider.ts +35 -0
- package/src/theme.ts +38 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Box, Text, useStdout } from "ink";
|
|
2
|
+
import { useEffect, useState, useRef, memo } from "react";
|
|
3
|
+
import { NEON_COLORS } from "@mirai/core/constants";
|
|
4
|
+
import { theme } from "src/theme";
|
|
5
|
+
|
|
6
|
+
const HEADS = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン01#%&+=:;│┃━─█▓";
|
|
7
|
+
const TAILS =
|
|
8
|
+
"。「」、・ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン.:;░▒";
|
|
9
|
+
|
|
10
|
+
function lerp(a: number, b: number, t: number) {
|
|
11
|
+
return a + (b - a) * t;
|
|
12
|
+
}
|
|
13
|
+
function hexRgb(h: string) {
|
|
14
|
+
return [
|
|
15
|
+
parseInt(h.slice(1, 3), 16),
|
|
16
|
+
parseInt(h.slice(3, 5), 16),
|
|
17
|
+
parseInt(h.slice(5, 7), 16),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
function rgbHex(r: number, g: number, b: number) {
|
|
21
|
+
return `#${Math.round(r).toString(16).padStart(2, "0")}${Math.round(g).toString(16).padStart(2, "0")}${Math.round(b).toString(16).padStart(2, "0")}`;
|
|
22
|
+
}
|
|
23
|
+
const STOPS = [
|
|
24
|
+
{ c: NEON_COLORS[0], pos: 0 },
|
|
25
|
+
{ c: NEON_COLORS[1], pos: 0.5 },
|
|
26
|
+
{ c: NEON_COLORS[2], pos: 1 },
|
|
27
|
+
];
|
|
28
|
+
function grad(t: number): string {
|
|
29
|
+
if (t <= 0) return STOPS[0].c;
|
|
30
|
+
if (t >= 1) return STOPS[STOPS.length - 1].c;
|
|
31
|
+
for (let i = 0; i < STOPS.length - 1; i++) {
|
|
32
|
+
const a = STOPS[i],
|
|
33
|
+
b = STOPS[i + 1];
|
|
34
|
+
if (t >= a.pos && t <= b.pos) {
|
|
35
|
+
const p = (t - a.pos) / (b.pos - a.pos);
|
|
36
|
+
const [r1, g1, b1] = hexRgb(a.c);
|
|
37
|
+
const [r2, g2, b2] = hexRgb(b.c);
|
|
38
|
+
return rgbHex(lerp(r1, r2, p), lerp(g1, g2, p), lerp(b1, b2, p));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return STOPS[STOPS.length - 1].c;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default memo(function MatrixRain() {
|
|
45
|
+
const { stdout } = useStdout();
|
|
46
|
+
const cols = stdout?.columns ?? 80;
|
|
47
|
+
const rows = stdout?.rows ?? 24;
|
|
48
|
+
const dropsRef = useRef<{ x: number; y: number; chars: string[] }[]>([]);
|
|
49
|
+
const [grid, setGrid] = useState<string[]>(() =>
|
|
50
|
+
Array.from({ length: rows }, () => " ".repeat(cols)),
|
|
51
|
+
);
|
|
52
|
+
const [rowColors] = useState<string[]>(() => {
|
|
53
|
+
const c: string[] = [];
|
|
54
|
+
for (let y = 0; y < rows; y++) c.push(grad(y / (rows - 1 || 1)));
|
|
55
|
+
return c;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const d: typeof dropsRef.current = [];
|
|
60
|
+
for (let x = 0; x < cols; x++) {
|
|
61
|
+
if (Math.random() > 0.4) continue;
|
|
62
|
+
d.push({
|
|
63
|
+
x: Math.floor(Math.random() * cols),
|
|
64
|
+
y: -Math.random() * rows * 2,
|
|
65
|
+
chars: [],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
dropsRef.current = d;
|
|
69
|
+
}, [cols, rows]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
73
|
+
let running = true;
|
|
74
|
+
function tick() {
|
|
75
|
+
if (!running) return;
|
|
76
|
+
const lines: string[] = Array.from({ length: rows }, () =>
|
|
77
|
+
" ".repeat(cols),
|
|
78
|
+
);
|
|
79
|
+
for (const drop of dropsRef.current) {
|
|
80
|
+
drop.y += 0.8;
|
|
81
|
+
if (Math.floor(drop.y) > rows + 6) {
|
|
82
|
+
drop.y = -Math.random() * rows;
|
|
83
|
+
drop.x = Math.floor(Math.random() * cols);
|
|
84
|
+
drop.chars = [];
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
drop.chars.length === 0 ||
|
|
88
|
+
Math.floor(drop.y) !== Math.floor(drop.y - 0.8)
|
|
89
|
+
) {
|
|
90
|
+
drop.chars.unshift(HEADS[Math.floor(Math.random() * HEADS.length)]);
|
|
91
|
+
if (drop.chars.length > 6) drop.chars.pop();
|
|
92
|
+
for (let j = 1; j < drop.chars.length; j++)
|
|
93
|
+
drop.chars[j] = TAILS[Math.floor(Math.random() * TAILS.length)];
|
|
94
|
+
}
|
|
95
|
+
for (let i = 0; i < drop.chars.length; i++) {
|
|
96
|
+
const yy = Math.floor(drop.y) - i;
|
|
97
|
+
if (yy < 0 || yy >= rows) continue;
|
|
98
|
+
const l = lines[yy].split("");
|
|
99
|
+
l[drop.x] = drop.chars[i];
|
|
100
|
+
lines[yy] = l.join("");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
setGrid(lines);
|
|
104
|
+
timer = setTimeout(tick, 33);
|
|
105
|
+
}
|
|
106
|
+
timer = setTimeout(tick, 0);
|
|
107
|
+
return () => {
|
|
108
|
+
running = false;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
};
|
|
111
|
+
}, [cols, rows]);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Box width={cols} height={rows} flexDirection="column" overflow="hidden">
|
|
115
|
+
{grid.map((line, y) => (
|
|
116
|
+
<Box key={y} height={1} width={cols}>
|
|
117
|
+
<Text color={theme.text.primary}>{line}</Text>
|
|
118
|
+
</Box>
|
|
119
|
+
))}
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import type { PermissionRequest } from "@mirai/permission";
|
|
3
|
+
import { ToolCapability, PermissionResponse } from "@mirai/permission";
|
|
4
|
+
import { theme } from "../theme.js";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
request: PermissionRequest;
|
|
8
|
+
onResolve: (response: PermissionResponse) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const capabilityLabels: Record<number, { label: string; color: string }> = {
|
|
12
|
+
[ToolCapability.READ]: { label: "Read", color: theme.info.primary },
|
|
13
|
+
[ToolCapability.WRITE]: { label: "Write", color: theme.warning.primary },
|
|
14
|
+
[ToolCapability.EXECUTE]: { label: "Execute", color: theme.warning.primary },
|
|
15
|
+
[ToolCapability.DANGEROUS]: { label: "Dangerous", color: theme.error.primary },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default function PermissionModal({ request, onResolve }: Props) {
|
|
19
|
+
const cap = capabilityLabels[request.capability] ?? {
|
|
20
|
+
label: "Unknown",
|
|
21
|
+
color: theme.text.dim,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
useInput((_input, key) => {
|
|
25
|
+
if (key.return) {
|
|
26
|
+
onResolve(PermissionResponse.ONCE);
|
|
27
|
+
} else if (key.escape) {
|
|
28
|
+
onResolve(PermissionResponse.REJECT);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Box
|
|
34
|
+
borderStyle="round"
|
|
35
|
+
borderColor={cap.color as any}
|
|
36
|
+
flexDirection="column"
|
|
37
|
+
paddingX={1}
|
|
38
|
+
paddingY={1}
|
|
39
|
+
marginTop={1}
|
|
40
|
+
marginBottom={1}
|
|
41
|
+
>
|
|
42
|
+
<Box>
|
|
43
|
+
<Text bold color={cap.color as any}>
|
|
44
|
+
⚠ {cap.label} Action
|
|
45
|
+
</Text>
|
|
46
|
+
</Box>
|
|
47
|
+
<Box marginTop={1}>
|
|
48
|
+
<Text bold>Tool: </Text>
|
|
49
|
+
<Text>{request.action}</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
<Box>
|
|
52
|
+
<Text bold>{request.action === "bash" ? "Command" : "Resource"}: </Text>
|
|
53
|
+
<Text dimColor>{request.description}</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
<Box marginTop={1} flexDirection="column">
|
|
56
|
+
<Text dimColor>{request.title}</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
<Box marginTop={1}>
|
|
59
|
+
<Text color={theme.success.primary}>[Enter]</Text>
|
|
60
|
+
<Text> Allow Once </Text>
|
|
61
|
+
<Text color={theme.error.primary}>[Esc]</Text>
|
|
62
|
+
<Text> Reject</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
</Box>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useRef, useLayoutEffect } from "react";
|
|
3
|
+
import { theme } from "../../theme.js";
|
|
4
|
+
import type { ScrollState } from "../../hooks/use-scroll.js";
|
|
5
|
+
import { useScrollBarDrag } from "../../hooks/use-scroll-bar-drag.js";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
scrollPos: ScrollState;
|
|
9
|
+
scrollRef: React.RefObject<any>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ScrollBar({ scrollPos, scrollRef }: Props) {
|
|
13
|
+
const { offset, content, viewport } = scrollPos;
|
|
14
|
+
if (!content || !viewport || content <= viewport) return null;
|
|
15
|
+
|
|
16
|
+
const barRef = useRef<any>(null);
|
|
17
|
+
const layoutRef = useRef({ barTop: 0 });
|
|
18
|
+
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
if (barRef.current) {
|
|
21
|
+
const node = (barRef.current as any).yogaNode;
|
|
22
|
+
layoutRef.current.barTop = node?.getComputedTop?.() ?? 0;
|
|
23
|
+
}
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const thumb = Math.max(1, Math.floor((viewport / content) * viewport));
|
|
27
|
+
const max = viewport - thumb;
|
|
28
|
+
const pos = Math.round((offset / (content - viewport)) * max);
|
|
29
|
+
|
|
30
|
+
const { isDragging } = useScrollBarDrag(barRef, scrollRef, {
|
|
31
|
+
thumbPos: pos,
|
|
32
|
+
thumbHeight: thumb,
|
|
33
|
+
barHeight: viewport - 1,
|
|
34
|
+
barTop: layoutRef.current.barTop,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const rows = Array.from({ length: viewport - 1 }).map((_, i) => {
|
|
38
|
+
const isThumb = i >= pos && i < pos + thumb;
|
|
39
|
+
return (
|
|
40
|
+
<Text
|
|
41
|
+
key={i}
|
|
42
|
+
color={
|
|
43
|
+
isThumb ? (isDragging ? theme.info.primary : theme.text.muted) : theme.text.muted
|
|
44
|
+
}
|
|
45
|
+
>
|
|
46
|
+
{isThumb ? "▓" : "│"}
|
|
47
|
+
</Text>
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Box ref={barRef} flexDirection="column" width={1}>
|
|
53
|
+
{rows}
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NEON_COLORS } from "@mirai/core/constants";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "../../theme.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
model: string;
|
|
7
|
+
tokensUsed: number;
|
|
8
|
+
totalTokens: number;
|
|
9
|
+
cost: number;
|
|
10
|
+
startTime: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function StatusBar({
|
|
14
|
+
model,
|
|
15
|
+
tokensUsed,
|
|
16
|
+
totalTokens,
|
|
17
|
+
cost,
|
|
18
|
+
startTime,
|
|
19
|
+
}: Props) {
|
|
20
|
+
const elapsed = Date.now() - startTime;
|
|
21
|
+
const s = Math.floor(elapsed / 1000);
|
|
22
|
+
const m = Math.floor(s / 60);
|
|
23
|
+
const time = m > 0 ? `${m}m${s % 60}s` : `${s}s`;
|
|
24
|
+
return (
|
|
25
|
+
<Box
|
|
26
|
+
backgroundColor={theme.bg.surface}
|
|
27
|
+
flexDirection="row"
|
|
28
|
+
borderLeft={true}
|
|
29
|
+
borderTop={false}
|
|
30
|
+
borderRight={true}
|
|
31
|
+
borderBottom={false}
|
|
32
|
+
borderStyle={"bold"}
|
|
33
|
+
borderColor={theme.role.user}
|
|
34
|
+
justifyContent="space-between"
|
|
35
|
+
paddingX={1}
|
|
36
|
+
>
|
|
37
|
+
<Text color={theme.text.dim}>{model}</Text>
|
|
38
|
+
<Text color={theme.text.dim}>
|
|
39
|
+
{tokensUsed}/{totalTokens} tok | ~${cost.toFixed(6)} | {time}
|
|
40
|
+
</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveRenderer } from '../renderers/registry.js';
|
|
2
|
+
import type { ToolResultBlock } from '@mirai/core/types';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
block: ToolResultBlock;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ToolResultView({ block }: Props) {
|
|
9
|
+
const Renderer = resolveRenderer(block.toolName);
|
|
10
|
+
return <Renderer block={block} />;
|
|
11
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { detectProvider } from "@mirai/llm";
|
|
3
|
+
import { DEFAULT_MODEL } from "@mirai/core/constants";
|
|
4
|
+
import type { Message, ContentBlock, ToolEntry } from "@mirai/core/types";
|
|
5
|
+
|
|
6
|
+
export interface ChatState {
|
|
7
|
+
messages: Message[];
|
|
8
|
+
isStreaming: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
toolFeed: ToolEntry[];
|
|
11
|
+
tokensUsed: number;
|
|
12
|
+
cost: number;
|
|
13
|
+
startTime: number;
|
|
14
|
+
submit(prompt: string): Promise<void>;
|
|
15
|
+
submitVersion: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useChat(): ChatState {
|
|
19
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
20
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [toolFeed, setToolFeed] = useState<ToolEntry[]>([]);
|
|
23
|
+
const [tokensUsed, setTokensUsed] = useState(0);
|
|
24
|
+
const [cost, setCost] = useState(0);
|
|
25
|
+
const startTimeRef = useRef(Date.now());
|
|
26
|
+
const messagesRef = useRef<Message[]>([]);
|
|
27
|
+
const [submitVersion, setSubmitVersion] = useState(0);
|
|
28
|
+
messagesRef.current = messages;
|
|
29
|
+
|
|
30
|
+
const submit = useCallback(async (prompt: string) => {
|
|
31
|
+
setError(null);
|
|
32
|
+
setIsStreaming(true);
|
|
33
|
+
setSubmitVersion((v) => v + 1);
|
|
34
|
+
|
|
35
|
+
// Snapshot current messages via ref (avoid stale closure)
|
|
36
|
+
const prevMsgs = messagesRef.current;
|
|
37
|
+
|
|
38
|
+
const userMsg: Message = {
|
|
39
|
+
role: "user",
|
|
40
|
+
content: [{ type: "text", text: prompt }],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Add user message + empty assistant placeholder
|
|
44
|
+
setMessages([...prevMsgs, userMsg, { role: "assistant", content: [] }]);
|
|
45
|
+
|
|
46
|
+
const apiMessages: Message[] = [
|
|
47
|
+
{
|
|
48
|
+
role: "system",
|
|
49
|
+
content: [{ type: "text", text: "You are a helpful coding assistant." }],
|
|
50
|
+
},
|
|
51
|
+
...prevMsgs,
|
|
52
|
+
userMsg,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Local parsing state (survives across setMessages batches)
|
|
56
|
+
let full = "";
|
|
57
|
+
let thinkingBuffer = "";
|
|
58
|
+
let inThinking = false;
|
|
59
|
+
let thinkingStartedAt = 0;
|
|
60
|
+
let thinkingDuration = 0;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const provider = detectProvider(DEFAULT_MODEL);
|
|
64
|
+
const req = { model: DEFAULT_MODEL, messages: apiMessages, stream: true };
|
|
65
|
+
|
|
66
|
+
for await (const event of provider.stream(req)) {
|
|
67
|
+
if (event.type === "thinking_delta") {
|
|
68
|
+
// Provider emits reasoning natively (OpenAI/xAI)
|
|
69
|
+
if (!thinkingStartedAt) thinkingStartedAt = Date.now();
|
|
70
|
+
thinkingBuffer += event.delta;
|
|
71
|
+
setMessages((prev) => {
|
|
72
|
+
const next = [...prev];
|
|
73
|
+
const last = { ...next[next.length - 1] };
|
|
74
|
+
const blocks: ContentBlock[] = [];
|
|
75
|
+
if (thinkingBuffer) {
|
|
76
|
+
blocks.push({
|
|
77
|
+
type: "thinking",
|
|
78
|
+
thinking: thinkingBuffer,
|
|
79
|
+
durationMs: thinkingDuration || undefined,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (full) blocks.push({ type: "text", text: full });
|
|
83
|
+
next[next.length - 1] = { ...last, content: blocks };
|
|
84
|
+
return next;
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (event.type === "text_delta") {
|
|
90
|
+
const chunk = event.delta;
|
|
91
|
+
|
|
92
|
+
// Handle <think> tags (Ollama-style reasoning in text)
|
|
93
|
+
if (!inThinking && chunk.includes("<think>")) {
|
|
94
|
+
if (!thinkingStartedAt) thinkingStartedAt = Date.now();
|
|
95
|
+
inThinking = true;
|
|
96
|
+
const [_before, ...rest] = chunk.split("<think>");
|
|
97
|
+
const after = rest.join("<think>"); // chunk after the opening tag
|
|
98
|
+
if (_before) full += _before;
|
|
99
|
+
if (after) {
|
|
100
|
+
const [thinkContent, ...tail] = after.split("</think>");
|
|
101
|
+
thinkingBuffer += thinkContent;
|
|
102
|
+
if (tail.length > 1) {
|
|
103
|
+
// closing tag found in same chunk
|
|
104
|
+
inThinking = false;
|
|
105
|
+
if (!thinkingDuration) thinkingDuration = Date.now() - thinkingStartedAt;
|
|
106
|
+
full += tail.join("</think>");
|
|
107
|
+
} else if (tail.length === 1 && tail[0]) {
|
|
108
|
+
// after </think> there's more text
|
|
109
|
+
inThinking = false;
|
|
110
|
+
if (!thinkingDuration) thinkingDuration = Date.now() - thinkingStartedAt;
|
|
111
|
+
full += tail[0];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else if (inThinking && chunk.includes("</think>")) {
|
|
115
|
+
const [thinkContent, ...rest] = chunk.split("</think>");
|
|
116
|
+
thinkingBuffer += thinkContent;
|
|
117
|
+
inThinking = false;
|
|
118
|
+
if (!thinkingDuration) thinkingDuration = Date.now() - thinkingStartedAt;
|
|
119
|
+
if (rest.length) full += rest.join("</think>");
|
|
120
|
+
} else if (inThinking) {
|
|
121
|
+
thinkingBuffer += chunk;
|
|
122
|
+
} else {
|
|
123
|
+
// Native thinking_delta just ended: first text_delta → calc duration
|
|
124
|
+
if (thinkingStartedAt && !thinkingDuration) {
|
|
125
|
+
thinkingDuration = Date.now() - thinkingStartedAt;
|
|
126
|
+
}
|
|
127
|
+
// Also check for </think> without opening tag (edge case)
|
|
128
|
+
if (chunk.includes("</think>")) {
|
|
129
|
+
const [thinkContent, ...rest] = chunk.split("</think>");
|
|
130
|
+
thinkingBuffer += thinkContent;
|
|
131
|
+
inThinking = false;
|
|
132
|
+
if (!thinkingDuration) thinkingDuration = Date.now() - thinkingStartedAt;
|
|
133
|
+
if (rest.length) full += rest.join("</think>");
|
|
134
|
+
} else {
|
|
135
|
+
full += chunk;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Update last message blocks
|
|
140
|
+
setMessages((prev) => {
|
|
141
|
+
const next = [...prev];
|
|
142
|
+
const last = { ...next[next.length - 1] };
|
|
143
|
+
const blocks: ContentBlock[] = [];
|
|
144
|
+
if (thinkingBuffer) {
|
|
145
|
+
blocks.push({
|
|
146
|
+
type: "thinking",
|
|
147
|
+
thinking: thinkingBuffer,
|
|
148
|
+
durationMs: thinkingDuration || undefined,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (full) blocks.push({ type: "text", text: full });
|
|
152
|
+
next[next.length - 1] = { ...last, content: blocks };
|
|
153
|
+
return next;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event.type === "tool_call") {
|
|
158
|
+
setMessages((prev) => {
|
|
159
|
+
const next = [...prev];
|
|
160
|
+
const last = { ...next[next.length - 1] };
|
|
161
|
+
const blocks: ContentBlock[] = [...last.content];
|
|
162
|
+
blocks.push({
|
|
163
|
+
type: "tool_use",
|
|
164
|
+
id: event.id,
|
|
165
|
+
name: event.name,
|
|
166
|
+
input: event.input as Record<string, unknown>,
|
|
167
|
+
});
|
|
168
|
+
next[next.length - 1] = { ...last, content: blocks };
|
|
169
|
+
return next;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (event.type === "done") {
|
|
174
|
+
// Finalize thinking duration if still running
|
|
175
|
+
if (thinkingStartedAt && !thinkingDuration) {
|
|
176
|
+
thinkingDuration = Date.now() - thinkingStartedAt;
|
|
177
|
+
}
|
|
178
|
+
if (event.usage) {
|
|
179
|
+
const p = "prompt_tokens" in event.usage
|
|
180
|
+
? (event.usage as any).prompt_tokens ?? 0
|
|
181
|
+
: event.usage.inputTokens;
|
|
182
|
+
const c = "completion_tokens" in event.usage
|
|
183
|
+
? (event.usage as any).completion_tokens ?? 0
|
|
184
|
+
: event.usage.outputTokens;
|
|
185
|
+
setTokensUsed((prev) => prev + p + c);
|
|
186
|
+
setCost((prev) => prev + p * 0.000002 + c * 0.00001);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
192
|
+
} finally {
|
|
193
|
+
setIsStreaming(false);
|
|
194
|
+
}
|
|
195
|
+
}, []); // stable — reads messages via ref
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
messages,
|
|
199
|
+
isStreaming,
|
|
200
|
+
error,
|
|
201
|
+
toolFeed,
|
|
202
|
+
tokensUsed,
|
|
203
|
+
cost,
|
|
204
|
+
startTime: startTimeRef.current,
|
|
205
|
+
submit,
|
|
206
|
+
submitVersion,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState, useRef, useCallback } from "react";
|
|
2
|
+
import { DOMElement } from "ink";
|
|
3
|
+
import { mouseInput, type MouseState } from "../services/mouse-input.js";
|
|
4
|
+
|
|
5
|
+
// ─── Context ────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
interface MouseCtx {
|
|
8
|
+
state: MouseState;
|
|
9
|
+
isSupported: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MouseContext = createContext<MouseCtx>({
|
|
13
|
+
state: { x: 0, y: 0, button: "none", isPressed: false, wheelDelta: 0 },
|
|
14
|
+
isSupported: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function MouseStateProvider({ children }: { children: React.ReactNode }) {
|
|
18
|
+
const [state, setState] = useState<MouseState>(mouseInput.state);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const unsub = mouseInput.on((s) => setState({ ...s }));
|
|
22
|
+
return unsub;
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<MouseContext.Provider value={{ state, isSupported: mouseInput.isTTY }}>
|
|
27
|
+
{children}
|
|
28
|
+
</MouseContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useMouseState(): MouseState {
|
|
33
|
+
return useContext(MouseContext).state;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── useOnMouseClick ────────────────────────────────────────
|
|
37
|
+
// Calls handler when left-click is detected within element bounds.
|
|
38
|
+
// Falls back to coordinate check if DOMElement bounds unavailable.
|
|
39
|
+
|
|
40
|
+
export function useOnMouseClick(
|
|
41
|
+
ref: React.RefObject<DOMElement | null>,
|
|
42
|
+
handler: () => void,
|
|
43
|
+
) {
|
|
44
|
+
const wasPressedRef = useRef(false);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const unsub = mouseInput.on((state) => {
|
|
48
|
+
if (!ref.current) return;
|
|
49
|
+
|
|
50
|
+
const node = (ref.current as any).yogaNode;
|
|
51
|
+
if (!node) return;
|
|
52
|
+
|
|
53
|
+
const left = node.getComputedLeft?.() ?? 0;
|
|
54
|
+
const top = node.getComputedTop?.() ?? 0;
|
|
55
|
+
const width = node.getComputedWidth?.() ?? 0;
|
|
56
|
+
const height = node.getComputedHeight?.() ?? 0;
|
|
57
|
+
|
|
58
|
+
const inside =
|
|
59
|
+
state.x >= left &&
|
|
60
|
+
state.x < left + width &&
|
|
61
|
+
state.y >= top &&
|
|
62
|
+
state.y < top + height;
|
|
63
|
+
|
|
64
|
+
if (inside && state.isPressed && !wasPressedRef.current) {
|
|
65
|
+
wasPressedRef.current = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (inside && !state.isPressed && wasPressedRef.current) {
|
|
69
|
+
wasPressedRef.current = false;
|
|
70
|
+
handler();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!state.isPressed) {
|
|
74
|
+
wasPressedRef.current = false;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return unsub;
|
|
79
|
+
}, [ref, handler]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── useOnMouseHover ────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function useOnMouseHover(
|
|
85
|
+
ref: React.RefObject<DOMElement | null>,
|
|
86
|
+
onEnter: () => void,
|
|
87
|
+
onLeave: () => void,
|
|
88
|
+
) {
|
|
89
|
+
const wasInsideRef = useRef(false);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const unsub = mouseInput.on((state) => {
|
|
93
|
+
if (!ref.current) return;
|
|
94
|
+
|
|
95
|
+
const node = (ref.current as any).yogaNode;
|
|
96
|
+
if (!node) return;
|
|
97
|
+
|
|
98
|
+
const left = node.getComputedLeft?.() ?? 0;
|
|
99
|
+
const top = node.getComputedTop?.() ?? 0;
|
|
100
|
+
const width = node.getComputedWidth?.() ?? 0;
|
|
101
|
+
const height = node.getComputedHeight?.() ?? 0;
|
|
102
|
+
|
|
103
|
+
const inside =
|
|
104
|
+
state.x >= left &&
|
|
105
|
+
state.x < left + width &&
|
|
106
|
+
state.y >= top &&
|
|
107
|
+
state.y < top + height;
|
|
108
|
+
|
|
109
|
+
if (inside && !wasInsideRef.current) {
|
|
110
|
+
wasInsideRef.current = true;
|
|
111
|
+
onEnter();
|
|
112
|
+
}
|
|
113
|
+
if (!inside && wasInsideRef.current) {
|
|
114
|
+
wasInsideRef.current = false;
|
|
115
|
+
onLeave();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return unsub;
|
|
120
|
+
}, [ref, onEnter, onLeave]);
|
|
121
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { TuiPermissionProvider } from "../services/tui-permission-provider.js";
|
|
3
|
+
import type { PermissionRequest, PermissionResponse } from "@mirai/permission";
|
|
4
|
+
import { InMemoryRuleStore } from "@mirai/permission";
|
|
5
|
+
import { PermissionMode } from "@mirai/permission";
|
|
6
|
+
import type { RuleStore } from "@mirai/permission";
|
|
7
|
+
|
|
8
|
+
export interface PermissionState {
|
|
9
|
+
provider: TuiPermissionProvider;
|
|
10
|
+
ruleStore: RuleStore;
|
|
11
|
+
mode: PermissionMode;
|
|
12
|
+
setMode: (mode: PermissionMode) => void;
|
|
13
|
+
pendingRequest: PermissionRequest | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function usePermission(initialMode: PermissionMode = PermissionMode.WORKSPACE_WRITE): PermissionState {
|
|
17
|
+
const [mode, setMode] = useState<PermissionMode>(initialMode);
|
|
18
|
+
const [pendingRequest, setPendingRequest] = useState<PermissionRequest | null>(null);
|
|
19
|
+
const providerRef = useRef<TuiPermissionProvider>(new TuiPermissionProvider());
|
|
20
|
+
const ruleStoreRef = useRef<RuleStore>(new InMemoryRuleStore());
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
providerRef.current.onRequest((req) => {
|
|
24
|
+
setPendingRequest(req);
|
|
25
|
+
});
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
provider: providerRef.current,
|
|
30
|
+
ruleStore: ruleStoreRef.current,
|
|
31
|
+
mode,
|
|
32
|
+
setMode,
|
|
33
|
+
pendingRequest,
|
|
34
|
+
};
|
|
35
|
+
}
|