merge(pi/main): integrate upstream changes through v0.31.1
Merged 53 commits from pi/main including: - Export HTML rewrite with tree sidebar and /share command - Hooks API enhancements (custom status, editor, session management) - Edit diff preview before tool execution - TUI fixes (Unicode format chars, OSC 8 hyperlinks, emoji optimization) - Gemini CLI rate limit retry with server-provided delay - Various bug fixes and documentation updates Resolved conflicts by preserving Bun migration (spawn→Bun.spawn, fs→Bun.file) while accepting upstream's new features and APIs.
This commit is contained in:
@@ -8,8 +8,8 @@ export class BorderedLoader extends Container {
|
||||
|
||||
constructor(tui: TUI, theme: Theme, message: string) {
|
||||
super();
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
const borderColor = (s: string) => theme.fg("border", s);
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
this.loader = new CancellableLoader(
|
||||
tui,
|
||||
(s) => theme.fg("accent", s),
|
||||
@@ -20,7 +20,7 @@ export class BorderedLoader extends Container {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
}
|
||||
|
||||
get signal(): AbortSignal {
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Component } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Dynamic border component that adjusts to viewport width
|
||||
* Dynamic border component that adjusts to viewport width.
|
||||
*
|
||||
* Note: When used from hooks loaded via jiti, the global `theme` may be undefined
|
||||
* because jiti creates a separate module cache. Always pass an explicit color
|
||||
* function when using DynamicBorder in components exported for hook use.
|
||||
*/
|
||||
export class DynamicBorder implements Component {
|
||||
private color: (str: string) => string;
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { type Component, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import type { AgentSession } from "../../../core/agent-session.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Sanitize text for display in a single-line status.
|
||||
* Removes newlines, tabs, carriage returns, and other control characters.
|
||||
*/
|
||||
function sanitizeStatusText(text: string): string {
|
||||
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
|
||||
return text
|
||||
.replace(/[\r\n\t]/g, " ")
|
||||
.replace(/ +/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the git root directory by walking up from cwd.
|
||||
* Returns the path to .git/HEAD if found, null otherwise.
|
||||
@@ -34,6 +46,7 @@ export class FooterComponent implements Component {
|
||||
private gitWatcher: FSWatcher | null = null;
|
||||
private onBranchChange: (() => void) | null = null;
|
||||
private autoCompactEnabled: boolean = true;
|
||||
private hookStatuses: Map<string, string> = new Map();
|
||||
|
||||
constructor(session: AgentSession) {
|
||||
this.session = session;
|
||||
@@ -43,6 +56,21 @@ export class FooterComponent implements Component {
|
||||
this.autoCompactEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hook status text to display in the footer.
|
||||
* Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
|
||||
* ANSI escape codes for styling are preserved.
|
||||
* @param key - Unique key to identify this status
|
||||
* @param text - Status text, or undefined to clear
|
||||
*/
|
||||
setHookStatus(key: string, text: string | undefined): void {
|
||||
if (text === undefined) {
|
||||
this.hookStatuses.delete(key);
|
||||
} else {
|
||||
this.hookStatuses.set(key, text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a file watcher on .git/HEAD to detect branch changes.
|
||||
* Call the provided callback when branch changes.
|
||||
@@ -279,6 +307,18 @@ export class FooterComponent implements Component {
|
||||
const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
|
||||
const dimRemainder = theme.fg("dim", remainder);
|
||||
|
||||
return [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
|
||||
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
|
||||
|
||||
// Add hook statuses on a single line, sorted by key alphabetically
|
||||
if (this.hookStatuses.size > 0) {
|
||||
const sortedStatuses = Array.from(this.hookStatuses.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, text]) => sanitizeStatusText(text));
|
||||
const statusLine = sortedStatuses.join(" ");
|
||||
// Truncate to terminal width with dim ellipsis for consistency with footer style
|
||||
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Multi-line editor component for hooks.
|
||||
* Supports Ctrl+G for external editor.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { getEditorTheme, theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export class HookEditorComponent extends Container {
|
||||
private editor: Editor;
|
||||
private onSubmitCallback: (value: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private tui: TUI;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
title: string,
|
||||
prefill: string | undefined,
|
||||
onSubmit: (value: string) => void,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.tui = tui;
|
||||
this.onSubmitCallback = onSubmit;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add title
|
||||
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create editor
|
||||
this.editor = new Editor(getEditorTheme());
|
||||
if (prefill) {
|
||||
this.editor.setText(prefill);
|
||||
}
|
||||
this.addChild(this.editor);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add hint
|
||||
const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR);
|
||||
const hint = hasExternalEditor
|
||||
? "ctrl+enter submit esc cancel ctrl+g external editor"
|
||||
: "ctrl+enter submit esc cancel";
|
||||
this.addChild(new Text(theme.fg("dim", hint), 1, 0));
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Ctrl+Enter to submit
|
||||
if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") {
|
||||
this.onSubmitCallback(this.editor.getText());
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (isEscape(keyData)) {
|
||||
this.onCancelCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+G for external editor
|
||||
if (isCtrlG(keyData)) {
|
||||
this.openExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward to editor
|
||||
this.editor.handleInput(keyData);
|
||||
}
|
||||
|
||||
private openExternalEditor(): void {
|
||||
const editorCmd = process.env.VISUAL || process.env.EDITOR;
|
||||
if (!editorCmd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentText = this.editor.getText();
|
||||
const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, currentText, "utf-8");
|
||||
this.tui.stop();
|
||||
|
||||
const [editor, ...editorArgs] = editorCmd.split(" ");
|
||||
const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
|
||||
this.editor.setText(newContent);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
this.tui.start();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ export class ModelSelectorComponent extends Container {
|
||||
|
||||
this.allModels = models;
|
||||
this.filteredModels = models;
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, models.length - 1));
|
||||
}
|
||||
|
||||
private filterModels(query: string): void {
|
||||
@@ -216,11 +217,13 @@ export class ModelSelectorComponent extends Container {
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow - wrap to bottom when at top
|
||||
if (isArrowUp(keyData)) {
|
||||
if (this.filteredModels.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow - wrap to top when at bottom
|
||||
else if (isArrowDown(keyData)) {
|
||||
if (this.filteredModels.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { CustomTool } from "../../../core/custom-tools/types.js";
|
||||
import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
|
||||
import { sanitizeBinaryOutput } from "../../../utils/shell.js";
|
||||
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
|
||||
@@ -58,11 +59,15 @@ export class ToolExecutionComponent extends Container {
|
||||
private isPartial = true;
|
||||
private customTool?: CustomTool;
|
||||
private ui: TUI;
|
||||
private cwd: string;
|
||||
private result?: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
isError: boolean;
|
||||
details?: any;
|
||||
};
|
||||
// Cached edit diff preview (computed when args arrive, before tool executes)
|
||||
private editDiffPreview?: EditDiffResult | EditDiffError;
|
||||
private editDiffArgsKey?: string; // Track which args the preview is for
|
||||
|
||||
constructor(
|
||||
toolName: string,
|
||||
@@ -70,6 +75,7 @@ export class ToolExecutionComponent extends Container {
|
||||
options: ToolExecutionOptions = {},
|
||||
customTool: CustomTool | undefined,
|
||||
ui: TUI,
|
||||
cwd: string = process.cwd(),
|
||||
) {
|
||||
super();
|
||||
this.toolName = toolName;
|
||||
@@ -77,6 +83,7 @@ export class ToolExecutionComponent extends Container {
|
||||
this.showImages = options.showImages ?? true;
|
||||
this.customTool = customTool;
|
||||
this.ui = ui;
|
||||
this.cwd = cwd;
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
@@ -98,6 +105,47 @@ export class ToolExecutionComponent extends Container {
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that args are complete (tool is about to execute).
|
||||
* This triggers diff computation for edit tool.
|
||||
*/
|
||||
setArgsComplete(): void {
|
||||
this.maybeComputeEditDiff();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute edit diff preview when we have complete args.
|
||||
* This runs async and updates display when done.
|
||||
*/
|
||||
private maybeComputeEditDiff(): void {
|
||||
if (this.toolName !== "edit") return;
|
||||
|
||||
const path = this.args?.path;
|
||||
const oldText = this.args?.oldText;
|
||||
const newText = this.args?.newText;
|
||||
|
||||
// Need all three params to compute diff
|
||||
if (!path || oldText === undefined || newText === undefined) return;
|
||||
|
||||
// Create a key to track which args this computation is for
|
||||
const argsKey = JSON.stringify({ path, oldText, newText });
|
||||
|
||||
// Skip if we already computed for these exact args
|
||||
if (this.editDiffArgsKey === argsKey) return;
|
||||
|
||||
this.editDiffArgsKey = argsKey;
|
||||
|
||||
// Compute diff async
|
||||
computeEditDiff(path, oldText, newText, this.cwd).then((result) => {
|
||||
// Only update if args haven't changed since we started
|
||||
if (this.editDiffArgsKey === argsKey) {
|
||||
this.editDiffPreview = result;
|
||||
this.updateDisplay();
|
||||
this.ui.requestRender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateResult(
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
@@ -393,9 +441,6 @@ export class ToolExecutionComponent extends Container {
|
||||
theme.fg("toolTitle", theme.bold("write")) +
|
||||
" " +
|
||||
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
|
||||
if (totalLines > 10) {
|
||||
text += ` (${totalLines} lines)`;
|
||||
}
|
||||
|
||||
if (fileContent) {
|
||||
const maxLines = this.expanded ? lines.length : 10;
|
||||
@@ -408,29 +453,38 @@ export class ToolExecutionComponent extends Container {
|
||||
.map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
|
||||
.join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines, ${totalLines} total)`);
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "edit") {
|
||||
const rawPath = this.args?.file_path || this.args?.path || "";
|
||||
const path = shortenPath(rawPath);
|
||||
|
||||
// Build path display, appending :line if we have a successful result with line info
|
||||
// Build path display, appending :line if we have diff info
|
||||
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
if (this.result && !this.result.isError && this.result.details?.firstChangedLine) {
|
||||
pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`);
|
||||
const firstChangedLine =
|
||||
(this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
|
||||
? this.editDiffPreview.firstChangedLine
|
||||
: undefined) ||
|
||||
(this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined);
|
||||
if (firstChangedLine) {
|
||||
pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
|
||||
}
|
||||
|
||||
text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
|
||||
|
||||
if (this.result) {
|
||||
if (this.result.isError) {
|
||||
const errorText = this.getTextOutput();
|
||||
if (errorText) {
|
||||
text += `\n\n${theme.fg("error", errorText)}`;
|
||||
}
|
||||
} else if (this.result.details?.diff) {
|
||||
text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath })}`;
|
||||
if (this.result?.isError) {
|
||||
// Show error from result
|
||||
const errorText = this.getTextOutput();
|
||||
if (errorText) {
|
||||
text += `\n\n${theme.fg("error", errorText)}`;
|
||||
}
|
||||
} else if (this.editDiffPreview) {
|
||||
// Use cached diff preview (works both before and after execution)
|
||||
if ("error" in this.editDiffPreview) {
|
||||
text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
|
||||
} else if (this.editDiffPreview.diff) {
|
||||
text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath })}`;
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "ls") {
|
||||
|
||||
@@ -37,11 +37,13 @@ import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
import { ArminComponent } from "./components/armin.js";
|
||||
import { AssistantMessageComponent } from "./components/assistant-message.js";
|
||||
import { BashExecutionComponent } from "./components/bash-execution.js";
|
||||
import { BorderedLoader } from "./components/bordered-loader.js";
|
||||
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";
|
||||
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";
|
||||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
import { DynamicBorder } from "./components/dynamic-border.js";
|
||||
import { FooterComponent } from "./components/footer.js";
|
||||
import { HookEditorComponent } from "./components/hook-editor.js";
|
||||
import { HookInputComponent } from "./components/hook-input.js";
|
||||
import { HookMessageComponent } from "./components/hook-message.js";
|
||||
import { HookSelectorComponent } from "./components/hook-selector.js";
|
||||
@@ -130,6 +132,7 @@ export class InteractiveMode {
|
||||
// Hook UI state
|
||||
private hookSelector: HookSelectorComponent | undefined = undefined;
|
||||
private hookInput: HookInputComponent | undefined = undefined;
|
||||
private hookEditor: HookEditorComponent | undefined = undefined;
|
||||
|
||||
// Custom tools for custom rendering
|
||||
private customTools: Map<string, LoadedCustomTool>;
|
||||
@@ -172,6 +175,7 @@ export class InteractiveMode {
|
||||
{ name: "settings", description: "Open settings menu" },
|
||||
{ name: "model", description: "Select model (opens selector UI)" },
|
||||
{ name: "export", description: "Export session to HTML file" },
|
||||
{ name: "share", description: "Share session as a secret GitHub gist" },
|
||||
{ name: "copy", description: "Copy last agent message to clipboard" },
|
||||
{ name: "session", description: "Show session info and stats" },
|
||||
{ name: "changelog", description: "Show changelog entries" },
|
||||
@@ -368,9 +372,14 @@ export class InteractiveMode {
|
||||
confirm: (title, message) => this.showHookConfirm(title, message),
|
||||
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
||||
notify: (message, type) => this.showHookNotify(message, type),
|
||||
setStatus: (key, text) => this.setHookStatus(key, text),
|
||||
custom: (factory) => this.showHookCustom(factory),
|
||||
setEditorText: (text) => this.editor.setText(text),
|
||||
getEditorText: () => this.editor.getText(),
|
||||
editor: (title, prefill) => this.showHookEditor(title, prefill),
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
};
|
||||
this.setToolUIContext(uiContext, true);
|
||||
|
||||
@@ -405,6 +414,74 @@ export class InteractiveMode {
|
||||
appendEntryHandler: (customType, data) => {
|
||||
this.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
newSessionHandler: async (options) => {
|
||||
// Stop any loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Create new session
|
||||
const success = await this.session.newSession({ parentSession: options?.parentSession });
|
||||
if (!success) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
// Call setup callback if provided
|
||||
if (options?.setup) {
|
||||
await options.setup(this.sessionManager);
|
||||
}
|
||||
|
||||
// Clear UI state
|
||||
this.chatContainer.clear();
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
|
||||
this.ui.requestRender();
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
branchHandler: async (entryId) => {
|
||||
const result = await this.session.branch(entryId);
|
||||
if (result.cancelled) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
this.editor.setText(result.selectedText);
|
||||
this.showStatus("Branched to new session");
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
navigateTreeHandler: async (targetId, options) => {
|
||||
const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
|
||||
if (result.cancelled) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
if (result.editorText) {
|
||||
this.editor.setText(result.editorText);
|
||||
}
|
||||
this.showStatus("Navigated to selected point");
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
isIdle: () => !this.session.isStreaming,
|
||||
waitForIdle: () => this.session.agent.waitForIdle(),
|
||||
abort: () => {
|
||||
this.session.abort();
|
||||
},
|
||||
hasQueuedMessages: () => this.session.queuedMessageCount > 0,
|
||||
uiContext,
|
||||
hasUI: true,
|
||||
});
|
||||
@@ -439,6 +516,11 @@ export class InteractiveMode {
|
||||
sessionManager: this.session.sessionManager,
|
||||
modelRegistry: this.session.modelRegistry,
|
||||
model: this.session.model,
|
||||
isIdle: () => !this.session.isStreaming,
|
||||
hasQueuedMessages: () => this.session.queuedMessageCount > 0,
|
||||
abort: () => {
|
||||
this.session.abort();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
|
||||
@@ -456,6 +538,14 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hook status text in the footer.
|
||||
*/
|
||||
private setHookStatus(key: string, text: string | undefined): void {
|
||||
this.footer.setHookStatus(key, text);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a selector for hooks.
|
||||
*/
|
||||
@@ -536,6 +626,43 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a multi-line editor for hooks (with Ctrl+G support).
|
||||
*/
|
||||
private showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookEditor = new HookEditorComponent(
|
||||
this.ui,
|
||||
title,
|
||||
prefill,
|
||||
(value) => {
|
||||
this.hideHookEditor();
|
||||
resolve(value);
|
||||
},
|
||||
() => {
|
||||
this.hideHookEditor();
|
||||
resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.hookEditor);
|
||||
this.ui.setFocus(this.hookEditor);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the hook editor.
|
||||
*/
|
||||
private hideHookEditor(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookEditor = undefined;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification for hooks.
|
||||
*/
|
||||
@@ -674,8 +801,13 @@ export class InteractiveMode {
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/share") {
|
||||
await this.handleShareCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/copy") {
|
||||
this.handleCopyCommand();
|
||||
await this.handleCopyCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
@@ -901,6 +1033,11 @@ export class InteractiveMode {
|
||||
});
|
||||
}
|
||||
this.pendingTools.clear();
|
||||
} else {
|
||||
// Args are now complete - trigger diff computation for edit tools
|
||||
for (const [, component] of this.pendingTools.entries()) {
|
||||
component.setArgsComplete();
|
||||
}
|
||||
}
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
@@ -1399,8 +1536,8 @@ export class InteractiveMode {
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
// On successful exit (status 0), replace editor content
|
||||
if (result.status === 0) {
|
||||
// On successful exit (exitCode 0), replace editor content
|
||||
if (result.exitCode === 0) {
|
||||
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
|
||||
this.editor.setText(newContent);
|
||||
}
|
||||
@@ -1910,7 +2047,123 @@ export class InteractiveMode {
|
||||
}
|
||||
}
|
||||
|
||||
private handleCopyCommand(): void {
|
||||
private async handleShareCommand(): Promise<void> {
|
||||
// Check if gh is available and logged in
|
||||
try {
|
||||
const authResult = Bun.spawnSync(["gh", "auth", "status"]);
|
||||
if (authResult.exitCode !== 0) {
|
||||
this.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
|
||||
return;
|
||||
}
|
||||
|
||||
// Export to a temp file
|
||||
const tmpFile = path.join(os.tmpdir(), "session.html");
|
||||
try {
|
||||
this.session.exportToHtml(tmpFile);
|
||||
} catch (error: unknown) {
|
||||
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show cancellable loader, replacing the editor
|
||||
const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(loader);
|
||||
this.ui.setFocus(loader);
|
||||
this.ui.requestRender();
|
||||
|
||||
const restoreEditor = () => {
|
||||
loader.dispose();
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
// Create a secret gist asynchronously
|
||||
let proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
|
||||
loader.onAbort = () => {
|
||||
proc?.kill();
|
||||
restoreEditor();
|
||||
this.showStatus("Share cancelled");
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
|
||||
proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
||||
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await stdoutReader.read();
|
||||
if (done) break;
|
||||
stdout += decoder.decode(value);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await stderrReader.read();
|
||||
if (done) break;
|
||||
stderr += decoder.decode(value);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
proc.exited.then((code) => resolve({ stdout, stderr, code }));
|
||||
});
|
||||
|
||||
if (loader.signal.aborted) return;
|
||||
|
||||
restoreEditor();
|
||||
|
||||
if (result.code !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || "Unknown error";
|
||||
this.showError(`Failed to create gist: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract gist ID from the URL returned by gh
|
||||
// gh returns something like: https://gist.github.com/username/GIST_ID
|
||||
const gistUrl = result.stdout?.trim();
|
||||
const gistId = gistUrl?.split("/").pop();
|
||||
if (!gistId) {
|
||||
this.showError("Failed to parse gist ID from gh output");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the preview URL
|
||||
const previewUrl = `https://shittycodingagent.ai/session?${gistId}`;
|
||||
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
|
||||
} catch (error: unknown) {
|
||||
if (!loader.signal.aborted) {
|
||||
restoreEditor();
|
||||
this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCopyCommand(): Promise<void> {
|
||||
const text = this.session.getLastAssistantText();
|
||||
if (!text) {
|
||||
this.showError("No agent messages to copy yet.");
|
||||
@@ -1918,7 +2171,7 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
try {
|
||||
copyToClipboard(text);
|
||||
await copyToClipboard(text);
|
||||
this.showStatus("Copied last agent message to clipboard");
|
||||
} catch (error) {
|
||||
this.showError(error instanceof Error ? error.message : String(error));
|
||||
@@ -2031,8 +2284,8 @@ export class InteractiveMode {
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Reset via session (emits hook and tool session events)
|
||||
await this.session.reset();
|
||||
// New session via session (emits hook and tool session events)
|
||||
await this.session.newSession();
|
||||
|
||||
// Clear UI state
|
||||
this.chatContainer.clear();
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||
"name": "dark",
|
||||
"vars": {
|
||||
"cyan": "#00d7ff",
|
||||
"blue": "#5f87ff",
|
||||
"green": "#b5bd68",
|
||||
"red": "#cc6666",
|
||||
"yellow": "#ffff00",
|
||||
"gray": "#808080",
|
||||
"dimGray": "#666666",
|
||||
"darkGray": "#505050",
|
||||
"accent": "#8abeb7",
|
||||
"selectedBg": "#3a3a4a",
|
||||
"userMsgBg": "#343541",
|
||||
"toolPendingBg": "#282832",
|
||||
"toolSuccessBg": "#283228",
|
||||
"toolErrorBg": "#3c2828",
|
||||
"customMsgBg": "#2d2838"
|
||||
"cyan": "#0088fa",
|
||||
"blue": "#178fb9",
|
||||
"green": "#89d281",
|
||||
"red": "#fc3a4b",
|
||||
"yellow": "#e4c00f",
|
||||
"gray": "#777d88",
|
||||
"dimGray": "#5f6673",
|
||||
"darkGray": "#3d424a",
|
||||
"accent": "#febc38",
|
||||
"selectedBg": "#31363f",
|
||||
"userMsgBg": "#221d1a",
|
||||
"toolPendingBg": "#1d2129",
|
||||
"toolSuccessBg": "#161a1f",
|
||||
"toolErrorBg": "#291d1d",
|
||||
"customMsgBg": "#2a2530"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "accent",
|
||||
@@ -36,28 +36,30 @@
|
||||
"userMessageText": "",
|
||||
"customMessageBg": "customMsgBg",
|
||||
"customMessageText": "",
|
||||
"customMessageLabel": "#9575cd",
|
||||
"customMessageLabel": "#b281d6",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
"toolTitle": "",
|
||||
"toolOutput": "gray",
|
||||
|
||||
"mdHeading": "#f0c674",
|
||||
"mdLink": "#81a2be",
|
||||
"mdHeading": "#febc38",
|
||||
"mdLink": "#0088fa",
|
||||
"mdLinkUrl": "dimGray",
|
||||
"mdCode": "accent",
|
||||
"mdCodeBlock": "green",
|
||||
"mdCodeBlockBorder": "gray",
|
||||
"mdCode": "#e5c1ff",
|
||||
"mdCodeBlock": "#9CDCFE",
|
||||
"mdCodeBlockBorder": "darkGray",
|
||||
"mdQuote": "gray",
|
||||
"mdQuoteBorder": "gray",
|
||||
"mdHr": "gray",
|
||||
"mdQuoteBorder": "darkGray",
|
||||
"mdHr": "darkGray",
|
||||
"mdListBullet": "accent",
|
||||
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "gray",
|
||||
|
||||
"link": "#0088fa",
|
||||
|
||||
"syntaxComment": "#6A9955",
|
||||
"syntaxKeyword": "#569CD6",
|
||||
"syntaxFunction": "#DCDCAA",
|
||||
@@ -69,12 +71,17 @@
|
||||
"syntaxPunctuation": "#D4D4D4",
|
||||
|
||||
"thinkingOff": "darkGray",
|
||||
"thinkingMinimal": "#6e6e6e",
|
||||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#81a2be",
|
||||
"thinkingHigh": "#b294bb",
|
||||
"thinkingXhigh": "#d183e8",
|
||||
"thinkingMinimal": "dimGray",
|
||||
"thinkingLow": "#178fb9",
|
||||
"thinkingMedium": "#0088fa",
|
||||
"thinkingHigh": "#b281d6",
|
||||
"thinkingXhigh": "#e5c1ff",
|
||||
|
||||
"bashMode": "green"
|
||||
"bashMode": "cyan"
|
||||
},
|
||||
"export": {
|
||||
"pageBg": "#18181e",
|
||||
"cardBg": "#1e1e24",
|
||||
"infoBg": "#3c3728"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,5 +75,10 @@
|
||||
"thinkingXhigh": "#8b008b",
|
||||
|
||||
"bashMode": "green"
|
||||
},
|
||||
"export": {
|
||||
"pageBg": "#f8f8f8",
|
||||
"cardBg": "#ffffff",
|
||||
"infoBg": "#fffae6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,25 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"export": {
|
||||
"type": "object",
|
||||
"description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)",
|
||||
"properties": {
|
||||
"pageBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Page background color"
|
||||
},
|
||||
"cardBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Card/container background color"
|
||||
},
|
||||
"infoBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Info sections background (system prompt, notices)"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -82,10 +82,18 @@ const ThemeJsonSchema = Type.Object({
|
||||
// Bash Mode (1 color)
|
||||
bashMode: ColorValueSchema,
|
||||
}),
|
||||
export: Type.Optional(
|
||||
Type.Object({
|
||||
pageBg: Type.Optional(ColorValueSchema),
|
||||
cardBg: Type.Optional(ColorValueSchema),
|
||||
infoBg: Type.Optional(ColorValueSchema),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type ThemeJson = Static<typeof ThemeJsonSchema>;
|
||||
|
||||
// @ts-expect-error TypeBox CJS/ESM type mismatch
|
||||
const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema);
|
||||
|
||||
export type ThemeColor =
|
||||
@@ -652,6 +660,129 @@ export function stopThemeWatcher(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTML Export Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert a 256-color index to hex string.
|
||||
* Indices 0-15: basic colors (approximate)
|
||||
* Indices 16-231: 6x6x6 color cube
|
||||
* Indices 232-255: grayscale ramp
|
||||
*/
|
||||
function ansi256ToHex(index: number): string {
|
||||
// Basic colors (0-15) - approximate common terminal values
|
||||
const basicColors = [
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#008000",
|
||||
"#808000",
|
||||
"#000080",
|
||||
"#800080",
|
||||
"#008080",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#ffff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffffff",
|
||||
];
|
||||
if (index < 16) {
|
||||
return basicColors[index];
|
||||
}
|
||||
|
||||
// Color cube (16-231): 6x6x6 = 216 colors
|
||||
if (index < 232) {
|
||||
const cubeIndex = index - 16;
|
||||
const r = Math.floor(cubeIndex / 36);
|
||||
const g = Math.floor((cubeIndex % 36) / 6);
|
||||
const b = cubeIndex % 6;
|
||||
const toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
// Grayscale (232-255): 24 shades
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
const grayHex = gray.toString(16).padStart(2, "0");
|
||||
return `#${grayHex}${grayHex}${grayHex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolved theme colors as CSS-compatible hex strings.
|
||||
* Used by HTML export to generate CSS custom properties.
|
||||
*/
|
||||
export function getResolvedThemeColors(themeName?: string): Record<string, string> {
|
||||
const name = themeName ?? getDefaultTheme();
|
||||
const isLight = name === "light";
|
||||
const themeJson = loadThemeJson(name);
|
||||
const resolved = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||
|
||||
// Default text color for empty values (terminal uses default fg color)
|
||||
const defaultText = isLight ? "#000000" : "#e5e5e7";
|
||||
|
||||
const cssColors: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
if (typeof value === "number") {
|
||||
cssColors[key] = ansi256ToHex(value);
|
||||
} else if (value === "") {
|
||||
// Empty means default terminal color - use sensible fallback for HTML
|
||||
cssColors[key] = defaultText;
|
||||
} else {
|
||||
cssColors[key] = value;
|
||||
}
|
||||
}
|
||||
return cssColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a theme is a "light" theme (for CSS that needs light/dark variants).
|
||||
*/
|
||||
export function isLightTheme(themeName?: string): boolean {
|
||||
// Currently just check the name - could be extended to analyze colors
|
||||
return themeName === "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explicit export colors from theme JSON, if specified.
|
||||
* Returns undefined for each color that isn't explicitly set.
|
||||
*/
|
||||
export function getThemeExportColors(themeName?: string): {
|
||||
pageBg?: string;
|
||||
cardBg?: string;
|
||||
infoBg?: string;
|
||||
} {
|
||||
const name = themeName ?? getDefaultTheme();
|
||||
try {
|
||||
const themeJson = loadThemeJson(name);
|
||||
const exportSection = themeJson.export;
|
||||
if (!exportSection) return {};
|
||||
|
||||
const vars = themeJson.vars ?? {};
|
||||
const resolve = (value: string | number | undefined): string | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === "number") return ansi256ToHex(value);
|
||||
if (value.startsWith("$")) {
|
||||
const resolved = vars[value];
|
||||
if (resolved === undefined) return undefined;
|
||||
if (typeof resolved === "number") return ansi256ToHex(resolved);
|
||||
return resolved;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
pageBg: resolve(exportSection.pageBg),
|
||||
cardBg: resolve(exportSection.cardBg),
|
||||
infoBg: resolve(exportSection.infoBg),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TUI Helpers
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user