323a3bc29c
* feat(rules): implemented alwaysApply auto-injection into system prompt
rules with alwaysApply: true were parsed by all providers and used to
exclude the rule from rulebookRules, but the inclusion half was never
built — rule content was silently dropped. now:
- full content is injected directly into the system prompt (before the
rulebook rules section) in both default and custom prompt templates
- rules remain addressable via rule:// for re-reading
- ttsr rules still take priority (condition + alwaysApply goes to ttsr only)
updated rulebook-matching-pipeline.md to reflect the three-bucket split
(ttsr > always-apply > rulebook) and corrected the rule:// resolution
docs.
* feat(browser): implement screenshot path saving
- Add `browser.screenshotDir` setting (tools tab) for a persistent
default screenshot directory, configurable via /settings
- Honour the existing `path` parameter in the screenshot action,
which was declared in the schema but never consumed by the implementation
- Resolution order: params.path (abs) > join(screenshotDir, params.path)
> join(screenshotDir, screenshot-<timestamp>.png) > /tmp only
- Writes full-resolution buffer to disk (not the API-compressed copy)
- Creates destination directory recursively if it doesn't exist
- details.screenshotPath reflects the actual saved location
Fixes: path param silently ignored since removal in v11.5.0
* feat(browser): expand ~ in screenshotDir and path params
Users can now configure browser.screenshotDir as ~/Downloads or
~/Pictures and use params.path as ~/screenshots/foo.png without
needing to supply fully-qualified paths.
* fix(browser): write screenshot to exactly one location
Previously wrote to /tmp unconditionally then copied to user path,
resulting in two files. Now resolves a single destination upfront:
1. params.path (absolute, or relative to screenshotDir/cwd)
2. screenshotDir + auto-timestamp filename
3. /tmp fallback (original behaviour, unchanged)
Also: user-defined destinations receive the full-res buffer;
/tmp fallback retains the API-compressed copy as before.
* feat(settings): add text input support for plain string settings
Settings with type: "string" and no submenu now render as an editable
text field in the /settings TUI panel instead of being silently skipped.
- settings-defs.ts: add TextInputSettingDef interface; pathToSettingDef
falls through to { type: "text" } for any plain string schema entry
- settings-selector.ts: add TextInputSubmenu class (mirrors
ConfigInputSubmenu from plugin-settings.ts); add "text" case to
#defToItem; add #createTextInput method
This makes browser.screenshotDir visible and editable in the Tools tab.
Empty field on submit clears the setting (falls back to /tmp behavior).
* fix(settings): text input — cursor at end, block tab navigation
- Cursor: call handleInput(ctrl+e) after setValue to jump to end of
pre-filled string instead of leaving it at position 0
- Tab/arrow guard: add #textInputActive flag; suppress tab-switch and
left/right routing to tab bar while a TextInputSubmenu is open, so
arrow keys reach the Input component's cursor movement handlers
instead of switching settings tabs
* fix(browser): expand ~\ (Windows backslash) in screenshot paths
expandHome now handles both Unix ~/... and Windows ~\... separators,
matching user expectation on all platforms. Addresses Codex review.
* fix(coding-agent): use expandPath for screenshots
* docs: add changelog for screenshot path option
Bug fixes:
- Add browser.screenshotDir to settings-schema.ts (lost during rebase conflict)
- Fix stale expandHome comment to expandPath in settings-selector.ts
- Final screenshotDir description: Directory to save screenshots with ~ support
* fix: biome format corrections for screenshot-path-option branch
* fix: add missing #textInputActive field and screenshotDir default
* fix(browser): align screenshot metadata with saved file contents
When screenshotDir or params.path is set, the full-resolution PNG buffer
is written to disk. Previously mimeType/bytes in details still reflected
the resized payload sent to the model, making metadata inconsistent with
the actual saved file.
Now savedBuffer/savedMimeType track what is written, and details reflects
that. Display output distinguishes 'Saved' vs 'Model' when full-res is
used, and collapses to a single Format/Dimensions line for temp-only.
* fix(browser): resolve params.path relative to cwd regardless of screenshotDir
screenshotDir is a default save location, not an anchor for explicit
paths. A relative params.path should always resolve against cwd so its
semantics are stable and predictable regardless of user settings.
* fix(tui): enforce strict line budget for collapsed tool output
The grep, ast_grep, and ast_edit renderers used group-count-based
collapse that always included the first group unconditionally,
allowing collapsed output to remain visually large when a single
group contained many lines.
Add maxCollapsedLines to renderTreeList that enforces a strict
total-line cap in collapsed mode. Items that exceed the remaining
budget are skipped entirely (no broken fragments). The isLast tree
branch is computed after the budget check to avoid double-last
branches when a summary line follows.
Remove the per-tool getCollapsedMatchLimit / getCollapsedChangeLimit
helpers that are now redundant.
Fixes #455
Made-with: Cursor
* WIP
* WIP
* cleanup
* docs(coding-agent): update changelog for custom model tags and cycle order
* Fix custom model precedence across load and refresh
* feat(ask): add multiline editor support for custom input
* feat(extension-ui): add dialog options and abort signal support to editor
* docs(ask): add multiline editor and timeout behavior guidance
* docs(ask): simplify multiline input documentation
* fix(tui): preserve terminal scrollback during full redraws
Replace destructive \x1b[3J\x1b[2J\x1b[H full-redraw sequence with
scrollback-preserving repaint helpers:
- seedTranscript: first paint with no prior frame, writes full transcript
without clearing scrollback
- repaintViewport: trusted-frame repaints that scroll viewport-shift
delta into scrollback before overwriting visible rows in-place
- Height-increase handler that pushes revealed scrollback back before
reclaiming the display
Replace requestRender(true) state destruction with a one-shot
without resetting #previousLines or cursor bookkeeping.
Track #previousHeight to detect terminal height increases that pull
scrollback lines into the visible area.
fixes #507
* fix(tui): fix exit gaps, content shrink drift, and overlay cursor recovery
- Rewrite stop() to use viewport-relative cursor positioning instead of
content-length, preventing blank gaps when content is shorter than viewport
- Replace trailing \r\n\x1b[2K clear loops in repaintViewport() and
height-increase path with \r\n\x1b[J to avoid cursor drift past content
- Add 12 TUI regression tests for exit gaps, content shrink, and overlay
dismiss cursor recovery
- Add 6 coding-agent controller tests for /new and /tree commands
* fix(tui): redraw sparse height increases atomically
* fix(coding-agent,tui): address review regressions
* fix(tui): reseed after terminal resume
* test(ai): avoid leaking kagi module mocks
* fix(ask): keep multiline custom input in prompt gutter
* fix(ask): preserve multiselect choices on editor dismiss
* fix(ask): honor app interrupt in prompt editor
* fix(coding-agent): preserve new-session approval state
* feat(core): add session-scoped model/provider retry fallback policy
* feat(core): validate retry fallback chains on session startup
* fix(ask): preserve prior answer when custom editor dismissed in single-select
* fix(core): harden retry fallback policy semantics
* fix(core): correct retry fallback edge cases
* test(coding-agent): removed macOS fallback test case from theme detection
- Removed test case for macOS fallback behavior inside Zellij.
* refactor: simplified null checks using optional chaining across TypeScript and Rust modules
- Simplified null/empty checks across TypeScript codebase using optional chaining operator (?.) for improved readability.
- Replaced explicit null checks in validation logic with optional chaining in oauth-discovery, gemini-cli, claude, zai, and lsp modules.
- Updated error handling in Rust command invocation to use double question mark operator (??) for cmd_result.
- Consolidated null validation patterns across tools (bash-skill-urls, browser, gemini-image, resolve) and keybindings using optional chaining.
* chore: bump version to 13.15.1
* fix(core): address PR review on fallback retry behavior
* test(ai): refactored auth storage test to use spyOn for cleaner mocks
- Refactored auth storage test to use vi.spyOn() instead of vi.mock() for cleaner mock management.
- Simplified mock type definitions by leveraging bun:test's Mock type import.
* chore: bump version to 13.15.2
* Fix stale OpenAI Responses replay across session boundaries (#534)
* Fix stale OpenAI Responses replay across session boundaries
Fixes #505
* Fix CI tests for session replay change
* Harden session reload and switch rollback
* Guard session switch snapshots
* fix(coding-agent): preserve responses replay snapshots
* Normalize pasted image formats before attach (#543)
Co-authored-by: iter <itertoolz@gmail.com>
* Allow overriding the Codex web search model (#516)
* Allow codex web search model override
* Handle blank codex web search model
* feat(ai): add gemini-3.1-pro-preview models to google-vertex provider (#521)
Add gemini-3.1-pro-preview and gemini-3.1-pro-preview-customtools to
the google-vertex provider in models.json, matching the existing
google-generative-ai entries.
Fixes #520
Co-authored-by: Muness Castle <munesscastle@artium.ai>
* Make temporary model selector keybinding configurable (#539)
Fixes #533
* chore: bump models.json
* refactor(coding-agent): migrated test mocks to vitest spyOn with Symbol.dispose cleanup
- Migrated test mocking from bun:test mock API to vitest spyOn pattern across 9 test files.
- Extracted mock setup logic into reusable helper functions with Symbol.dispose cleanup pattern.
- Replaced manual beforeEach/afterEach and try-finally blocks with TypeScript 5.2 using declarations.
- Removed 178 lines of boilerplate mock initialization and restoration code from test suite.
* chore: bump version to 13.15.3
* fix(ask): restore prompt-style enter handling
* fix(tui): avoid blank scrollback regressions
* fix(models): keep same-id replacements authoritative
* fix(tui): enforce collapsed line budgets
* fix(models): unify selector role sources
* fix(rules): dedupe always-apply prompt injection
* fix(browser): show saved screenshot path
* style: format merged PR fixes
* refactor(coding-agent): restructured screenshot and prompt utilities into focused helpers
- Extracted screenshot formatting logic into dedicated `formatScreenshot()` function with options support.
- Consolidated prompt source deduplication into `dedupePromptSource()` helper to prevent rule duplication.
- Refactored editor text sanitization to use `replaceTabs()` utility for consistent tab width handling.
- Added test coverage verifying editor respects configured tab width when loading text programmatically.
* refactor(coding-agent): restructured validation and rendering for consistency
- Refactored theme color validation to use single source of truth with THEME_COLOR_RECORD object.
- Simplified model registry to defer per-model overrides to dedicated method and use constant for role IDs.
- Refactored tree list rendering to pre-render items once for consistent line counts across phases.
- Refactored question result formatting to use early returns and consistently include question ID in output.
- Updated hook editor hint text to include ctrl+g external editor option when prompt style is enabled.
- Removed unused isLogicalLineStart property from LayoutLine interface in editor component.
* revert: 535 due to TUI regressions
* test(coding-agent): corrected hook-editor assertion for keybinding render
- Corrected assertion in hook-editor test to verify external editor keybinding is rendered.
* feat(tools): added root path alias to resolve bare / to working directory
- Added root path alias feature to resolve bare `/` to session working directory in path resolution.
- Updated browser tool to use `resolveToCwd()` for consistent workspace-relative path handling.
- Added comprehensive test suite validating root path alias resolution across grep, read, find, ast_grep, and ast_edit tools.
* feat(prompts/tools): clarified hashline block boundary handling with examples
- Improved hashline tool documentation with clearer guidance on block boundary handling and closing delimiter duplication prevention.
- Added concrete example demonstrating correct anchor placement when replacing entire blocks including closing braces.
- Reorganized boundary duplication warnings into actionable self-check guidance with visual comparison steps.
* chore: bump version to 13.16.0
* fix(coding-agent): fixed python kernel startup hangs (#548)
* fix(coding-agent): fixed python kernel startup hangs
* fix(coding-agent): fixed startup timeout regressions
* fix(coding-agent): preserved startup cancellation typing
* perf(pi-natives): optimized memory allocation with MiMalloc integration
- Integrated MiMalloc as global allocator to improve memory allocation performance.
* feat: fff
- Added SearchDb class for stateful shared search database instances enabling persistent file indexing and frecency tracking across grep, glob, and fuzzyFind operations.
- Added optional db parameter to grep(), glob(), and fuzzyFind() functions for database-backed searching with improved performance via cached file indices.
- Replaced grep-searcher with fff-grep and added fff-search dependency for enhanced file discovery and search capabilities with memory-mapped file support.
- Migrated fuzzy file discovery from fd module to fff module with SearchDb integration for stateful caching and improved search performance.
- Exported SearchDb type from @oh-my-pi/pi-natives public API for type-safe usage in grep, glob, and fuzzyFind workflows.
* feat(pi-natives): added unified picker coordination for file search operations
- Added `wait_for_picker_scan()` function to search_db module with cancellation token support for polling picker scan completion.
- Integrated picker-based file search into glob matching logic with `collect_files_from_picker()` helper to reuse shared SearchDb picker results.
- Refactored fff and grep modules to use centralized `wait_for_picker_scan()` wrapper instead of direct FilePicker calls, improving cancellation handling.
- Propagated SearchDb instance through agent session initialization and input controller to enable unified file picker coordination across search operations.
* chore: bump version to 13.16.1
* fix: install zig in CI
* feat(tui): make inline image max-width configurable via tui.maxInlineImageColumns (#551)
* feat(tui): make inline image max-width configurable via tui.maxInlineImageColumns
* fix(tui): handle 0 as unlimited in maxInlineImageColumns; drop || undefined coercion
* feat(browser): auto-detect NixOS and use system Chromium (#550)
Puppeteer's bundled Chromium is a dynamically-linked FHS binary that
cannot run on NixOS. On startup, resolveSystemChromium() checks for
/etc/NIXOS and searches for a usable binary in order:
1. chromium on PATH
2. chromium-browser on PATH
3. ~/.nix-profile/bin/chromium
4. /run/current-system/sw/bin/chromium
The resolved path is passed as executablePath to puppeteer.launch().
Result is cached per process. On non-NixOS systems the function returns
undefined immediately, leaving Puppeteer's default resolution intact.
* feat(pi-natives): added automatic parenthesis escaping in regex patterns
- Added automatic escaping of unescaped parentheses in regex patterns when group syntax errors occur, enabling literal function call patterns like `fetchAnthropicProvider(` to work as search queries.
- Extracted regex matcher builder into separate function for reusability and error recovery logic.
- Added 2 test cases validating parenthesis escaping behavior for both escaped and literal parentheses.
- Fixed documentation formatting in sanitize_braces comment.
* style: reformat
* chore: bump version to 13.16.2
* fix: only show update banner when npm version is strictly newer (#552)
* fix(ai): corrected OAuth credential updates to replace in-place instead of accumulating soft-deleted rows
- Fixed OAuth credential updates to replace matching credentials in-place rather than creating disabled rows, preventing unbounded accumulation of soft-deleted credentials.
- Modified OAuth credential saving to preserve unrelated identities instead of replacing all credentials for a provider.
- Updated credential identity resolution to use provider context for more accurate email deduplication.
- Implemented upsertAuthCredentialForProvider method to handle credential matching and in-place updates.
- Added 5 test cases covering credential preservation across reauth, multi-account scenarios, and stale cache handling.
* chore: bump version to 13.16.3
* feat: introduced unified range API for hashline edits and model catalog updates
- Simplified hashline edit location API by replacing separate `line` and `block` properties with unified `range` property accepting `{ pos, end }` anchors.
- Renamed hashline helper functions from `hlineref`/`hlinefull` to `href`/`hline` for improved brevity and consistency.
- Added detection for `kysely-codegen` generated files in auto-generated file guard with corresponding test coverage.
- Added 13 new AI model configurations and updated token limits and pricing for existing models across multiple providers.
- Enhanced file type validation in grep native to reject symlinks, FIFOs, sockets, and non-regular files with improved error handling.
* chore: bump version to 13.16.4
* fix: pin rustc-hash to 2.1.1 to avoid SIGILL on CI
rustc-hash 2.1.2 (released today) refactored hash_bytes to use
split_first_chunk, which produces illegal instructions when compiled
with nightly + -C target-cpu=x86-64-v3 on CI runners.
* fix(ci): pin nightly to 2026-03-27 to avoid codegen SIGILL regression
Today's nightly produces illegal instructions when compiled with
-C target-cpu=x86-64-v3. Reverts the unnecessary rustc-hash pin from
the previous commit since the real cause is the nightly compiler.
Also lets Cargo.lock return to rustc-hash 2.1.2 (not the culprit).
* fix(ci): add rustup target fallback for pinned nightly cross-compile
* fix(coding-agent): do not prompt to use grep and find tools if they are disabled (#566)
Co-authored-by: le-cameleon <200889489+le-cameleon@users.noreply.github.com>
* fix(natives): skipped grep special files (#565)
avoided opening fifos and other special filesystem nodes during grep and added fifo regressions in native and coding-agent tests.
* fix: skill baseDir regex fails on Windows backslash paths (#554)
The regex that strips SKILL.md from the path to compute baseDir only
matches forward slashes. On Windows where paths use backslashes, the
replace is a no-op and baseDir equals the full SKILL.md file path.
This breaks sub-path resolution for skills: the subpath gets appended
to the SKILL.md file path instead of the skill directory.
Fix: use character class matching both path separators.
---------
Co-authored-by: deadcode-walker <268043493+deadcode-walker@users.noreply.github.com>
Co-authored-by: Rens Tillmann <rens@super-forms.com>
Co-authored-by: haiyang.zhou <haiyang.zhou@seamoney.com>
Co-authored-by: Leo P <junk@slact.net>
Co-authored-by: Zakhar Kogan <36503576+zaharkogan@users.noreply.github.com>
Co-authored-by: Vu Anh Nguyen <vuanhng00@gmail.com>
Co-authored-by: can1357 <me@can.ac>
Co-authored-by: daandden <64765666+daandden@users.noreply.github.com>
Co-authored-by: iter <72358817+itertea@users.noreply.github.com>
Co-authored-by: iter <itertoolz@gmail.com>
Co-authored-by: Cheol Kang <dev@cheol.me>
Co-authored-by: Muness Castle <931+muness@users.noreply.github.com>
Co-authored-by: Muness Castle <munesscastle@artium.ai>
Co-authored-by: zamo <falby97@proton.me>
Co-authored-by: elikoga <elikowa@gmail.com>
Co-authored-by: BayLee4 <63376748+BayLee4@users.noreply.github.com>
Co-authored-by: le-cameleon <200889489+le-cameleon@users.noreply.github.com>
Co-authored-by: Wiedzmin <56316383+art-wiedzmin@users.noreply.github.com>
588 lines
22 KiB
TypeScript
588 lines
22 KiB
TypeScript
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
|
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
import { Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
|
import { settings } from "../../config/settings";
|
|
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
|
import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
|
|
import { TodoReminderComponent } from "../../modes/components/todo-reminder";
|
|
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
|
|
import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
|
|
import { getSymbolTheme, theme } from "../../modes/theme/theme";
|
|
import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
|
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
import type { ExitPlanModeDetails } from "../../tools";
|
|
|
|
export class EventController {
|
|
#lastReadGroup: ReadToolGroupComponent | undefined = undefined;
|
|
#lastThinkingCount = 0;
|
|
#renderedCustomMessages = new Set<string>();
|
|
#lastIntent: string | undefined = undefined;
|
|
#backgroundToolCallIds = new Set<string>();
|
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
|
constructor(private ctx: InteractiveModeContext) {}
|
|
|
|
#resetReadGroup(): void {
|
|
this.#lastReadGroup = undefined;
|
|
}
|
|
|
|
#getReadGroup(): ReadToolGroupComponent {
|
|
if (!this.#lastReadGroup) {
|
|
this.ctx.chatContainer.addChild(new Text("", 0, 0));
|
|
const group = new ReadToolGroupComponent();
|
|
group.setExpanded(this.ctx.toolOutputExpanded);
|
|
this.ctx.chatContainer.addChild(group);
|
|
this.#lastReadGroup = group;
|
|
}
|
|
return this.#lastReadGroup;
|
|
}
|
|
|
|
#trackReadToolCall(toolCallId: string, args: unknown): void {
|
|
if (!toolCallId) return;
|
|
const normalizedArgs =
|
|
args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
|
|
this.#readToolCallArgs.set(toolCallId, normalizedArgs);
|
|
const assistantComponent = this.ctx.streamingComponent ?? this.#lastAssistantComponent;
|
|
if (assistantComponent) {
|
|
this.#readToolCallAssistantComponents.set(toolCallId, assistantComponent);
|
|
}
|
|
}
|
|
|
|
#clearReadToolCall(toolCallId: string): void {
|
|
this.#readToolCallArgs.delete(toolCallId);
|
|
this.#readToolCallAssistantComponents.delete(toolCallId);
|
|
}
|
|
|
|
#inlineReadToolImages(
|
|
toolCallId: string,
|
|
result: { content: Array<{ type: string; data?: string; mimeType?: string }> },
|
|
): boolean {
|
|
if (!settings.get("terminal.showImages")) return false;
|
|
const assistantComponent = this.#readToolCallAssistantComponents.get(toolCallId);
|
|
if (!assistantComponent) return false;
|
|
const images: ImageContent[] = result.content
|
|
.filter(
|
|
(content): content is ImageContent =>
|
|
content.type === "image" && typeof content.data === "string" && typeof content.mimeType === "string",
|
|
)
|
|
.map(content => ({ type: "image", data: content.data, mimeType: content.mimeType }));
|
|
if (images.length === 0) return false;
|
|
assistantComponent.setToolResultImages(toolCallId, images);
|
|
return true;
|
|
}
|
|
#updateWorkingMessageFromIntent(intent: string | undefined): void {
|
|
const trimmed = intent?.trim();
|
|
if (!trimmed || trimmed === this.#lastIntent) return;
|
|
this.#lastIntent = trimmed;
|
|
this.ctx.setWorkingMessage(`${trimmed} (esc to interrupt)`);
|
|
}
|
|
|
|
subscribeToAgent(): void {
|
|
this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
|
|
await this.handleEvent(event);
|
|
});
|
|
}
|
|
|
|
async handleEvent(event: AgentSessionEvent): Promise<void> {
|
|
if (!this.ctx.isInitialized) {
|
|
await this.ctx.init();
|
|
}
|
|
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorTopBorder();
|
|
|
|
switch (event.type) {
|
|
case "agent_start":
|
|
this.#lastIntent = undefined;
|
|
this.#readToolCallArgs.clear();
|
|
this.#readToolCallAssistantComponents.clear();
|
|
this.#lastAssistantComponent = undefined;
|
|
if (this.ctx.retryEscapeHandler) {
|
|
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
|
|
this.ctx.retryEscapeHandler = undefined;
|
|
}
|
|
if (this.ctx.retryLoader) {
|
|
this.ctx.retryLoader.stop();
|
|
this.ctx.retryLoader = undefined;
|
|
this.ctx.statusContainer.clear();
|
|
}
|
|
this.ctx.ensureLoadingAnimation();
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
|
|
case "message_start":
|
|
if (event.message.role === "hookMessage" || event.message.role === "custom") {
|
|
const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
|
|
if (this.#renderedCustomMessages.has(signature)) {
|
|
break;
|
|
}
|
|
this.#renderedCustomMessages.add(signature);
|
|
this.#resetReadGroup();
|
|
this.ctx.addMessageToChat(event.message);
|
|
this.ctx.ui.requestRender();
|
|
} else if (event.message.role === "user") {
|
|
const textContent = this.ctx.getUserMessageText(event.message);
|
|
const imageCount =
|
|
typeof event.message.content === "string"
|
|
? 0
|
|
: event.message.content.filter(content => content.type === "image").length;
|
|
const signature = `${textContent}\u0000${imageCount}`;
|
|
|
|
this.#resetReadGroup();
|
|
if (this.ctx.optimisticUserMessageSignature !== signature) {
|
|
this.ctx.addMessageToChat(event.message);
|
|
}
|
|
this.ctx.optimisticUserMessageSignature = undefined;
|
|
|
|
if (!event.message.synthetic) {
|
|
this.ctx.editor.setText("");
|
|
this.ctx.updatePendingMessagesDisplay();
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
} else if (event.message.role === "fileMention") {
|
|
this.#resetReadGroup();
|
|
this.ctx.addMessageToChat(event.message);
|
|
this.ctx.ui.requestRender();
|
|
} else if (event.message.role === "assistant") {
|
|
this.#lastThinkingCount = 0;
|
|
this.#resetReadGroup();
|
|
this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock);
|
|
this.ctx.streamingMessage = event.message;
|
|
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
|
|
this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
break;
|
|
|
|
case "message_update":
|
|
if (this.ctx.streamingComponent && event.message.role === "assistant") {
|
|
this.ctx.streamingMessage = event.message;
|
|
this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
|
|
|
|
const thinkingCount = this.ctx.streamingMessage.content.filter(
|
|
content => content.type === "thinking" && content.thinking.trim(),
|
|
).length;
|
|
if (thinkingCount > this.#lastThinkingCount) {
|
|
this.#resetReadGroup();
|
|
this.#lastThinkingCount = thinkingCount;
|
|
}
|
|
|
|
for (const content of this.ctx.streamingMessage.content) {
|
|
if (content.type !== "toolCall") continue;
|
|
if (content.name === "read") {
|
|
this.#trackReadToolCall(content.id, content.arguments);
|
|
const component = this.ctx.pendingTools.get(content.id);
|
|
if (component) {
|
|
component.updateArgs(content.arguments, content.id);
|
|
} else {
|
|
const group = this.#getReadGroup();
|
|
group.updateArgs(content.arguments, content.id);
|
|
this.ctx.pendingTools.set(content.id, group);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
|
|
// Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
|
|
const renderArgs =
|
|
"partialJson" in content
|
|
? { ...content.arguments, __partialJson: content.partialJson }
|
|
: content.arguments;
|
|
if (!this.ctx.pendingTools.has(content.id)) {
|
|
this.#resetReadGroup();
|
|
this.ctx.chatContainer.addChild(new Text("", 0, 0));
|
|
const tool = this.ctx.session.getToolByName(content.name);
|
|
const component = new ToolExecutionComponent(
|
|
content.name,
|
|
renderArgs,
|
|
{
|
|
showImages: settings.get("terminal.showImages"),
|
|
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
|
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
|
},
|
|
tool,
|
|
this.ctx.ui,
|
|
this.ctx.sessionManager.getCwd(),
|
|
);
|
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
this.ctx.chatContainer.addChild(component);
|
|
this.ctx.pendingTools.set(content.id, component);
|
|
} else {
|
|
const component = this.ctx.pendingTools.get(content.id);
|
|
if (component) {
|
|
component.updateArgs(renderArgs, content.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update working message with intent from streamed tool arguments
|
|
for (const content of this.ctx.streamingMessage.content) {
|
|
if (content.type !== "toolCall") continue;
|
|
const args = content.arguments;
|
|
if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) continue;
|
|
this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
break;
|
|
|
|
case "message_end":
|
|
if (event.message.role === "user") break;
|
|
if (this.ctx.streamingComponent && event.message.role === "assistant") {
|
|
this.ctx.streamingMessage = event.message;
|
|
let errorMessage: string | undefined;
|
|
if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
|
|
const retryAttempt = this.ctx.session.retryAttempt;
|
|
errorMessage =
|
|
retryAttempt > 0
|
|
? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
|
|
: "Operation aborted";
|
|
this.ctx.streamingMessage.errorMessage = errorMessage;
|
|
}
|
|
if (this.ctx.session.isTtsrAbortPending && this.ctx.streamingMessage.stopReason === "aborted") {
|
|
const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
|
|
this.ctx.streamingComponent.updateContent(msgWithoutAbort);
|
|
} else {
|
|
this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
|
|
}
|
|
|
|
if (
|
|
this.ctx.streamingMessage.stopReason !== "aborted" &&
|
|
this.ctx.streamingMessage.stopReason !== "error"
|
|
) {
|
|
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
component.setArgsComplete(toolCallId);
|
|
}
|
|
}
|
|
this.#lastAssistantComponent = this.ctx.streamingComponent;
|
|
this.#lastAssistantComponent.setUsageInfo(event.message.usage);
|
|
this.ctx.streamingComponent = undefined;
|
|
this.ctx.streamingMessage = undefined;
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorTopBorder();
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
|
|
case "tool_execution_start": {
|
|
this.#updateWorkingMessageFromIntent(event.intent);
|
|
if (!this.ctx.pendingTools.has(event.toolCallId)) {
|
|
if (event.toolName === "read") {
|
|
this.#trackReadToolCall(event.toolCallId, event.args);
|
|
const component = this.ctx.pendingTools.get(event.toolCallId);
|
|
if (component) {
|
|
component.updateArgs(event.args, event.toolCallId);
|
|
} else {
|
|
const group = this.#getReadGroup();
|
|
group.updateArgs(event.args, event.toolCallId);
|
|
this.ctx.pendingTools.set(event.toolCallId, group);
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
this.#resetReadGroup();
|
|
const tool = this.ctx.session.getToolByName(event.toolName);
|
|
const component = new ToolExecutionComponent(
|
|
event.toolName,
|
|
event.args,
|
|
{
|
|
showImages: settings.get("terminal.showImages"),
|
|
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
|
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
|
},
|
|
tool,
|
|
this.ctx.ui,
|
|
this.ctx.sessionManager.getCwd(),
|
|
);
|
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
this.ctx.chatContainer.addChild(component);
|
|
this.ctx.pendingTools.set(event.toolCallId, component);
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "tool_execution_update": {
|
|
const component = this.ctx.pendingTools.get(event.toolCallId);
|
|
if (component) {
|
|
const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async
|
|
?.state;
|
|
const isFinalAsyncState = asyncState === "completed" || asyncState === "failed";
|
|
component.updateResult(
|
|
{ ...event.partialResult, isError: asyncState === "failed" },
|
|
!isFinalAsyncState,
|
|
event.toolCallId,
|
|
);
|
|
if (isFinalAsyncState) {
|
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "tool_execution_end": {
|
|
if (event.toolName === "read") {
|
|
if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
|
|
const component = this.ctx.pendingTools.get(event.toolCallId);
|
|
if (component) {
|
|
component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
|
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
}
|
|
const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
|
|
if (asyncState === "running") {
|
|
this.#backgroundToolCallIds.add(event.toolCallId);
|
|
} else {
|
|
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
this.#clearReadToolCall(event.toolCallId);
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
} else {
|
|
let component = this.ctx.pendingTools.get(event.toolCallId);
|
|
if (!component) {
|
|
const group = this.#getReadGroup();
|
|
const args = this.#readToolCallArgs.get(event.toolCallId);
|
|
if (args) {
|
|
group.updateArgs(args, event.toolCallId);
|
|
}
|
|
component = group;
|
|
this.ctx.pendingTools.set(event.toolCallId, group);
|
|
}
|
|
const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
|
|
const isBackgroundRunning = asyncState === "running";
|
|
component.updateResult(
|
|
{ ...event.result, isError: event.isError },
|
|
isBackgroundRunning,
|
|
event.toolCallId,
|
|
);
|
|
if (isBackgroundRunning) {
|
|
this.#backgroundToolCallIds.add(event.toolCallId);
|
|
} else {
|
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
this.#clearReadToolCall(event.toolCallId);
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
} else {
|
|
const component = this.ctx.pendingTools.get(event.toolCallId);
|
|
if (component) {
|
|
const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
|
|
const isBackgroundRunning = asyncState === "running";
|
|
component.updateResult(
|
|
{ ...event.result, isError: event.isError },
|
|
isBackgroundRunning,
|
|
event.toolCallId,
|
|
);
|
|
if (isBackgroundRunning) {
|
|
this.#backgroundToolCallIds.add(event.toolCallId);
|
|
} else {
|
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
}
|
|
// Update todo display when todo_write tool completes
|
|
if (event.toolName === "todo_write" && !event.isError) {
|
|
const details = event.result.details as { phases?: TodoPhase[] } | undefined;
|
|
if (details?.phases) {
|
|
this.ctx.setTodos(details.phases);
|
|
}
|
|
} else if (event.toolName === "todo_write" && event.isError) {
|
|
const textContent = event.result.content.find(
|
|
(content: { type: string; text?: string }) => content.type === "text",
|
|
)?.text;
|
|
this.ctx.showWarning(
|
|
`Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
|
|
);
|
|
}
|
|
if (event.toolName === "exit_plan_mode" && !event.isError) {
|
|
const details = event.result.details as ExitPlanModeDetails | undefined;
|
|
if (details) {
|
|
await this.ctx.handleExitPlanModeTool(details);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "agent_end":
|
|
if (this.ctx.loadingAnimation) {
|
|
this.ctx.loadingAnimation.stop();
|
|
this.ctx.loadingAnimation = undefined;
|
|
this.ctx.statusContainer.clear();
|
|
}
|
|
if (this.ctx.streamingComponent) {
|
|
this.ctx.chatContainer.removeChild(this.ctx.streamingComponent);
|
|
this.ctx.streamingComponent = undefined;
|
|
this.ctx.streamingMessage = undefined;
|
|
}
|
|
await this.ctx.flushPendingModelSwitch();
|
|
for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
|
|
if (!this.#backgroundToolCallIds.has(toolCallId)) {
|
|
this.ctx.pendingTools.delete(toolCallId);
|
|
}
|
|
}
|
|
this.#backgroundToolCallIds = new Set(
|
|
Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
|
|
);
|
|
this.#readToolCallArgs.clear();
|
|
this.#readToolCallAssistantComponents.clear();
|
|
this.#lastAssistantComponent = undefined;
|
|
this.ctx.ui.requestRender();
|
|
this.sendCompletionNotification();
|
|
break;
|
|
|
|
case "auto_compaction_start": {
|
|
this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
|
|
this.ctx.editor.onEscape = () => {
|
|
this.ctx.session.abortCompaction();
|
|
};
|
|
this.ctx.statusContainer.clear();
|
|
const reasonText = event.reason === "overflow" ? "Context overflow detected, " : "";
|
|
const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
|
|
this.ctx.autoCompactionLoader = new Loader(
|
|
this.ctx.ui,
|
|
spinner => theme.fg("accent", spinner),
|
|
text => theme.fg("muted", text),
|
|
`${reasonText}${actionLabel}… (esc to cancel)`,
|
|
getSymbolTheme().spinnerFrames,
|
|
);
|
|
this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader);
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "auto_compaction_end": {
|
|
if (this.ctx.autoCompactionEscapeHandler) {
|
|
this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler;
|
|
this.ctx.autoCompactionEscapeHandler = undefined;
|
|
}
|
|
if (this.ctx.autoCompactionLoader) {
|
|
this.ctx.autoCompactionLoader.stop();
|
|
this.ctx.autoCompactionLoader = undefined;
|
|
this.ctx.statusContainer.clear();
|
|
}
|
|
const isHandoffAction = event.action === "handoff";
|
|
if (event.aborted) {
|
|
this.ctx.showStatus(
|
|
isHandoffAction ? "Auto-handoff cancelled" : "Auto context-full maintenance cancelled",
|
|
);
|
|
} else if (event.result) {
|
|
this.ctx.rebuildChatFromMessages();
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorTopBorder();
|
|
} else if (event.errorMessage) {
|
|
this.ctx.showWarning(event.errorMessage);
|
|
} else if (isHandoffAction) {
|
|
this.ctx.chatContainer.clear();
|
|
this.ctx.rebuildChatFromMessages();
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorTopBorder();
|
|
await this.ctx.reloadTodos();
|
|
this.ctx.showStatus("Auto-handoff completed");
|
|
} else if (event.skipped) {
|
|
// Benign skip: no model selected, no candidate models available, or nothing
|
|
// to compact yet. Not a failure — suppress the warning.
|
|
} else {
|
|
this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance");
|
|
}
|
|
await this.ctx.flushCompactionQueue({ willRetry: event.willRetry });
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "auto_retry_start": {
|
|
this.ctx.retryEscapeHandler = this.ctx.editor.onEscape;
|
|
this.ctx.editor.onEscape = () => {
|
|
this.ctx.session.abortRetry();
|
|
};
|
|
this.ctx.statusContainer.clear();
|
|
const delaySeconds = Math.round(event.delayMs / 1000);
|
|
this.ctx.retryLoader = new Loader(
|
|
this.ctx.ui,
|
|
spinner => theme.fg("warning", spinner),
|
|
text => theme.fg("muted", text),
|
|
`Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s… (esc to cancel)`,
|
|
getSymbolTheme().spinnerFrames,
|
|
);
|
|
this.ctx.statusContainer.addChild(this.ctx.retryLoader);
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "auto_retry_end": {
|
|
if (this.ctx.retryEscapeHandler) {
|
|
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
|
|
this.ctx.retryEscapeHandler = undefined;
|
|
}
|
|
if (this.ctx.retryLoader) {
|
|
this.ctx.retryLoader.stop();
|
|
this.ctx.retryLoader = undefined;
|
|
this.ctx.statusContainer.clear();
|
|
}
|
|
if (!event.success) {
|
|
this.ctx.showError(
|
|
`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`,
|
|
);
|
|
}
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "retry_fallback_applied": {
|
|
this.ctx.showWarning(`Fallback: ${event.from} -> ${event.to}`);
|
|
break;
|
|
}
|
|
|
|
case "retry_fallback_succeeded": {
|
|
this.ctx.showStatus(`Fallback succeeded on ${event.model}`);
|
|
break;
|
|
}
|
|
|
|
case "ttsr_triggered": {
|
|
const component = new TtsrNotificationComponent(event.rules);
|
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
this.ctx.chatContainer.addChild(component);
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "todo_reminder": {
|
|
const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
|
|
this.ctx.chatContainer.addChild(component);
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
case "todo_auto_clear":
|
|
await this.ctx.reloadTodos();
|
|
break;
|
|
}
|
|
}
|
|
|
|
sendCompletionNotification(): void {
|
|
if (this.ctx.isBackgrounded === false) return;
|
|
const notify = settings.get("completion.notify");
|
|
if (notify === "off") return;
|
|
const title = this.ctx.sessionManager.getSessionName();
|
|
const message = title ? `${title}: Complete` : "Complete";
|
|
TERMINAL.sendNotification(message);
|
|
}
|
|
|
|
async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
|
|
if (event.type !== "agent_end") {
|
|
return;
|
|
}
|
|
if (this.ctx.session.queuedMessageCount > 0 || this.ctx.session.isStreaming) {
|
|
return;
|
|
}
|
|
this.sendCompletionNotification();
|
|
await this.ctx.shutdown();
|
|
}
|
|
}
|