+10








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>
1448 lines
47 KiB
TypeScript
1448 lines
47 KiB
TypeScript
/**
|
|
* Interactive mode for the coding agent.
|
|
* Handles TUI rendering and user interaction, delegating business logic to AgentSession.
|
|
*/
|
|
import * as path from "node:path";
|
|
import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
|
|
import type { Component, SlashCommand } from "@oh-my-pi/pi-tui";
|
|
import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
import chalk from "chalk";
|
|
import { KeybindingsManager } from "../config/keybindings";
|
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
import { type Settings, settings } from "../config/settings";
|
|
import type {
|
|
ExtensionUIContext,
|
|
ExtensionUIDialogOptions,
|
|
ExtensionWidgetContent,
|
|
ExtensionWidgetOptions,
|
|
} from "../extensibility/extensions";
|
|
import type { CompactOptions } from "../extensibility/extensions/types";
|
|
import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
|
|
import { resolveLocalUrlToPath } from "../internal-urls";
|
|
import { renameApprovedPlanFile } from "../plan-mode/approved-plan";
|
|
import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
|
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
import { HistoryStorage } from "../session/history-storage";
|
|
import type { SessionContext, SessionManager } from "../session/session-manager";
|
|
import { getRecentSessions } from "../session/session-manager";
|
|
import { STTController, type SttState } from "../stt";
|
|
import type { ExitPlanModeDetails } from "../tools";
|
|
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
|
|
import type { AssistantMessageComponent } from "./components/assistant-message";
|
|
import type { BashExecutionComponent } from "./components/bash-execution";
|
|
import { CustomEditor } from "./components/custom-editor";
|
|
import { DynamicBorder } from "./components/dynamic-border";
|
|
import type { HookEditorComponent } from "./components/hook-editor";
|
|
import type { HookInputComponent } from "./components/hook-input";
|
|
import type { HookSelectorComponent } from "./components/hook-selector";
|
|
import type { PythonExecutionComponent } from "./components/python-execution";
|
|
import { StatusLineComponent } from "./components/status-line";
|
|
import type { ToolExecutionHandle } from "./components/tool-execution";
|
|
import { WelcomeComponent } from "./components/welcome";
|
|
import { BtwController } from "./controllers/btw-controller";
|
|
import { CommandController } from "./controllers/command-controller";
|
|
import { EventController } from "./controllers/event-controller";
|
|
import { ExtensionUiController } from "./controllers/extension-ui-controller";
|
|
import { InputController } from "./controllers/input-controller";
|
|
import { MCPCommandController } from "./controllers/mcp-command-controller";
|
|
import { SelectorController } from "./controllers/selector-controller";
|
|
import { SSHCommandController } from "./controllers/ssh-command-controller";
|
|
import { OAuthManualInputManager } from "./oauth-manual-input";
|
|
import { setMermaidRenderCallback } from "./theme/mermaid-cache";
|
|
import type { Theme } from "./theme/theme";
|
|
import {
|
|
getEditorTheme,
|
|
getMarkdownTheme,
|
|
getSymbolTheme,
|
|
onTerminalAppearanceChange,
|
|
onThemeChange,
|
|
theme,
|
|
} from "./theme/theme";
|
|
import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
|
|
import { UiHelpers } from "./utils/ui-helpers";
|
|
|
|
const EDITOR_MAX_HEIGHT_MIN = 6;
|
|
const EDITOR_MAX_HEIGHT_MAX = 18;
|
|
const EDITOR_RESERVED_ROWS = 12;
|
|
const EDITOR_FALLBACK_ROWS = 24;
|
|
|
|
/** Options for creating an InteractiveMode instance (for future API use) */
|
|
export interface InteractiveModeOptions {
|
|
/** Providers that were migrated during startup */
|
|
migratedProviders?: string[];
|
|
/** Warning message if model fallback occurred */
|
|
modelFallbackMessage?: string;
|
|
/** Initial message to send */
|
|
initialMessage?: string;
|
|
/** Initial images to include with the message */
|
|
initialImages?: ImageContent[];
|
|
/** Additional initial messages to queue */
|
|
initialMessages?: string[];
|
|
}
|
|
|
|
export class InteractiveMode implements InteractiveModeContext {
|
|
session: AgentSession;
|
|
sessionManager: SessionManager;
|
|
settings: Settings;
|
|
keybindings: KeybindingsManager;
|
|
agent: Agent;
|
|
historyStorage?: HistoryStorage;
|
|
|
|
ui: TUI;
|
|
chatContainer: Container;
|
|
pendingMessagesContainer: Container;
|
|
statusContainer: Container;
|
|
todoContainer: Container;
|
|
btwContainer: Container;
|
|
editor: CustomEditor;
|
|
editorContainer: Container;
|
|
hookWidgetContainerAbove: Container;
|
|
hookWidgetContainerBelow: Container;
|
|
statusLine: StatusLineComponent;
|
|
|
|
isInitialized = false;
|
|
isBackgrounded = false;
|
|
isBashMode = false;
|
|
toolOutputExpanded = false;
|
|
todoExpanded = false;
|
|
planModeEnabled = false;
|
|
planModePaused = false;
|
|
planModePlanFilePath: string | undefined = undefined;
|
|
todoPhases: TodoPhase[] = [];
|
|
hideThinkingBlock = false;
|
|
pendingImages: ImageContent[] = [];
|
|
compactionQueuedMessages: CompactionQueuedMessage[] = [];
|
|
pendingTools = new Map<string, ToolExecutionHandle>();
|
|
pendingBashComponents: BashExecutionComponent[] = [];
|
|
bashComponent: BashExecutionComponent | undefined = undefined;
|
|
pendingPythonComponents: PythonExecutionComponent[] = [];
|
|
pythonComponent: PythonExecutionComponent | undefined = undefined;
|
|
isPythonMode = false;
|
|
streamingComponent: AssistantMessageComponent | undefined = undefined;
|
|
streamingMessage: AssistantMessage | undefined = undefined;
|
|
loadingAnimation: Loader | undefined = undefined;
|
|
autoCompactionLoader: Loader | undefined = undefined;
|
|
retryLoader: Loader | undefined = undefined;
|
|
#pendingWorkingMessage: string | undefined;
|
|
readonly #defaultWorkingMessage = `Working… (esc to interrupt)`;
|
|
autoCompactionEscapeHandler?: () => void;
|
|
retryEscapeHandler?: () => void;
|
|
unsubscribe?: () => void;
|
|
onInputCallback?: (input: SubmittedUserInput) => void;
|
|
optimisticUserMessageSignature: string | undefined = undefined;
|
|
#pendingSubmittedInput: SubmittedUserInput | undefined;
|
|
lastSigintTime = 0;
|
|
lastEscapeTime = 0;
|
|
shutdownRequested = false;
|
|
#isShuttingDown = false;
|
|
hookSelector: HookSelectorComponent | undefined = undefined;
|
|
hookInput: HookInputComponent | undefined = undefined;
|
|
hookEditor: HookEditorComponent | undefined = undefined;
|
|
lastStatusSpacer: Spacer | undefined = undefined;
|
|
lastStatusText: Text | undefined = undefined;
|
|
fileSlashCommands: Set<string> = new Set();
|
|
skillCommands: Map<string, string> = new Map();
|
|
oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
|
|
|
|
#pendingSlashCommands: SlashCommand[] = [];
|
|
#cleanupUnsubscribe?: () => void;
|
|
readonly #version: string;
|
|
readonly #changelogMarkdown: string | undefined;
|
|
#planModePreviousTools: string[] | undefined;
|
|
#planModePreviousModel: Model | undefined;
|
|
#pendingModelSwitch: Model | undefined;
|
|
#planModeHasEntered = false;
|
|
readonly lspServers:
|
|
| Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>
|
|
| undefined = undefined;
|
|
mcpManager?: import("../mcp").MCPManager;
|
|
readonly #toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
|
|
|
|
readonly #btwController: BtwController;
|
|
readonly #commandController: CommandController;
|
|
readonly #eventController: EventController;
|
|
readonly #extensionUiController: ExtensionUiController;
|
|
readonly #inputController: InputController;
|
|
readonly #selectorController: SelectorController;
|
|
readonly #uiHelpers: UiHelpers;
|
|
#sttController: STTController | undefined;
|
|
#voiceAnimationInterval: NodeJS.Timeout | undefined;
|
|
#voiceHue = 0;
|
|
#voicePreviousShowHardwareCursor: boolean | null = null;
|
|
#voicePreviousUseTerminalCursor: boolean | null = null;
|
|
#resizeHandler?: () => void;
|
|
|
|
constructor(
|
|
session: AgentSession,
|
|
version: string,
|
|
changelogMarkdown: string | undefined = undefined,
|
|
setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
|
|
lspServers:
|
|
| Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>
|
|
| undefined = undefined,
|
|
mcpManager?: import("../mcp").MCPManager,
|
|
) {
|
|
this.session = session;
|
|
this.sessionManager = session.sessionManager;
|
|
this.settings = session.settings;
|
|
this.keybindings = KeybindingsManager.inMemory();
|
|
this.agent = session.agent;
|
|
this.#version = version;
|
|
this.#changelogMarkdown = changelogMarkdown;
|
|
this.#toolUiContextSetter = setToolUIContext;
|
|
this.lspServers = lspServers;
|
|
this.mcpManager = mcpManager;
|
|
|
|
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
|
|
this.ui.setClearOnShrink(settings.get("clearOnShrink"));
|
|
setMermaidRenderCallback(() => this.ui.requestRender());
|
|
this.chatContainer = new Container();
|
|
this.pendingMessagesContainer = new Container();
|
|
this.statusContainer = new Container();
|
|
this.todoContainer = new Container();
|
|
this.btwContainer = new Container();
|
|
this.editor = new CustomEditor(getEditorTheme());
|
|
this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
|
|
this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
|
|
this.editor.onAutocompleteCancel = () => {
|
|
this.ui.requestRender(true);
|
|
};
|
|
this.editor.onAutocompleteUpdate = () => {
|
|
this.ui.requestRender();
|
|
};
|
|
this.#syncEditorMaxHeight();
|
|
this.#resizeHandler = () => {
|
|
this.#syncEditorMaxHeight();
|
|
};
|
|
process.stdout.on("resize", this.#resizeHandler);
|
|
try {
|
|
this.historyStorage = HistoryStorage.open();
|
|
this.editor.setHistoryStorage(this.historyStorage);
|
|
} catch (error) {
|
|
logger.warn("History storage unavailable", { error: String(error) });
|
|
}
|
|
this.hookWidgetContainerAbove = new Container();
|
|
this.hookWidgetContainerAbove.addChild(new Spacer(1));
|
|
this.hookWidgetContainerBelow = new Container();
|
|
this.editorContainer = new Container();
|
|
this.editorContainer.addChild(this.editor);
|
|
this.statusLine = new StatusLineComponent(session);
|
|
this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled);
|
|
|
|
this.hideThinkingBlock = settings.get("hideThinkingBlock");
|
|
|
|
const builtinCommandNames = new Set(BUILTIN_SLASH_COMMANDS.map(c => c.name));
|
|
const hookCommands: SlashCommand[] = (
|
|
this.session.extensionRunner?.getRegisteredCommands(builtinCommandNames) ?? []
|
|
).map(cmd => ({
|
|
name: cmd.name,
|
|
description: cmd.description ?? "(hook command)",
|
|
getArgumentCompletions: cmd.getArgumentCompletions,
|
|
}));
|
|
|
|
// Convert custom commands (TypeScript) to SlashCommand format
|
|
const customCommands: SlashCommand[] = this.session.customCommands.map(loaded => ({
|
|
name: loaded.command.name,
|
|
description: `${loaded.command.description} (${loaded.source})`,
|
|
}));
|
|
|
|
// Build skill commands from session.skills (if enabled)
|
|
const skillCommandList: SlashCommand[] = [];
|
|
if (settings.get("skills.enableSkillCommands")) {
|
|
for (const skill of this.session.skills) {
|
|
const commandName = `skill:${skill.name}`;
|
|
this.skillCommands.set(commandName, skill.filePath);
|
|
skillCommandList.push({ name: commandName, description: skill.description });
|
|
}
|
|
}
|
|
|
|
// Store pending commands for init() where file commands are loaded async
|
|
this.#pendingSlashCommands = [...BUILTIN_SLASH_COMMANDS, ...hookCommands, ...customCommands, ...skillCommandList];
|
|
|
|
this.#uiHelpers = new UiHelpers(this);
|
|
this.#btwController = new BtwController(this);
|
|
this.#extensionUiController = new ExtensionUiController(this);
|
|
this.#eventController = new EventController(this);
|
|
this.#commandController = new CommandController(this);
|
|
this.#selectorController = new SelectorController(this);
|
|
this.#inputController = new InputController(this);
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
if (this.isInitialized) return;
|
|
|
|
this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
|
|
|
|
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
|
|
this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
|
|
|
|
await logger.timeAsync("InteractiveMode.init:slashCommands", () =>
|
|
this.refreshSlashCommandState(getProjectDir()),
|
|
);
|
|
|
|
// Get current model info for welcome screen
|
|
const modelName = this.session.model?.name ?? "Unknown";
|
|
const providerName = this.session.model?.provider ?? "Unknown";
|
|
|
|
// Get recent sessions
|
|
const recentSessions = await logger.timeAsync("InteractiveMode.init:recentSessions", () =>
|
|
getRecentSessions(this.sessionManager.getSessionDir()).then(sessions =>
|
|
sessions.map(s => ({
|
|
name: s.name,
|
|
timeAgo: s.timeAgo,
|
|
})),
|
|
),
|
|
);
|
|
|
|
// Convert LSP servers to welcome format
|
|
const lspServerInfo =
|
|
this.lspServers?.map(s => ({
|
|
name: s.name,
|
|
status: s.status as "ready" | "error" | "connecting",
|
|
fileTypes: s.fileTypes,
|
|
})) ?? [];
|
|
|
|
const startupQuiet = settings.get("startup.quiet");
|
|
|
|
for (const warning of this.session.configWarnings) {
|
|
this.ui.addChild(new Text(theme.fg("warning", `Warning: ${warning}`), 1, 0));
|
|
this.ui.addChild(new Spacer(1));
|
|
}
|
|
|
|
if (!startupQuiet) {
|
|
// Add welcome header
|
|
const welcome = new WelcomeComponent(this.#version, modelName, providerName, recentSessions, lspServerInfo);
|
|
|
|
// Setup UI layout
|
|
this.ui.addChild(new Spacer(1));
|
|
this.ui.addChild(welcome);
|
|
this.ui.addChild(new Spacer(1));
|
|
|
|
// Add changelog if provided
|
|
if (this.#changelogMarkdown) {
|
|
this.ui.addChild(new DynamicBorder());
|
|
if (settings.get("collapseChangelog")) {
|
|
const versionMatch = this.#changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
|
|
const latestVersion = versionMatch ? versionMatch[1] : this.#version;
|
|
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
|
|
this.ui.addChild(new Text(condensedText, 1, 0));
|
|
} else {
|
|
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
|
|
this.ui.addChild(new Spacer(1));
|
|
this.ui.addChild(new Markdown(this.#changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
|
|
this.ui.addChild(new Spacer(1));
|
|
}
|
|
this.ui.addChild(new DynamicBorder());
|
|
}
|
|
}
|
|
|
|
this.ui.addChild(this.chatContainer);
|
|
this.ui.addChild(this.pendingMessagesContainer);
|
|
this.ui.addChild(this.statusContainer);
|
|
this.ui.addChild(this.todoContainer);
|
|
this.ui.addChild(this.btwContainer);
|
|
this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
|
|
this.ui.addChild(this.hookWidgetContainerAbove);
|
|
this.ui.addChild(this.editorContainer);
|
|
this.ui.addChild(this.hookWidgetContainerBelow);
|
|
this.ui.setFocus(this.editor);
|
|
|
|
this.#inputController.setupKeyHandlers();
|
|
this.#inputController.setupEditorSubmitHandler();
|
|
|
|
// Load initial todos
|
|
await this.#loadTodoList();
|
|
|
|
// Start the UI
|
|
this.ui.start();
|
|
pushTerminalTitle();
|
|
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
this.#syncEditorMaxHeight();
|
|
this.isInitialized = true;
|
|
this.ui.requestRender(true);
|
|
|
|
// Initialize hooks with TUI-based UI context
|
|
await this.initHooksAndCustomTools();
|
|
|
|
// Restore mode from session (e.g. plan mode on resume)
|
|
await this.#restoreModeFromSession();
|
|
|
|
// Subscribe to agent events
|
|
this.#subscribeToAgent();
|
|
|
|
// Set up theme file watcher
|
|
onThemeChange(() => {
|
|
this.ui.invalidate();
|
|
this.updateEditorBorderColor();
|
|
this.ui.requestRender();
|
|
});
|
|
|
|
// Subscribe to terminal dark/light appearance changes.
|
|
// The terminal queries background color via OSC 11 at startup and on
|
|
// Mode 2031 notifications, computing luminance to detect dark/light.
|
|
this.ui.terminal.onAppearanceChange(mode => {
|
|
onTerminalAppearanceChange(mode);
|
|
});
|
|
|
|
// Set up git branch watcher
|
|
this.statusLine.watchBranch(() => {
|
|
this.updateEditorTopBorder();
|
|
this.ui.requestRender();
|
|
});
|
|
|
|
// Initial top border update
|
|
this.updateEditorTopBorder();
|
|
}
|
|
|
|
/** Reload slash commands and autocomplete for the provided working directory. */
|
|
async refreshSlashCommandState(cwd?: string): Promise<void> {
|
|
const basePath = cwd ?? this.sessionManager.getCwd();
|
|
const fileCommands = await loadSlashCommands({ cwd: basePath });
|
|
this.fileSlashCommands = new Set(fileCommands.map(cmd => cmd.name));
|
|
const fileSlashCommands: SlashCommand[] = fileCommands.map(cmd => ({
|
|
name: cmd.name,
|
|
description: cmd.description,
|
|
}));
|
|
const autocompleteProvider = this.#inputController.createAutocompleteProvider(
|
|
[...this.#pendingSlashCommands, ...fileSlashCommands],
|
|
basePath,
|
|
);
|
|
this.editor.setAutocompleteProvider(autocompleteProvider);
|
|
this.session.setSlashCommands(fileCommands);
|
|
}
|
|
|
|
async getUserInput(): Promise<SubmittedUserInput> {
|
|
const { promise, resolve } = Promise.withResolvers<SubmittedUserInput>();
|
|
this.onInputCallback = input => {
|
|
this.onInputCallback = undefined;
|
|
resolve(input);
|
|
};
|
|
return promise;
|
|
}
|
|
|
|
startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
|
|
const submission: SubmittedUserInput = {
|
|
text: input.text,
|
|
images: input.images,
|
|
cancelled: false,
|
|
started: false,
|
|
};
|
|
this.#pendingSubmittedInput = submission;
|
|
this.optimisticUserMessageSignature = `${submission.text}\u0000${submission.images?.length ?? 0}`;
|
|
this.addMessageToChat({
|
|
role: "user",
|
|
content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
|
|
attribution: "user",
|
|
timestamp: Date.now(),
|
|
});
|
|
this.editor.setText("");
|
|
this.ensureLoadingAnimation();
|
|
this.ui.requestRender();
|
|
return submission;
|
|
}
|
|
|
|
cancelPendingSubmission(): boolean {
|
|
const submission = this.#pendingSubmittedInput;
|
|
if (!submission || submission.started) {
|
|
return false;
|
|
}
|
|
|
|
submission.cancelled = true;
|
|
this.#pendingSubmittedInput = undefined;
|
|
this.optimisticUserMessageSignature = undefined;
|
|
this.#pendingWorkingMessage = undefined;
|
|
if (this.loadingAnimation) {
|
|
this.loadingAnimation.stop();
|
|
this.loadingAnimation = undefined;
|
|
this.statusContainer.clear();
|
|
}
|
|
this.pendingImages = submission.images ? [...submission.images] : [];
|
|
this.rebuildChatFromMessages();
|
|
this.editor.setText(submission.text);
|
|
this.updateEditorBorderColor();
|
|
this.ui.requestRender();
|
|
return true;
|
|
}
|
|
|
|
markPendingSubmissionStarted(input: SubmittedUserInput): boolean {
|
|
if (this.#pendingSubmittedInput !== input || input.cancelled) {
|
|
return false;
|
|
}
|
|
input.started = true;
|
|
return true;
|
|
}
|
|
|
|
finishPendingSubmission(input: SubmittedUserInput): void {
|
|
if (this.#pendingSubmittedInput === input) {
|
|
this.#pendingSubmittedInput = undefined;
|
|
}
|
|
}
|
|
|
|
#computeEditorMaxHeight(): number {
|
|
const rows = this.ui.terminal.rows;
|
|
const terminalRows = Number.isFinite(rows) && rows > 0 ? rows : EDITOR_FALLBACK_ROWS;
|
|
const maxHeight = terminalRows - EDITOR_RESERVED_ROWS;
|
|
return Math.max(EDITOR_MAX_HEIGHT_MIN, Math.min(EDITOR_MAX_HEIGHT_MAX, maxHeight));
|
|
}
|
|
|
|
#syncEditorMaxHeight(): void {
|
|
this.editor.setMaxHeight(this.#computeEditorMaxHeight());
|
|
}
|
|
|
|
updateEditorBorderColor(): void {
|
|
if (this.isBashMode) {
|
|
this.editor.borderColor = theme.getBashModeBorderColor();
|
|
} else if (this.isPythonMode) {
|
|
this.editor.borderColor = theme.getPythonModeBorderColor();
|
|
} else {
|
|
const level = this.session.thinkingLevel ?? ThinkingLevel.Off;
|
|
this.editor.borderColor = theme.getThinkingBorderColor(level);
|
|
}
|
|
this.updateEditorTopBorder();
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
updateEditorTopBorder(): void {
|
|
const availableWidth = this.editor.getTopBorderAvailableWidth(this.ui.terminal.columns);
|
|
const topBorder = this.statusLine.getTopBorder(availableWidth);
|
|
this.editor.setTopBorder(topBorder);
|
|
}
|
|
|
|
rebuildChatFromMessages(): void {
|
|
this.chatContainer.clear();
|
|
const context = this.sessionManager.buildSessionContext();
|
|
this.renderSessionContext(context);
|
|
}
|
|
|
|
#formatTodoLine(todo: TodoItem, prefix: string): string {
|
|
const checkbox = theme.checkbox;
|
|
switch (todo.status) {
|
|
case "completed":
|
|
return theme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(todo.content)}`);
|
|
case "in_progress": {
|
|
const main = theme.fg("accent", `${prefix}${checkbox.unchecked} ${todo.content}`);
|
|
if (!todo.details) return main;
|
|
const detailLines = todo.details.split("\n").map(line => theme.fg("dim", `${prefix} ${line}`));
|
|
return [main, ...detailLines].join("\n");
|
|
}
|
|
case "abandoned":
|
|
return theme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(todo.content)}`);
|
|
default:
|
|
return theme.fg("dim", `${prefix}${checkbox.unchecked} ${todo.content}`);
|
|
}
|
|
}
|
|
|
|
#getActivePhase(phases: TodoPhase[]): TodoPhase | undefined {
|
|
const nonEmpty = phases.filter(phase => phase.tasks.length > 0);
|
|
const active = nonEmpty.find(phase =>
|
|
phase.tasks.some(task => task.status === "pending" || task.status === "in_progress"),
|
|
);
|
|
return active ?? nonEmpty[nonEmpty.length - 1];
|
|
}
|
|
|
|
#renderTodoList(): void {
|
|
this.todoContainer.clear();
|
|
const phases = this.todoPhases.filter(phase => phase.tasks.length > 0);
|
|
if (phases.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const indent = " ";
|
|
const hook = theme.tree.hook;
|
|
const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
|
|
|
|
if (!this.todoExpanded) {
|
|
const activePhase = this.#getActivePhase(phases);
|
|
if (!activePhase) return;
|
|
lines.push(`${indent}${theme.fg("accent", `${hook} ${activePhase.name}`)}`);
|
|
const visibleTasks = activePhase.tasks.slice(0, 5);
|
|
visibleTasks.forEach((todo, index) => {
|
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
lines.push(this.#formatTodoLine(todo, prefix));
|
|
});
|
|
if (visibleTasks.length < activePhase.tasks.length) {
|
|
const remaining = activePhase.tasks.length - visibleTasks.length;
|
|
lines.push(theme.fg("muted", `${indent} ${hook} +${remaining} more`));
|
|
}
|
|
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
return;
|
|
}
|
|
|
|
for (const phase of phases) {
|
|
lines.push(`${indent}${theme.fg("accent", `${hook} ${phase.name}`)}`);
|
|
phase.tasks.forEach((todo, index) => {
|
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
lines.push(this.#formatTodoLine(todo, prefix));
|
|
});
|
|
}
|
|
|
|
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
}
|
|
|
|
async #loadTodoList(): Promise<void> {
|
|
this.todoPhases = this.session.getTodoPhases();
|
|
this.#renderTodoList();
|
|
}
|
|
|
|
async #getPlanFilePath(): Promise<string> {
|
|
return "local://PLAN.md";
|
|
}
|
|
|
|
#resolvePlanFilePath(planFilePath: string): string {
|
|
if (planFilePath.startsWith("local://")) {
|
|
return resolveLocalUrlToPath(planFilePath, {
|
|
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
|
getSessionId: () => this.sessionManager.getSessionId(),
|
|
});
|
|
}
|
|
return path.resolve(this.sessionManager.getCwd(), planFilePath);
|
|
}
|
|
|
|
#updatePlanModeStatus(): void {
|
|
const status =
|
|
this.planModeEnabled || this.planModePaused
|
|
? {
|
|
enabled: this.planModeEnabled,
|
|
paused: this.planModePaused,
|
|
}
|
|
: undefined;
|
|
this.statusLine.setPlanModeStatus(status);
|
|
this.updateEditorTopBorder();
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
async #applyPlanModeModel(): Promise<void> {
|
|
const planModel = this.session.resolveRoleModel("plan");
|
|
if (!planModel) return;
|
|
const currentModel = this.session.model;
|
|
if (currentModel && currentModel.provider === planModel.provider && currentModel.id === planModel.id) {
|
|
return;
|
|
}
|
|
this.#planModePreviousModel = currentModel;
|
|
if (this.session.isStreaming) {
|
|
this.#pendingModelSwitch = planModel;
|
|
return;
|
|
}
|
|
try {
|
|
await this.session.setModelTemporary(planModel);
|
|
} catch (error) {
|
|
this.showWarning(
|
|
`Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Apply any deferred model switch after the current stream ends. */
|
|
async flushPendingModelSwitch(): Promise<void> {
|
|
const model = this.#pendingModelSwitch;
|
|
if (!model) return;
|
|
this.#pendingModelSwitch = undefined;
|
|
try {
|
|
await this.session.setModelTemporary(model);
|
|
} catch (error) {
|
|
this.showWarning(
|
|
`Failed to switch model after streaming: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Restore mode state from session entries on resume (e.g. plan mode). */
|
|
async #restoreModeFromSession(): Promise<void> {
|
|
const sessionContext = this.sessionManager.buildSessionContext();
|
|
if (sessionContext.mode === "plan") {
|
|
const planFilePath = sessionContext.modeData?.planFilePath as string | undefined;
|
|
await this.#enterPlanMode({ planFilePath });
|
|
} else if (sessionContext.mode === "plan_paused") {
|
|
this.planModePaused = true;
|
|
this.#planModeHasEntered = true;
|
|
this.#updatePlanModeStatus();
|
|
}
|
|
}
|
|
|
|
async #enterPlanMode(options?: { planFilePath?: string; workflow?: "parallel" | "iterative" }): Promise<void> {
|
|
if (this.planModeEnabled) {
|
|
return;
|
|
}
|
|
|
|
this.planModePaused = false;
|
|
|
|
const planFilePath = options?.planFilePath ?? (await this.#getPlanFilePath());
|
|
const previousTools = this.session.getActiveToolNames();
|
|
const hasExitTool = this.session.getToolByName("exit_plan_mode") !== undefined;
|
|
const planTools = hasExitTool ? [...previousTools, "exit_plan_mode"] : previousTools;
|
|
const uniquePlanTools = [...new Set(planTools)];
|
|
|
|
this.#planModePreviousTools = previousTools;
|
|
this.planModePlanFilePath = planFilePath;
|
|
this.planModeEnabled = true;
|
|
|
|
await this.session.setActiveToolsByName(uniquePlanTools);
|
|
this.session.setPlanModeState({
|
|
enabled: true,
|
|
planFilePath,
|
|
workflow: options?.workflow ?? "parallel",
|
|
reentry: this.#planModeHasEntered,
|
|
});
|
|
if (this.session.isStreaming) {
|
|
await this.session.sendPlanModeContext({ deliverAs: "steer" });
|
|
}
|
|
this.#planModeHasEntered = true;
|
|
await this.#applyPlanModeModel();
|
|
this.#updatePlanModeStatus();
|
|
this.sessionManager.appendModeChange("plan", { planFilePath });
|
|
this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
|
|
}
|
|
|
|
async #exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise<void> {
|
|
if (!this.planModeEnabled) {
|
|
return;
|
|
}
|
|
|
|
const previousTools = this.#planModePreviousTools;
|
|
if (previousTools && previousTools.length > 0) {
|
|
await this.session.setActiveToolsByName(previousTools);
|
|
}
|
|
if (this.#planModePreviousModel) {
|
|
if (this.session.isStreaming) {
|
|
this.#pendingModelSwitch = this.#planModePreviousModel;
|
|
} else {
|
|
await this.session.setModelTemporary(this.#planModePreviousModel);
|
|
}
|
|
}
|
|
|
|
this.session.setPlanModeState(undefined);
|
|
this.planModeEnabled = false;
|
|
this.planModePaused = options?.paused ?? false;
|
|
this.planModePlanFilePath = undefined;
|
|
this.#planModePreviousTools = undefined;
|
|
this.#planModePreviousModel = undefined;
|
|
this.#updatePlanModeStatus();
|
|
const paused = options?.paused ?? false;
|
|
this.sessionManager.appendModeChange(paused ? "plan_paused" : "none");
|
|
if (!options?.silent) {
|
|
this.showStatus(paused ? "Plan mode paused." : "Plan mode disabled.");
|
|
}
|
|
}
|
|
|
|
async #readPlanFile(planFilePath: string): Promise<string | null> {
|
|
const resolvedPath = this.#resolvePlanFilePath(planFilePath);
|
|
try {
|
|
return await Bun.file(resolvedPath).text();
|
|
} catch (error) {
|
|
if (isEnoent(error)) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
#renderPlanPreview(planContent: string): void {
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Plan Review")), 1, 1));
|
|
this.chatContainer.addChild(new Spacer(1));
|
|
this.chatContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme()));
|
|
this.chatContainer.addChild(new DynamicBorder());
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
async #approvePlan(
|
|
planContent: string,
|
|
options: { planFilePath: string; finalPlanFilePath: string },
|
|
): Promise<void> {
|
|
await renameApprovedPlanFile({
|
|
planFilePath: options.planFilePath,
|
|
finalPlanFilePath: options.finalPlanFilePath,
|
|
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
|
getSessionId: () => this.sessionManager.getSessionId(),
|
|
});
|
|
const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
|
|
await this.#exitPlanMode({ silent: true, paused: false });
|
|
await this.handleClearCommand();
|
|
// The new session has a fresh local:// root — persist the approved plan there
|
|
// so `local://<title>.md` resolves correctly in the execution session.
|
|
const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
|
|
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
|
getSessionId: () => this.sessionManager.getSessionId(),
|
|
});
|
|
await Bun.write(newLocalPath, planContent);
|
|
if (previousTools.length > 0) {
|
|
await this.session.setActiveToolsByName(previousTools);
|
|
}
|
|
this.session.setPlanReferencePath(options.finalPlanFilePath);
|
|
this.session.markPlanReferenceSent();
|
|
const prompt = renderPromptTemplate(planModeApprovedPrompt, {
|
|
planContent,
|
|
finalPlanFilePath: options.finalPlanFilePath,
|
|
});
|
|
await this.session.prompt(prompt, { synthetic: true });
|
|
}
|
|
|
|
async handlePlanModeCommand(initialPrompt?: string): Promise<void> {
|
|
if (this.planModeEnabled) {
|
|
const confirmed = await this.showHookConfirm(
|
|
"Exit plan mode?",
|
|
"This exits plan mode without approving a plan.",
|
|
);
|
|
if (!confirmed) return;
|
|
await this.#exitPlanMode({ paused: true });
|
|
return;
|
|
}
|
|
await this.#enterPlanMode();
|
|
if (initialPrompt && this.onInputCallback) {
|
|
this.onInputCallback(this.startPendingSubmission({ text: initialPrompt }));
|
|
}
|
|
}
|
|
|
|
async handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void> {
|
|
if (!this.planModeEnabled) {
|
|
this.showWarning("Plan mode is not active.");
|
|
return;
|
|
}
|
|
|
|
// Abort the agent to prevent it from continuing (e.g., calling exit_plan_mode
|
|
// again) while the popup is showing. The event listener fires asynchronously
|
|
// (agent's #emit is fire-and-forget), so without this the model sees "Plan
|
|
// ready for approval." and immediately calls exit_plan_mode in a loop.
|
|
await this.session.abort();
|
|
|
|
const planFilePath = details.planFilePath || this.planModePlanFilePath || (await this.#getPlanFilePath());
|
|
this.planModePlanFilePath = planFilePath;
|
|
const planContent = await this.#readPlanFile(planFilePath);
|
|
if (!planContent) {
|
|
this.showError(`Plan file not found at ${planFilePath}`);
|
|
return;
|
|
}
|
|
|
|
this.#renderPlanPreview(planContent);
|
|
const choice = await this.showHookSelector("Plan mode - next step", [
|
|
"Approve and execute",
|
|
"Refine plan",
|
|
"Stay in plan mode",
|
|
]);
|
|
|
|
if (choice === "Approve and execute") {
|
|
const finalPlanFilePath = details.finalPlanFilePath || planFilePath;
|
|
try {
|
|
await this.#approvePlan(planContent, { planFilePath, finalPlanFilePath });
|
|
} catch (error) {
|
|
this.showError(
|
|
`Failed to finalize approved plan: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (choice === "Refine plan") {
|
|
const refinement = await this.showHookInput("What should be refined?");
|
|
if (refinement) {
|
|
this.editor.setText(refinement);
|
|
}
|
|
}
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.loadingAnimation) {
|
|
this.loadingAnimation.stop();
|
|
this.loadingAnimation = undefined;
|
|
}
|
|
this.#cleanupMicAnimation();
|
|
if (this.#sttController) {
|
|
this.#sttController.dispose();
|
|
this.#sttController = undefined;
|
|
}
|
|
this.#extensionUiController.clearExtensionTerminalInputListeners();
|
|
this.#extensionUiController.clearHookWidgets();
|
|
this.statusLine.dispose();
|
|
if (this.#resizeHandler) {
|
|
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
this.#resizeHandler = undefined;
|
|
}
|
|
if (this.unsubscribe) {
|
|
this.unsubscribe();
|
|
}
|
|
if (this.#cleanupUnsubscribe) {
|
|
this.#cleanupUnsubscribe();
|
|
}
|
|
if (this.isInitialized) {
|
|
this.ui.stop();
|
|
this.isInitialized = false;
|
|
}
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
if (this.#isShuttingDown) return;
|
|
this.#isShuttingDown = true;
|
|
|
|
// Flush pending session writes before shutdown
|
|
await this.sessionManager.flush();
|
|
this.#btwController.dispose();
|
|
|
|
// Emit shutdown event to hooks
|
|
await this.session.dispose();
|
|
|
|
if (this.isInitialized) {
|
|
this.ui.requestRender(true);
|
|
}
|
|
|
|
// Wait for any pending renders to complete
|
|
// requestRender() uses process.nextTick(), so we wait one tick
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
|
|
// Drain any in-flight Kitty key release events before stopping.
|
|
// This prevents escape sequences from leaking to the parent shell over slow SSH.
|
|
await this.ui.terminal.drainInput(1000);
|
|
popTerminalTitle();
|
|
this.stop();
|
|
|
|
// Print resumption hint if this is a persisted session
|
|
const sessionId = this.sessionManager.getSessionId();
|
|
const sessionFile = this.sessionManager.getSessionFile();
|
|
if (sessionId && sessionFile) {
|
|
process.stderr.write(`\n${chalk.dim(`Resume this session with ${APP_NAME} --resume ${sessionId}`)}\n`);
|
|
}
|
|
|
|
await postmortem.quit(0);
|
|
}
|
|
|
|
async checkShutdownRequested(): Promise<void> {
|
|
if (!this.shutdownRequested) return;
|
|
await this.shutdown();
|
|
}
|
|
|
|
// Extension UI integration
|
|
setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void {
|
|
this.#toolUiContextSetter(uiContext, hasUI);
|
|
}
|
|
|
|
initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void {
|
|
this.#extensionUiController.initializeHookRunner(uiContext, hasUI);
|
|
}
|
|
|
|
createBackgroundUiContext(): ExtensionUIContext {
|
|
return this.#extensionUiController.createBackgroundUiContext();
|
|
}
|
|
|
|
// Event handling
|
|
async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
|
|
await this.#eventController.handleBackgroundEvent(event);
|
|
}
|
|
|
|
// UI helpers
|
|
showStatus(message: string, options?: { dim?: boolean }): void {
|
|
this.#uiHelpers.showStatus(message, options);
|
|
}
|
|
|
|
showError(message: string): void {
|
|
this.#pendingSubmittedInput = undefined;
|
|
this.optimisticUserMessageSignature = undefined;
|
|
this.#pendingWorkingMessage = undefined;
|
|
if (this.loadingAnimation) {
|
|
this.loadingAnimation.stop();
|
|
this.loadingAnimation = undefined;
|
|
this.statusContainer.clear();
|
|
}
|
|
this.#uiHelpers.showError(message);
|
|
}
|
|
|
|
showWarning(message: string): void {
|
|
this.#uiHelpers.showWarning(message);
|
|
}
|
|
|
|
ensureLoadingAnimation(): void {
|
|
if (!this.loadingAnimation) {
|
|
this.statusContainer.clear();
|
|
this.loadingAnimation = new Loader(
|
|
this.ui,
|
|
spinner => theme.fg("accent", spinner),
|
|
text => theme.fg("muted", text),
|
|
this.#defaultWorkingMessage,
|
|
getSymbolTheme().spinnerFrames,
|
|
);
|
|
this.statusContainer.addChild(this.loadingAnimation);
|
|
}
|
|
|
|
this.applyPendingWorkingMessage();
|
|
}
|
|
|
|
setWorkingMessage(message?: string): void {
|
|
if (message === undefined) {
|
|
this.#pendingWorkingMessage = undefined;
|
|
if (this.loadingAnimation) {
|
|
this.loadingAnimation.setMessage(this.#defaultWorkingMessage);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.loadingAnimation) {
|
|
this.loadingAnimation.setMessage(message);
|
|
return;
|
|
}
|
|
|
|
this.#pendingWorkingMessage = message;
|
|
}
|
|
|
|
applyPendingWorkingMessage(): void {
|
|
if (this.#pendingWorkingMessage === undefined) {
|
|
return;
|
|
}
|
|
|
|
const message = this.#pendingWorkingMessage;
|
|
this.#pendingWorkingMessage = undefined;
|
|
this.setWorkingMessage(message);
|
|
}
|
|
|
|
showNewVersionNotification(newVersion: string): void {
|
|
this.#uiHelpers.showNewVersionNotification(newVersion);
|
|
}
|
|
|
|
clearEditor(): void {
|
|
this.#uiHelpers.clearEditor();
|
|
}
|
|
|
|
updatePendingMessagesDisplay(): void {
|
|
this.#uiHelpers.updatePendingMessagesDisplay();
|
|
}
|
|
|
|
queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
|
|
this.#uiHelpers.queueCompactionMessage(text, mode);
|
|
}
|
|
|
|
flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
|
|
return this.#uiHelpers.flushCompactionQueue(options);
|
|
}
|
|
|
|
flushPendingBashComponents(): void {
|
|
this.#uiHelpers.flushPendingBashComponents();
|
|
}
|
|
|
|
isKnownSlashCommand(text: string): boolean {
|
|
return this.#uiHelpers.isKnownSlashCommand(text);
|
|
}
|
|
|
|
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
|
|
this.#uiHelpers.addMessageToChat(message, options);
|
|
}
|
|
|
|
renderSessionContext(
|
|
sessionContext: SessionContext,
|
|
options?: { updateFooter?: boolean; populateHistory?: boolean },
|
|
): void {
|
|
this.#uiHelpers.renderSessionContext(sessionContext, options);
|
|
}
|
|
|
|
renderInitialMessages(): void {
|
|
this.#uiHelpers.renderInitialMessages();
|
|
}
|
|
|
|
getUserMessageText(message: Message): string {
|
|
return this.#uiHelpers.getUserMessageText(message);
|
|
}
|
|
|
|
findLastAssistantMessage(): AssistantMessage | undefined {
|
|
return this.#uiHelpers.findLastAssistantMessage();
|
|
}
|
|
|
|
extractAssistantText(message: AssistantMessage): string {
|
|
return this.#uiHelpers.extractAssistantText(message);
|
|
}
|
|
|
|
// Command handling
|
|
handleExportCommand(text: string): Promise<void> {
|
|
return this.#commandController.handleExportCommand(text);
|
|
}
|
|
|
|
handleDumpCommand() {
|
|
return this.#commandController.handleDumpCommand();
|
|
}
|
|
|
|
handleDebugTranscriptCommand(): Promise<void> {
|
|
return this.#commandController.handleDebugTranscriptCommand();
|
|
}
|
|
|
|
handleShareCommand(): Promise<void> {
|
|
return this.#commandController.handleShareCommand();
|
|
}
|
|
|
|
handleCopyCommand(sub?: string) {
|
|
return this.#commandController.handleCopyCommand(sub);
|
|
}
|
|
|
|
handleSessionCommand(): Promise<void> {
|
|
return this.#commandController.handleSessionCommand();
|
|
}
|
|
|
|
handleJobsCommand(): Promise<void> {
|
|
return this.#commandController.handleJobsCommand();
|
|
}
|
|
|
|
handleUsageCommand(reports?: UsageReport[] | null): Promise<void> {
|
|
return this.#commandController.handleUsageCommand(reports);
|
|
}
|
|
|
|
async handleChangelogCommand(showFull = false): Promise<void> {
|
|
await this.#commandController.handleChangelogCommand(showFull);
|
|
}
|
|
|
|
handleHotkeysCommand(): void {
|
|
this.#commandController.handleHotkeysCommand();
|
|
}
|
|
|
|
handleClearCommand(): Promise<void> {
|
|
this.#btwController.dispose();
|
|
this.#extensionUiController.clearExtensionTerminalInputListeners();
|
|
return this.#commandController.handleClearCommand();
|
|
}
|
|
|
|
handleForkCommand(): Promise<void> {
|
|
this.#btwController.dispose();
|
|
return this.#commandController.handleForkCommand();
|
|
}
|
|
|
|
handleMoveCommand(targetPath: string): Promise<void> {
|
|
return this.#commandController.handleMoveCommand(targetPath);
|
|
}
|
|
|
|
handleMemoryCommand(text: string): Promise<void> {
|
|
return this.#commandController.handleMemoryCommand(text);
|
|
}
|
|
|
|
async handleSTTToggle(): Promise<void> {
|
|
if (!settings.get("stt.enabled")) {
|
|
this.showWarning("Speech-to-text is disabled. Enable it in settings: stt.enabled");
|
|
return;
|
|
}
|
|
if (!this.#sttController) {
|
|
this.#sttController = new STTController();
|
|
}
|
|
await this.#sttController.toggle(this.editor, {
|
|
showWarning: (msg: string) => this.showWarning(msg),
|
|
showStatus: (msg: string) => this.showStatus(msg),
|
|
onStateChange: (state: SttState) => {
|
|
if (state === "recording") {
|
|
this.#voicePreviousShowHardwareCursor = this.ui.getShowHardwareCursor();
|
|
this.#voicePreviousUseTerminalCursor = this.editor.getUseTerminalCursor();
|
|
this.ui.setShowHardwareCursor(false);
|
|
this.editor.setUseTerminalCursor(false);
|
|
this.#startMicAnimation();
|
|
} else if (state === "transcribing") {
|
|
this.#stopMicAnimation();
|
|
this.#setMicCursor({ r: 200, g: 200, b: 200 });
|
|
} else {
|
|
this.#cleanupMicAnimation();
|
|
}
|
|
this.updateEditorTopBorder();
|
|
this.ui.requestRender();
|
|
},
|
|
});
|
|
}
|
|
|
|
#setMicCursor(color: { r: number; g: number; b: number }): void {
|
|
this.editor.cursorOverride = `\x1b[38;2;${color.r};${color.g};${color.b}m${theme.icon.mic}\x1b[0m`;
|
|
// Theme symbols can be wide (for example, 🎤), so measure the rendered override.
|
|
this.editor.cursorOverrideWidth = visibleWidth(this.editor.cursorOverride);
|
|
}
|
|
|
|
#updateMicIcon(): void {
|
|
const { r, g, b } = hsvToRgb({ h: this.#voiceHue, s: 0.9, v: 1.0 });
|
|
this.#setMicCursor({ r, g, b });
|
|
}
|
|
|
|
#startMicAnimation(): void {
|
|
if (this.#voiceAnimationInterval) return;
|
|
this.#voiceHue = 0;
|
|
this.#updateMicIcon();
|
|
this.#voiceAnimationInterval = setInterval(() => {
|
|
this.#voiceHue = (this.#voiceHue + 8) % 360;
|
|
this.#updateMicIcon();
|
|
this.ui.requestRender();
|
|
}, 60);
|
|
}
|
|
|
|
#stopMicAnimation(): void {
|
|
if (this.#voiceAnimationInterval) {
|
|
clearInterval(this.#voiceAnimationInterval);
|
|
this.#voiceAnimationInterval = undefined;
|
|
}
|
|
}
|
|
|
|
#cleanupMicAnimation(): void {
|
|
if (this.#voiceAnimationInterval) {
|
|
clearInterval(this.#voiceAnimationInterval);
|
|
this.#voiceAnimationInterval = undefined;
|
|
}
|
|
this.editor.cursorOverride = undefined;
|
|
this.editor.cursorOverrideWidth = undefined;
|
|
if (this.#voicePreviousShowHardwareCursor !== null) {
|
|
this.ui.setShowHardwareCursor(this.#voicePreviousShowHardwareCursor);
|
|
this.#voicePreviousShowHardwareCursor = null;
|
|
}
|
|
if (this.#voicePreviousUseTerminalCursor !== null) {
|
|
this.editor.setUseTerminalCursor(this.#voicePreviousUseTerminalCursor);
|
|
this.#voicePreviousUseTerminalCursor = null;
|
|
}
|
|
}
|
|
|
|
showDebugSelector(): void {
|
|
this.#selectorController.showDebugSelector();
|
|
}
|
|
|
|
handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void> {
|
|
return this.#commandController.handleBashCommand(command, excludeFromContext);
|
|
}
|
|
|
|
handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void> {
|
|
return this.#commandController.handlePythonCommand(code, excludeFromContext);
|
|
}
|
|
|
|
async handleMCPCommand(text: string): Promise<void> {
|
|
const controller = new MCPCommandController(this);
|
|
await controller.handle(text);
|
|
}
|
|
|
|
async handleSSHCommand(text: string): Promise<void> {
|
|
const controller = new SSHCommandController(this);
|
|
await controller.handle(text);
|
|
}
|
|
|
|
handleCompactCommand(customInstructions?: string): Promise<void> {
|
|
return this.#commandController.handleCompactCommand(customInstructions);
|
|
}
|
|
|
|
handleHandoffCommand(customInstructions?: string): Promise<void> {
|
|
return this.#commandController.handleHandoffCommand(customInstructions);
|
|
}
|
|
|
|
executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void> {
|
|
return this.#commandController.executeCompaction(customInstructionsOrOptions, isAuto);
|
|
}
|
|
|
|
openInBrowser(urlOrPath: string): void {
|
|
this.#commandController.openInBrowser(urlOrPath);
|
|
}
|
|
|
|
// Selector handling
|
|
showSettingsSelector(): void {
|
|
this.#selectorController.showSettingsSelector();
|
|
}
|
|
|
|
showHistorySearch(): void {
|
|
this.#selectorController.showHistorySearch();
|
|
}
|
|
|
|
showExtensionsDashboard(): void {
|
|
void this.#selectorController.showExtensionsDashboard();
|
|
}
|
|
|
|
showAgentsDashboard(): void {
|
|
void this.#selectorController.showAgentsDashboard();
|
|
}
|
|
|
|
showModelSelector(options?: { temporaryOnly?: boolean }): void {
|
|
this.#selectorController.showModelSelector(options);
|
|
}
|
|
|
|
showUserMessageSelector(): void {
|
|
this.#selectorController.showUserMessageSelector();
|
|
}
|
|
|
|
showTreeSelector(): void {
|
|
this.#selectorController.showTreeSelector();
|
|
}
|
|
|
|
showSessionSelector(): void {
|
|
this.#selectorController.showSessionSelector();
|
|
}
|
|
|
|
handleResumeSession(sessionPath: string): Promise<void> {
|
|
this.#btwController.dispose();
|
|
return this.#selectorController.handleResumeSession(sessionPath);
|
|
}
|
|
|
|
handleSessionDeleteCommand(): Promise<void> {
|
|
return this.#selectorController.handleSessionDeleteCommand();
|
|
}
|
|
|
|
showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
|
|
return this.#selectorController.showOAuthSelector(mode, providerId);
|
|
}
|
|
|
|
showHookConfirm(title: string, message: string): Promise<boolean> {
|
|
return this.#extensionUiController.showHookConfirm(title, message);
|
|
}
|
|
|
|
// Input handling
|
|
handleCtrlC(): void {
|
|
this.#inputController.handleCtrlC();
|
|
}
|
|
|
|
handleCtrlD(): void {
|
|
this.#inputController.handleCtrlD();
|
|
}
|
|
|
|
handleCtrlZ(): void {
|
|
this.#inputController.handleCtrlZ();
|
|
}
|
|
|
|
handleDequeue(): void {
|
|
this.#inputController.handleDequeue();
|
|
}
|
|
|
|
handleBackgroundCommand(): void {
|
|
this.#inputController.handleBackgroundCommand();
|
|
}
|
|
|
|
handleImagePaste(): Promise<boolean> {
|
|
return this.#inputController.handleImagePaste();
|
|
}
|
|
|
|
handleBtwCommand(question: string): Promise<void> {
|
|
return this.#btwController.start(question);
|
|
}
|
|
|
|
hasActiveBtw(): boolean {
|
|
return this.#btwController.hasActiveRequest();
|
|
}
|
|
|
|
handleBtwEscape(): boolean {
|
|
return this.#btwController.handleEscape();
|
|
}
|
|
|
|
cycleThinkingLevel(): void {
|
|
this.#inputController.cycleThinkingLevel();
|
|
}
|
|
|
|
cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
|
|
return this.#inputController.cycleRoleModel(options);
|
|
}
|
|
|
|
toggleToolOutputExpansion(): void {
|
|
this.#inputController.toggleToolOutputExpansion();
|
|
}
|
|
|
|
setToolsExpanded(expanded: boolean): void {
|
|
this.#inputController.setToolsExpanded(expanded);
|
|
}
|
|
|
|
toggleThinkingBlockVisibility(): void {
|
|
this.#inputController.toggleThinkingBlockVisibility();
|
|
}
|
|
|
|
toggleTodoExpansion(): void {
|
|
this.todoExpanded = !this.todoExpanded;
|
|
this.#renderTodoList();
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
setTodos(todos: TodoItem[] | TodoPhase[]): void {
|
|
if (todos.length > 0 && "tasks" in todos[0]) {
|
|
this.todoPhases = todos as TodoPhase[];
|
|
} else {
|
|
this.todoPhases = [
|
|
{
|
|
id: "default",
|
|
name: "Todos",
|
|
tasks: todos as TodoItem[],
|
|
},
|
|
];
|
|
}
|
|
this.#renderTodoList();
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
async reloadTodos(): Promise<void> {
|
|
await this.#loadTodoList();
|
|
this.ui.requestRender();
|
|
}
|
|
|
|
openExternalEditor(): void {
|
|
this.#inputController.openExternalEditor();
|
|
}
|
|
|
|
registerExtensionShortcuts(): void {
|
|
this.#inputController.registerExtensionShortcuts();
|
|
}
|
|
|
|
// Hook UI methods
|
|
initHooksAndCustomTools(): Promise<void> {
|
|
return this.#extensionUiController.initHooksAndCustomTools();
|
|
}
|
|
|
|
emitCustomToolSessionEvent(
|
|
reason: "start" | "switch" | "branch" | "tree" | "shutdown",
|
|
previousSessionFile?: string,
|
|
): Promise<void> {
|
|
return this.#extensionUiController.emitCustomToolSessionEvent(reason, previousSessionFile);
|
|
}
|
|
|
|
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
|
|
this.#extensionUiController.setHookWidget(key, content, options);
|
|
}
|
|
|
|
setHookStatus(key: string, text: string | undefined): void {
|
|
this.#extensionUiController.setHookStatus(key, text);
|
|
}
|
|
|
|
showHookSelector(
|
|
title: string,
|
|
options: string[],
|
|
dialogOptions?: ExtensionUIDialogOptions,
|
|
): Promise<string | undefined> {
|
|
return this.#extensionUiController.showHookSelector(title, options, dialogOptions);
|
|
}
|
|
|
|
hideHookSelector(): void {
|
|
this.#extensionUiController.hideHookSelector();
|
|
}
|
|
|
|
showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
|
|
return this.#extensionUiController.showHookInput(title, placeholder);
|
|
}
|
|
|
|
hideHookInput(): void {
|
|
this.#extensionUiController.hideHookInput();
|
|
}
|
|
|
|
showHookEditor(
|
|
title: string,
|
|
prefill?: string,
|
|
dialogOptions?: ExtensionUIDialogOptions,
|
|
editorOptions?: { promptStyle?: boolean },
|
|
): Promise<string | undefined> {
|
|
return this.#extensionUiController.showHookEditor(title, prefill, dialogOptions, editorOptions);
|
|
}
|
|
|
|
hideHookEditor(): void {
|
|
this.#extensionUiController.hideHookEditor();
|
|
}
|
|
|
|
showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
|
|
this.#extensionUiController.showHookNotify(message, type);
|
|
}
|
|
|
|
showHookCustom<T>(
|
|
factory: (
|
|
tui: TUI,
|
|
theme: Theme,
|
|
keybindings: KeybindingsManager,
|
|
done: (result: T) => void,
|
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
|
options?: { overlay?: boolean },
|
|
): Promise<T> {
|
|
return this.#extensionUiController.showHookCustom(factory, options);
|
|
}
|
|
|
|
showExtensionError(extensionPath: string, error: string): void {
|
|
this.#extensionUiController.showExtensionError(extensionPath, error);
|
|
}
|
|
|
|
showToolError(toolName: string, error: string): void {
|
|
this.#extensionUiController.showToolError(toolName, error);
|
|
}
|
|
|
|
#subscribeToAgent(): void {
|
|
this.#eventController.subscribeToAgent();
|
|
}
|
|
}
|