Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3823fd6
feat: agent rework
Baz00k Jan 13, 2026
fe12b17
chore: remove writer agent remnants
Baz00k Jan 13, 2026
d35bf9f
fix: add missing tools
Baz00k Jan 13, 2026
6f16f0c
fix: add explicit overwrite flag
Baz00k Jan 13, 2026
aeeaeab
fix: improve agents prompts for new wokflow
Baz00k Jan 13, 2026
b6e3dba
refactor: yield error directly
Baz00k Jan 13, 2026
b48c34e
chore: improve write file tools descriptions
Baz00k Jan 13, 2026
6cfabe7
feat: preserve writer context across revisions to avoid re-reading files
Baz00k Jan 13, 2026
ed40986
feat: sidebar with current status
Baz00k Jan 13, 2026
d71d2ec
feat: improved visual and reliability of user feedback widget
Baz00k Jan 13, 2026
96b69a3
feat: improve UI
Baz00k Jan 13, 2026
6aff64c
refactor: use global agent state for hook
Baz00k Jan 14, 2026
9ddaf98
feat: manual session restart on error
Baz00k Jan 14, 2026
af708f3
fix: reject binary content and truncate long responses from web fetch
Baz00k Jan 14, 2026
a28b7b9
feat: add support for reading PDF files
Baz00k Jan 14, 2026
c1edb2f
fix: launch tui correctly with root level command if arguments provided
Baz00k Jan 14, 2026
12ca9f1
fix: include current date in writer and reviewer prompts
Baz00k Jan 14, 2026
ca8c435
fix: ensure vfs state is preserved when resuming session
Baz00k Jan 14, 2026
25e44ad
chore: add more tests to prompts service
Baz00k Jan 14, 2026
ac993be
chore: add tui keymap
Baz00k Jan 15, 2026
aa9e70b
feat: add diff view to user feedback
Baz00k Jan 15, 2026
d3d502b
fix: agent unable to write new files
Baz00k Jan 15, 2026
f3186f0
fix: diff view not displaying properly
Baz00k Jan 15, 2026
08ae816
chore: clean up write command after move to VFS
Baz00k Jan 15, 2026
7fc728c
fix: improve context passed between agents and workflow description
Baz00k Jan 15, 2026
ee85556
chore(deps): update deps
Baz00k Jan 15, 2026
41efd99
fix: ai generating empty response is now a valid case
Baz00k Jan 16, 2026
115cee0
refactor: use effect fn for readability
Baz00k Jan 16, 2026
b258fcd
feat: UI total revamp
Baz00k Jan 16, 2026
9be18e5
fix: remove theme from header
Baz00k Jan 16, 2026
d77dbed
feat: improve task input experience
Baz00k Jan 16, 2026
de4379e
fix: DiffReviewModal not showing content, display file headers
Baz00k Jan 16, 2026
7bf1cd4
fix: input overflow and sizing issues
Baz00k Jan 16, 2026
bc5d7b7
chore: improve color palettes
Baz00k Jan 16, 2026
0612253
fix: input label
Baz00k Jan 16, 2026
50e8db4
feat: unified user feedback modal
Baz00k Jan 16, 2026
ad670d8
refactor: use effect fn
Baz00k Jan 17, 2026
a4fa692
style: autoformat
Baz00k Jan 17, 2026
62b7458
refactor: address review comment about hook architecture
Baz00k Jan 17, 2026
7c21a85
chore: add test
Baz00k Jan 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 40 additions & 43 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
"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",
"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",
Expand All @@ -46,6 +48,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"
Expand Down
72 changes: 3 additions & 69 deletions src/commands/utils/workflow-errors.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
import { log, note } from "@clack/prompts";
import { Effect, Match, Option } from "effect";
import { log } from "@clack/prompts";
import { Effect, Match } 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 totalCost: number;
}

/**
* Result of handling a workflow error.
*/
export type ErrorHandlingResult =
| { readonly _tag: "Rethrow"; readonly error: UserCancel }
| { readonly _tag: "Handled"; readonly savedPath: Option.Option<string> };

/**
* Displays a user-friendly error message based on the error type.
Expand Down Expand Up @@ -46,58 +29,9 @@ export const displayError = (error: unknown): Effect.Effect<void> =>
);
});

/**
* Displays the last draft if available.
*/
export const displayLastDraft = (snapshot: WorkflowSnapshot): Effect.Effect<Option.Option<string>> =>
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."));
return Option.none();
});

/**
* Displays summary information after saving a draft.
*/
export const displaySaveSuccess = (savedPath: string, snapshot: WorkflowSnapshot): Effect.Effect<void> =>
Effect.sync(() => {
log.success(`Draft saved to: ${savedPath}`);
if (snapshot.workflowState.iterationCount > 0) {
log.info(`Completed ${snapshot.workflowState.iterationCount} 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<string> } => result._tag === "Handled";

/**
* Creates a "handled" result.
*/
export const handled = (savedPath: Option.Option<string>): ErrorHandlingResult => ({
_tag: "Handled",
savedPath,
});

/**
* Creates a "rethrow" result.
*/
export const rethrow = (error: UserCancel): ErrorHandlingResult => ({
_tag: "Rethrow",
error,
});
export class WorkflowErrorHandled {}
147 changes: 64 additions & 83 deletions src/commands/write.ts
Original file line number Diff line number Diff line change
@@ -1,20 +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 { Effect, Fiber, Option, Stream } from "effect";
import {
displayError,
displayLastDraft,
displaySaveSuccess,
shouldRethrow,
type WorkflowSnapshot,
} from "@/commands/utils/workflow-errors";
import { Chunk, Effect, Fiber, Option, Stream } from "effect";
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 = <T>(promptFn: () => Promise<T | symbol>) =>
Expand Down Expand Up @@ -43,31 +37,48 @@ const runPrompt = <T>(promptFn: () => Promise<T | symbol>) =>
),
);

const formatDiffs = (diffs: ReadonlyArray<FilePatch>): 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<UserAction, UserCancel | Error> =>
const getUserFeedback = (
diffs: ReadonlyArray<FilePatch>,
cycle: number,
): Effect.Effect<UserAction, UserCancel | Error> =>
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" },
Expand All @@ -79,7 +90,7 @@ const getUserFeedback = (draft: string, cycle: number): Effect.Effect<UserAction
const comment = yield* runPrompt(() =>
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 };
Expand All @@ -101,56 +112,14 @@ const getUserFeedback = (draft: string, cycle: number): Effect.Effect<UserAction
});

/**
* 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<string | undefined, Error | UserCancel, FileSystem.FileSystem | Path.Path> =>
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<WorkflowSnapshot>,
s: ReturnType<typeof spinner>,
): Effect.Effect<RunResult, UserCancel | WorkflowErrorHandled, FileSystem.FileSystem | Path.Path> =>
vfs: VFS,
): Effect.Effect<RunResult, UserCancel | WorkflowErrorHandled> =>
Effect.gen(function* () {
yield* Effect.sync(() => s.stop());

Expand All @@ -159,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({});
});
Expand Down Expand Up @@ -243,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..."));

Expand Down Expand Up @@ -298,7 +281,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);

Expand All @@ -317,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);
Expand Down
3 changes: 3 additions & 0 deletions src/domain/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions src/domain/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}> {}
Expand Down
Loading