diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md new file mode 100644 index 00000000..960dd1e9 --- /dev/null +++ b/packages/coding-agent/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## [0.7.7] - Unreleased + +### Added + +- Automatic changelog viewer on startup in interactive mode. When starting a new session (not continuing/resuming), the agent will display all changelog entries since the last version you used in a scrollable markdown viewer. The last shown version is tracked in `~/.pi/agent/settings.json`. + +### Changed + +- **BREAKING**: Renamed project context file from `AGENT.md` to `AGENTS.md`. The system now looks for `AGENTS.md` or `CLAUDE.md` (with `AGENTS.md` preferred). Existing `AGENT.md` files will need to be renamed to `AGENTS.md` to continue working. (fixes [#9](https://github.com/badlogic/pi-mono/pull/9)) + +## [0.7.6] - 2025-11-13 + +Previous releases did not maintain a changelog. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ca3aa5b4..cc8b4a7f 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -134,26 +134,26 @@ Paste multiple lines of text (e.g., code snippets, logs) and they'll be automati ## Project Context Files -The agent automatically loads context from `AGENT.md` or `CLAUDE.md` files at the start of new sessions (not when continuing/resuming). These files are loaded in hierarchical order to support both global preferences and monorepo structures. +The agent automatically loads context from `AGENTS.md` or `CLAUDE.md` files at the start of new sessions (not when continuing/resuming). These files are loaded in hierarchical order to support both global preferences and monorepo structures. ### File Locations Context files are loaded in this order: -1. **Global context**: `~/.pi/agent/AGENT.md` or `CLAUDE.md` +1. **Global context**: `~/.pi/agent/AGENTS.md` or `CLAUDE.md` - Applies to all your coding sessions - Great for personal coding preferences and workflows 2. **Parent directories** (top-most first down to current directory) - Walks up from current directory to filesystem root - - Each directory can have its own `AGENT.md` or `CLAUDE.md` + - Each directory can have its own `AGENTS.md` or `CLAUDE.md` - Perfect for monorepos with shared context at higher levels -3. **Current directory**: Your project's `AGENT.md` or `CLAUDE.md` +3. **Current directory**: Your project's `AGENTS.md` or `CLAUDE.md` - Most specific context, loaded last - Overwrites or extends parent/global context -**File preference**: In each directory, `AGENT.md` is preferred over `CLAUDE.md` if both exist. +**File preference**: In each directory, `AGENTS.md` is preferred over `CLAUDE.md` if both exist. ### What to Include @@ -364,9 +364,9 @@ You: Read ~/agent-tools/screenshot/README.md and use that tool to take a screens The agent will read the README, understand the tool, and invoke it via bash as needed. If you need a new tool, ask the agent to write it for you. -You can also reference tool READMEs in your `AGENT.md` files to make them automatically available: -- Global: `~/.pi/agent/AGENT.md` - available in all sessions -- Project-specific: `./AGENT.md` - available in this project +You can also reference tool READMEs in your `AGENTS.md` files to make them automatically available: +- Global: `~/.pi/agent/AGENTS.md` - available in all sessions +- Project-specific: `./AGENTS.md` - available in this project **Real-world example:** diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 4f199b8a..25ae4e44 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -9,7 +9,8 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ - "dist" + "dist", + "CHANGELOG.md" ], "scripts": { "clean": "rm -rf dist", diff --git a/packages/coding-agent/src/changelog.ts b/packages/coding-agent/src/changelog.ts new file mode 100644 index 00000000..4813856a --- /dev/null +++ b/packages/coding-agent/src/changelog.ts @@ -0,0 +1,107 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +export interface ChangelogEntry { + major: number; + minor: number; + patch: number; + content: string; +} + +/** + * Parse changelog entries from CHANGELOG.md + * Scans for ## lines and collects content until next ## or EOF + */ +export function parseChangelog(changelogPath: string): ChangelogEntry[] { + if (!existsSync(changelogPath)) { + return []; + } + + try { + const content = readFileSync(changelogPath, "utf-8"); + const lines = content.split("\n"); + const entries: ChangelogEntry[] = []; + + let currentLines: string[] = []; + let currentVersion: { major: number; minor: number; patch: number } | null = null; + + for (const line of lines) { + // Check if this is a version header (## [x.y.z] ...) + if (line.startsWith("## ")) { + // Save previous entry if exists + if (currentVersion && currentLines.length > 0) { + entries.push({ + ...currentVersion, + content: currentLines.join("\n").trim(), + }); + } + + // Try to parse version from this line + const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/); + if (versionMatch) { + currentVersion = { + major: Number.parseInt(versionMatch[1], 10), + minor: Number.parseInt(versionMatch[2], 10), + patch: Number.parseInt(versionMatch[3], 10), + }; + currentLines = [line]; + } else { + // Reset if we can't parse version + currentVersion = null; + currentLines = []; + } + } else if (currentVersion) { + // Collect lines for current version + currentLines.push(line); + } + } + + // Save last entry + if (currentVersion && currentLines.length > 0) { + entries.push({ + ...currentVersion, + content: currentLines.join("\n").trim(), + }); + } + + return entries; + } catch (error) { + console.error(`Warning: Could not parse changelog: ${error}`); + return []; + } +} + +/** + * Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 + */ +export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number { + if (v1.major !== v2.major) return v1.major - v2.major; + if (v1.minor !== v2.minor) return v1.minor - v2.minor; + return v1.patch - v2.patch; +} + +/** + * Get entries newer than lastVersion + */ +export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] { + // Parse lastVersion + const parts = lastVersion.split(".").map(Number); + const last: ChangelogEntry = { + major: parts[0] || 0, + minor: parts[1] || 0, + patch: parts[2] || 0, + content: "", + }; + + return entries.filter((entry) => compareVersions(entry, last) > 0); +} + +/** + * Get the path to the CHANGELOG.md file + */ +export function getChangelogPath(): string { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + return join(__dirname, "../CHANGELOG.md"); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 61a6476f..1b5f83ee 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -6,7 +6,9 @@ import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; +import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js"; import { SessionManager } from "./session-manager.js"; +import { SettingsManager } from "./settings-manager.js"; import { codingTools } from "./tools/index.js"; import { SessionSelectorComponent } from "./tui/session-selector.js"; import { TuiRenderer } from "./tui/tui-renderer.js"; @@ -221,10 +223,10 @@ Guidelines: } /** - * Look for AGENT.md or CLAUDE.md in a directory (prefers AGENT.md) + * Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md) */ function loadContextFileFromDir(dir: string): { path: string; content: string } | null { - const candidates = ["AGENT.md", "CLAUDE.md"]; + const candidates = ["AGENTS.md", "CLAUDE.md"]; for (const filename of candidates) { const filePath = join(dir, filename); if (existsSync(filePath)) { @@ -243,7 +245,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } /** * Load all project context files in order: - * 1. Global: ~/.pi/agent/AGENT.md or CLAUDE.md + * 1. Global: ~/.pi/agent/AGENTS.md or CLAUDE.md * 2. Parent directories (top-most first) down to cwd * Each returns {path, content} for separate messages */ @@ -316,8 +318,13 @@ async function selectSession(sessionManager: SessionManager): Promise { - const renderer = new TuiRenderer(agent, sessionManager, version); +async function runInteractiveMode( + agent: Agent, + sessionManager: SessionManager, + version: string, + changelogMarkdown: string | null = null, +): Promise { + const renderer = new TuiRenderer(agent, sessionManager, version, changelogMarkdown); // Initialize TUI await renderer.init(); @@ -581,8 +588,38 @@ export async function main(args: string[]) { // RPC mode - headless operation await runRpcMode(agent, sessionManager); } else if (isInteractive) { + // Check if we should show changelog (only in interactive mode, only for new sessions) + let changelogMarkdown: string | null = null; + if (!parsed.continue && !parsed.resume) { + const settingsManager = new SettingsManager(); + const lastVersion = settingsManager.getLastChangelogVersion(); + + // Check if we need to show changelog + if (!lastVersion) { + // First run - show all entries + const changelogPath = getChangelogPath(); + const entries = parseChangelog(changelogPath); + if (entries.length > 0) { + changelogMarkdown = entries.map((e) => e.content).join("\n\n"); + settingsManager.setLastChangelogVersion(VERSION); + } + } else { + // Parse current and last versions + const currentParts = VERSION.split(".").map(Number); + const current = { major: currentParts[0] || 0, minor: currentParts[1] || 0, patch: currentParts[2] || 0 }; + const changelogPath = getChangelogPath(); + const entries = parseChangelog(changelogPath); + const newEntries = getNewEntries(entries, lastVersion); + + if (newEntries.length > 0) { + changelogMarkdown = newEntries.map((e) => e.content).join("\n\n"); + settingsManager.setLastChangelogVersion(VERSION); + } + } + } + // No messages and not RPC - use TUI - await runInteractiveMode(agent, sessionManager, VERSION); + await runInteractiveMode(agent, sessionManager, VERSION, changelogMarkdown); } else { // CLI mode with messages await runSingleShotMode(agent, sessionManager, parsed.messages, mode); diff --git a/packages/coding-agent/src/settings-manager.ts b/packages/coding-agent/src/settings-manager.ts new file mode 100644 index 00000000..10f4cf32 --- /dev/null +++ b/packages/coding-agent/src/settings-manager.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { dirname, join } from "path"; + +export interface Settings { + lastChangelogVersion?: string; +} + +export class SettingsManager { + private settingsPath: string; + private settings: Settings; + + constructor(baseDir?: string) { + const dir = baseDir || join(homedir(), ".pi", "agent"); + this.settingsPath = join(dir, "settings.json"); + this.settings = this.load(); + } + + private load(): Settings { + if (!existsSync(this.settingsPath)) { + return {}; + } + + try { + const content = readFileSync(this.settingsPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + console.error(`Warning: Could not read settings file: ${error}`); + return {}; + } + } + + private save(): void { + try { + // Ensure directory exists + const dir = dirname(this.settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8"); + } catch (error) { + console.error(`Warning: Could not save settings file: ${error}`); + } + } + + getLastChangelogVersion(): string | undefined { + return this.settings.lastChangelogVersion; + } + + setLastChangelogVersion(version: string): void { + this.settings.lastChangelogVersion = version; + this.save(); + } +} diff --git a/packages/coding-agent/src/tui/dynamic-border.ts b/packages/coding-agent/src/tui/dynamic-border.ts new file mode 100644 index 00000000..8f6a4bdd --- /dev/null +++ b/packages/coding-agent/src/tui/dynamic-border.ts @@ -0,0 +1,17 @@ +import type { Component } from "@mariozechner/pi-tui"; +import chalk from "chalk"; + +/** + * Dynamic border component that adjusts to viewport width + */ +export class DynamicBorder implements Component { + private color: (str: string) => string; + + constructor(color: (str: string) => string = chalk.blue) { + this.color = color; + } + + render(width: number): string[] { + return [this.color("─".repeat(Math.max(1, width)))]; + } +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 5e42a566..63e15468 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -5,6 +5,7 @@ import { CombinedAutocompleteProvider, Container, Loader, + Markdown, ProcessTerminal, Spacer, Text, @@ -15,6 +16,7 @@ import { exportSessionToHtml } from "../export-html.js"; import type { SessionManager } from "../session-manager.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { CustomEditor } from "./custom-editor.js"; +import { DynamicBorder } from "./dynamic-border.js"; import { FooterComponent } from "./footer.js"; import { ModelSelectorComponent } from "./model-selector.js"; import { ThinkingSelectorComponent } from "./thinking-selector.js"; @@ -39,6 +41,7 @@ export class TuiRenderer { private loadingAnimation: Loader | null = null; private onInterruptCallback?: () => void; private lastSigintTime = 0; + private changelogMarkdown: string | null = null; // Streaming message tracking private streamingComponent: AssistantMessageComponent | null = null; @@ -55,10 +58,11 @@ export class TuiRenderer { // Track if this is the first user message (to skip spacer) private isFirstUserMessage = true; - constructor(agent: Agent, sessionManager: SessionManager, version: string) { + constructor(agent: Agent, sessionManager: SessionManager, version: string, changelogMarkdown: string | null = null) { this.agent = agent; this.sessionManager = sessionManager; this.version = version; + this.changelogMarkdown = changelogMarkdown; this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.statusContainer = new Container(); @@ -125,6 +129,18 @@ export class TuiRenderer { this.ui.addChild(new Spacer(1)); this.ui.addChild(header); this.ui.addChild(new Spacer(1)); + + // Add changelog if provided + if (this.changelogMarkdown) { + this.ui.addChild(new DynamicBorder(chalk.cyan)); + this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0)); + this.ui.addChild(new Spacer(1)); + this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), undefined, undefined, undefined, 1, 0)); + this.ui.addChild(new Spacer(1)); + this.ui.addChild(new DynamicBorder(chalk.cyan)); + this.ui.addChild(new Spacer(1)); + } + this.ui.addChild(this.chatContainer); this.ui.addChild(this.statusContainer); this.ui.addChild(new Spacer(1));