Add BRAIN_DISABLED flag and fix Ollama tool call formatting

Features:
  - Add BRAIN_DISABLED feature flag to hide all Brain functionality
  - When enabled, hides Brain banner, status indicator, menu, and commands
  - Flag location: src/constants/brain.ts

  Fixes:
  - Fix Ollama 400 error by properly formatting tool_calls in messages
  - Update OllamaMessage type to include tool_calls field
  - Fix Brain menu keyboard not working (add missing modes to isMenuOpen)

  UI Changes:
  - Remove "^Tab toggle mode" hint from status bar
  - Remove "ctrl+t to hide todos" hint from status bar

  Files modified:
  - src/constants/brain.ts (add BRAIN_DISABLED flag)
  - src/types/ollama.ts (add tool_calls to OllamaMessage)
  - src/providers/ollama/chat.ts (format tool_calls in messages)
  - src/tui-solid/components/header.tsx (hide Brain UI when disabled)
  - src/tui-solid/components/status-bar.tsx (remove hints)
  - src/tui-solid/components/command-menu.tsx (filter brain command)
  - src/tui-solid/components/input-area.tsx (fix isMenuOpen modes)
  - src/tui-solid/routes/session.tsx (skip brain menu when disabled)
  - src/services/brain.ts (early return when disabled)
  - src/services/chat-tui/initialize.ts (skip brain init when disabled)
This commit is contained in:
2026-02-02 13:25:38 -05:00
parent 2eadda584a
commit c839fc4d68
114 changed files with 17243 additions and 273 deletions

View File

@@ -0,0 +1,343 @@
/**
* MultiEdit Tool Execution
*
* Performs batch file editing with atomic transactions
*/
import fs from "fs/promises";
import path from "path";
import {
MULTI_EDIT_DEFAULTS,
MULTI_EDIT_MESSAGES,
MULTI_EDIT_TITLES,
MULTI_EDIT_DESCRIPTION,
} from "@constants/multi-edit";
import { isFileOpAllowed, promptFilePermission } from "@services/permissions";
import { formatDiff, generateDiff } from "@utils/diff";
import { multiEditParams } from "@tools/multi-edit/params";
import type { ToolDefinition, ToolContext, ToolResult } from "@/types/tools";
import type { EditItem, MultiEditParams } from "@tools/multi-edit/params";
interface FileBackup {
path: string;
content: string;
}
interface EditValidation {
valid: boolean;
error?: string;
fileContent?: string;
}
interface EditResult {
path: string;
success: boolean;
diff?: string;
additions?: number;
deletions?: number;
error?: string;
}
const createErrorResult = (error: string): ToolResult => ({
success: false,
title: MULTI_EDIT_TITLES.FAILED,
output: "",
error,
});
const createSuccessResult = (
results: EditResult[],
totalEdits: number,
): ToolResult => {
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
const diffOutput = successful
.map((r) => `## ${path.basename(r.path)}\n\n${r.diff}`)
.join("\n\n---\n\n");
const totalAdditions = successful.reduce((sum, r) => sum + (r.additions ?? 0), 0);
const totalDeletions = successful.reduce((sum, r) => sum + (r.deletions ?? 0), 0);
const title =
failed.length > 0
? MULTI_EDIT_TITLES.PARTIAL(successful.length, failed.length)
: MULTI_EDIT_TITLES.SUCCESS(successful.length);
let output = diffOutput;
if (failed.length > 0) {
output +=
"\n\n## Failed Edits\n\n" +
failed.map((r) => `- ${r.path}: ${r.error}`).join("\n");
}
return {
success: failed.length === 0,
title,
output,
metadata: {
totalEdits,
successful: successful.length,
failed: failed.length,
totalAdditions,
totalDeletions,
},
};
};
/**
* Validate a single edit
*/
const validateEdit = async (
edit: EditItem,
workingDir: string,
): Promise<EditValidation> => {
const fullPath = path.isAbsolute(edit.file_path)
? edit.file_path
: path.join(workingDir, edit.file_path);
try {
const stat = await fs.stat(fullPath);
if (!stat.isFile()) {
return { valid: false, error: `Not a file: ${edit.file_path}` };
}
if (stat.size > MULTI_EDIT_DEFAULTS.MAX_FILE_SIZE) {
return { valid: false, error: MULTI_EDIT_MESSAGES.FILE_TOO_LARGE(edit.file_path) };
}
const content = await fs.readFile(fullPath, "utf-8");
// Check if old_string exists
if (!content.includes(edit.old_string)) {
const preview = edit.old_string.slice(0, 50);
return {
valid: false,
error: MULTI_EDIT_MESSAGES.OLD_STRING_NOT_FOUND(edit.file_path, preview),
};
}
// Check uniqueness
const occurrences = content.split(edit.old_string).length - 1;
if (occurrences > 1) {
return {
valid: false,
error: MULTI_EDIT_MESSAGES.OLD_STRING_NOT_UNIQUE(edit.file_path, occurrences),
};
}
return { valid: true, fileContent: content };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { valid: false, error: MULTI_EDIT_MESSAGES.FILE_NOT_FOUND(edit.file_path) };
}
const message = error instanceof Error ? error.message : String(error);
return { valid: false, error: message };
}
};
/**
* Check permissions for all files
*/
const checkPermissions = async (
edits: EditItem[],
workingDir: string,
autoApprove: boolean,
): Promise<{ allowed: boolean; denied: string[] }> => {
const denied: string[] = [];
for (const edit of edits) {
const fullPath = path.isAbsolute(edit.file_path)
? edit.file_path
: path.join(workingDir, edit.file_path);
if (!autoApprove && !isFileOpAllowed("Edit", fullPath)) {
const { allowed } = await promptFilePermission(
"Edit",
fullPath,
`Edit file: ${edit.file_path}`,
);
if (!allowed) {
denied.push(edit.file_path);
}
}
}
return { allowed: denied.length === 0, denied };
};
/**
* Apply a single edit
*/
const applyEdit = async (
edit: EditItem,
workingDir: string,
fileContent: string,
): Promise<EditResult> => {
const fullPath = path.isAbsolute(edit.file_path)
? edit.file_path
: path.join(workingDir, edit.file_path);
try {
const newContent = fileContent.replace(edit.old_string, edit.new_string);
const diff = generateDiff(fileContent, newContent);
const relativePath = path.relative(workingDir, fullPath);
const diffOutput = formatDiff(diff, relativePath);
await fs.writeFile(fullPath, newContent, "utf-8");
return {
path: edit.file_path,
success: true,
diff: diffOutput,
additions: diff.additions,
deletions: diff.deletions,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
path: edit.file_path,
success: false,
error: message,
};
}
};
/**
* Rollback changes using backups
*/
const rollback = async (backups: FileBackup[]): Promise<void> => {
for (const backup of backups) {
try {
await fs.writeFile(backup.path, backup.content, "utf-8");
} catch {
// Best effort rollback
}
}
};
/**
* Execute multi-edit
*/
export const executeMultiEdit = async (
args: MultiEditParams,
ctx: ToolContext,
): Promise<ToolResult> => {
const { edits } = args;
// Validate edit count
if (edits.length === 0) {
return createErrorResult(MULTI_EDIT_MESSAGES.NO_EDITS);
}
if (edits.length > MULTI_EDIT_DEFAULTS.MAX_EDITS) {
return createErrorResult(
MULTI_EDIT_MESSAGES.TOO_MANY_EDITS(MULTI_EDIT_DEFAULTS.MAX_EDITS),
);
}
ctx.onMetadata?.({
title: MULTI_EDIT_TITLES.VALIDATING(edits.length),
status: "running",
});
// Phase 1: Validate all edits
const validations = new Map<string, { validation: EditValidation; edit: EditItem }>();
const errors: string[] = [];
for (const edit of edits) {
const validation = await validateEdit(edit, ctx.workingDir);
validations.set(edit.file_path, { validation, edit });
if (!validation.valid) {
errors.push(validation.error ?? "Unknown error");
}
}
if (errors.length > 0) {
return createErrorResult(
MULTI_EDIT_MESSAGES.VALIDATION_FAILED + ":\n" + errors.join("\n"),
);
}
// Phase 2: Check permissions
const permCheck = await checkPermissions(
edits,
ctx.workingDir,
ctx.autoApprove ?? false,
);
if (!permCheck.allowed) {
return createErrorResult(
`Permission denied for: ${permCheck.denied.join(", ")}`,
);
}
// Phase 3: Create backups and apply edits atomically
const backups: FileBackup[] = [];
const results: EditResult[] = [];
let failed = false;
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
const data = validations.get(edit.file_path);
if (!data?.validation.fileContent) continue;
ctx.onMetadata?.({
title: MULTI_EDIT_TITLES.APPLYING(i + 1, edits.length),
status: "running",
});
const fullPath = path.isAbsolute(edit.file_path)
? edit.file_path
: path.join(ctx.workingDir, edit.file_path);
// Create backup
backups.push({
path: fullPath,
content: data.validation.fileContent,
});
// Apply edit
const result = await applyEdit(edit, ctx.workingDir, data.validation.fileContent);
results.push(result);
if (!result.success) {
failed = true;
break;
}
// Update file content for subsequent edits to same file
if (result.success) {
const newContent = data.validation.fileContent.replace(
edit.old_string,
edit.new_string,
);
// Update the validation cache for potential subsequent edits to same file
validations.set(edit.file_path, {
...data,
validation: { ...data.validation, fileContent: newContent },
});
}
}
// Phase 4: Rollback if any edit failed
if (failed) {
ctx.onMetadata?.({
title: MULTI_EDIT_TITLES.ROLLBACK,
status: "running",
});
await rollback(backups);
return createErrorResult(MULTI_EDIT_MESSAGES.ATOMIC_FAILURE);
}
return createSuccessResult(results, edits.length);
};
export const multiEditTool: ToolDefinition<typeof multiEditParams> = {
name: "multi_edit",
description: MULTI_EDIT_DESCRIPTION,
parameters: multiEditParams,
execute: executeMultiEdit,
};

View File

@@ -0,0 +1,13 @@
/**
* MultiEdit Tool
*
* Batch file editing with atomic transactions
*/
export { multiEditTool, executeMultiEdit } from "@tools/multi-edit/execute";
export {
multiEditParams,
editItemSchema,
type EditItem,
type MultiEditParams,
} from "@tools/multi-edit/params";

View File

@@ -0,0 +1,21 @@
/**
* MultiEdit Tool Parameters
*/
import { z } from "zod";
export const editItemSchema = z.object({
file_path: z.string().describe("Absolute path to the file to edit"),
old_string: z.string().describe("The exact text to find and replace"),
new_string: z.string().describe("The replacement text"),
});
export const multiEditParams = z.object({
edits: z
.array(editItemSchema)
.min(1)
.describe("Array of edits to apply atomically"),
});
export type EditItem = z.infer<typeof editItemSchema>;
export type MultiEditParams = z.infer<typeof multiEditParams>;