hoomanjs 1.17.3 → 1.18.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/README.md +1 -1
- package/package.json +3 -1
- package/src/acp/approvals.ts +1 -1
- package/src/acp/utils/tool-kind.ts +1 -1
- package/src/chat/app.tsx +57 -36
- package/src/chat/approvals.ts +1 -1
- package/src/chat/components/TodoPanel.tsx +1 -1
- package/src/chat/components/ToolEvent.tsx +7 -0
- package/src/chat/components/ToolEventFileResult.tsx +116 -0
- package/src/chat/components/TranscriptViewport.tsx +170 -0
- package/src/chat/components/file-tool-diff/file-tool-result.ts +155 -0
- package/src/chat/components/prompt-input/usePromptInputController.ts +5 -0
- package/src/chat/mouse.ts +17 -0
- package/src/chat/types.ts +3 -0
- package/src/core/agent/index.ts +1 -1
- package/src/core/agents/registry.ts +12 -14
- package/src/core/config.ts +1 -1
- package/src/core/prompts/harness/engineering.md +4 -4
- package/src/core/prompts/system.ts +13 -0
- package/src/core/state/file-tool-display.ts +70 -0
- package/src/core/state/thought-process.ts +49 -0
- package/src/core/state/todos.ts +84 -0
- package/src/core/tools/filesystem.ts +369 -26
- package/src/core/tools/thinking.ts +5 -40
- package/src/core/tools/todo.ts +6 -79
- package/src/daemon/approvals.ts +1 -1
- package/src/exec/approvals.ts +1 -1
- /package/src/core/{approvals/allowed-tools.ts → state/tool-approvals.ts} +0 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Hackable Bun-powered AI agent toolkit for building local CLI, ACP, MCP, and channel-driven workflows.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vaibhav Pandey",
|
|
@@ -70,8 +70,10 @@
|
|
|
70
70
|
"handlebars": "^4.7.9",
|
|
71
71
|
"ink": "^7.0.0",
|
|
72
72
|
"ink-ansi": "^1.0.0",
|
|
73
|
+
"ink-scroll-view": "^0.3.6",
|
|
73
74
|
"ink-select-input": "^6.2.0",
|
|
74
75
|
"ink-text-input": "^6.0.0",
|
|
76
|
+
"ink-use-mouse": "^1.0.0",
|
|
75
77
|
"jsdom": "^29.0.2",
|
|
76
78
|
"lodash": "^4.18.1",
|
|
77
79
|
"luxon": "^3.7.2",
|
package/src/acp/approvals.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
INTERNAL_ALWAYS_ALLOWED,
|
|
5
5
|
allowToolForSession,
|
|
6
6
|
isToolSessionAllowed,
|
|
7
|
-
} from "../core/
|
|
7
|
+
} from "../core/state/tool-approvals.ts";
|
|
8
8
|
import { inferToolKind, toolDisplayTitle } from "./utils/tool-kind.ts";
|
|
9
9
|
import { toolCallLocationsFromInput } from "./utils/tool-locations.ts";
|
|
10
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ToolKind } from "@agentclientprotocol/sdk";
|
|
2
2
|
import type { Tool } from "@strands-agents/sdk";
|
|
3
|
-
import { INTERNAL_ALWAYS_ALLOWED } from "../../core/
|
|
3
|
+
import { INTERNAL_ALWAYS_ALLOWED } from "../../core/state/tool-approvals.ts";
|
|
4
4
|
|
|
5
5
|
const KNOWN_TOOL_KINDS = new Map<string, ToolKind>([
|
|
6
6
|
["read_file", "read"],
|
package/src/chat/app.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import React, {
|
|
|
6
6
|
useState,
|
|
7
7
|
} from "react";
|
|
8
8
|
import fastq from "fastq";
|
|
9
|
-
import { Box, useApp, useInput } from "ink";
|
|
9
|
+
import { Box, useApp, useInput, useWindowSize } from "ink";
|
|
10
10
|
import {
|
|
11
11
|
BeforeToolCallEvent,
|
|
12
12
|
Message,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "@strands-agents/sdk";
|
|
18
18
|
import type { Manager as McpManager } from "../core/mcp/index.ts";
|
|
19
19
|
import type { Registry } from "../core/skills/index.ts";
|
|
20
|
+
import { takeFileToolDisplay } from "../core/state/file-tool-display.ts";
|
|
20
21
|
import {
|
|
21
22
|
ChatApprovalController,
|
|
22
23
|
createChatApprovalHandler,
|
|
@@ -26,10 +27,11 @@ import { Composer } from "./components/Composer.tsx";
|
|
|
26
27
|
import { QueuedPrompts } from "./components/QueuedPrompts.tsx";
|
|
27
28
|
import { StatusBar } from "./components/StatusBar.tsx";
|
|
28
29
|
import { TodoPanel } from "./components/TodoPanel.tsx";
|
|
29
|
-
import {
|
|
30
|
+
import { TranscriptViewport } from "./components/TranscriptViewport.tsx";
|
|
30
31
|
import type { ApprovalRequest, ChatLine } from "./types.ts";
|
|
31
|
-
import { getTodoViewState, type TodoViewState } from "../core/
|
|
32
|
+
import { getTodoViewState, type TodoViewState } from "../core/state/todos.ts";
|
|
32
33
|
import { attachmentPathsToPromptBlocks } from "../core/utils/attachments.ts";
|
|
34
|
+
import { isMouseInput } from "./mouse.ts";
|
|
33
35
|
import type { PromptSubmission } from "./components/prompt-input/hooks/usePromptInputController.ts";
|
|
34
36
|
|
|
35
37
|
type ChatAppProps = {
|
|
@@ -121,6 +123,7 @@ export function ChatApp({
|
|
|
121
123
|
onExit,
|
|
122
124
|
}: ChatAppProps): React.JSX.Element {
|
|
123
125
|
const { exit } = useApp();
|
|
126
|
+
const { rows } = useWindowSize();
|
|
124
127
|
const totalTools =
|
|
125
128
|
(agent as Agent & { tools?: unknown[] }).tools?.length ?? 0;
|
|
126
129
|
const [input, setInput] = useState("");
|
|
@@ -141,6 +144,7 @@ export function ChatApp({
|
|
|
141
144
|
useState<ApprovalRequest | null>(null);
|
|
142
145
|
const [queuedPrompts, setQueuedPrompts] = useState<QueuedPrompt[]>([]);
|
|
143
146
|
const [liveReasoning, setLiveReasoning] = useState("");
|
|
147
|
+
const [followRequest, setFollowRequest] = useState(0);
|
|
144
148
|
const [todoState, setTodoState] = useState<TodoViewState>(() =>
|
|
145
149
|
getTodoViewState(agent),
|
|
146
150
|
);
|
|
@@ -342,6 +346,10 @@ export function ChatApp({
|
|
|
342
346
|
case "toolResultEvent": {
|
|
343
347
|
const resultContent = toToolResultText(e.result);
|
|
344
348
|
const toolUseId = getToolUseId(e.result);
|
|
349
|
+
const fileToolDisplay = takeFileToolDisplay(
|
|
350
|
+
agent.appState,
|
|
351
|
+
toolUseId,
|
|
352
|
+
);
|
|
345
353
|
let toolLineId = toolUseId
|
|
346
354
|
? toolLineIdsRef.current.get(toolUseId)
|
|
347
355
|
: undefined;
|
|
@@ -365,6 +373,7 @@ export function ChatApp({
|
|
|
365
373
|
phase: "done",
|
|
366
374
|
done: true,
|
|
367
375
|
resultContent,
|
|
376
|
+
fileToolDisplay,
|
|
368
377
|
});
|
|
369
378
|
} else {
|
|
370
379
|
appendLine({
|
|
@@ -374,6 +383,7 @@ export function ChatApp({
|
|
|
374
383
|
toolName: "unknown",
|
|
375
384
|
content: "",
|
|
376
385
|
resultContent,
|
|
386
|
+
fileToolDisplay,
|
|
377
387
|
phase: "done",
|
|
378
388
|
done: true,
|
|
379
389
|
});
|
|
@@ -524,6 +534,7 @@ export function ChatApp({
|
|
|
524
534
|
return;
|
|
525
535
|
}
|
|
526
536
|
if (pushPrompt(value)) {
|
|
537
|
+
setFollowRequest((value) => value + 1);
|
|
527
538
|
setInput("");
|
|
528
539
|
}
|
|
529
540
|
},
|
|
@@ -532,6 +543,9 @@ export function ChatApp({
|
|
|
532
543
|
|
|
533
544
|
useInput(
|
|
534
545
|
(inputKey, key) => {
|
|
546
|
+
if (isMouseInput(inputKey)) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
535
549
|
if (key.ctrl && inputKey.toLowerCase() === "c") {
|
|
536
550
|
if (runningRef.current) {
|
|
537
551
|
agent.cancel();
|
|
@@ -574,42 +588,49 @@ export function ChatApp({
|
|
|
574
588
|
: status;
|
|
575
589
|
|
|
576
590
|
return (
|
|
577
|
-
<Box flexDirection="column" width="100%" paddingX={1}>
|
|
578
|
-
<
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
{pendingApproval ? (
|
|
585
|
-
<ApprovalPrompt
|
|
586
|
-
onDecision={(decision) => controllerRef.current.decide(decision)}
|
|
587
|
-
/>
|
|
588
|
-
) : null}
|
|
591
|
+
<Box flexDirection="column" width="100%" height={rows} paddingX={1}>
|
|
592
|
+
<TranscriptViewport
|
|
593
|
+
lines={lines}
|
|
594
|
+
liveReasoning={liveReasoning}
|
|
595
|
+
followRequest={followRequest}
|
|
596
|
+
/>
|
|
589
597
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
598
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
599
|
+
{running && todoState.visible && todoState.todos.length > 0 ? (
|
|
600
|
+
<TodoPanel todos={todoState.todos} />
|
|
601
|
+
) : null}
|
|
602
|
+
<QueuedPrompts prompts={queuedPrompts} />
|
|
603
|
+
|
|
604
|
+
{pendingApproval ? (
|
|
605
|
+
<ApprovalPrompt
|
|
606
|
+
onDecision={(decision) => controllerRef.current.decide(decision)}
|
|
607
|
+
/>
|
|
608
|
+
) : null}
|
|
609
|
+
|
|
610
|
+
{!pendingApproval ? (
|
|
611
|
+
<Composer
|
|
612
|
+
input={input}
|
|
613
|
+
running={running}
|
|
614
|
+
disabled={Boolean(pendingApproval)}
|
|
615
|
+
hint={INPUT_HINT}
|
|
616
|
+
onChange={setInput}
|
|
617
|
+
onSubmit={onSubmit}
|
|
618
|
+
/>
|
|
619
|
+
) : null}
|
|
620
|
+
|
|
621
|
+
<StatusBar
|
|
593
622
|
running={running}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
623
|
+
status={status}
|
|
624
|
+
statusLabel={statusLabel}
|
|
625
|
+
sessionId={sessionId}
|
|
626
|
+
elapsedLabel={elapsedLabel}
|
|
627
|
+
turnCount={turnCount}
|
|
628
|
+
totalTools={totalTools}
|
|
629
|
+
skillsFound={skillsFound}
|
|
630
|
+
manager={manager}
|
|
631
|
+
usage={usage}
|
|
598
632
|
/>
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
<StatusBar
|
|
602
|
-
running={running}
|
|
603
|
-
status={status}
|
|
604
|
-
statusLabel={statusLabel}
|
|
605
|
-
sessionId={sessionId}
|
|
606
|
-
elapsedLabel={elapsedLabel}
|
|
607
|
-
turnCount={turnCount}
|
|
608
|
-
totalTools={totalTools}
|
|
609
|
-
skillsFound={skillsFound}
|
|
610
|
-
manager={manager}
|
|
611
|
-
usage={usage}
|
|
612
|
-
/>
|
|
633
|
+
</Box>
|
|
613
634
|
</Box>
|
|
614
635
|
);
|
|
615
636
|
}
|
package/src/chat/approvals.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
INTERNAL_ALWAYS_ALLOWED,
|
|
4
4
|
allowToolForSession,
|
|
5
5
|
isToolSessionAllowed,
|
|
6
|
-
} from "../core/
|
|
6
|
+
} from "../core/state/tool-approvals.ts";
|
|
7
7
|
import type { ApprovalDecision, ApprovalRequest } from "./types.ts";
|
|
8
8
|
const INPUT_PREVIEW_LIMIT = 256;
|
|
9
9
|
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import type { ChatLine } from "../types.ts";
|
|
3
|
+
import { useFileToolResult } from "./file-tool-diff/file-tool-result.ts";
|
|
3
4
|
import { compactInline, formatToolArgs } from "./shared.ts";
|
|
4
5
|
import { Spinner } from "./Spinner.tsx";
|
|
6
|
+
import { ToolEventFileResult } from "./ToolEventFileResult.tsx";
|
|
5
7
|
|
|
6
8
|
type ToolEventProps = {
|
|
7
9
|
line: ChatLine;
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
export function ToolEvent({ line }: ToolEventProps) {
|
|
13
|
+
const fileToolResult = useFileToolResult(line);
|
|
14
|
+
if (fileToolResult) {
|
|
15
|
+
return <ToolEventFileResult line={line} result={fileToolResult} />;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
const args = formatToolArgs(line.content)[0] ?? "";
|
|
12
19
|
const result = line.resultContent
|
|
13
20
|
? compactInline(line.resultContent, 256)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
import type { ChatLine } from "../types.ts";
|
|
4
|
+
import { compactInline, truncLine } from "./shared.ts";
|
|
5
|
+
import { Spinner } from "./Spinner.tsx";
|
|
6
|
+
import {
|
|
7
|
+
type FileToolResult,
|
|
8
|
+
type StructuredPatchHunk,
|
|
9
|
+
} from "./file-tool-diff/file-tool-result.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_PATCH_LINES = 18;
|
|
12
|
+
const MAX_PATCH_WIDTH = 140;
|
|
13
|
+
|
|
14
|
+
type ToolEventFileResultProps = {
|
|
15
|
+
line: ChatLine;
|
|
16
|
+
result: FileToolResult;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function ToolEventFileResult({
|
|
20
|
+
line,
|
|
21
|
+
result,
|
|
22
|
+
}: ToolEventFileResultProps): React.ReactNode {
|
|
23
|
+
return (
|
|
24
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
25
|
+
<Text color="yellow" bold>
|
|
26
|
+
Tool
|
|
27
|
+
</Text>
|
|
28
|
+
<Text>
|
|
29
|
+
<Text bold>{result.toolName}</Text>
|
|
30
|
+
<Text color="gray">: </Text>
|
|
31
|
+
<Text>{compactInline(result.path, 160)}</Text>
|
|
32
|
+
</Text>
|
|
33
|
+
{line.phase === "running" ? (
|
|
34
|
+
<Box flexDirection="row">
|
|
35
|
+
<Spinner type="dots" color="yellow" />
|
|
36
|
+
<Text color="gray"> running...</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
) : null}
|
|
39
|
+
{line.phase === "done" ? <FileToolSummary result={result} /> : null}
|
|
40
|
+
{result.structuredPatch.length > 0 ? (
|
|
41
|
+
<PatchPreview hunks={result.structuredPatch} />
|
|
42
|
+
) : null}
|
|
43
|
+
</Box>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function FileToolSummary({ result }: { result: FileToolResult }) {
|
|
48
|
+
if (result.kind === "write") {
|
|
49
|
+
const operation = result.appended ? "Appended" : "Wrote";
|
|
50
|
+
return (
|
|
51
|
+
<Text color="gray">
|
|
52
|
+
{operation}
|
|
53
|
+
{result.bytesWritten === undefined
|
|
54
|
+
? ""
|
|
55
|
+
: ` ${result.bytesWritten} bytes`}
|
|
56
|
+
</Text>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const mode = result.dryRun ? "Previewed" : "Applied";
|
|
61
|
+
const edits =
|
|
62
|
+
result.editsApplied === undefined
|
|
63
|
+
? "edits"
|
|
64
|
+
: `${result.editsApplied} ${result.editsApplied === 1 ? "edit" : "edits"}`;
|
|
65
|
+
const changed =
|
|
66
|
+
result.changed === undefined
|
|
67
|
+
? ""
|
|
68
|
+
: result.changed
|
|
69
|
+
? " changed"
|
|
70
|
+
: " no changes";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Text color="gray">
|
|
74
|
+
{mode} {edits}
|
|
75
|
+
{changed}
|
|
76
|
+
</Text>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function PatchPreview({ hunks }: { hunks: StructuredPatchHunk[] }) {
|
|
81
|
+
const lines = hunks.flatMap((hunk, index) => [
|
|
82
|
+
...(index > 0 ? ["..."] : []),
|
|
83
|
+
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
|
84
|
+
...hunk.lines,
|
|
85
|
+
]);
|
|
86
|
+
const visibleLines = lines.slice(0, MAX_PATCH_LINES);
|
|
87
|
+
const hiddenLineCount = lines.length - visibleLines.length;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Box flexDirection="column" marginTop={1}>
|
|
91
|
+
{visibleLines.map((line, index) => (
|
|
92
|
+
<Text key={`${index}-${line}`} color={colorForPatchLine(line)}>
|
|
93
|
+
{truncLine(line, MAX_PATCH_WIDTH)}
|
|
94
|
+
</Text>
|
|
95
|
+
))}
|
|
96
|
+
{hiddenLineCount > 0 ? (
|
|
97
|
+
<Text color="gray">
|
|
98
|
+
... +{hiddenLineCount} {hiddenLineCount === 1 ? "line" : "lines"}
|
|
99
|
+
</Text>
|
|
100
|
+
) : null}
|
|
101
|
+
</Box>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function colorForPatchLine(line: string): string {
|
|
106
|
+
if (line.startsWith("+")) {
|
|
107
|
+
return "green";
|
|
108
|
+
}
|
|
109
|
+
if (line.startsWith("-")) {
|
|
110
|
+
return "red";
|
|
111
|
+
}
|
|
112
|
+
if (line.startsWith("@@") || line === "...") {
|
|
113
|
+
return "gray";
|
|
114
|
+
}
|
|
115
|
+
return "white";
|
|
116
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, useInput, useStdin, useStdout } from "ink";
|
|
3
|
+
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
|
4
|
+
import type { ChatLine } from "../types.ts";
|
|
5
|
+
import {
|
|
6
|
+
MOUSE_REPORTING_DISABLE,
|
|
7
|
+
MOUSE_REPORTING_ENABLE,
|
|
8
|
+
parseMouseEvents,
|
|
9
|
+
} from "../mouse.ts";
|
|
10
|
+
import { Transcript } from "./Transcript.tsx";
|
|
11
|
+
|
|
12
|
+
type TranscriptViewportProps = {
|
|
13
|
+
lines: ChatLine[];
|
|
14
|
+
liveReasoning: string;
|
|
15
|
+
followRequest: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const WHEEL_LINES = 3;
|
|
19
|
+
|
|
20
|
+
function isAtBottom(scroll: ScrollViewRef): boolean {
|
|
21
|
+
return scroll.getScrollOffset() >= scroll.getBottomOffset() - 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clampScrollOffset(scroll: ScrollViewRef, offset: number): number {
|
|
25
|
+
return Math.max(0, Math.min(offset, scroll.getBottomOffset()));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TranscriptViewport({
|
|
29
|
+
lines,
|
|
30
|
+
liveReasoning,
|
|
31
|
+
followRequest,
|
|
32
|
+
}: TranscriptViewportProps): React.JSX.Element {
|
|
33
|
+
const scrollRef = useRef<ScrollViewRef>(null);
|
|
34
|
+
const stickyRef = useRef(true);
|
|
35
|
+
const { stdout } = useStdout();
|
|
36
|
+
const { stdin, setRawMode } = useStdin();
|
|
37
|
+
|
|
38
|
+
const remeasure = useCallback(() => {
|
|
39
|
+
scrollRef.current?.remeasure();
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const followIfSticky = useCallback(() => {
|
|
43
|
+
if (!stickyRef.current) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
scrollRef.current?.scrollToBottom();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const scrollBy = useCallback((delta: number) => {
|
|
50
|
+
const scroll = scrollRef.current;
|
|
51
|
+
if (!scroll) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (delta < 0) {
|
|
55
|
+
stickyRef.current = false;
|
|
56
|
+
}
|
|
57
|
+
scroll.scrollTo(
|
|
58
|
+
clampScrollOffset(scroll, scroll.getScrollOffset() + delta),
|
|
59
|
+
);
|
|
60
|
+
stickyRef.current = isAtBottom(scroll);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const scrollToTop = useCallback(() => {
|
|
64
|
+
const scroll = scrollRef.current;
|
|
65
|
+
if (!scroll) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
stickyRef.current = false;
|
|
69
|
+
scroll.scrollToTop();
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const scrollToBottom = useCallback(() => {
|
|
73
|
+
const scroll = scrollRef.current;
|
|
74
|
+
if (!scroll) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
stickyRef.current = true;
|
|
78
|
+
scroll.scrollToBottom();
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
stdout?.on("resize", remeasure);
|
|
83
|
+
return () => {
|
|
84
|
+
stdout?.off("resize", remeasure);
|
|
85
|
+
};
|
|
86
|
+
}, [remeasure, stdout]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
remeasure();
|
|
91
|
+
followIfSticky();
|
|
92
|
+
}, 0);
|
|
93
|
+
return () => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
};
|
|
96
|
+
}, [followIfSticky, lines, liveReasoning, remeasure]);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
scrollToBottom();
|
|
100
|
+
}, [followRequest, scrollToBottom]);
|
|
101
|
+
|
|
102
|
+
useInput((_, key) => {
|
|
103
|
+
const scroll = scrollRef.current;
|
|
104
|
+
if (!scroll) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const page = Math.max(1, scroll.getViewportHeight() - 1);
|
|
108
|
+
if (key.pageUp) {
|
|
109
|
+
scrollBy(-page);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (key.pageDown) {
|
|
113
|
+
scrollBy(page);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if ((key.ctrl || key.meta || key.super) && key.home) {
|
|
117
|
+
scrollToTop();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if ((key.ctrl || key.meta || key.super) && key.end) {
|
|
121
|
+
scrollToBottom();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!stdin) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const onData = (data: Buffer | string) => {
|
|
131
|
+
for (const event of parseMouseEvents(data.toString())) {
|
|
132
|
+
if (event.type === "scroll-up") {
|
|
133
|
+
scrollBy(-WHEEL_LINES);
|
|
134
|
+
} else if (event.type === "scroll-down") {
|
|
135
|
+
scrollBy(WHEEL_LINES);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
setRawMode(true);
|
|
141
|
+
process.stdout.write(MOUSE_REPORTING_ENABLE);
|
|
142
|
+
stdin.on("data", onData);
|
|
143
|
+
return () => {
|
|
144
|
+
stdin.off("data", onData);
|
|
145
|
+
process.stdout.write(MOUSE_REPORTING_DISABLE);
|
|
146
|
+
};
|
|
147
|
+
}, [scrollBy, setRawMode, stdin]);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Box flexDirection="column" flexGrow={1} minHeight={1}>
|
|
151
|
+
<ScrollView
|
|
152
|
+
ref={scrollRef}
|
|
153
|
+
flexGrow={1}
|
|
154
|
+
width="100%"
|
|
155
|
+
onScroll={(offset) => {
|
|
156
|
+
const scroll = scrollRef.current;
|
|
157
|
+
if (scroll) {
|
|
158
|
+
stickyRef.current = offset >= scroll.getBottomOffset() - 1;
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
onContentHeightChange={followIfSticky}
|
|
162
|
+
onViewportSizeChange={followIfSticky}
|
|
163
|
+
>
|
|
164
|
+
<Box key="transcript" flexDirection="column" width="100%">
|
|
165
|
+
<Transcript lines={lines} liveReasoning={liveReasoning} />
|
|
166
|
+
</Box>
|
|
167
|
+
</ScrollView>
|
|
168
|
+
</Box>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ChatLine } from "../../types.ts";
|
|
3
|
+
|
|
4
|
+
export type StructuredPatchHunk = {
|
|
5
|
+
oldStart: number;
|
|
6
|
+
oldLines: number;
|
|
7
|
+
newStart: number;
|
|
8
|
+
newLines: number;
|
|
9
|
+
lines: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type FileToolResult =
|
|
13
|
+
| {
|
|
14
|
+
kind: "write";
|
|
15
|
+
toolName: "write_file";
|
|
16
|
+
path: string;
|
|
17
|
+
appended: boolean;
|
|
18
|
+
bytesWritten?: number;
|
|
19
|
+
structuredPatch: StructuredPatchHunk[];
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
kind: "edit";
|
|
23
|
+
toolName: "edit_file";
|
|
24
|
+
path: string;
|
|
25
|
+
dryRun: boolean;
|
|
26
|
+
editsApplied?: number;
|
|
27
|
+
changed?: boolean;
|
|
28
|
+
previews: string[];
|
|
29
|
+
structuredPatch: StructuredPatchHunk[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type WriteFileInput = {
|
|
33
|
+
path: string;
|
|
34
|
+
append?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type WriteFileResult = {
|
|
38
|
+
path: string;
|
|
39
|
+
appended: boolean;
|
|
40
|
+
bytes_written: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type EditFileInput = {
|
|
44
|
+
path: string;
|
|
45
|
+
edits: Array<{
|
|
46
|
+
oldText: string;
|
|
47
|
+
newText: string;
|
|
48
|
+
}>;
|
|
49
|
+
dry_run?: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type EditFileResult = {
|
|
53
|
+
path: string;
|
|
54
|
+
dry_run: boolean;
|
|
55
|
+
edits_applied: number;
|
|
56
|
+
changed: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function parseJson<T>(raw: string | undefined): T | null {
|
|
60
|
+
if (!raw) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(raw) as T;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useFileToolResult(line: ChatLine): FileToolResult | null {
|
|
72
|
+
return useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
parseFileToolResult({
|
|
75
|
+
phase: line.phase,
|
|
76
|
+
toolName: line.toolName,
|
|
77
|
+
inputContent: line.content,
|
|
78
|
+
resultContent: line.resultContent,
|
|
79
|
+
structuredPatch: line.fileToolDisplay?.structuredPatch,
|
|
80
|
+
previews: line.fileToolDisplay?.previews,
|
|
81
|
+
}),
|
|
82
|
+
[
|
|
83
|
+
line.content,
|
|
84
|
+
line.fileToolDisplay?.previews,
|
|
85
|
+
line.fileToolDisplay?.structuredPatch,
|
|
86
|
+
line.phase,
|
|
87
|
+
line.resultContent,
|
|
88
|
+
line.toolName,
|
|
89
|
+
],
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseFileToolResult({
|
|
94
|
+
phase,
|
|
95
|
+
toolName,
|
|
96
|
+
inputContent,
|
|
97
|
+
resultContent,
|
|
98
|
+
previews,
|
|
99
|
+
structuredPatch,
|
|
100
|
+
}: {
|
|
101
|
+
phase: ChatLine["phase"];
|
|
102
|
+
toolName: string | undefined;
|
|
103
|
+
inputContent: string;
|
|
104
|
+
resultContent?: string;
|
|
105
|
+
previews?: string[];
|
|
106
|
+
structuredPatch?: StructuredPatchHunk[];
|
|
107
|
+
}): FileToolResult | null {
|
|
108
|
+
if (toolName === "write_file") {
|
|
109
|
+
const input = parseJson<WriteFileInput>(inputContent);
|
|
110
|
+
const result = parseJson<WriteFileResult>(resultContent);
|
|
111
|
+
if (phase === "done" && !result) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const path = result?.path ?? input?.path;
|
|
116
|
+
if (!path) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
kind: "write",
|
|
122
|
+
toolName,
|
|
123
|
+
path,
|
|
124
|
+
appended: result?.appended ?? input?.append ?? false,
|
|
125
|
+
bytesWritten: result?.bytes_written,
|
|
126
|
+
structuredPatch: structuredPatch ?? [],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (toolName === "edit_file") {
|
|
131
|
+
const input = parseJson<EditFileInput>(inputContent);
|
|
132
|
+
const result = parseJson<EditFileResult>(resultContent);
|
|
133
|
+
if (phase === "done" && !result) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const path = result?.path ?? input?.path;
|
|
138
|
+
if (!path) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
kind: "edit",
|
|
144
|
+
toolName,
|
|
145
|
+
path,
|
|
146
|
+
dryRun: result?.dry_run ?? input?.dry_run ?? false,
|
|
147
|
+
editsApplied: result?.edits_applied ?? input?.edits?.length,
|
|
148
|
+
changed: result?.changed,
|
|
149
|
+
previews: previews ?? [],
|
|
150
|
+
structuredPatch: structuredPatch ?? [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|