Files
oh-my-pi/AGENTS.md
T
can1357 4eef01bdaa fix: fixed tab character display holes in UI by sanitizing output
- Fixed tab character display in error messages and bash tool output by applying replaceTabs() sanitization.
- Added TUI rendering sanitization documentation to AGENTS.md with guidelines for sanitizing text in tool renderers.
- Applied replaceTabs() function to error text in patch shared module to ensure proper tab-to-space conversion.
- Applied replaceTabs() function to bash tool output lines in both expanded and collapsed display modes.
2026-02-06 07:38:46 +01:00

17 KiB

Development Rules

Default Context

This repo contains multiple packages, but packages/coding-agent/ is the primary focus. Unless otherwise specified, assume work refers to this package.

Terminology: When the user says "agent" or asks "why is agent doing X", they mean the coding-agent package implementation, not you (the assistant). The coding-agent is a CLI tool that uses Claude—questions about its behavior refer to the code in packages/coding-agent/, not your current session.

Package Structure

Package Description
packages/ai Multi-provider LLM client with streaming support
packages/agent Agent runtime with tool calling and state management
packages/coding-agent Main CLI application (primary focus)
packages/tui Terminal UI library with differential rendering
packages/natives WASM bindings for native text/image/grep operations
packages/stats Local observability dashboard (omp stats)
packages/utils Shared utilities (logger, streams, temp files)
crates/pi-natives Rust WASM crate for performance-critical text/grep ops

Code Quality

  • No any types unless absolutely necessary

  • Check node_modules for external API type definitions instead of guessing

  • NEVER use inline imports - no await import("./foo.js"), no import("pkg").Type in type positions, no dynamic imports for types. Always use standard top-level imports.

  • NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead

  • Always ask before removing functionality or code that appears to be intentional

  • NEVER build prompts in code — no inline strings, no template literals, no string concatenation. Prompts live in static .md files; use Handlebars for any dynamic content.

  • Import static text files via Bun — use import content from "./prompt.md" with { type: "text" } instead of readFileSync

  • Use Promise.withResolvers() instead of new Promise((resolve, reject) => ...) — cleaner, avoids callback nesting, and the resolver functions are properly typed:

    // BAD: Verbose, callback nesting
    const promise = new Promise<string>((resolve, reject) => { ... });
    
    // GOOD: Clean destructuring, typed resolvers
    const { promise, resolve, reject } = Promise.withResolvers<string>();
    

WASM Bindgen Resources

Use using declarations for wasm-bindgen objects — they implement Symbol.dispose:

// BAD: Manual try/finally with .free()
const image = PhotonImage.new_from_byteslice(bytes);
try {
	return image.get_bytes();
} finally {
	image.free();
}

// GOOD: Automatic cleanup via Symbol.dispose
using image = PhotonImage.new_from_byteslice(bytes);
return image.get_bytes();

This applies to all wasm-bindgen generated classes (CompiledPattern, PhotonImage, etc.).

Bun Over Node

This project uses Bun. Use Bun APIs where they provide a cleaner alternative; use node:fs for operations Bun doesn't cover.

NEVER spawn shell commands for operations that have proper APIs (e.g., Bun.spawnSync(["mkdir", "-p", dir]) — use mkdirSync instead).

Process Execution

Prefer Bun Shell ($ template literals) for simple commands:

import { $ } from "bun";

// Capture output
const result = await $`git status`.cwd(dir).quiet().nothrow();
if (result.exitCode === 0) {
	const text = result.text();
}

// Fire and forget
$`do-stuff ${tmpFile}`.quiet().nothrow();

Use Bun.spawn/Bun.spawnSync only when:

  • Long-running processes (LSP servers, Python kernels)
  • Streaming stdin/stdout/stderr required (SSE, JSON-RPC)
  • Process control needed (signals, kill, complex lifecycle)

Bun Shell methods:

  • .quiet() - suppress output (stdout/stderr to null)
  • .nothrow() - don't throw on non-zero exit
  • .text() - get stdout as string
  • .cwd(path) - set working directory

Sleep

Prefer await Bun.sleep(ms)
Avoid new Promise((resolve) => setTimeout(resolve, ms))

Node Module Imports

NEVER use named imports from node:fs or node:path — always use namespace imports:

// BAD: Named imports
import { readdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";

// GOOD: Namespace imports
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";

// Then use: fs.readdir(), path.join(), etc.

Choosing between node:fs and node:fs/promises:

  • Async-only file → import * as fs from "node:fs/promises"
  • Needs both sync and async → import * as fs from "node:fs", use fs.promises.xxx for async
// File with only async operations
import * as fs from "node:fs/promises";
await fs.readdir(dir);
await fs.stat(path);

// File mixing sync and async (e.g., sync in constructor, async in methods)
import * as fs from "node:fs";
fs.existsSync(path); // sync
await fs.promises.readdir(dir); // async

File I/O

Prefer Bun file APIs:

// Read
const text = await Bun.file(path).text();
const data = await Bun.file(path).json();
const exists = await Bun.file(path).exists();

// Write
await Bun.write(path, data);

Bun.write() is smart — it auto-creates parent directories and uses optimal syscalls:

// BAD: Redundant mkdir before write
await mkdir(dirname(path), { recursive: true });
await Bun.write(path, data);

// GOOD: Bun.write handles it
await Bun.write(path, data); // Creates parent dirs automatically

Use node:fs/promises for directories (Bun has no native directory APIs):

import * as fs from "node:fs/promises";

await fs.mkdir(path, { recursive: true });
await fs.rm(path, { recursive: true, force: true });
const entries = await fs.readdir(path);

Avoid sync APIs in async flows:

  • Don't use existsSync/readFileSync/writeFileSync when async is possible
  • Use sync only when required by a synchronous interface

File I/O Anti-Patterns

NEVER check .exists() before reading — use try-catch with error code:

// BAD: Two syscalls, race condition
if (await Bun.file(path).exists()) {
	return await Bun.file(path).json();
}

// BAD: Even with handle reuse, still two syscalls
const file = Bun.file(path);
if (await file.exists()) {
	return await file.json();
}

// GOOD: One syscall, atomic, type-safe error handling
import { isEnoent } from "@anthropic/pi-utils";

try {
	return await Bun.file(path).json();
} catch (err) {
	if (isEnoent(err)) return null;
	throw err;
}

NEVER use Bun.file().exists() for directories — it doesn't distinguish files from dirs:

// BAD: Bun.file is for files, not directories
if (await Bun.file(dirPath).exists()) { ... }

// GOOD: Use fs.stat for directories
import * as fs from "node:fs/promises";

try {
	const s = await fs.stat(dirPath);
	if (s.isDirectory()) { ... }
} catch (err) {
	if (isEnoent(err)) { /* doesn't exist */ }
}

NEVER create multiple handles to the same path:

// BAD: Creates two file handles
if (await Bun.file(path).exists()) {
	const content = await Bun.file(path).text();
}

// BAD: Still wasteful even in separate functions
async function checkConfig() {
	return await Bun.file(configPath).exists();
}
async function loadConfig() {
	return await Bun.file(configPath).json(); // second handle
}

NEVER use Buffer.from(await Bun.file(x).arrayBuffer()) — just use readFile:

// BAD: Unnecessary conversion
const buffer = Buffer.from(await Bun.file(path).arrayBuffer());

// GOOD: Direct buffer read
import * as fs from "node:fs/promises";
const buffer = await fs.readFile(path);

NEVER mix redundant existence checks with try-catch:

// BAD: Existence check is pointless when you have try-catch
if (await file.exists()) {
	try {
		return await file.json();
	} catch {
		return null;
	}
}

// GOOD: Let try-catch handle missing files
try {
	return await Bun.file(path).json();
} catch (err) {
	if (isEnoent(err)) return null;
	throw err;
}

Streams

Prefer centralized helpers:

import { readStream, readLines } from "./utils/stream";

// Read entire stream
const text = await readStream(child.stdout);

// Line-by-line iteration
for await (const line of readLines(stream)) {
	// process line
}

Avoid manual reader loops unless protocol requires it (SSE, streaming JSON-RPC).

JSON5 Parsing

Use Bun.JSON5 — never add json5 as a dependency:

// BAD: External dependency
import JSON5 from "json5";
const data = JSON5.parse(text);

// GOOD: Bun builtin
const data = Bun.JSON5.parse(text);
const output = Bun.JSON5.stringify(obj);

JSONL Parsing

Use Bun.JSONL — never manually split and parse:

// BAD: Manual split + JSON.parse
const lines = text.split("\n").filter(Boolean);
const entries = lines.map((line) => JSON.parse(line));

// GOOD: Full blob parsing
const entries = Bun.JSONL.parse(text);

For streaming JSONL (SSE, JSON-RPC, subprocess output), use Bun.JSONL.parseChunk() | Bun.JSONL.parse() without decoding to string:

Terminal Width and Wrapping

Use Bun.stringWidth() for display width calculations:

// BAD: External dependency or custom implementation
import { getWidth } from "get-east-asian-width";
function visibleWidth(str: string) {
	/* custom logic */
}

// GOOD: Bun builtin (handles ANSI, emoji, CJK)
const width = Bun.stringWidth(text);
const widthNoAnsi = Bun.stringWidth(text, { countAnsiEscapeCodes: false });

Use Bun.wrapAnsi() for ANSI-aware text wrapping:

// BAD: Custom ANSI-aware wrapping
function wrapTextWithAnsi(text: string, width: number) {
	/* complex SGR tracking */
}

// GOOD: Bun builtin
const wrapped = Bun.wrapAnsi(text, width, {
	wordWrap: true,
	hard: false,
	trim: true,
});

Where Bun Wins

Operation Use Not
File read/write Bun.file(), Bun.write() readFileSync, writeFileSync
Spawn process $\cmd`, Bun.spawn()` child_process
Sleep Bun.sleep(ms) setTimeout promise
Binary lookup Bun.which("git") spawnSync(["which", "git"])
HTTP server Bun.serve() http.createServer()
SQLite bun:sqlite better-sqlite3
Hashing Bun.hash(), Web Crypto node:crypto
Path resolution import.meta.dir, import.meta.path fileURLToPath dance
JSON5 parsing Bun.JSON5.parse() json5 package
JSONL parsing Bun.JSONL.parse(), .parseChunk() manual split + JSON.parse
String width Bun.stringWidth() get-east-asian-width, custom
Text wrapping Bun.wrapAnsi() custom ANSI-aware wrappers

Patterns

Subprocess streams — cast when using pipe mode:

const child = Bun.spawn(["cmd"], { stdout: "pipe", stderr: "pipe" });
const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();

Password hashing — built-in bcrypt/argon2:

const hash = await Bun.password.hash("password", "bcrypt");
const valid = await Bun.password.verify("password", hash);

Anti-Patterns

  • Bun.spawnSync([...]) for simple commands → use $\...``
  • new Promise((resolve) => setTimeout(resolve, ms)) → use Bun.sleep(ms)
  • existsSync/readFileSync/writeFileSync in async code → use Bun.file() APIs
  • Manual child.stdout.getReader() loops for non-streaming commands → use readStream() helper
  • import JSON5 from "json5" → use Bun.JSON5.parse()
  • text.split("\n").map(JSON.parse) for JSONL → use Bun.JSONL.parse()
  • Custom visibleWidth() / get-east-asian-width → use Bun.stringWidth()
  • Custom ANSI-aware text wrapping → use Bun.wrapAnsi()

Logging

NEVER use console.log, console.error, or console.warn in the coding-agent package. Console output corrupts the TUI rendering.

Use the centralized logger instead:

import { logger } from "@oh-my-pi/pi-utils";

logger.error("MCP request failed", { url, method });
logger.warn("Theme file invalid, using fallback", { path });
logger.debug("LSP fallback triggered", { reason });

Logs go to ~/.omp/logs/omp.YYYY-MM-DD.log with automatic rotation.

TUI Rendering Sanitization

All text displayed in tool renderers must be sanitized before output. Raw content (file contents, error messages, tool output) can contain characters that break terminal rendering — tabs cause visual holes, long lines overflow, and unsanitized paths leak home directories.

Rules

  • Tabs → spaces: Always pass displayed text through replaceTabs() before rendering. Tabs produce variable-width gaps in terminals and cause visual holes in the TUI. Import from @oh-my-pi/pi-tui or ../tools/render-utils.
  • Line truncation: Truncate displayed lines with truncateToWidth() or ui.truncate() to prevent horizontal overflow. Use constants from TRUNCATE_LENGTHS for consistency.
  • Path shortening: Use shortenPath() for file paths shown to users — replaces home directory prefix with ~.
  • Content preview limits: Use PREVIEW_LIMITS constants for collapsed/expanded line counts. Don't invent ad-hoc limits.

Where to apply

Sanitization applies to every code path that renders text to the TUI, including:

  • Success output (file previews, command output, search results)
  • Error messages — these often embed file content (e.g., patch failure messages include the lines that failed to match)
  • Diff content (both added/removed lines)
  • Streaming previews

A common mistake is sanitizing the happy path but forgetting error paths. If a message includes file content, it needs replaceTabs().

Commands

Command Description
bun check Check all (TypeScript + Rust)
bun check:ts Biome check + tsgo type checking
bun check:rs Cargo fmt --check + clippy
bun lint Lint all
bun lint:ts Biome lint
bun lint:rs Cargo clippy
bun fmt Format all
bun fmt:ts Biome format
bun fmt:rs Cargo fmt
bun fix Fix all (unsafe fixes + format)
bun fix:ts Biome --unsafe + format-prompts
bun fix:rs Clippy --fix + cargo fmt
  • NEVER run: bun run dev, bun test unless user instructs
  • Only run specific tests if user instructs: bun test test/specific.test.ts
  • NEVER commit unless user asks
  • Do NOT use tsc or npx tsc - always use bun check

GitHub Issues

When reading issues:

  • Always read all comments on the issue

When creating issues:

  • Use standard GitHub labels (bug, enhancement, documentation, etc.)
  • If an issue affects a specific package, mention it in the issue title or description

When closing issues via commit:

  • Include fixes #<number> or closes #<number> in the commit message
  • This automatically closes the issue when the commit is merged

Tools

  • GitHub CLI for issues/PRs
  • TUI interaction: use tmux

Style

  • Keep answers short and concise
  • No emojis in commits, issues, PR comments, or code
  • No fluff or cheerful filler text
  • Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")

Changelog

Location: packages/*/CHANGELOG.md (each package has its own)

Format

Use these sections under ## [Unreleased]:

  • ### Added - New features
  • ### Changed - Changes to existing functionality
  • ### Fixed - Bug fixes
  • ### Removed - Removed features
  • ### Breaking Changes - API changes requiring migration (appears first if present)

Rules

  • New entries ALWAYS go under ## [Unreleased] section
  • NEVER modify already-released version sections (e.g., ## [0.12.2])
  • Each version section is immutable once released

Attribution

  • Internal changes (from issues): Fixed foo bar ([#123](https://github.com/can1357/oh-my-pi/issues/123))
  • External contributions: Added feature X ([#456](https://github.com/can1357/oh-my-pi/pull/456) by [@username](https://github.com/username))

Releasing

  1. Update CHANGELOGs: Ensure all changes since last release are documented in the [Unreleased] section of each affected package's CHANGELOG.md

  2. Run release script:

    bun run release
    

The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new [Unreleased] sections.