From 3823fd65aede7fc2e2c593d15a53f98e6bc8771b Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:55:11 +0100 Subject: [PATCH 01/40] feat: agent rework --- bun.lock | 100 ++---- package.json | 2 + src/commands/utils/workflow-errors.ts | 32 +- src/commands/write.ts | 36 ++- src/domain/errors.ts | 5 + src/domain/vfs.ts | 43 +++ src/domain/workflow.test.ts | 324 ------------------- src/domain/workflow.ts | 84 ----- src/providers/antigravity/constants.ts | 2 +- src/services/agent.test.ts | 6 +- src/services/agent.ts | 280 ++++++---------- src/services/prompts.ts | 112 +++---- src/services/session.ts | 33 +- src/services/user-dirs.ts | 4 +- src/services/vfs.test.ts | 158 +++++++++ src/services/vfs.ts | 228 +++++++++++++ src/services/web.ts | 9 +- src/tools/edit-file.ts | 2 +- src/tools/index.ts | 56 ++-- src/tools/review.ts | 108 +++++++ src/tools/vfs.ts | 66 ++++ src/tui/components/timeline/TimelineItem.tsx | 2 +- src/tui/hooks/useAgent.ts | 5 +- 23 files changed, 887 insertions(+), 810 deletions(-) create mode 100644 src/domain/vfs.ts delete mode 100644 src/domain/workflow.test.ts delete mode 100644 src/domain/workflow.ts create mode 100644 src/services/vfs.test.ts create mode 100644 src/services/vfs.ts create mode 100644 src/tools/review.ts create mode 100644 src/tools/vfs.ts diff --git a/bun.lock b/bun.lock index 09b8b70..6c5430d 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "ai": "^6.0.39", "cli-highlight": "^2.1.11", "dedent": "^1.7.1", + "diff": "^8.0.3", "effect": "^3.19.14", "htmlrewriter": "^0.0.13", "ignore": "^7.0.5", @@ -35,6 +36,7 @@ "@total-typescript/tsconfig": "^1.0.4", "@tsconfig/bun": "^1.0.10", "@types/bun": "1.3.6", + "@types/diff": "^8.0.0", "@types/json-schema": "^7.0.15", "@types/react": "^19.2.8", "react-devtools-core": "^7.0.1", @@ -181,49 +183,49 @@ "@opentui-ui/dialog": ["@opentui-ui/dialog@0.1.2", "", { "peerDependencies": { "@opentui/core": "^0.1.69", "@opentui/react": "^0.1.69", "@opentui/solid": "^0.1.69" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-EZ4FG5u5sxU75+6pcsJsLzsD5JqO05So/1ceZUKUu7nxZ9IF7gcZEi+MU4HnYC9cb2Q7w6hM9y3/iW+jE1C53w=="], - "@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="], + "@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="], - "@opentui/react": ["@opentui/react@0.1.73", "", { "dependencies": { "@opentui/core": "0.1.73", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-DdtsJqlhLOjGRpD0vOv7n1ec8nT+HT/e4ozHYR1EgGZtA/kddfLZ/kpCQEY+RJr6jMJ7UdsBxgAo5x4k2C7slA=="], + "@opentui/react": ["@opentui/react@0.1.74", "", { "dependencies": { "@opentui/core": "0.1.74", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-2wiTVtBcbjNuWJjVDaSNdfVM9x9Cs7U+wCRPMmzVrYYCbWGjYQcA0Ump+XSKJpN+swzZRDBYHIw9xBlgUUnoLw=="], - "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], + "@parcel/watcher": ["@parcel/watcher@2.5.4", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.4", "@parcel/watcher-darwin-arm64": "2.5.4", "@parcel/watcher-darwin-x64": "2.5.4", "@parcel/watcher-freebsd-x64": "2.5.4", "@parcel/watcher-linux-arm-glibc": "2.5.4", "@parcel/watcher-linux-arm-musl": "2.5.4", "@parcel/watcher-linux-arm64-glibc": "2.5.4", "@parcel/watcher-linux-arm64-musl": "2.5.4", "@parcel/watcher-linux-x64-glibc": "2.5.4", "@parcel/watcher-linux-x64-musl": "2.5.4", "@parcel/watcher-win32-arm64": "2.5.4", "@parcel/watcher-win32-ia32": "2.5.4", "@parcel/watcher-win32-x64": "2.5.4" } }, "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ=="], - "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.4", "", { "os": "android", "cpu": "arm64" }, "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g=="], - "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw=="], - "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg=="], - "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q=="], - "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.4", "", { "os": "linux", "cpu": "arm" }, "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw=="], - "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ=="], - "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw=="], - "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ=="], - "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA=="], - "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg=="], - "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ=="], - "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg=="], - "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw=="], "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], @@ -239,9 +241,11 @@ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], @@ -249,7 +253,7 @@ "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], - "@webgpu/types": ["@webgpu/types@0.1.68", "", {}, "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -271,8 +275,6 @@ "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], @@ -307,9 +309,9 @@ "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "effect": ["effect@3.19.14", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-3vwdq0zlvQOxXzXNKRIPKTqZNMyGCdaFUBfMPqpsyzZDre67kgC1EEHDV4EoQTovJ4w5fmJW756f86kkuz7WFA=="], @@ -333,8 +335,6 @@ "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -361,8 +361,6 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], @@ -375,8 +373,6 @@ "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], @@ -413,7 +409,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], @@ -439,7 +435,7 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -473,8 +469,6 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], @@ -513,41 +507,17 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="], - "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="], - "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="], - "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opentui/core/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], "@opentui/react/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], @@ -555,8 +525,6 @@ "marked-terminal/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], diff --git a/package.json b/package.json index c03b522..69e90ad 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "ai": "^6.0.39", "cli-highlight": "^2.1.11", "dedent": "^1.7.1", + "diff": "^8.0.3", "effect": "^3.19.14", "htmlrewriter": "^0.0.13", "ignore": "^7.0.5", @@ -46,6 +47,7 @@ "@total-typescript/tsconfig": "^1.0.4", "@tsconfig/bun": "^1.0.10", "@types/bun": "1.3.6", + "@types/diff": "^8.0.0", "@types/json-schema": "^7.0.15", "@types/react": "^19.2.8", "react-devtools-core": "^7.0.1" diff --git a/src/commands/utils/workflow-errors.ts b/src/commands/utils/workflow-errors.ts index 6f407bd..819fada 100644 --- a/src/commands/utils/workflow-errors.ts +++ b/src/commands/utils/workflow-errors.ts @@ -1,14 +1,12 @@ -import { log, note } from "@clack/prompts"; +import { log } from "@clack/prompts"; import { Effect, Match, Option } from "effect"; import { AgentLoopError, AIGenerationError, MaxIterationsReached, UserCancel } from "@/domain/errors"; -import type { WorkflowState } from "@/domain/workflow"; -import { renderMarkdownSnippet } from "@/text/utils"; /** * Represents the current state of a workflow, used for error recovery. */ export interface WorkflowSnapshot { - readonly workflowState: WorkflowState; + readonly cycle: number; readonly totalCost: number; } @@ -48,15 +46,14 @@ export const displayError = (error: unknown): Effect.Effect => /** * Displays the last draft if available. + * + * Since the agent now stages files in VFS instead of producing a single text draft, + * we cannot easily "save the draft" from a snapshot. This function remains for API compatibility + * but always returns None. */ -export const displayLastDraft = (snapshot: WorkflowSnapshot): Effect.Effect> => - Effect.gen(function* () { - const lastDraft = Option.getOrUndefined(snapshot.workflowState.latestDraft); - if (lastDraft && lastDraft.trim().length > 0) { - yield* Effect.sync(() => note(renderMarkdownSnippet(lastDraft), "Last Draft Before Error")); - return Option.some(lastDraft); - } - yield* Effect.sync(() => log.info("No draft was available to save.")); +export const displayLastDraft = (_snapshot: WorkflowSnapshot): Effect.Effect> => + Effect.sync(() => { + log.info("Draft recovery is not supported in the new VFS architecture."); return Option.none(); }); @@ -66,8 +63,8 @@ export const displayLastDraft = (snapshot: WorkflowSnapshot): Effect.Effect => Effect.sync(() => { log.success(`Draft saved to: ${savedPath}`); - if (snapshot.workflowState.iterationCount > 0) { - log.info(`Completed ${snapshot.workflowState.iterationCount} iteration(s)`); + if (snapshot.cycle > 0) { + log.info(`Completed ${snapshot.cycle} iteration(s)`); } if (snapshot.totalCost > 0) { log.info(`Total cost: $${snapshot.totalCost.toFixed(6)}`); @@ -101,3 +98,10 @@ export const rethrow = (error: UserCancel): ErrorHandlingResult => ({ _tag: "Rethrow", error, }); + +export class WorkflowErrorHandled { + readonly savedPath?: string; + constructor(props: { savedPath?: string }) { + this.savedPath = props.savedPath; + } +} diff --git a/src/commands/write.ts b/src/commands/write.ts index a8eb152..d51fe55 100644 --- a/src/commands/write.ts +++ b/src/commands/write.ts @@ -2,7 +2,7 @@ import { cancel, intro, isCancel, log, note, outro, select, spinner, text } from import { Args, Command, Options } from "@effect/cli"; import { FileSystem, Path } from "@effect/platform"; import { BunContext } from "@effect/platform-bun"; -import { Effect, Fiber, Option, Stream } from "effect"; +import { Chunk, Effect, Fiber, Option, Stream } from "effect"; import { displayError, displayLastDraft, @@ -12,6 +12,7 @@ import { } from "@/commands/utils/workflow-errors"; import { UserCancel, WorkflowErrorHandled } from "@/domain/errors"; import { Messages } from "@/domain/messages"; +import type { FilePatch } from "@/domain/vfs"; import type { AgentEvent, RunResult, UserAction } from "@/services/agent"; import { Agent, reasoningOptions } from "@/services/agent"; import { Config } from "@/services/config"; @@ -43,31 +44,48 @@ const runPrompt = (promptFn: () => Promise) => ), ); +const formatDiffs = (diffs: ReadonlyArray): string => { + if (diffs.length === 0) return "No changes."; + return diffs + .map((patch) => { + const status = patch.isNew ? " (New)" : patch.isDeleted ? " (Deleted)" : ""; + const hunks = Chunk.toArray(patch.hunks) + .map((h) => h.content) + .join("\n"); + return `=== ${patch.path}${status} ===\n${hunks}`; + }) + .join("\n\n"); +}; + /** * Handle user feedback when AI review approves the draft. * Returns the user's decision to approve or request changes. */ -const getUserFeedback = (draft: string, cycle: number): Effect.Effect => +const getUserFeedback = ( + diffs: ReadonlyArray, + cycle: number, +): Effect.Effect => Effect.gen(function* () { - yield* Effect.sync(() => note(renderMarkdownSnippet(draft), `Draft (Cycle ${cycle}) - Preview`)); + const diffText = formatDiffs(diffs); + yield* Effect.sync(() => note(renderMarkdownSnippet(diffText), `Changes (Cycle ${cycle})`)); const action = yield* runPrompt(() => select({ - message: "AI review approved this draft. What would you like to do?", + message: "AI review approved these changes. What would you like to do?", options: [ { value: "approve", label: "Approve and finalize" }, { value: "reject", label: "Request changes" }, - { value: "view", label: "View full draft" }, + { value: "view", label: "View full diffs" }, ], }), ); if (action === "view") { - yield* Effect.sync(() => note(renderMarkdown(draft), "Full Draft")); + yield* Effect.sync(() => note(renderMarkdownSnippet(diffText), "Full Diffs")); // Re-prompt after viewing const finalAction = yield* runPrompt(() => select({ - message: "What would you like to do with this draft?", + message: "What would you like to do with these changes?", options: [ { value: "approve", label: "Approve and finalize" }, { value: "reject", label: "Request changes" }, @@ -79,7 +97,7 @@ const getUserFeedback = (draft: string, cycle: number): Effect.Effect text({ message: "What changes would you like?", - placeholder: "e.g., Make the tone more formal, add more examples...", + placeholder: "e.g., Fix the typo in the header, rename the variable...", }), ); return { type: "reject" as const, comment }; @@ -298,7 +316,7 @@ export const writeCommand = Command.make( case "UserActionRequired": { yield* Effect.sync(() => s.stop("Awaiting your review...")); - const userAction = yield* getUserFeedback(event.draft, event.cycle); + const userAction = yield* getUserFeedback(event.diffs, event.cycle); yield* agentSession.submitUserAction(userAction); diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 7389848..8092654 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -1,5 +1,10 @@ import { Data } from "effect"; +export class VFSError extends Data.TaggedError("VFSError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + export class ConfigReadError extends Data.TaggedError("ConfigReadError")<{ readonly cause: unknown; }> {} diff --git a/src/domain/vfs.ts b/src/domain/vfs.ts new file mode 100644 index 0000000..766ef80 --- /dev/null +++ b/src/domain/vfs.ts @@ -0,0 +1,43 @@ +import { type Chunk, Data, type Option } from "effect"; + +// Represents a single file change +export class VirtualFile extends Data.Class<{ + readonly path: string; + readonly content: string; + /** None if new file */ + readonly originalContent: Option.Option; + readonly timestamp: number; +}> {} + +export class DiffHunk extends Data.Class<{ + readonly oldStart: number; + readonly oldLines: number; + readonly newStart: number; + readonly newLines: number; + /** Unified diff format */ + readonly content: string; +}> {} + +/** A patch/diff representation */ +export class FilePatch extends Data.Class<{ + readonly path: string; + readonly hunks: Chunk.Chunk; + readonly isNew: boolean; + readonly isDeleted: boolean; +}> {} + +/** Reviewer comments attached to specific files/lines */ +export class ReviewComment extends Data.Class<{ + readonly id: string; + readonly path: string; + /** None = file-level comment */ + readonly line: Option.Option; + readonly content: string; + readonly timestamp: number; +}> {} + +export class VFSSummary extends Data.Class<{ + readonly fileCount: number; + readonly files: ReadonlyArray; + readonly commentCount: number; +}> {} diff --git a/src/domain/workflow.test.ts b/src/domain/workflow.test.ts deleted file mode 100644 index 3f05a68..0000000 --- a/src/domain/workflow.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Option } from "effect"; -import { DraftGenerated, ReviewCompleted, UserFeedback, WorkflowState } from "@/domain/workflow"; - -describe("WorkflowState", () => { - test("empty state has zero iterations", () => { - const state = WorkflowState.empty; - expect(state.iterationCount).toBe(0); - }); - - test("empty state has no latest draft", () => { - const state = WorkflowState.empty; - expect(Option.isNone(state.latestDraft)).toBe(true); - }); - - test("empty state has no latest feedback", () => { - const state = WorkflowState.empty; - expect(Option.isNone(state.latestFeedback)).toBe(true); - }); - - test("empty state is not approved", () => { - const state = WorkflowState.empty; - expect(state.isApproved).toBe(false); - }); - - test("adding a draft increments iteration count", () => { - const state = WorkflowState.empty.add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ); - - expect(state.iterationCount).toBe(1); - }); - - test("latestDraft returns the most recent draft content", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Needs improvement", - reasoning: "Not detailed enough", - timestamp: Date.now(), - }), - ) - .add( - new DraftGenerated({ - cycle: 2, - content: "Second draft", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestDraft, () => "")).toBe("Second draft"); - expect(state.iterationCount).toBe(2); - }); - - test("latestFeedback returns AI critique when not approved", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Needs more examples", - reasoning: "Analysis", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Needs more examples"); - }); - - test("latestFeedback returns user comment when user rejects", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "reject", - comment: "Make it more formal", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Make it more formal"); - }); - - test("latestFeedback returns default message when user rejects without comment", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "reject", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Please revise based on user request."); - }); - - test("isApproved is true when AI review approves", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Perfect", - timestamp: Date.now(), - }), - ); - - expect(state.isApproved).toBe(true); - }); - - test("isApproved is true when user approves", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "approve", - timestamp: Date.now(), - }), - ); - - expect(state.isApproved).toBe(true); - }); - - test("isApproved is false after user rejection", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "reject", - comment: "Needs work", - timestamp: Date.now(), - }), - ); - - expect(state.isApproved).toBe(false); - }); - - test("immutability - add returns a new state instance", () => { - const state1 = WorkflowState.empty; - const state2 = state1.add( - new DraftGenerated({ - cycle: 1, - content: "Draft", - timestamp: Date.now(), - }), - ); - - expect(state1).not.toBe(state2); - expect(state1.iterationCount).toBe(0); - expect(state2.iterationCount).toBe(1); - }); - - test("latestReview returns the most recent review", () => { - const review = new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Test critique", - reasoning: "Test reasoning", - timestamp: 12345, - }); - - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "Draft", - timestamp: Date.now(), - }), - ) - .add(review); - - const latestReview = Option.getOrNull(state.latestReview); - expect(latestReview).not.toBeNull(); - expect(latestReview?.critique).toBe("Test critique"); - expect(latestReview?.approved).toBe(false); - }); - - test("full workflow cycle - draft, reject, revise, approve", () => { - let state = WorkflowState.empty; - - // Cycle 1: Initial draft - state = state.add( - new DraftGenerated({ - cycle: 1, - content: "Initial draft content", - timestamp: Date.now(), - }), - ); - expect(state.iterationCount).toBe(1); - - // AI rejects - state = state.add( - new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Too short", - reasoning: "Analysis", - timestamp: Date.now(), - }), - ); - expect(state.isApproved).toBe(false); - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Too short"); - - // Cycle 2: Revised draft - state = state.add( - new DraftGenerated({ - cycle: 2, - content: "Expanded draft with more content", - timestamp: Date.now(), - }), - ); - expect(state.iterationCount).toBe(2); - expect(Option.getOrElse(state.latestDraft, () => "")).toBe("Expanded draft with more content"); - - // AI approves - state = state.add( - new ReviewCompleted({ - cycle: 2, - approved: true, - critique: "", - reasoning: "Good", - timestamp: Date.now(), - }), - ); - expect(state.isApproved).toBe(true); - - // User also approves - state = state.add( - new UserFeedback({ - action: "approve", - timestamp: Date.now(), - }), - ); - expect(state.isApproved).toBe(true); - }); -}); diff --git a/src/domain/workflow.ts b/src/domain/workflow.ts deleted file mode 100644 index 0aec634..0000000 --- a/src/domain/workflow.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Chunk, Data, Option, Schema } from "effect"; - -export class DraftGenerated extends Data.TaggedClass("DraftGenerated")<{ - readonly cycle: number; - readonly content: string; - readonly timestamp: number; -}> {} - -export class ReviewCompleted extends Data.TaggedClass("ReviewCompleted")<{ - readonly cycle: number; - readonly approved: boolean; - readonly critique: string; - readonly reasoning: string; - readonly timestamp: number; -}> {} - -export class UserFeedback extends Data.TaggedClass("UserFeedback")<{ - readonly action: "approve" | "reject"; - readonly comment?: string; - readonly timestamp: number; -}> {} - -export type WorkflowEvent = DraftGenerated | ReviewCompleted | UserFeedback; - -export class ReviewResult extends Schema.Class("ReviewResult")({ - approved: Schema.Boolean.annotations({ description: "True if the reviewed content meets the goal" }), - critique: Schema.String.annotations({ description: "Specific feedback if not approved, empty if approved" }), - reasoning: Schema.String.annotations({ description: "Reasoning behind the review result" }), -}) {} - -export class WorkflowState extends Data.Class<{ - readonly history: Chunk.Chunk; -}> { - static readonly empty = new WorkflowState({ history: Chunk.empty() }); - - get iterationCount(): number { - return Chunk.reduce(this.history, 0, (count, event) => (event._tag === "DraftGenerated" ? count + 1 : count)); - } - - get latestDraft(): Option.Option { - return Chunk.findLast(this.history, (e): e is DraftGenerated => e._tag === "DraftGenerated").pipe( - Option.map((e) => e.content), - ); - } - - get latestFeedback(): Option.Option { - return Chunk.findLast(this.history, (e): e is ReviewCompleted | UserFeedback => { - if (e._tag === "ReviewCompleted" && !e.approved) return true; - if (e._tag === "UserFeedback" && e.action === "reject") return true; - return false; - }).pipe( - Option.map((e) => { - if (e._tag === "ReviewCompleted") return e.critique; - if (e._tag === "UserFeedback") return e.comment ?? "Please revise based on user request."; - return ""; - }), - ); - } - - get latestReview(): Option.Option { - return Chunk.findLast(this.history, (e): e is ReviewCompleted => e._tag === "ReviewCompleted"); - } - - get isApproved(): boolean { - const lastEvent = Chunk.findLast( - this.history, - (e): e is ReviewCompleted | UserFeedback => e._tag === "ReviewCompleted" || e._tag === "UserFeedback", - ); - return Option.match(lastEvent, { - onNone: () => false, - onSome: (e) => { - if (e._tag === "ReviewCompleted") return e.approved; - if (e._tag === "UserFeedback") return e.action === "approve"; - return false; - }, - }); - } - - add(event: WorkflowEvent): WorkflowState { - return new WorkflowState({ - history: Chunk.append(this.history, event), - }); - } -} diff --git a/src/providers/antigravity/constants.ts b/src/providers/antigravity/constants.ts index edab5d6..47973c0 100644 --- a/src/providers/antigravity/constants.ts +++ b/src/providers/antigravity/constants.ts @@ -13,7 +13,7 @@ export const ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_ENDPOINT_DAILY = "https://daily-cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_ENDPOINT_SANDBOX = "https://daily-cloudcode-pa.sandbox.googleapis.com"; -export const ANTIGRAVITY_DEFAULT_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY; +export const ANTIGRAVITY_DEFAULT_ENDPOINT = ANTIGRAVITY_ENDPOINT_SANDBOX; export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ ANTIGRAVITY_ENDPOINT_SANDBOX, ANTIGRAVITY_ENDPOINT_DAILY, diff --git a/src/services/agent.test.ts b/src/services/agent.test.ts index 4843a89..d708892 100644 --- a/src/services/agent.test.ts +++ b/src/services/agent.test.ts @@ -145,7 +145,7 @@ describe("Agent Service", () => { const result = yield* runner.result.pipe(Effect.flip); expect(result._tag).toBe("MaxIterationsReached"); - expect((result as MaxIterationsReached).iterations).toBe(2); + expect((result as MaxIterationsReached).iterations).toBe(3); }); await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); @@ -222,7 +222,7 @@ describe("Agent Service", () => { const agent = yield* Agent; const runner = yield* agent.run({ prompt: "Do work" }); - const stateRef = yield* Ref.make(undefined); + const stateRef = yield* Ref.make<{ cycle: number; totalCost: number } | undefined>(undefined); yield* Stream.runCollect( runner.events.pipe( @@ -247,7 +247,7 @@ describe("Agent Service", () => { const capturedState = yield* Ref.get(stateRef); expect(capturedState).toBeTruthy(); - expect(capturedState?.workflowState.iterationCount).toBe(1); + expect(capturedState?.cycle).toBe(1); }); await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); diff --git a/src/services/agent.ts b/src/services/agent.ts index 36d703d..e830eba 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -1,19 +1,24 @@ -import { Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; +import type { PlatformError } from "@effect/platform/Error"; +import { Chunk, Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; import { DEFAULT_MODEL_REVIEWER, DEFAULT_MODEL_WRITER, MAX_STEP_COUNT } from "@/domain/constants"; import { AgentLoopError, AgentStreamError, + type FileReadError, + type FileWriteError, MaxIterationsReached, NoUserActionPending, UserCancel, + type VFSError, } from "@/domain/errors"; -import { DraftGenerated, ReviewCompleted, ReviewResult, UserFeedback, WorkflowState } from "@/domain/workflow"; +import type { FilePatch } from "@/domain/vfs"; import { Config } from "@/services/config"; import { LLM, type ToolCallRecord } from "@/services/llm"; import { Prompts } from "@/services/prompts"; import { Session } from "@/services/session"; +import { VFS } from "@/services/vfs"; import { Web } from "@/services/web"; -import { edit_tools, explore_tools } from "@/tools"; +import { makeReviewerTools, makeWriterTools } from "@/tools"; export const reasoningOptions = Schema.Literal("low", "medium", "high"); @@ -46,7 +51,7 @@ export type AgentEvent = } | { readonly _tag: "UserActionRequired"; - readonly draft: string; + readonly diffs: ReadonlyArray; readonly cycle: number; } | { @@ -83,7 +88,6 @@ export interface RunOptions { export interface RunResult { readonly finalContent: string; readonly iterations: number; - readonly state: WorkflowState; readonly totalCost: number; readonly sessionId: string; readonly sessionPath: string; @@ -95,12 +99,10 @@ export class Agent extends Effect.Service()("services/agent", { const config = yield* Config; const session = yield* Session; const llm = yield* LLM; + const vfs = yield* VFS; + const runtime = yield* Effect.runtime(); return { - /** - * Run the autonomous agent loop. - * Returns a stream of events and a result promise. - */ run: (options: RunOptions) => Effect.gen(function* () { yield* Effect.logInfo("Starting agent run").pipe( @@ -157,14 +159,14 @@ export class Agent extends Effect.Service()("services/agent", { const writerTask = yield* prompts.getWriterTask; const reviewerTask = yield* prompts.getReviewerTask; - const editorTask = yield* prompts.getEditorTask; const eventQueue = yield* Queue.unbounded(); const userActionDeferred = yield* Ref.make | null>(null); - const stateRef = yield* Ref.make(WorkflowState.empty); + const lastFeedbackRef = yield* Ref.make>(Option.none()); - const runtime = yield* Effect.runtime(); + const writer_tools = makeWriterTools(runtime); + const reviewer_tools = makeReviewerTools(runtime); const emitEvent = (event: AgentEvent) => Effect.all([Queue.offer(eventQueue, event), sessionHandle.addAgentEvent(event)], { @@ -188,103 +190,69 @@ export class Agent extends Effect.Service()("services/agent", { ); }; - const step = (): Effect.Effect< + const step = ( + currentCycle: number, + ): Effect.Effect< string, - AgentLoopError | MaxIterationsReached | UserCancel | AgentStreamError + | AgentLoopError + | MaxIterationsReached + | UserCancel + | AgentStreamError + | PlatformError + | FileReadError + | FileWriteError + | VFSError > => Effect.gen(function* () { - const state = yield* Ref.get(stateRef); - const cycle = state.iterationCount + 1; + const cycle = currentCycle + 1; + yield* sessionHandle.updateIterations(cycle); yield* Effect.logDebug(`Starting agent cycle ${cycle}`); - if (state.iterationCount >= maxIterations) { - const lastDraft = Option.getOrElse(state.latestDraft, () => ""); + if (cycle > maxIterations) { const totalCost = yield* sessionHandle .getTotalCost() .pipe(Effect.orElseSucceed(() => 0)); yield* emitEvent({ _tag: "IterationLimitReached", - iterations: state.iterationCount, - lastDraft, + iterations: cycle, + lastDraft: "", }); return yield* new MaxIterationsReached({ - iterations: state.iterationCount, - lastDraft, + iterations: cycle, + lastDraft: "", totalCost, }); } - const isRevision = Option.isSome(state.latestDraft); - const latestFeedback = state.latestFeedback; - - // Extract previously read files to provide context during revision - const getSourceContext = Effect.gen(function* () { - if (!isRevision) return undefined; - const toolCalls = yield* sessionHandle.getToolCalls(); - const readCalls = toolCalls.filter( - (tc) => - tc.name === "read_file" && - typeof tc.output === "string" && - typeof tc.input === "object" && - tc.input !== null && - "filePath" in tc.input, - ); - - // Deduplicate by filePath, keeping the latest read - const fileMap = new Map(); - for (const call of readCalls) { - const path = (call.input as { filePath: string }).filePath; - fileMap.set(path, call.output as string); - } - - if (fileMap.size === 0) return undefined; - - return Array.from(fileMap.entries()) - .map(([path, content]) => `File: ${path}\n\`\`\`\n${content}\n\`\`\``) - .join("\n\n"); - }); - - const sourceFiles = yield* getSourceContext; - - yield* Effect.logDebug("Starting drafting phase", { - isRevision, - hasSourceContext: !!sourceFiles, - }); + const isRevision = cycle > 1; yield* emitEvent({ _tag: "Progress", - message: isRevision ? "Revising draft..." : "Drafting initial content...", + message: isRevision ? "Revising changes..." : "Drafting changes...", cycle, }); - if (cycle === 1 && !isRevision) { - yield* emitEvent({ - _tag: "UserInput", - content: options.prompt, - cycle, - }); + if (!isRevision) { + yield* vfs.reset(); } + const lastFeedback = yield* Ref.get(lastFeedbackRef); + const lastComments = yield* vfs.getComments(); + const writerPrompt = writerTask.render({ goal: options.prompt, - context: - isRevision && Option.isSome(latestFeedback) - ? { - draft: Option.getOrElse(state.latestDraft, () => ""), - feedback: latestFeedback.value, - sourceFiles, - } - : undefined, + latestComments: lastComments, + latestFeedback: lastFeedback, }); - const { content: newContent, cost: draftCost } = yield* llm + const { content: _writerOutput, cost: draftCost } = yield* llm .streamText( { model: writerModel, system: writerTask.system, prompt: writerPrompt, - tools: isRevision ? {} : explore_tools, + tools: writer_tools, maxSteps: MAX_STEP_COUNT, }, (chunk) => { @@ -316,45 +284,53 @@ export class Agent extends Effect.Service()("services/agent", { yield* sessionHandle.addCost(draftCost); - yield* Ref.update(stateRef, (s) => - s.add( - new DraftGenerated({ - cycle, - content: newContent, - timestamp: Date.now(), - }), - ), - ); + const summary = yield* vfs.getSummary(); - yield* Effect.logDebug("Drafting complete", { length: newContent.length }); + yield* Effect.logDebug("Drafting complete", { files: summary.fileCount }); yield* emitEvent({ _tag: "DraftComplete", - content: newContent, + content: `Staged ${summary.fileCount} files.`, cycle, }); - yield* Effect.logDebug("Starting review phase", { cycle }); yield* emitEvent({ _tag: "Progress", - message: "Reviewing draft...", + message: "Reviewer inspecting changes...", cycle, }); + const diffs = yield* vfs.getDiffs(); const reviewPrompt = reviewerTask.render({ goal: options.prompt, - draft: newContent, - sourceFiles, + diffs, }); - const { result: reviewResult, cost: reviewCost } = yield* llm - .generateObject({ - model: reviewerModel, - system: reviewerTask.system, - prompt: reviewPrompt, - tools: explore_tools, - schema: ReviewResult, - }) + const { content: _reviewOutput, cost: reviewCost } = yield* llm + .streamText( + { + model: reviewerModel, + system: reviewerTask.system, + prompt: reviewPrompt, + tools: reviewer_tools, + maxSteps: MAX_STEP_COUNT, + }, + (chunk) => { + Runtime.runSync(runtime)( + Effect.all( + [ + Queue.offer(eventQueue, { + _tag: "StreamChunk", + content: chunk, + phase: "reviewing", + } as const), + ], + { discard: true }, + ), + ); + }, + saveToolCall, + ) .pipe( Effect.mapError( (error) => @@ -368,39 +344,30 @@ export class Agent extends Effect.Service()("services/agent", { yield* sessionHandle.addCost(reviewCost); - yield* Effect.logDebug("Review complete", { approved: reviewResult.approved }); - - yield* Ref.update(stateRef, (s) => - s.add( - new ReviewCompleted({ - cycle, - approved: reviewResult.approved, - critique: reviewResult.critique, - reasoning: reviewResult.reasoning, - timestamp: Date.now(), - }), - ), - ); + const decision = yield* vfs.getDecision(); + const comments = yield* vfs.getComments(); + const approved = Option.getOrElse(decision, () => "rejected") === "approved"; yield* emitEvent({ _tag: "ReviewComplete", - approved: reviewResult.approved, - critique: reviewResult.critique, + approved, + critique: `Reviewer left ${Chunk.size(comments)} comments.`, cycle, }); - if (!reviewResult.approved) { + if (!approved) { yield* emitEvent({ _tag: "Progress", message: "AI review rejected. Starting revision...", cycle, }); - return yield* step(); + yield* Ref.set(lastFeedbackRef, Option.some("Please address the review comments.")); + return yield* step(cycle); } yield* emitEvent({ _tag: "UserActionRequired", - draft: newContent, + diffs: Chunk.toArray(diffs), cycle, }); @@ -418,23 +385,14 @@ export class Agent extends Effect.Service()("services/agent", { cycle, }); - yield* Ref.update(stateRef, (s) => - s.add( - new UserFeedback({ - action: userAction.type, - comment: userAction.comment, - timestamp: Date.now(), - }), - ), - ); - if (userAction.type === "reject") { yield* emitEvent({ _tag: "Progress", message: "User requested changes. Starting revision...", cycle, }); - return yield* step(); + yield* Ref.set(lastFeedbackRef, Option.some(userAction.comment ?? "Please revise.")); + return yield* step(cycle); } yield* emitEvent({ @@ -443,53 +401,12 @@ export class Agent extends Effect.Service()("services/agent", { cycle, }); - const editPrompt = editorTask.render({ - goal: options.prompt, - approvedContent: newContent, - }); + const flushedFiles = yield* vfs.flush(); - const { content: _editOutput, cost: editCost } = yield* llm - .streamText( - { - model: writerModel, - system: editorTask.system, - prompt: editPrompt, - tools: edit_tools, - maxSteps: MAX_STEP_COUNT, - }, - (chunk) => { - Runtime.runSync(runtime)( - Effect.all( - [ - Queue.offer(eventQueue, { - _tag: "StreamChunk", - content: chunk, - phase: "editing", - } as const), - ], - { discard: true }, - ), - ); - }, - saveToolCall, - ) - .pipe( - Effect.mapError( - (error) => - new AgentLoopError({ - cause: error, - message: error.message, - phase: "editing", - }), - ), - ); - - yield* sessionHandle.addCost(editCost); - - return newContent; + return `Applied ${flushedFiles.length} file(s): ${flushedFiles.join(", ")}`; }); - const workflowFiber = yield* step().pipe( + const workflowFiber = yield* step(0).pipe( Effect.tap((content) => sessionHandle.updateStatus("completed", content)), Effect.tapError((error) => Effect.gen(function* () { @@ -527,21 +444,17 @@ export class Agent extends Effect.Service()("services/agent", { sessionPath: sessionHandle.path, result: Effect.gen(function* () { const content = yield* Fiber.join(workflowFiber); - const finalState = yield* Ref.get(stateRef); + const cycle = yield* sessionHandle.getIterations(); const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); return { finalContent: content, - iterations: finalState.iterationCount, - state: finalState, + iterations: cycle, totalCost, sessionId: sessionHandle.id, sessionPath: sessionHandle.path, } satisfies RunResult; }), - /** - * Submit user action to continue the workflow. - */ submitUserAction: (action: UserAction) => Effect.gen(function* () { const deferred = yield* Ref.get(userActionDeferred); @@ -560,9 +473,6 @@ export class Agent extends Effect.Service()("services/agent", { yield* Deferred.succeed(deferred, action); }), - /** - * Cancel the workflow and cleanup resources - */ cancel: () => Effect.gen(function* () { const deferred = yield* Ref.get(userActionDeferred); @@ -573,18 +483,14 @@ export class Agent extends Effect.Service()("services/agent", { yield* Queue.shutdown(eventQueue); }), - /** - * Get the current workflow state and cost. - * Useful for retrieving the last draft when an error occurs. - */ getCurrentState: () => Effect.gen(function* () { - const workflowState = yield* Ref.get(stateRef); + const cycle = yield* sessionHandle.getIterations(); const totalCost = yield* sessionHandle .getTotalCost() .pipe(Effect.orElseSucceed(() => 0)); return { - workflowState, + cycle, totalCost, }; }), @@ -592,5 +498,5 @@ export class Agent extends Effect.Service()("services/agent", { }), }; }), - dependencies: [Prompts.Default, Config.Default, Session.Default, LLM.Default, Web.Default], + dependencies: [Prompts.Default, Config.Default, Session.Default, LLM.Default, Web.Default, VFS.Default], }) {} diff --git a/src/services/prompts.ts b/src/services/prompts.ts index 3caaa8c..2d3d29a 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -1,36 +1,27 @@ import { FileSystem } from "@effect/platform"; import { BunFileSystem } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; +import { Chunk, Effect, Layer, Option } from "effect"; import { PromptReadError } from "@/domain/errors"; +import type { FilePatch, ReviewComment } from "@/domain/vfs"; import { promptPaths } from "@/prompts"; export type PromptType = keyof typeof promptPaths; export interface WriterTaskInput { readonly goal: string; - readonly context?: { - readonly draft: string; - readonly feedback: string; - readonly sourceFiles?: string; - }; + readonly latestComments: Chunk.Chunk; + readonly latestFeedback: Option.Option; } export interface ReviewerTaskInput { readonly goal: string; - readonly draft: string; - readonly sourceFiles?: string; -} - -export interface EditorTaskInput { - readonly goal: string; - readonly approvedContent: string; + readonly diffs: Chunk.Chunk; } export class Prompts extends Effect.Service()("services/prompts", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - // Load raw prompt templates const loadRaw = (promptType: PromptType) => Effect.gen(function* () { const promptPath = promptPaths[promptType]; @@ -47,95 +38,70 @@ export class Prompts extends Effect.Service()("services/prompts", { ); return { - /** - * Get raw prompt content by type - */ get: (promptType: PromptType) => loadRaw(promptType), - /** - * Get a function that renders writer task prompts. - * Handles both initial drafting and revision scenarios. - */ getWriterTask: Effect.gen(function* () { const systemPrompt = yield* loadRaw("writer"); return { system: systemPrompt, render: (input: WriterTaskInput): string => { - if (input.context) { - // Revision prompt - include current draft and feedback - const parts = [`Task: ${input.goal}`, "", "## Current Draft", input.context.draft]; - - if (input.context.sourceFiles) { - parts.push("", "## Source Files (from initial exploration)", input.context.sourceFiles); - } + const parts = [`Task: ${input.goal}`]; + const latestComments = input.latestComments; + if (Chunk.isNonEmpty(latestComments)) { parts.push( "", - "## Critique to Address", - input.context.feedback, - "", - "## Instructions", - "Revise the draft IN-PLACE to address each critique point.", - "Output the complete revised draft.", + "## Reviewer Feedback to Address", + Chunk.toArray(latestComments) + .map( + (c) => + `- ${c.path}${Option.isSome(c.line) ? `:${c.line.value}` : ""}: ${c.content}`, + ) + .join("\n"), ); + } - return parts.join("\n"); + const latestFeedback = input.latestFeedback; + if (Option.isSome(latestFeedback)) { + parts.push("", "## User Feedback", latestFeedback.value); } - // Initial draft prompt - return [ - `Task: ${input.goal}`, + parts.push( "", - "Please draft the initial content based on the project context.", - "Use tools to explore the project structure and gather relevant information first.", - ].join("\n"); + "Use tools to explore the project, then use write_file/edit_file to make changes.", + "Changes are staged and will be reviewed before applying.", + ); + + return parts.join("\n"); }, }; }), - /** - * Get a function that renders reviewer task prompts. - */ getReviewerTask: Effect.gen(function* () { const systemPrompt = yield* loadRaw("reviewer"); return { system: systemPrompt, render: (input: ReviewerTaskInput): string => { - const parts = ["## Original Goal", input.goal, "", "## Draft to Review", input.draft]; - - if (input.sourceFiles) { - parts.push("", "## Source Files (from initial exploration)", input.sourceFiles); - } - - parts.push("", "Evaluate this draft against the original goal."); - - return parts.join("\n"); - }, - }; - }), - - /** - * Get a function that renders editor task prompts. - */ - getEditorTask: Effect.gen(function* () { - const systemPrompt = yield* loadRaw("editor"); + const formatPatch = (patch: FilePatch): string => { + return Chunk.head(patch.hunks).pipe( + Option.map((h) => h.content), + Option.getOrElse(() => ""), + ); + }; - return { - system: systemPrompt, - render: (input: EditorTaskInput): string => { return [ "## Original Goal", input.goal, "", - "## Approved Content", - input.approvedContent, + "## Staged Changes (Diffs)", + Chunk.toArray(input.diffs) + .map((p) => `### ${p.path}\n\`\`\`diff\n${formatPatch(p)}\n\`\`\``) + .join("\n\n"), "", - "Please apply the approved content to the project files using the available tools.", - "You may need to create new files or edit existing ones.", - "You HAVE TO save the changes, if none of the files seem fitting, just save it in the CWD with a descriptive name.", - "When you have finished, provide a brief summary of the changes.", + "Review these changes. Use add_review_comment for specific feedback.", + "Call approve_changes if correct, or reject_changes with a critique.", ].join("\n"); }, }; @@ -152,10 +118,6 @@ export const TestPrompts = new Prompts({ render: (_: WriterTaskInput) => "prompt", system: "system", }), - getEditorTask: Effect.succeed({ - render: (_: EditorTaskInput) => "prompt", - system: "system", - }), getReviewerTask: Effect.succeed({ render: (_: ReviewerTaskInput) => "prompt", system: "system", diff --git a/src/services/session.ts b/src/services/session.ts index 2930609..c1db0bc 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -90,6 +90,7 @@ export interface SessionHandle { readonly getTotalCost: () => Effect.Effect; readonly getToolCalls: () => Effect.Effect>; readonly updateIterations: (iterations: number) => Effect.Effect; + readonly getIterations: () => Effect.Effect; readonly flush: () => Effect.Effect; readonly close: () => Effect.Effect; } @@ -250,6 +251,8 @@ export class Session extends Effect.Service()("services/session", { }), ), + getIterations: () => Ref.get(stateRef).pipe(Effect.map((d) => d.iterations)), + flush: () => flush, close: () => close, }; @@ -299,19 +302,23 @@ export class Session extends Effect.Service()("services/session", { export const TestSession = new Session({ create: () => - Effect.succeed({ - id: "test-session", - path: "/tmp/test-session.json", - addEntry: (_entry) => Effect.void, - addAgentEvent: (_event) => Effect.void, - addToolCall: (_name, _input, _output) => Effect.void, - updateStatus: (_status, _finalContent) => Effect.void, - addCost: (_cost) => Effect.void, - getTotalCost: () => Effect.succeed(0), - getToolCalls: () => Effect.succeed([]), - updateIterations: (_iterations) => Effect.void, - flush: () => Effect.void, - close: () => Effect.void, + Effect.gen(function* () { + const iterationsRef = yield* Ref.make(0); + return { + id: "test-session", + path: "/tmp/test-session.json", + addEntry: (_entry) => Effect.void, + addAgentEvent: (_event) => Effect.void, + addToolCall: (_name, _input, _output) => Effect.void, + updateStatus: (_status, _finalContent) => Effect.void, + addCost: (_cost) => Effect.void, + getTotalCost: () => Effect.succeed(0), + getToolCalls: () => Effect.succeed([]), + updateIterations: (i) => Ref.set(iterationsRef, i), + getIterations: () => Ref.get(iterationsRef), + flush: () => Effect.void, + close: () => Effect.void, + }; }), list: () => Effect.succeed([]), get: () => Effect.succeed(null), diff --git a/src/services/user-dirs.ts b/src/services/user-dirs.ts index a025526..db1ba29 100644 --- a/src/services/user-dirs.ts +++ b/src/services/user-dirs.ts @@ -24,7 +24,7 @@ const getConfigDir = Effect.gen(function* () { return path.join(homeDir, ".config", APP_NAME); } - return yield* Effect.fail(new UserDirError({ message: "Could not determine config directory" })); + return yield* new UserDirError({ message: "Could not determine config directory" }); }); const getDataDir = Effect.gen(function* () { @@ -45,7 +45,7 @@ const getDataDir = Effect.gen(function* () { return path.join(homeDir, ".local", "share", APP_NAME); } - return yield* Effect.fail(new UserDirError({ message: "Could not determine data directory" })); + return yield* new UserDirError({ message: "Could not determine data directory" }); }); export class UserDirs extends Effect.Service()("services/user-dirs", { diff --git a/src/services/vfs.test.ts b/src/services/vfs.test.ts new file mode 100644 index 0000000..3d36231 --- /dev/null +++ b/src/services/vfs.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { Chunk, Effect, Layer, Option } from "effect"; +import { ProjectFiles } from "@/services/project-files"; +import { VFS } from "@/services/vfs"; +import { TestProjectFilesLayer } from "@/test/mocks/project-files"; + +describe("VFS Service", () => { + const TestLayer = VFS.DefaultWithoutDependencies.pipe(Layer.provideMerge(TestProjectFilesLayer)); + + test("writeFile stages content without writing to disk", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* vfs.writeFile("test.txt", "staged content"); + + const stagedContent = yield* vfs.readFile("test.txt"); + expect(stagedContent).toBe("staged content"); + + const diskContent = yield* projectFiles + .readFile("test.txt") + .pipe(Effect.catchTag("FileReadError", () => Effect.succeed("NOT_FOUND"))); + expect(diskContent).toBe("NOT_FOUND"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("readFile returns staged content if present, else disk content", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("disk.txt", "disk content"); + + const content1 = yield* vfs.readFile("disk.txt"); + expect(content1).toBe("disk content"); + + yield* vfs.writeFile("disk.txt", "staged content"); + + const content2 = yield* vfs.readFile("disk.txt"); + expect(content2).toBe("staged content"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("editFile modifies content correctly", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("code.ts", "const x = 1;\nconst y = 2;"); + + yield* vfs.editFile("code.ts", "const x = 1;", "const x = 10;"); + + const content = yield* vfs.readFile("code.ts"); + expect(content).toBe("const x = 10;\nconst y = 2;"); + + const diskContent = yield* projectFiles.readFile("code.ts"); + expect(diskContent).toBe("const x = 1;\nconst y = 2;"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("getDiffs generates unified diffs", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("file.txt", "line1\nline2\nline3"); + yield* vfs.editFile("file.txt", "line2", "line2_modified"); + + const diffs = yield* vfs.getDiffs(); + expect(Chunk.size(diffs)).toBe(1); + + const patch = Chunk.head(diffs).pipe(Option.getOrThrow); + expect(patch.path).toBe("file.txt"); + + const hunk = Chunk.head(patch.hunks).pipe(Option.getOrThrow); + expect(hunk.content).toContain("-line2"); + expect(hunk.content).toContain("+line2_modified"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("flush applies changes to disk", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("test.txt", "original"); + yield* vfs.writeFile("test.txt", "new"); + yield* vfs.writeFile("new.txt", "created"); + + yield* vfs.flush(); + + const diskTest = yield* projectFiles.readFile("test.txt"); + const diskNew = yield* projectFiles.readFile("new.txt"); + + expect(diskTest).toBe("new"); + expect(diskNew).toBe("created"); + + const summary = yield* vfs.getSummary(); + expect(summary.fileCount).toBe(0); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("comments and decisions management", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + + yield* vfs.addComment("test.txt", 1, "Fix this"); + + const comments = yield* vfs.getComments(); + expect(Chunk.size(comments)).toBe(1); + + const comment = Chunk.head(comments).pipe(Option.getOrThrow); + expect(comment.content).toBe("Fix this"); + expect(Option.getOrNull(comment.line)).toBe(1); + + yield* vfs.approve(); + const decision1 = yield* vfs.getDecision(); + expect(Option.getOrNull(decision1)).toBe("approved"); + + yield* vfs.reject(); + const decision2 = yield* vfs.getDecision(); + expect(Option.getOrNull(decision2)).toBe("rejected"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("reset clears all state", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + + yield* vfs.writeFile("test.txt", "content"); + yield* vfs.addComment("test.txt", null, "comment"); + yield* vfs.approve(); + + yield* vfs.reset(); + + const summary = yield* vfs.getSummary(); + expect(summary.fileCount).toBe(0); + expect(summary.commentCount).toBe(0); + + const decision = yield* vfs.getDecision(); + expect(Option.isNone(decision)).toBe(true); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); +}); diff --git a/src/services/vfs.ts b/src/services/vfs.ts new file mode 100644 index 0000000..a8cf929 --- /dev/null +++ b/src/services/vfs.ts @@ -0,0 +1,228 @@ +import { createTwoFilesPatch } from "diff"; +import { Chunk, Data, Effect, HashMap, Layer, Option, Ref } from "effect"; +import { VFSError } from "@/domain/errors"; +import { DiffHunk, FilePatch, ReviewComment, VFSSummary, VirtualFile } from "@/domain/vfs"; +import { ProjectFiles } from "@/services/project-files"; +import { replace } from "@/tools/edit-file"; + +export class VFSState extends Data.Class<{ + readonly files: HashMap.HashMap; + readonly comments: Chunk.Chunk; + readonly decision: Option.Option<"approved" | "rejected">; +}> { + static readonly empty = new VFSState({ + files: HashMap.empty(), + comments: Chunk.empty(), + decision: Option.none(), + }); +} + +export class VFS extends Effect.Service()("services/vfs", { + effect: Effect.gen(function* () { + const stateRef = yield* Ref.make(VFSState.empty); + const projectFiles = yield* ProjectFiles; + + const readFile = (path: string) => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const staged = HashMap.get(state.files, path); + if (Option.isSome(staged)) { + return staged.value.content; + } + return yield* projectFiles + .readFile(path, { disableExcerpts: true }) + .pipe(Effect.catchTag("FileReadError", () => Effect.succeed(""))); + }); + + const generateUnifiedDiff = (path: string, oldContent: string, newContent: string) => + Effect.sync(() => { + const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent, "", ""); + + return new FilePatch({ + path, + hunks: Chunk.make( + new DiffHunk({ + oldStart: 0, + oldLines: 0, + newStart: 0, + newLines: 0, + content: patch, + }), + ), + isNew: !oldContent && !!newContent, + isDeleted: !!oldContent && !newContent, + }); + }); + + return { + writeFile: (path: string, content: string) => + Effect.gen(function* () { + const original = yield* projectFiles.readFile(path, { disableExcerpts: true }).pipe( + Effect.map(Option.some), + Effect.catchTag("FileReadError", () => Effect.succeed(Option.none())), + ); + + yield* Ref.update( + stateRef, + (state) => + new VFSState({ + ...state, + files: HashMap.set( + state.files, + path, + new VirtualFile({ + path, + content, + originalContent: original, + timestamp: Date.now(), + }), + ), + }), + ); + return `[VFS] Staged write to ${path}`; + }), + + editFile: (path: string, oldString: string, newString: string, replaceAll = false) => + Effect.gen(function* () { + const currentContent = yield* readFile(path); + const newContent = yield* replace(currentContent, oldString, newString, replaceAll).pipe( + Effect.mapError((e) => new VFSError({ message: e.message })), + ); + + const state = yield* Ref.get(stateRef); + let original: Option.Option = Option.none(); + + const existing = HashMap.get(state.files, path); + if (Option.isSome(existing)) { + original = existing.value.originalContent; + } else { + original = yield* projectFiles.readFile(path, { disableExcerpts: true }).pipe( + Effect.map(Option.some), + Effect.catchTag("FileReadError", () => Effect.succeed(Option.none())), + ); + } + + yield* Ref.update( + stateRef, + (state) => + new VFSState({ + ...state, + files: HashMap.set( + state.files, + path, + new VirtualFile({ + path, + content: newContent, + originalContent: original, + timestamp: Date.now(), + }), + ), + }), + ); + return `[VFS] Staged edit to ${path}`; + }), + + readFile, + + getDiffs: () => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const patches: FilePatch[] = []; + for (const [path, file] of HashMap.entries(state.files)) { + const patch = yield* generateUnifiedDiff( + path, + Option.getOrElse(file.originalContent, () => ""), + file.content, + ); + patches.push(patch); + } + return Chunk.fromIterable(patches); + }), + + getFileDiff: (path: string) => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const file = HashMap.get(state.files, path); + if (Option.isNone(file)) { + return yield* new VFSError({ message: `${path} not staged` }); + } + return yield* generateUnifiedDiff( + path, + Option.getOrElse(file.value.originalContent, () => ""), + file.value.content, + ); + }), + + addComment: (path: string, line: number | null, content: string) => + Ref.update( + stateRef, + (state) => + new VFSState({ + ...state, + comments: Chunk.append( + state.comments, + new ReviewComment({ + id: crypto.randomUUID(), + path, + line: line ? Option.some(line) : Option.none(), + content, + timestamp: Date.now(), + }), + ), + }), + ), + + getComments: () => Ref.get(stateRef).pipe(Effect.map((s) => s.comments)), + + approve: () => Ref.update(stateRef, (s) => new VFSState({ ...s, decision: Option.some("approved") })), + + reject: () => Ref.update(stateRef, (s) => new VFSState({ ...s, decision: Option.some("rejected") })), + + getDecision: () => Ref.get(stateRef).pipe(Effect.map((s) => s.decision)), + + flush: () => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const results: string[] = []; + for (const [path, file] of HashMap.entries(state.files)) { + yield* projectFiles.writeFile(path, file.content, true); + results.push(path); + } + yield* Ref.set(stateRef, VFSState.empty); + return results; + }), + + reset: () => Ref.set(stateRef, VFSState.empty), + + getSummary: () => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + return new VFSSummary({ + fileCount: HashMap.size(state.files), + files: Array.from(HashMap.keys(state.files)), + commentCount: Chunk.size(state.comments), + }); + }), + }; + }), + dependencies: [ProjectFiles.Default], + accessors: true, +}) {} + +export const TestVFS = new VFS({ + reset: () => Effect.void, + getSummary: () => Effect.succeed({ fileCount: 0, files: [], commentCount: 0 }), + getDiffs: () => Effect.succeed(Chunk.empty()), + getDecision: () => Effect.succeed(Option.none()), + getComments: () => Effect.succeed(Chunk.empty()), + flush: () => Effect.succeed([]), + writeFile: () => Effect.succeed(""), + readFile: () => Effect.succeed(""), + editFile: () => Effect.succeed(""), + getFileDiff: () => Effect.succeed({ path: "", hunks: Chunk.empty(), isDeleted: false, isNew: false }), + addComment: () => Effect.void, + approve: () => Effect.void, + reject: () => Effect.void, +}); + +export const TestVFSLayer = Layer.succeed(VFS, TestVFS); diff --git a/src/services/web.ts b/src/services/web.ts index d876bb9..ce31115 100644 --- a/src/services/web.ts +++ b/src/services/web.ts @@ -1,5 +1,5 @@ import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; -import { Effect, Match } from "effect"; +import { Effect, Layer, Match } from "effect"; import { WebFetchError, WebSearchError } from "@/domain/errors"; import { convertHTMLToMarkdown } from "@/text/converters/html-markdown-converter"; import { extractTextFromHTML } from "@/text/converters/html-text-extractor"; @@ -154,3 +154,10 @@ export class Web extends Effect.Service()("services/web", { }), dependencies: [FetchHttpClient.layer], }) {} + +export const TestWeb = new Web({ + search: () => Effect.succeed(""), + fetch: () => Effect.succeed(""), +}); + +export const TestWebLayer = Layer.succeed(Web, TestWeb); diff --git a/src/tools/edit-file.ts b/src/tools/edit-file.ts index 5f96c92..5eb20ba 100644 --- a/src/tools/edit-file.ts +++ b/src/tools/edit-file.ts @@ -15,7 +15,7 @@ import { WhitespaceNormalizedReplacer, } from "@/text/replacers"; -function replace( +export function replace( content: string, oldString: string, newString: string, diff --git a/src/tools/index.ts b/src/tools/index.ts index 3ec267d..95c00fd 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,39 +1,41 @@ import type { ToolSet } from "ai"; +import type { Runtime } from "effect"; +import type { VFS } from "@/services/vfs"; import { editFileTool } from "./edit-file"; import { listFilesTool } from "./list-files"; import { readFileTool } from "./read-file"; +import { + makeAddReviewCommentTool, + makeApproveChangesTool, + makeReadAllDiffsTool, + makeReadFileDiffTool, + makeRejectChangesTool, +} from "./review"; import { searchFilesTool } from "./search-files"; +import { makeVfsEditFileTool, makeVfsReadFileTool, makeVfsWriteFileTool } from "./vfs"; import { webFetchTool } from "./web-fetch"; import { webSearchTool } from "./web-search"; import { writeFileTool } from "./write-file"; export { editFileTool, listFilesTool, readFileTool, searchFilesTool, webFetchTool, webSearchTool, writeFileTool }; -export const tools = { - list_files: listFilesTool, - read_file: readFileTool, - search_files: searchFilesTool, - write_file: writeFileTool, - edit_file: editFileTool, - web_fetch: webFetchTool, - web_search: webSearchTool, -} satisfies ToolSet; +export const makeWriterTools = (runtime: Runtime.Runtime) => + ({ + list_files: listFilesTool, + search_files: searchFilesTool, + read_file: makeVfsReadFileTool(runtime), + write_file: makeVfsWriteFileTool(runtime), + edit_file: makeVfsEditFileTool(runtime), + web_fetch: webFetchTool, + web_search: webSearchTool, + }) satisfies ToolSet; -export const explore_tools = { - list_files: listFilesTool, - search_files: searchFilesTool, - read_file: readFileTool, - web_fetch: webFetchTool, - web_search: webSearchTool, -} satisfies ToolSet; - -export const edit_tools = { - list_files: listFilesTool, - search_files: searchFilesTool, - read_file: readFileTool, - write_file: writeFileTool, - edit_file: editFileTool, -} satisfies ToolSet; - -export type ExploreTools = typeof explore_tools; -export type EditTools = typeof edit_tools; +export const makeReviewerTools = (runtime: Runtime.Runtime) => + ({ + read_all_diffs: makeReadAllDiffsTool(runtime), + read_file_diff: makeReadFileDiffTool(runtime), + read_file: makeVfsReadFileTool(runtime), + add_review_comment: makeAddReviewCommentTool(runtime), + approve_changes: makeApproveChangesTool(runtime), + reject_changes: makeRejectChangesTool(runtime), + }) satisfies ToolSet; diff --git a/src/tools/review.ts b/src/tools/review.ts new file mode 100644 index 0000000..bb13b38 --- /dev/null +++ b/src/tools/review.ts @@ -0,0 +1,108 @@ +import { jsonSchema, tool } from "ai"; +import { Chunk, Effect, JSONSchema, Option, Runtime, Schema } from "effect"; +import type { DiffHunk, FilePatch } from "@/domain/vfs"; +import { VFS } from "@/services/vfs"; + +const formatPatch = (patch: FilePatch): string => { + return Chunk.head(patch.hunks).pipe( + Option.map((h: DiffHunk) => h.content), + Option.getOrElse(() => ""), + ); +}; + +export const makeReadAllDiffsTool = (runtime: Runtime.Runtime) => + tool({ + description: "Get unified diffs of all staged file changes.", + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async () => { + return Runtime.runPromise(runtime)( + VFS.getDiffs().pipe( + Effect.map((patches) => { + if (Chunk.isEmpty(patches)) { + return "No staged changes found."; + } + return Chunk.toArray(patches) + .map((p) => `=== ${p.path} ===\n${formatPatch(p)}`) + .join("\n\n"); + }), + ), + ); + }, + }); + +export const makeReadFileDiffTool = (runtime: Runtime.Runtime) => + tool({ + description: "Get the diff for a specific file.", + inputSchema: jsonSchema<{ filePath: string }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file" }), + }), + ), + ), + execute: async ({ filePath }) => { + return Runtime.runPromise(runtime)( + VFS.getFileDiff(filePath).pipe( + Effect.map(formatPatch), + Effect.catchAll((error) => Effect.succeed(`Error getting file diff: ${error}`)), + ), + ); + }, + }); + +export const makeAddReviewCommentTool = (runtime: Runtime.Runtime) => + tool({ + description: "Add a comment to a file or specific line in a diff.", + inputSchema: jsonSchema<{ filePath: string; line?: number; comment: string }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file" }), + line: Schema.optional(Schema.Number.annotations({ description: "The line number (optional)" })), + comment: Schema.String.annotations({ description: "The comment content" }), + }), + ), + ), + execute: async ({ filePath, line, comment }) => { + return Runtime.runPromise(runtime)( + VFS.addComment(filePath, line ?? null, comment).pipe( + Effect.map(() => `Comment added to ${filePath}${line ? `:${line}` : ""}`), + ), + ); + }, + }); + +export const makeApproveChangesTool = (runtime: Runtime.Runtime) => + tool({ + description: "Approve all staged changes. Call this when the diff looks correct.", + inputSchema: jsonSchema<{ summary?: string }>( + JSONSchema.make( + Schema.Struct({ + summary: Schema.optional( + Schema.String.annotations({ description: "Optional summary of approval" }), + ), + }), + ), + ), + execute: async ({ summary }) => { + return Runtime.runPromise(runtime)( + VFS.approve().pipe(Effect.map(() => `Changes approved.${summary ? ` Summary: ${summary}` : ""}`)), + ); + }, + }); + +export const makeRejectChangesTool = (runtime: Runtime.Runtime) => + tool({ + description: "Reject staged changes with a critique for the writer to address.", + inputSchema: jsonSchema<{ critique: string }>( + JSONSchema.make( + Schema.Struct({ + critique: Schema.String.annotations({ description: "The critique/reason for rejection" }), + }), + ), + ), + execute: async ({ critique }) => { + return Runtime.runPromise(runtime)( + VFS.reject().pipe(Effect.map(() => `Changes rejected. Critique: ${critique}`)), + ); + }, + }); diff --git a/src/tools/vfs.ts b/src/tools/vfs.ts new file mode 100644 index 0000000..a92c743 --- /dev/null +++ b/src/tools/vfs.ts @@ -0,0 +1,66 @@ +import { jsonSchema, tool } from "ai"; +import { Effect, JSONSchema, Runtime, Schema } from "effect"; +import { VFS } from "@/services/vfs"; + +export const makeVfsWriteFileTool = (runtime: Runtime.Runtime) => + tool({ + description: "Write content to a file (staged in VFS, not applied to disk until approved).", + inputSchema: jsonSchema<{ filePath: string; content: string }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file to write" }), + content: Schema.String.annotations({ description: "The content to write" }), + }), + ), + ), + execute: async ({ filePath, content }) => { + return Runtime.runPromise(runtime)( + VFS.writeFile(filePath, content).pipe( + Effect.map(() => `Successfully wrote to ${filePath}. Staged in VFS.`), + Effect.catchAll((error) => Effect.succeed(`Error writing to ${filePath}: ${error.message}`)), + ), + ); + }, + }); + +export const makeVfsEditFileTool = (runtime: Runtime.Runtime) => + tool({ + description: "Edit a file by replacing text (staged in VFS).", + inputSchema: jsonSchema<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file to edit" }), + oldString: Schema.String.annotations({ description: "The text to replace" }), + newString: Schema.String.annotations({ description: "The text to replace it with" }), + replaceAll: Schema.optional(Schema.Boolean.annotations({ description: "Replace all occurrences" })), + }), + ), + ), + execute: async ({ filePath, oldString, newString, replaceAll }) => { + return Runtime.runPromise(runtime)( + VFS.editFile(filePath, oldString, newString, replaceAll ?? false).pipe( + Effect.map(() => `Successfully edited ${filePath}. Staged in VFS.`), + Effect.catchAll((error) => Effect.succeed(`Error editing ${filePath}: ${error.message}`)), + ), + ); + }, + }); + +export const makeVfsReadFileTool = (runtime: Runtime.Runtime) => + tool({ + description: "Read a file (returns staged version if modified, otherwise disk version).", + inputSchema: jsonSchema<{ filePath: string }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file to read" }), + }), + ), + ), + execute: async ({ filePath }) => { + return Runtime.runPromise(runtime)( + VFS.readFile(filePath).pipe( + Effect.catchAll((error) => Effect.succeed(`Error reading ${filePath}: ${error.message}`)), + ), + ); + }, + }); diff --git a/src/tui/components/timeline/TimelineItem.tsx b/src/tui/components/timeline/TimelineItem.tsx index 7b84893..5445cfa 100644 --- a/src/tui/components/timeline/TimelineItem.tsx +++ b/src/tui/components/timeline/TimelineItem.tsx @@ -47,7 +47,7 @@ export const TimelineItem = ({ case "UserActionRequired": return ( ; readonly cycle: number; } @@ -141,7 +142,7 @@ function handleAgentEvent(state: AgentState, event: AgentEvent): AgentState { ...state, timeline, phase: "awaiting-user", - pendingAction: { draft: event.draft, cycle: event.cycle }, + pendingAction: { diffs: event.diffs, cycle: event.cycle }, }; case "IterationLimitReached": From fe12b17b9686990191a553639b255354e72bbd6e Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:26:56 +0100 Subject: [PATCH 02/40] chore: remove writer agent remnants --- src/prompts/editor.md | 5 ----- src/prompts/index.ts | 2 -- 2 files changed, 7 deletions(-) delete mode 100644 src/prompts/editor.md diff --git a/src/prompts/editor.md b/src/prompts/editor.md deleted file mode 100644 index 6454076..0000000 --- a/src/prompts/editor.md +++ /dev/null @@ -1,5 +0,0 @@ -You are an expert editor tasked with applying approved content to a codebase. -Your goal is to ensure the project files accurately reflect the approved draft. -You have access to tools for reading, writing, searching files, and web browsing for reference. -Work autonomously to create or update the necessary files. -Ensure your changes are precise and follow project conventions. diff --git a/src/prompts/index.ts b/src/prompts/index.ts index 2b3937c..5ce1063 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,9 +1,7 @@ -import editor from "./editor.md" with { type: "file" }; import reviewer from "./reviewer.md" with { type: "file" }; import writer from "./writer.md" with { type: "file" }; export const promptPaths = { - editor, reviewer, writer, }; From d35bf9fac6cbd9cf7c4ec1e158fae2854708e26a Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:27:14 +0100 Subject: [PATCH 03/40] fix: add missing tools --- src/tools/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tools/index.ts b/src/tools/index.ts index 95c00fd..f12e0d4 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -23,6 +23,8 @@ export const makeWriterTools = (runtime: Runtime.Runtime) => ({ list_files: listFilesTool, search_files: searchFilesTool, + read_all_diffs: makeReadAllDiffsTool(runtime), + read_file_diff: makeReadFileDiffTool(runtime), read_file: makeVfsReadFileTool(runtime), write_file: makeVfsWriteFileTool(runtime), edit_file: makeVfsEditFileTool(runtime), @@ -38,4 +40,6 @@ export const makeReviewerTools = (runtime: Runtime.Runtime) => add_review_comment: makeAddReviewCommentTool(runtime), approve_changes: makeApproveChangesTool(runtime), reject_changes: makeRejectChangesTool(runtime), + web_fetch: webFetchTool, + web_search: webSearchTool, }) satisfies ToolSet; From 6f16f0c0906b0011f884027b761ef36752c5a1a3 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:27:47 +0100 Subject: [PATCH 04/40] fix: add explicit overwrite flag --- src/services/vfs.test.ts | 4 ++-- src/services/vfs.ts | 8 +++++++- src/tools/vfs.ts | 11 ++++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/services/vfs.test.ts b/src/services/vfs.test.ts index 3d36231..ba15caf 100644 --- a/src/services/vfs.test.ts +++ b/src/services/vfs.test.ts @@ -36,7 +36,7 @@ describe("VFS Service", () => { const content1 = yield* vfs.readFile("disk.txt"); expect(content1).toBe("disk content"); - yield* vfs.writeFile("disk.txt", "staged content"); + yield* vfs.writeFile("disk.txt", "staged content", true); const content2 = yield* vfs.readFile("disk.txt"); expect(content2).toBe("staged content"); @@ -92,7 +92,7 @@ describe("VFS Service", () => { const projectFiles = yield* ProjectFiles; yield* projectFiles.writeFile("test.txt", "original"); - yield* vfs.writeFile("test.txt", "new"); + yield* vfs.writeFile("test.txt", "new", true); yield* vfs.writeFile("new.txt", "created"); yield* vfs.flush(); diff --git a/src/services/vfs.ts b/src/services/vfs.ts index a8cf929..2cd18ab 100644 --- a/src/services/vfs.ts +++ b/src/services/vfs.ts @@ -55,13 +55,19 @@ export class VFS extends Effect.Service()("services/vfs", { }); return { - writeFile: (path: string, content: string) => + writeFile: (path: string, content: string, overwrite = false) => Effect.gen(function* () { const original = yield* projectFiles.readFile(path, { disableExcerpts: true }).pipe( Effect.map(Option.some), Effect.catchTag("FileReadError", () => Effect.succeed(Option.none())), ); + if (!overwrite && Option.isSome(original)) { + return yield* new VFSError({ + message: `File ${path} already exists. Set overwrite to true if you intended to replace it.`, + }); + } + yield* Ref.update( stateRef, (state) => diff --git a/src/tools/vfs.ts b/src/tools/vfs.ts index a92c743..1ce7589 100644 --- a/src/tools/vfs.ts +++ b/src/tools/vfs.ts @@ -5,17 +5,22 @@ import { VFS } from "@/services/vfs"; export const makeVfsWriteFileTool = (runtime: Runtime.Runtime) => tool({ description: "Write content to a file (staged in VFS, not applied to disk until approved).", - inputSchema: jsonSchema<{ filePath: string; content: string }>( + inputSchema: jsonSchema<{ filePath: string; content: string; overwrite?: boolean }>( JSONSchema.make( Schema.Struct({ filePath: Schema.String.annotations({ description: "The path to the file to write" }), content: Schema.String.annotations({ description: "The content to write" }), + overwrite: Schema.optional( + Schema.Boolean.annotations({ + description: "Whether to overwrite the file if it already exists. Use with caution!", + }), + ), }), ), ), - execute: async ({ filePath, content }) => { + execute: async ({ filePath, content, overwrite = false }) => { return Runtime.runPromise(runtime)( - VFS.writeFile(filePath, content).pipe( + VFS.writeFile(filePath, content, overwrite).pipe( Effect.map(() => `Successfully wrote to ${filePath}. Staged in VFS.`), Effect.catchAll((error) => Effect.succeed(`Error writing to ${filePath}: ${error.message}`)), ), From aeeaeabcf10d09fb3d9ff6873152fbfcb260de3e Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:28:19 +0100 Subject: [PATCH 05/40] fix: improve agents prompts for new wokflow --- src/prompts/writer.md | 11 +++-------- src/services/prompts.ts | 1 + 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/prompts/writer.md b/src/prompts/writer.md index bff3a23..9f1e576 100644 --- a/src/prompts/writer.md +++ b/src/prompts/writer.md @@ -30,16 +30,11 @@ Follow these guidelines to ensure the work is of high quality: - Explore: First, explore the project files to understand the structure and context using tools. - Search: Search files to find specific content patterns, existing writing style, bibliography, and formatting. - Research: Use web search and web fetch tools when you need current information, citations, or to verify facts from external sources. - - Write: Draft the content based on the gathered context and research. + - Write: Draft the content based on the gathered context and research. Use tools to save the draft to a Virtual File System (VFS). 2. Revision (When a draft and critique are provided) - - Address the specific points raised in the "Critique to Address". - - Edit the text directly. Fix exactly what the critique identifies: typos, factual corrections, missing citations, stylistic issues. Trust the critique's facts. + - Address both the specific comments and general feedback. + - Edit the text directly using tools. Fix exactly what the critique identifies: typos, factual corrections, missing citations, stylistic issues. Trust the critique's facts. - Your output should be the complete revised draft with all corrections applied. -3. Output Requirements - - Your response should be formatted using Markdown. - - If you wish to edit multiple files, use code blocks with specified file paths and language tags (i.e. `markdown` or `latex`) so that an editor can easily identify and edit the files. - - You do not have tools to directly edit files. Do not worry about this, your work will be saved appropriately. - Do NOT include any responses that are not directly related to the task at hand. diff --git a/src/services/prompts.ts b/src/services/prompts.ts index 2d3d29a..e263230 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -102,6 +102,7 @@ export class Prompts extends Effect.Service()("services/prompts", { "", "Review these changes. Use add_review_comment for specific feedback.", "Call approve_changes if correct, or reject_changes with a critique.", + "As a last action, ALWAYS call either approve_changes or reject_changes.", ].join("\n"); }, }; From b6e3dba091a8e78d5fe156838118c817542a67a1 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:28:31 +0100 Subject: [PATCH 06/40] refactor: yield error directly --- src/services/project-files.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services/project-files.ts b/src/services/project-files.ts index a62dd5b..8d2251d 100644 --- a/src/services/project-files.ts +++ b/src/services/project-files.ts @@ -49,12 +49,10 @@ export class ProjectFiles extends Effect.Service()("services/Proje const lowerCwd = normCwd.toLowerCase(); if (!lowerResolved.startsWith(lowerCwd)) { - return yield* Effect.fail( - new FileReadError({ - cause: "Access denied", - message: `Access denied: ${resolved} is outside of ${cwd}`, - }), - ); + return yield* new FileReadError({ + cause: "Access denied", + message: `Access denied: ${resolved} is outside of ${cwd}`, + }); } return resolved; From b48c34e07eba7bb1a6bd95430fd07227af9e672a Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:45:56 +0100 Subject: [PATCH 07/40] chore: improve write file tools descriptions --- src/tools/vfs.ts | 7 ++++++- src/tools/write-file.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tools/vfs.ts b/src/tools/vfs.ts index 1ce7589..5e77c96 100644 --- a/src/tools/vfs.ts +++ b/src/tools/vfs.ts @@ -1,10 +1,15 @@ import { jsonSchema, tool } from "ai"; +import dedent from "dedent"; import { Effect, JSONSchema, Runtime, Schema } from "effect"; import { VFS } from "@/services/vfs"; export const makeVfsWriteFileTool = (runtime: Runtime.Runtime) => tool({ - description: "Write content to a file (staged in VFS, not applied to disk until approved).", + description: dedent` + Write content to a file (staged in VFS, not applied to disk until approved). + Use this tool if you want to create a new file or fully overwrite an existing one. + If you wish to edit an existing file, use the 'edit-file' tool instead. + `, inputSchema: jsonSchema<{ filePath: string; content: string; overwrite?: boolean }>( JSONSchema.make( Schema.Struct({ diff --git a/src/tools/write-file.ts b/src/tools/write-file.ts index 317ad1b..1bdcb93 100644 --- a/src/tools/write-file.ts +++ b/src/tools/write-file.ts @@ -1,4 +1,5 @@ import { jsonSchema, tool } from "ai"; +import dedent from "dedent"; import { Effect, JSONSchema, Schema } from "effect"; import { ProjectFiles } from "@/services/project-files"; @@ -11,7 +12,7 @@ const writeFileSchema = Schema.Struct({ }), overwrite: Schema.optional( Schema.Boolean.annotations({ - description: "Whether to overwrite the file if it already exists", + description: "Whether to overwrite the file if it already exists. Use with caution!", }), ), }); @@ -19,7 +20,11 @@ const writeFileSchema = Schema.Struct({ type WriteFileInput = Schema.Schema.Type; export const writeFileTool = tool({ - description: "Write or overwrite content to a file. USE WITH CAUTION.", + description: dedent` + Write content to a file. + Use this tool if you want to create a new file or fully overwrite an existing one. + If you wish to edit an existing file, use the 'edit-file' tool instead. + `, inputSchema: jsonSchema(JSONSchema.make(writeFileSchema)), execute: async ({ filePath, content, overwrite = false }) => { const execute = ProjectFiles.writeFile(filePath, content, overwrite).pipe( From 6cfabe74f68862b4a69ffa8e8dd34bf33b6ce533 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:14:06 +0100 Subject: [PATCH 08/40] feat: preserve writer context across revisions to avoid re-reading files --- src/services/agent.ts | 60 +++++++++++++++++++++++++++++++++++++++-- src/services/prompts.ts | 33 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/services/agent.ts b/src/services/agent.ts index e830eba..613e66d 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -14,7 +14,7 @@ import { import type { FilePatch } from "@/domain/vfs"; import { Config } from "@/services/config"; import { LLM, type ToolCallRecord } from "@/services/llm"; -import { Prompts } from "@/services/prompts"; +import { Prompts, type WriterContext } from "@/services/prompts"; import { Session } from "@/services/session"; import { VFS } from "@/services/vfs"; import { Web } from "@/services/web"; @@ -164,6 +164,10 @@ export class Agent extends Effect.Service()("services/agent", { const userActionDeferred = yield* Ref.make | null>(null); const lastFeedbackRef = yield* Ref.make>(Option.none()); + const writerContextRef = yield* Ref.make({ + filesRead: [], + filesModified: [], + }); const writer_tools = makeWriterTools(runtime); const reviewer_tools = makeReviewerTools(runtime); @@ -173,6 +177,37 @@ export class Agent extends Effect.Service()("services/agent", { discard: true, }); + const extractContextFromToolCall = ( + name: string, + input: unknown, + output: unknown, + ): Partial => { + if (name === "read_file" && typeof input === "object" && input !== null) { + const filePath = (input as { filePath?: string }).filePath; + if (filePath) { + const summary = + typeof output === "string" + ? output + .split("\n") + .find((l) => l.trim()) + ?.slice(0, 80) + : undefined; + return { filesRead: [{ path: filePath, summary }] }; + } + } + if ( + (name === "write_file" || name === "edit_file") && + typeof input === "object" && + input !== null + ) { + const filePath = (input as { filePath?: string }).filePath; + if (filePath) { + return { filesModified: [filePath] }; + } + } + return {}; + }; + const saveToolCall = (record: ToolCallRecord) => { Runtime.runPromise(runtime)( Effect.all( @@ -184,6 +219,25 @@ export class Agent extends Effect.Service()("services/agent", { input: record.input, output: record.output, } as const), + Effect.suspend(() => { + const partial = extractContextFromToolCall( + record.name, + record.input, + record.output, + ); + if (Object.keys(partial).length > 0) { + return Ref.update(writerContextRef, (ctx) => ({ + filesRead: [...ctx.filesRead, ...(partial.filesRead ?? [])], + filesModified: [ + ...new Set([ + ...ctx.filesModified, + ...(partial.filesModified ?? []), + ]), + ], + })); + } + return Effect.void; + }), ], { discard: true }, ), @@ -239,11 +293,13 @@ export class Agent extends Effect.Service()("services/agent", { const lastFeedback = yield* Ref.get(lastFeedbackRef); const lastComments = yield* vfs.getComments(); + const previousContext = yield* Ref.get(writerContextRef); const writerPrompt = writerTask.render({ goal: options.prompt, latestComments: lastComments, latestFeedback: lastFeedback, + previousContext: isRevision ? Option.some(previousContext) : Option.none(), }); const { content: _writerOutput, cost: draftCost } = yield* llm @@ -290,7 +346,7 @@ export class Agent extends Effect.Service()("services/agent", { yield* emitEvent({ _tag: "DraftComplete", - content: `Staged ${summary.fileCount} files.`, + content: _writerOutput, cycle, }); diff --git a/src/services/prompts.ts b/src/services/prompts.ts index e263230..342c8f1 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -7,10 +7,21 @@ import { promptPaths } from "@/prompts"; export type PromptType = keyof typeof promptPaths; +export interface FileContext { + readonly path: string; + readonly summary?: string; +} + +export interface WriterContext { + readonly filesRead: ReadonlyArray; + readonly filesModified: ReadonlyArray; +} + export interface WriterTaskInput { readonly goal: string; readonly latestComments: Chunk.Chunk; readonly latestFeedback: Option.Option; + readonly previousContext: Option.Option; } export interface ReviewerTaskInput { @@ -48,6 +59,28 @@ export class Prompts extends Effect.Service()("services/prompts", { render: (input: WriterTaskInput): string => { const parts = [`Task: ${input.goal}`]; + const previousContext = input.previousContext; + if (Option.isSome(previousContext)) { + const ctx = previousContext.value; + parts.push("", "## Context from Previous Iterations"); + + if (ctx.filesRead.length > 0) { + parts.push("### Files Already Read"); + parts.push( + ctx.filesRead + .map((f) => (f.summary ? `- ${f.path}: ${f.summary}` : `- ${f.path}`)) + .join("\n"), + ); + } + + if (ctx.filesModified.length > 0) { + parts.push("### Files You Modified (still staged)"); + parts.push(ctx.filesModified.map((f) => `- ${f}`).join("\n")); + } + + parts.push("", "Use this context to avoid re-reading files unnecessarily."); + } + const latestComments = input.latestComments; if (Chunk.isNonEmpty(latestComments)) { parts.push( From ed40986121fcb63b4fa1aeb2273d35b2ac0f231e Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:57:48 +0100 Subject: [PATCH 09/40] feat: sidebar with current status --- src/services/agent.ts | 50 ++++++++++++++++++++++------------ src/tui/app.tsx | 32 ++++++++++++---------- src/tui/components/Sidebar.tsx | 49 +++++++++++++++++++++++++++++++++ src/tui/hooks/useAgent.ts | 28 +++++++++++-------- 4 files changed, 116 insertions(+), 43 deletions(-) create mode 100644 src/tui/components/Sidebar.tsx diff --git a/src/services/agent.ts b/src/services/agent.ts index 613e66d..b48ca80 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -74,6 +74,11 @@ export type AgentEvent = readonly _tag: "IterationLimitReached"; readonly iterations: number; readonly lastDraft: string; + } + | { + readonly _tag: "StateUpdate"; + readonly files: ReadonlyArray; + readonly cost: number; }; export interface RunOptions { @@ -177,6 +182,17 @@ export class Agent extends Effect.Service()("services/agent", { discard: true, }); + const broadcastState = () => + Effect.gen(function* () { + const summary = yield* vfs.getSummary(); + const cost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + yield* emitEvent({ + _tag: "StateUpdate", + files: summary.files, + cost, + }); + }); + const extractContextFromToolCall = ( name: string, input: unknown, @@ -225,18 +241,20 @@ export class Agent extends Effect.Service()("services/agent", { record.input, record.output, ); - if (Object.keys(partial).length > 0) { - return Ref.update(writerContextRef, (ctx) => ({ - filesRead: [...ctx.filesRead, ...(partial.filesRead ?? [])], - filesModified: [ - ...new Set([ - ...ctx.filesModified, - ...(partial.filesModified ?? []), - ]), - ], - })); - } - return Effect.void; + const contextUpdate = + Object.keys(partial).length > 0 + ? Ref.update(writerContextRef, (ctx) => ({ + filesRead: [...ctx.filesRead, ...(partial.filesRead ?? [])], + filesModified: [ + ...new Set([ + ...ctx.filesModified, + ...(partial.filesModified ?? []), + ]), + ], + })) + : Effect.void; + + return Effect.all([contextUpdate, broadcastState()], { discard: true }); }), ], { discard: true }, @@ -339,6 +357,7 @@ export class Agent extends Effect.Service()("services/agent", { ); yield* sessionHandle.addCost(draftCost); + yield* broadcastState(); const summary = yield* vfs.getSummary(); @@ -399,6 +418,7 @@ export class Agent extends Effect.Service()("services/agent", { ); yield* sessionHandle.addCost(reviewCost); + yield* broadcastState(); const decision = yield* vfs.getDecision(); const comments = yield* vfs.getComments(); @@ -451,12 +471,6 @@ export class Agent extends Effect.Service()("services/agent", { return yield* step(cycle); } - yield* emitEvent({ - _tag: "Progress", - message: "Applying approved changes to project files...", - cycle, - }); - const flushedFiles = yield* vfs.flush(); return `Applied ${flushedFiles.length} file(s): ${flushedFiles.join(", ")}`; diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 6a53301..3617009 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -6,6 +6,7 @@ import { StrictMode, useState } from "react"; import { copyToClipboard } from "@/services/clipboard"; import { ErrorBoundary } from "@/tui/components/ErrorBoundary"; import { SettingsModal } from "@/tui/components/SettingsModal"; +import { Sidebar } from "@/tui/components/Sidebar"; import { StatusBar } from "@/tui/components/StatusBar"; import { AgentProvider, useAgentContext } from "@/tui/context/AgentContext"; import { ConfigProvider } from "@/tui/context/ConfigContext"; @@ -64,20 +65,23 @@ function AgentWorkflow() { state.phase !== "cancelled"; return ( - - - - - - + + + + + + + + + ); } diff --git a/src/tui/components/Sidebar.tsx b/src/tui/components/Sidebar.tsx new file mode 100644 index 0000000..ac581fa --- /dev/null +++ b/src/tui/components/Sidebar.tsx @@ -0,0 +1,49 @@ +import { useAgentContext } from "@/tui/context/AgentContext"; + +export const Sidebar = () => { + const { state } = useAgentContext(); + const { totalCost, files, phase } = state; + + return ( + + + STATUS + + {phase.toUpperCase()} + + + + + COST + ${totalCost.toFixed(4)} + + + {files.length > 0 && ( + + + EDITED FILES + + + {files.map((path) => ( + + • {path} + + ))} + + + )} + + ); +}; diff --git a/src/tui/hooks/useAgent.ts b/src/tui/hooks/useAgent.ts index 693b605..6641244 100644 --- a/src/tui/hooks/useAgent.ts +++ b/src/tui/hooks/useAgent.ts @@ -43,6 +43,7 @@ export interface AgentState { readonly lastDraft?: string; } | null; readonly totalCost: number; + readonly files: ReadonlyArray; readonly sessionId: string | null; } @@ -57,6 +58,7 @@ export const initialAgentState: AgentState = { result: null, error: null, totalCost: 0, + files: [], sessionId: null, }; @@ -101,7 +103,7 @@ function handleAgentEvent(state: AgentState, event: AgentEvent): AgentState { cycle: "cycle" in event ? event.cycle : state.cycle, }; - const timeline = [...state.timeline, entry]; + const timeline = event._tag === "StateUpdate" ? state.timeline : [...state.timeline, entry]; switch (event._tag) { case "Progress": @@ -111,6 +113,7 @@ function handleAgentEvent(state: AgentState, event: AgentEvent): AgentState { cycle: event.cycle, phase: inferPhaseFromProgress(event.message), streamBuffer: "", + streamPhase: null, }; case "StreamChunk": @@ -135,6 +138,8 @@ function handleAgentEvent(state: AgentState, event: AgentEvent): AgentState { ...state, timeline, phase: event.approved ? "awaiting-user" : "drafting", + streamBuffer: "", + streamPhase: null, }; case "UserActionRequired": @@ -143,6 +148,8 @@ function handleAgentEvent(state: AgentState, event: AgentEvent): AgentState { timeline, phase: "awaiting-user", pendingAction: { diffs: event.diffs, cycle: event.cycle }, + streamBuffer: "", + streamPhase: null, }; case "IterationLimitReached": @@ -155,6 +162,14 @@ function handleAgentEvent(state: AgentState, event: AgentEvent): AgentState { canRetry: false, lastDraft: event.lastDraft, }, + streamPhase: null, + }; + + case "StateUpdate": + return { + ...state, + files: event.files, + totalCost: event.cost, }; case "ToolCall": @@ -298,17 +313,8 @@ export function useAgent( console.error("Failed to submit action:", error); dispatch({ type: "ERROR", error: mapErrorToState(error) }); }); - - dispatch({ - type: "EVENT", - event: { - _tag: "Progress", - message: action.type === "approve" ? "Applying changes..." : "Revising...", - cycle: state.cycle, - }, - }); }, - [runtime, state.phase, state.cycle], + [runtime, state.phase], ); const cancel = useCallback(() => { From d71d2eca4832492d97f42928e2cee92e2e09dfd5 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:58:45 +0100 Subject: [PATCH 10/40] feat: improved visual and reliability of user feedback widget --- src/tui/components/FeedbackWidget.tsx | 79 ++++++++++++++++++++------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/src/tui/components/FeedbackWidget.tsx b/src/tui/components/FeedbackWidget.tsx index e1b8509..7946964 100644 --- a/src/tui/components/FeedbackWidget.tsx +++ b/src/tui/components/FeedbackWidget.tsx @@ -1,6 +1,7 @@ import { useKeyboard } from "@opentui/react"; import { useState } from "react"; import type { PendingUserAction } from "@/tui/hooks/useAgent"; +import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; interface FeedbackWidgetProps { pendingAction: PendingUserAction; @@ -11,7 +12,7 @@ interface FeedbackWidgetProps { export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: FeedbackWidgetProps) => { const [rejectMode, setRejectMode] = useState(false); - const [comment, setComment] = useState(""); + const buffer = useTextBuffer(""); useKeyboard((key) => { if (!focused) return; @@ -21,54 +22,92 @@ export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: else if (key.name === "n") setRejectMode(true); } else { if (key.name === "return") { - onReject(comment); + onReject(buffer.text); setRejectMode(false); - setComment(""); + buffer.clear(); } else if (key.name === "escape") { setRejectMode(false); - setComment(""); + buffer.clear(); } else if (key.name === "backspace") { - setComment((prev) => prev.slice(0, -1)); + buffer.deleteBack(); + } else if (key.name === "left") { + buffer.moveLeft(); + } else if (key.name === "right") { + buffer.moveRight(); } else if (key.name === "space") { - setComment((prev) => `${prev} `); - } else if (key.name?.length === 1) { - setComment((prev) => prev + key.name); + buffer.insert(" "); + } else if (key.name?.length === 1 && !key.ctrl && !key.meta) { + const char = key.sequence && key.sequence.length === 1 ? key.sequence : key.name; + if (char && char.length === 1) buffer.insert(char); } } }); + const renderInput = () => { + const text = buffer.text; + const cursor = buffer.cursor; + const before = text.slice(0, cursor); + const cursorChar = text[cursor] || " "; + const after = text.slice(cursor + 1); + + return ( + + {before} + {cursorChar} + {after} + + ); + }; + return ( - + {focused ? "▶ USER ACTION REQUIRED" : "USER ACTION REQUIRED (Press Tab to Focus)"} - Draft (Cycle {pendingAction.cycle}) is ready for review. + + Cycle {pendingAction.cycle}: The agent has proposed changes to {pendingAction.diffs.length} file(s). + {rejectMode ? ( - - Reason for changes: - - {comment}█ + + + Please describe required changes: + + + {renderInput()} - [Enter] Submit [Esc] Cancel + + [Enter] Submit [Esc] Cancel + ) : ( - - Approve? [y] Yes /{" "} - [n] No (Request Changes) - + + + [y] Approve & Apply + + + [n] Reject & Request Changes + + )} From 96b69a30b4563a04fae00453009e253191d4c8ee Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:59:52 +0100 Subject: [PATCH 11/40] feat: improve UI --- src/tui/components/timeline/DraftItem.tsx | 6 +++++- src/tui/components/timeline/ProgressItem.tsx | 15 ++++++++++++--- src/tui/components/timeline/ReviewItem.tsx | 14 +++++++------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/tui/components/timeline/DraftItem.tsx b/src/tui/components/timeline/DraftItem.tsx index 1af09bb..4ad733d 100644 --- a/src/tui/components/timeline/DraftItem.tsx +++ b/src/tui/components/timeline/DraftItem.tsx @@ -12,10 +12,14 @@ export const DraftItem = ({ event, cycle }: DraftItemProps) => { marginTop: 1, marginBottom: 1, flexDirection: "column", + borderStyle: "rounded", + borderColor: "blue", padding: 1, }} > - DRAFT (Cycle {cycle}) + + Writer (Cycle {cycle}) + {event.content} ); diff --git a/src/tui/components/timeline/ProgressItem.tsx b/src/tui/components/timeline/ProgressItem.tsx index 00e263f..168e1fb 100644 --- a/src/tui/components/timeline/ProgressItem.tsx +++ b/src/tui/components/timeline/ProgressItem.tsx @@ -7,10 +7,19 @@ interface ProgressItemProps { export const ProgressItem = ({ event, cycle }: ProgressItemProps) => { return ( - - - [Cycle {cycle}] {event.message} + + + Running Cycle {cycle}: + {event.message} ); }; diff --git a/src/tui/components/timeline/ReviewItem.tsx b/src/tui/components/timeline/ReviewItem.tsx index e980577..2210340 100644 --- a/src/tui/components/timeline/ReviewItem.tsx +++ b/src/tui/components/timeline/ReviewItem.tsx @@ -10,18 +10,18 @@ export const ReviewItem = ({ event }: ReviewItemProps) => { style={{ marginTop: 1, marginBottom: 1, + borderStyle: "rounded", borderColor: event.approved ? "green" : "yellow", flexDirection: "column", padding: 1, }} > - {event.approved ? "REVIEW PASSED" : "REVIEW REJECTED"} - {!event.approved && ( - - Critique: - {event.critique} - - )} + + + Reviewer {event.approved ? "(PASSED)" : "(REQUESTED CHANGES)"} + + + {event.critique} ); }; From 6aff64c0d56c9857f615d3aa467d8b86f799a29d Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:49:43 +0100 Subject: [PATCH 12/40] refactor: use global agent state for hook --- src/tui/hooks/useAgent.ts | 73 ++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/tui/hooks/useAgent.ts b/src/tui/hooks/useAgent.ts index 6641244..038fb8e 100644 --- a/src/tui/hooks/useAgent.ts +++ b/src/tui/hooks/useAgent.ts @@ -1,5 +1,5 @@ import { Effect, Fiber, type ManagedRuntime, Stream } from "effect"; -import { useCallback, useEffect, useReducer, useRef } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import type { FilePatch } from "@/domain/vfs"; import { Agent, type AgentEvent, type RunOptions, type RunResult, type UserAction } from "@/services/agent"; import type { Config } from "@/services/config"; @@ -70,6 +70,7 @@ export interface UseAgentOptions { export interface UseAgentReturn { readonly state: AgentState; readonly start: (options: RunOptions) => void; + readonly retry: () => void; readonly submitAction: (action: UserAction) => void; readonly cancel: () => void; readonly reset: () => void; @@ -228,35 +229,48 @@ function mapErrorToState(error: unknown): AgentState["error"] { }; } +// Global state container +let globalState: AgentState = { ...initialAgentState }; +const listeners = new Set<() => void>(); + +let globalSession: { + submitUserAction: (action: UserAction) => Effect.Effect; + cancel: () => Effect.Effect; + fiber: Fiber.RuntimeFiber | null; +} | null = null; + +let globalLastRunOptions: RunOptions | null = null; + +function dispatch(action: AgentAction) { + globalState = agentReducer(globalState, action); + for (const listener of listeners) { + listener(); + } +} + export function useAgent( runtime: ManagedRuntime.ManagedRuntime, options: UseAgentOptions = {}, ): UseAgentReturn { - const [state, dispatch] = useReducer(agentReducer, initialAgentState); - - const sessionRef = useRef<{ - submitUserAction: (action: UserAction) => Effect.Effect; - cancel: () => Effect.Effect; - fiber: Fiber.RuntimeFiber | null; - } | null>(null); - - useEffect(() => { - return () => { - if (sessionRef.current) { - void runtime.runPromise(sessionRef.current.cancel()); - } - }; - }, [runtime]); + const subscribe = useCallback((listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, []); + + const getSnapshot = useCallback(() => globalState, []); + + const state = useSyncExternalStore(subscribe, getSnapshot); const start = useCallback( (runOptions: RunOptions) => { + globalLastRunOptions = runOptions; const program = Effect.gen(function* () { const agent = yield* Agent; const session = yield* agent.run(runOptions); dispatch({ type: "START", sessionId: session.sessionId }); - sessionRef.current = { + globalSession = { submitUserAction: session.submitUserAction, cancel: session.cancel, fiber: null, @@ -267,8 +281,8 @@ export function useAgent( Effect.fork, ); - if (sessionRef.current) { - sessionRef.current.fiber = eventFiber; + if (globalSession) { + globalSession.fiber = eventFiber; } const result = yield* session.result; @@ -305,11 +319,20 @@ export function useAgent( [runtime, options.onComplete, options.onError], ); + const retry = useCallback(() => { + if (!state.error || !state.sessionId || !globalLastRunOptions) return; + + start({ + ...globalLastRunOptions, + sessionId: state.sessionId, + }); + }, [state.error, state.sessionId, start]); + const submitAction = useCallback( (action: UserAction) => { - if (!sessionRef.current || state.phase !== "awaiting-user") return; + if (!globalSession || state.phase !== "awaiting-user") return; - runtime.runPromise(sessionRef.current.submitUserAction(action)).catch((error) => { + runtime.runPromise(globalSession.submitUserAction(action)).catch((error) => { console.error("Failed to submit action:", error); dispatch({ type: "ERROR", error: mapErrorToState(error) }); }); @@ -318,10 +341,10 @@ export function useAgent( ); const cancel = useCallback(() => { - if (!sessionRef.current) return; + if (!globalSession) return; runtime - .runPromise(sessionRef.current.cancel()) + .runPromise(globalSession.cancel()) .then(() => dispatch({ type: "CANCEL" })) .catch((error) => { console.error("Failed to cancel:", error); @@ -330,13 +353,15 @@ export function useAgent( }, [runtime]); const reset = useCallback(() => { - sessionRef.current = null; + globalSession = null; + globalLastRunOptions = null; dispatch({ type: "RESET" }); }, []); return { state, start, + retry, submitAction, cancel, reset, From 9ddaf985d43923b8191667437079d1f5c58f436a Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:49:59 +0100 Subject: [PATCH 13/40] feat: manual session restart on error --- src/services/agent.ts | 187 ++++++++++++++++++++++--------- src/services/session.ts | 165 ++++++++++++++++++++++++++- src/tui/components/StatusBar.tsx | 9 +- 3 files changed, 305 insertions(+), 56 deletions(-) diff --git a/src/services/agent.ts b/src/services/agent.ts index b48ca80..11761ee 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -1,5 +1,3 @@ -import type { PlatformError } from "@effect/platform/Error"; -import { Chunk, Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; import { DEFAULT_MODEL_REVIEWER, DEFAULT_MODEL_WRITER, MAX_STEP_COUNT } from "@/domain/constants"; import { AgentLoopError, @@ -15,10 +13,12 @@ import type { FilePatch } from "@/domain/vfs"; import { Config } from "@/services/config"; import { LLM, type ToolCallRecord } from "@/services/llm"; import { Prompts, type WriterContext } from "@/services/prompts"; -import { Session } from "@/services/session"; +import { Session, type SessionHandle } from "@/services/session"; import { VFS } from "@/services/vfs"; import { Web } from "@/services/web"; import { makeReviewerTools, makeWriterTools } from "@/tools"; +import type { PlatformError } from "@effect/platform/Error"; +import { Chunk, Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; export const reasoningOptions = Schema.Literal("low", "medium", "high"); @@ -82,7 +82,8 @@ export type AgentEvent = }; export interface RunOptions { - readonly prompt: string; + readonly prompt?: string; + readonly sessionId?: string; readonly modelWriter?: string; readonly modelReviewer?: string; readonly reasoning?: boolean; @@ -116,6 +117,7 @@ export class Agent extends Effect.Service()("services/agent", { reviewer: options.modelReviewer, reasoning: options.reasoning, maxIterations: options.maxIterations, + sessionId: options.sessionId, }), ); @@ -141,57 +143,16 @@ export class Agent extends Effect.Service()("services/agent", { const writerModelName = options.modelWriter ?? DEFAULT_MODEL_WRITER; const reviewerModelName = options.modelReviewer ?? DEFAULT_MODEL_REVIEWER; - const sessionHandle = yield* session - .create({ - prompt: options.prompt, - modelWriter: writerModelName, - modelReviewer: reviewerModelName, - reasoning, - reasoningEffort, - maxIterations, - }) - .pipe( - Effect.mapError( - (error) => - new AgentStreamError({ - cause: error, - message: "message" in error ? error.message : "Failed to create session", - }), - ), - ); - yield* Effect.logDebug(`Session created: ${sessionHandle.id}`); + let sessionHandle: SessionHandle; + let initialPrompt = options.prompt ?? ""; + let startCycle = 0; - const writerTask = yield* prompts.getWriterTask; - const reviewerTask = yield* prompts.getReviewerTask; - - const eventQueue = yield* Queue.unbounded(); - const userActionDeferred = yield* Ref.make | null>(null); - - const lastFeedbackRef = yield* Ref.make>(Option.none()); - const writerContextRef = yield* Ref.make({ + let initialContext: WriterContext = { filesRead: [], filesModified: [], - }); - - const writer_tools = makeWriterTools(runtime); - const reviewer_tools = makeReviewerTools(runtime); - - const emitEvent = (event: AgentEvent) => - Effect.all([Queue.offer(eventQueue, event), sessionHandle.addAgentEvent(event)], { - discard: true, - }); - - const broadcastState = () => - Effect.gen(function* () { - const summary = yield* vfs.getSummary(); - const cost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); - yield* emitEvent({ - _tag: "StateUpdate", - files: summary.files, - cost, - }); - }); + }; + let initialFeedback: Option.Option = Option.none(); const extractContextFromToolCall = ( name: string, @@ -224,6 +185,126 @@ export class Agent extends Effect.Service()("services/agent", { return {}; }; + if (options.sessionId) { + sessionHandle = yield* session.resume(options.sessionId).pipe( + Effect.mapError( + (error) => + new AgentStreamError({ + cause: error, + message: "message" in error ? error.message : "Failed to resume session", + }), + ), + ); + const sessionData = yield* session.get(options.sessionId); + if (sessionData) { + startCycle = sessionData.iterations; + if (sessionData.status === "failed") { + startCycle = Math.max(0, startCycle - 1); + } + + const promptEntry = sessionData.entries.find((e) => e._tag === "UserInput") as + | { prompt: string } + | undefined; + if (promptEntry) { + initialPrompt = promptEntry.prompt; + } + + for (const entry of sessionData.entries) { + if (entry._tag === "ToolCall") { + const partial = extractContextFromToolCall(entry.name, entry.input, entry.output); + initialContext = { + filesRead: [...initialContext.filesRead, ...(partial.filesRead ?? [])], + filesModified: [ + ...initialContext.filesModified, + ...(partial.filesModified ?? []), + ], + }; + } + if ( + entry._tag === "AgentEvent" && + typeof entry.event === "object" && + entry.event !== null + ) { + const evt = entry.event as AgentEvent; + if (evt._tag === "ReviewComplete") { + if (!evt.approved) { + initialFeedback = Option.some(evt.critique); + } else { + initialFeedback = Option.none(); + } + } + if (evt._tag === "UserInput") { + if (evt.content.startsWith("Rejected:")) { + initialFeedback = Option.some(evt.content.replace("Rejected: ", "")); + } else if (evt.content === "Approved") { + initialFeedback = Option.none(); + } + } + } + } + initialContext = { + ...initialContext, + filesModified: [...new Set(initialContext.filesModified)], + }; + } + } else { + if (!options.prompt) { + return yield* new AgentStreamError({ + message: "Prompt is required for new sessions", + cause: new Error("Missing prompt"), + }); + } + initialPrompt = options.prompt; + sessionHandle = yield* session + .create({ + prompt: options.prompt, + modelWriter: writerModelName, + modelReviewer: reviewerModelName, + reasoning, + reasoningEffort, + maxIterations, + }) + .pipe( + Effect.mapError( + (error) => + new AgentStreamError({ + cause: error, + message: "message" in error ? error.message : "Failed to create session", + }), + ), + ); + } + + yield* Effect.logDebug(`Session ID: ${sessionHandle.id}, Cycle: ${startCycle}`); + + const writerTask = yield* prompts.getWriterTask; + const reviewerTask = yield* prompts.getReviewerTask; + + const eventQueue = yield* Queue.unbounded(); + const userActionDeferred = yield* Ref.make | null>(null); + + const lastFeedbackRef = yield* Ref.make>(initialFeedback); + const writerContextRef = yield* Ref.make(initialContext); + + const writer_tools = makeWriterTools(runtime); + const reviewer_tools = makeReviewerTools(runtime); + + const emitEvent = (event: AgentEvent) => + Effect.all([Queue.offer(eventQueue, event), sessionHandle.addAgentEvent(event)], { + discard: true, + }); + + const broadcastState = () => + Effect.gen(function* () { + const summary = yield* vfs.getSummary(); + const cost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + yield* emitEvent({ + _tag: "StateUpdate", + files: summary.files, + cost, + }); + }); + const saveToolCall = (record: ToolCallRecord) => { Runtime.runPromise(runtime)( Effect.all( @@ -314,7 +395,7 @@ export class Agent extends Effect.Service()("services/agent", { const previousContext = yield* Ref.get(writerContextRef); const writerPrompt = writerTask.render({ - goal: options.prompt, + goal: initialPrompt, latestComments: lastComments, latestFeedback: lastFeedback, previousContext: isRevision ? Option.some(previousContext) : Option.none(), @@ -377,7 +458,7 @@ export class Agent extends Effect.Service()("services/agent", { const diffs = yield* vfs.getDiffs(); const reviewPrompt = reviewerTask.render({ - goal: options.prompt, + goal: initialPrompt, diffs, }); diff --git a/src/services/session.ts b/src/services/session.ts index c1db0bc..1fc2923 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -260,6 +260,146 @@ export class Session extends Effect.Service()("services/session", { return handle; }), + resume: (id: string): Effect.Effect => + Effect.gen(function* () { + const sessionPath = yield* userDirs.getPath("data", DIR_NAME.SESSIONS, `${id}.json`); + const json = yield* fs + .readFileString(sessionPath) + .pipe( + Effect.catchAll((error) => Effect.fail(new Error(`Failed to read session file: ${error}`))), + ); + const initialData = yield* Effect.try(() => JSON.parse(json)).pipe( + Effect.flatMap(Schema.decodeUnknown(SessionData)), + Effect.catchAll((error) => Effect.fail(new Error(`Failed to parse session data: ${error}`))), + ); + + const dataWithRunningStatus = new SessionData({ + ...initialData, + status: "running", + }); + + const stateRef = yield* Ref.make(dataWithRunningStatus); + const dirtyRef = yield* Ref.make(false); + + const doSave = Effect.gen(function* () { + const isDirty = yield* Ref.getAndSet(dirtyRef, false); + if (!isDirty) return; + + const data = yield* Ref.get(stateRef); + yield* fs + .writeFileString(sessionPath, JSON.stringify(data, null, 2)) + .pipe(Effect.catchAll((error) => Effect.logWarning(`Session save failed: ${error}`))); + }).pipe(Effect.uninterruptible); + + const backgroundSaver = Effect.forever( + Effect.sleep(`${SAVE_INTERVAL_MS} millis`).pipe(Effect.zipRight(doSave)), + ); + + const saverFiber = yield* Effect.forkDaemon(backgroundSaver); + + const markDirty = Ref.set(dirtyRef, true); + + const updateState = (f: (data: SessionData) => SessionData): Effect.Effect => + Ref.update(stateRef, f).pipe(Effect.zipRight(markDirty)); + + const flush = Effect.gen(function* () { + yield* Ref.set(dirtyRef, true); + yield* doSave; + }); + + const close = Effect.gen(function* () { + yield* Fiber.interrupt(saverFiber); + yield* flush; + }); + + const handle: SessionHandle = { + id, + path: sessionPath, + + addEntry: (entry) => + updateState((data) => { + const timestamp = Date.now(); + const fullEntry = { ...entry, timestamp } as SessionEntry; + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, fullEntry], + }); + }), + + addAgentEvent: (event) => + updateState((data) => { + const timestamp = Date.now(); + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, { _tag: "AgentEvent", event, timestamp }], + }); + }), + + addToolCall: (name, input, output) => + updateState((data) => { + const timestamp = Date.now(); + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, { _tag: "ToolCall", name, input, output, timestamp }], + }); + }), + + updateStatus: (status, finalContent) => + updateState((data) => { + const now = Date.now(); + return new SessionData({ + ...data, + status, + updatedAt: now, + completedAt: status !== "running" ? now : data.completedAt, + finalContent: finalContent ?? data.finalContent, + }); + }), + + addCost: (cost) => + updateState( + (data) => + new SessionData({ + ...data, + totalCost: data.totalCost + cost, + updatedAt: Date.now(), + }), + ), + + getTotalCost: () => Ref.get(stateRef).pipe(Effect.map((d) => d.totalCost)), + + getToolCalls: () => + Ref.get(stateRef).pipe( + Effect.map((d) => + d.entries + .filter((e) => e._tag === "ToolCall") + .map((e) => ({ name: e.name, input: e.input, output: e.output })), + ), + ), + + updateIterations: (iterations) => + updateState( + (data) => + new SessionData({ + ...data, + iterations, + updatedAt: Date.now(), + }), + ), + + getIterations: () => Ref.get(stateRef).pipe(Effect.map((d) => d.iterations)), + + flush: () => flush, + close: () => close, + }; + + yield* Effect.logDebug(`Resumed session: ${id}`); + return handle; + }), + list: () => Effect.gen(function* () { const files = yield* fs.readDirectory(sessionsDir).pipe( @@ -272,7 +412,8 @@ export class Session extends Effect.Service()("services/session", { for (const file of files) { const filePath = yield* userDirs.getPath("data", DIR_NAME.SESSIONS, file); const content = yield* fs.readFileString(filePath).pipe( - Effect.flatMap((json) => Effect.try(() => JSON.parse(json) as SessionData)), + Effect.flatMap((json) => Effect.try(() => JSON.parse(json))), + Effect.flatMap(Schema.decodeUnknown(SessionData)), Effect.catchAll(() => Effect.succeed(null)), ); if (content) { @@ -287,7 +428,8 @@ export class Session extends Effect.Service()("services/session", { Effect.gen(function* () { const sessionPath = yield* userDirs.getPath("data", DIR_NAME.SESSIONS, `${id}.json`); const content = yield* fs.readFileString(sessionPath).pipe( - Effect.flatMap((json) => Effect.try(() => JSON.parse(json) as SessionData)), + Effect.flatMap((json) => Effect.try(() => JSON.parse(json))), + Effect.flatMap(Schema.decodeUnknown(SessionData)), Effect.catchAll(() => Effect.succeed(null)), ); return content; @@ -323,6 +465,25 @@ export const TestSession = new Session({ list: () => Effect.succeed([]), get: () => Effect.succeed(null), getSessionsDir: () => Effect.succeed("/tmp/sessions"), + resume: () => + Effect.gen(function* () { + const iterationsRef = yield* Ref.make(0); + return { + id: "test-session", + path: "/tmp/test-session.json", + addEntry: (_entry) => Effect.void, + addAgentEvent: (_event) => Effect.void, + addToolCall: (_name, _input, _output) => Effect.void, + updateStatus: (_status, _finalContent) => Effect.void, + addCost: (_cost) => Effect.void, + getTotalCost: () => Effect.succeed(0), + getToolCalls: () => Effect.succeed([]), + updateIterations: (i) => Ref.set(iterationsRef, i), + getIterations: () => Ref.get(iterationsRef), + flush: () => Effect.void, + close: () => Effect.void, + }; + }), }); export const TestSessionLayer = Layer.succeed(Session, TestSession); diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index c34884e..96ba514 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -1,4 +1,5 @@ import { useKeyboard } from "@opentui/react"; +import { useAgentContext } from "@/tui/context/AgentContext"; import { useConfigContext } from "@/tui/context/ConfigContext"; import { useRenderer } from "@/tui/context/RendererContext"; @@ -10,10 +11,16 @@ export interface StatusBarProps { export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { const { config } = useConfigContext(); const renderer = useRenderer(); + const { state, retry } = useAgentContext(); useKeyboard((key) => { if (disabled) return; + if (state.error && key.name === "r") { + retry(); + return; + } + if (key.name === "escape") { // Reset window title before destroying renderer renderer.setTerminalTitle(""); @@ -35,7 +42,7 @@ export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { minHeight: 3, }} > - Esc: Exit | F2: Settings + Esc: Exit | F2: Settings{state.error ? " | R: Retry" : ""} W: {writer} | R: {reviewer} From af708f3d22781d1df23b5e0c638f70c9a5c0c89e Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:59:44 +0100 Subject: [PATCH 14/40] fix: reject binary content and truncate long responses from web fetch --- src/domain/constants.ts | 3 ++ src/services/web.ts | 64 ++++++++++++++++++++++++++++++++++++----- src/tools/web-fetch.ts | 2 ++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/domain/constants.ts b/src/domain/constants.ts index 51450b0..a7c10be 100644 --- a/src/domain/constants.ts +++ b/src/domain/constants.ts @@ -21,3 +21,6 @@ export const MAX_LIST_FILE_SIZE_KB = 1024 * 1024; export const EXCERPT_SIZE_KB = 40 * 1024; export const STREAM_WINDOW_SIZE = 80; + +export const MAX_WEB_FETCH_BYTES = 5 * 1024 * 1024; +export const MAX_WEB_FETCH_CHARS = 100_000; diff --git a/src/services/web.ts b/src/services/web.ts index ce31115..11b5b59 100644 --- a/src/services/web.ts +++ b/src/services/web.ts @@ -1,8 +1,9 @@ -import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; -import { Effect, Layer, Match } from "effect"; +import { MAX_WEB_FETCH_BYTES, MAX_WEB_FETCH_CHARS } from "@/domain/constants"; import { WebFetchError, WebSearchError } from "@/domain/errors"; import { convertHTMLToMarkdown } from "@/text/converters/html-markdown-converter"; import { extractTextFromHTML } from "@/text/converters/html-text-extractor"; +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; +import { Effect, Layer, Match } from "effect"; const API_CONFIG = { ENDPOINT: "https://mcp.exa.ai/mcp", @@ -13,6 +14,42 @@ const API_CONFIG = { TIMEOUT: 25000, } as const; +const BINARY_CONTENT_TYPES = [ + "application/pdf", + "application/octet-stream", + "application/zip", + "application/gzip", + "application/x-tar", + "application/x-rar-compressed", + "application/x-7z-compressed", + "application/msword", + "application/vnd.ms-", + "application/vnd.openxmlformats-", + "image/", + "audio/", + "video/", +] as const; + +const isLikelyBinary = (contentType: string | undefined): boolean => { + if (!contentType) return false; + const normalized = contentType.toLowerCase().split(";").at(0)?.trim(); + return BINARY_CONTENT_TYPES.some((pattern) => normalized?.startsWith(pattern)); +}; + +const truncateOutput = (text: string, maxChars = MAX_WEB_FETCH_CHARS): string => { + if (text.length <= maxChars) return text; + + const excerptSize = Math.floor(maxChars / 2) - 100; + const beginning = text.slice(0, excerptSize); + const end = text.slice(-excerptSize); + + return [ + beginning, + `\n\n[... truncated: showing ${excerptSize.toLocaleString()} of ${text.length.toLocaleString()} chars ...]\n\n`, + end, + ].join(""); +}; + export class Web extends Effect.Service()("services/web", { effect: Effect.gen(function* () { const client = yield* HttpClient.HttpClient; @@ -130,9 +167,19 @@ export class Web extends Effect.Service()("services/web", { ), ); + const contentType = response.headers?.["content-type"]; + + if (isLikelyBinary(contentType)) { + return yield* new WebFetchError({ + message: `Cannot fetch binary content (${contentType ?? "unknown"}).`, + }); + } + const contentLength = response.headers?.["content-length"]; - if (contentLength && parseInt(contentLength, 10) > 5 * 1024 * 1024) { - return yield* new WebFetchError({ message: "Response too large (>5MB)" }); + if (contentLength && parseInt(contentLength, 10) > MAX_WEB_FETCH_BYTES) { + return yield* new WebFetchError({ + message: `Response too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB). Maximum is ${MAX_WEB_FETCH_BYTES / 1024 / 1024}MB.`, + }); } const responseText = yield* response.text.pipe( @@ -141,13 +188,16 @@ export class Web extends Effect.Service()("services/web", { ), ); + let result: string; if (format === "html") { - return responseText; + result = responseText; } else if (format === "text") { - return yield* extractTextFromHTML(responseText).pipe(Effect.tapError(Effect.logWarning)); + result = yield* extractTextFromHTML(responseText).pipe(Effect.tapError(Effect.logWarning)); } else { - return yield* convertHTMLToMarkdown(responseText).pipe(Effect.tapError(Effect.logWarning)); + result = yield* convertHTMLToMarkdown(responseText).pipe(Effect.tapError(Effect.logWarning)); } + + return truncateOutput(result); }); return { search, fetch }; diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts index ef8bb22..29fccd3 100644 --- a/src/tools/web-fetch.ts +++ b/src/tools/web-fetch.ts @@ -21,6 +21,8 @@ export const webFetchTool = tool({ description: dedent` Fetches content from a specified URL and converts it to the requested format. Use this tool when you need to retrieve and analyze web content. + + Note: Cannot fetch binary files (PDFs, images, archives). Large responses are truncated to ~100KB. `, inputSchema: jsonSchema(JSONSchema.make(webFetchSchema)), execute: async ({ url, format, timeout }) => { From a28b7b9e488a4c1ba3e7880540528bd17a4e1091 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:11:32 +0100 Subject: [PATCH 15/40] feat: add support for reading PDF files --- bun.lock | 3 ++ package.json | 3 +- src/services/web.ts | 105 ++++++++++++++++++++++++++++++----------- src/tools/web-fetch.ts | 3 +- 4 files changed, 83 insertions(+), 31 deletions(-) diff --git a/bun.lock b/bun.lock index 6c5430d..3548b80 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "marked-terminal": "^7.3.0", "react": "^19.2.3", "turndown": "^7.2.2", + "unpdf": "^1.4.0", }, "devDependencies": { "@biomejs/biome": "2.3.11", @@ -481,6 +482,8 @@ "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + "unpdf": ["unpdf@1.4.0", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], diff --git a/package.json b/package.json index 69e90ad..e7245d1 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "marked": "^16.4.2", "marked-terminal": "^7.3.0", "react": "^19.2.3", - "turndown": "^7.2.2" + "turndown": "^7.2.2", + "unpdf": "^1.4.0" }, "devDependencies": { "@biomejs/biome": "2.3.11", diff --git a/src/services/web.ts b/src/services/web.ts index 11b5b59..d3880bc 100644 --- a/src/services/web.ts +++ b/src/services/web.ts @@ -1,9 +1,10 @@ +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; +import { Effect, Layer, Match } from "effect"; +import { extractText } from "unpdf"; import { MAX_WEB_FETCH_BYTES, MAX_WEB_FETCH_CHARS } from "@/domain/constants"; import { WebFetchError, WebSearchError } from "@/domain/errors"; import { convertHTMLToMarkdown } from "@/text/converters/html-markdown-converter"; import { extractTextFromHTML } from "@/text/converters/html-text-extractor"; -import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; -import { Effect, Layer, Match } from "effect"; const API_CONFIG = { ENDPOINT: "https://mcp.exa.ai/mcp", @@ -15,7 +16,6 @@ const API_CONFIG = { } as const; const BINARY_CONTENT_TYPES = [ - "application/pdf", "application/octet-stream", "application/zip", "application/gzip", @@ -30,10 +30,23 @@ const BINARY_CONTENT_TYPES = [ "video/", ] as const; -const isLikelyBinary = (contentType: string | undefined): boolean => { - if (!contentType) return false; - const normalized = contentType.toLowerCase().split(";").at(0)?.trim(); - return BINARY_CONTENT_TYPES.some((pattern) => normalized?.startsWith(pattern)); +type ContentType = "pdf" | "binary" | "text"; + +const detectContentType = (contentType: string | undefined, url: string): ContentType => { + const normalizedType = contentType?.toLowerCase().split(";").at(0)?.trim() ?? ""; + const lowerUrl = url.toLowerCase(); + + return Match.value({ type: normalizedType, url: lowerUrl }).pipe( + Match.when( + ({ type, url }) => type.includes("application/pdf") || url.endsWith(".pdf"), + () => "pdf" as const, + ), + Match.when( + ({ type }) => BINARY_CONTENT_TYPES.some((pattern) => type.startsWith(pattern)), + () => "binary" as const, + ), + Match.orElse(() => "text" as const), + ); }; const truncateOutput = (text: string, maxChars = MAX_WEB_FETCH_CHARS): string => { @@ -167,14 +180,6 @@ export class Web extends Effect.Service()("services/web", { ), ); - const contentType = response.headers?.["content-type"]; - - if (isLikelyBinary(contentType)) { - return yield* new WebFetchError({ - message: `Cannot fetch binary content (${contentType ?? "unknown"}).`, - }); - } - const contentLength = response.headers?.["content-length"]; if (contentLength && parseInt(contentLength, 10) > MAX_WEB_FETCH_BYTES) { return yield* new WebFetchError({ @@ -182,22 +187,66 @@ export class Web extends Effect.Service()("services/web", { }); } - const responseText = yield* response.text.pipe( - Effect.mapError( - (error) => new WebFetchError({ message: `Failed to read response: ${error}`, cause: error }), + const contentType = detectContentType(response.headers?.["content-type"], url); + + return yield* Match.value(contentType).pipe( + Match.when("pdf", () => + Effect.gen(function* () { + const buffer = yield* response.arrayBuffer.pipe( + Effect.mapError( + (error) => + new WebFetchError({ + message: `Failed to read PDF response: ${error}`, + cause: error, + }), + ), + ); + const result = yield* Effect.tryPromise({ + try: () => extractText(new Uint8Array(buffer)), + catch: (error) => + new WebFetchError({ message: `Failed to parse PDF: ${error}`, cause: error }), + }); + const text = Array.isArray(result.text) ? result.text.join("\n") : result.text; + return truncateOutput(text); + }), ), - ); + Match.when( + "binary", + () => + new WebFetchError({ + message: `Cannot fetch binary content (${response.headers?.["content-type"] ?? "unknown"}).`, + }), + ), + Match.when("text", () => + Effect.gen(function* () { + const responseText = yield* response.text.pipe( + Effect.mapError( + (error) => + new WebFetchError({ + message: `Failed to read response: ${error}`, + cause: error, + }), + ), + ); - let result: string; - if (format === "html") { - result = responseText; - } else if (format === "text") { - result = yield* extractTextFromHTML(responseText).pipe(Effect.tapError(Effect.logWarning)); - } else { - result = yield* convertHTMLToMarkdown(responseText).pipe(Effect.tapError(Effect.logWarning)); - } + let result: string; + if (format === "html") { + result = responseText; + } else if (format === "text") { + result = yield* extractTextFromHTML(responseText).pipe( + Effect.tapError(Effect.logWarning), + ); + } else { + result = yield* convertHTMLToMarkdown(responseText).pipe( + Effect.tapError(Effect.logWarning), + ); + } - return truncateOutput(result); + return truncateOutput(result); + }), + ), + Match.exhaustive, + ); }); return { search, fetch }; diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts index 29fccd3..7e504b0 100644 --- a/src/tools/web-fetch.ts +++ b/src/tools/web-fetch.ts @@ -21,8 +21,7 @@ export const webFetchTool = tool({ description: dedent` Fetches content from a specified URL and converts it to the requested format. Use this tool when you need to retrieve and analyze web content. - - Note: Cannot fetch binary files (PDFs, images, archives). Large responses are truncated to ~100KB. + Supports HTML, plain text, and PDF files. Large responses are truncated. `, inputSchema: jsonSchema(JSONSchema.make(webFetchSchema)), execute: async ({ url, format, timeout }) => { From c1edb2f7e6442c09e82acbcb5fdef93d43ae89c6 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:17:16 +0100 Subject: [PATCH 16/40] fix: launch tui correctly with root level command if arguments provided --- src/index.ts | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9362cda..18fa014 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ import { Command } from "@effect/cli"; import { BunRuntime } from "@effect/platform-bun"; import { Effect } from "effect"; import { antigravityCommand } from "@/commands/antigravity"; -import { authCommand } from "@/commands/antigravity/auth"; import { configCommand } from "@/commands/config"; import { writeCommand } from "@/commands/write"; import { TUIStartupError } from "@/domain/errors"; @@ -11,9 +10,14 @@ import { UniversalLayer } from "@/runtime"; import { startTUI } from "@/tui/app"; import { version } from "../package.json"; -const command = Command.make("jot").pipe( +const command = Command.make("jot", {}, () => + Effect.tryPromise({ + try: () => startTUI(), + catch: (error) => new TUIStartupError({ cause: error, message: `Failed to start TUI: ${error}` }), + }), +).pipe( + Command.withSubcommands([writeCommand, configCommand, antigravityCommand]), Command.withDescription("AI Research Assistant CLI"), - Command.withSubcommands([configCommand, writeCommand, authCommand, antigravityCommand]), ); const cli = Command.run(command, { @@ -21,19 +25,6 @@ const cli = Command.run(command, { version: version, }); -const program = Effect.gen(function* () { - const args = process.argv; - - if (args.length <= 2) { - // Launch TUI when no arguments provided - yield* Effect.tryPromise({ - try: () => startTUI(), - catch: (error) => new TUIStartupError({ cause: error, message: `Failed to start TUI: ${error}` }), - }); - } else { - // Run normal CLI with arguments - yield* cli(args); - } -}).pipe(Effect.tapErrorCause((cause) => Effect.logError("Application error", cause))); +const program = cli(process.argv).pipe(Effect.tapErrorCause((cause) => Effect.logError("Application error", cause))); program.pipe(Effect.provide(UniversalLayer), BunRuntime.runMain({ disablePrettyLogger: true })); From 12ca9f13d39e24a30afa0198820e85a7f5b67c06 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:20:41 +0100 Subject: [PATCH 17/40] fix: include current date in writer and reviewer prompts --- src/services/prompts.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/services/prompts.ts b/src/services/prompts.ts index 342c8f1..14d14ae 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -57,7 +57,16 @@ export class Prompts extends Effect.Service()("services/prompts", { return { system: systemPrompt, render: (input: WriterTaskInput): string => { - const parts = [`Task: ${input.goal}`]; + const parts = [ + "# Task", + "", + input.goal, + "", + "## Environment details", + `Current Date: ${new Date().toDateString()}`, + `Current Time: ${new Date().toLocaleTimeString()}`, + `Current Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`, + ]; const previousContext = input.previousContext; if (Option.isSome(previousContext)) { @@ -125,7 +134,17 @@ export class Prompts extends Effect.Service()("services/prompts", { }; return [ + "# Task", + "", + "You are a strict academic reviewer.", + "Your task is to review the changes to files and provide feedback.", + "Use tools to provide specific feedback to exact lines of text", + "or provide general feedback to the entire set of changes.", + "You can approve or reject the work and ask for additional changes.", + "As a last action, you must ALWAYS call either approve_changes or reject_changes.", + "", "## Original Goal", + "", input.goal, "", "## Staged Changes (Diffs)", @@ -133,9 +152,10 @@ export class Prompts extends Effect.Service()("services/prompts", { .map((p) => `### ${p.path}\n\`\`\`diff\n${formatPatch(p)}\n\`\`\``) .join("\n\n"), "", - "Review these changes. Use add_review_comment for specific feedback.", - "Call approve_changes if correct, or reject_changes with a critique.", - "As a last action, ALWAYS call either approve_changes or reject_changes.", + "## Environment details", + `Current Date: ${new Date().toDateString()}`, + `Current Time: ${new Date().toLocaleTimeString()}`, + `Current Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`, ].join("\n"); }, }; From ca8c43516a9a190ddc47fd8518f736a44cce5bfa Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:43:49 +0100 Subject: [PATCH 18/40] fix: ensure vfs state is preserved when resuming session This commit fixes a bug where resuming a session would reset the VFS because the agent loop started from cycle 0 instead of the saved cycle count. It also refactors the Session service to reuse logic and adds a regression test. --- src/services/agent.test.ts | 112 +++++++++++- src/services/agent.ts | 50 ++++- src/services/session.ts | 366 +++++++++++++------------------------ 3 files changed, 285 insertions(+), 243 deletions(-) diff --git a/src/services/agent.test.ts b/src/services/agent.test.ts index d708892..a5e76d2 100644 --- a/src/services/agent.test.ts +++ b/src/services/agent.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import { Chunk, Effect, Layer, Ref, Stream } from "effect"; +import { Chunk, Effect, Layer, Option, Ref, Stream } from "effect"; import type { MaxIterationsReached } from "@/domain/errors"; +import type { FilePatch } from "@/domain/vfs"; import { Agent, type AgentEvent } from "@/services/agent"; import { TestConfigLayer } from "@/services/config"; import { TestLLM, TestLLMLayer } from "@/services/llm"; import { TestAppLogger } from "@/services/logger"; import { TestPromptsLayer } from "@/services/prompts"; -import { TestSessionLayer } from "@/services/session"; +import { Session, SessionData, TestSessionLayer } from "@/services/session"; import { VFS } from "@/services/vfs"; import { TestWebLayer } from "@/services/web"; import { TestProjectFilesLayer } from "@/test/mocks/project-files"; @@ -252,4 +253,111 @@ describe("Agent Service", () => { await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); }); + + test("resumes session and skips vfs reset if cycle > 1", async () => { + const streamTextMock = mock(); + streamTextMock.mockImplementation(() => Effect.succeed({ content: "Resumed content", cost: 0 })); + TestLLM.streamText = streamTextMock; + + const vfsResetMock = mock(() => Effect.void); + + const MockVFSLayer = Layer.succeed(VFS, { + reset: vfsResetMock, + writeFile: () => Effect.void, + editFile: () => Effect.void, + getSummary: () => Effect.succeed({ files: [], fileCount: 0, commentCount: 0 }), + getDiffs: () => Effect.succeed(Chunk.empty()), + getComments: () => Effect.succeed(Chunk.empty()), + getDecision: () => Effect.succeed(Option.none()), + flush: () => Effect.succeed([]), + readFile: () => Effect.fail(new Error("Not implemented")), + addComment: () => Effect.void, + approve: () => Effect.void, + reject: () => Effect.void, + getFileDiff: () => Effect.dieMessage("Not implemented"), + listFiles: () => Effect.succeed([]), + readDirectory: () => Effect.succeed([]), + } as unknown as typeof VFS.Service); + + const sessionMock = { + create: mock(), + resume: mock(() => + Effect.succeed({ + id: "test-session", + path: "/tmp/test-session.json", + addEntry: () => Effect.void, + addAgentEvent: () => Effect.void, + addToolCall: () => Effect.void, + updateStatus: () => Effect.void, + addCost: () => Effect.void, + getTotalCost: () => Effect.succeed(0), + getToolCalls: () => Effect.succeed([]), + updateIterations: () => Effect.void, + getIterations: () => Effect.succeed(1), + flush: () => Effect.void, + close: () => Effect.void, + }), + ), + list: mock(), + get: mock(() => + Effect.succeed( + new SessionData({ + id: "test-session", + modelWriter: "gpt-4", + modelReviewer: "claude-3", + reasoning: true, + reasoningEffort: "high", + maxIterations: 5, + startedAt: Date.now(), + updatedAt: Date.now(), + iterations: 1, + totalCost: 0, + status: "running", + entries: [ + { + _tag: "UserInput", + prompt: "Test prompt", + timestamp: Date.now(), + }, + { + _tag: "ToolCall", + name: "write_file", + input: { filePath: "/test.txt", content: "hello" }, + output: "ok", + timestamp: Date.now(), + }, + ], + }), + ), + ), + getSessionsDir: mock(), + } as unknown as typeof Session.Service; + + const TestLayerWithResume = Agent.DefaultWithoutDependencies.pipe( + Layer.provideMerge( + Layer.mergeAll( + TestConfigLayer, + TestPromptsLayer, + TestAppLogger, + Layer.succeed(Session, sessionMock), + TestLLMLayer, + MockVFSLayer, + TestWebLayer, + TestProjectFilesLayer, + ), + ), + ); + + const program = Effect.gen(function* () { + const agent = yield* Agent; + const runner = yield* agent.run({ sessionId: "test-session", maxIterations: 2 }); + + yield* Stream.runDrain(runner.events); + yield* runner.result.pipe(Effect.ignore); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayerWithResume))); + + expect(vfsResetMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/agent.ts b/src/services/agent.ts index 11761ee..d5e3db4 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -1,3 +1,5 @@ +import type { PlatformError } from "@effect/platform/Error"; +import { Chunk, Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; import { DEFAULT_MODEL_REVIEWER, DEFAULT_MODEL_WRITER, MAX_STEP_COUNT } from "@/domain/constants"; import { AgentLoopError, @@ -17,8 +19,6 @@ import { Session, type SessionHandle } from "@/services/session"; import { VFS } from "@/services/vfs"; import { Web } from "@/services/web"; import { makeReviewerTools, makeWriterTools } from "@/tools"; -import type { PlatformError } from "@effect/platform/Error"; -import { Chunk, Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; export const reasoningOptions = Schema.Literal("low", "medium", "high"); @@ -202,6 +202,50 @@ export class Agent extends Effect.Service()("services/agent", { startCycle = Math.max(0, startCycle - 1); } + let replayCycle = 0; + for (const entry of sessionData.entries) { + if ( + entry._tag === "AgentEvent" && + typeof entry.event === "object" && + entry.event !== null && + "cycle" in entry.event + ) { + replayCycle = (entry.event as { cycle: number }).cycle; + } + + if (entry._tag === "ToolCall" && replayCycle <= startCycle) { + if (entry.name === "write_file") { + const input = entry.input as { filePath: string; content: string }; + if (input?.filePath && typeof input.content === "string") { + yield* vfs + .writeFile(input.filePath, input.content, true) + .pipe(Effect.ignore); + } + } else if (entry.name === "edit_file") { + const input = entry.input as { + filePath: string; + oldString: string; + newString: string; + replaceAll?: boolean; + }; + if ( + input?.filePath && + typeof input.oldString === "string" && + typeof input.newString === "string" + ) { + yield* vfs + .editFile( + input.filePath, + input.oldString, + input.newString, + input.replaceAll ?? false, + ) + .pipe(Effect.ignore); + } + } + } + } + const promptEntry = sessionData.entries.find((e) => e._tag === "UserInput") as | { prompt: string } | undefined; @@ -557,7 +601,7 @@ export class Agent extends Effect.Service()("services/agent", { return `Applied ${flushedFiles.length} file(s): ${flushedFiles.join(", ")}`; }); - const workflowFiber = yield* step(0).pipe( + const workflowFiber = yield* step(startCycle).pipe( Effect.tap((content) => sessionHandle.updateStatus("completed", content)), Effect.tapError((error) => Effect.gen(function* () { diff --git a/src/services/session.ts b/src/services/session.ts index 1fc2923..603bac8 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -30,7 +30,6 @@ const ErrorEntry = Schema.TaggedStruct("Error", { const SessionEntryBase = Schema.Union(UserInput, AgentEventEntry, ToolCall, ModelResponse, ErrorEntry); export type SessionEntryInput = Schema.Schema.Type; -// Full entry schema with timestamp - used for storage const withTimestamp = (s: Schema.Struct) => Schema.Struct({ ...s.fields, timestamp: Schema.Number }); @@ -97,6 +96,129 @@ export interface SessionHandle { const SAVE_INTERVAL_MS = 500; +const makeSessionHandle = (fs: FileSystem.FileSystem, id: string, sessionPath: string, initialData: SessionData) => + Effect.gen(function* () { + const stateRef = yield* Ref.make(initialData); + const dirtyRef = yield* Ref.make(true); + + const doSave = Effect.gen(function* () { + const isDirty = yield* Ref.getAndSet(dirtyRef, false); + if (!isDirty) return; + + const data = yield* Ref.get(stateRef); + yield* fs + .writeFileString(sessionPath, JSON.stringify(data, null, 2)) + .pipe(Effect.catchAll((error) => Effect.logWarning(`Session save failed: ${error}`))); + }).pipe(Effect.uninterruptible); + + const backgroundSaver = Effect.forever( + Effect.sleep(`${SAVE_INTERVAL_MS} millis`).pipe(Effect.zipRight(doSave)), + ); + + const saverFiber = yield* Effect.forkDaemon(backgroundSaver); + + const markDirty = Ref.set(dirtyRef, true); + + const updateState = (f: (data: SessionData) => SessionData): Effect.Effect => + Ref.update(stateRef, f).pipe(Effect.zipRight(markDirty)); + + const flush = Effect.gen(function* () { + yield* Ref.set(dirtyRef, true); + yield* doSave; + }); + + const close = Effect.gen(function* () { + yield* Fiber.interrupt(saverFiber); + yield* flush; + }); + + const handle: SessionHandle = { + id, + path: sessionPath, + + addEntry: (entry) => + updateState((data) => { + const timestamp = Date.now(); + const fullEntry = { ...entry, timestamp } as SessionEntry; + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, fullEntry], + }); + }), + + addAgentEvent: (event) => + updateState((data) => { + const timestamp = Date.now(); + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, { _tag: "AgentEvent", event, timestamp }], + }); + }), + + addToolCall: (name, input, output) => + updateState((data) => { + const timestamp = Date.now(); + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, { _tag: "ToolCall", name, input, output, timestamp }], + }); + }), + + updateStatus: (status, finalContent) => + updateState((data) => { + const now = Date.now(); + return new SessionData({ + ...data, + status, + updatedAt: now, + completedAt: status !== "running" ? now : data.completedAt, + finalContent: finalContent ?? data.finalContent, + }); + }), + + addCost: (cost) => + updateState( + (data) => + new SessionData({ + ...data, + totalCost: data.totalCost + cost, + updatedAt: Date.now(), + }), + ), + + getTotalCost: () => Ref.get(stateRef).pipe(Effect.map((d) => d.totalCost)), + + getToolCalls: () => + Ref.get(stateRef).pipe( + Effect.map((d) => + d.entries + .filter((e) => e._tag === "ToolCall") + .map((e) => ({ name: e.name, input: e.input, output: e.output })), + ), + ), + + updateIterations: (iterations) => + updateState( + (data) => + new SessionData({ + ...data, + iterations, + updatedAt: Date.now(), + }), + ), + + getIterations: () => Ref.get(stateRef).pipe(Effect.map((d) => d.iterations)), + + flush: () => flush, + close: () => close, + }; + + return handle; + }); + export class Session extends Effect.Service()("services/session", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -137,127 +259,13 @@ export class Session extends Effect.Service()("services/session", { ], }); - const stateRef = yield* Ref.make(initialData); - const dirtyRef = yield* Ref.make(true); - - const doSave = Effect.gen(function* () { - const isDirty = yield* Ref.getAndSet(dirtyRef, false); - if (!isDirty) return; - - const data = yield* Ref.get(stateRef); - yield* fs - .writeFileString(sessionPath, JSON.stringify(data, null, 2)) - .pipe(Effect.catchAll((error) => Effect.logWarning(`Session save failed: ${error}`))); - }).pipe(Effect.uninterruptible); - - const backgroundSaver = Effect.forever( - Effect.sleep(`${SAVE_INTERVAL_MS} millis`).pipe(Effect.zipRight(doSave)), - ); - - const saverFiber = yield* Effect.forkDaemon(backgroundSaver); - - const markDirty = Ref.set(dirtyRef, true); - - const updateState = (f: (data: SessionData) => SessionData): Effect.Effect => - Ref.update(stateRef, f).pipe(Effect.zipRight(markDirty)); + yield* fs + .writeFileString(sessionPath, JSON.stringify(initialData, null, 2)) + .pipe(Effect.catchAll((error) => Effect.logWarning(`Initial session save failed: ${error}`))); yield* Effect.logDebug(`Created session: ${id}`); - const flush = Effect.gen(function* () { - yield* Ref.set(dirtyRef, true); - yield* doSave; - }); - - const close = Effect.gen(function* () { - yield* Fiber.interrupt(saverFiber); - yield* flush; - }); - - const handle: SessionHandle = { - id, - path: sessionPath, - - addEntry: (entry) => - updateState((data) => { - const timestamp = Date.now(); - const fullEntry = { ...entry, timestamp } as SessionEntry; - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, fullEntry], - }); - }), - - addAgentEvent: (event) => - updateState((data) => { - const timestamp = Date.now(); - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, { _tag: "AgentEvent", event, timestamp }], - }); - }), - - addToolCall: (name, input, output) => - updateState((data) => { - const timestamp = Date.now(); - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, { _tag: "ToolCall", name, input, output, timestamp }], - }); - }), - - updateStatus: (status, finalContent) => - updateState((data) => { - const now = Date.now(); - return new SessionData({ - ...data, - status, - updatedAt: now, - completedAt: status !== "running" ? now : data.completedAt, - finalContent: finalContent ?? data.finalContent, - }); - }), - - addCost: (cost) => - updateState( - (data) => - new SessionData({ - ...data, - totalCost: data.totalCost + cost, - updatedAt: Date.now(), - }), - ), - - getTotalCost: () => Ref.get(stateRef).pipe(Effect.map((d) => d.totalCost)), - - getToolCalls: () => - Ref.get(stateRef).pipe( - Effect.map((d) => - d.entries - .filter((e) => e._tag === "ToolCall") - .map((e) => ({ name: e.name, input: e.input, output: e.output })), - ), - ), - - updateIterations: (iterations) => - updateState( - (data) => - new SessionData({ - ...data, - iterations, - updatedAt: Date.now(), - }), - ), - - getIterations: () => Ref.get(stateRef).pipe(Effect.map((d) => d.iterations)), - - flush: () => flush, - close: () => close, - }; - - return handle; + return yield* makeSessionHandle(fs, id, sessionPath, initialData); }), resume: (id: string): Effect.Effect => @@ -278,126 +286,8 @@ export class Session extends Effect.Service()("services/session", { status: "running", }); - const stateRef = yield* Ref.make(dataWithRunningStatus); - const dirtyRef = yield* Ref.make(false); - - const doSave = Effect.gen(function* () { - const isDirty = yield* Ref.getAndSet(dirtyRef, false); - if (!isDirty) return; - - const data = yield* Ref.get(stateRef); - yield* fs - .writeFileString(sessionPath, JSON.stringify(data, null, 2)) - .pipe(Effect.catchAll((error) => Effect.logWarning(`Session save failed: ${error}`))); - }).pipe(Effect.uninterruptible); - - const backgroundSaver = Effect.forever( - Effect.sleep(`${SAVE_INTERVAL_MS} millis`).pipe(Effect.zipRight(doSave)), - ); - - const saverFiber = yield* Effect.forkDaemon(backgroundSaver); - - const markDirty = Ref.set(dirtyRef, true); - - const updateState = (f: (data: SessionData) => SessionData): Effect.Effect => - Ref.update(stateRef, f).pipe(Effect.zipRight(markDirty)); - - const flush = Effect.gen(function* () { - yield* Ref.set(dirtyRef, true); - yield* doSave; - }); - - const close = Effect.gen(function* () { - yield* Fiber.interrupt(saverFiber); - yield* flush; - }); - - const handle: SessionHandle = { - id, - path: sessionPath, - - addEntry: (entry) => - updateState((data) => { - const timestamp = Date.now(); - const fullEntry = { ...entry, timestamp } as SessionEntry; - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, fullEntry], - }); - }), - - addAgentEvent: (event) => - updateState((data) => { - const timestamp = Date.now(); - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, { _tag: "AgentEvent", event, timestamp }], - }); - }), - - addToolCall: (name, input, output) => - updateState((data) => { - const timestamp = Date.now(); - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, { _tag: "ToolCall", name, input, output, timestamp }], - }); - }), - - updateStatus: (status, finalContent) => - updateState((data) => { - const now = Date.now(); - return new SessionData({ - ...data, - status, - updatedAt: now, - completedAt: status !== "running" ? now : data.completedAt, - finalContent: finalContent ?? data.finalContent, - }); - }), - - addCost: (cost) => - updateState( - (data) => - new SessionData({ - ...data, - totalCost: data.totalCost + cost, - updatedAt: Date.now(), - }), - ), - - getTotalCost: () => Ref.get(stateRef).pipe(Effect.map((d) => d.totalCost)), - - getToolCalls: () => - Ref.get(stateRef).pipe( - Effect.map((d) => - d.entries - .filter((e) => e._tag === "ToolCall") - .map((e) => ({ name: e.name, input: e.input, output: e.output })), - ), - ), - - updateIterations: (iterations) => - updateState( - (data) => - new SessionData({ - ...data, - iterations, - updatedAt: Date.now(), - }), - ), - - getIterations: () => Ref.get(stateRef).pipe(Effect.map((d) => d.iterations)), - - flush: () => flush, - close: () => close, - }; - yield* Effect.logDebug(`Resumed session: ${id}`); - return handle; + return yield* makeSessionHandle(fs, id, sessionPath, dataWithRunningStatus); }), list: () => From 25e44ad6a96b8f97cec3a511d494102df4a6bd5b Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:18:29 +0100 Subject: [PATCH 19/40] chore: add more tests to prompts service --- src/services/prompts.test.ts | 89 +++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/src/services/prompts.test.ts b/src/services/prompts.test.ts index af0c6da..5d527a4 100644 --- a/src/services/prompts.test.ts +++ b/src/services/prompts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { Effect } from "effect"; +import { Chunk, Effect, Option } from "effect"; +import { DiffHunk, FilePatch, ReviewComment } from "@/domain/vfs"; import { Prompts } from "@/services/prompts"; describe("Prompts Service", () => { @@ -33,4 +34,90 @@ describe("Prompts Service", () => { expect(Effect.runPromise(program)).rejects.toThrow(); }); + + test("generates writer task prompt with full context", async () => { + const program = Effect.gen(function* () { + const writerTask = yield* Prompts.getWriterTask; + + const input = { + goal: "Write a test", + latestComments: Chunk.make( + new ReviewComment({ + id: "1", + path: "/src/test.ts", + line: Option.some(10), + content: "Fix this typo", + timestamp: Date.now(), + }), + ), + latestFeedback: Option.some("Please add more tests"), + previousContext: Option.some({ + filesRead: [{ path: "/src/existing.ts", summary: "Existing code" }], + filesModified: ["/src/test.ts"], + }), + }; + + const prompt = writerTask.render(input); + + expect(prompt).toContain("# Task"); + expect(prompt).toContain("Write a test"); + expect(prompt).toContain("Current Date:"); + + expect(prompt).toContain("## Context from Previous Iterations"); + expect(prompt).toContain("### Files Already Read"); + expect(prompt).toContain("- /src/existing.ts: Existing code"); + expect(prompt).toContain("### Files You Modified (still staged)"); + expect(prompt).toContain("- /src/test.ts"); + + expect(prompt).toContain("## Reviewer Feedback to Address"); + expect(prompt).toContain("- /src/test.ts:10: Fix this typo"); + + expect(prompt).toContain("## User Feedback"); + expect(prompt).toContain("Please add more tests"); + }).pipe(Effect.provide(Prompts.Default)); + + await Effect.runPromise(program); + }); + + test("generates reviewer task prompt with diffs", async () => { + const program = Effect.gen(function* () { + const reviewerTask = yield* Prompts.getReviewerTask; + + const diffHunk = new DiffHunk({ + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 2, + content: "@@ -1,1 +1,2 @@\n-old\n+new line 1\n+new line 2", + }); + + const input = { + goal: "Review changes", + diffs: Chunk.make( + new FilePatch({ + path: "/src/changed.ts", + hunks: Chunk.make(diffHunk), + isNew: false, + isDeleted: false, + }), + ), + }; + + const prompt = reviewerTask.render(input); + + expect(prompt).toContain("# Task"); + expect(prompt).toContain("Review changes"); + expect(prompt).toContain("Current Date:"); + + expect(prompt).toContain("## Staged Changes (Diffs)"); + expect(prompt).toContain("### /src/changed.ts"); + expect(prompt).toContain("```diff"); + expect(prompt).toContain("@@ -1,1 +1,2 @@"); + expect(prompt).toContain("-old"); + expect(prompt).toContain("+new line 1"); + expect(prompt).toContain("+new line 2"); + }).pipe(Effect.provide(Prompts.Default)); + + await Effect.runPromise(program); + }); }); From ac993be77d336cd280d1b4ae5e10512d05797fe3 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:58:53 +0100 Subject: [PATCH 20/40] chore: add tui keymap --- src/tui/app.tsx | 20 +++++++++---- src/tui/components/StatusBar.tsx | 20 +++++-------- src/tui/components/TaskInput.tsx | 13 ++++++--- src/tui/keyboard/keymap.ts | 50 ++++++++++++++++++++++++++++++++ src/tui/keyboard/utils.ts | 28 ++++++++++++++++++ 5 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 src/tui/keyboard/keymap.ts create mode 100644 src/tui/keyboard/utils.ts diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 3617009..ac0fa8b 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -1,5 +1,5 @@ import { createCliRenderer } from "@opentui/core"; -import { createRoot, useKeyboard } from "@opentui/react"; +import { createRoot, useKeyboard, useRenderer } from "@opentui/react"; import { DialogProvider, useDialog, useDialogState } from "@opentui-ui/dialog/react"; import { Effect } from "effect"; import { StrictMode, useState } from "react"; @@ -12,20 +12,23 @@ import { AgentProvider, useAgentContext } from "@/tui/context/AgentContext"; import { ConfigProvider } from "@/tui/context/ConfigContext"; import { EffectProvider } from "@/tui/context/EffectContext"; import { RendererProvider } from "@/tui/context/RendererContext"; +import { Keymap } from "@/tui/keyboard/keymap"; import { TaskInput } from "./components/TaskInput"; import { Timeline } from "./components/Timeline"; +import { areKeyBindingsEqual } from "./keyboard/utils"; function AgentWorkflow() { - const { state, start, submitAction, cancel } = useAgentContext(); const dialog = useDialog(); + const renderer = useRenderer(); + const { state, start, submitAction, cancel } = useAgentContext(); const isDialogOpen = useDialogState((s) => s.isOpen); const [activeFocus, setActiveFocus] = useState<"input" | "timeline">("input"); - useKeyboard((key) => { + useKeyboard((keyEvent) => { if (isDialogOpen) return; - if (key.name === "f2") { + if (areKeyBindingsEqual(keyEvent, Keymap.Global.Settings)) { dialog.prompt({ content: (ctx) => , size: "large", @@ -33,14 +36,19 @@ function AgentWorkflow() { return; } - if (key.name === "tab") { + if (areKeyBindingsEqual(keyEvent, Keymap.Navigation.FocusNext)) { setActiveFocus((prev) => (prev === "input" ? "timeline" : "input")); } + if (state.phase === "awaiting-user" && activeFocus !== "timeline") { setActiveFocus("timeline"); } - if (key.name === "escape" && state.phase !== "idle") { + + if (areKeyBindingsEqual(keyEvent, Keymap.Global.Cancel) && state.phase !== "idle") { cancel(); + renderer.setTerminalTitle(""); + renderer.destroy(); + process.exit(0); } }); diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index 96ba514..6ea94f4 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -1,7 +1,8 @@ import { useKeyboard } from "@opentui/react"; import { useAgentContext } from "@/tui/context/AgentContext"; import { useConfigContext } from "@/tui/context/ConfigContext"; -import { useRenderer } from "@/tui/context/RendererContext"; +import { Keymap } from "@/tui/keyboard/keymap"; +import { areKeyBindingsEqual } from "../keyboard/utils"; export interface StatusBarProps { isRunning: boolean; @@ -10,23 +11,15 @@ export interface StatusBarProps { export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { const { config } = useConfigContext(); - const renderer = useRenderer(); const { state, retry } = useAgentContext(); - useKeyboard((key) => { + useKeyboard((keyEvent) => { if (disabled) return; - if (state.error && key.name === "r") { + if (state.error && areKeyBindingsEqual(keyEvent, Keymap.Global.Retry)) { retry(); return; } - - if (key.name === "escape") { - // Reset window title before destroying renderer - renderer.setTerminalTitle(""); - renderer.destroy(); - process.exit(0); - } }); const writer = config?.writerModel ?? "default"; @@ -42,7 +35,10 @@ export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { minHeight: 3, }} > - Esc: Exit | F2: Settings{state.error ? " | R: Retry" : ""} + + {Keymap.Global.Cancel.label}: Exit | {Keymap.Global.Settings.label}: Settings + {state.error ? ` | ${Keymap.Global.Retry.label}: Retry` : ""} + W: {writer} | R: {reviewer} diff --git a/src/tui/components/TaskInput.tsx b/src/tui/components/TaskInput.tsx index 40e69f5..78880e5 100644 --- a/src/tui/components/TaskInput.tsx +++ b/src/tui/components/TaskInput.tsx @@ -3,6 +3,7 @@ import { Effect } from "effect"; import { readFromClipboard } from "@/services/clipboard"; import { useEffectRuntime } from "@/tui/context/EffectContext"; import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; +import { Keymap } from "@/tui/keyboard/keymap"; export interface TaskInputProps { onTaskSubmit: (task: string) => void; @@ -17,7 +18,7 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) useKeyboard((key) => { if (!focused || isRunning) return; - if (key.name === "return") { + if (key.name === Keymap.TaskInput.Submit.name) { if (key.ctrl || key.meta) { buffer.insert("\n"); } else { @@ -26,7 +27,7 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) buffer.clear(); } } - } else if ((key.ctrl || key.meta) && key.name === "v") { + } else if ((key.ctrl || key.meta) && key.name === Keymap.TaskInput.Paste.name) { runtime .runPromise( readFromClipboard().pipe( @@ -89,7 +90,9 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) > {!buffer.text && !focused ? ( - Enter your writing task here... (Ctrl+Enter for newline) + + Enter your writing task here... ({Keymap.TaskInput.NewLine.label} for newline) + ) : ( renderContent() )} @@ -97,7 +100,9 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) - {isRunning ? "Running agent..." : "Enter: Submit | Ctrl+Enter: New Line | Esc: Cancel"} + {isRunning + ? "Running agent..." + : `${Keymap.TaskInput.Submit.label}: Submit | ${Keymap.TaskInput.NewLine.label}: New Line | ${Keymap.Global.Cancel.label}: Cancel`} diff --git a/src/tui/keyboard/keymap.ts b/src/tui/keyboard/keymap.ts new file mode 100644 index 0000000..d3727e5 --- /dev/null +++ b/src/tui/keyboard/keymap.ts @@ -0,0 +1,50 @@ +export interface KeyBinding { + /** The name of the key */ + name: string; + /** Control modifier */ + ctrl?: boolean; + /** Shift modifier */ + shift?: boolean; + /** Meta modifier */ + meta?: boolean; + /** Super modifier */ + super?: boolean; + /** Display label */ + label: string; + /** Optional description */ + description?: string; +} + +export type KeyMap = Record>; + +export const Keymap = { + Global: { + Exit: { name: "c", ctrl: true, label: "Ctrl+C" }, + Help: { name: "?", label: "?" }, + Settings: { name: "f2", label: "F2" }, + Retry: { name: "r", label: "R" }, + Submit: { name: "return", label: "Enter" }, + Cancel: { name: "escape", label: "Esc" }, + }, + DiffView: { + ToggleView: { name: "v", label: "V" }, + ToggleLineNumbers: { name: "l", label: "L" }, + ToggleWrap: { name: "w", label: "W" }, + CycleTheme: { name: "t", label: "T" }, + CloseHelp: { name: "escape", label: "Esc" }, + }, + Feedback: { + Approve: { name: "y", label: "y" }, + Reject: { name: "n", label: "n" }, + SubmitReject: { name: "return", label: "Enter" }, + CancelReject: { name: "escape", label: "Esc" }, + }, + Navigation: { + FocusNext: { name: "tab", label: "Tab" }, + }, + TaskInput: { + Submit: { name: "return", label: "Enter" }, + NewLine: { name: "return", ctrl: true, label: "Ctrl+Enter" }, + Paste: { name: "v", ctrl: true, label: "Ctrl+V" }, + }, +} as const satisfies KeyMap; diff --git a/src/tui/keyboard/utils.ts b/src/tui/keyboard/utils.ts new file mode 100644 index 0000000..dd20107 --- /dev/null +++ b/src/tui/keyboard/utils.ts @@ -0,0 +1,28 @@ +import type { KeyBinding } from "./keymap"; + +/** + * Converts a key binding to a human-readable string representation + * @returns A string like "ctrl+shift+y" or just "escape" + */ +export const keyBindingToString = (binding: KeyBinding): string => { + const parts: string[] = []; + if (binding.ctrl) parts.push("ctrl"); + if (binding.shift) parts.push("shift"); + if (binding.meta) parts.push("meta"); + if (binding.super) parts.push("super"); + parts.push(binding.name); + return parts.join("+"); +}; + +/** + * Check if provided key bindings refer to the same key combo + */ +export const areKeyBindingsEqual = (a: Partial, b: Partial): boolean => { + return ( + a.name === b.name && + !!a.ctrl === !!b.ctrl && + !!a.shift === !!b.shift && + !!a.meta === !!b.meta && + !!a.super === !!b.super + ); +}; From aa9e70bd2a18a6e2f72bdb4968827cd542585b19 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:59:20 +0100 Subject: [PATCH 21/40] feat: add diff view to user feedback --- src/tui/components/DiffView.tsx | 291 ++++++++++++++++++++++++++ src/tui/components/FeedbackWidget.tsx | 31 ++- src/tui/utils/diff.ts | 18 ++ 3 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 src/tui/components/DiffView.tsx create mode 100644 src/tui/utils/diff.ts diff --git a/src/tui/components/DiffView.tsx b/src/tui/components/DiffView.tsx new file mode 100644 index 0000000..27f1dad --- /dev/null +++ b/src/tui/components/DiffView.tsx @@ -0,0 +1,291 @@ +import { parseColor, SyntaxStyle } from "@opentui/core"; +import { type PromptContext, useDialog, useDialogKeyboard } from "@opentui-ui/dialog/react"; +import { useMemo, useState } from "react"; +import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; +import { Keymap } from "@/tui/keyboard/keymap"; + +export interface DiffTheme { + name: string; + backgroundColor: string; + borderColor: string; + addedBg: string; + removedBg: string; + contextBg: string; + addedSignColor: string; + removedSignColor: string; + lineNumberFg: string; + lineNumberBg: string; + addedLineNumberBg: string; + removedLineNumberBg: string; + selectionBg: string; + selectionFg: string; + syntaxStyle: Parameters[0]; +} + +export const themes: [DiffTheme, ...DiffTheme[]] = [ + { + name: "GitHub Dark", + backgroundColor: "#0D1117", + borderColor: "#4ECDC4", + addedBg: "#1a4d1a", + removedBg: "#4d1a1a", + contextBg: "transparent", + addedSignColor: "#22c55e", + removedSignColor: "#ef4444", + lineNumberFg: "#6b7280", + lineNumberBg: "#161b22", + addedLineNumberBg: "#0d3a0d", + removedLineNumberBg: "#3a0d0d", + selectionBg: "#264F78", + selectionFg: "#FFFFFF", + syntaxStyle: { + keyword: { fg: parseColor("#FF7B72"), bold: true }, + "keyword.import": { fg: parseColor("#FF7B72"), bold: true }, + string: { fg: parseColor("#A5D6FF") }, + comment: { fg: parseColor("#8B949E"), italic: true }, + number: { fg: parseColor("#79C0FF") }, + boolean: { fg: parseColor("#79C0FF") }, + constant: { fg: parseColor("#79C0FF") }, + function: { fg: parseColor("#D2A8FF") }, + "function.call": { fg: parseColor("#D2A8FF") }, + constructor: { fg: parseColor("#FFA657") }, + type: { fg: parseColor("#FFA657") }, + operator: { fg: parseColor("#FF7B72") }, + variable: { fg: parseColor("#E6EDF3") }, + property: { fg: parseColor("#79C0FF") }, + bracket: { fg: parseColor("#F0F6FC") }, + punctuation: { fg: parseColor("#F0F6FC") }, + default: { fg: parseColor("#E6EDF3") }, + }, + }, + { + name: "Monokai", + backgroundColor: "#272822", + borderColor: "#FD971F", + addedBg: "#2d4a2b", + removedBg: "#4a2b2b", + contextBg: "transparent", + addedSignColor: "#A6E22E", + removedSignColor: "#F92672", + lineNumberFg: "#75715E", + lineNumberBg: "#1e1f1c", + addedLineNumberBg: "#1e3a1e", + removedLineNumberBg: "#3a1e1e", + selectionBg: "#49483E", + selectionFg: "#F8F8F2", + syntaxStyle: { + keyword: { fg: parseColor("#F92672"), bold: true }, + "keyword.import": { fg: parseColor("#F92672"), bold: true }, + string: { fg: parseColor("#E6DB74") }, + comment: { fg: parseColor("#75715E"), italic: true }, + number: { fg: parseColor("#AE81FF") }, + boolean: { fg: parseColor("#AE81FF") }, + constant: { fg: parseColor("#AE81FF") }, + function: { fg: parseColor("#A6E22E") }, + "function.call": { fg: parseColor("#A6E22E") }, + constructor: { fg: parseColor("#FD971F") }, + type: { fg: parseColor("#66D9EF") }, + operator: { fg: parseColor("#F92672") }, + variable: { fg: parseColor("#F8F8F2") }, + property: { fg: parseColor("#66D9EF") }, + bracket: { fg: parseColor("#F8F8F2") }, + punctuation: { fg: parseColor("#F8F8F2") }, + default: { fg: parseColor("#F8F8F2") }, + }, + }, + { + name: "Dracula", + backgroundColor: "#282A36", + borderColor: "#BD93F9", + addedBg: "#2d4737", + removedBg: "#4d2d37", + contextBg: "transparent", + addedSignColor: "#50FA7B", + removedSignColor: "#FF5555", + lineNumberFg: "#6272A4", + lineNumberBg: "#21222C", + addedLineNumberBg: "#1f3626", + removedLineNumberBg: "#3a2328", + selectionBg: "#44475A", + selectionFg: "#F8F8F2", + syntaxStyle: { + keyword: { fg: parseColor("#FF79C6"), bold: true }, + "keyword.import": { fg: parseColor("#FF79C6"), bold: true }, + string: { fg: parseColor("#F1FA8C") }, + comment: { fg: parseColor("#6272A4"), italic: true }, + number: { fg: parseColor("#BD93F9") }, + boolean: { fg: parseColor("#BD93F9") }, + constant: { fg: parseColor("#BD93F9") }, + function: { fg: parseColor("#50FA7B") }, + "function.call": { fg: parseColor("#50FA7B") }, + constructor: { fg: parseColor("#FFB86C") }, + type: { fg: parseColor("#8BE9FD") }, + operator: { fg: parseColor("#FF79C6") }, + variable: { fg: parseColor("#F8F8F2") }, + property: { fg: parseColor("#8BE9FD") }, + bracket: { fg: parseColor("#F8F8F2") }, + punctuation: { fg: parseColor("#F8F8F2") }, + default: { fg: parseColor("#F8F8F2") }, + }, + }, +]; + +interface DiffViewProps { + diff: string; + filetype: string; + theme: DiffTheme; + view: "unified" | "split"; + showLineNumbers: boolean; + wrapMode: "none" | "word"; +} + +export function DiffView({ diff, filetype, theme, view, showLineNumbers, wrapMode }: DiffViewProps) { + const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(theme.syntaxStyle), [theme]); + + return ( + + + + ); +} + +interface DiffReviewModalProps extends PromptContext { + diff: string; + onApprove: () => void; + onReject: (comment?: string) => void; +} + +export function DiffReviewModal({ dialogId, dismiss, diff, onApprove, onReject }: DiffReviewModalProps) { + const dialog = useDialog(); + const [themeIndex, setThemeIndex] = useState(0); + const [view, setView] = useState<"unified" | "split">("unified"); + const [showLineNumbers, setShowLineNumbers] = useState(true); + const [wrapMode, setWrapMode] = useState<"none" | "word">("none"); + const [rejectMode, setRejectMode] = useState(false); + + const theme = themes[themeIndex % themes.length] ?? themes[0]; + + useDialogKeyboard((key) => { + if (rejectMode) { + return; + } + + if (key.raw === Keymap.Global.Help.name) { + return; + } + + if (key.name === Keymap.DiffView.ToggleView.name && !key.ctrl && !key.meta) { + setView((prev) => (prev === "unified" ? "split" : "unified")); + } else if (key.name === Keymap.DiffView.ToggleLineNumbers.name && !key.ctrl && !key.meta) { + setShowLineNumbers((prev) => !prev); + } else if (key.name === Keymap.DiffView.ToggleWrap.name && !key.ctrl && !key.meta) { + setWrapMode((prev) => (prev === "none" ? "word" : "none")); + } else if (key.name === Keymap.DiffView.CycleTheme.name && !key.ctrl && !key.meta) { + setThemeIndex((prev) => (prev + 1) % themes.length); + } else if (key.name === Keymap.Feedback.Approve.name && !key.ctrl && !key.meta) { + onApprove(); + dismiss(); + } else if (key.name === Keymap.Feedback.Reject.name && !key.ctrl && !key.meta) { + setRejectMode(true); + dialog.prompt({ + content: (ctx) => ( + { + onReject(reason); + dismiss(); + }} + /> + ), + size: "small", + }); + } else if (key.name === Keymap.Global.Exit.name && key.ctrl) { + dismiss(); + } + }, dialogId); + + return ( + + + + + + + + ); +} + +function RejectInput({ dialogId, dismiss, onSubmit }: PromptContext & { onSubmit: (text: string) => void }) { + const buffer = useTextBuffer(""); + + useDialogKeyboard((key) => { + if (key.name === "return") { + onSubmit(buffer.text); + dismiss(); + } else if (key.name === "escape") { + dismiss(); + } else if (key.name === "backspace") { + buffer.deleteBack(); + } else if (key.name?.length === 1 && !key.ctrl && !key.meta) { + buffer.insert(key.name); + } + }, dialogId); + + return ( + + Reason for rejection: + + {buffer.text} + + Enter: Submit | Esc: Cancel + + ); +} diff --git a/src/tui/components/FeedbackWidget.tsx b/src/tui/components/FeedbackWidget.tsx index 7946964..b97e371 100644 --- a/src/tui/components/FeedbackWidget.tsx +++ b/src/tui/components/FeedbackWidget.tsx @@ -1,7 +1,11 @@ import { useKeyboard } from "@opentui/react"; +import { useDialog } from "@opentui-ui/dialog/react"; import { useState } from "react"; +import { DiffReviewModal } from "@/tui/components/DiffView"; import type { PendingUserAction } from "@/tui/hooks/useAgent"; import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; +import { Keymap } from "@/tui/keyboard/keymap"; +import { formatDiffs } from "@/tui/utils/diff"; interface FeedbackWidgetProps { pendingAction: PendingUserAction; @@ -11,21 +15,31 @@ interface FeedbackWidgetProps { } export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: FeedbackWidgetProps) => { + const dialog = useDialog(); const [rejectMode, setRejectMode] = useState(false); const buffer = useTextBuffer(""); + const openReviewModal = () => { + const diffContent = formatDiffs(pendingAction.diffs); + dialog.prompt({ + content: (ctx) => , + size: "full", + }); + }; + useKeyboard((key) => { if (!focused) return; if (!rejectMode) { - if (key.name === "y") onApprove(); - else if (key.name === "n") setRejectMode(true); + if (key.name === Keymap.Feedback.Approve.name) onApprove(); + else if (key.name === Keymap.Feedback.Reject.name) setRejectMode(true); + else if (key.name === Keymap.DiffView.ToggleView.name) openReviewModal(); } else { - if (key.name === "return") { + if (key.name === Keymap.Feedback.SubmitReject.name) { onReject(buffer.text); setRejectMode(false); buffer.clear(); - } else if (key.name === "escape") { + } else if (key.name === Keymap.Feedback.CancelReject.name) { setRejectMode(false); buffer.clear(); } else if (key.name === "backspace") { @@ -96,16 +110,19 @@ export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: {renderInput()} - [Enter] Submit [Esc] Cancel + [{Keymap.Feedback.SubmitReject.label}] Submit [{Keymap.Feedback.CancelReject.label}] Cancel ) : ( - [y] Approve & Apply + [{Keymap.DiffView.ToggleView.label}] View Changes (Diff) + + + [{Keymap.Feedback.Approve.label}] Approve & Apply - [n] Reject & Request Changes + [{Keymap.Feedback.Reject.label}] Reject & Request Changes )} diff --git a/src/tui/utils/diff.ts b/src/tui/utils/diff.ts new file mode 100644 index 0000000..8903769 --- /dev/null +++ b/src/tui/utils/diff.ts @@ -0,0 +1,18 @@ +import { Chunk } from "effect"; +import type { FilePatch } from "@/domain/vfs"; + +export function formatFilePatch(patch: FilePatch): string { + const oldPath = patch.isNew ? "/dev/null" : `a/${patch.path}`; + const newPath = patch.isDeleted ? "/dev/null" : `b/${patch.path}`; + const header = `--- ${oldPath}\n+++ ${newPath}\n`; + + const hunks = Chunk.toReadonlyArray(patch.hunks) + .map((hunk) => hunk.content) + .join(""); + + return header + hunks; +} + +export function formatDiffs(diffs: ReadonlyArray): string { + return diffs.map(formatFilePatch).join("\n"); +} From d3d502bb8c3a27b93aea87c6e21c00a14ead44f7 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:09:09 +0100 Subject: [PATCH 22/40] fix: agent unable to write new files --- src/services/vfs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/vfs.ts b/src/services/vfs.ts index 2cd18ab..5bef010 100644 --- a/src/services/vfs.ts +++ b/src/services/vfs.ts @@ -59,7 +59,7 @@ export class VFS extends Effect.Service()("services/vfs", { Effect.gen(function* () { const original = yield* projectFiles.readFile(path, { disableExcerpts: true }).pipe( Effect.map(Option.some), - Effect.catchTag("FileReadError", () => Effect.succeed(Option.none())), + Effect.catchAll(() => Effect.succeed(Option.none())), ); if (!overwrite && Option.isSome(original)) { @@ -104,7 +104,7 @@ export class VFS extends Effect.Service()("services/vfs", { } else { original = yield* projectFiles.readFile(path, { disableExcerpts: true }).pipe( Effect.map(Option.some), - Effect.catchTag("FileReadError", () => Effect.succeed(Option.none())), + Effect.catchAll(() => Effect.succeed(Option.none())), ); } From f3186f004865e7e54aed114f33f22b0475aee739 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:48:13 +0100 Subject: [PATCH 23/40] fix: diff view not displaying properly --- src/tui/components/DiffView.tsx | 145 ++++++++++++++++++++------ src/tui/components/FeedbackWidget.tsx | 6 +- 2 files changed, 117 insertions(+), 34 deletions(-) diff --git a/src/tui/components/DiffView.tsx b/src/tui/components/DiffView.tsx index 27f1dad..bea48ac 100644 --- a/src/tui/components/DiffView.tsx +++ b/src/tui/components/DiffView.tsx @@ -1,6 +1,8 @@ -import { parseColor, SyntaxStyle } from "@opentui/core"; +import { parseColor, type SyntaxStyle } from "@opentui/core"; import { type PromptContext, useDialog, useDialogKeyboard } from "@opentui-ui/dialog/react"; -import { useMemo, useState } from "react"; +import { Chunk } from "effect"; +import { useState } from "react"; +import type { DiffHunk, FilePatch } from "@/domain/vfs"; import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; import { Keymap } from "@/tui/keyboard/keymap"; @@ -19,6 +21,7 @@ export interface DiffTheme { removedLineNumberBg: string; selectionBg: string; selectionFg: string; + defaultFg: string; syntaxStyle: Parameters[0]; } @@ -38,6 +41,7 @@ export const themes: [DiffTheme, ...DiffTheme[]] = [ removedLineNumberBg: "#3a0d0d", selectionBg: "#264F78", selectionFg: "#FFFFFF", + defaultFg: "#E6EDF3", syntaxStyle: { keyword: { fg: parseColor("#FF7B72"), bold: true }, "keyword.import": { fg: parseColor("#FF7B72"), bold: true }, @@ -73,6 +77,7 @@ export const themes: [DiffTheme, ...DiffTheme[]] = [ removedLineNumberBg: "#3a1e1e", selectionBg: "#49483E", selectionFg: "#F8F8F2", + defaultFg: "#F8F8F2", syntaxStyle: { keyword: { fg: parseColor("#F92672"), bold: true }, "keyword.import": { fg: parseColor("#F92672"), bold: true }, @@ -108,6 +113,7 @@ export const themes: [DiffTheme, ...DiffTheme[]] = [ removedLineNumberBg: "#3a2328", selectionBg: "#44475A", selectionFg: "#F8F8F2", + defaultFg: "#F8F8F2", syntaxStyle: { keyword: { fg: parseColor("#FF79C6"), bold: true }, "keyword.import": { fg: parseColor("#FF79C6"), bold: true }, @@ -131,7 +137,7 @@ export const themes: [DiffTheme, ...DiffTheme[]] = [ ]; interface DiffViewProps { - diff: string; + patches: ReadonlyArray; filetype: string; theme: DiffTheme; view: "unified" | "split"; @@ -139,9 +145,7 @@ interface DiffViewProps { wrapMode: "none" | "word"; } -export function DiffView({ diff, filetype, theme, view, showLineNumbers, wrapMode }: DiffViewProps) { - const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(theme.syntaxStyle), [theme]); - +export function DiffView({ patches, theme, view, showLineNumbers, wrapMode }: DiffViewProps) { return ( - + {patches.map((patch) => ( + + ))} + + ); +} + +interface FilePatchViewProps { + patch: FilePatch; + theme: DiffTheme; + showLineNumbers: boolean; + wrapMode: "none" | "word"; + view: "unified" | "split"; +} + +function FilePatchView({ patch, theme, showLineNumbers }: FilePatchViewProps) { + const hunks = Chunk.toReadonlyArray(patch.hunks); + + return ( + + + + {patch.path} {patch.isNew ? "(New)" : patch.isDeleted ? "(Deleted)" : ""} + + + {hunks.map((hunk, i) => ( + + ))} + + ); +} + +function HunkView({ hunk, theme, showLineNumbers }: { hunk: DiffHunk; theme: DiffTheme; showLineNumbers: boolean }) { + const lines = hunk.content.split("\n"); + let oldLine = hunk.oldStart; + let newLine = hunk.newStart; + + return ( + + {lines.map((line, i) => { + if (line.length === 0) return null; + const isAdd = line.startsWith("+"); + const isRem = line.startsWith("-"); + const isHeader = line.startsWith("@@"); + + let currentOld = ""; + let currentNew = ""; + + if (!isHeader) { + if (isAdd) { + currentNew = String(newLine++); + } else if (isRem) { + currentOld = String(oldLine++); + } else { + currentOld = String(oldLine++); + currentNew = String(newLine++); + } + } + + const bgColor = isAdd + ? theme.addedBg + : isRem + ? theme.removedBg + : isHeader + ? theme.lineNumberBg + : theme.backgroundColor; + const fgColor = isAdd + ? theme.addedSignColor + : isRem + ? theme.removedSignColor + : isHeader + ? theme.borderColor + : theme.defaultFg; + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: diff lines have no unique id + + {showLineNumbers && !isHeader && ( + + + {currentOld} + + + │ + + + {currentNew} + + + )} + {line} + + ); + })} ); } interface DiffReviewModalProps extends PromptContext { - diff: string; + patches: ReadonlyArray; onApprove: () => void; onReject: (comment?: string) => void; } -export function DiffReviewModal({ dialogId, dismiss, diff, onApprove, onReject }: DiffReviewModalProps) { +export function DiffReviewModal({ dialogId, dismiss, patches, onApprove, onReject }: DiffReviewModalProps) { const dialog = useDialog(); const [themeIndex, setThemeIndex] = useState(0); const [view, setView] = useState<"unified" | "split">("unified"); @@ -252,7 +335,7 @@ export function DiffReviewModal({ dialogId, dismiss, diff, onApprove, onReject } { - const diffContent = formatDiffs(pendingAction.diffs); dialog.prompt({ - content: (ctx) => , + content: (ctx) => ( + + ), size: "full", }); }; From 08ae8169ce18f8bf9a8e8f05881f7750cf5ed7be Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:22:20 +0100 Subject: [PATCH 24/40] chore: clean up write command after move to VFS --- src/commands/utils/workflow-errors.ts | 74 +---------------- src/commands/write.ts | 111 +++++++++----------------- src/runtime/index.ts | 3 + 3 files changed, 42 insertions(+), 146 deletions(-) diff --git a/src/commands/utils/workflow-errors.ts b/src/commands/utils/workflow-errors.ts index 819fada..b42b50a 100644 --- a/src/commands/utils/workflow-errors.ts +++ b/src/commands/utils/workflow-errors.ts @@ -1,22 +1,7 @@ import { log } from "@clack/prompts"; -import { Effect, Match, Option } from "effect"; +import { Effect, Match } from "effect"; import { AgentLoopError, AIGenerationError, MaxIterationsReached, UserCancel } from "@/domain/errors"; -/** - * Represents the current state of a workflow, used for error recovery. - */ -export interface WorkflowSnapshot { - readonly cycle: number; - readonly totalCost: number; -} - -/** - * Result of handling a workflow error. - */ -export type ErrorHandlingResult = - | { readonly _tag: "Rethrow"; readonly error: UserCancel } - | { readonly _tag: "Handled"; readonly savedPath: Option.Option }; - /** * Displays a user-friendly error message based on the error type. */ @@ -44,64 +29,9 @@ export const displayError = (error: unknown): Effect.Effect => ); }); -/** - * Displays the last draft if available. - * - * Since the agent now stages files in VFS instead of producing a single text draft, - * we cannot easily "save the draft" from a snapshot. This function remains for API compatibility - * but always returns None. - */ -export const displayLastDraft = (_snapshot: WorkflowSnapshot): Effect.Effect> => - Effect.sync(() => { - log.info("Draft recovery is not supported in the new VFS architecture."); - return Option.none(); - }); - -/** - * Displays summary information after saving a draft. - */ -export const displaySaveSuccess = (savedPath: string, snapshot: WorkflowSnapshot): Effect.Effect => - Effect.sync(() => { - log.success(`Draft saved to: ${savedPath}`); - if (snapshot.cycle > 0) { - log.info(`Completed ${snapshot.cycle} iteration(s)`); - } - if (snapshot.totalCost > 0) { - log.info(`Total cost: $${snapshot.totalCost.toFixed(6)}`); - } - }); - /** * Checks if an error should be rethrown (not handled inline). */ export const shouldRethrow = (error: unknown): error is UserCancel => error instanceof UserCancel; -/** - * Checks if an error was already handled (used to exit gracefully). - */ -export const isHandled = ( - result: ErrorHandlingResult, -): result is { _tag: "Handled"; savedPath: Option.Option } => result._tag === "Handled"; - -/** - * Creates a "handled" result. - */ -export const handled = (savedPath: Option.Option): ErrorHandlingResult => ({ - _tag: "Handled", - savedPath, -}); - -/** - * Creates a "rethrow" result. - */ -export const rethrow = (error: UserCancel): ErrorHandlingResult => ({ - _tag: "Rethrow", - error, -}); - -export class WorkflowErrorHandled { - readonly savedPath?: string; - constructor(props: { savedPath?: string }) { - this.savedPath = props.savedPath; - } -} +export class WorkflowErrorHandled {} diff --git a/src/commands/write.ts b/src/commands/write.ts index d51fe55..57fcd44 100644 --- a/src/commands/write.ts +++ b/src/commands/write.ts @@ -1,21 +1,14 @@ import { cancel, intro, isCancel, log, note, outro, select, spinner, text } from "@clack/prompts"; import { Args, Command, Options } from "@effect/cli"; -import { FileSystem, Path } from "@effect/platform"; -import { BunContext } from "@effect/platform-bun"; import { Chunk, Effect, Fiber, Option, Stream } from "effect"; -import { - displayError, - displayLastDraft, - displaySaveSuccess, - shouldRethrow, - type WorkflowSnapshot, -} from "@/commands/utils/workflow-errors"; +import { displayError, shouldRethrow } from "@/commands/utils/workflow-errors"; import { UserCancel, WorkflowErrorHandled } from "@/domain/errors"; import { Messages } from "@/domain/messages"; import type { FilePatch } from "@/domain/vfs"; import type { AgentEvent, RunResult, UserAction } from "@/services/agent"; import { Agent, reasoningOptions } from "@/services/agent"; import { Config } from "@/services/config"; +import { VFS } from "@/services/vfs"; import { formatWindow, renderMarkdown, renderMarkdownSnippet } from "@/text/utils"; const runPrompt = (promptFn: () => Promise) => @@ -119,56 +112,14 @@ const getUserFeedback = ( }); /** - * Prompt the user to save a draft to a file. - * Returns the file path if the user chooses to save, or undefined if they decline. - */ -const promptToSaveDraft = ( - draft: string, -): Effect.Effect => - Effect.gen(function* () { - const shouldSave = yield* runPrompt(() => - select({ - message: "Would you like to save the draft to a file?", - options: [ - { value: "yes", label: "Yes, save to file" }, - { value: "no", label: "No, discard" }, - ], - }), - ); - - if (shouldSave === "no") { - return undefined; - } - - const defaultFileName = `draft-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5)}.md`; - const filePath = yield* runPrompt(() => - text({ - message: "Enter the file path to save the draft:", - placeholder: defaultFileName, - defaultValue: defaultFileName, - }), - ); - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const fullPath = path.resolve(filePath); - - yield* fs - .writeFileString(fullPath, draft) - .pipe(Effect.mapError((error) => new Error(`Failed to save file: ${String(error)}`))); - - return fullPath; - }); - -/** - * Handle workflow errors by displaying error info and offering to save any draft. + * Handle workflow errors by displaying error info. * Returns WorkflowErrorHandled to signal graceful exit, or rethrows UserCancel. */ const handleWorkflowError = ( error: unknown, - getSnapshot: () => Effect.Effect, s: ReturnType, -): Effect.Effect => + vfs: VFS, +): Effect.Effect => Effect.gen(function* () { yield* Effect.sync(() => s.stop()); @@ -177,25 +128,38 @@ const handleWorkflowError = ( return yield* error; } - // Get current state and display error - const snapshot = yield* getSnapshot(); yield* displayError(error); - // Offer to save draft if one exists - const draftOption = yield* displayLastDraft(snapshot); - - if (Option.isSome(draftOption)) { - const savedPath = yield* promptToSaveDraft(draftOption.value).pipe( - Effect.provide(BunContext.layer), - Effect.catchAll(() => Effect.succeed(undefined)), - ); - - if (savedPath) { - yield* displaySaveSuccess(savedPath, snapshot); - return yield* new WorkflowErrorHandled({ savedPath }); - } - yield* Effect.sync(() => log.info("Draft not saved.")); - } + yield* vfs.getDiffs().pipe( + Effect.flatMap((diffs) => + Effect.gen(function* () { + if (Chunk.size(diffs) > 0) { + const files = Chunk.map(diffs, (d) => d.path).pipe(Chunk.join(", ")); + log.warn(`There are unsaved changes in: ${files}`); + + const shouldSave = yield* runPrompt(() => + select({ + message: "Would you like to save these changes?", + options: [ + { value: "yes", label: "Yes, save changes" }, + { value: "no", label: "No, discard" }, + ], + }), + ); + + if (shouldSave === "yes") { + const savedFiles = yield* vfs.flush(); + yield* Effect.sync(() => { + log.success(`Saved ${savedFiles.length} file(s): ${savedFiles.join(", ")}`); + }); + } else { + yield* Effect.sync(() => log.info("Changes discarded.")); + } + } + }), + ), + Effect.catchAll((e) => Effect.sync(() => log.error(`Failed to check for unsaved changes: ${e}`))), + ); return yield* new WorkflowErrorHandled({}); }); @@ -261,6 +225,7 @@ export const writeCommand = Command.make( } const agent = yield* Agent; + const vfs = yield* VFS; const s = spinner(); yield* Effect.sync(() => s.start("Initializing agent...")); @@ -335,12 +300,10 @@ export const writeCommand = Command.make( } }); - // Process events in the background const eventProcessor = yield* agentSession.events.pipe(Stream.runForEach(processEvent), Effect.fork); - // Wait for the result - handle errors inline where we have access to agentSession const agentResult = yield* agentSession.result.pipe( - Effect.catchAll((error) => handleWorkflowError(error, () => agentSession.getCurrentState(), s)), + Effect.catchAll((error) => handleWorkflowError(error, s, vfs)), ); yield* Fiber.join(eventProcessor); diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 7f9ac09..063dad2 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -10,6 +10,7 @@ import { ProjectFiles } from "@/services/project-files"; import { Prompts } from "@/services/prompts"; import { Session } from "@/services/session"; import { UserDirs } from "@/services/user-dirs"; +import { VFS } from "@/services/vfs"; import { Web } from "@/services/web"; export const UniversalLayer = Layer.mergeAll( @@ -22,6 +23,7 @@ export const UniversalLayer = Layer.mergeAll( Session.Default, UserDirs.Default, Web.Default, + VFS.Default, Clipboard.Default, FetchHttpClient.layer, ).pipe(Layer.provideMerge(BunContext.layer)); @@ -35,6 +37,7 @@ export type UniversalServices = | Session | UserDirs | Web + | VFS | Clipboard | HttpClient.HttpClient | BunContext.BunContext; From 7fc728c4bb1835b8b5cdf57dec855969e5cae9bd Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:11:39 +0100 Subject: [PATCH 25/40] fix: improve context passed between agents and workflow description --- src/prompts/reviewer.md | 27 +++++++++++++----- src/services/agent.ts | 20 ++++++++++++-- src/services/prompts.test.ts | 15 +++++----- src/services/prompts.ts | 53 ++++++++++++++++++++---------------- src/services/vfs.test.ts | 4 +-- src/services/vfs.ts | 14 ++++++++-- src/tools/review.ts | 20 ++++++++++---- 7 files changed, 102 insertions(+), 51 deletions(-) diff --git a/src/prompts/reviewer.md b/src/prompts/reviewer.md index d5af6a8..e9e5e66 100644 --- a/src/prompts/reviewer.md +++ b/src/prompts/reviewer.md @@ -1,7 +1,20 @@ -You are a strict academic reviewer. -Critique the following text for clarity, accuracy, academic tone, and adherence to formatting standards if applicable. -Check the text for factual errors and typos, make sure all data is backed by existing credible sources. -You may use web search and web fetch tools to verify claims and check for current research when fact-checking. -Do not critique the writer for not providing proof of reading/writing project files. -The output can be formatted in Markdown with code blocks indicating the required changes. -Be constructive but rigorous. +You are a strict reviewer. +Your task is to review the staged changes (diffs) and provide actionable feedback to the writer. + +# Workflow Instructions + +1. Analyze Diffs: Read the provided diffs carefully. If you need more context, use `read_file` or `read_all_diffs`. +2. Provide Feedback: Use the `add_review_comment` tool to leave specific comments on the code. + - **CRITICAL**: The writer will see feedback provided via `add_review_comment` and the `critique` in `reject_changes`. + - Do **NOT** provide feedback in your final text response. It will be lost. + - For general feedback that applies to the whole set of changes, use `reject_changes` with a detailed critique or `add_review_comment` with `line` set to `null` or 0. +3. Finalize: + - If the changes are good: Call `approve_changes` **ONCE** as your final action. + - If changes are needed: Call `reject_changes` **ONCE** as your final action. The `critique` you provide will be passed to the writer. + +# Review Guidelines + +- Check for correctness, potential bugs, and adherence to best practices. +- Be specific in your comments. Explain WHY something is wrong and HOW to fix it. +- Do not hallucinate issues. Verify your claims. +- Be constructive but rigorous. diff --git a/src/services/agent.ts b/src/services/agent.ts index d5e3db4..ddf9608 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -547,12 +547,19 @@ export class Agent extends Effect.Service()("services/agent", { const decision = yield* vfs.getDecision(); const comments = yield* vfs.getComments(); - const approved = Option.getOrElse(decision, () => "rejected") === "approved"; + const decisionValue = Option.getOrElse(decision, () => ({ + type: "rejected" as const, + message: undefined as string | undefined, + })); + const approved = decisionValue.type === "approved"; yield* emitEvent({ _tag: "ReviewComplete", approved, - critique: `Reviewer left ${Chunk.size(comments)} comments.`, + critique: + decisionValue.type === "rejected" && decisionValue.message + ? decisionValue.message + : `Reviewer left ${Chunk.size(comments)} comments.`, cycle, }); @@ -562,7 +569,14 @@ export class Agent extends Effect.Service()("services/agent", { message: "AI review rejected. Starting revision...", cycle, }); - yield* Ref.set(lastFeedbackRef, Option.some("Please address the review comments.")); + yield* Ref.set( + lastFeedbackRef, + Option.some( + decisionValue.type === "rejected" && decisionValue.message + ? decisionValue.message + : "Please address the review comments.", + ), + ); return yield* step(cycle); } diff --git a/src/services/prompts.test.ts b/src/services/prompts.test.ts index 5d527a4..6006b1f 100644 --- a/src/services/prompts.test.ts +++ b/src/services/prompts.test.ts @@ -61,19 +61,18 @@ describe("Prompts Service", () => { expect(prompt).toContain("# Task"); expect(prompt).toContain("Write a test"); - expect(prompt).toContain("Current Date:"); + expect(prompt).toContain("Current date:"); expect(prompt).toContain("## Context from Previous Iterations"); - expect(prompt).toContain("### Files Already Read"); + expect(prompt).toContain("### Files Read"); expect(prompt).toContain("- /src/existing.ts: Existing code"); - expect(prompt).toContain("### Files You Modified (still staged)"); + expect(prompt).toContain("### Files Modified"); expect(prompt).toContain("- /src/test.ts"); - expect(prompt).toContain("## Reviewer Feedback to Address"); - expect(prompt).toContain("- /src/test.ts:10: Fix this typo"); - - expect(prompt).toContain("## User Feedback"); + expect(prompt).toContain("## Reviewer Feedback"); expect(prompt).toContain("Please add more tests"); + expect(prompt).toContain("### Specific Comments"); + expect(prompt).toContain("- /src/test.ts:10: Fix this typo"); }).pipe(Effect.provide(Prompts.Default)); await Effect.runPromise(program); @@ -107,7 +106,7 @@ describe("Prompts Service", () => { expect(prompt).toContain("# Task"); expect(prompt).toContain("Review changes"); - expect(prompt).toContain("Current Date:"); + expect(prompt).toContain("Current date:"); expect(prompt).toContain("## Staged Changes (Diffs)"); expect(prompt).toContain("### /src/changed.ts"); diff --git a/src/services/prompts.ts b/src/services/prompts.ts index 14d14ae..9dcbe55 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -63,9 +63,7 @@ export class Prompts extends Effect.Service()("services/prompts", { input.goal, "", "## Environment details", - `Current Date: ${new Date().toDateString()}`, - `Current Time: ${new Date().toLocaleTimeString()}`, - `Current Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`, + `Current date: ${formatter.format(new Date())}`, ]; const previousContext = input.previousContext; @@ -74,7 +72,7 @@ export class Prompts extends Effect.Service()("services/prompts", { parts.push("", "## Context from Previous Iterations"); if (ctx.filesRead.length > 0) { - parts.push("### Files Already Read"); + parts.push("### Files Read"); parts.push( ctx.filesRead .map((f) => (f.summary ? `- ${f.path}: ${f.summary}` : `- ${f.path}`)) @@ -83,7 +81,7 @@ export class Prompts extends Effect.Service()("services/prompts", { } if (ctx.filesModified.length > 0) { - parts.push("### Files You Modified (still staged)"); + parts.push("### Files Modified"); parts.push(ctx.filesModified.map((f) => `- ${f}`).join("\n")); } @@ -91,22 +89,28 @@ export class Prompts extends Effect.Service()("services/prompts", { } const latestComments = input.latestComments; - if (Chunk.isNonEmpty(latestComments)) { - parts.push( - "", - "## Reviewer Feedback to Address", - Chunk.toArray(latestComments) - .map( - (c) => - `- ${c.path}${Option.isSome(c.line) ? `:${c.line.value}` : ""}: ${c.content}`, - ) - .join("\n"), - ); - } - const latestFeedback = input.latestFeedback; - if (Option.isSome(latestFeedback)) { - parts.push("", "## User Feedback", latestFeedback.value); + + if (Chunk.isNonEmpty(latestComments) || Option.isSome(latestFeedback)) { + parts.push("", "## Reviewer Feedback", ""); + + if (Option.isSome(latestFeedback)) { + parts.push(latestFeedback.value, ""); + } + + if (Chunk.isNonEmpty(latestComments)) { + parts.push( + "### Specific Comments", + Chunk.toArray(latestComments) + .map( + (c) => + `- ${c.path}${Option.isSome(c.line) ? `:${c.line.value}` : ""}: ${c.content}`, + ) + .join("\n"), + ); + } + + parts.push("", "You have to address the feedback and resubmit the work for review."); } parts.push( @@ -153,9 +157,7 @@ export class Prompts extends Effect.Service()("services/prompts", { .join("\n\n"), "", "## Environment details", - `Current Date: ${new Date().toDateString()}`, - `Current Time: ${new Date().toLocaleTimeString()}`, - `Current Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`, + `Current date: ${formatter.format(new Date())}`, ].join("\n"); }, }; @@ -166,6 +168,11 @@ export class Prompts extends Effect.Service()("services/prompts", { accessors: true, }) {} +const formatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "full", + timeStyle: "short", +}); + export const TestPrompts = new Prompts({ get: () => Effect.succeed("prompt"), getWriterTask: Effect.succeed({ diff --git a/src/services/vfs.test.ts b/src/services/vfs.test.ts index ba15caf..31b5420 100644 --- a/src/services/vfs.test.ts +++ b/src/services/vfs.test.ts @@ -125,11 +125,11 @@ describe("VFS Service", () => { yield* vfs.approve(); const decision1 = yield* vfs.getDecision(); - expect(Option.getOrNull(decision1)).toBe("approved"); + expect(Option.getOrNull(decision1)).toEqual({ type: "approved", message: undefined }); yield* vfs.reject(); const decision2 = yield* vfs.getDecision(); - expect(Option.getOrNull(decision2)).toBe("rejected"); + expect(Option.getOrNull(decision2)).toEqual({ type: "rejected", message: undefined }); }); await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); diff --git a/src/services/vfs.ts b/src/services/vfs.ts index 5bef010..ae24701 100644 --- a/src/services/vfs.ts +++ b/src/services/vfs.ts @@ -8,7 +8,7 @@ import { replace } from "@/tools/edit-file"; export class VFSState extends Data.Class<{ readonly files: HashMap.HashMap; readonly comments: Chunk.Chunk; - readonly decision: Option.Option<"approved" | "rejected">; + readonly decision: Option.Option<{ type: "approved" | "rejected"; message?: string }>; }> { static readonly empty = new VFSState({ files: HashMap.empty(), @@ -180,9 +180,17 @@ export class VFS extends Effect.Service()("services/vfs", { getComments: () => Ref.get(stateRef).pipe(Effect.map((s) => s.comments)), - approve: () => Ref.update(stateRef, (s) => new VFSState({ ...s, decision: Option.some("approved") })), + approve: (message?: string) => + Ref.update( + stateRef, + (s) => new VFSState({ ...s, decision: Option.some({ type: "approved", message }) }), + ), - reject: () => Ref.update(stateRef, (s) => new VFSState({ ...s, decision: Option.some("rejected") })), + reject: (message?: string) => + Ref.update( + stateRef, + (s) => new VFSState({ ...s, decision: Option.some({ type: "rejected", message }) }), + ), getDecision: () => Ref.get(stateRef).pipe(Effect.map((s) => s.decision)), diff --git a/src/tools/review.ts b/src/tools/review.ts index bb13b38..b18cdc9 100644 --- a/src/tools/review.ts +++ b/src/tools/review.ts @@ -1,4 +1,5 @@ import { jsonSchema, tool } from "ai"; +import dedent from "dedent"; import { Chunk, Effect, JSONSchema, Option, Runtime, Schema } from "effect"; import type { DiffHunk, FilePatch } from "@/domain/vfs"; import { VFS } from "@/services/vfs"; @@ -52,7 +53,11 @@ export const makeReadFileDiffTool = (runtime: Runtime.Runtime) => export const makeAddReviewCommentTool = (runtime: Runtime.Runtime) => tool({ - description: "Add a comment to a file or specific line in a diff.", + description: dedent` + Add a comment to a file or specific line in a diff. + IMPORTANT: Use this for ALL feedback passed to the writer. + The writer will ONLY see comments added via this tool. + `, inputSchema: jsonSchema<{ filePath: string; line?: number; comment: string }>( JSONSchema.make( Schema.Struct({ @@ -73,7 +78,10 @@ export const makeAddReviewCommentTool = (runtime: Runtime.Runtime) => export const makeApproveChangesTool = (runtime: Runtime.Runtime) => tool({ - description: "Approve all staged changes. Call this when the diff looks correct.", + description: dedent` + Approve all staged changes. + Call this tool EXACTLY ONCE as your final action when the diff looks correct. + `, inputSchema: jsonSchema<{ summary?: string }>( JSONSchema.make( Schema.Struct({ @@ -85,14 +93,16 @@ export const makeApproveChangesTool = (runtime: Runtime.Runtime) => ), execute: async ({ summary }) => { return Runtime.runPromise(runtime)( - VFS.approve().pipe(Effect.map(() => `Changes approved.${summary ? ` Summary: ${summary}` : ""}`)), + VFS.approve(summary).pipe( + Effect.map(() => `Changes approved.${summary ? ` Summary: ${summary}` : ""}`), + ), ); }, }); export const makeRejectChangesTool = (runtime: Runtime.Runtime) => tool({ - description: "Reject staged changes with a critique for the writer to address.", + description: "Reject staged changes. You must provide a critique to explain the rejection reason.", inputSchema: jsonSchema<{ critique: string }>( JSONSchema.make( Schema.Struct({ @@ -102,7 +112,7 @@ export const makeRejectChangesTool = (runtime: Runtime.Runtime) => ), execute: async ({ critique }) => { return Runtime.runPromise(runtime)( - VFS.reject().pipe(Effect.map(() => `Changes rejected. Critique: ${critique}`)), + VFS.reject(critique).pipe(Effect.map(() => `Changes rejected. Critique: ${critique}`)), ); }, }); From ee855567e538973dae55b1f003456564422c1bfb Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:12:18 +0100 Subject: [PATCH 26/40] chore(deps): update deps --- bun.lock | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 3548b80..0e72833 100644 --- a/bun.lock +++ b/bun.lock @@ -84,8 +84,6 @@ "@effect/cluster": ["@effect/cluster@0.56.1", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.1", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.14" } }, "sha512-gnrsH6kfrUjn+82j/bw1IR4yFqJqV8tc7xZvrbJPRgzANycc6K1hu3LMg548uYbUkTzD8YYyqrSatMO1mkQpzw=="], - "@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="], - "@effect/language-service": ["@effect/language-service@0.66.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-zrH1EQvp+SrDGk245UcVP+jQgsbZmmKmeZEM2EkAkJhldlwucu9EAHufQZWg5cvAwyMBh+9j7p//1wEDdXWwrA=="], "@effect/platform": ["@effect/platform@0.94.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.14" } }, "sha512-SlL8OMTogHmMNnFLnPAHHo3ua1yrB1LNQOVQMiZsqYu9g3216xjr0gn5WoDgCxUyOdZcseegMjWJ7dhm/2vnfg=="], @@ -102,10 +100,6 @@ "@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="], - "@effect/typeclass": ["@effect/typeclass@0.38.0", "", { "peerDependencies": { "effect": "^3.19.0" } }, "sha512-lMUcJTRtG8KXhXoczapZDxbLK5os7M6rn0zkvOgncJW++A0UyelZfMVMKdT5R+fgpZcsAU/1diaqw3uqLJwGxA=="], - - "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -510,10 +504,42 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="], "@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="], From 41efd99d3557b8e72ce310121cf0b87a4633eda1 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 16 Jan 2026 01:38:51 +0100 Subject: [PATCH 27/40] fix: ai generating empty response is now a valid case --- src/services/llm.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/services/llm.ts b/src/services/llm.ts index f40129d..74306e5 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -168,14 +168,6 @@ export class LLM extends Effect.Service()("services/llm", { return yield* AIGenerationError.fromUnknown(streamError); } - if (!accumulatedText || accumulatedText.trim().length === 0) { - return yield* new AIGenerationError({ - cause: null, - message: "Generation failed: Empty response received.", - isRetryable: true, - }); - } - let cost = 0; const metadata = (yield* Effect.tryPromise(() => response.providerMetadata).pipe( Effect.orElseSucceed(() => undefined), From 115cee0a355cfd14a3fd87cb00ebe6cba23cf149 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 16 Jan 2026 01:39:08 +0100 Subject: [PATCH 28/40] refactor: use effect fn for readability --- src/services/agent.ts | 1067 ++++++++++++++++++++--------------------- 1 file changed, 530 insertions(+), 537 deletions(-) diff --git a/src/services/agent.ts b/src/services/agent.ts index ddf9608..e5a8643 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -109,602 +109,595 @@ export class Agent extends Effect.Service()("services/agent", { const runtime = yield* Effect.runtime(); return { - run: (options: RunOptions) => - Effect.gen(function* () { - yield* Effect.logInfo("Starting agent run").pipe( - Effect.annotateLogs({ - writer: options.modelWriter, - reviewer: options.modelReviewer, - reasoning: options.reasoning, - maxIterations: options.maxIterations, - sessionId: options.sessionId, - }), - ); - - const userConfig = yield* config.get; - - const maxIterations = options.maxIterations ?? userConfig.agentMaxIterations; - const reasoning = options.reasoning ?? true; - const reasoningEffort = options.reasoningEffort ?? "high"; - - const writerModel = yield* llm.createModel({ - name: options.modelWriter ?? userConfig.writerModel ?? DEFAULT_MODEL_WRITER, - role: "writer", - reasoning, - reasoningEffort, - }); - - const reviewerModel = yield* llm.createModel({ - name: options.modelReviewer ?? userConfig.reviewerModel ?? DEFAULT_MODEL_REVIEWER, - role: "reviewer", - reasoning, - reasoningEffort, - }); - - const writerModelName = options.modelWriter ?? DEFAULT_MODEL_WRITER; - const reviewerModelName = options.modelReviewer ?? DEFAULT_MODEL_REVIEWER; - - let sessionHandle: SessionHandle; - let initialPrompt = options.prompt ?? ""; - let startCycle = 0; - - let initialContext: WriterContext = { - filesRead: [], - filesModified: [], - }; - let initialFeedback: Option.Option = Option.none(); - - const extractContextFromToolCall = ( - name: string, - input: unknown, - output: unknown, - ): Partial => { - if (name === "read_file" && typeof input === "object" && input !== null) { - const filePath = (input as { filePath?: string }).filePath; - if (filePath) { - const summary = - typeof output === "string" - ? output - .split("\n") - .find((l) => l.trim()) - ?.slice(0, 80) - : undefined; - return { filesRead: [{ path: filePath, summary }] }; - } + run: Effect.fn("run")(function* (options: RunOptions) { + const userConfig = yield* config.get; + + const maxIterations = options.maxIterations ?? userConfig.agentMaxIterations; + const reasoning = options.reasoning ?? true; + const reasoningEffort = options.reasoningEffort ?? "high"; + const modelWriter = options.modelWriter ?? userConfig.writerModel ?? DEFAULT_MODEL_WRITER; + const modelReviewer = options.modelReviewer ?? userConfig.reviewerModel ?? DEFAULT_MODEL_REVIEWER; + + yield* Effect.logInfo("Starting agent run").pipe( + Effect.annotateLogs({ + writer: modelWriter, + reviewer: modelReviewer, + reasoning: reasoning, + maxIterations: maxIterations, + sessionId: options.sessionId, + }), + ); + + const writerModel = yield* llm.createModel({ + name: modelWriter, + role: "writer", + reasoning, + reasoningEffort, + }); + + const reviewerModel = yield* llm.createModel({ + name: modelReviewer, + role: "reviewer", + reasoning, + reasoningEffort, + }); + + let sessionHandle: SessionHandle; + let initialPrompt = options.prompt ?? ""; + let startCycle = 0; + + let initialContext: WriterContext = { + filesRead: [], + filesModified: [], + }; + let initialFeedback: Option.Option = Option.none(); + + const extractContextFromToolCall = ( + name: string, + input: unknown, + output: unknown, + ): Partial => { + if (name === "read_file" && typeof input === "object" && input !== null) { + const filePath = (input as { filePath?: string }).filePath; + if (filePath) { + const summary = + typeof output === "string" + ? output + .split("\n") + .find((l) => l.trim()) + ?.slice(0, 80) + : undefined; + return { filesRead: [{ path: filePath, summary }] }; } - if ( - (name === "write_file" || name === "edit_file") && - typeof input === "object" && - input !== null - ) { - const filePath = (input as { filePath?: string }).filePath; - if (filePath) { - return { filesModified: [filePath] }; - } + } + if ( + (name === "write_file" || name === "edit_file") && + typeof input === "object" && + input !== null + ) { + const filePath = (input as { filePath?: string }).filePath; + if (filePath) { + return { filesModified: [filePath] }; + } + } + return {}; + }; + + if (options.sessionId) { + sessionHandle = yield* session.resume(options.sessionId).pipe( + Effect.mapError( + (error) => + new AgentStreamError({ + cause: error, + message: "message" in error ? error.message : "Failed to resume session", + }), + ), + ); + const sessionData = yield* session.get(options.sessionId); + if (sessionData) { + startCycle = sessionData.iterations; + if (sessionData.status === "failed") { + startCycle = Math.max(0, startCycle - 1); } - return {}; - }; - if (options.sessionId) { - sessionHandle = yield* session.resume(options.sessionId).pipe( - Effect.mapError( - (error) => - new AgentStreamError({ - cause: error, - message: "message" in error ? error.message : "Failed to resume session", - }), - ), - ); - const sessionData = yield* session.get(options.sessionId); - if (sessionData) { - startCycle = sessionData.iterations; - if (sessionData.status === "failed") { - startCycle = Math.max(0, startCycle - 1); + let replayCycle = 0; + for (const entry of sessionData.entries) { + if ( + entry._tag === "AgentEvent" && + typeof entry.event === "object" && + entry.event !== null && + "cycle" in entry.event + ) { + replayCycle = (entry.event as { cycle: number }).cycle; } - let replayCycle = 0; - for (const entry of sessionData.entries) { - if ( - entry._tag === "AgentEvent" && - typeof entry.event === "object" && - entry.event !== null && - "cycle" in entry.event - ) { - replayCycle = (entry.event as { cycle: number }).cycle; - } - - if (entry._tag === "ToolCall" && replayCycle <= startCycle) { - if (entry.name === "write_file") { - const input = entry.input as { filePath: string; content: string }; - if (input?.filePath && typeof input.content === "string") { - yield* vfs - .writeFile(input.filePath, input.content, true) - .pipe(Effect.ignore); - } - } else if (entry.name === "edit_file") { - const input = entry.input as { - filePath: string; - oldString: string; - newString: string; - replaceAll?: boolean; - }; - if ( - input?.filePath && - typeof input.oldString === "string" && - typeof input.newString === "string" - ) { - yield* vfs - .editFile( - input.filePath, - input.oldString, - input.newString, - input.replaceAll ?? false, - ) - .pipe(Effect.ignore); - } + if (entry._tag === "ToolCall" && replayCycle <= startCycle) { + if (entry.name === "write_file") { + const input = entry.input as { filePath: string; content: string }; + if (input?.filePath && typeof input.content === "string") { + yield* vfs.writeFile(input.filePath, input.content, true).pipe(Effect.ignore); + } + } else if (entry.name === "edit_file") { + const input = entry.input as { + filePath: string; + oldString: string; + newString: string; + replaceAll?: boolean; + }; + if ( + input?.filePath && + typeof input.oldString === "string" && + typeof input.newString === "string" + ) { + yield* vfs + .editFile( + input.filePath, + input.oldString, + input.newString, + input.replaceAll ?? false, + ) + .pipe(Effect.ignore); } } } + } - const promptEntry = sessionData.entries.find((e) => e._tag === "UserInput") as - | { prompt: string } - | undefined; - if (promptEntry) { - initialPrompt = promptEntry.prompt; - } + const promptEntry = sessionData.entries.find((e) => e._tag === "UserInput") as + | { prompt: string } + | undefined; + if (promptEntry) { + initialPrompt = promptEntry.prompt; + } - for (const entry of sessionData.entries) { - if (entry._tag === "ToolCall") { - const partial = extractContextFromToolCall(entry.name, entry.input, entry.output); - initialContext = { - filesRead: [...initialContext.filesRead, ...(partial.filesRead ?? [])], - filesModified: [ - ...initialContext.filesModified, - ...(partial.filesModified ?? []), - ], - }; - } - if ( - entry._tag === "AgentEvent" && - typeof entry.event === "object" && - entry.event !== null - ) { - const evt = entry.event as AgentEvent; - if (evt._tag === "ReviewComplete") { - if (!evt.approved) { - initialFeedback = Option.some(evt.critique); - } else { - initialFeedback = Option.none(); - } + for (const entry of sessionData.entries) { + if (entry._tag === "ToolCall") { + const partial = extractContextFromToolCall(entry.name, entry.input, entry.output); + initialContext = { + filesRead: [...initialContext.filesRead, ...(partial.filesRead ?? [])], + filesModified: [...initialContext.filesModified, ...(partial.filesModified ?? [])], + }; + } + if ( + entry._tag === "AgentEvent" && + typeof entry.event === "object" && + entry.event !== null + ) { + const evt = entry.event as AgentEvent; + if (evt._tag === "ReviewComplete") { + if (!evt.approved) { + initialFeedback = Option.some(evt.critique); + } else { + initialFeedback = Option.none(); } - if (evt._tag === "UserInput") { - if (evt.content.startsWith("Rejected:")) { - initialFeedback = Option.some(evt.content.replace("Rejected: ", "")); - } else if (evt.content === "Approved") { - initialFeedback = Option.none(); - } + } + if (evt._tag === "UserInput") { + if (evt.content.startsWith("Rejected:")) { + initialFeedback = Option.some(evt.content.replace("Rejected: ", "")); + } else if (evt.content === "Approved") { + initialFeedback = Option.none(); } } } - initialContext = { - ...initialContext, - filesModified: [...new Set(initialContext.filesModified)], - }; - } - } else { - if (!options.prompt) { - return yield* new AgentStreamError({ - message: "Prompt is required for new sessions", - cause: new Error("Missing prompt"), - }); } - initialPrompt = options.prompt; - sessionHandle = yield* session - .create({ - prompt: options.prompt, - modelWriter: writerModelName, - modelReviewer: reviewerModelName, - reasoning, - reasoningEffort, - maxIterations, - }) - .pipe( - Effect.mapError( - (error) => - new AgentStreamError({ - cause: error, - message: "message" in error ? error.message : "Failed to create session", - }), - ), - ); + initialContext = { + ...initialContext, + filesModified: [...new Set(initialContext.filesModified)], + }; } + } else { + if (!options.prompt) { + return yield* new AgentStreamError({ + message: "Prompt is required for new sessions", + cause: new Error("Missing prompt"), + }); + } + initialPrompt = options.prompt; + sessionHandle = yield* session + .create({ + prompt: options.prompt, + modelWriter, + modelReviewer, + reasoning, + reasoningEffort, + maxIterations, + }) + .pipe( + Effect.mapError( + (error) => + new AgentStreamError({ + cause: error, + message: "message" in error ? error.message : "Failed to create session", + }), + ), + ); + } - yield* Effect.logDebug(`Session ID: ${sessionHandle.id}, Cycle: ${startCycle}`); + yield* Effect.logDebug(`Session ID: ${sessionHandle.id}, Cycle: ${startCycle}`); - const writerTask = yield* prompts.getWriterTask; - const reviewerTask = yield* prompts.getReviewerTask; + const writerTask = yield* prompts.getWriterTask; + const reviewerTask = yield* prompts.getReviewerTask; - const eventQueue = yield* Queue.unbounded(); - const userActionDeferred = yield* Ref.make | null>(null); + const eventQueue = yield* Queue.unbounded(); + const userActionDeferred = yield* Ref.make | null>(null); - const lastFeedbackRef = yield* Ref.make>(initialFeedback); - const writerContextRef = yield* Ref.make(initialContext); + const lastFeedbackRef = yield* Ref.make>(initialFeedback); + const writerContextRef = yield* Ref.make(initialContext); - const writer_tools = makeWriterTools(runtime); - const reviewer_tools = makeReviewerTools(runtime); + const writer_tools = makeWriterTools(runtime); + const reviewer_tools = makeReviewerTools(runtime); - const emitEvent = (event: AgentEvent) => - Effect.all([Queue.offer(eventQueue, event), sessionHandle.addAgentEvent(event)], { - discard: true, - }); + const emitEvent = (event: AgentEvent) => + Effect.all([Queue.offer(eventQueue, event), sessionHandle.addAgentEvent(event)], { + discard: true, + }); - const broadcastState = () => - Effect.gen(function* () { - const summary = yield* vfs.getSummary(); - const cost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); - yield* emitEvent({ - _tag: "StateUpdate", - files: summary.files, - cost, - }); + const broadcastState = Effect.fn("broadcastState")(function* () { + const summary = yield* vfs.getSummary(); + const cost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + yield* emitEvent({ + _tag: "StateUpdate", + files: summary.files, + cost, + }); + }); + + const saveToolCall = (record: ToolCallRecord) => { + Runtime.runPromise(runtime)( + Effect.all( + [ + sessionHandle.addToolCall(record.name, record.input, record.output), + Queue.offer(eventQueue, { + _tag: "ToolCall", + name: record.name, + input: record.input, + output: record.output, + } as const), + Effect.suspend(() => { + const partial = extractContextFromToolCall( + record.name, + record.input, + record.output, + ); + const contextUpdate = + Object.keys(partial).length > 0 + ? Ref.update(writerContextRef, (ctx) => ({ + filesRead: [...ctx.filesRead, ...(partial.filesRead ?? [])], + filesModified: [ + ...new Set([ + ...ctx.filesModified, + ...(partial.filesModified ?? []), + ]), + ], + })) + : Effect.void; + + return Effect.all([contextUpdate, broadcastState()], { discard: true }); + }), + ], + { discard: true }, + ), + ); + }; + + const step = Effect.fn("step")(function* ( + currentCycle: number, + ): Effect.fn.Return< + string, + | AgentStreamError + | AgentLoopError + | MaxIterationsReached + | UserCancel + | PlatformError + | FileReadError + | FileWriteError + | VFSError, + never + > { + const cycle = currentCycle + 1; + yield* sessionHandle.updateIterations(cycle); + + yield* Effect.logDebug(`Starting agent cycle ${cycle}`); + + if (currentCycle === 0) { + yield* emitEvent({ + _tag: "UserInput", + content: initialPrompt, + cycle, }); + } - const saveToolCall = (record: ToolCallRecord) => { - Runtime.runPromise(runtime)( - Effect.all( - [ - sessionHandle.addToolCall(record.name, record.input, record.output), - Queue.offer(eventQueue, { - _tag: "ToolCall", - name: record.name, - input: record.input, - output: record.output, - } as const), - Effect.suspend(() => { - const partial = extractContextFromToolCall( - record.name, - record.input, - record.output, - ); - const contextUpdate = - Object.keys(partial).length > 0 - ? Ref.update(writerContextRef, (ctx) => ({ - filesRead: [...ctx.filesRead, ...(partial.filesRead ?? [])], - filesModified: [ - ...new Set([ - ...ctx.filesModified, - ...(partial.filesModified ?? []), - ]), - ], - })) - : Effect.void; - - return Effect.all([contextUpdate, broadcastState()], { discard: true }); - }), - ], - { discard: true }, - ), - ); - }; - - const step = ( - currentCycle: number, - ): Effect.Effect< - string, - | AgentLoopError - | MaxIterationsReached - | UserCancel - | AgentStreamError - | PlatformError - | FileReadError - | FileWriteError - | VFSError - > => - Effect.gen(function* () { - const cycle = currentCycle + 1; - yield* sessionHandle.updateIterations(cycle); - - yield* Effect.logDebug(`Starting agent cycle ${cycle}`); - - if (cycle > maxIterations) { - const totalCost = yield* sessionHandle - .getTotalCost() - .pipe(Effect.orElseSucceed(() => 0)); - yield* emitEvent({ - _tag: "IterationLimitReached", - iterations: cycle, - lastDraft: "", - }); - return yield* new MaxIterationsReached({ - iterations: cycle, - lastDraft: "", - totalCost, - }); - } + if (cycle > maxIterations) { + const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + yield* emitEvent({ + _tag: "IterationLimitReached", + iterations: cycle, + lastDraft: "", + }); + return yield* new MaxIterationsReached({ + iterations: cycle, + lastDraft: "", + totalCost, + }); + } - const isRevision = cycle > 1; + const isRevision = cycle > 1; - yield* emitEvent({ - _tag: "Progress", - message: isRevision ? "Revising changes..." : "Drafting changes...", - cycle, - }); + yield* emitEvent({ + _tag: "Progress", + message: isRevision ? "Revising changes..." : "Drafting changes...", + cycle, + }); - if (!isRevision) { - yield* vfs.reset(); - } + if (!isRevision) { + yield* vfs.reset(); + } - const lastFeedback = yield* Ref.get(lastFeedbackRef); - const lastComments = yield* vfs.getComments(); - const previousContext = yield* Ref.get(writerContextRef); + const lastFeedback = yield* Ref.get(lastFeedbackRef); + const lastComments = yield* vfs.getComments(); + const previousContext = yield* Ref.get(writerContextRef); - const writerPrompt = writerTask.render({ - goal: initialPrompt, - latestComments: lastComments, - latestFeedback: lastFeedback, - previousContext: isRevision ? Option.some(previousContext) : Option.none(), - }); + const writerPrompt = writerTask.render({ + goal: initialPrompt, + latestComments: lastComments, + latestFeedback: lastFeedback, + previousContext: isRevision ? Option.some(previousContext) : Option.none(), + }); - const { content: _writerOutput, cost: draftCost } = yield* llm - .streamText( - { - model: writerModel, - system: writerTask.system, - prompt: writerPrompt, - tools: writer_tools, - maxSteps: MAX_STEP_COUNT, - }, - (chunk) => { - Runtime.runSync(runtime)( - Effect.all( - [ - Queue.offer(eventQueue, { - _tag: "StreamChunk", - content: chunk, - phase: "drafting", - } as const), - ], - { discard: true }, - ), - ); - }, - saveToolCall, - ) - .pipe( - Effect.mapError( - (error) => - new AgentLoopError({ - cause: error, - message: error.message, + const { content: _writerOutput, cost: draftCost } = yield* llm + .streamText( + { + model: writerModel, + system: writerTask.system, + prompt: writerPrompt, + tools: writer_tools, + maxSteps: MAX_STEP_COUNT, + }, + (chunk) => { + Runtime.runSync(runtime)( + Effect.all( + [ + Queue.offer(eventQueue, { + _tag: "StreamChunk", + content: chunk, phase: "drafting", - }), + } as const), + ], + { discard: true }, ), ); + }, + saveToolCall, + ) + .pipe( + Effect.mapError( + (error) => + new AgentLoopError({ + cause: error, + message: error.message, + phase: "drafting", + }), + ), + ); - yield* sessionHandle.addCost(draftCost); - yield* broadcastState(); + yield* sessionHandle.addCost(draftCost); + yield* broadcastState(); - const summary = yield* vfs.getSummary(); + const summary = yield* vfs.getSummary(); - yield* Effect.logDebug("Drafting complete", { files: summary.fileCount }); + yield* Effect.logDebug("Drafting complete", { files: summary.fileCount }); - yield* emitEvent({ - _tag: "DraftComplete", - content: _writerOutput, - cycle, - }); + yield* emitEvent({ + _tag: "DraftComplete", + content: _writerOutput, + cycle, + }); - yield* emitEvent({ - _tag: "Progress", - message: "Reviewer inspecting changes...", - cycle, - }); + yield* emitEvent({ + _tag: "Progress", + message: "Reviewer inspecting changes...", + cycle, + }); - const diffs = yield* vfs.getDiffs(); - const reviewPrompt = reviewerTask.render({ - goal: initialPrompt, - diffs, - }); + const diffs = yield* vfs.getDiffs(); + const reviewPrompt = reviewerTask.render({ + goal: initialPrompt, + diffs, + }); - const { content: _reviewOutput, cost: reviewCost } = yield* llm - .streamText( - { - model: reviewerModel, - system: reviewerTask.system, - prompt: reviewPrompt, - tools: reviewer_tools, - maxSteps: MAX_STEP_COUNT, - }, - (chunk) => { - Runtime.runSync(runtime)( - Effect.all( - [ - Queue.offer(eventQueue, { - _tag: "StreamChunk", - content: chunk, - phase: "reviewing", - } as const), - ], - { discard: true }, - ), - ); - }, - saveToolCall, - ) - .pipe( - Effect.mapError( - (error) => - new AgentLoopError({ - cause: error, - message: error.message, + const { content: _reviewOutput, cost: reviewCost } = yield* llm + .streamText( + { + model: reviewerModel, + system: reviewerTask.system, + prompt: reviewPrompt, + tools: reviewer_tools, + maxSteps: MAX_STEP_COUNT, + }, + (chunk) => { + Runtime.runSync(runtime)( + Effect.all( + [ + Queue.offer(eventQueue, { + _tag: "StreamChunk", + content: chunk, phase: "reviewing", - }), + } as const), + ], + { discard: true }, ), ); + }, + saveToolCall, + ) + .pipe( + Effect.mapError( + (error) => + new AgentLoopError({ + cause: error, + message: error.message, + phase: "reviewing", + }), + ), + ); - yield* sessionHandle.addCost(reviewCost); - yield* broadcastState(); - - const decision = yield* vfs.getDecision(); - const comments = yield* vfs.getComments(); - const decisionValue = Option.getOrElse(decision, () => ({ - type: "rejected" as const, - message: undefined as string | undefined, - })); - const approved = decisionValue.type === "approved"; - - yield* emitEvent({ - _tag: "ReviewComplete", - approved, - critique: - decisionValue.type === "rejected" && decisionValue.message - ? decisionValue.message - : `Reviewer left ${Chunk.size(comments)} comments.`, - cycle, - }); + yield* sessionHandle.addCost(reviewCost); + yield* broadcastState(); + + const decision = yield* vfs.getDecision(); + const comments = yield* vfs.getComments(); + const decisionValue = Option.getOrElse(decision, () => ({ + type: "rejected" as const, + message: undefined as string | undefined, + })); + const approved = decisionValue.type === "approved"; + + yield* emitEvent({ + _tag: "ReviewComplete", + approved, + critique: + decisionValue.type === "rejected" && decisionValue.message + ? decisionValue.message + : `Reviewer left ${Chunk.size(comments)} comments.`, + cycle, + }); - if (!approved) { - yield* emitEvent({ - _tag: "Progress", - message: "AI review rejected. Starting revision...", - cycle, - }); - yield* Ref.set( - lastFeedbackRef, - Option.some( - decisionValue.type === "rejected" && decisionValue.message - ? decisionValue.message - : "Please address the review comments.", - ), - ); - return yield* step(cycle); - } + if (!approved) { + yield* emitEvent({ + _tag: "Progress", + message: "AI review rejected. Starting revision...", + cycle, + }); + yield* Ref.set( + lastFeedbackRef, + Option.some( + decisionValue.type === "rejected" && decisionValue.message + ? decisionValue.message + : "Please address the review comments.", + ), + ); + return yield* step(cycle); + } - yield* emitEvent({ - _tag: "UserActionRequired", - diffs: Chunk.toArray(diffs), - cycle, - }); + yield* emitEvent({ + _tag: "UserActionRequired", + diffs: Chunk.toArray(diffs), + cycle, + }); - const deferred = yield* Deferred.make(); - yield* Ref.set(userActionDeferred, deferred); + const deferred = yield* Deferred.make(); + yield* Ref.set(userActionDeferred, deferred); - const userAction = yield* Deferred.await(deferred); + const userAction = yield* Deferred.await(deferred); - yield* emitEvent({ - _tag: "UserInput", - content: - userAction.type === "approve" - ? "Approved" - : `Rejected: ${userAction.comment ?? "No comment"}`, - cycle, - }); + yield* emitEvent({ + _tag: "UserInput", + content: + userAction.type === "approve" + ? "Approved" + : `Rejected: ${userAction.comment ?? "No comment"}`, + cycle, + }); - if (userAction.type === "reject") { - yield* emitEvent({ - _tag: "Progress", - message: "User requested changes. Starting revision...", - cycle, - }); - yield* Ref.set(lastFeedbackRef, Option.some(userAction.comment ?? "Please revise.")); - return yield* step(cycle); - } + if (userAction.type === "reject") { + yield* emitEvent({ + _tag: "Progress", + message: "User requested changes. Starting revision...", + cycle, + }); + yield* Ref.set(lastFeedbackRef, Option.some(userAction.comment ?? "Please revise.")); + return yield* step(cycle); + } - const flushedFiles = yield* vfs.flush(); + const flushedFiles = yield* vfs.flush(); - return `Applied ${flushedFiles.length} file(s): ${flushedFiles.join(", ")}`; - }); + return `Applied ${flushedFiles.length} file(s): ${flushedFiles.join(", ")}`; + }); - const workflowFiber = yield* step(startCycle).pipe( - Effect.tap((content) => sessionHandle.updateStatus("completed", content)), - Effect.tapError((error) => - Effect.gen(function* () { - if (error instanceof UserCancel) { - return yield* sessionHandle.updateStatus("cancelled"); - } - const message = error instanceof Error ? error.message : String(error); + const workflowFiber = yield* step(startCycle).pipe( + Effect.tap((content) => sessionHandle.updateStatus("completed", content)), + Effect.tapError( + Effect.fn(function* (error) { + if (error instanceof UserCancel) { + return yield* sessionHandle.updateStatus("cancelled"); + } + const message = error instanceof Error ? error.message : String(error); - yield* emitEvent({ + yield* emitEvent({ + _tag: "Error", + message, + cycle: 0, + }); + return yield* sessionHandle + .addEntry({ _tag: "Error", message, - cycle: 0, - }); - return yield* sessionHandle - .addEntry({ - _tag: "Error", - message, - phase: "phase" in error ? String(error.phase) : undefined, - }) - .pipe(Effect.andThen(sessionHandle.updateStatus("failed"))); - }), - ), - Effect.ensuring( - Queue.shutdown(eventQueue).pipe( - Effect.andThen(Effect.logDebug("Event queue shutdown")), - Effect.andThen(sessionHandle.close()), - ), - ), - Effect.fork, - ); - - return { - events: Stream.fromQueue(eventQueue), - sessionId: sessionHandle.id, - sessionPath: sessionHandle.path, - result: Effect.gen(function* () { - const content = yield* Fiber.join(workflowFiber); - const cycle = yield* sessionHandle.getIterations(); - const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); - return { - finalContent: content, - iterations: cycle, - totalCost, - sessionId: sessionHandle.id, - sessionPath: sessionHandle.path, - } satisfies RunResult; + phase: "phase" in error ? String(error.phase) : undefined, + }) + .pipe(Effect.andThen(sessionHandle.updateStatus("failed"))); }), + ), + Effect.ensuring( + Queue.shutdown(eventQueue).pipe( + Effect.andThen(Effect.logDebug("Event queue shutdown")), + Effect.andThen(sessionHandle.close()), + ), + ), + Effect.fork, + ); + + return { + events: Stream.fromQueue(eventQueue), + sessionId: sessionHandle.id, + sessionPath: sessionHandle.path, + result: Effect.gen(function* () { + const content = yield* Fiber.join(workflowFiber); + const cycle = yield* sessionHandle.getIterations(); + const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + return { + finalContent: content, + iterations: cycle, + totalCost, + sessionId: sessionHandle.id, + sessionPath: sessionHandle.path, + } satisfies RunResult; + }), + + submitUserAction: Effect.fn("submitUserAction")(function* (action: UserAction) { + const deferred = yield* Ref.get(userActionDeferred); + if (!deferred) { + return yield* new NoUserActionPending({ + message: + "No user action is pending. The agent may have already completed or not yet reached a user feedback point.", + }); + } + const isDone = yield* Deferred.isDone(deferred); + if (isDone) { + return yield* new NoUserActionPending({ + message: "User action was already submitted for this cycle.", + }); + } + yield* Deferred.succeed(deferred, action); + }), - submitUserAction: (action: UserAction) => - Effect.gen(function* () { - const deferred = yield* Ref.get(userActionDeferred); - if (!deferred) { - return yield* new NoUserActionPending({ - message: - "No user action is pending. The agent may have already completed or not yet reached a user feedback point.", - }); - } - const isDone = yield* Deferred.isDone(deferred); - if (isDone) { - return yield* new NoUserActionPending({ - message: "User action was already submitted for this cycle.", - }); - } - yield* Deferred.succeed(deferred, action); - }), - - cancel: () => - Effect.gen(function* () { - const deferred = yield* Ref.get(userActionDeferred); - if (deferred) { - yield* Deferred.fail(deferred, new UserCancel()); - } - yield* Fiber.interrupt(workflowFiber); - yield* Queue.shutdown(eventQueue); - }), - - getCurrentState: () => - Effect.gen(function* () { - const cycle = yield* sessionHandle.getIterations(); - const totalCost = yield* sessionHandle - .getTotalCost() - .pipe(Effect.orElseSucceed(() => 0)); - return { - cycle, - totalCost, - }; - }), - }; - }), + cancel: Effect.fn("cancel")(function* () { + const deferred = yield* Ref.get(userActionDeferred); + if (deferred) { + yield* Deferred.fail(deferred, new UserCancel()); + } + yield* Fiber.interrupt(workflowFiber); + yield* Queue.shutdown(eventQueue); + }), + + getCurrentState: Effect.fn("getCurrentState")(function* () { + const cycle = yield* sessionHandle.getIterations(); + const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + return { + cycle, + totalCost, + }; + }), + }; + }), }; }), dependencies: [Prompts.Default, Config.Default, Session.Default, LLM.Default, Web.Default, VFS.Default], From b258fcdd6a8f5d79ffeedc637f43821725aa6774 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 16 Jan 2026 01:56:49 +0100 Subject: [PATCH 29/40] feat: UI total revamp Better inputs, themes support, global keymap, diff view for changes --- src/tui/app.tsx | 19 +- src/tui/components/DiffReviewModal.tsx | 143 +++++++++ src/tui/components/DiffView.tsx | 386 ++----------------------- src/tui/components/FeedbackWidget.tsx | 92 ++---- src/tui/components/Input.tsx | 43 +++ src/tui/components/SettingsModal.tsx | 166 ++++++----- src/tui/components/Sidebar.tsx | 18 +- src/tui/components/StatusBar.tsx | 7 +- src/tui/components/TaskInput.tsx | 12 +- src/tui/components/Timeline.tsx | 20 +- src/tui/context/ThemeContext.tsx | 29 ++ src/tui/keyboard/keymap.ts | 2 +- src/tui/theme/index.ts | 172 +++++++++++ 13 files changed, 579 insertions(+), 530 deletions(-) create mode 100644 src/tui/components/DiffReviewModal.tsx create mode 100644 src/tui/components/Input.tsx create mode 100644 src/tui/context/ThemeContext.tsx create mode 100644 src/tui/theme/index.ts diff --git a/src/tui/app.tsx b/src/tui/app.tsx index ac0fa8b..4dc9552 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -12,6 +12,7 @@ import { AgentProvider, useAgentContext } from "@/tui/context/AgentContext"; import { ConfigProvider } from "@/tui/context/ConfigContext"; import { EffectProvider } from "@/tui/context/EffectContext"; import { RendererProvider } from "@/tui/context/RendererContext"; +import { ThemeProvider } from "@/tui/context/ThemeContext"; import { Keymap } from "@/tui/keyboard/keymap"; import { TaskInput } from "./components/TaskInput"; import { Timeline } from "./components/Timeline"; @@ -44,7 +45,7 @@ function AgentWorkflow() { setActiveFocus("timeline"); } - if (areKeyBindingsEqual(keyEvent, Keymap.Global.Cancel) && state.phase !== "idle") { + if (areKeyBindingsEqual(keyEvent, Keymap.Global.Cancel)) { cancel(); renderer.setTerminalTitle(""); renderer.destroy(); @@ -100,13 +101,15 @@ function App() { - - - - - - - + + + + + + + + + diff --git a/src/tui/components/DiffReviewModal.tsx b/src/tui/components/DiffReviewModal.tsx new file mode 100644 index 0000000..a40aad0 --- /dev/null +++ b/src/tui/components/DiffReviewModal.tsx @@ -0,0 +1,143 @@ +import { useRenderer } from "@opentui/react"; +import { type PromptContext, useDialog, useDialogKeyboard } from "@opentui-ui/dialog/react"; +import { useState } from "react"; +import { DiffView } from "@/tui/components/DiffView"; +import { Input } from "@/tui/components/Input"; +import { useTheme } from "@/tui/context/ThemeContext"; +import { Keymap } from "@/tui/keyboard/keymap"; + +interface DiffReviewModalProps extends PromptContext { + diff: string; + filetype?: string; + onApprove: () => void; + onReject: (comment?: string) => void; +} + +export function DiffReviewModal({ + dialogId, + dismiss, + diff, + filetype = "markdown", + onApprove, + onReject, +}: DiffReviewModalProps) { + const renderer = useRenderer(); + const dialog = useDialog(); + const { theme } = useTheme(); + const [view, setView] = useState<"unified" | "split">("unified"); + const [showLineNumbers, setShowLineNumbers] = useState(true); + const [wrapMode, setWrapMode] = useState<"none" | "word">("none"); + const [rejectMode, setRejectMode] = useState(false); + + useDialogKeyboard((key) => { + if (rejectMode) { + return; + } + + if (key.raw === Keymap.Global.Help.name) { + return; + } + + if (key.name === Keymap.DiffView.ToggleView.name && !key.ctrl && !key.meta) { + setView((prev) => (prev === "unified" ? "split" : "unified")); + } else if (key.name === Keymap.DiffView.ToggleLineNumbers.name && !key.ctrl && !key.meta) { + setShowLineNumbers((prev) => !prev); + } else if (key.name === Keymap.DiffView.ToggleWrap.name && !key.ctrl && !key.meta) { + setWrapMode((prev) => (prev === "none" ? "word" : "none")); + } else if (key.name === Keymap.Feedback.Approve.name && !key.ctrl && !key.meta) { + onApprove(); + dismiss(); + } else if (key.name === Keymap.Feedback.Reject.name && !key.ctrl && !key.meta) { + setRejectMode(true); + dialog.prompt({ + content: (ctx) => ( + { + onReject(reason); + dismiss(); + }} + /> + ), + size: "small", + }); + } else if (key.name === Keymap.Global.Exit.name && key.ctrl) { + dismiss(); + } + }, dialogId); + + return ( + + {diff.length === 0 ? ( + No changes to display. + ) : ( + + + + )} + + + + + ); +} + +function RejectInput({ dialogId, dismiss, onSubmit }: PromptContext & { onSubmit: (text: string) => void }) { + const { theme } = useTheme(); + + useDialogKeyboard((key) => { + if (key.name === "escape") { + dismiss(); + } + }, dialogId); + + return ( + + Reason for rejection: + { + onSubmit(val); + dismiss(); + }} + /> + Enter: Submit | Esc: Cancel + + ); +} diff --git a/src/tui/components/DiffView.tsx b/src/tui/components/DiffView.tsx index bea48ac..6f32f63 100644 --- a/src/tui/components/DiffView.tsx +++ b/src/tui/components/DiffView.tsx @@ -1,143 +1,9 @@ -import { parseColor, type SyntaxStyle } from "@opentui/core"; -import { type PromptContext, useDialog, useDialogKeyboard } from "@opentui-ui/dialog/react"; -import { Chunk } from "effect"; -import { useState } from "react"; -import type { DiffHunk, FilePatch } from "@/domain/vfs"; -import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; -import { Keymap } from "@/tui/keyboard/keymap"; - -export interface DiffTheme { - name: string; - backgroundColor: string; - borderColor: string; - addedBg: string; - removedBg: string; - contextBg: string; - addedSignColor: string; - removedSignColor: string; - lineNumberFg: string; - lineNumberBg: string; - addedLineNumberBg: string; - removedLineNumberBg: string; - selectionBg: string; - selectionFg: string; - defaultFg: string; - syntaxStyle: Parameters[0]; -} - -export const themes: [DiffTheme, ...DiffTheme[]] = [ - { - name: "GitHub Dark", - backgroundColor: "#0D1117", - borderColor: "#4ECDC4", - addedBg: "#1a4d1a", - removedBg: "#4d1a1a", - contextBg: "transparent", - addedSignColor: "#22c55e", - removedSignColor: "#ef4444", - lineNumberFg: "#6b7280", - lineNumberBg: "#161b22", - addedLineNumberBg: "#0d3a0d", - removedLineNumberBg: "#3a0d0d", - selectionBg: "#264F78", - selectionFg: "#FFFFFF", - defaultFg: "#E6EDF3", - syntaxStyle: { - keyword: { fg: parseColor("#FF7B72"), bold: true }, - "keyword.import": { fg: parseColor("#FF7B72"), bold: true }, - string: { fg: parseColor("#A5D6FF") }, - comment: { fg: parseColor("#8B949E"), italic: true }, - number: { fg: parseColor("#79C0FF") }, - boolean: { fg: parseColor("#79C0FF") }, - constant: { fg: parseColor("#79C0FF") }, - function: { fg: parseColor("#D2A8FF") }, - "function.call": { fg: parseColor("#D2A8FF") }, - constructor: { fg: parseColor("#FFA657") }, - type: { fg: parseColor("#FFA657") }, - operator: { fg: parseColor("#FF7B72") }, - variable: { fg: parseColor("#E6EDF3") }, - property: { fg: parseColor("#79C0FF") }, - bracket: { fg: parseColor("#F0F6FC") }, - punctuation: { fg: parseColor("#F0F6FC") }, - default: { fg: parseColor("#E6EDF3") }, - }, - }, - { - name: "Monokai", - backgroundColor: "#272822", - borderColor: "#FD971F", - addedBg: "#2d4a2b", - removedBg: "#4a2b2b", - contextBg: "transparent", - addedSignColor: "#A6E22E", - removedSignColor: "#F92672", - lineNumberFg: "#75715E", - lineNumberBg: "#1e1f1c", - addedLineNumberBg: "#1e3a1e", - removedLineNumberBg: "#3a1e1e", - selectionBg: "#49483E", - selectionFg: "#F8F8F2", - defaultFg: "#F8F8F2", - syntaxStyle: { - keyword: { fg: parseColor("#F92672"), bold: true }, - "keyword.import": { fg: parseColor("#F92672"), bold: true }, - string: { fg: parseColor("#E6DB74") }, - comment: { fg: parseColor("#75715E"), italic: true }, - number: { fg: parseColor("#AE81FF") }, - boolean: { fg: parseColor("#AE81FF") }, - constant: { fg: parseColor("#AE81FF") }, - function: { fg: parseColor("#A6E22E") }, - "function.call": { fg: parseColor("#A6E22E") }, - constructor: { fg: parseColor("#FD971F") }, - type: { fg: parseColor("#66D9EF") }, - operator: { fg: parseColor("#F92672") }, - variable: { fg: parseColor("#F8F8F2") }, - property: { fg: parseColor("#66D9EF") }, - bracket: { fg: parseColor("#F8F8F2") }, - punctuation: { fg: parseColor("#F8F8F2") }, - default: { fg: parseColor("#F8F8F2") }, - }, - }, - { - name: "Dracula", - backgroundColor: "#282A36", - borderColor: "#BD93F9", - addedBg: "#2d4737", - removedBg: "#4d2d37", - contextBg: "transparent", - addedSignColor: "#50FA7B", - removedSignColor: "#FF5555", - lineNumberFg: "#6272A4", - lineNumberBg: "#21222C", - addedLineNumberBg: "#1f3626", - removedLineNumberBg: "#3a2328", - selectionBg: "#44475A", - selectionFg: "#F8F8F2", - defaultFg: "#F8F8F2", - syntaxStyle: { - keyword: { fg: parseColor("#FF79C6"), bold: true }, - "keyword.import": { fg: parseColor("#FF79C6"), bold: true }, - string: { fg: parseColor("#F1FA8C") }, - comment: { fg: parseColor("#6272A4"), italic: true }, - number: { fg: parseColor("#BD93F9") }, - boolean: { fg: parseColor("#BD93F9") }, - constant: { fg: parseColor("#BD93F9") }, - function: { fg: parseColor("#50FA7B") }, - "function.call": { fg: parseColor("#50FA7B") }, - constructor: { fg: parseColor("#FFB86C") }, - type: { fg: parseColor("#8BE9FD") }, - operator: { fg: parseColor("#FF79C6") }, - variable: { fg: parseColor("#F8F8F2") }, - property: { fg: parseColor("#8BE9FD") }, - bracket: { fg: parseColor("#F8F8F2") }, - punctuation: { fg: parseColor("#F8F8F2") }, - default: { fg: parseColor("#F8F8F2") }, - }, - }, -]; +import { SyntaxStyle } from "@opentui/core"; +import { useMemo } from "react"; +import type { DiffTheme } from "@/tui/theme"; interface DiffViewProps { - patches: ReadonlyArray; + diff: string; filetype: string; theme: DiffTheme; view: "unified" | "split"; @@ -145,230 +11,32 @@ interface DiffViewProps { wrapMode: "none" | "word"; } -export function DiffView({ patches, theme, view, showLineNumbers, wrapMode }: DiffViewProps) { +export function DiffView({ diff, filetype, theme, view, showLineNumbers, wrapMode }: DiffViewProps) { + const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(theme.syntaxStyle), [theme]); + return ( - - {patches.map((patch) => ( - - ))} - - ); -} - -interface FilePatchViewProps { - patch: FilePatch; - theme: DiffTheme; - showLineNumbers: boolean; - wrapMode: "none" | "word"; - view: "unified" | "split"; -} - -function FilePatchView({ patch, theme, showLineNumbers }: FilePatchViewProps) { - const hunks = Chunk.toReadonlyArray(patch.hunks); - - return ( - - - - {patch.path} {patch.isNew ? "(New)" : patch.isDeleted ? "(Deleted)" : ""} - - - {hunks.map((hunk, i) => ( - - ))} - - ); -} - -function HunkView({ hunk, theme, showLineNumbers }: { hunk: DiffHunk; theme: DiffTheme; showLineNumbers: boolean }) { - const lines = hunk.content.split("\n"); - let oldLine = hunk.oldStart; - let newLine = hunk.newStart; - - return ( - - {lines.map((line, i) => { - if (line.length === 0) return null; - const isAdd = line.startsWith("+"); - const isRem = line.startsWith("-"); - const isHeader = line.startsWith("@@"); - - let currentOld = ""; - let currentNew = ""; - - if (!isHeader) { - if (isAdd) { - currentNew = String(newLine++); - } else if (isRem) { - currentOld = String(oldLine++); - } else { - currentOld = String(oldLine++); - currentNew = String(newLine++); - } - } - - const bgColor = isAdd - ? theme.addedBg - : isRem - ? theme.removedBg - : isHeader - ? theme.lineNumberBg - : theme.backgroundColor; - const fgColor = isAdd - ? theme.addedSignColor - : isRem - ? theme.removedSignColor - : isHeader - ? theme.borderColor - : theme.defaultFg; - - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: diff lines have no unique id - - {showLineNumbers && !isHeader && ( - - - {currentOld} - - - │ - - - {currentNew} - - - )} - {line} - - ); - })} - - ); -} - -interface DiffReviewModalProps extends PromptContext { - patches: ReadonlyArray; - onApprove: () => void; - onReject: (comment?: string) => void; -} - -export function DiffReviewModal({ dialogId, dismiss, patches, onApprove, onReject }: DiffReviewModalProps) { - const dialog = useDialog(); - const [themeIndex, setThemeIndex] = useState(0); - const [view, setView] = useState<"unified" | "split">("unified"); - const [showLineNumbers, setShowLineNumbers] = useState(true); - const [wrapMode, setWrapMode] = useState<"none" | "word">("none"); - const [rejectMode, setRejectMode] = useState(false); - - const theme = themes[themeIndex % themes.length] ?? themes[0]; - - useDialogKeyboard((key) => { - if (rejectMode) { - return; - } - - if (key.raw === Keymap.Global.Help.name) { - return; - } - - if (key.name === Keymap.DiffView.ToggleView.name && !key.ctrl && !key.meta) { - setView((prev) => (prev === "unified" ? "split" : "unified")); - } else if (key.name === Keymap.DiffView.ToggleLineNumbers.name && !key.ctrl && !key.meta) { - setShowLineNumbers((prev) => !prev); - } else if (key.name === Keymap.DiffView.ToggleWrap.name && !key.ctrl && !key.meta) { - setWrapMode((prev) => (prev === "none" ? "word" : "none")); - } else if (key.name === Keymap.DiffView.CycleTheme.name && !key.ctrl && !key.meta) { - setThemeIndex((prev) => (prev + 1) % themes.length); - } else if (key.name === Keymap.Feedback.Approve.name && !key.ctrl && !key.meta) { - onApprove(); - dismiss(); - } else if (key.name === Keymap.Feedback.Reject.name && !key.ctrl && !key.meta) { - setRejectMode(true); - dialog.prompt({ - content: (ctx) => ( - { - onReject(reason); - dismiss(); - }} - /> - ), - size: "small", - }); - } else if (key.name === Keymap.Global.Exit.name && key.ctrl) { - dismiss(); - } - }, dialogId); - - return ( - - - - - - - - ); -} - -function RejectInput({ dialogId, dismiss, onSubmit }: PromptContext & { onSubmit: (text: string) => void }) { - const buffer = useTextBuffer(""); - - useDialogKeyboard((key) => { - if (key.name === "return") { - onSubmit(buffer.text); - dismiss(); - } else if (key.name === "escape") { - dismiss(); - } else if (key.name === "backspace") { - buffer.deleteBack(); - } else if (key.name?.length === 1 && !key.ctrl && !key.meta) { - buffer.insert(key.name); - } - }, dialogId); - - return ( - - Reason for rejection: - - {buffer.text} - - Enter: Submit | Esc: Cancel - + /> ); } diff --git a/src/tui/components/FeedbackWidget.tsx b/src/tui/components/FeedbackWidget.tsx index dee11c7..3aca4ab 100644 --- a/src/tui/components/FeedbackWidget.tsx +++ b/src/tui/components/FeedbackWidget.tsx @@ -1,10 +1,12 @@ import { useKeyboard } from "@opentui/react"; import { useDialog } from "@opentui-ui/dialog/react"; import { useState } from "react"; -import { DiffReviewModal } from "@/tui/components/DiffView"; +import { DiffReviewModal } from "@/tui/components/DiffReviewModal"; +import { Input } from "@/tui/components/Input"; +import { useTheme } from "@/tui/context/ThemeContext"; import type { PendingUserAction } from "@/tui/hooks/useAgent"; -import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; import { Keymap } from "@/tui/keyboard/keymap"; +import { formatDiffs } from "@/tui/utils/diff"; interface FeedbackWidgetProps { pendingAction: PendingUserAction; @@ -15,14 +17,13 @@ interface FeedbackWidgetProps { export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: FeedbackWidgetProps) => { const dialog = useDialog(); + const { theme } = useTheme(); const [rejectMode, setRejectMode] = useState(false); - const buffer = useTextBuffer(""); const openReviewModal = () => { + const diffContent = formatDiffs(pendingAction.diffs); dialog.prompt({ - content: (ctx) => ( - - ), + content: (ctx) => , size: "full", }); }; @@ -30,61 +31,30 @@ export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: useKeyboard((key) => { if (!focused) return; - if (!rejectMode) { - if (key.name === Keymap.Feedback.Approve.name) onApprove(); - else if (key.name === Keymap.Feedback.Reject.name) setRejectMode(true); - else if (key.name === Keymap.DiffView.ToggleView.name) openReviewModal(); - } else { - if (key.name === Keymap.Feedback.SubmitReject.name) { - onReject(buffer.text); + if (rejectMode) { + if (key.name === Keymap.Feedback.CancelReject.name) { setRejectMode(false); - buffer.clear(); - } else if (key.name === Keymap.Feedback.CancelReject.name) { - setRejectMode(false); - buffer.clear(); - } else if (key.name === "backspace") { - buffer.deleteBack(); - } else if (key.name === "left") { - buffer.moveLeft(); - } else if (key.name === "right") { - buffer.moveRight(); - } else if (key.name === "space") { - buffer.insert(" "); - } else if (key.name?.length === 1 && !key.ctrl && !key.meta) { - const char = key.sequence && key.sequence.length === 1 ? key.sequence : key.name; - if (char && char.length === 1) buffer.insert(char); } + return; } - }); - const renderInput = () => { - const text = buffer.text; - const cursor = buffer.cursor; - const before = text.slice(0, cursor); - const cursorChar = text[cursor] || " "; - const after = text.slice(cursor + 1); - - return ( - - {before} - {cursorChar} - {after} - - ); - }; + if (key.name === Keymap.Feedback.Approve.name) onApprove(); + else if (key.name === Keymap.Feedback.Reject.name) setRejectMode(true); + else if (key.name === Keymap.DiffView.ToggleView.name) openReviewModal(); + }); return ( - + {focused ? "▶ USER ACTION REQUIRED" : "USER ACTION REQUIRED (Press Tab to Focus)"} @@ -96,33 +66,35 @@ export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: {rejectMode ? ( - + Please describe required changes: - { + onReject(text); + setRejectMode(false); }} - > - {renderInput()} - - + /> + [{Keymap.Feedback.SubmitReject.label}] Submit [{Keymap.Feedback.CancelReject.label}] Cancel ) : ( - [{Keymap.DiffView.ToggleView.label}] View Changes (Diff) + + [{Keymap.DiffView.ToggleView.label}] View Changes (Diff) + - [{Keymap.Feedback.Approve.label}] Approve & Apply + [{Keymap.Feedback.Approve.label}] Approve & Apply - [{Keymap.Feedback.Reject.label}] Reject & Request Changes + + [{Keymap.Feedback.Reject.label}] Reject & Request Changes + )} diff --git a/src/tui/components/Input.tsx b/src/tui/components/Input.tsx new file mode 100644 index 0000000..d124f79 --- /dev/null +++ b/src/tui/components/Input.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { useTheme } from "@/tui/context/ThemeContext"; + +export interface InputProps { + label?: string; + placeholder?: string; + focused?: boolean; + onInput?: (value: string) => void; + onSubmit?: (value: string) => void; + value?: string; +} + +export function Input({ label, placeholder, focused, onInput, onSubmit, value }: InputProps) { + const { theme } = useTheme(); + const [localValue, setLocalValue] = useState(value || ""); + + useEffect(() => { + if (value !== undefined) { + setLocalValue(value); + } + }, [value]); + + const handleInput = (newValue: string) => { + setLocalValue(newValue); + onInput?.(newValue); + }; + + return ( + + + + ); +} diff --git a/src/tui/components/SettingsModal.tsx b/src/tui/components/SettingsModal.tsx index 2ce78a0..cbe98dd 100644 --- a/src/tui/components/SettingsModal.tsx +++ b/src/tui/components/SettingsModal.tsx @@ -1,65 +1,77 @@ import { type PromptContext, useDialogKeyboard } from "@opentui-ui/dialog/react"; import { useEffect, useState } from "react"; +import { Input } from "@/tui/components/Input"; import { useConfigContext } from "@/tui/context/ConfigContext"; -import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; +import { useTheme } from "@/tui/context/ThemeContext"; +import { type Theme, themes } from "@/tui/theme"; + +type Field = "writer" | "reviewer" | "theme"; export const SettingsModal = ({ dialogId, dismiss }: PromptContext) => { const { config, updateConfig } = useConfigContext(); - const [focusedField, setFocusedField] = useState<"writer" | "reviewer">("writer"); + const { theme, setTheme } = useTheme(); + const [focusedField, setFocusedField] = useState("writer"); const [status, setStatus] = useState<"idle" | "saving">("idle"); + const [writerModel, setWriterModel] = useState(config?.writerModel ?? ""); + const [reviewerModel, setReviewerModel] = useState(config?.reviewerModel ?? ""); - const writerBuffer = useTextBuffer(config?.writerModel ?? ""); - const reviewerBuffer = useTextBuffer(config?.reviewerModel ?? ""); - - // biome-ignore lint/correctness/useExhaustiveDependencies: sync only on config load useEffect(() => { - if (config?.writerModel && writerBuffer.text === "") { - writerBuffer.setText(config.writerModel); - } - if (config?.reviewerModel && reviewerBuffer.text === "") { - reviewerBuffer.setText(config.reviewerModel); - } + if (config?.writerModel) setWriterModel(config.writerModel); + if (config?.reviewerModel) setReviewerModel(config.reviewerModel); }, [config]); + const handleSave = () => { + setStatus("saving"); + updateConfig({ + writerModel, + reviewerModel, + }) + .then(() => { + setStatus("idle"); + dismiss(); + }) + .catch(() => { + setStatus("idle"); + }); + }; + + const cycleTheme = () => { + const currentIndex = themes.findIndex((t: Theme) => t.name === theme.name); + const nextIndex = (currentIndex + 1) % themes.length; + setTheme(nextIndex); + }; + useDialogKeyboard((key) => { if (status === "saving") return; - if (key.name === "tab" || key.name === "down" || key.name === "up") { - setFocusedField((prev) => (prev === "writer" ? "reviewer" : "writer")); + if (key.name === "tab" || key.name === "down") { + setFocusedField((prev) => { + if (prev === "writer") return "reviewer"; + if (prev === "reviewer") return "theme"; + return "writer"; + }); return; } - if (key.name === "return") { - setStatus("saving"); - updateConfig({ - writerModel: writerBuffer.text, - reviewerModel: reviewerBuffer.text, - }) - .then(() => { - setStatus("idle"); - dismiss(); - }) - .catch(() => { - setStatus("idle"); - }); + if (key.name === "up") { + setFocusedField((prev) => { + if (prev === "writer") return "theme"; + if (prev === "reviewer") return "writer"; + return "reviewer"; + }); return; } - const activeBuffer = focusedField === "writer" ? writerBuffer : reviewerBuffer; + if ( + focusedField === "theme" && + (key.name === "right" || key.name === "left" || key.name === "return" || key.name === "space") + ) { + cycleTheme(); + return; + } - if (key.name === "backspace") { - activeBuffer.deleteBack(); - } else if (key.name === "left") { - activeBuffer.moveLeft(); - } else if (key.name === "right") { - activeBuffer.moveRight(); - } else if (key.name === "space") { - activeBuffer.insert(" "); - } else if (key.name?.length === 1 && !key.ctrl && !key.meta) { - const char = key.sequence && key.sequence.length === 1 ? key.sequence : key.name; - if (char && char.length === 1) { - activeBuffer.insert(char); - } + if (key.name === "return") { + handleSave(); } }, dialogId); @@ -68,53 +80,37 @@ export const SettingsModal = ({ dialogId, dismiss }: PromptContext) => { Settings - - - + - - {status === "saving" ? "Saving..." : "Enter: Save | Tab: Switch | Esc: Cancel"} - - - ); -}; + -const Input = ({ - label, - buffer, - isFocused, -}: { - label: string; - buffer: ReturnType; - isFocused: boolean; -}) => { - const text = buffer.text; - const cursor = buffer.cursor; - const before = text.slice(0, cursor); - const cursorChar = text[cursor] || " "; - const after = text.slice(cursor + 1); + + {theme.name} + + - return ( - - {label} - - - {before} - {isFocused ? ( - - {cursorChar} - - ) : ( - cursorChar - )} - {after} + + + {status === "saving" ? "Saving..." : "Enter: Save | Tab: Switch | Esc: Cancel"} diff --git a/src/tui/components/Sidebar.tsx b/src/tui/components/Sidebar.tsx index ac581fa..cb78a34 100644 --- a/src/tui/components/Sidebar.tsx +++ b/src/tui/components/Sidebar.tsx @@ -1,8 +1,10 @@ import { useAgentContext } from "@/tui/context/AgentContext"; +import { useTheme } from "@/tui/context/ThemeContext"; export const Sidebar = () => { const { state } = useAgentContext(); const { totalCost, files, phase } = state; + const { theme } = useTheme(); return ( { height: "100%", border: true, borderStyle: "rounded", - borderColor: "gray", + borderColor: theme.borderColor, flexDirection: "column", padding: 1, marginLeft: 1, @@ -19,25 +21,27 @@ export const Sidebar = () => { title="Context" > - STATUS + STATUS - {phase.toUpperCase()} + + {phase.toUpperCase()} + - COST - ${totalCost.toFixed(4)} + COST + ${totalCost.toFixed(4)} {files.length > 0 && ( - + EDITED FILES {files.map((path) => ( - + • {path} ))} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index 6ea94f4..459b9b1 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -1,6 +1,7 @@ import { useKeyboard } from "@opentui/react"; import { useAgentContext } from "@/tui/context/AgentContext"; import { useConfigContext } from "@/tui/context/ConfigContext"; +import { useTheme } from "@/tui/context/ThemeContext"; import { Keymap } from "@/tui/keyboard/keymap"; import { areKeyBindingsEqual } from "../keyboard/utils"; @@ -12,6 +13,7 @@ export interface StatusBarProps { export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { const { config } = useConfigContext(); const { state, retry } = useAgentContext(); + const { theme } = useTheme(); useKeyboard((keyEvent) => { if (disabled) return; @@ -33,6 +35,7 @@ export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { justifyContent: "space-between", alignItems: "center", minHeight: 3, + borderColor: theme.borderColor, }} > @@ -42,7 +45,9 @@ export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { W: {writer} | R: {reviewer} - {isRunning ? "Status: Running" : "Status: Ready"} + + {isRunning ? "Status: Running" : "Status: Ready"} + ); }; diff --git a/src/tui/components/TaskInput.tsx b/src/tui/components/TaskInput.tsx index 78880e5..5ee1276 100644 --- a/src/tui/components/TaskInput.tsx +++ b/src/tui/components/TaskInput.tsx @@ -2,6 +2,7 @@ import { useKeyboard } from "@opentui/react"; import { Effect } from "effect"; import { readFromClipboard } from "@/services/clipboard"; import { useEffectRuntime } from "@/tui/context/EffectContext"; +import { useTheme } from "@/tui/context/ThemeContext"; import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; import { Keymap } from "@/tui/keyboard/keymap"; @@ -14,6 +15,7 @@ export interface TaskInputProps { export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) => { const runtime = useEffectRuntime(); const buffer = useTextBuffer(""); + const { theme } = useTheme(); useKeyboard((key) => { if (!focused || isRunning) return; @@ -68,7 +70,9 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) {before} {focused && !isRunning ? ( - {cursorChar === "\n" ? " " : cursorChar} + + {cursorChar === "\n" ? " " : cursorChar} + ) : ( cursorChar )} @@ -83,14 +87,14 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) style={{ width: "100%", border: true, - borderColor: focused ? "cyan" : "gray", + borderColor: focused ? theme.primaryColor : theme.borderColor, flexDirection: "column", minHeight: 4, }} > {!buffer.text && !focused ? ( - + Enter your writing task here... ({Keymap.TaskInput.NewLine.label} for newline) ) : ( @@ -99,7 +103,7 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) - + {isRunning ? "Running agent..." : `${Keymap.TaskInput.Submit.label}: Submit | ${Keymap.TaskInput.NewLine.label}: New Line | ${Keymap.Global.Cancel.label}: Cancel`} diff --git a/src/tui/components/Timeline.tsx b/src/tui/components/Timeline.tsx index b35de0a..2f1fc7c 100644 --- a/src/tui/components/Timeline.tsx +++ b/src/tui/components/Timeline.tsx @@ -1,4 +1,5 @@ import { useAgentContext } from "@/tui/context/AgentContext"; +import { useTheme } from "@/tui/context/ThemeContext"; import { TimelineItem } from "./timeline/TimelineItem"; interface TimelineProps { @@ -10,6 +11,7 @@ interface TimelineProps { export const Timeline = ({ focused, onApprove, onReject }: TimelineProps) => { const { state } = useAgentContext(); const { timeline: entries, streamBuffer, streamPhase } = state; + const { theme } = useTheme(); return ( { scrollbarOptions: { showArrows: true, trackOptions: { - foregroundColor: "#7aa2f7", - backgroundColor: "#414868", + foregroundColor: theme.primaryColor, + backgroundColor: theme.diff.lineNumberBg, }, }, contentOptions: { @@ -29,6 +31,7 @@ export const Timeline = ({ focused, onApprove, onReject }: TimelineProps) => { }, flexGrow: 1, border: true, + borderColor: focused ? theme.primaryColor : theme.borderColor, }} focused={focused} title="Jot CLI - AI Research Assistant" @@ -45,9 +48,16 @@ export const Timeline = ({ focused, onApprove, onReject }: TimelineProps) => { ))} {streamPhase && ( - - {streamPhase === "drafting" ? "Drafting..." : "Processing..."} - {streamBuffer} + + {streamPhase === "drafting" ? "Drafting..." : "Processing..."} + {streamBuffer} )} diff --git a/src/tui/context/ThemeContext.tsx b/src/tui/context/ThemeContext.tsx new file mode 100644 index 0000000..ed90703 --- /dev/null +++ b/src/tui/context/ThemeContext.tsx @@ -0,0 +1,29 @@ +import { type Theme, themes } from "@/tui/theme"; +import { createContext, type ReactNode, useContext, useState } from "react"; + +interface ThemeContextType { + theme: Theme; + nextTheme: () => void; + setTheme: (index: number) => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [themeIndex, setThemeIndex] = useState(0); + + const theme = themes[themeIndex % themes.length] ?? themes[0]; + + const nextTheme = () => setThemeIndex((prev) => (prev + 1) % themes.length); + const setTheme = (index: number) => setThemeIndex(index); + + return {children}; +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/tui/keyboard/keymap.ts b/src/tui/keyboard/keymap.ts index d3727e5..edc9e07 100644 --- a/src/tui/keyboard/keymap.ts +++ b/src/tui/keyboard/keymap.ts @@ -15,7 +15,7 @@ export interface KeyBinding { description?: string; } -export type KeyMap = Record>; +export type KeyMap = Readonly>>; export const Keymap = { Global: { diff --git a/src/tui/theme/index.ts b/src/tui/theme/index.ts new file mode 100644 index 0000000..51f8e1f --- /dev/null +++ b/src/tui/theme/index.ts @@ -0,0 +1,172 @@ +import { parseColor, type SyntaxStyle } from "@opentui/core"; + +export interface Theme { + name: string; + + primaryColor: string; + secondaryColor: string; + backgroundColor: string; + borderColor: string; + + textColor: string; + mutedColor: string; + + successColor: string; + errorColor: string; + warningColor: string; + + diff: { + addedBg: string; + removedBg: string; + contextBg: string; + addedSignColor: string; + removedSignColor: string; + lineNumberFg: string; + lineNumberBg: string; + addedLineNumberBg: string; + removedLineNumberBg: string; + selectionBg: string; + selectionFg: string; + syntaxStyle: Parameters[0]; + }; +} + +export type DiffTheme = Theme["diff"]; + +export const themes: [Theme, ...Theme[]] = [ + { + name: "GitHub Dark", + primaryColor: "cyan", + secondaryColor: "magenta", + backgroundColor: "#0D1117", + borderColor: "gray", + textColor: "#E6EDF3", + mutedColor: "gray", + successColor: "green", + errorColor: "red", + warningColor: "yellow", + + diff: { + addedBg: "#1a4d1a", + removedBg: "#4d1a1a", + contextBg: "transparent", + addedSignColor: "#22c55e", + removedSignColor: "#ef4444", + lineNumberFg: "#6b7280", + lineNumberBg: "#161b22", + addedLineNumberBg: "#0d3a0d", + removedLineNumberBg: "#3a0d0d", + selectionBg: "#264F78", + selectionFg: "#FFFFFF", + syntaxStyle: { + keyword: { fg: parseColor("#FF7B72"), bold: true }, + "keyword.import": { fg: parseColor("#FF7B72"), bold: true }, + string: { fg: parseColor("#A5D6FF") }, + comment: { fg: parseColor("#8B949E"), italic: true }, + number: { fg: parseColor("#79C0FF") }, + boolean: { fg: parseColor("#79C0FF") }, + constant: { fg: parseColor("#79C0FF") }, + function: { fg: parseColor("#D2A8FF") }, + "function.call": { fg: parseColor("#D2A8FF") }, + constructor: { fg: parseColor("#FFA657") }, + type: { fg: parseColor("#FFA657") }, + operator: { fg: parseColor("#FF7B72") }, + variable: { fg: parseColor("#E6EDF3") }, + property: { fg: parseColor("#79C0FF") }, + bracket: { fg: parseColor("#F0F6FC") }, + punctuation: { fg: parseColor("#F0F6FC") }, + default: { fg: parseColor("#E6EDF3") }, + }, + }, + }, + { + name: "Monokai", + primaryColor: "#FD971F", + secondaryColor: "#AE81FF", + backgroundColor: "#272822", + borderColor: "#75715E", + textColor: "#F8F8F2", + mutedColor: "#75715E", + successColor: "#A6E22E", + errorColor: "#F92672", + warningColor: "#FD971F", + + diff: { + addedBg: "#2d4a2b", + removedBg: "#4a2b2b", + contextBg: "transparent", + addedSignColor: "#A6E22E", + removedSignColor: "#F92672", + lineNumberFg: "#75715E", + lineNumberBg: "#1e1f1c", + addedLineNumberBg: "#1e3a1e", + removedLineNumberBg: "#3a1e1e", + selectionBg: "#49483E", + selectionFg: "#F8F8F2", + syntaxStyle: { + keyword: { fg: parseColor("#F92672"), bold: true }, + "keyword.import": { fg: parseColor("#F92672"), bold: true }, + string: { fg: parseColor("#E6DB74") }, + comment: { fg: parseColor("#75715E"), italic: true }, + number: { fg: parseColor("#AE81FF") }, + boolean: { fg: parseColor("#AE81FF") }, + constant: { fg: parseColor("#AE81FF") }, + function: { fg: parseColor("#A6E22E") }, + "function.call": { fg: parseColor("#A6E22E") }, + constructor: { fg: parseColor("#FD971F") }, + type: { fg: parseColor("#66D9EF") }, + operator: { fg: parseColor("#F92672") }, + variable: { fg: parseColor("#F8F8F2") }, + property: { fg: parseColor("#66D9EF") }, + bracket: { fg: parseColor("#F8F8F2") }, + punctuation: { fg: parseColor("#F8F8F2") }, + default: { fg: parseColor("#F8F8F2") }, + }, + }, + }, + { + name: "Dracula", + primaryColor: "#BD93F9", + secondaryColor: "#FF79C6", + backgroundColor: "#282A36", + borderColor: "#6272A4", + textColor: "#F8F8F2", + mutedColor: "#6272A4", + successColor: "#50FA7B", + errorColor: "#FF5555", + warningColor: "#FFB86C", + + diff: { + addedBg: "#2d4737", + removedBg: "#4d2d37", + contextBg: "transparent", + addedSignColor: "#50FA7B", + removedSignColor: "#FF5555", + lineNumberFg: "#6272A4", + lineNumberBg: "#21222C", + addedLineNumberBg: "#1f3626", + removedLineNumberBg: "#3a2328", + selectionBg: "#44475A", + selectionFg: "#F8F8F2", + syntaxStyle: { + keyword: { fg: parseColor("#FF79C6"), bold: true }, + "keyword.import": { fg: parseColor("#FF79C6"), bold: true }, + string: { fg: parseColor("#F1FA8C") }, + comment: { fg: parseColor("#6272A4"), italic: true }, + number: { fg: parseColor("#BD93F9") }, + boolean: { fg: parseColor("#BD93F9") }, + constant: { fg: parseColor("#BD93F9") }, + function: { fg: parseColor("#50FA7B") }, + "function.call": { fg: parseColor("#50FA7B") }, + constructor: { fg: parseColor("#FFB86C") }, + type: { fg: parseColor("#8BE9FD") }, + operator: { fg: parseColor("#FF79C6") }, + variable: { fg: parseColor("#F8F8F2") }, + property: { fg: parseColor("#8BE9FD") }, + bracket: { fg: parseColor("#F8F8F2") }, + punctuation: { fg: parseColor("#F8F8F2") }, + default: { fg: parseColor("#F8F8F2") }, + }, + }, + }, +]; From 9be18e55c57b10ecf50f6b33182f08e996d25dcb Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 16 Jan 2026 03:08:13 +0100 Subject: [PATCH 30/40] fix: remove theme from header --- src/tui/components/DiffReviewModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/components/DiffReviewModal.tsx b/src/tui/components/DiffReviewModal.tsx index a40aad0..69073b2 100644 --- a/src/tui/components/DiffReviewModal.tsx +++ b/src/tui/components/DiffReviewModal.tsx @@ -98,7 +98,7 @@ export function DiffReviewModal({ )} Date: Fri, 16 Jan 2026 03:08:35 +0100 Subject: [PATCH 31/40] feat: improve task input experience --- src/tui/components/TaskInput.tsx | 152 ++++++++++++++----------------- src/tui/context/ThemeContext.tsx | 2 +- src/tui/hooks/useTextBuffer.ts | 152 ------------------------------- 3 files changed, 70 insertions(+), 236 deletions(-) delete mode 100644 src/tui/hooks/useTextBuffer.ts diff --git a/src/tui/components/TaskInput.tsx b/src/tui/components/TaskInput.tsx index 5ee1276..b1d68f3 100644 --- a/src/tui/components/TaskInput.tsx +++ b/src/tui/components/TaskInput.tsx @@ -1,10 +1,7 @@ -import { useKeyboard } from "@opentui/react"; -import { Effect } from "effect"; -import { readFromClipboard } from "@/services/clipboard"; -import { useEffectRuntime } from "@/tui/context/EffectContext"; import { useTheme } from "@/tui/context/ThemeContext"; -import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; import { Keymap } from "@/tui/keyboard/keymap"; +import type { KeyBinding, PasteEvent, TextareaRenderable } from "@opentui/core"; +import { useEffect, useRef } from "react"; export interface TaskInputProps { onTaskSubmit: (task: string) => void; @@ -12,74 +9,60 @@ export interface TaskInputProps { focused: boolean; } +const keyBindings: KeyBinding[] = [ + { + name: Keymap.TaskInput.Submit.name, + ctrl: false, + action: "submit", + }, + { + name: Keymap.TaskInput.NewLine.name, + ctrl: true, + action: "newline", + }, + { + name: "backspace", + ctrl: true, + action: "delete-word-backward", + }, + { + name: "left", + ctrl: true, + action: "word-backward", + }, + { + name: "right", + ctrl: true, + action: "word-forward", + }, +]; + export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) => { - const runtime = useEffectRuntime(); - const buffer = useTextBuffer(""); const { theme } = useTheme(); + const inputRef = useRef(null); - useKeyboard((key) => { - if (!focused || isRunning) return; + useEffect(() => { + if (focused && inputRef.current) { + inputRef.current.focus(); + } + }, [focused]); - if (key.name === Keymap.TaskInput.Submit.name) { - if (key.ctrl || key.meta) { - buffer.insert("\n"); - } else { - if (buffer.text.trim()) { - onTaskSubmit(buffer.text); - buffer.clear(); - } - } - } else if ((key.ctrl || key.meta) && key.name === Keymap.TaskInput.Paste.name) { - runtime - .runPromise( - readFromClipboard().pipe( - Effect.tapError(Effect.logError), - Effect.catchAll(() => Effect.succeed("")), - ), - ) - .then((text) => buffer.insert(text)); - } else if (key.name === "backspace") { - buffer.deleteBack(); - } else if (key.name === "left") { - buffer.moveLeft(); - } else if (key.name === "right") { - buffer.moveRight(); - } else if (key.name === "up") { - buffer.moveUp(); - } else if (key.name === "down") { - buffer.moveDown(); - } else if (key.name === "space") { - buffer.insert(" "); - } else if (key.name?.length === 1 || (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta)) { - const char = key.sequence && key.sequence.length === 1 ? key.sequence : key.name; - if (char && char.length === 1) { - buffer.insert(char); + const handleSubmit = () => { + const text = inputRef.current?.plainText || ""; + if (text?.trim()) { + onTaskSubmit(text); + if (inputRef.current) { + inputRef.current.clear(); + inputRef.current.focus(); } } - }); - - const renderContent = () => { - const text = buffer.text; - const cursor = buffer.cursor; + }; - const before = text.slice(0, cursor); - const cursorChar = text[cursor] || " "; - const after = text.slice(cursor + 1); + const handlePaste = (event: PasteEvent) => { + if (!focused) return event.preventDefault(); - return ( - - {before} - {focused && !isRunning ? ( - - {cursorChar === "\n" ? " " : cursorChar} - - ) : ( - cursorChar - )} - {cursorChar === "\n" ? "\n" : ""} - {after} - - ); + const text = event.text.trim(); + inputRef.current?.insertText(text); }; return ( @@ -89,26 +72,29 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) border: true, borderColor: focused ? theme.primaryColor : theme.borderColor, flexDirection: "column", - minHeight: 4, + paddingBottom: 1, }} > - - {!buffer.text && !focused ? ( - - Enter your writing task here... ({Keymap.TaskInput.NewLine.label} for newline) - - ) : ( - renderContent() - )} - - - - - {isRunning - ? "Running agent..." - : `${Keymap.TaskInput.Submit.label}: Submit | ${Keymap.TaskInput.NewLine.label}: New Line | ${Keymap.Global.Cancel.label}: Cancel`} - - +