Add settings manager, show changelog on startup if not shown yet.

This commit is contained in:
Mario Zechner 2025-11-13 21:19:57 +01:00
parent 16740ea077
commit c82f9f4f8c
8 changed files with 264 additions and 16 deletions

View file

@ -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.

View file

@ -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:**

View file

@ -9,7 +9,8 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
"dist",
"CHANGELOG.md"
],
"scripts": {
"clean": "rm -rf dist",

View file

@ -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");
}

View file

@ -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<string | n
});
}
async function runInteractiveMode(agent: Agent, sessionManager: SessionManager, version: string): Promise<void> {
const renderer = new TuiRenderer(agent, sessionManager, version);
async function runInteractiveMode(
agent: Agent,
sessionManager: SessionManager,
version: string,
changelogMarkdown: string | null = null,
): Promise<void> {
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);

View file

@ -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();
}
}

View file

@ -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)))];
}
}

View file

@ -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));