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 CHANGED
@@ -284,7 +284,7 @@ This is the config shape loaded by Hooman:
284
284
  "behaviour": true,
285
285
  "communication": true,
286
286
  "execution": true,
287
- "engineering": false,
287
+ "engineering": true,
288
288
  "guardrails": true
289
289
  },
290
290
  "tools": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hoomanjs",
3
- "version": "1.17.3",
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",
@@ -4,7 +4,7 @@ import {
4
4
  INTERNAL_ALWAYS_ALLOWED,
5
5
  allowToolForSession,
6
6
  isToolSessionAllowed,
7
- } from "../core/approvals/allowed-tools.ts";
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/approvals/allowed-tools.ts";
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 { Transcript } from "./components/Transcript.tsx";
30
+ import { TranscriptViewport } from "./components/TranscriptViewport.tsx";
30
31
  import type { ApprovalRequest, ChatLine } from "./types.ts";
31
- import { getTodoViewState, type TodoViewState } from "../core/tools/todo.ts";
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
- <Transcript lines={lines} liveReasoning={liveReasoning} />
579
- {running && todoState.visible && todoState.todos.length > 0 ? (
580
- <TodoPanel todos={todoState.todos} />
581
- ) : null}
582
- <QueuedPrompts prompts={queuedPrompts} />
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
- {!pendingApproval ? (
591
- <Composer
592
- input={input}
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
- disabled={Boolean(pendingApproval)}
595
- hint={INPUT_HINT}
596
- onChange={setInput}
597
- onSubmit={onSubmit}
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
- ) : null}
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
  }
@@ -3,7 +3,7 @@ import {
3
3
  INTERNAL_ALWAYS_ALLOWED,
4
4
  allowToolForSession,
5
5
  isToolSessionAllowed,
6
- } from "../core/approvals/allowed-tools.ts";
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,5 +1,5 @@
1
1
  import { Box, Text } from "ink";
2
- import type { TodoItem } from "../../core/tools/todo.ts";
2
+ import type { TodoItem } from "../../core/state/todos.ts";
3
3
 
4
4
  type TodoPanelProps = {
5
5
  todos: TodoItem[];
@@ -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
+ }