diff --git a/README.md b/README.md index 4a9bf529a..c6047cd84 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,48 @@ [Discord 群组](https://discord.gg/qZU6zS7Q) -- [x] V4 — 测试补全、[Buddy](https://ccb.agent-aura.top/docs/features/buddy)、[Auto Mode](https://ccb.agent-aura.top/docs/safety/auto-mode)、环境变量 Feature 开关 -- [x] V5 — [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup) / [GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) 企业监控、[自定义 Login](https://ccb.agent-aura.top/docs/features/custom-platform-login)、[OpenAI 兼容](https://ccb.agent-aura.top/docs/plans/openai-compatibility)、[Web Search](https://ccb.agent-aura.top/docs/features/web-browser-tool)、[Computer Use](https://ccb.agent-aura.top/docs/features/computer-use) / [Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp)、[Voice Mode](https://ccb.agent-aura.top/docs/features/voice-mode)、[Bridge Mode](https://ccb.agent-aura.top/docs/features/bridge-mode)、[/dream 记忆整理](https://ccb.agent-aura.top/docs/features/auto-dream) -- [ ] V6 — 大规模重构石山代码,全面模块分包(全新分支,main 封存为历史版本) - - -## 快速开始(安装版) - -不用克隆仓库, 从 NPM 下载后, 直接使用 - -```sh -bun i -g claude-code-best -bun pm -g trust claude-code-best -ccb # 直接打开 claude code -``` - -国内对 github 网络较差的, 需要先设置这个环境变量 - -```bash -DEFAULT_RELEASE_BASE=https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v15.0.1 -``` - -## 快速开始(源码版) +赞助商占位符 + +- [x] v1 会完成跑通及基本的类型检查通过; +- [x] V2 会完整实现工程化配套设施; + - [ ] Biome 格式化可能不会先实施, 避免代码冲突 + - [x] 构建流水线完成, 产物 Node/Bun 都可以运行 +- [x] V3 会写大量文档, 完善文档站点 +- [x] V4 会完成大量的测试文件, 以提高稳定性 + - [x] Buddy 小宠物回来啦 [文档](https://ccb.agent-aura.top/docs/features/buddy) + - [x] Auto Mode 回归 [文档](https://ccb.agent-aura.top/docs/safety/auto-mode) + - [x] 所有 Feature 现在可以通过环境变量配置, 而不是垃圾的 bun --feature +- [x] V5 支持企业级的监控上报功能, 补全缺失的工具, 解除限制 + - [x] 移除牢 A 的反蒸馏代码!!! + - [x] 补全 web search 能力(用的 Bing 搜索)!!! [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) + - [x] 支持 Debug [文档](https://ccb.agent-aura.top/docs/features/debug-mode) + - [x] 关闭自动更新; + - [x] 添加自定义 sentry 错误上报支持 [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) + - [x] 添加自定义 GrowthBook 支持 (GB 也是开源的, 现在你可以配置一个自定义的遥控平台) [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) + - [x] 自定义 login 模式, 大家可以用这个配置 Claude 的模型! [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) + - [x] Remote Control / Bridge Mode 支持 [文档](https://ccb.agent-aura.top/docs/features/bridge-mode) + - [x] 修复搜索工具的 rg 缺失问题(需要重新 bun i) + - [x] OpenAI 接口兼容! /login 然后配置 OpenAI 平台即可! [文档](https://ccb.agent-aura.top/docs/plans/openai-compatibility) + - [x] Any Use + - [x] 由于 Chrome Use 和 Computer Use 原本都是未完全验证的能力, 还是比较建议大家用社区里面的 MCP 支持 + - [x] Chrome use 支持 (浏览器插件要订阅权限 ) 感谢 @amDosion [文档](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) + - [x] 普通用户可以使用 [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp/) 替代, 比较不那么折腾 + - [x] Computer use 支持 感谢 @amDosion [文档](https://ccb.agent-aura.top/docs/features/computer-use) + - [x] Mac 上可以用这个项目 [computer-use-mcp](https://github.com/domdomegg/computer-use-mcp) + - 注意这个库的命名方式与官方冲突了, 需要改为 `claude mcp add --scope user --transport stdio computer-use-mcp -- npx -y computer-use-mcp` + - [x] /voice 支持 @amDosion [文档](https://ccb.agent-aura.top/docs/features/voice-mode) + - [x] /dream 记忆整理命令(手动 + 自动后台触发) [文档](https://ccb.agent-aura.top/docs/features/auto-dream) +- [ ] V6 大规模重构石山代码, 全面模块分包 + - [ ] V6 将会为全新分支, 届时 main 分支将会封存为历史版本 + +> 我不知道这个项目还会存在多久, Star + Fork + git clone + .zip 包最稳健; 说白了就是扛旗项目, 看看能走多远 +> +> 这个项目更新很快, 后台有 Opus 持续优化, 几乎几个小时就有新变化; +> +> Claude 已经烧了 1000$ 以上, 没钱了, 换成 GLM 继续玩; @zai-org GLM 5.1 非常可以; +> + +## 快速开始 ### 环境要求 @@ -76,7 +96,7 @@ bun run build ### 新人配置 /login -首次运行后,在 REPL 中输入 `/login` 命令进入登录配置界面,选择 **Custom Platform** 即可对接第三方 API 兼容服务(无需 Anthropic 官方账号)。 +首次运行后,在 REPL 中输入 `/login` 命令进入登录配置界面,选择 **Anthropic Compatible** 即可对接第三方 API 兼容服务(无需 Anthropic 官方账号)。 需要填写的字段: @@ -89,7 +109,22 @@ bun run build | Opus Model | 高性能模型 ID | `claude-opus-4-6` | - **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存 - +- 模型字段会自动读取当前环境变量预填 +- 配置保存到 `~/.claude/settings.json` 的 `env` 字段,保存后立即生效 + +也可以直接编辑 `~/.claude/settings.json`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-xxx", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-6" + } +} +``` > 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。 diff --git a/README_EN.md b/README_EN.md index 3b3053b1d..5e3255b3e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -74,7 +74,7 @@ If you encounter a bug, please open an issue — we'll prioritize it. ### First-time Setup /login -After the first run, enter `/login` in the REPL to access the login configuration screen. Select **Custom Platform** to connect to third-party API-compatible services (no Anthropic account required). +After the first run, enter `/login` in the REPL to access the login configuration screen. Select **Anthropic Compatible** to connect to third-party API-compatible services (no Anthropic account required). Fields to fill in: diff --git a/Run.ps1 b/Run.ps1 new file mode 100644 index 000000000..6741892cb --- /dev/null +++ b/Run.ps1 @@ -0,0 +1,2 @@ +bun install +bun run dev --dangerously-skip-permissions \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..5c0501f4d --- /dev/null +++ b/TODO.md @@ -0,0 +1,26 @@ +# TODO + +尽可能实现下面的包, 使得与主包的关系完全吻合 + +## Packages + +- [x] `url-handler-napi` — URL 处理 NAPI 模块 (签名修正,保持 null fallback) +- [x] `modifiers-napi` — 修饰键检测 NAPI 模块 (Bun FFI + Carbon) +- [x] `audio-capture-napi` — 音频捕获 NAPI 模块 (SoX/arecord) +- [x] `color-diff-napi` — 颜色差异计算 NAPI 模块 (纯 TS 实现) +- [x] `image-processor-napi` — 图像处理 NAPI 模块 (sharp + osascript 剪贴板) + +- [x] `@ant/computer-use-swift` — Computer Use Swift 原生模块 (macOS JXA/screencapture 实现) +- [x] `@ant/computer-use-mcp` — Computer Use MCP 服务 (类型安全 stub + sentinel apps + targetImageSize) +- [x] `@ant/computer-use-input` — Computer Use 输入模块 (macOS AppleScript/JXA 实现) + + +## 工程化能力 + +- [x] 代码格式化与校验 +- [x] 冗余代码检查 +- [x] git hook 的配置 +- [x] 代码健康度检查 +- [x] Biome lint 规则调优(适配反编译代码,关闭格式化避免大规模 diff) +- [x] 单元测试基础设施搭建 (test runner 配置) +- [x] CI/CD 流水线 (GitHub Actions) diff --git a/V6.md b/V6.md deleted file mode 100644 index 3d970340a..000000000 --- a/V6.md +++ /dev/null @@ -1,1330 +0,0 @@ -# Claude Code 架构重构规划 - -## 一、当前架构 (As-Is) - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Entry Layer │ -│ cli.tsx → main.tsx (4680行) ─┬─→ REPL.tsx (5005行, 交互) │ -│ ├─→ print.ts (Headless/Pipe) │ -│ └─→ SDK (QueryEngine) │ -├─────────────────────────────────────────────────────────────────┤ -│ Core Monolith │ -│ │ -│ ┌──────────┐ ┌──────────────┐ ┌───────────────────────┐ │ -│ │ query.ts │←──│ QueryEngine │ │ AppState (199行类型) │ │ -│ │ (1732行) │ │ (1320行) │ │ UI+MCP+Bridge+ │ │ -│ │ │ │ │ │ Perm+Plugin+Agent │ │ -│ └────┬─────┘ └──────────────┘ └───────────────────────┘ │ -│ │ │ -│ ┌────▼─────────────────────────────────────────────────────┐ │ -│ │ services/api/claude.ts (3415行) │ │ -│ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ │ │ -│ │ │Anthropic│ │ Bedrock │ │ Vertex │ │ OpenAI │ │ │ -│ │ │ (主路径)│ │(if分支) │ │(if分支) │ │ (882行 │ │ │ -│ │ │ │ │ │ │ │ │ 已实现) │ │ │ -│ │ └─────────┘ └──────────┘ └─────────┘ └───────────┘ │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────▼──────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ tools.ts │ │ services/mcp/│ │ auth.ts │ │ -│ │ (硬编码列表) │ │ client.ts │ │ (2001行,7种认证) │ │ -│ │ 54个Tool │ │ (3351行) │ │ +oauth/(12文件) │ │ -│ └───────────────┘ └──────────────┘ └──────────────────┘ │ -│ │ -│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │ -│ │ sessionStorage│ │ context.ts │ │ ink/ (104文件) │ │ -│ │ (5106行,JSONL)│ │ (189行,轻量) │ │ (UI框架) │ │ -│ └───────────────┘ └───────────────┘ └─────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────────┐ │ -│ │ 隐蔽巨文件 │ │ -│ │ tasks/LocalMainSessionTask.ts (15373行) ← 全库最大单文件 │ │ -│ │ utils/hooks.ts (5177行) + hooks/ (5文件 1494行) │ │ -│ │ components/ (596文件) ← UI组件, 低估 3.5x │ │ -│ └───────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - -耦合特征: - ● main.tsx ~2867行内联 action handler (L1003-L3870), 51个subcommand - ● REPL.tsx God Component: 54 useState, 68 useEffect, ~30 自定义Hook - ● AppState 单一类型混合 7+ 个域 (UI/MCP/Permission/Bridge/Agent/Plugin/Team) - ● Provider 分发靠 if/else 字符串比较 (108处 provider 相关引用) - ● Tool 注册为静态列表 (getAllBaseTools), 54个工具, 无发现机制 - ● auth.ts (2001行) + oauth服务 (12文件 1077行) + 其他 (~779行), 总计约3857行 - ● hooks.ts 5177行, 27种 hook 事件类型, 混杂在 utils/ 中 - ● LocalMainSessionTask.ts 15373行为全库最大单体文件, 文档此前未提及 -``` - ---- - -## 二、目标架构 (To-Be) - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Entry Layer │ -│ │ -│ cli.tsx ──→ main.tsx (瘦身) ─┬─→ REPL (纯 UI, Hook 编排) │ -│ ├─→ Headless (JSON 输出) │ -│ ├─→ SDK (程序化接口) │ -│ └─→ Deep Links (claude:// 协议) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────── packages/agent ──────────────────────────┐ │ -│ │ (核心引擎, 零 UI 依赖) │ │ -│ │ │ │ -│ │ query() QueryEngine HookLifecycle │ │ -│ │ ├─ streaming ├─ turn管理 ├─ PreToolUse │ │ -│ │ ├─ recovery ├─ compaction ├─ PostToolUse │ │ -│ │ ├─ attachments ├─ SDK消息转换 ├─ Notification │ │ -│ │ └─ abort └─ budget追踪 ├─ Stop │ │ -│ │ ├─ SubagentStop │ │ -│ │ CompactionService CronScheduler ├─ UserPromptSubmit │ │ -│ │ (snip/micro/auto) (定时任务/抖动) ├─ SessionStart/End │ │ -│ │ ├─ PreCompact/ │ │ -│ │ LocalMainSessionTask │ PostCompact │ │ -│ │ (15373行 → 分解重构) ├─ Permission* │ │ -│ │ └─ ... (27种事件) │ │ -│ │ QueryDeps (依赖注入) │ │ -│ └──────────────────────────┬────────────────────────────────────┘ │ -│ │ │ -├─────────────────────────────┼───────────────────────────────────────────┤ -│ ▼ │ -│ ┌──────────────── packages/provider ────────────────────────┐ │ -│ │ (适配器层, 可扩展) │ │ -│ │ │ │ -│ │ ┌─────────────────┐ ┌────────────────┐ │ │ -│ │ │ ProviderAdapter │ │ AuthProvider │ │ │ -│ │ │ ├─queryStream() │ │ ├─getCredentials() │ │ -│ │ │ ├─query() │ │ ├─refresh() │ │ │ -│ │ │ ├─isAvailable() │ │ └─invalidate() │ │ │ -│ │ │ └─listModels() │ └────────────────┘ │ │ -│ │ └────────┬────────┘ │ │ -│ │ │ │ │ -│ │ ┌────────▼────────────────────────────────────────┐ │ │ -│ │ │ StreamAdapter (归一化) │ │ │ -│ │ │ 将任意 SSE/WS/流 → 统一的内部事件格式 │ │ │ -│ │ └─────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────┐ │ │ -│ │ │ ContextProvider (可插拔 prompt 管线) │ │ │ -│ │ │ GitStatus → ClaudeMd → Date → Attribution → ... │ │ │ -│ │ └──────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────┐ │ │ -│ │ │ NetworkLayer (网络/代理) │ │ │ -│ │ │ Proxy / mTLS / CA证书 / Upstream Proxy │ │ │ -│ │ └──────────────────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ │ -├─────────────────────────────┼───────────────────────────────────────────┤ -│ ▼ │ -│ ┌──────────────── 具体实现 (Implementation) ──────────────────────┐ │ -│ │ │ │ -│ │ LLM Providers Auth Implementations │ │ -│ │ ├─ Anthropic ├─ AnthropicOAuth │ │ -│ │ ├─ OpenAI ├─ APIKey (Keychain/env/config) │ │ -│ │ ├─ Gemini ├─ AWS (Bedrock IAM) │ │ -│ │ ├─ Mistral ├─ GCP (Vertex ADC) │ │ -│ │ ├─ Bedrock └─ Azure (Managed Identity) │ │ -│ │ ├─ Vertex │ │ -│ │ ├─ 通义千问 Storage Backends │ │ -│ │ └─ 本地推理 ├─ LocalFile (JSONL) │ │ -│ │ (llama.cpp/vLLM) ├─ RemoteAPI │ │ -│ │ └─ Memory (测试) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -├────────────────────────────────── UI ───────────────────────────────────┤ -│ │ -│ ┌──────────── packages/ink ───────────────────────────────────────┐ │ -│ │ UI 框架 (104文件 + 扩展) │ │ -│ │ ├─ reconciler / hooks (useInput等) / components │ │ -│ │ ├─ Keybinding 系统 (可配置键绑定, 模式解析, 冲突解决) │ │ -│ │ ├─ Vim Emulation (motions / operators / text objects) │ │ -│ │ ├─ Typeahead (命令/文件建议, 模糊搜索, ghost text) │ │ -│ │ └─ InkConfig (12个注入点) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -├──────────────────────── 基础设施层 ─────────────────────────────────────┤ -│ │ -│ ┌──────── packages/agent-tools ──────────────────────────────────┐ │ -│ │ Agent 工具库 (纯逻辑) │ │ -│ │ ├─ Tool interface + 54 个工具实现 │ │ -│ │ ├─ Sandbox 系统 (沙盒隔离执行) │ │ -│ │ ├─ configs, aliases, cost, deprecation, contextWindow │ │ -│ │ └─ ModelDeps (注入点) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/shell ────────────────────────────────────────┐ │ -│ │ Shell 执行层 (独立抽象, 零 UI 依赖) │ │ -│ │ ├─ ShellProvider 接口 (统一 bash/zsh/PowerShell) │ │ -│ │ ├─ Bash/Zsh 实现 (命令前缀注入/超时/环境构建) │ │ -│ │ ├─ PowerShell 实现 (Windows 路径转换/FindGitBash) │ │ -│ │ └─ 子进程环境构建 (subprocessEnv) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/config ───────────────────────────────────────┐ │ -│ │ 配置管理 (基础设施层, 被所有模块依赖) │ │ -│ │ ├─ SettingsManager (7层优先级合并: user→project→local→ │ │ -│ │ │ policy→flag→command→session) │ │ -│ │ ├─ FeatureFlagProvider (BunBundle/EnvVar/ConfigFile/Remote) │ │ -│ │ ├─ SettingsSync (跨设备同步) │ │ -│ │ ├─ RemoteManagedSettings (企业管控) │ │ -│ │ └─ GlobalConfig (apiKey/oauthToken等, config.ts 1821行) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/telemetry ────────────────────────────────────┐ │ -│ │ 遥测/诊断 (真实实现, 非空 stub, 需谨慎处理) │ │ -│ │ ├─ AnalyticsEventEmitter (OTel日志导出+JSONL批处理, 806行) │ │ -│ │ ├─ GrowthBook客户端 (AB测试/FeatureFlag, 1163行) │ │ -│ │ ├─ Datadog日志 (日志上传, 321行) │ │ -│ │ ├─ SessionTracer (OTel兼容会话追踪) │ │ -│ │ └─ Metadata (事件元数据enrichment, 973行) │ │ -│ │ 总计: services/analytics/ (10文件, 4062行) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -├──────────────────────── 领域系统 ───────────────────────────────────────┤ -│ │ -│ ┌──────── packages/memory ───────────────────────────────────────┐ │ -│ │ 记忆系统 (独立实现, 零 UI 依赖) │ │ -│ │ ├─ MemoryStore (存储抽象) / MemoryRecall (相关性检索) │ │ -│ │ ├─ MemoryExtract (后台提取) / MemoryConsolidation (合并/清理) │ │ -│ │ └─ 类型: user / feedback / project / reference │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/permission ───────────────────────────────────┐ │ -│ │ 权限系统 (独立实现, 零 UI 依赖) │ │ -│ │ ├─ PermissionMode (8种模式) / PermissionPipeline (检查管线) │ │ -│ │ ├─ RuleStore (allow/deny/ask 规则) / AutoClassifier (AI分类) │ │ -│ │ └─ ToolPermissionContext │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -├──────────────────────── 扩展系统 (Phase 5) ─────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │ -│ │ ToolRegistry │ │ OutputTarget │ │ packages/swarm │ │ -│ │ ├─ 内置(静态)│ │ ├─ Terminal(Ink) │ │ (多Agent协调) │ │ -│ │ ├─ MCP(动态) │ │ ├─ JSON (SDK) │ │ ├─ Backends │ │ -│ │ ├─ Plugin │ │ ├─ Web (未来) │ │ │ (进程/Tmux/iTerm2) │ │ -│ │ └─ 用户自定义│ │ └─ Silent (后台) │ │ ├─ PermissionSync │ │ -│ └──────────────┘ └──────────────────┘ │ ├─ TeammateMailbox │ │ -│ │ └─ Worktree管理 │ │ -│ ┌──────────────────┐ ┌───────────────┐ └──────────────────────────┘ │ -│ │ packages/ide │ │ packages/server│ │ -│ │ ├─ VS Code │ │ ├─ DirectConn │ ┌──────────────────────────┐ │ -│ │ ├─ JetBrains │ │ └─ LockFile │ │ packages/teleport │ │ -│ │ ├─ LSP Client │ │ (6/11文件stub │ │ ├─ 环境选择/配置 │ │ -│ │ ├─ Code Indexing │ │ 仅371行有效) │ │ ├─ Git 打包 │ │ -│ │ └─ Claude-in- │ │ └─ API 集成 │ │ -│ │ Chrome │ └──────────────────────────┘ │ -│ └──────────────────┘ │ -│ ┌──────────────────────────┐ │ -│ ┌──────────────────┐ │ packages/updater │ │ -│ │ packages/cli │ │ ├─ NativeInstaller │ │ -│ │ ├─ Transport │ │ │ (.deb/.rpm/.pkg) │ │ -│ │ │ (Hybrid/SSE/ │ │ ├─ BinaryDownload │ │ -│ │ │ WS/Worker) │ │ └─ AutoUpdateCheck │ │ -│ │ ├─ StructuredIO │ └──────────────────────────┘ │ -│ │ └─ Rollback │ │ -│ └──────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 三、单体文件分解图 - -``` -main.tsx (4680行) REPL.tsx (5005行) -┌──────────────────────┐ ┌──────────────────────────┐ -│ main() (安全/URL/argv)│ │ God Component │ -├──────────────────────┤ │ 54 useState, 68 useEffect│ -│ .action() handler │ │ ~30 自定义 Hook │ -│ ┌──────────────────┐ │ │ │ -│ │ parseActionOptions│ │ │ ┌─ useQueryLifecycle ──┐│ -│ │ (L1003-L3870, │ │ │ │ query生命周期 (830行) ││ -│ │ ~2867行内联) │ │ │ │ 权限回调, abort处理 ││ -│ ├──────────────────┤ │ │ └──────────────────────┘│ -│ │ mcpSetup │ │ │ ┌─ usePromptSubmit ────┐│ -│ ├──────────────────┤ │ │ │ 命令解析, 队列 (350行)││ -│ │ headlessSetup │ │ │ └──────────────────────┘│ -│ │ buildInitialState│ │ │ ┌─ useDialogManager ───┐│ -│ ├──────────────────┤ │ │ │ 通知优先级 (20路) ││ -│ │ sessionResume │ │ │ └──────────────────────┘│ -│ └──────────────────┘ │ │ ┌─ useScrollManager ───┐│ -├──────────────────────┤ │ │ 视口状态机 (135行) ││ -│ 51个 subcommands │ │ └──────────────────────┘│ -│ mcp/server/ssh/auth │ │ ┌─ useSessionInit ─────┐│ -│ plugin/doctor/update │ │ │ 初始化, initial msg ││ -│ agents/task/autoMode │ │ └──────────────────────┘│ -└──────────────────────┘ └──────────────────────────┘ - -query.ts (1732行) AppState (199行类型定义) -┌──────────────────────┐ ┌──────────────────────────┐ -│ query() 异步生成器 │ │ 单一嵌套类型, ~90字段 │ -│ ┌──────────────────┐ │ │ │ -│ │compactionPipeline│ │ │ ┌─ UISlice ────────────┐│ -│ │ (snip/micro/auto)│ │ │ │ verbose, expanded, ││ -│ ├──────────────────┤ │ │ │ footer, spinner ││ -│ │streamingOrchestrator │ └─────────────────────┘│ -│ │ (流+错误+abort) │ │ │ ┌─ MCPSlice ───────────┐│ -│ ├──────────────────┤ │ │ │ clients, tools, ││ -│ │ recovery │ │ │ │ commands, resources ││ -│ │ (max_tokens/ptl) │ │ │ └─────────────────────┘│ -│ ├──────────────────┤ │ │ ┌─ PermissionSlice ────┐│ -│ │ attachments │ │ │ │ toolPermissionContext││ -│ │ (files/mem/skill)│ │ │ └─────────────────────┘│ -│ └──────────────────┘ │ │ ┌─ BridgeSlice ────────┐│ -└──────────────────────┘ │ │ replBridge* (~20字段)││ - │ └─────────────────────┘│ -services/mcp/client.ts (3351行) │ ┌─ AgentSlice ─────────┐│ -┌──────────────────────┐ │ │ tasks, agents, team ││ -│ ┌──────────────────┐ │ │ └─────────────────────┘│ -│ │transportManager │ │ │ ┌─ PluginSlice ────────┐│ -│ │(stdio/SSE/WS) │ │ │ │ enabled, commands ││ -│ ├──────────────────┤ │ │ └─────────────────────┘│ -│ │ toolDiscovery │ │ └──────────────────────────┘ -│ │(MCPTool实例化) │ │ -│ ├──────────────────┤ │ → Domain Slicing: -│ │ authManager │ │ type AppState = UISlice -│ │(OAuth+重连) │ │ & MCPSlice & PermissionSlice -│ └──────────────────┘ │ & BridgeSlice & AgentSlice -└──────────────────────┘ & PluginSlice & TeamSlice - -LocalMainSessionTask.ts (15373行) ← 全库最大单体文件 -┌──────────────────────┐ (此前文档未提及) -│ 单文件承担本地主会话 │ -│ 全部生命周期管理 │ -│ 包含: turn管理/消息 │ -│ 处理/工具调用/权限 │ -│ 恢复/compaction等 │ -│ 分解优先级: P1 │ -└──────────────────────┘ -``` - ---- - -## 四、适配器层详解 - -### 4.1 LLM Provider 适配器 (P1) - -``` - ┌─────────────────────────┐ - │ agent │ - │ query() / QueryEngine │ - └────────────┬────────────┘ - │ - ┌────────────▼────────────┐ - │ ProviderAdapter │ - │ ┌─ queryStreaming() │ - │ ├─ query() │ - │ ├─ isAvailable() │ - │ └─ listModels() │ - └────────────┬────────────┘ - │ - ┌──────────────────┼──────────────────┐ - │ │ │ - ┌─────────▼──────┐ ┌────────▼───────┐ ┌────────▼───────┐ - │ Anthropic │ │ OpenAI │ │ Gemini │ - │ │ │ (已有参考) │ │ (新增) │ - │ ┌────────────┐ │ │ ┌────────────┐│ │ ┌────────────┐ │ - │ │消息格式转换 │ │ │ │消息格式转换││ │ │消息格式转换│ │ - │ └────────────┘ │ │ └────────────┘│ │ └────────────┘ │ - │ ┌────────────┐ │ │ ┌────────────┐│ │ ┌────────────┐ │ - │ │工具格式转换 │ │ │ │工具格式转换││ │ │工具格式转换│ │ - │ └────────────┘ │ │ └────────────┘│ │ └────────────┘ │ - │ ┌────────────┐ │ │ ┌────────────┐│ │ ┌────────────┐ │ - │ │StreamAdapter│ │ │StreamAdapter ││ │StreamAdapter │ │ - │ │(原生) │ │ │(SSE→内部) ││ │(SSE→内部) │ │ - │ └────────────┘ │ │ └────────────┘│ │ └────────────┘ │ - └────────────────┘ └───────────────┘ └────────────────┘ - ↑ ↑ ↑ - ┌────┴────────────────────┴──────────────────┴────┐ - │ 归一化事件格式 │ - │ content_block_start/delta/stop │ - │ message_start/delta/stop │ - │ error │ - └──────────────────────────────────────────────────┘ - -当前问题: claude.ts 3415行单体, Bedrock/Vertex 靠 if/else 分支 (108处provider引用) -参考实现: src/services/api/openai/ (6文件, 882行) - ├─ streamAdapter.ts (310行, SSE→内部事件流) - ├─ convertMessages.ts (184行, 消息格式转换) - ├─ convertTools.ts (68行, 工具格式转换) - ├─ modelMapping.ts (56行, 模型名称映射) - ├─ client.ts (48行, OpenAI客户端) - └─ index.ts (216行, 入口+queryModel) -已验证可行: 通过 CLAUDE_CODE_USE_OPENAI=1 启用, 流适配器模式已跑通 -``` - -### 4.2 Auth Provider 适配器 (P1, 与 LLM 配套) - -``` -┌──────────────────────────────────────────────────┐ -│ ProviderAdapter │ -│ 需要 Credentials 才能工作 │ -└─────────────────────┬────────────────────────────┘ - │ - ┌────────────▼────────────┐ - │ AuthProvider │ - │ ┌─ getCredentials() │ - │ ├─ refresh() │ - │ ├─ isAuthenticated() │ - │ └─ invalidate() │ - └────────────┬───────────┘ - │ - ┌────────┬────────┼────────┬──────────┐ - │ │ │ │ │ -┌───▼──┐ ┌──▼───┐ ┌──▼──┐ ┌──▼──┐ ┌────▼───┐ -│OAuth │ │APIKey│ │ AWS │ │ GCP │ │ Azure │ -│ │ │ │ │ │ │ │ │ │ -│PKCE │ │Keych │ │IAM │ │ADC │ │Managed │ -│flow │ │env │ │STS │ │ │ │Identity│ -│ │ │config│ │ │ │ │ │ │ -└──────┘ └──────┘ └─────┘ └─────┘ └────────┘ - -当前问题: auth.ts (2001行) + oauth/(12文件 1077行) + 其他(~779行) = 总计约3857行 - 7种认证: APIKey/OAuth/Bedrock IAM/Vertex ADC/Keychain/ApiKeyHelper/Foundry -已有实现: 全部可从现有代码提取 -``` - -### 4.3 Tool Registry (P1) - -``` -┌───────────────────────────────────────────┐ -│ ToolRegistry │ -│ │ -│ register(tool) unregister(name) │ -│ get(name) getAll() │ -│ │ -│ ┌─────────────────────────────────────┐ │ -│ │ 发现机制 │ │ -│ │ 1. BuiltInTools ─── 静态注册 │ │ -│ │ 2. MCPTools ─── 动态加载 (已有)│ │ -│ │ 3. PluginTools ─── npm包+配置 │ │ -│ │ 4. UserTools ─── ~/.claude/ │ │ -│ └─────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 统一 AgentTool 接口 │ -│ call() / checkPermissions() │ -│ isEnabled() / isReadOnly() │ -└───────────────────────────────────────────┘ - -当前问题: tools.ts getAllBaseTools() 硬编码 54个 (20常驻 + 34条件, feature()控制) -优势: Tool 接口已很干净, MCPTool 已证明动态加载 -``` - -### 4.4 Storage Backend (P2) - -``` -┌───────────────────────────────────────────┐ -│ Project (会话管理) │ -│ 写队列 / 刷新 / 远程同步 │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ StorageBackend │ │ -│ │ read/write/append │ │ -│ │ delete/list │ │ -│ └──────────┬──────────┘ │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ │ │ │ │ -│ ┌──▼──────┐ ┌─────▼──────┐ ┌─────▼───┐ │ -│ │LocalFile│ │ RemoteAPI │ │ Memory │ │ -│ │(JSONL) │ │(已有部分) │ │(测试用) │ │ -│ └─────────┘ └────────────┘ └─────────┘ │ -└───────────────────────────────────────────┘ - -当前问题: sessionStorage.ts (5106行, JSONL) + sessionRestore.ts (551行) - + sessionStoragePortable.ts (793行) + listSessionsImpl.ts (454行) - = 总计约6968行, 硬编码 JSONL 格式 -已有基础: hydrateRemoteSession / persistToRemote 部分实现 -``` - -### 4.5 Output Target (P3) - -``` -┌───────────────────────────────────────────┐ -│ agent │ -│ (产出 Message/Event) │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ OutputTarget │ │ -│ │ renderMessage() │ │ -│ │ renderToolProgress()│ │ -│ │ renderError() │ │ -│ │ renderPermission() │ │ -│ └──────────┬──────────┘ │ -│ │ │ -│ ┌─────────┬───────┼────────┬──────────┐ │ -│ │ │ │ │ │ │ -│ ┌▼───────┐┌▼─────┐┌▼──────┐┌▼────────┐ │ │ -│ │Terminal││ JSON ││ Web ││ Silent │ │ │ -│ │(Ink) ││(SDK) ││(未来) ││(后台) │ │ │ -│ └────────┘└──────┘└───────┘└─────────┘ │ │ -└───────────────────────────────────────────┘ - -当前问题: 170+组件耦合 Ink API, 3种输出路径未抽象 -已有分支: headless/pipe 绕过 Ink, SDK 跳过 Ink -``` - -### 4.6 Context Pipeline (P2) - -``` -┌──────────────────────────────────────────────────┐ -│ System Prompt 装配 │ -│ │ -│ ┌────────────────────────────────────────────┐ │ -│ │ ContextProvider[] │ │ -│ │ (按 priority 排序, 可插拔) │ │ -│ │ │ │ -│ │ ┌─ GitStatusProvider ──── priority: 10 │ │ -│ │ ├─ ClaudeMdProvider ──── priority: 20 │ │ -│ │ ├─ DateProvider ──────── priority: 30 │ │ -│ │ ├─ AttributionProvider ─ priority: 40 │ │ -│ │ ├─ AdvisorProvider ──── priority: 50 │ │ -│ │ └─ CustomProvider ────── priority: 99 │ │ -│ │ (用户通过配置注册) │ │ -│ └────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 最终 System Prompt │ -└──────────────────────────────────────────────────┘ - -当前问题: context.ts (189行, 轻量) 提供上下文, claude.ts buildSystemPromptBlocks() - 做 prompt 缓存分块, 无自定义 hook 点 -改动范围: 集中在 prompt 装配逻辑 (claude.ts + context.ts) -``` - -### 4.7 Hook 生命周期 (P1, 核心扩展机制) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ HookLifecycle (packages/agent 内) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Hook 点 (用户在 settings.json 配置 shell 命令) │ │ -│ │ 共 27 种事件, 按类型分组: │ │ -│ │ │ │ -│ │ 工具相关: │ │ -│ │ ├─ PreToolUse 工具调用前 → 可阻止/修改输入 │ │ -│ │ ├─ PostToolUse 工具调用后 → 可修改输出 │ │ -│ │ └─ PostToolUseFailure 工具调用失败后 │ │ -│ │ 会话相关: │ │ -│ │ ├─ SessionStart/End 会话生命周期 │ │ -│ │ ├─ UserPromptSubmit 用户提交输入时 │ │ -│ │ ├─ Stop/StopFailure 对话停止时 │ │ -│ │ ├─ PreCompact/PostCompact 上下文压缩前后 │ │ -│ │ └─ CwdChanged 工作目录变更时 │ │ -│ │ 子Agent/团队: │ │ -│ │ ├─ SubagentStart/Stop 子Agent生命周期 │ │ -│ │ ├─ TeammateIdle 队友空闲时 │ │ -│ │ ├─ TaskCreated/Completed 任务生命周期 │ │ -│ │ └─ WorktreeCreate/Remove Worktree管理 │ │ -│ │ 权限/通知: │ │ -│ │ ├─ Notification 通知触发 → 自定义渠道 │ │ -│ │ ├─ PermissionRequest/Denied 权限审批 │ │ -│ │ ├─ Elicitation/Result 用户反馈请求 │ │ -│ │ └─ ConfigChange 配置变更时 │ │ -│ │ 其他: Setup, InstructionsLoaded, FileChanged │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼──────────────────────────────────────┐ │ -│ │ HookExecutor (hooks.ts, 5177行 → 提取为独立模块) │ │ -│ │ │ │ -│ │ ├─ spawnShellCommand() → 执行用户配置的 shell 命令 │ │ -│ │ ├─ parseJsonOutput() → 解析 hook 的 JSON 输出 │ │ -│ │ ├─ timeout / abort → 超时和中断处理 │ │ -│ │ ├─ hooksConfigSnapshot → 配置快照 (热加载) │ │ -│ │ └─ fileChangedWatcher → 文件变更监听触发 hook │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: hooks.ts (5177行) + hooks/ (5文件 1494行) = 6713行 │ -│ 混杂在 utils/ 中, 27种事件类型全部硬编码 │ -│ 改动范围: 提取到 packages/agent/hooks/, 作为核心生命周期 │ -│ 依赖关系: HookExecutor ← ToolRegistry (PreToolUse/PostToolUse) │ -│ HookExecutor ← QueryEngine (Stop/SubagentStop) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.8 Shell 执行层 (P2, BashTool 底层依赖) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/shell (~19000行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ ShellProvider 接口 │ │ -│ │ ├─ spawn(command, options) → ShellResult │ │ -│ │ ├─ getShellPath() → string │ │ -│ │ ├─ buildEnv(context) → Record │ │ -│ │ └─ wrapCommand(cmd, sandbox) → string (前缀注入) │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────┼──────────────────────┐ │ -│ │ │ │ │ -│ ┌──────▼─────────┐ ┌──────▼─────────┐ ┌────────▼──────────┐ │ -│ │ BashProvider │ │ ZshProvider │ │ PowerShellProvider│ │ -│ │ │ │ │ │ │ │ -│ │ ├─ bash/(24文件)│ │ ├─ .zshrc 检测│ │ ├─ powershell/ │ │ -│ │ │ 12310行 │ │ ├─ 插件兼容 │ │ │ 3文件, 2305行 │ │ -│ │ ├─ 超时/中断 │ │ └─ compfix │ │ ├─ FindGitBash │ │ -│ │ ├─ 沙盒集成 │ └────────────────┘ │ ├─ Windows路径 │ │ -│ │ └─ 命令前缀 │ │ └─ ExecutionPolicy│ │ -│ └─────────────────┘ └───────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ subprocessEnv.ts → 子进程环境变量构建 │ │ -│ │ ShellCommand.ts (465行) → 命令封装 + 流式输出 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: bash/ 12310行 + powershell/ 2305行 混在 utils/ 中 │ -│ 改动范围: 提取为独立 package, BashTool 通过依赖注入使用 │ -│ 依赖方向: packages/shell ← packages/agent-tools (BashTool) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.9 配置管理系统 (P2, 基础设施层) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/config (~9700行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ SettingsManager │ │ -│ │ ├─ 7 层优先级合并 (低→高, 后者覆盖前者): │ │ -│ │ │ 1. userSettings ~/.claude/settings.json │ │ -│ │ │ 2. projectSettings .claude/settings.json │ │ -│ │ │ 3. localSettings .claude/local/settings.json │ │ -│ │ │ 4. policySettings 企业管理 (远程下发) │ │ -│ │ │ 5. flagSettings GrowthBook feature flags │ │ -│ │ │ 6. cliArg --allowed-tools 等 CLI 参数 │ │ -│ │ │ 7. session 临时 ("始终允许" 按钮产生) │ │ -│ │ └─ get(key) / set(key, value, source) / watch(key, cb) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ -│ │ FeatureFlagProvider │ │ SettingsSync │ │ -│ │ ├─ BunBundle │ │ ├─ 跨设备同步 (已部分实现) │ │ -│ │ ├─ EnvVar │ │ ├─ 冲突检测/合并 │ │ -│ │ ├─ ConfigFile │ │ └─ RemoteManagedSettings │ │ -│ │ ├─ Remote (GrowthBook)│ │ (企业管控配置) │ │ -│ │ └─ feature(name)→bool│ └──────────────────────────────────┘ │ -│ └──────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ GlobalConfig (src/utils/config.ts, 1821行) │ │ -│ │ ├─ apiKey / oauthToken / customApiKeyResponses │ │ -│ │ ├─ preferredNotifChannel / projects (per-project) │ │ -│ │ ├─ saveGlobalConfig / getGlobalConfig (文件锁+新鲜度监控) │ │ -│ │ └─ trust dialog / config backup / default factory │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: settings/(3文件 1411行) + config.ts(1821行) 混在utils │ -│ 改动范围: 提取为独立 package, 作为最底层基础设施 │ -│ 依赖方向: 被 packages/agent, packages/permission 等所有模块依赖 │ -│ 注意: feature() 使用181处, 分布在30+文件, 提取需谨慎 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.10 遥测/诊断系统 (P3, 真实实现, 非空 stub) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ services/analytics/ (10文件, 4062行, 真实实现) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ 重要: 这不是空 stub, 而是完整的生产级实现 │ │ -│ │ 包含 OTel 日志导出、GrowthBook AB测试、Datadog 日志上传 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ FirstPartyEventLoggingExporter (806行) │ │ -│ │ ├─ OpenTelemetry LogExporter + JSONL 批处理 + HTTP 上传 │ │ -│ └─ FirstPartyEventLogger (449行) │ │ -│ └─ OTel LoggerProvider + 采样策略 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ GrowthBook 客户端 (1163行) │ │ -│ │ ├─ Feature flags / AB 测试 / 远程配置 │ │ -│ └─ sinkKillswitch (25行) — per-sink 开关 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Datadog 日志 (321行) + Metadata (973行) │ │ -│ │ ├─ Datadog 日志上传 (需环境变量配置端点/密钥) │ │ -│ └─ 事件元数据enrichment │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: 真实实现散布在 services/analytics/, 耦合 GrowthBook │ -│ 建议: 暂不提取为独立 package, 保持原地, 避免引入回归风险 │ -│ 依赖方向: 被多个模块调用, 但不影响核心业务逻辑 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.11 Compaction 服务 (P2, 从 query.ts 独立) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ CompactionService (packages/agent 内) │ -│ src/services/compact/ (29文件, 4267行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ CompactionStrategy (策略模式) │ │ -│ │ │ │ -│ │ ├─ SnipCompaction 精确裁剪 (保留首尾, 裁剪中间) │ │ -│ │ ├─ MicroCompaction 摘要压缩 (每 N 轮自动摘要) │ │ -│ │ └─ AutoCompaction 智能压缩 (按 token budget 触发) │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────────▼──────────────────────────────────┐ │ -│ │ ContextWindowManager │ │ -│ │ ├─ token 计数 / budget 分配 │ │ -│ │ ├─ 触发阈值检测 (80%/90%/95%) │ │ -│ │ └─ prompt 构造 (摘要请求 → 模型 → 替换历史) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: 逻辑分散在 query.ts + services/compact/ 两处 │ -│ 改动范围: 统一到 packages/agent/compaction/ │ -│ 依赖方向: ← QueryEngine 调用; → packages/provider (摘要请求) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.12 Swarm / 多Agent 协调 (P3, 扩展系统) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/swarm (~7548行 + tasks/) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ SwarmOrchestrator │ │ -│ │ ├─ spawnTeammate(config) → TeammateHandle │ │ -│ │ ├─ broadcast(message) → void │ │ -│ │ ├─ getTeamStatus() → TeamStatus[] │ │ -│ │ └─ shutdown() → void │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────┼──────────────────────┐ │ -│ │ │ │ │ -│ ┌──────▼──────────┐ ┌─────▼───────────┐ ┌──────▼──────────┐ │ -│ │ InProcessBackend│ │ TmuxBackend │ │ ITerm2Backend │ │ -│ │ (同进程, 线程) │ │ (Tmux pane) │ │ (iTerm2 split) │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ │ -│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ -│ │ TeammateMailbox │ │ PermissionSync │ │ -│ │ ├─ send(to, msg) │ │ ├─ 主Agent → 子Agent 权限传递 │ │ -│ │ ├─ receive() │ │ ├─ 规则同步 / 模式继承 │ │ -│ │ └─ broadcast(msg) │ │ └─ 子Agent 结果审批 │ │ -│ └──────────────────────┘ └──────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Task 类型系统 (src/tasks/, 13入口, 含巨文件) │ │ -│ │ ├─ LocalMainSessionTask 15373行 (全库最大!) │ │ -│ │ ├─ DreamTask 后台记忆合并 │ │ -│ │ ├─ InProcessTeammateTask 进程内队友 │ │ -│ │ ├─ LocalAgentTask 本地子Agent │ │ -│ │ ├─ RemoteAgentTask 远程子Agent │ │ -│ │ └─ MonitorMcpTask MCP 监控 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Worktree 管理 (src/utils/worktree.ts, 1519行) │ │ -│ │ ├─ createWorktree() → 隔离的 git worktree │ │ -│ │ ├─ cleanupWorktree() → 合并/丢弃 │ │ -│ │ └─ getWorktreePaths() → 路径映射 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: src/utils/swarm/(22文件, 7548行) + tasks/ 分散 │ -│ swarm 含 13文件(4486行) + backends/(9文件 3062行) │ -│ 改动范围: 统一到 packages/swarm/ │ -│ 依赖方向: ← packages/agent (AgentTool); → packages/shell │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.13 IDE / 编辑器集成 (P3, 扩展系统) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/ide (~6800行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ IDEConnector 接口 │ │ -│ │ ├─ connect(ideType) → IDEConnection │ │ -│ │ ├─ highlightFile(path, range) │ │ -│ │ ├─ openFile(path, line?) │ │ -│ │ └─ getSelection() → Range | null │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────┼──────────────────────┐ │ -│ │ │ │ │ -│ ┌──────▼──────────┐ ┌─────▼───────────┐ ┌──────▼──────────┐ │ -│ │ VSCodeProvider │ │ JetBrainsProvider│ │ LSPClient │ │ -│ │ (ide.ts 1494行) │ │ (jetbrains.ts │ │ (services/lsp/ │ │ -│ │ LSP 协议连接 │ │ 191行) │ │ 8文件) │ │ -│ │ 选区/高亮同步 │ │ IDE 集成 │ │ 代码操作/诊断 │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ CodeIndexing (native-ts/file-index/) │ │ -│ │ ├─ 文件内容索引 → 快速代码搜索 │ │ -│ │ └─ 增量更新 / 持久化 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Claude-in-Chrome (utils/claudeInChrome/, 7文件, 2337行) │ │ -│ │ ├─ Chrome Native Messaging → 浏览器控制 │ │ -│ │ ├─ 设置管理 / Prompt 注入 │ │ -│ │ └─ 独立于 Computer Use, 通过 --chrome 启用 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: ide.ts + jetbrains.ts + lsp/ + claudeInChrome/ 分散 │ -│ 改动范围: 合并到 packages/ide/ │ -│ 依赖方向: ← packages/agent (LSPTool); 独立于核心循环 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.14 Server 模式 (P3, 大部分 stub) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/server (11文件, 大部分 stub) │ -│ src/server/ (392行, 其中仅371行有效代码) │ -│ │ -│ 实际实现 (4文件, 371行): │ -│ ├─ directConnectManager.ts (213行) — Direct Connect 管理 │ -│ ├─ createDirectConnectSession.ts (88行) — 会话创建 │ -│ ├─ types.ts (57行) — 类型定义 │ -│ └─ lockfile.ts (13行) — PID 锁 │ -│ │ -│ Stub 文件 (6个, 各3行): │ -│ ├─ server.ts / sessionManager.ts / connectHeadless.ts │ -│ ├─ serverBanner.ts / serverLog.ts / parseConnectUrl.ts │ -│ └─ backends/dangerousBackend.ts │ -│ │ -│ 当前问题: 6/11文件为空 stub, 仅 DirectConnect 有实际代码 │ -│ 建议: 低优先级, 待 stub 被恢复后再考虑提取 │ -│ 依赖方向: ← main.tsx (启动, DIRECT_CONNECT feature); 独立于核心 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.15 远程执行 / Teleport (P3, 扩展系统) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/teleport (~2200行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ TeleportProvider │ │ -│ │ ├─ selectEnvironment(config) → Environment │ │ -│ │ ├─ packGitContext() → Archive (Git 打包上传) │ │ -│ │ ├─ execute(command, env) → Result (远程执行) │ │ -│ │ └─ syncResults(remote, local) (结果同步) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: teleport.tsx(1234行) + teleport/(4文件, 956行) │ -│ = 总计约2190行, 散布在 utils/ 中 │ -│ 改动范围: 提取为独立 package │ -│ 依赖方向: ← packages/agent; → packages/shell (远程执行) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.16 自动更新 / 安装器 (P3, 入口层辅助) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/updater (~3579行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ NativeInstaller │ │ -│ │ ├─ Linux: .deb / .rpm 包管理 │ │ -│ │ ├─ macOS: .pkg 安装器 │ │ -│ │ └─ Windows: (预留) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ AutoUpdater (autoUpdater.ts, 561行) │ │ -│ │ ├─ 版本检查 (远程 / npm registry) │ │ -│ │ ├─ 二进制下载 + 校验 │ │ -│ │ └─ PID 锁 (防止并发更新) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: nativeInstaller/(5文件 3018行) + autoUpdater.ts(561行)│ -│ = 总计约3579行, 散布在 utils/ 中 │ -│ 改动范围: 提取为独立 package, 仅被 entry layer 调用 │ -│ 依赖方向: ← cli.tsx / main.tsx (启动时检查); 无业务依赖 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.17 CLI 传输层 (P3, 入口层基础设施) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/cli (~12700行) │ -│ src/cli/ (127文件) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Transport 层 (可插拔 I/O 传输) │ │ -│ │ │ │ -│ │ ├─ HybridTransport 混合模式 (本地 + 远程) │ │ -│ │ ├─ SSETransport Server-Sent Events │ │ -│ │ ├─ WebSocketTransport WebSocket 双向通信 │ │ -│ │ ├─ WorkerStateTransport Worker 线程通信 │ │ -│ │ └─ SerialBatchTransport 串行批处理 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Handler 模块 (8个, 按 subcommand 分发) │ │ -│ │ ├─ agents / auth / mcp / autoMode / ... │ │ -│ │ ├─ StructuredIO (结构化输入输出) │ │ -│ │ └─ Rollback 机制 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: 127文件独立目录但未抽为 package │ -│ 改动范围: 提取为 packages/cli/, 仅被 entry layer 引用 │ -│ 依赖方向: ← cli.tsx / main.tsx; → packages/agent │ -└───────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 五、Packages 目录结构 - -``` -packages/ -├── @ant/ (已有) -│ ├── computer-use-mcp/ -│ ├── computer-use-input/ -│ ├── computer-use-swift/ -│ └── claude-for-chrome-mcp/ -├── audio-capture-napi/ (已有) -├── color-diff-napi/ (已有) -├── image-processor-napi/ (已有) -│ -├── ink/ ← Phase 1 UI 框架 + Keybinding + Vim + Typeahead -├── agent-tools/ ← Phase 2 Agent 工具库 + Sandbox 系统 -├── memory/ ← Phase 2 记忆系统, 独立实现 -├── permission/ ← Phase 2 权限系统, 独立实现 -├── config/ ← Phase 2 配置管理 + FeatureFlag + GlobalConfig -├── telemetry/ ← Phase 2 遥测/诊断 (真实实现, 非空stub) -├── agent/ ← Phase 3 核心引擎 + Hook 生命周期 + Compaction + Cron -├── provider/ ← Phase 4 ProviderAdapter + AuthProvider + NetworkLayer -├── shell/ ← Phase 4 Shell 执行层 (Bash/Zsh/PowerShell) -├── swarm/ ← Phase 5 多Agent协调 + Worktree -├── ide/ ← Phase 5 IDE/LSP/CodeIndex + Claude-in-Chrome -├── server/ ← Phase 5 服务器模式 (大部分stub, 低优先级) -├── teleport/ ← Phase 5 远程执行环境 -├── updater/ ← Phase 5 自动更新 + 原生安装器 -└── cli/ ← Phase 5 CLI 传输层 + Handler 分发 -``` - ---- - -## 六、实施路线图 - -``` -Phase 0: 内部分解 (与 Phase 1-3 并行, 低风险) - ├── main.tsx → 6 个模块 - ├── REPL.tsx → 5 个 Hook - ├── query.ts → 4 个子模块 - ├── services/mcp/client.ts → 3 个模块 - ├── LocalMainSessionTask.ts → 分解 (15373行, 全库最大) - └── AppState → Domain Slicing - -Phase 1: packages/ink/ 风险: 低 - ├── Ink 框架 (reconciler/hooks/components) - ├── Keybinding 系统 (16文件 → 纳入 ink/) - ├── Vim 模拟 (5文件 → 纳入 ink/) - └── Typeahead/Suggestion (→ 纳入 ink/) - -Phase 2: 独立系统提取 风险: 低-中 - ├── packages/agent-tools/ Agent 工具库 + Sandbox - ├── packages/memory/ 记忆系统 - ├── packages/permission/ 权限系统 - ├── packages/config/ 配置管理 + FeatureFlag + GlobalConfig - └── packages/telemetry/ 遥测/诊断 (真实实现, 谨慎提取) - -Phase 3: packages/agent/ 风险: 中-高 - ├── query() + QueryEngine 核心循环 - ├── Hook 生命周期 (hooks.ts 5177行 + hooks/ 5文件 → 提取, 27种事件) - ├── Compaction 服务 (services/compact/ 29文件 → 统一) - ├── Cron/Scheduler (utils/cron* → 提取) - └── FileHistory (utils/fileHistory → 提取) - -Phase 4: packages/provider/ + packages/shell/ 风险: 中 - ├── LLM Provider 适配器 (核心价值最高) - ├── Auth Provider 适配器 - ├── Context Pipeline - ├── NetworkLayer (proxy/mTLS/CA证书) - └── Shell 执行层 (bash/powershell → 提取) - -Phase 5: 扩展系统 风险: 中-高 - ├── Tool Registry / Plugin - ├── Storage Backend / Command System - ├── Output Target / Feature Flag Provider - ├── packages/swarm/ 多Agent协调 + Worktree - ├── packages/ide/ IDE/LSP + CodeIndex + Chrome - ├── packages/server/ 服务器 (大部分stub, 可延后) - ├── packages/teleport/ 远程执行 - ├── packages/updater/ 自动更新 + 安装器 - └── packages/cli/ CLI 传输层 -``` - ---- - -## 七、优先级矩阵 - -| 项目 | 用户价值 | 风险 | 优先级 | -|------|---------|------|--------| -| **LLM Provider 适配器** | 高 (多模型, 已有OpenAI参考实现) | 中 | **P1** | -| **Auth Provider 适配器** | 高 (安全, 7种认证约3857行) | 高 | **P1** | -| **Tool Registry** | 高 (生态, 54个工具) | 低 | **P1** | -| **Hook 生命周期** | 高 (核心扩展点, 27种事件) | 中 | **P1** | -| **LocalMainSessionTask分解** | 高 (15373行巨文件) | 中 | **P1** | -| **Shell 执行层** | 高 (BashTool底层, bash/12310行) | 中 | **P2** | -| **配置管理** | 高 (基础设施, config.ts 1821行) | 低-中 | **P2** | -| **记忆系统** | 高 (跨会话记忆, 24文件 6330行) | 低-中 | **P2** | -| **权限系统** | 高 (安全/可扩展, 24文件 9416行) | 中 | **P2** | -| **Compaction 服务** | 中 (上下文管理, 29文件 4267行) | 低 | **P2** | -| **命令系统** | 中 (可扩展命令, 93目录 96命令) | 低 | P2 | -| Context Pipeline | 中 (自定义prompt) | 低 | P2 | -| Storage Backend | 中 (云同步/测试, 5文件约6968行) | 低-中 | P2 | -| **遥测/诊断** | 中 (可观测性, 真实实现非stub) | 中 | P2 | -| main.tsx 分解 | 中 (可维护性, 4680行) | 低 | P2 | -| REPL.tsx 分解 | 中 (可维护性, 5005行) | 中 | P2 | -| query.ts 分解 | 中 (可维护性, 1732行) | 低 | P3 | -| AppState Slicing | 低 (199行类型) | 低 | P3 | -| mcp/client.ts 分解 | 低 (3351行, services/mcp/) | 低 | P3 | -| Output Target | 中 (596个组件, 范围大) | 中-高 | P3 | -| Feature Flag Provider | 低 (181处使用, 30+文件) | 中 | P3 | -| **Swarm/多Agent** | 高 (并行能力, 22文件 7548行) | 高 | **P3** | -| **IDE/LSP 集成** | 中 (编辑器体验) | 中 | P3 | -| **Server 模式** | 低 (6/11文件为stub, 仅371行有效) | 低 | P4 | -| **Teleport 远程执行** | 中 (远程开发, 约2190行) | 中 | P3 | -| **自动更新/安装器** | 低 (运维, 约3579行) | 低 | P3 | -| **CLI 传输层** | 低 (内部架构, 127文件) | 低 | P3 | - ---- - -## 八、命令系统 - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ 用户输入 "/" 触发 │ -│ │ -│ REPL.tsx (用户按 Enter) │ -│ │ │ -│ ▼ │ -│ handlePromptSubmit.ts (610行) │ -│ │ │ -│ ▼ │ -│ processUserInput.ts (605行) ─── 识别 "/" 前缀 │ -│ │ │ -│ ▼ │ -│ processSlashCommand.tsx (921行) ─── 命令分发器 │ -│ │ │ -│ │ 解析命令名 + 参数 │ -│ │ findCommand() → 查找 Command 对象 │ -│ │ │ -│ ├──────────────┬──────────────────┬─────────────────┐ │ -│ ▼ ▼ ▼ ▼ │ -│ ┌─────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ local │ │ local-jsx │ │ prompt │ │ 未知命令 │ │ -│ │ │ │ │ │ │ │ → 错误 │ │ -│ │ load() │ │ load() │ │ getPrompt() │ └──────────┘ │ -│ │ →call() │ │ →call() │ │ → 注入消息 │ │ -│ │ →结果 │ │ → ReactNode│ │ → 发送查询 │ │ -│ └─────────┘ └─────────────┘ └──────────────┘ │ -│ │ │ │ │ -│ └──────────────┴──────────────────┘ │ -│ │ │ -│ ▼ │ -│ 返回结果给 REPL │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 命令注册 (src/commands.ts, ~470行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ COMMANDS() ─── 静态注册 ~96 个命令 │ │ -│ │ (71个静态导入 + 条件feature控制 + ~25个INTERNAL_ONLY) │ │ -│ │ │ │ -│ │ ┌─ src/commands/ (93个目录, 228文件) ─── 每个命令: │ │ -│ │ │ name, description, aliases, type │ │ -│ │ │ load: () => import('./impl.js') ← 懒加载 │ │ -│ │ │ isEnabled(), availability │ │ -│ │ └───────────────────────────────────────────────────────── │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ + │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ getCommands() ─── 动态源 (合并到最终列表) │ │ -│ │ │ │ -│ │ ├─ getSkillDirCommands() .claude/commands/ + skills/ │ │ -│ │ ├─ getBundledSkills() 内置 skill │ │ -│ │ ├─ getPluginSkills() 插件 skill │ │ -│ │ ├─ getPluginCommands() 插件命令 │ │ -│ │ ├─ getWorkflowCommands() workflow 脚本命令 │ │ -│ │ └─ getDynamicSkills() 运行时动态发现 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 过滤: isEnabled → meetsAvailability → 去重 │ -│ │ │ -│ ▼ │ -│ findCommand() / getCommand() / hasCommand() │ -│ (按 name, alias 查找, Fuse.js 模糊匹配) │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 自动补全 (useTypeahead, 1384行) │ -│ │ -│ 用户键入 "/" │ -│ │ │ -│ ▼ │ -│ generateCommandSuggestions() (567行) │ -│ │ │ -│ ▼ │ -│ Fuse.js 模糊搜索 ─── 精确 > alias > 前缀 > 模糊 │ -│ │ │ -│ ▼ │ -│ Ghost Text 补全 / 列表选择 / Tab/Enter 确认 │ -└───────────────────────────────────────────────────────────────────┘ - -耦合特征: - ● commands.ts (~470行) 静态导入所有命令, 新增命令必须手动注册 - ● src/commands/ 93个目录 228文件, 每个命令独立目录 - ● processSlashCommand.tsx switch 分发, 新命令类型需改分发器 - ● SkillTool (1109行) 提供第二条路径: 模型直接调用 skill - ● REMOTE_SAFE_COMMANDS / BRIDGE_SAFE_COMMANDS 硬编码安全列表 -``` - ---- - -## 九、记忆系统 - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ 记忆系统总览 │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ CLAUDE.md 指令文件 (用户管理) claudemd.ts (1479行) │ │ -│ │ │ │ -│ │ 加载优先级 (低→高, 后者覆盖前者): │ │ -│ │ 1. Managed /etc/claude-code/CLAUDE.md │ │ -│ │ 2. User ~/.claude/CLAUDE.md │ │ -│ │ 3. Project CLAUDE.md, .claude/CLAUDE.md, .claude/rules/ │ │ -│ │ 4. Local CLAUDE.local.md (gitignored) │ │ -│ │ 5. AutoMem MEMORY.md (agent 管理) │ │ -│ │ 6. TeamMem MEMORY.md (团队共享) │ │ -│ │ │ │ -│ │ 特性: │ │ -│ │ ├─ @include 指令 (递归解析, 最深 5 层) │ │ -│ │ ├─ frontmatter paths: 条件规则 │ │ -│ │ ├─ HTML 注释剥离, 循环引用防护 │ │ -│ │ └─ MEMORY.md 截断: 200行 / 25KB │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Auto-Memory (Agent 管理) memdir/ (9文件, 1743行)│ │ -│ │ │ │ -│ │ 存储位置: ~/.claude/projects//memory/ │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────────────┐ │ │ -│ │ │ 四类型分类法 │ │ │ -│ │ │ │ │ │ -│ │ │ user 用户角色/偏好/知识 (私有) │ │ │ -│ │ │ feedback 方法指导/纠正/确认 (默认私有) │ │ │ -│ │ │ project 项目工作/目标/决策 (团队共享) │ │ │ -│ │ │ reference 外部系统指针 (团队共享) │ │ │ -│ │ └─────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ -│ │ │ MemoryStore │ │ MemoryRecall │ │ MemoryExtract │ │ │ -│ │ │ 存储抽象 │ │ 相关性检索 │ │ 后台提取 │ │ │ -│ │ │ │ │ │ │ │ │ │ -│ │ │ 写入 .md │ │ Sonnet 侧查 │ │ 每轮对话后触发 │ │ │ -│ │ │ + frontmatter│ │ → 选 5 条最 │ │ → fork subagent │ │ │ -│ │ │ + MEMORY.md │ │ 相关记忆 │ │ → 只读工具+写记忆│ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────┐ │ │ -│ │ │ MemoryConsolidate│ autoDream.ts (326行) │ │ -│ │ │ 合并/清理/修剪 │ 后台运行, 防并发锁 │ │ -│ │ └──────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────────┐ │ │ -│ │ │ extractMemories/ (2文件, 769行) │ │ │ -│ │ │ 后台异步提取, fork subagent, 只读工具+写记忆 │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────────┐ │ │ -│ │ │ teamMemorySync/ (4文件, 2167行) │ │ │ -│ │ │ 团队记忆同步, 跨会话/跨Agent共享 │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ 集成点 │ │ -│ │ │ │ -│ │ context.ts ─── getMemoryFiles() + getClaudeMds() │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ 每轮对话注入指令上下文 │ │ -│ │ │ │ -│ │ stopHooks.ts ─── handleStopHooks() │ │ -│ │ │ │ │ -│ │ ├─→ executeExtractMemories() (异步提取) │ │ -│ │ └─→ executeAutoDream() (异步合并) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 特性门控: │ -│ CLAUDE_CODE_DISABLE_AUTO_MEMORY, autoMemoryEnabled, │ -│ tengu_passport_quail (提取), tengu_moth_copse (附件模式), │ -│ tengu_herring_clock (团队记忆), EXTRACT_MEMORIES, TEAMMEM │ -│ │ -│ 总规模: 24文件, 约6330行 │ -│ ├─ memdir/ (9文件, 1743行) 存储抽象 │ -│ ├─ services/autoDream/ (4文件, 552行) 合并/清理 │ -│ ├─ services/extractMemories/ (2文件, 769行) 后台提取 │ -│ ├─ services/teamMemorySync/ (4文件, 2167行) 团队同步 │ -│ ├─ tasks/DreamTask/ (1文件, 157行) 后台任务 │ -│ └─ utils/辅助文件 (4文件, ~542行) 检测/操作 │ -└───────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 十、权限系统 - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ 权限模式 (PermissionMode) │ -│ PermissionMode.ts (141行) │ -│ │ -│ ┌──────────────┐ Shift+Tab 循环 │ -│ │ default │ ←→ acceptEdits ←→ plan ←→ bypassPermissions │ -│ │ (每步确认) │ ↑ (YOLO模式) │ -│ └──────────────┘ │ │ -│ auto (仅内部) │ -│ bubble (子agent) │ -│ dontAsk (静默拒绝) │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 权限检查管线 (核心, 1486行) │ -│ permissions.ts: hasPermissionsToUseToolInner() │ -│ │ -│ Tool 调用请求 │ -│ │ │ -│ ┌────▼────────────────────────────────────────────────────┐ │ -│ │ Step 1: 强制检查 (不可被模式跳过) │ │ -│ │ │ │ -│ │ 1a. deny 规则匹配 → 立即拒绝 │ │ -│ │ 1b. ask 规则匹配 → 需要确认 │ │ -│ │ 1c. tool.checkPermissions() → 工具自检 │ │ -│ │ ├─ 文件工具 → filesystem.ts (1778行) │ │ -│ │ │ 读: UNC/Windows/拒绝/工作目录/内部路径 │ │ -│ │ │ 写: .git/.claude/.bashrc 安全检查 │ │ -│ │ └─ Bash → bashPermissions.ts (2621行) │ │ -│ │ 子命令级别权限校验 │ │ -│ │ 1d. 工具级拒绝 │ │ -│ │ 1e. requiresUserInteraction() → 强制交互 │ │ -│ │ 1f. 内容级 ask 规则 (bypass-immune) │ │ -│ │ 1g. 安全路径检查 (.git/ .claude/ .bashrc) │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────▼────────────────────────────────────────────────────┐ │ -│ │ Step 2: 模式决策 │ │ -│ │ │ │ -│ │ 2a. bypassPermissions → 放行 (除非被 Step 1 拦截) │ │ -│ │ 2b. alwaysAllow 规则匹配 → 放行 │ │ -│ │ 2c. 无匹配 → 转为 ask │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────▼────────────────────────────────────────────────────┐ │ -│ │ Step 3: 模式后处理 │ │ -│ │ │ │ -│ │ dontAsk → ask 变 deny (静默拒绝) │ │ -│ │ auto → YOLO Classifier (1495行) │ │ -│ │ ├─ Stage 1: 快速分类 (无 thinking) │ │ -│ │ └─ Stage 2: 深度分析 (chain-of-thought) │ │ -│ │ headless → 先跑 PermissionRequest hooks, 再 auto-deny│ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 结果: allow / deny / ask │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 规则来源 (优先级从低到高) │ -│ permissionSetup.ts (1533行) │ -│ │ -│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ -│ │ userSettings │ │ projectSettings │ │ localSettings │ │ -│ │ ~/.claude/ │ │ .claude/ │ │ .claude/local │ │ -│ │ settings.json │ │ settings.json │ │ settings.json │ │ -│ └────────────────┘ └─────────────────┘ └──────────────────┘ │ -│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ -│ │ policySettings │ │ flagSettings │ │ cliArg │ │ -│ │ (企业管理) │ │ (GrowthBook) │ │ --allowed-tools │ │ -│ └────────────────┘ └─────────────────┘ └──────────────────┘ │ -│ ┌────────────────┐ ┌─────────────────┐ │ -│ │ command │ │ session │ │ -│ │ /permissions │ │ (临时, "始终允许"│ │ -│ │ │ │ 按钮产生) │ │ -│ └────────────────┘ └─────────────────┘ │ -│ │ -│ 规则格式: "Bash(git push:*)", "Edit(.claude/**)" │ -│ 解析: permissionRuleParser.ts (198行) │ -│ 三种行为: allow / deny / ask │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 交互式审批流 (竞速模式) │ -│ interactiveHandler.ts (536行) │ -│ │ -│ ask 结果到达 │ -│ │ │ -│ ▼ │ -│ 推送 ToolUseConfirm 到 React 确认队列 │ -│ │ │ -│ ├─────────────┬──────────────┬──────────────┐ │ -│ ▼ ▼ ▼ ▼ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Terminal │ │ Bridge │ │ Channel Relay│ │ Hooks │ │ -│ │ 本地 Y/N │ │ claude.ai│ │ Telegram/IM │ │ 外部程序审批 │ │ -│ └──────────┘ └──────────┘ └──────────────┘ └──────────────┘ │ -│ │ │ │ │ │ -│ └─────────────┴──────────────┴──────────────┘ │ -│ │ │ -│ createResolveOnce 原子竞争 │ -│ 第一个响应者胜出 │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ ToolPermissionContext (核心状态对象) │ -│ 存储于 AppState.toolPermissionContext │ -│ │ -│ ├─ mode: PermissionMode │ -│ ├─ additionalWorkingDirectories: Map │ -│ ├─ alwaysAllowRules: { [source]: string[] } │ -│ ├─ alwaysDenyRules: { [source]: string[] } │ -│ ├─ alwaysAskRules: { [source]: string[] } │ -│ ├─ isBypassPermissionsModeAvailable: boolean │ -│ ├─ isAutoModeAvailable?: boolean │ -│ ├─ strippedDangerousRules?: { [source]: string[] } │ -│ ├─ shouldAvoidPermissionPrompts?: boolean │ -│ └─ prePlanMode?: PermissionMode │ -└───────────────────────────────────────────────────────────────────┘ - -当前问题: - ● 权限逻辑集中在 src/utils/permissions/ (24文件, 9416行) - ● 核心文件: permissions.ts (1486行), permissionSetup.ts (1533行) - ● filesystem.ts (1778行) 文件工具权限, yoloClassifier.ts (1495行) AI分类 - ● bashPermissions 不在 bash/ 目录, bash分类逻辑在 permissions/bashClassifier.ts - ● 规则来源 7 种, 优先级隐含在加载顺序中 - ● UI 组件 src/components/permissions/ (79文件) 与逻辑混合 -``` diff --git a/docs/pr-120-merge-plan.md b/docs/pr-120-merge-plan.md new file mode 100644 index 000000000..3f6c8b746 --- /dev/null +++ b/docs/pr-120-merge-plan.md @@ -0,0 +1,49 @@ +# PR #120 分批合入方案 + +> 分支: `pr-sobird-120` → `main` +> 总计: 558 files / +102,830 / -101,373 + +## 执行进度 + +### B1: 552 文件格式化 — 拆分为 6 个子批次 + +#### B1-1: ink + buddy + cli + context + screens + tasks + services + keybindings (43 files) ✓ +- [x] 合入: src/ink/ (17), src/buddy/ (2), src/cli/ (2), src/context/ (9), src/screens/ (3), src/tasks/ (4), src/services/ (3), src/keybindings/ (2), src/state/ (1) +- [x] 验证 `bun run build` 通过 ✓ (475 files) + +#### B1-2: commands (79 files) ✓ +- [x] 合入: src/commands/ (79 files) +- [x] 验证 `bun run build` 通过 ✓ + +#### B1-3: components/messages + permissions + mcp + sandbox + shell (104 files) ✓ +- [x] 合入: src/components/messages/ (39), src/components/permissions/ (39), src/components/mcp/ (11), src/components/sandbox/ (5), src/components/shell/ (4) +- [x] 验证 `bun run build` 通过 ✓ + +#### B1-4: components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files) ✓ +- [x] 合入: src/components/PromptInput/ (13), src/components/FeedbackSurvey/ (6), src/components/tasks/ (12), src/components/agents/ (17), src/components/skills/ (1), src/components/design-system/ (14), src/components/wizard/ (3) +- [x] 验证 `bun run build` 通过 ✓ + +#### B1-5: components 其余 + hooks + tools (232 files) ✓ +- [x] 合入: src/components/ 其余目录 (~169), src/hooks/ (28), src/tools/ (35) +- [x] 验证 `bun run build` 通过 ✓ + +#### B1-6: 根目录 + utils + 其他零散文件 (21 files) ✓ +- [x] 合入: src/main.tsx, src/dialogLaunchers.tsx, src/replLauncher.tsx, src/interactiveHelpers.tsx, src/entrypoints/, src/moreright/, src/utils/ (15) +- [x] 验证 `bun run build` 通过 ✓ + +### B2: 45 文件 USER_TYPE 替换 (commit `fc200fd`) +- [ ] cherry-pick USER_TYPE 提交 +- [ ] 检查替换是否完整无遗漏 +- [ ] 验证 `bun run build` 通过 + +### B3: 文档变更 (README / Run.ps1 / TODO.md / V6.md) +- [ ] 合入 README.md, Run.ps1, TODO.md +- [ ] 删除 V6.md +- [ ] 验证无破坏 + +### B4: 构建配置变更 — 跳过 + +### 最终验证 +- [ ] `bun run build` 完整构建 +- [ ] `bun test` 测试通过 +- [ ] git log 确认提交历史清晰 diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index 06e72d047..d8c7ae473 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -1,162 +1,114 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import React, { useEffect, useRef, useState } from 'react'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import type { AppState } from '../state/AppStateStore.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { isFullscreenActive } from '../utils/fullscreen.js'; -import type { Theme } from '../utils/theme.js'; -import { getCompanion } from './companion.js'; -import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'; -import { RARITY_COLORS } from './types.js'; -const TICK_MS = 500; -const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms -const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go -const PET_BURST_MS = 2500; // how long hearts float after /buddy pet +import { feature } from 'bun:bundle' +import figures from 'figures' +import React, { useEffect, useRef, useState } from 'react' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import type { AppState } from '../state/AppStateStore.js' +import { getGlobalConfig } from '../utils/config.js' +import { isFullscreenActive } from '../utils/fullscreen.js' +import type { Theme } from '../utils/theme.js' +import { getCompanion } from './companion.js' +import { renderFace, renderSprite, spriteFrameCount } from './sprites.js' +import { RARITY_COLORS } from './types.js' + +const TICK_MS = 500 +const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms +const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go +const PET_BURST_MS = 2500 // how long hearts float after /buddy pet // Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink. // Sequence indices map to sprite frames; -1 means "blink on frame 0". -const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; +const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0] // Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite. -const H = figures.heart; -const PET_HEARTS = [` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · ']; +const H = figures.heart +const PET_HEARTS = [ + ` ${H} ${H} `, + ` ${H} ${H} ${H} `, + ` ${H} ${H} ${H} `, + `${H} ${H} ${H} `, + '· · · ', +] + function wrap(text: string, width: number): string[] { - const words = text.split(' '); - const lines: string[] = []; - let cur = ''; + const words = text.split(' ') + const lines: string[] = [] + let cur = '' for (const w of words) { if (cur.length + w.length + 1 > width && cur) { - lines.push(cur); - cur = w; + lines.push(cur) + cur = w } else { - cur = cur ? `${cur} ${w}` : w; + cur = cur ? `${cur} ${w}` : w } } - if (cur) lines.push(cur); - return lines; + if (cur) lines.push(cur) + return lines } -function SpeechBubble(t0) { - const $ = _c(31); - const { - text, - color, - fading, - tail - } = t0; - let T0; - let borderColor; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - if ($[0] !== color || $[1] !== fading || $[2] !== text) { - const lines = wrap(text, 30); - borderColor = fading ? "inactive" : color; - T0 = Box; - t1 = "column"; - t2 = "round"; - t3 = borderColor; - t4 = 1; - t5 = 34; - let t7; - if ($[11] !== fading) { - t7 = (l, i) => {l}; - $[11] = fading; - $[12] = t7; - } else { - t7 = $[12]; - } - t6 = lines.map(t7); - $[0] = color; - $[1] = fading; - $[2] = text; - $[3] = T0; - $[4] = borderColor; - $[5] = t1; - $[6] = t2; - $[7] = t3; - $[8] = t4; - $[9] = t5; - $[10] = t6; - } else { - T0 = $[3]; - borderColor = $[4]; - t1 = $[5]; - t2 = $[6]; - t3 = $[7]; - t4 = $[8]; - t5 = $[9]; - t6 = $[10]; - } - let t7; - if ($[13] !== T0 || $[14] !== t1 || $[15] !== t2 || $[16] !== t3 || $[17] !== t4 || $[18] !== t5 || $[19] !== t6) { - t7 = {t6}; - $[13] = T0; - $[14] = t1; - $[15] = t2; - $[16] = t3; - $[17] = t4; - $[18] = t5; - $[19] = t6; - $[20] = t7; - } else { - t7 = $[20]; - } - const bubble = t7; - if (tail === "right") { - let t8; - if ($[21] !== borderColor) { - t8 = ; - $[21] = borderColor; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== bubble || $[24] !== t8) { - t9 = {bubble}{t8}; - $[23] = bubble; - $[24] = t8; - $[25] = t9; - } else { - t9 = $[25]; - } - return t9; - } - let t8; - if ($[26] !== borderColor) { - t8 = ; - $[26] = borderColor; - $[27] = t8; - } else { - t8 = $[27]; - } - let t9; - if ($[28] !== bubble || $[29] !== t8) { - t9 = {bubble}{t8}; - $[28] = bubble; - $[29] = t8; - $[30] = t9; - } else { - t9 = $[30]; + +function SpeechBubble({ + text, + color, + fading, + tail, +}: { + text: string + color: keyof Theme + fading: boolean + tail: 'down' | 'right' +}): React.ReactNode { + const lines = wrap(text, 30) + const borderColor = fading ? 'inactive' : color + const bubble = ( + + {lines.map((l, i) => ( + + {l} + + ))} + + ) + if (tail === 'right') { + return ( + + {bubble} + + + ) } - return t9; + return ( + + {bubble} + + + + + + ) } -export const MIN_COLS_FOR_FULL_SPRITE = 100; -const SPRITE_BODY_WIDTH = 12; -const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name ` -const SPRITE_PADDING_X = 2; -const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column -const NARROW_QUIP_CAP = 24; + +export const MIN_COLS_FOR_FULL_SPRITE = 100 +const SPRITE_BODY_WIDTH = 12 +const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name ` +const SPRITE_PADDING_X = 2 +const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column +const NARROW_QUIP_CAP = 24 + function spriteColWidth(nameWidth: number): number { - return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD); + return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD) } // Width the sprite area consumes. PromptInput subtracts this so text wraps @@ -164,115 +116,171 @@ function spriteColWidth(nameWidth: number): number { // width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more. // Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row // (above input in fullscreen, below in scrollback), so no reservation. -export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { - if (!feature('BUDDY')) return 0; - const companion = getCompanion(); - if (!companion || getGlobalConfig().companionMuted) return 0; - if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; - const nameWidth = stringWidth(companion.name); - const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; - return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; +export function companionReservedColumns( + terminalColumns: number, + speaking: boolean, +): number { + if (!feature('BUDDY')) return 0 + const companion = getCompanion() + if (!companion || getGlobalConfig().companionMuted) return 0 + if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0 + const nameWidth = stringWidth(companion.name) + const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0 + return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble } + export function CompanionSprite(): React.ReactNode { - const reaction = useAppState(s => s.companionReaction); - const petAt = useAppState(s => s.companionPetAt); - const focused = useAppState(s => s.footerSelection === 'companion'); - const setAppState = useSetAppState(); - const { - columns - } = useTerminalSize(); - const [tick, setTick] = useState(0); - const lastSpokeTick = useRef(0); + const reaction = useAppState(s => s.companionReaction) + const petAt = useAppState(s => s.companionPetAt) + const focused = useAppState(s => s.footerSelection === 'companion') + const setAppState = useSetAppState() + const { columns } = useTerminalSize() + const [tick, setTick] = useState(0) + const lastSpokeTick = useRef(0) // Sync-during-render (not useEffect) so the first post-pet render already // has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped. - const [{ - petStartTick, - forPetAt - }, setPetStart] = useState({ + const [{ petStartTick, forPetAt }, setPetStart] = useState({ petStartTick: 0, - forPetAt: petAt - }); + forPetAt: petAt, + }) if (petAt !== forPetAt) { - setPetStart({ - petStartTick: tick, - forPetAt: petAt - }); + setPetStart({ petStartTick: tick, forPetAt: petAt }) } + useEffect(() => { - const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick); - return () => clearInterval(timer); - }, []); + const timer = setInterval( + setT => setT((t: number) => t + 1), + TICK_MS, + setTick, + ) + return () => clearInterval(timer) + }, []) + useEffect(() => { - if (!reaction) return; - lastSpokeTick.current = tick; - const timer = setTimeout(setA => setA((prev: AppState) => prev.companionReaction === undefined ? prev : { - ...prev, - companionReaction: undefined - }), BUBBLE_SHOW * TICK_MS, setAppState); - return () => clearTimeout(timer); + if (!reaction) return + lastSpokeTick.current = tick + const timer = setTimeout( + setA => + setA((prev: AppState) => + prev.companionReaction === undefined + ? prev + : { ...prev, companionReaction: undefined }, + ), + BUBBLE_SHOW * TICK_MS, + setAppState, + ) + return () => clearTimeout(timer) // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked - }, [reaction, setAppState]); - if (!feature('BUDDY')) return null; - const companion = getCompanion(); - if (!companion || getGlobalConfig().companionMuted) return null; - const color = RARITY_COLORS[companion.rarity]; - const colWidth = spriteColWidth(stringWidth(companion.name)); - const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; - const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; - const petAge = petAt ? tick - petStartTick : Infinity; - const petting = petAge * TICK_MS < PET_BURST_MS; + }, [reaction, setAppState]) + + if (!feature('BUDDY')) return null + const companion = getCompanion() + if (!companion || getGlobalConfig().companionMuted) return null + + const color = RARITY_COLORS[companion.rarity] + const colWidth = spriteColWidth(stringWidth(companion.name)) + + const bubbleAge = reaction ? tick - lastSpokeTick.current : 0 + const fading = + reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW + + const petAge = petAt ? tick - petStartTick : Infinity + const petting = petAge * TICK_MS < PET_BURST_MS // Narrow terminals: collapse to one-line face. When speaking, the quip // replaces the name beside the face (no room for a bubble). if (columns < MIN_COLS_FOR_FULL_SPRITE) { - const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; - const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name; - return + const quip = + reaction && reaction.length > NARROW_QUIP_CAP + ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' + : reaction + const label = quip + ? `"${quip}"` + : focused + ? ` ${companion.name} ` + : companion.name + return ( + {petting && {figures.heart} } {renderFace(companion)} {' '} - + {label} - ; + + ) } - const frameCount = spriteFrameCount(companion.species); - const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; - let spriteFrame: number; - let blink = false; + const frameCount = spriteFrameCount(companion.species) + const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null + + let spriteFrame: number + let blink = false if (reaction || petting) { // Excited: cycle all fidget frames fast - spriteFrame = tick % frameCount; + spriteFrame = tick % frameCount } else { - const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!; + const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]! if (step === -1) { - spriteFrame = 0; - blink = true; + spriteFrame = 0 + blink = true } else { - spriteFrame = step % frameCount; + spriteFrame = step % frameCount } } - const body = renderSprite(companion, spriteFrame).map(line => blink ? line.replaceAll(companion.eye, '-') : line); - const sprite = heartFrame ? [heartFrame, ...body] : body; + + const body = renderSprite(companion, spriteFrame).map(line => + blink ? line.replaceAll(companion.eye, '-') : line, + ) + const sprite = heartFrame ? [heartFrame, ...body] : body // Name row doubles as hint row — unfocused shows dim name + ↓ discovery, // focused shows inverse name. The enter-to-open hint lives in // PromptInputFooter's right column so this row stays one line and the // sprite doesn't jump up when selected. flexShrink=0 stops the // inline-bubble row wrapper from squeezing the sprite to fit. - const spriteColumn = - {sprite.map((line, i) => + const spriteColumn = ( + + {sprite.map((line, i) => ( + {line} - )} - + + ))} + {focused ? ` ${companion.name} ` : companion.name} - ; + + ) + if (!reaction) { - return {spriteColumn}; + return {spriteColumn} } // Fullscreen: bubble renders separately via CompanionFloatingBubble in @@ -281,90 +289,60 @@ export function CompanionSprite(): React.ReactNode { // Non-fullscreen: bubble sits inline beside the sprite (input shrinks) // because floating into Static scrollback can't be cleared. if (isFullscreenActive()) { - return {spriteColumn}; + return {spriteColumn} } - return - + return ( + + {spriteColumn} - ; + + ) } // Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's // bottomFloat slot (outside the overflowY:hidden clip) so it can extend into // the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this // just reads companionReaction and renders the fade. -export function CompanionFloatingBubble() { - const $ = _c(8); - const reaction = useAppState(_temp); - let t0; - if ($[0] !== reaction) { - t0 = { - tick: 0, - forReaction: reaction - }; - $[0] = reaction; - $[1] = t0; - } else { - t0 = $[1]; - } - const [t1, setTick] = useState(t0); - const { - tick, - forReaction - } = t1; +export function CompanionFloatingBubble(): React.ReactNode { + const reaction = useAppState(s => s.companionReaction) + const [{ tick, forReaction }, setTick] = useState({ + tick: 0, + forReaction: reaction, + }) + + // Reset tick synchronously when reaction changes (not in useEffect, which + // runs post-render and would show one stale-faded frame). Storing the + // reaction the tick is counting FOR alongside the tick itself means the + // fade computation never sees a tick from a previous reaction. if (reaction !== forReaction) { - setTick({ - tick: 0, - forReaction: reaction - }); - } - let t2; - let t3; - if ($[2] !== reaction) { - t2 = () => { - if (!reaction) { - return; - } - const timer = setInterval(_temp3, TICK_MS, setTick); - return () => clearInterval(timer); - }; - t3 = [reaction]; - $[2] = reaction; - $[3] = t2; - $[4] = t3; - } else { - t2 = $[3]; - t3 = $[4]; + setTick({ tick: 0, forReaction: reaction }) } - useEffect(t2, t3); - if (!feature("BUDDY") || !reaction) { - return null; - } - const companion = getCompanion(); - if (!companion || getGlobalConfig().companionMuted) { - return null; - } - const t4 = tick >= BUBBLE_SHOW - FADE_WINDOW; - let t5; - if ($[5] !== reaction || $[6] !== t4) { - t5 = ; - $[5] = reaction; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; -} -function _temp3(set) { - return set(_temp2); -} -function _temp2(s_0) { - return { - ...s_0, - tick: s_0.tick + 1 - }; -} -function _temp(s) { - return s.companionReaction; + + useEffect(() => { + if (!reaction) return + const timer = setInterval( + set => set(s => ({ ...s, tick: s.tick + 1 })), + TICK_MS, + setTick, + ) + return () => clearInterval(timer) + }, [reaction]) + + if (!feature('BUDDY') || !reaction) return null + const companion = getCompanion() + if (!companion || getGlobalConfig().companionMuted) return null + + return ( + = BUBBLE_SHOW - FADE_WINDOW} + tail="down" + /> + ) } diff --git a/src/buddy/useBuddyNotification.tsx b/src/buddy/useBuddyNotification.tsx index 645316396..62d61f4cf 100644 --- a/src/buddy/useBuddyNotification.tsx +++ b/src/buddy/useBuddyNotification.tsx @@ -1,97 +1,67 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import React, { useEffect } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { getRainbowColor } from '../utils/thinking.js'; +import { feature } from 'bun:bundle' +import React, { useEffect } from 'react' +import { useNotifications } from '../context/notifications.js' +import { Text } from '../ink.js' +import { getGlobalConfig } from '../utils/config.js' +import { getRainbowColor } from '../utils/thinking.js' // Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter // buzz instead of a single UTC-midnight spike, gentler on soul-gen load. // Teaser window: April 1-7, 2026 only. Command stays live forever after. export function isBuddyTeaserWindow(): boolean { - if ((process.env.USER_TYPE) === 'ant') return true; - const d = new Date(); - return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; + if (process.env.USER_TYPE === 'ant') return true + const d = new Date() + return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7 } + export function isBuddyLive(): boolean { - if ((process.env.USER_TYPE) === 'ant') return true; - const d = new Date(); - return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3; + if (process.env.USER_TYPE === 'ant') return true + const d = new Date() + return ( + d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3) + ) } -function RainbowText(t0) { - const $ = _c(2); - const { - text - } = t0; - let t1; - if ($[0] !== text) { - t1 = <>{[...text].map(_temp)}; - $[0] = text; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + +function RainbowText({ text }: { text: string }): React.ReactNode { + return ( + <> + {[...text].map((ch, i) => ( + + {ch} + + ))} + + ) } // Rainbow /buddy teaser shown on startup when no companion hatched yet. // Idle presence and reactions are handled by CompanionSprite directly. -function _temp(ch, i) { - return {ch}; -} -export function useBuddyNotification() { - const $ = _c(4); - const { - addNotification, - removeNotification - } = useNotifications(); - let t0; - let t1; - if ($[0] !== addNotification || $[1] !== removeNotification) { - t0 = () => { - if (!feature("BUDDY")) { - return; - } - const config = getGlobalConfig(); - if (config.companion || !isBuddyTeaserWindow()) { - return; - } - addNotification({ - key: "buddy-teaser", - jsx: , - priority: "immediate", - timeoutMs: 15000 - }); - return () => removeNotification("buddy-teaser"); - }; - t1 = [addNotification, removeNotification]; - $[0] = addNotification; - $[1] = removeNotification; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - useEffect(t0, t1); +export function useBuddyNotification(): void { + const { addNotification, removeNotification } = useNotifications() + + useEffect(() => { + if (!feature('BUDDY')) return + const config = getGlobalConfig() + if (config.companion || !isBuddyTeaserWindow()) return + addNotification({ + key: 'buddy-teaser', + jsx: , + priority: 'immediate', + timeoutMs: 15_000, + }) + return () => removeNotification('buddy-teaser') + }, [addNotification, removeNotification]) } -export function findBuddyTriggerPositions(text: string): Array<{ - start: number; - end: number; -}> { - if (!feature('BUDDY')) return []; - const triggers: Array<{ - start: number; - end: number; - }> = []; - const re = /\/buddy\b/g; - let m: RegExpExecArray | null; + +export function findBuddyTriggerPositions( + text: string, +): Array<{ start: number; end: number }> { + if (!feature('BUDDY')) return [] + const triggers: Array<{ start: number; end: number }> = [] + const re = /\/buddy\b/g + let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - triggers.push({ - start: m.index, - end: m.index + m[0].length - }); + triggers.push({ start: m.index, end: m.index + m[0].length }) } - return triggers; + return triggers } diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index c144d0452..134918c75 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -3,359 +3,453 @@ * These are dynamically imported only when the corresponding `claude mcp *` command runs. */ -import { stat } from 'fs/promises'; -import pMap from 'p-map'; -import { cwd } from 'process'; -import React from 'react'; -import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'; -import { render } from '../../ink.js'; -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js'; -import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; -import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js'; -import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js'; -import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js'; -import { AppStateProvider } from '../../state/AppState.js'; -import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js'; -import { isFsInaccessible } from '../../utils/errors.js'; -import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; -import { safeParseJSON } from '../../utils/json.js'; -import { getPlatform } from '../../utils/platform.js'; -import { cliError, cliOk } from '../exit.js'; -async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise { +import { stat } from 'fs/promises' +import pMap from 'p-map' +import { cwd } from 'process' +import React from 'react' +import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js' +import { render } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { + clearMcpClientConfig, + clearServerTokensFromLocalStorage, + getMcpClientConfig, + readClientSecret, + saveMcpClientSecret, +} from '../../services/mcp/auth.js' +import { + connectToServer, + getMcpServerConnectionBatchSize, +} from '../../services/mcp/client.js' +import { + addMcpConfig, + getAllMcpConfigs, + getMcpConfigByName, + getMcpConfigsByScope, + removeMcpConfig, +} from '../../services/mcp/config.js' +import type { + ConfigScope, + ScopedMcpServerConfig, +} from '../../services/mcp/types.js' +import { + describeMcpConfigFilePath, + ensureConfigScope, + getScopeLabel, +} from '../../services/mcp/utils.js' +import { AppStateProvider } from '../../state/AppState.js' +import { + getCurrentProjectConfig, + getGlobalConfig, + saveCurrentProjectConfig, +} from '../../utils/config.js' +import { isFsInaccessible } from '../../utils/errors.js' +import { gracefulShutdown } from '../../utils/gracefulShutdown.js' +import { safeParseJSON } from '../../utils/json.js' +import { getPlatform } from '../../utils/platform.js' +import { cliError, cliOk } from '../exit.js' + +async function checkMcpServerHealth( + name: string, + server: ScopedMcpServerConfig, +): Promise { try { - const result = await connectToServer(name, server); + const result = await connectToServer(name, server) if (result.type === 'connected') { - return '✓ Connected'; + return '✓ Connected' } else if (result.type === 'needs-auth') { - return '! Needs authentication'; + return '! Needs authentication' } else { - return '✗ Failed to connect'; + return '✗ Failed to connect' } } catch (_error) { - return '✗ Connection error'; + return '✗ Connection error' } } // mcp serve (lines 4512–4532) export async function mcpServeHandler({ debug, - verbose + verbose, }: { - debug?: boolean; - verbose?: boolean; + debug?: boolean + verbose?: boolean }): Promise { - const providedCwd = cwd(); - logEvent('tengu_mcp_start', {}); + const providedCwd = cwd() + logEvent('tengu_mcp_start', {}) + try { - await stat(providedCwd); + await stat(providedCwd) } catch (error) { if (isFsInaccessible(error)) { - cliError(`Error: Directory ${providedCwd} does not exist`); + cliError(`Error: Directory ${providedCwd} does not exist`) } - throw error; + throw error } + try { - const { - setup - } = await import('../../setup.js'); - await setup(providedCwd, 'default', false, false, undefined, false); - const { - startMCPServer - } = await import('../../entrypoints/mcp.js'); - await startMCPServer(providedCwd, debug ?? false, verbose ?? false); + const { setup } = await import('../../setup.js') + await setup(providedCwd, 'default', false, false, undefined, false) + const { startMCPServer } = await import('../../entrypoints/mcp.js') + await startMCPServer(providedCwd, debug ?? false, verbose ?? false) } catch (error) { - cliError(`Error: Failed to start MCP server: ${error}`); + cliError(`Error: Failed to start MCP server: ${error}`) } } // mcp remove (lines 4545–4635) -export async function mcpRemoveHandler(name: string, options: { - scope?: string; -}): Promise { +export async function mcpRemoveHandler( + name: string, + options: { scope?: string }, +): Promise { // Look up config before removing so we can clean up secure storage - const serverBeforeRemoval = getMcpConfigByName(name); + const serverBeforeRemoval = getMcpConfigByName(name) + const cleanupSecureStorage = () => { - if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) { - clearServerTokensFromLocalStorage(name, serverBeforeRemoval); - clearMcpClientConfig(name, serverBeforeRemoval); + if ( + serverBeforeRemoval && + (serverBeforeRemoval.type === 'sse' || + serverBeforeRemoval.type === 'http') + ) { + clearServerTokensFromLocalStorage(name, serverBeforeRemoval) + clearMcpClientConfig(name, serverBeforeRemoval) } - }; + } + try { if (options.scope) { - const scope = ensureConfigScope(options.scope); + const scope = ensureConfigScope(options.scope) logEvent('tengu_mcp_delete', { name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - await removeMcpConfig(name, scope); - cleanupSecureStorage(); - process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`); - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + await removeMcpConfig(name, scope) + cleanupSecureStorage() + process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`) + cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) } // If no scope specified, check where the server exists - const projectConfig = getCurrentProjectConfig(); - const globalConfig = getGlobalConfig(); + const projectConfig = getCurrentProjectConfig() + const globalConfig = getGlobalConfig() // Check if server exists in project scope (.mcp.json) - const { - servers: projectServers - } = getMcpConfigsByScope('project'); - const mcpJsonExists = !!projectServers[name]; + const { servers: projectServers } = getMcpConfigsByScope('project') + const mcpJsonExists = !!projectServers[name] // Count how many scopes contain this server - const scopes: Array> = []; - if (projectConfig.mcpServers?.[name]) scopes.push('local'); - if (mcpJsonExists) scopes.push('project'); - if (globalConfig.mcpServers?.[name]) scopes.push('user'); + const scopes: Array> = [] + if (projectConfig.mcpServers?.[name]) scopes.push('local') + if (mcpJsonExists) scopes.push('project') + if (globalConfig.mcpServers?.[name]) scopes.push('user') + if (scopes.length === 0) { - cliError(`No MCP server found with name: "${name}"`); + cliError(`No MCP server found with name: "${name}"`) } else if (scopes.length === 1) { // Server exists in only one scope, remove it - const scope = scopes[0]!; + const scope = scopes[0]! logEvent('tengu_mcp_delete', { name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - await removeMcpConfig(name, scope); - cleanupSecureStorage(); - process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`); - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + await removeMcpConfig(name, scope) + cleanupSecureStorage() + process.stdout.write( + `Removed MCP server "${name}" from ${scope} config\n`, + ) + cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) } else { // Server exists in multiple scopes - process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`); + process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`) scopes.forEach(scope => { - process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`); - }); - process.stderr.write('\nTo remove from a specific scope, use:\n'); + process.stderr.write( + ` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`, + ) + }) + process.stderr.write('\nTo remove from a specific scope, use:\n') scopes.forEach(scope => { - process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`); - }); - cliError(); + process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`) + }) + cliError() } } catch (error) { - cliError((error as Error).message); + cliError((error as Error).message) } } // mcp list (lines 4641–4688) export async function mcpListHandler(): Promise { - logEvent('tengu_mcp_list', {}); - const { - servers: configs - } = await getAllMcpConfigs(); + logEvent('tengu_mcp_list', {}) + const { servers: configs } = await getAllMcpConfigs() if (Object.keys(configs).length === 0) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log('No MCP servers configured. Use `claude mcp add` to add a server.'); + console.log( + 'No MCP servers configured. Use `claude mcp add` to add a server.', + ) } else { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log('Checking MCP server health...\n'); + console.log('Checking MCP server health...\n') // Check servers concurrently - const entries = Object.entries(configs); - const results = await pMap(entries, async ([name, server]) => ({ - name, - server, - status: await checkMcpServerHealth(name, server) - }), { - concurrency: getMcpServerConnectionBatchSize() - }); - for (const { - name, - server, - status - } of results) { + const entries = Object.entries(configs) + const results = await pMap( + entries, + async ([name, server]) => ({ + name, + server, + status: await checkMcpServerHealth(name, server), + }), + { concurrency: getMcpServerConnectionBatchSize() }, + ) + + for (const { name, server, status } of results) { // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} (SSE) - ${status}`); + console.log(`${name}: ${server.url} (SSE) - ${status}`) } else if (server.type === 'http') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} (HTTP) - ${status}`); + console.log(`${name}: ${server.url} (HTTP) - ${status}`) } else if (server.type === 'claudeai-proxy') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} - ${status}`); + console.log(`${name}: ${server.url} - ${status}`) } else if (!server.type || server.type === 'stdio') { - const args = Array.isArray((server as any).args) ? (server as any).args : []; + const args = Array.isArray(server.args) ? server.args : [] // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${(server as any).command} ${args.join(' ')} - ${status}`); + console.log(`${name}: ${server.command} ${args.join(' ')} - ${status}`) } } } // Use gracefulShutdown to properly clean up MCP server connections // (process.exit bypasses cleanup handlers, leaving child processes orphaned) - await gracefulShutdown(0); + await gracefulShutdown(0) } // mcp get (lines 4694–4786) export async function mcpGetHandler(name: string): Promise { logEvent('tengu_mcp_get', { - name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const server = getMcpConfigByName(name); + name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const server = getMcpConfigByName(name) if (!server) { - cliError(`No MCP server found with name: ${name}`); + cliError(`No MCP server found with name: ${name}`) } // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}:`); + console.log(`${name}:`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Scope: ${getScopeLabel(server.scope)}`); + console.log(` Scope: ${getScopeLabel(server.scope)}`) // Check server health - const status = await checkMcpServerHealth(name, server); + const status = await checkMcpServerHealth(name, server) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Status: ${status}`); + console.log(` Status: ${status}`) // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: sse`); + console.log(` Type: sse`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`); + console.log(` URL: ${server.url}`) if (server.headers) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Headers:'); + console.log(' Headers:') for (const [key, value] of Object.entries(server.headers)) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}: ${value}`); + console.log(` ${key}: ${value}`) } } if (server.oauth?.clientId || server.oauth?.callbackPort) { - const parts: string[] = []; + const parts: string[] = [] if (server.oauth.clientId) { - parts.push('client_id configured'); - const clientConfig = getMcpClientConfig(name, server); - if (clientConfig?.clientSecret) parts.push('client_secret configured'); + parts.push('client_id configured') + const clientConfig = getMcpClientConfig(name, server) + if (clientConfig?.clientSecret) parts.push('client_secret configured') } - if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + if (server.oauth.callbackPort) + parts.push(`callback_port ${server.oauth.callbackPort}`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`); + console.log(` OAuth: ${parts.join(', ')}`) } } else if (server.type === 'http') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: http`); + console.log(` Type: http`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`); + console.log(` URL: ${server.url}`) if (server.headers) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Headers:'); + console.log(' Headers:') for (const [key, value] of Object.entries(server.headers)) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}: ${value}`); + console.log(` ${key}: ${value}`) } } if (server.oauth?.clientId || server.oauth?.callbackPort) { - const parts: string[] = []; + const parts: string[] = [] if (server.oauth.clientId) { - parts.push('client_id configured'); - const clientConfig = getMcpClientConfig(name, server); - if (clientConfig?.clientSecret) parts.push('client_secret configured'); + parts.push('client_id configured') + const clientConfig = getMcpClientConfig(name, server) + if (clientConfig?.clientSecret) parts.push('client_secret configured') } - if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + if (server.oauth.callbackPort) + parts.push(`callback_port ${server.oauth.callbackPort}`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`); + console.log(` OAuth: ${parts.join(', ')}`) } } else if (server.type === 'stdio') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: stdio`); + console.log(` Type: stdio`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Command: ${server.command}`); - const args = Array.isArray(server.args) ? server.args : []; + console.log(` Command: ${server.command}`) + const args = Array.isArray(server.args) ? server.args : [] // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Args: ${args.join(' ')}`); + console.log(` Args: ${args.join(' ')}`) if (server.env) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Environment:'); + console.log(' Environment:') for (const [key, value] of Object.entries(server.env)) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}=${value}`); + console.log(` ${key}=${value}`) } } } // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`); + console.log( + `\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`, + ) // Use gracefulShutdown to properly clean up MCP server connections // (process.exit bypasses cleanup handlers, leaving child processes orphaned) - await gracefulShutdown(0); + await gracefulShutdown(0) } // mcp add-json (lines 4801–4870) -export async function mcpAddJsonHandler(name: string, json: string, options: { - scope?: string; - clientSecret?: true; -}): Promise { +export async function mcpAddJsonHandler( + name: string, + json: string, + options: { scope?: string; clientSecret?: true }, +): Promise { try { - const scope = ensureConfigScope(options.scope); - const parsedJson = safeParseJSON(json); + const scope = ensureConfigScope(options.scope) + const parsedJson = safeParseJSON(json) // Read secret before writing config so cancellation doesn't leave partial state - const needsSecret = options.clientSecret && parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson && (parsedJson.type === 'sse' || parsedJson.type === 'http') && 'url' in parsedJson && typeof parsedJson.url === 'string' && 'oauth' in parsedJson && parsedJson.oauth && typeof parsedJson.oauth === 'object' && 'clientId' in parsedJson.oauth; - const clientSecret = needsSecret ? await readClientSecret() : undefined; - await addMcpConfig(name, parsedJson, scope); - const transportType = parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson ? String(parsedJson.type || 'stdio') : 'stdio'; - if (clientSecret && parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson && (parsedJson.type === 'sse' || parsedJson.type === 'http') && 'url' in parsedJson && typeof parsedJson.url === 'string') { - saveMcpClientSecret(name, { - type: parsedJson.type, - url: parsedJson.url - }, clientSecret); + const needsSecret = + options.clientSecret && + parsedJson && + typeof parsedJson === 'object' && + 'type' in parsedJson && + (parsedJson.type === 'sse' || parsedJson.type === 'http') && + 'url' in parsedJson && + typeof parsedJson.url === 'string' && + 'oauth' in parsedJson && + parsedJson.oauth && + typeof parsedJson.oauth === 'object' && + 'clientId' in parsedJson.oauth + const clientSecret = needsSecret ? await readClientSecret() : undefined + + await addMcpConfig(name, parsedJson, scope) + + const transportType = + parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson + ? String(parsedJson.type || 'stdio') + : 'stdio' + + if ( + clientSecret && + parsedJson && + typeof parsedJson === 'object' && + 'type' in parsedJson && + (parsedJson.type === 'sse' || parsedJson.type === 'http') && + 'url' in parsedJson && + typeof parsedJson.url === 'string' + ) { + saveMcpClientSecret( + name, + { type: parsedJson.type, url: parsedJson.url }, + clientSecret, + ) } + logEvent('tengu_mcp_add', { - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`) } catch (error) { - cliError((error as Error).message); + cliError((error as Error).message) } } // mcp add-from-claude-desktop (lines 4881–4927) export async function mcpAddFromDesktopHandler(options: { - scope?: string; + scope?: string }): Promise { try { - const scope = ensureConfigScope(options.scope); - const platform = getPlatform(); + const scope = ensureConfigScope(options.scope) + const platform = getPlatform() + logEvent('tengu_mcp_add', { - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const { - readClaudeDesktopMcpServers - } = await import('../../utils/claudeDesktop.js'); - const servers = await readClaudeDesktopMcpServers(); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + platform: + platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + const { readClaudeDesktopMcpServers } = await import( + '../../utils/claudeDesktop.js' + ) + const servers = await readClaudeDesktopMcpServers() + if (Object.keys(servers).length === 0) { - cliOk('No MCP servers found in Claude Desktop configuration or configuration file does not exist.'); + cliOk( + 'No MCP servers found in Claude Desktop configuration or configuration file does not exist.', + ) } - const { - unmount - } = await render( + + const { unmount } = await render( + - { - unmount(); - }} /> + { + unmount() + }} + /> - , { - exitOnCtrlC: true - }); + , + { exitOnCtrlC: true }, + ) } catch (error) { - cliError((error as Error).message); + cliError((error as Error).message) } } // mcp reset-project-choices (lines 4935–4952) export async function mcpResetChoicesHandler(): Promise { - logEvent('tengu_mcp_reset_mcpjson_choices', {}); + logEvent('tengu_mcp_reset_mcpjson_choices', {}) saveCurrentProjectConfig(current => ({ ...current, enabledMcpjsonServers: [], disabledMcpjsonServers: [], - enableAllProjectMcpServers: false - })); - cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start Claude Code.'); + enableAllProjectMcpServers: false, + })) + cliOk( + 'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + + 'You will be prompted for approval next time you start Claude Code.', + ) } diff --git a/src/cli/handlers/util.tsx b/src/cli/handlers/util.tsx index ee042e189..c86b31737 100644 --- a/src/cli/handlers/util.tsx +++ b/src/cli/handlers/util.tsx @@ -1,34 +1,37 @@ -import { c as _c } from "react/compiler-runtime"; /** * Miscellaneous subcommand handlers — extracted from main.tsx for lazy loading. * setup-token, doctor, install */ /* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */ -import { cwd } from 'process'; -import React from 'react'; -import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'; -import { useManagePlugins } from '../../hooks/useManagePlugins.js'; -import type { Root } from '../../ink.js'; -import { Box, Text } from '../../ink.js'; -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'; -import { AppStateProvider } from '../../state/AppState.js'; -import { onChangeAppState } from '../../state/onChangeAppState.js'; -import { isAnthropicAuthEnabled } from '../../utils/auth.js'; +import { cwd } from 'process' +import React from 'react' +import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js' +import { useManagePlugins } from '../../hooks/useManagePlugins.js' +import type { Root } from '../../ink.js' +import { Box, Text } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { logEvent } from '../../services/analytics/index.js' +import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js' +import { AppStateProvider } from '../../state/AppState.js' +import { onChangeAppState } from '../../state/onChangeAppState.js' +import { isAnthropicAuthEnabled } from '../../utils/auth.js' + export async function setupTokenHandler(root: Root): Promise { - logEvent('tengu_setup_token_command', {}); - const showAuthWarning = !isAnthropicAuthEnabled(); - const { - ConsoleOAuthFlow - } = await import('../../components/ConsoleOAuthFlow.js'); + logEvent('tengu_setup_token_command', {}) + + const showAuthWarning = !isAnthropicAuthEnabled() + const { ConsoleOAuthFlow } = await import( + '../../components/ConsoleOAuthFlow.js' + ) await new Promise(resolve => { - root.render( + root.render( + - {showAuthWarning && + {showAuthWarning && ( + Warning: You already have authentication configured via environment variable or API key helper. @@ -37,73 +40,87 @@ export async function setupTokenHandler(root: Root): Promise { The setup-token command will create a new OAuth token which you can use instead. - } - { - void resolve(); - }} mode="setup-token" startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." /> + + )} + { + void resolve() + }} + mode="setup-token" + startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." + /> - ); - }); - root.unmount(); - process.exit(0); + , + ) + }) + root.unmount() + process.exit(0) } // DoctorWithPlugins wrapper + doctor handler -const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ - default: m.Doctor -}))); -function DoctorWithPlugins(t0) { - const $ = _c(2); - const { - onDone - } = t0; - useManagePlugins(); - let t1; - if ($[0] !== onDone) { - t1 = ; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const DoctorLazy = React.lazy(() => + import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })), +) + +function DoctorWithPlugins({ + onDone, +}: { + onDone: () => void +}): React.ReactNode { + useManagePlugins() + return ( + + + + ) } + export async function doctorHandler(root: Root): Promise { - logEvent('tengu_doctor_command', {}); + logEvent('tengu_doctor_command', {}) + await new Promise(resolve => { - root.render( + root.render( + - - { - void resolve(); - }} /> + + { + void resolve() + }} + /> - ); - }); - root.unmount(); - process.exit(0); + , + ) + }) + root.unmount() + process.exit(0) } // install handler -export async function installHandler(target: string | undefined, options: { - force?: boolean; -}): Promise { - const { - setup - } = await import('../../setup.js'); - await setup(cwd(), 'default', false, false, undefined, false); - const { - install - } = await import('../../commands/install.js'); +export async function installHandler( + target: string | undefined, + options: { force?: boolean }, +): Promise { + const { setup } = await import('../../setup.js') + await setup(cwd(), 'default', false, false, undefined, false) + const { install } = await import('../../commands/install.js') await new Promise(resolve => { - const args: string[] = []; - if (target) args.push(target); - if (options.force) args.push('--force'); - void install.call(result => { - void resolve(); - process.exit(result.includes('failed') ? 1 : 0); - }, {}, args); - }); + const args: string[] = [] + if (target) args.push(target) + if (options.force) args.push('--force') + + void install.call( + result => { + void resolve() + process.exit(result.includes('failed') ? 1 : 0) + }, + {}, + args, + ) + }) } diff --git a/src/commands/add-dir/add-dir.tsx b/src/commands/add-dir/add-dir.tsx index 49304d2dc..cfb6c6687 100644 --- a/src/commands/add-dir/add-dir.tsx +++ b/src/commands/add-dir/add-dir.tsx @@ -1,125 +1,154 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import React, { useEffect } from 'react'; -import { getAdditionalDirectoriesForClaudeMd, setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js'; -import { Box, Text } from '../../ink.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import { applyPermissionUpdate, persistPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { addDirHelpMessage, validateDirectoryForWorkspace } from './validation.js'; -function AddDirError(t0) { - const $ = _c(10); - const { - message, - args, - onDone - } = t0; - let t1; - let t2; - if ($[0] !== onDone) { - t1 = () => { - const timer = setTimeout(onDone, 0); - return () => clearTimeout(timer); - }; - t2 = [onDone]; - $[0] = onDone; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - let t3; - if ($[3] !== args) { - t3 = {figures.pointer} /add-dir {args}; - $[3] = args; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== message) { - t4 = {message}; - $[5] = message; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t3 || $[8] !== t4) { - t5 = {t3}{t4}; - $[7] = t3; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; +import chalk from 'chalk' +import figures from 'figures' +import React, { useEffect } from 'react' +import { + getAdditionalDirectoriesForClaudeMd, + setAdditionalDirectoriesForClaudeMd, +} from '../../bootstrap/state.js' +import type { LocalJSXCommandContext } from '../../commands.js' +import { MessageResponse } from '../../components/MessageResponse.js' +import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js' +import { Box, Text } from '../../ink.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import { + applyPermissionUpdate, + persistPermissionUpdate, +} from '../../utils/permissions/PermissionUpdate.js' +import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { + addDirHelpMessage, + validateDirectoryForWorkspace, +} from './validation.js' + +function AddDirError({ + message, + args, + onDone, +}: { + message: string + args: string + onDone: () => void +}): React.ReactNode { + useEffect(() => { + // We need to defer calling onDone to avoid the "return null" bug where + // the component unmounts before React can render the error message. + // Using setTimeout ensures the error displays before the command exits. + const timer = setTimeout(onDone, 0) + return () => clearTimeout(timer) + }, [onDone]) + + return ( + + + {figures.pointer} /add-dir {args} + + + {message} + + + ) } -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args?: string): Promise { - const directoryPath = (args ?? '').trim(); - const appState = context.getAppState(); + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, + args?: string, +): Promise { + const directoryPath = (args ?? '').trim() + const appState = context.getAppState() // Helper to handle adding a directory (shared by both with-path and no-path cases) const handleAddDirectory = async (path: string, remember = false) => { - const destination: PermissionUpdateDestination = remember ? 'localSettings' : 'session'; + const destination: PermissionUpdateDestination = remember + ? 'localSettings' + : 'session' + const permissionUpdate = { type: 'addDirectories' as const, directories: [path], - destination - }; + destination, + } // Apply to session context - const latestAppState = context.getAppState(); - const updatedContext = applyPermissionUpdate(latestAppState.toolPermissionContext, permissionUpdate); + const latestAppState = context.getAppState() + const updatedContext = applyPermissionUpdate( + latestAppState.toolPermissionContext, + permissionUpdate, + ) context.setAppState(prev => ({ ...prev, - toolPermissionContext: updatedContext - })); + toolPermissionContext: updatedContext, + })) // Update sandbox config so Bash commands can access the new directory. // Bootstrap state is the source of truth for session-only dirs; persisted // dirs are picked up via the settings subscription, but we refresh // eagerly here to avoid a race when the user acts immediately. - const currentDirs = getAdditionalDirectoriesForClaudeMd(); + const currentDirs = getAdditionalDirectoriesForClaudeMd() if (!currentDirs.includes(path)) { - setAdditionalDirectoriesForClaudeMd([...currentDirs, path]); + setAdditionalDirectoriesForClaudeMd([...currentDirs, path]) } - SandboxManager.refreshConfig(); - let message: string; + SandboxManager.refreshConfig() + + let message: string + if (remember) { try { - persistPermissionUpdate(permissionUpdate); - message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`; + persistPermissionUpdate(permissionUpdate) + message = `Added ${chalk.bold(path)} as a working directory and saved to local settings` } catch (error) { - message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`; + message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}` } } else { - message = `Added ${chalk.bold(path)} as a working directory for this session`; + message = `Added ${chalk.bold(path)} as a working directory for this session` } - const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`; - onDone(messageWithHint); - }; + + const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}` + onDone(messageWithHint) + } // When no path is provided, show AddWorkspaceDirectory input form directly // and return to REPL after confirmation if (!directoryPath) { - return { - onDone('Did not add a working directory.'); - }} />; + return ( + { + onDone('Did not add a working directory.') + }} + /> + ) } - const result = await validateDirectoryForWorkspace(directoryPath, appState.toolPermissionContext); + + const result = await validateDirectoryForWorkspace( + directoryPath, + appState.toolPermissionContext, + ) + if (result.resultType !== 'success') { - const message = addDirHelpMessage(result); - return onDone(message)} />; + const message = addDirHelpMessage(result) + + return ( + onDone(message)} + /> + ) } - return { - onDone(`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`); - }} />; + + return ( + { + onDone( + `Did not add ${chalk.bold(result.absolutePath)} as a working directory.`, + ) + }} + /> + ) } diff --git a/src/commands/agents/agents.tsx b/src/commands/agents/agents.tsx index 1d2c55974..6a5931756 100644 --- a/src/commands/agents/agents.tsx +++ b/src/commands/agents/agents.tsx @@ -1,11 +1,16 @@ -import * as React from 'react'; -import { AgentsMenu } from '../../components/agents/AgentsMenu.js'; -import type { ToolUseContext } from '../../Tool.js'; -import { getTools } from '../../tools.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise { - const appState = context.getAppState(); - const permissionContext = appState.toolPermissionContext; - const tools = getTools(permissionContext); - return ; +import * as React from 'react' +import { AgentsMenu } from '../../components/agents/AgentsMenu.js' +import type { ToolUseContext } from '../../Tool.js' +import { getTools } from '../../tools.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext, +): Promise { + const appState = context.getAppState() + const permissionContext = appState.toolPermissionContext + const tools = getTools(permissionContext) + + return } diff --git a/src/commands/bridge/bridge.tsx b/src/commands/bridge/bridge.tsx index dadf1c89f..33a681202 100644 --- a/src/commands/bridge/bridge.tsx +++ b/src/commands/bridge/bridge.tsx @@ -1,27 +1,40 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import { toString as qrToString } from 'qrcode'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js'; -import { checkBridgeMinVersion, getBridgeDisabledReason, isEnvLessBridgeEnabled } from '../../bridge/bridgeEnabled.js'; -import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js'; -import { BRIDGE_LOGIN_INSTRUCTION, REMOTE_CONTROL_DISCONNECTED_MSG } from '../../bridge/types.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { ListItem } from '../../components/design-system/ListItem.js'; -import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { useAppState, useSetAppState } from '../../state/AppState.js'; -import type { ToolUseContext } from '../../Tool.js'; -import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; -import { logForDebugging } from '../../utils/debug.js'; +import { feature } from 'bun:bundle' +import { toString as qrToString } from 'qrcode' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js' +import { + checkBridgeMinVersion, + getBridgeDisabledReason, + isEnvLessBridgeEnabled, +} from '../../bridge/bridgeEnabled.js' +import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js' +import { + BRIDGE_LOGIN_INSTRUCTION, + REMOTE_CONTROL_DISCONNECTED_MSG, +} from '../../bridge/types.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { ListItem } from '../../components/design-system/ListItem.js' +import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' +import type { ToolUseContext } from '../../Tool.js' +import type { + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../../types/command.js' +import { logForDebugging } from '../../utils/debug.js' + type Props = { - onDone: LocalJSXCommandOnDone; - name?: string; -}; + onDone: LocalJSXCommandOnDone + name?: string +} /** * /remote-control command — manages the bidirectional bridge connection. @@ -35,392 +48,194 @@ type Props = { * Running /remote-control when already connected shows a dialog with the session * URL and options to disconnect or continue. */ -function BridgeToggle(t0) { - const $ = _c(10); - const { - onDone, - name - } = t0; - const setAppState = useSetAppState(); - const replBridgeConnected = useAppState(_temp); - const replBridgeEnabled = useAppState(_temp2); - const replBridgeOutboundOnly = useAppState(_temp3); - const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); - let t1; - if ($[0] !== name || $[1] !== onDone || $[2] !== replBridgeConnected || $[3] !== replBridgeEnabled || $[4] !== replBridgeOutboundOnly || $[5] !== setAppState) { - t1 = () => { - if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) { - setShowDisconnectDialog(true); - return; +function BridgeToggle({ onDone, name }: Props): React.ReactNode { + const setAppState = useSetAppState() + const replBridgeConnected = useAppState(s => s.replBridgeConnected) + const replBridgeEnabled = useAppState(s => s.replBridgeEnabled) + const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly) + const [showDisconnectDialog, setShowDisconnectDialog] = useState(false) + + // biome-ignore lint/correctness/useExhaustiveDependencies: bridge starts once, should not restart on state changes + useEffect(() => { + // If already connected or enabled in full bidirectional mode, show + // disconnect confirmation. Outbound-only (CCR mirror) doesn't count — + // /remote-control upgrades it to full RC instead. + if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) { + setShowDisconnectDialog(true) + return + } + + let cancelled = false + void (async () => { + // Pre-flight checks before enabling (awaits GrowthBook init if disk + // cache is stale — so Max users don't get a false "not enabled" error) + const error = await checkBridgePrerequisites() + if (cancelled) return + if (error) { + logEvent('tengu_bridge_command', { + action: + 'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + onDone(error, { display: 'system' }) + return } - let cancelled = false; - (async () => { - const error = await checkBridgePrerequisites(); - if (cancelled) { - return; - } - if (error) { - logEvent("tengu_bridge_command", { - action: "preflight_failed" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - onDone(error, { - display: "system" - }); - return; - } - if (shouldShowRemoteCallout()) { - setAppState(prev => { - if (prev.showRemoteCallout) { - return prev; - } - return { - ...prev, - showRemoteCallout: true, - replBridgeInitialName: name - }; - }); - onDone("", { - display: "system" - }); - return; - } - logEvent("tengu_bridge_command", { - action: "connect" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - setAppState(prev_0 => { - if (prev_0.replBridgeEnabled && !prev_0.replBridgeOutboundOnly) { - return prev_0; - } + + // Show first-time remote dialog if not yet seen. + // Store the name now so it's in AppState when the callout handler later + // enables the bridge (the handler only sets replBridgeEnabled, not the name). + if (shouldShowRemoteCallout()) { + setAppState(prev => { + if (prev.showRemoteCallout) return prev return { - ...prev_0, - replBridgeEnabled: true, - replBridgeExplicit: true, - replBridgeOutboundOnly: false, - replBridgeInitialName: name - }; - }); - onDone("Remote Control connecting\u2026", { - display: "system" - }); - })(); - return () => { - cancelled = true; - }; - }; - $[0] = name; - $[1] = onDone; - $[2] = replBridgeConnected; - $[3] = replBridgeEnabled; - $[4] = replBridgeOutboundOnly; - $[5] = setAppState; - $[6] = t1; - } else { - t1 = $[6]; - } - let t2; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[7] = t2; - } else { - t2 = $[7]; - } - useEffect(t1, t2); - if (showDisconnectDialog) { - let t3; - if ($[8] !== onDone) { - t3 = ; - $[8] = onDone; - $[9] = t3; - } else { - t3 = $[9]; + ...prev, + showRemoteCallout: true, + replBridgeInitialName: name, + } + }) + onDone('', { display: 'system' }) + return + } + + // Enable the bridge — useReplBridge in REPL.tsx handles the rest: + // registers environment, creates session with conversation, connects WebSocket + logEvent('tengu_bridge_command', { + action: + 'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + setAppState(prev => { + if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev + return { + ...prev, + replBridgeEnabled: true, + replBridgeExplicit: true, + replBridgeOutboundOnly: false, + replBridgeInitialName: name, + } + }) + onDone('Remote Control connecting\u2026', { + display: 'system', + }) + })() + + return () => { + cancelled = true } - return t3; + }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount + + if (showDisconnectDialog) { + return } - return null; + + return null } /** * Dialog shown when /remote-control is used while the bridge is already connected. * Shows the session URL and lets the user disconnect or continue. */ -function _temp3(s_1) { - return s_1.replBridgeOutboundOnly; -} -function _temp2(s_0) { - return s_0.replBridgeEnabled; -} -function _temp(s) { - return s.replBridgeConnected; -} -function BridgeDisconnectDialog(t0) { - const $ = _c(61); - const { - onDone - } = t0; - useRegisterOverlay("bridge-disconnect-dialog", undefined); - const setAppState = useSetAppState(); - const sessionUrl = useAppState(_temp4); - const connectUrl = useAppState(_temp5); - const sessionActive = useAppState(_temp6); - const [focusIndex, setFocusIndex] = useState(2); - const [showQR, setShowQR] = useState(false); - const [qrText, setQrText] = useState(""); - const displayUrl = sessionActive ? sessionUrl : connectUrl; - let t1; - let t2; - if ($[0] !== displayUrl || $[1] !== showQR) { - t1 = () => { - if (!showQR || !displayUrl) { - setQrText(""); - return; +function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { + useRegisterOverlay('bridge-disconnect-dialog') + const setAppState = useSetAppState() + const sessionUrl = useAppState(s => s.replBridgeSessionUrl) + const connectUrl = useAppState(s => s.replBridgeConnectUrl) + const sessionActive = useAppState(s => s.replBridgeSessionActive) + const [focusIndex, setFocusIndex] = useState(2) + const [showQR, setShowQR] = useState(false) + const [qrText, setQrText] = useState('') + + const displayUrl = sessionActive ? sessionUrl : connectUrl + + // Generate QR code when URL changes or QR is toggled on + useEffect(() => { + if (!showQR || !displayUrl) { + setQrText('') + return + } + qrToString(displayUrl, { + type: 'utf8', + errorCorrectionLevel: 'L', + small: true, + }) + .then(setQrText) + .catch(() => setQrText('')) + }, [showQR, displayUrl]) + + function handleDisconnect(): void { + setAppState(prev => { + if (!prev.replBridgeEnabled) return prev + return { + ...prev, + replBridgeEnabled: false, + replBridgeExplicit: false, + replBridgeOutboundOnly: false, } - qrToString(displayUrl, { - type: "utf8", - errorCorrectionLevel: "L", - small: true - }).then(setQrText).catch(() => setQrText("")); - }; - t2 = [showQR, displayUrl]; - $[0] = displayUrl; - $[1] = showQR; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== onDone || $[5] !== setAppState) { - t3 = function handleDisconnect() { - setAppState(_temp7); - logEvent("tengu_bridge_command", { - action: "disconnect" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { - display: "system" - }); - }; - $[4] = onDone; - $[5] = setAppState; - $[6] = t3; - } else { - t3 = $[6]; + }) + logEvent('tengu_bridge_command', { + action: + 'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' }) } - const handleDisconnect = t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = function handleShowQR() { - setShowQR(_temp8); - }; - $[7] = t4; - } else { - t4 = $[7]; - } - const handleShowQR = t4; - let t5; - if ($[8] !== onDone) { - t5 = function handleContinue() { - onDone(undefined, { - display: "skip" - }); - }; - $[8] = onDone; - $[9] = t5; - } else { - t5 = $[9]; + + function handleShowQR(): void { + setShowQR(prev => !prev) } - const handleContinue = t5; - let t6; - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => setFocusIndex(_temp9); - t7 = () => setFocusIndex(_temp0); - $[10] = t6; - $[11] = t7; - } else { - t6 = $[10]; - t7 = $[11]; + + function handleContinue(): void { + onDone(undefined, { display: 'skip' }) } - let t8; - if ($[12] !== focusIndex || $[13] !== handleContinue || $[14] !== handleDisconnect) { - t8 = { - "select:next": t6, - "select:previous": t7, - "select:accept": () => { + + const ITEM_COUNT = 3 + + useKeybindings( + { + 'select:next': () => setFocusIndex(i => (i + 1) % ITEM_COUNT), + 'select:previous': () => + setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT), + 'select:accept': () => { if (focusIndex === 0) { - handleDisconnect(); + handleDisconnect() + } else if (focusIndex === 1) { + handleShowQR() } else { - if (focusIndex === 1) { - handleShowQR(); - } else { - handleContinue(); - } + handleContinue() } - } - }; - $[12] = focusIndex; - $[13] = handleContinue; - $[14] = handleDisconnect; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t9 = { - context: "Select" - }; - $[16] = t9; - } else { - t9 = $[16]; - } - useKeybindings(t8, t9); - let T0; - let T1; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - if ($[17] !== displayUrl || $[18] !== handleContinue || $[19] !== qrText || $[20] !== showQR) { - const qrLines = qrText ? qrText.split("\n").filter(_temp1) : []; - T1 = Dialog; - t14 = "Remote Control"; - t15 = handleContinue; - t16 = true; - T0 = Box; - t10 = "column"; - t11 = 1; - const t17 = displayUrl ? ` at ${displayUrl}` : ""; - if ($[30] !== t17) { - t12 = This session is available via Remote Control{t17}.; - $[30] = t17; - $[31] = t12; - } else { - t12 = $[31]; - } - t13 = showQR && qrLines.length > 0 && {qrLines.map(_temp10)}; - $[17] = displayUrl; - $[18] = handleContinue; - $[19] = qrText; - $[20] = showQR; - $[21] = T0; - $[22] = T1; - $[23] = t10; - $[24] = t11; - $[25] = t12; - $[26] = t13; - $[27] = t14; - $[28] = t15; - $[29] = t16; - } else { - T0 = $[21]; - T1 = $[22]; - t10 = $[23]; - t11 = $[24]; - t12 = $[25]; - t13 = $[26]; - t14 = $[27]; - t15 = $[28]; - t16 = $[29]; - } - const t17 = focusIndex === 0; - let t18; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t18 = Disconnect this session; - $[32] = t18; - } else { - t18 = $[32]; - } - let t19; - if ($[33] !== t17) { - t19 = {t18}; - $[33] = t17; - $[34] = t19; - } else { - t19 = $[34]; - } - const t20 = focusIndex === 1; - const t21 = showQR ? "Hide QR code" : "Show QR code"; - let t22; - if ($[35] !== t21) { - t22 = {t21}; - $[35] = t21; - $[36] = t22; - } else { - t22 = $[36]; - } - let t23; - if ($[37] !== t20 || $[38] !== t22) { - t23 = {t22}; - $[37] = t20; - $[38] = t22; - $[39] = t23; - } else { - t23 = $[39]; - } - const t24 = focusIndex === 2; - let t25; - if ($[40] === Symbol.for("react.memo_cache_sentinel")) { - t25 = Continue; - $[40] = t25; - } else { - t25 = $[40]; - } - let t26; - if ($[41] !== t24) { - t26 = {t25}; - $[41] = t24; - $[42] = t26; - } else { - t26 = $[42]; - } - let t27; - if ($[43] !== t19 || $[44] !== t23 || $[45] !== t26) { - t27 = {t19}{t23}{t26}; - $[43] = t19; - $[44] = t23; - $[45] = t26; - $[46] = t27; - } else { - t27 = $[46]; - } - let t28; - if ($[47] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Enter to select · Esc to continue; - $[47] = t28; - } else { - t28 = $[47]; - } - let t29; - if ($[48] !== T0 || $[49] !== t10 || $[50] !== t11 || $[51] !== t12 || $[52] !== t13 || $[53] !== t27) { - t29 = {t12}{t13}{t27}{t28}; - $[48] = T0; - $[49] = t10; - $[50] = t11; - $[51] = t12; - $[52] = t13; - $[53] = t27; - $[54] = t29; - } else { - t29 = $[54]; - } - let t30; - if ($[55] !== T1 || $[56] !== t14 || $[57] !== t15 || $[58] !== t16 || $[59] !== t29) { - t30 = {t29}; - $[55] = T1; - $[56] = t14; - $[57] = t15; - $[58] = t16; - $[59] = t29; - $[60] = t30; - } else { - t30 = $[60]; - } - return t30; + }, + }, + { context: 'Select' }, + ) + + const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [] + + return ( + + + + This session is available via Remote Control + {displayUrl ? ` at ${displayUrl}` : ''}. + + {showQR && qrLines.length > 0 && ( + + {qrLines.map((line, i) => ( + {line} + ))} + + )} + + + Disconnect this session + + + {showQR ? 'Hide QR code' : 'Show QR code'} + + + Continue + + + Enter to select · Esc to continue + + + ) } /** @@ -429,80 +244,52 @@ function BridgeDisconnectDialog(t0) { * cache is stale, so a user who just became entitled (e.g. upgraded to Max, * or the flag just launched) gets an accurate result on the first try. */ -function _temp10(line, i_1) { - return {line}; -} -function _temp1(l) { - return l.length > 0; -} -function _temp0(i_0) { - return (i_0 - 1 + 3) % 3; -} -function _temp9(i) { - return (i + 1) % 3; -} -function _temp8(prev_0) { - return !prev_0; -} -function _temp7(prev) { - if (!prev.replBridgeEnabled) { - return prev; - } - return { - ...prev, - replBridgeEnabled: false, - replBridgeExplicit: false, - replBridgeOutboundOnly: false - }; -} -function _temp6(s_1) { - return s_1.replBridgeSessionActive; -} -function _temp5(s_0) { - return s_0.replBridgeConnectUrl; -} -function _temp4(s) { - return s.replBridgeSessionUrl; -} async function checkBridgePrerequisites(): Promise { // Check organization policy — remote control may be disabled - const { - waitForPolicyLimitsToLoad, - isPolicyAllowed - } = await import('../../services/policyLimits/index.js'); - await waitForPolicyLimitsToLoad(); + const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import( + '../../services/policyLimits/index.js' + ) + await waitForPolicyLimitsToLoad() if (!isPolicyAllowed('allow_remote_control')) { - return "Remote Control is disabled by your organization's policy."; + return "Remote Control is disabled by your organization's policy." } - const disabledReason = await getBridgeDisabledReason(); + + const disabledReason = await getBridgeDisabledReason() if (disabledReason) { - return disabledReason; + return disabledReason } // Mirror the v1/v2 branching logic in initReplBridge: env-less (v2) is used // only when the flag is on AND the session is not perpetual. In assistant // mode (KAIROS) useReplBridge sets perpetual=true, which forces // initReplBridge onto the v1 path — so the prerequisite check must match. - let useV2 = isEnvLessBridgeEnabled(); + let useV2 = isEnvLessBridgeEnabled() if (feature('KAIROS') && useV2) { - const { - isAssistantMode - } = await import('../../assistant/index.js'); + const { isAssistantMode } = await import('../../assistant/index.js') if (isAssistantMode()) { - useV2 = false; + useV2 = false } } - const versionError = useV2 ? await checkEnvLessBridgeMinVersion() : checkBridgeMinVersion(); + const versionError = useV2 + ? await checkEnvLessBridgeMinVersion() + : checkBridgeMinVersion() if (versionError) { - return versionError; + return versionError } + if (!getBridgeAccessToken()) { - return BRIDGE_LOGIN_INSTRUCTION; + return BRIDGE_LOGIN_INSTRUCTION } - logForDebugging('[bridge] Prerequisites passed, enabling bridge'); - return null; + + logForDebugging('[bridge] Prerequisites passed, enabling bridge') + return null } -export async function call(onDone: LocalJSXCommandOnDone, _context: ToolUseContext & LocalJSXCommandContext, args: string): Promise { - const name = args.trim() || undefined; - return ; + +export async function call( + onDone: LocalJSXCommandOnDone, + _context: ToolUseContext & LocalJSXCommandContext, + args: string, +): Promise { + const name = args.trim() || undefined + return } diff --git a/src/commands/btw/btw.tsx b/src/commands/btw/btw.tsx index bf5daca30..28a83946b 100644 --- a/src/commands/btw/btw.tsx +++ b/src/commands/btw/btw.tsx @@ -1,183 +1,151 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { useInterval } from 'usehooks-ts'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Markdown } from '../../components/Markdown.js'; -import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js'; -import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js'; -import { getSystemPrompt } from '../../constants/prompts.js'; -import { useModalOrTerminalSize } from '../../context/modalContext.js'; -import { getSystemContext, getUserContext } from '../../context.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import ScrollBox, { type ScrollBoxHandle } from '../../ink/components/ScrollBox.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import type { Message } from '../../types/message.js'; -import { createAbortController } from '../../utils/abortController.js'; -import { saveGlobalConfig } from '../../utils/config.js'; -import { errorMessage } from '../../utils/errors.js'; -import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js'; -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; -import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; -import { runSideQuestion } from '../../utils/sideQuestion.js'; -import { asSystemPrompt } from '../../utils/systemPromptType.js'; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { useInterval } from 'usehooks-ts' +import type { CommandResultDisplay } from '../../commands.js' +import { Markdown } from '../../components/Markdown.js' +import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js' +import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js' +import { getSystemPrompt } from '../../constants/prompts.js' +import { useModalOrTerminalSize } from '../../context/modalContext.js' +import { getSystemContext, getUserContext } from '../../context.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import ScrollBox, { + type ScrollBoxHandle, +} from '../../ink/components/ScrollBox.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { Message } from '../../types/message.js' +import { createAbortController } from '../../utils/abortController.js' +import { saveGlobalConfig } from '../../utils/config.js' +import { errorMessage } from '../../utils/errors.js' +import { + type CacheSafeParams, + getLastCacheSafeParams, +} from '../../utils/forkedAgent.js' +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' +import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' +import { runSideQuestion } from '../../utils/sideQuestion.js' +import { asSystemPrompt } from '../../utils/systemPromptType.js' + type BtwComponentProps = { - question: string; - context: ProcessUserInputContext; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -const CHROME_ROWS = 5; -const OUTER_CHROME_ROWS = 6; -const SCROLL_LINES = 3; -function BtwSideQuestion(t0) { - const $ = _c(25); - const { - question, - context, - onDone - } = t0; - const [response, setResponse] = useState(null); - const [error, setError] = useState(null); - const [frame, setFrame] = useState(0); - const scrollRef = useRef(null); - const { - rows - } = useModalOrTerminalSize(useTerminalSize()); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setFrame(_temp); - $[0] = t1; - } else { - t1 = $[0]; - } - useInterval(t1, response || error ? null : 80); - let t2; - if ($[1] !== onDone) { - t2 = function handleKeyDown(e) { - if (e.key === "escape" || e.key === "return" || e.key === " " || e.ctrl && (e.key === "c" || e.key === "d")) { - e.preventDefault(); - onDone(undefined, { - display: "skip" - }); - return; - } - if (e.key === "up" || e.ctrl && e.key === "p") { - e.preventDefault(); - scrollRef.current?.scrollBy(-SCROLL_LINES); - } - if (e.key === "down" || e.ctrl && e.key === "n") { - e.preventDefault(); - scrollRef.current?.scrollBy(SCROLL_LINES); - } - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; + question: string + context: ProcessUserInputContext + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +const CHROME_ROWS = 5 +const OUTER_CHROME_ROWS = 6 +const SCROLL_LINES = 3 + +function BtwSideQuestion({ + question, + context, + onDone, +}: BtwComponentProps): React.ReactNode { + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [frame, setFrame] = useState(0) + const scrollRef = useRef(null) + const { rows } = useModalOrTerminalSize(useTerminalSize()) + + // Animate spinner while loading + useInterval(() => setFrame(f => f + 1), response || error ? null : 80) + + function handleKeyDown(e: KeyboardEvent): void { + if ( + e.key === 'escape' || + e.key === 'return' || + e.key === ' ' || + (e.ctrl && (e.key === 'c' || e.key === 'd')) + ) { + e.preventDefault() + onDone(undefined, { display: 'skip' }) + return + } + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + scrollRef.current?.scrollBy(-SCROLL_LINES) + } + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + scrollRef.current?.scrollBy(SCROLL_LINES) + } } - const handleKeyDown = t2; - let t3; - let t4; - if ($[3] !== context || $[4] !== question) { - t3 = () => { - const abortController = createAbortController(); - const fetchResponse = async function fetchResponse() { - ; - try { - const cacheSafeParams = await buildCacheSafeParams(context); - const result = await runSideQuestion({ - question, - cacheSafeParams - }); - if (!abortController.signal.aborted) { - if (result.response) { - setResponse(result.response); - } else { - setError("No response received"); - } - } - } catch (t5) { - const err = t5; - if (!abortController.signal.aborted) { - setError(errorMessage(err) || "Failed to get response"); + + useEffect(() => { + const abortController = createAbortController() + + async function fetchResponse(): Promise { + try { + const cacheSafeParams = await buildCacheSafeParams(context) + const result = await runSideQuestion({ question, cacheSafeParams }) + + if (!abortController.signal.aborted) { + if (result.response) { + setResponse(result.response) + } else { + setError('No response received') } } - }; - fetchResponse(); - return () => { - abortController.abort(); - }; - }; - t4 = [question, context]; - $[3] = context; - $[4] = question; - $[5] = t3; - $[6] = t4; - } else { - t3 = $[5]; - t4 = $[6]; - } - useEffect(t3, t4); - const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS); - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = /btw{" "}; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== question) { - t6 = {t5}{question}; - $[8] = question; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== error || $[11] !== frame || $[12] !== response) { - t7 = {error ? {error} : response ? {response} : Answering...}; - $[10] = error; - $[11] = frame; - $[12] = response; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] !== maxContentHeight || $[15] !== t7) { - t8 = {t7}; - $[14] = maxContentHeight; - $[15] = t7; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== error || $[18] !== response) { - t9 = (response || error) && {UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to dismiss; - $[17] = error; - $[18] = response; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== handleKeyDown || $[21] !== t6 || $[22] !== t8 || $[23] !== t9) { - t10 = {t6}{t8}{t9}; - $[20] = handleKeyDown; - $[21] = t6; - $[22] = t8; - $[23] = t9; - $[24] = t10; - } else { - t10 = $[24]; - } - return t10; + } catch (err) { + if (!abortController.signal.aborted) { + setError(errorMessage(err) || 'Failed to get response') + } + } + } + + void fetchResponse() + + return () => { + abortController.abort() + } + }, [question, context]) + + const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS) + + return ( + + + + /btw{' '} + + {question} + + + + {error ? ( + {error} + ) : response ? ( + {response} + ) : ( + + + Answering... + + )} + + + {(response || error) && ( + + + {UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to + dismiss + + + )} + + ) } /** @@ -195,48 +163,67 @@ function BtwSideQuestion(t0) { * applied buildEffectiveSystemPrompt extras (--agent, --system-prompt, * --append-system-prompt, coordinator mode). */ -function _temp(f) { - return f + 1; -} function stripInProgressAssistantMessage(messages: Message[]): Message[] { - const last = messages.at(-1); + const last = messages.at(-1) if (last?.type === 'assistant' && last.message.stop_reason === null) { - return messages.slice(0, -1); + return messages.slice(0, -1) } - return messages; + return messages } -async function buildCacheSafeParams(context: ProcessUserInputContext): Promise { - const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages)); - const saved = getLastCacheSafeParams(); + +async function buildCacheSafeParams( + context: ProcessUserInputContext, +): Promise { + const forkContextMessages = getMessagesAfterCompactBoundary( + stripInProgressAssistantMessage(context.messages), + ) + const saved = getLastCacheSafeParams() if (saved) { return { systemPrompt: saved.systemPrompt, userContext: saved.userContext, systemContext: saved.systemContext, toolUseContext: context, - forkContextMessages - }; + forkContextMessages, + } } - const [rawSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(context.options.tools, context.options.mainLoopModel, [], context.options.mcpClients), getUserContext(), getSystemContext()]); + const [rawSystemPrompt, userContext, systemContext] = await Promise.all([ + getSystemPrompt( + context.options.tools, + context.options.mainLoopModel, + [], + context.options.mcpClients, + ), + getUserContext(), + getSystemContext(), + ]) return { systemPrompt: asSystemPrompt(rawSystemPrompt), userContext, systemContext, toolUseContext: context, - forkContextMessages - }; + forkContextMessages, + } } -export async function call(onDone: LocalJSXCommandOnDone, context: ProcessUserInputContext, args: string): Promise { - const question = args?.trim(); + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ProcessUserInputContext, + args: string, +): Promise { + const question = args?.trim() + if (!question) { - onDone('Usage: /btw ', { - display: 'system' - }); - return null; + onDone('Usage: /btw ', { display: 'system' }) + return null } + saveGlobalConfig(current => ({ ...current, - btwUseCount: current.btwUseCount + 1 - })); - return ; + btwUseCount: current.btwUseCount + 1, + })) + + return ( + + ) } diff --git a/src/commands/chrome/chrome.tsx b/src/commands/chrome/chrome.tsx index b659c2e80..3fd0dbca3 100644 --- a/src/commands/chrome/chrome.tsx +++ b/src/commands/chrome/chrome.tsx @@ -1,284 +1,240 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useState } from 'react'; -import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { Box, Text } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import { isClaudeAISubscriber } from '../../utils/auth.js'; -import { openBrowser } from '../../utils/browser.js'; -import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, openInChrome } from '../../utils/claudeInChrome/common.js'; -import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { env } from '../../utils/env.js'; -import { isRunningOnHomespace } from '../../utils/envUtils.js'; -const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'; -const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'; -const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect'; -type MenuAction = 'install-extension' | 'reconnect' | 'manage-permissions' | 'toggle-default'; +import React, { useState } from 'react' +import { + type OptionWithDescription, + Select, +} from '../../components/CustomSelect/select.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { Box, Text } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import { isClaudeAISubscriber } from '../../utils/auth.js' +import { openBrowser } from '../../utils/browser.js' +import { + CLAUDE_IN_CHROME_MCP_SERVER_NAME, + openInChrome, +} from '../../utils/claudeInChrome/common.js' +import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { env } from '../../utils/env.js' +import { isRunningOnHomespace } from '../../utils/envUtils.js' + +const CHROME_EXTENSION_URL = 'https://claude.ai/chrome' +const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions' +const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect' + +type MenuAction = + | 'install-extension' + | 'reconnect' + | 'manage-permissions' + | 'toggle-default' + type Props = { - onDone: (result?: string) => void; - isExtensionInstalled: boolean; - configEnabled: boolean | undefined; - isClaudeAISubscriber: boolean; - isWSL: boolean; -}; -function ClaudeInChromeMenu(t0) { - const $ = _c(41); - const { - onDone, - isExtensionInstalled: installed, - configEnabled, - isClaudeAISubscriber, - isWSL - } = t0; - const mcpClients = useAppState(_temp); - const [selectKey, setSelectKey] = useState(0); - const [enabledByDefault, setEnabledByDefault] = useState(configEnabled ?? false); - const [showInstallHint, setShowInstallHint] = useState(false); - const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = false && isRunningOnHomespace(); - $[0] = t1; - } else { - t1 = $[0]; - } - const isHomespace = t1; - let t2; - if ($[1] !== mcpClients) { - t2 = mcpClients.find(_temp2); - $[1] = mcpClients; - $[2] = t2; - } else { - t2 = $[2]; - } - const chromeClient = t2; - const isConnected = chromeClient?.type === "connected"; - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = function openUrl(url) { - if (isHomespace) { - openBrowser(url); - } else { - openInChrome(url); - } - }; - $[3] = t3; - } else { - t3 = $[3]; + onDone: (result?: string) => void + isExtensionInstalled: boolean + configEnabled: boolean | undefined + isClaudeAISubscriber: boolean + isWSL: boolean +} + +function ClaudeInChromeMenu({ + onDone, + isExtensionInstalled: installed, + configEnabled, + isClaudeAISubscriber, + isWSL, +}: Props): React.ReactNode { + const mcpClients = useAppState(s => s.mcp.clients) + const [selectKey, setSelectKey] = useState(0) + const [enabledByDefault, setEnabledByDefault] = useState( + configEnabled ?? false, + ) + const [showInstallHint, setShowInstallHint] = useState(false) + const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed) + + const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace() + + const chromeClient = mcpClients.find( + c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, + ) + const isConnected = chromeClient?.type === 'connected' + + function openUrl(url: string): void { + if (isHomespace) { + void openBrowser(url) + } else { + void openInChrome(url) + } } - const openUrl = t3; - let t4; - if ($[4] !== enabledByDefault) { - t4 = function handleAction(action) { - bb22: switch (action) { - case "install-extension": - { - setSelectKey(_temp3); - setShowInstallHint(true); - openUrl(CHROME_EXTENSION_URL); - break bb22; - } - case "reconnect": - { - setSelectKey(_temp4); - isChromeExtensionInstalled().then(installed_0 => { - setIsExtensionInstalled(installed_0); - if (installed_0) { - setShowInstallHint(false); - } - }); - openUrl(CHROME_RECONNECT_URL); - break bb22; - } - case "manage-permissions": - { - setSelectKey(_temp5); - openUrl(CHROME_PERMISSIONS_URL); - break bb22; + + function handleAction(action: MenuAction): void { + switch (action) { + case 'install-extension': + setSelectKey(k => k + 1) + setShowInstallHint(true) + openUrl(CHROME_EXTENSION_URL) + break + case 'reconnect': + setSelectKey(k => k + 1) + void isChromeExtensionInstalled().then(installed => { + setIsExtensionInstalled(installed) + if (installed) { + setShowInstallHint(false) } - case "toggle-default": - { - const newValue = !enabledByDefault; - saveGlobalConfig(current => ({ - ...current, - claudeInChromeDefaultEnabled: newValue - })); - setEnabledByDefault(newValue); - } - } - }; - $[4] = enabledByDefault; - $[5] = t4; - } else { - t4 = $[5]; - } - const handleAction = t4; - let options; - if ($[6] !== enabledByDefault || $[7] !== isExtensionInstalled) { - options = []; - const requiresExtensionSuffix = isExtensionInstalled ? "" : " (requires extension)"; - if (!isExtensionInstalled && !isHomespace) { - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Install Chrome extension", - value: "install-extension" - }; - $[9] = t5; - } else { - t5 = $[9]; + }) + openUrl(CHROME_RECONNECT_URL) + break + case 'manage-permissions': + setSelectKey(k => k + 1) + openUrl(CHROME_PERMISSIONS_URL) + break + case 'toggle-default': { + const newValue = !enabledByDefault + saveGlobalConfig(current => ({ + ...current, + claudeInChromeDefaultEnabled: newValue, + })) + setEnabledByDefault(newValue) + break } - options.push(t5); - } - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Manage permissions; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== requiresExtensionSuffix) { - t6 = { - label: <>{t5}{requiresExtensionSuffix}, - value: "manage-permissions" - }; - $[11] = requiresExtensionSuffix; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Reconnect extension; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] !== requiresExtensionSuffix) { - t8 = { - label: <>{t7}{requiresExtensionSuffix}, - value: "reconnect" - }; - $[14] = requiresExtensionSuffix; - $[15] = t8; - } else { - t8 = $[15]; - } - const t9 = `Enabled by default: ${enabledByDefault ? "Yes" : "No"}`; - let t10; - if ($[16] !== t9) { - t10 = { - label: t9, - value: "toggle-default" - }; - $[16] = t9; - $[17] = t10; - } else { - t10 = $[17]; } - options.push(t6, t8, t10); - $[6] = enabledByDefault; - $[7] = isExtensionInstalled; - $[8] = options; - } else { - options = $[8]; - } - const isDisabled = isWSL; - let t5; - if ($[18] !== onDone) { - t5 = () => onDone(); - $[18] = onDone; - $[19] = t5; - } else { - t5 = $[19]; - } - let t6; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.; - $[20] = t6; - } else { - t6 = $[20]; - } - let t7; - if ($[21] !== isWSL) { - t7 = isWSL && Claude in Chrome is not supported in WSL at this time.; - $[21] = isWSL; - $[22] = t7; - } else { - t7 = $[22]; } - let t8; - if ($[23] !== isClaudeAISubscriber) { - t8 = false; - $[23] = isClaudeAISubscriber; - $[24] = t8; - } else { - t8 = $[24]; + + const options: OptionWithDescription[] = [] + const requiresExtensionSuffix = isExtensionInstalled + ? '' + : ' (requires extension)' + + if (!isExtensionInstalled && !isHomespace) { + options.push({ + label: 'Install Chrome extension', + value: 'install-extension', + }) } - let t9; - if ($[25] !== handleAction || $[26] !== isConnected || $[27] !== isDisabled || $[28] !== isExtensionInstalled || $[29] !== options || $[30] !== selectKey || $[31] !== showInstallHint) { - t9 = !isDisabled && <>{!isHomespace && Status:{" "}{isConnected ? Enabled : Disabled}Extension:{" "}{isExtensionInstalled ? Installed : Not detected}} + + {showInstallHint && ( + + Once installed, select {'"Reconnect extension"'} to connect. + + )} + + + Usage: + claude --chrome + or + claude --no-chrome + + + + Site-level permissions are inherited from the Chrome extension. + Manage permissions in the Chrome extension settings to control + which sites Claude can browse, click, and type on. + + + )} + Learn more: https://code.claude.com/docs/en/chrome + + + ) } -function _temp(s) { - return s.mcp.clients; + +export const call = async function ( + onDone: (result?: string) => void, +): Promise { + const isExtensionInstalled = await isChromeExtensionInstalled() + const config = getGlobalConfig() + const isSubscriber = isClaudeAISubscriber() + const isWSL = env.isWslEnvironment() + + return ( + + ) } -export const call = async function (onDone: (result?: string) => void): Promise { - const isExtensionInstalled = await isChromeExtensionInstalled(); - const config = getGlobalConfig(); - const isSubscriber = isClaudeAISubscriber(); - const isWSL = env.isWslEnvironment(); - return ; -}; diff --git a/src/commands/config/config.tsx b/src/commands/config/config.tsx index b263e37ba..d4e216c38 100644 --- a/src/commands/config/config.tsx +++ b/src/commands/config/config.tsx @@ -1,6 +1,7 @@ -import * as React from 'react'; -import { Settings } from '../../components/Settings/Settings.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; +import * as React from 'react' +import { Settings } from '../../components/Settings/Settings.js' +import type { LocalJSXCommandCall } from '../../types/command.js' + export const call: LocalJSXCommandCall = async (onDone, context) => { - return ; -}; + return +} diff --git a/src/commands/context/context.tsx b/src/commands/context/context.tsx index 595a3c594..747c5a9de 100644 --- a/src/commands/context/context.tsx +++ b/src/commands/context/context.tsx @@ -1,13 +1,13 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { ContextVisualization } from '../../components/ContextVisualization.js'; -import { microcompactMessages } from '../../services/compact/microCompact.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import type { Message } from '../../types/message.js'; -import { analyzeContextUsage } from '../../utils/analyzeContext.js'; -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; -import { renderToAnsiString } from '../../utils/staticRender.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import type { LocalJSXCommandContext } from '../../commands.js' +import { ContextVisualization } from '../../components/ContextVisualization.js' +import { microcompactMessages } from '../../services/compact/microCompact.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { Message } from '../../types/message.js' +import { analyzeContextUsage } from '../../utils/analyzeContext.js' +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' +import { renderToAnsiString } from '../../utils/staticRender.js' /** * Apply the same context transforms query.ts does before the API call, so @@ -16,48 +16,53 @@ import { renderToAnsiString } from '../../utils/staticRender.js'; * was collapsed — user sees "180k, 3 spans collapsed" when the API sees 120k. */ function toApiView(messages: Message[]): Message[] { - let view = getMessagesAfterCompactBoundary(messages); + let view = getMessagesAfterCompactBoundary(messages) if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - projectView - } = require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js'); + const { projectView } = + require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js') /* eslint-enable @typescript-eslint/no-require-imports */ - view = projectView(view); + view = projectView(view) } - return view; + return view } -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, +): Promise { const { messages, getAppState, - options: { - mainLoopModel, - tools - } - } = context; - const apiView = toApiView(messages); + options: { mainLoopModel, tools }, + } = context + + const apiView = toApiView(messages) // Apply microcompact to get accurate representation of messages sent to API - const { - messages: compactedMessages - } = await microcompactMessages(apiView); + const { messages: compactedMessages } = await microcompactMessages(apiView) // Get terminal width for responsive sizing - const terminalWidth = process.stdout.columns || 80; - const appState = getAppState(); + const terminalWidth = process.stdout.columns || 80 + + const appState = getAppState() // Analyze context with compacted messages // Pass original messages as last parameter for accurate API usage extraction - const data = await analyzeContextUsage(compactedMessages, mainLoopModel, async () => appState.toolPermissionContext, tools, appState.agentDefinitions, terminalWidth, context, - // Pass full context for system prompt calculation - undefined, - // mainThreadAgentDefinition - apiView // Original messages for API usage extraction - ); + const data = await analyzeContextUsage( + compactedMessages, + mainLoopModel, + async () => appState.toolPermissionContext, + tools, + appState.agentDefinitions, + terminalWidth, + context, // Pass full context for system prompt calculation + undefined, // mainThreadAgentDefinition + apiView, // Original messages for API usage extraction + ) // Render to ANSI string to preserve colors and pass to onDone like local commands do - const output = await renderToAnsiString(); - onDone(output); - return null; + const output = await renderToAnsiString() + onDone(output) + return null } diff --git a/src/commands/copy/copy.tsx b/src/commands/copy/copy.tsx index f9fc720d0..d5196de20 100644 --- a/src/commands/copy/copy.tsx +++ b/src/commands/copy/copy.tsx @@ -1,45 +1,44 @@ -import { c as _c } from "react/compiler-runtime"; -import { mkdir, writeFile } from 'fs/promises'; -import { marked, type Tokens } from 'marked'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import React, { useRef } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import type { OptionWithDescription } from '../../components/CustomSelect/select.js'; -import { Select } from '../../components/CustomSelect/select.js'; -import { Byline } from '../../components/design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; -import { Pane } from '../../components/design-system/Pane.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { setClipboard } from '../../ink/termio/osc.js'; -import { Box, Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -import type { AssistantMessage, Message } from '../../types/message.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js'; -import { countCharInString } from '../../utils/stringUtils.js'; -const COPY_DIR = join(tmpdir(), 'claude'); -const RESPONSE_FILENAME = 'response.md'; -const MAX_LOOKBACK = 20; +import { mkdir, writeFile } from 'fs/promises' +import { marked, type Tokens } from 'marked' +import { tmpdir } from 'os' +import { join } from 'path' +import React, { useRef } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import type { OptionWithDescription } from '../../components/CustomSelect/select.js' +import { Select } from '../../components/CustomSelect/select.js' +import { Byline } from '../../components/design-system/Byline.js' +import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js' +import { Pane } from '../../components/design-system/Pane.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { setClipboard } from '../../ink/termio/osc.js' +import { Box, Text } from '../../ink.js' +import { logEvent } from '../../services/analytics/index.js' +import type { LocalJSXCommandCall } from '../../types/command.js' +import type { AssistantMessage, Message } from '../../types/message.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js' +import { countCharInString } from '../../utils/stringUtils.js' + +const COPY_DIR = join(tmpdir(), 'claude') +const RESPONSE_FILENAME = 'response.md' +const MAX_LOOKBACK = 20 + type CodeBlock = { - code: string; - lang: string | undefined; -}; + code: string + lang: string | undefined +} + function extractCodeBlocks(markdown: string): CodeBlock[] { - const tokens = marked.lexer(stripPromptXMLTags(markdown)); - const blocks: CodeBlock[] = []; + const tokens = marked.lexer(stripPromptXMLTags(markdown)) + const blocks: CodeBlock[] = [] for (const token of tokens) { if (token.type === 'code') { - const codeToken = token as Tokens.Code; - blocks.push({ - code: codeToken.text, - lang: codeToken.lang - }); + const codeToken = token as Tokens.Code + blocks.push({ code: codeToken.text, lang: codeToken.lang }) } } - return blocks; + return blocks } /** @@ -48,323 +47,267 @@ function extractCodeBlocks(markdown: string): CodeBlock[] { * Index 0 = latest, 1 = second-to-latest, etc. Caps at MAX_LOOKBACK. */ export function collectRecentAssistantTexts(messages: Message[]): string[] { - const texts: string[] = []; - for (let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i--) { - const msg = messages[i]; - if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue; - const content = (msg as AssistantMessage).message.content; - if (!Array.isArray(content)) continue; - const text = extractTextContent(content, '\n\n'); - if (text) texts.push(text); + const texts: string[] = [] + for ( + let i = messages.length - 1; + i >= 0 && texts.length < MAX_LOOKBACK; + i-- + ) { + const msg = messages[i] + if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue + const content = (msg as AssistantMessage).message.content + if (!Array.isArray(content)) continue + const text = extractTextContent(content, '\n\n') + if (text) texts.push(text) } - return texts; + return texts } + export function fileExtension(lang: string | undefined): string { if (lang) { // Sanitize to prevent path traversal (e.g. ```../../etc/passwd) // Language identifiers are alphanumeric: python, tsx, jsonc, etc. - const sanitized = lang.replace(/[^a-zA-Z0-9]/g, ''); + const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '') if (sanitized && sanitized !== 'plaintext') { - return `.${sanitized}`; + return `.${sanitized}` } } - return '.txt'; + return '.txt' } + async function writeToFile(text: string, filename: string): Promise { - const filePath = join(COPY_DIR, filename); - await mkdir(COPY_DIR, { - recursive: true - }); - await writeFile(filePath, text, 'utf-8'); - return filePath; + const filePath = join(COPY_DIR, filename) + await mkdir(COPY_DIR, { recursive: true }) + await writeFile(filePath, text, 'utf-8') + return filePath } -async function copyOrWriteToFile(text: string, filename: string): Promise { - const raw = await setClipboard(text); - if (raw) process.stdout.write(raw); - const lineCount = countCharInString(text, '\n') + 1; - const charCount = text.length; + +async function copyOrWriteToFile( + text: string, + filename: string, +): Promise { + const raw = await setClipboard(text) + if (raw) process.stdout.write(raw) + const lineCount = countCharInString(text, '\n') + 1 + const charCount = text.length // Also write to a temp file — clipboard paths are best-effort (OSC 52 needs // terminal support), so the file provides a reliable fallback. try { - const filePath = await writeToFile(text, filename); - return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`; + const filePath = await writeToFile(text, filename) + return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}` } catch { - return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`; + return `Copied to clipboard (${charCount} characters, ${lineCount} lines)` } } + function truncateLine(text: string, maxLen: number): string { - const firstLine = text.split('\n')[0] ?? ''; + const firstLine = text.split('\n')[0] ?? '' if (stringWidth(firstLine) <= maxLen) { - return firstLine; + return firstLine } - let result = ''; - let width = 0; - const targetWidth = maxLen - 1; + let result = '' + let width = 0 + const targetWidth = maxLen - 1 for (const char of firstLine) { - const charWidth = stringWidth(char); - if (width + charWidth > targetWidth) break; - result += char; - width += charWidth; + const charWidth = stringWidth(char) + if (width + charWidth > targetWidth) break + result += char + width += charWidth } - return result + '\u2026'; + return result + '\u2026' } + type PickerProps = { - fullText: string; - codeBlocks: CodeBlock[]; - messageAge: number; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -type PickerSelection = number | 'full' | 'always'; -function CopyPicker(t0) { - const $ = _c(33); - const { - fullText, - codeBlocks, - messageAge, - onDone - } = t0; - const focusedRef = useRef("full"); - const t1 = `${fullText.length} chars, ${countCharInString(fullText, "\n") + 1} lines`; - let t2; - if ($[0] !== t1) { - t2 = { - label: "Full response", - value: "full" as const, - description: t1 - }; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== codeBlocks || $[3] !== t2) { - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Always copy full response", - value: "always" as const, - description: "Skip this picker in the future (revert via /config)" - }; - $[5] = t4; - } else { - t4 = $[5]; - } - t3 = [t2, ...codeBlocks.map(_temp), t4]; - $[2] = codeBlocks; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - const options = t3; - let t4; - if ($[6] !== codeBlocks || $[7] !== fullText) { - t4 = function getSelectionContent(selected) { - if (selected === "full" || selected === "always") { - return { - text: fullText, - filename: RESPONSE_FILENAME - }; - } - const block_0 = codeBlocks[selected]; + fullText: string + codeBlocks: CodeBlock[] + messageAge: number + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type PickerSelection = number | 'full' | 'always' + +function CopyPicker({ + fullText, + codeBlocks, + messageAge, + onDone, +}: PickerProps): React.ReactNode { + const focusedRef = useRef('full') + + const options: OptionWithDescription[] = [ + { + label: 'Full response', + value: 'full' as const, + description: `${fullText.length} chars, ${countCharInString(fullText, '\n') + 1} lines`, + }, + ...codeBlocks.map((block, index) => { + const blockLines = countCharInString(block.code, '\n') + 1 return { - text: block_0.code, - filename: `copy${fileExtension(block_0.lang)}`, - blockIndex: selected - }; - }; - $[6] = codeBlocks; - $[7] = fullText; - $[8] = t4; - } else { - t4 = $[8]; - } - const getSelectionContent = t4; - let t5; - if ($[9] !== codeBlocks.length || $[10] !== getSelectionContent || $[11] !== messageAge || $[12] !== onDone) { - t5 = async function handleSelect(selected_0) { - const content = getSelectionContent(selected_0); - if (selected_0 === "always") { - if (!getGlobalConfig().copyFullResponse) { - saveGlobalConfig(_temp2); - } - logEvent("tengu_copy", { - block_count: codeBlocks.length, - always: true, - message_age: messageAge - }); - const result = await copyOrWriteToFile(content.text, content.filename); - onDone(`${result}\nPreference saved. Use /config to change copyFullResponse`); - return; + label: truncateLine(block.code, 60), + value: index, + description: + [block.lang, blockLines > 1 ? `${blockLines} lines` : undefined] + .filter(Boolean) + .join(', ') || undefined, } - logEvent("tengu_copy", { - selected_block: content.blockIndex, - block_count: codeBlocks.length, - message_age: messageAge - }); - const result_0 = await copyOrWriteToFile(content.text, content.filename); - onDone(result_0); - }; - $[9] = codeBlocks.length; - $[10] = getSelectionContent; - $[11] = messageAge; - $[12] = onDone; - $[13] = t5; - } else { - t5 = $[13]; + }), + { + label: 'Always copy full response', + value: 'always' as const, + description: 'Skip this picker in the future (revert via /config)', + }, + ] + + function getSelectionContent(selected: PickerSelection): { + text: string + filename: string + blockIndex?: number + } { + if (selected === 'full' || selected === 'always') { + return { text: fullText, filename: RESPONSE_FILENAME } + } + const block = codeBlocks[selected]! + return { + text: block.code, + filename: `copy${fileExtension(block.lang)}`, + blockIndex: selected, + } } - const handleSelect = t5; - let t6; - if ($[14] !== codeBlocks.length || $[15] !== getSelectionContent || $[16] !== messageAge || $[17] !== onDone) { - const handleWrite = async function handleWrite(selected_1) { - const content_0 = getSelectionContent(selected_1); - logEvent("tengu_copy", { - selected_block: content_0.blockIndex, + + async function handleSelect(selected: PickerSelection): Promise { + const content = getSelectionContent(selected) + if (selected === 'always') { + if (!getGlobalConfig().copyFullResponse) { + saveGlobalConfig(c => ({ ...c, copyFullResponse: true })) + } + logEvent('tengu_copy', { block_count: codeBlocks.length, + always: true, message_age: messageAge, - write_shortcut: true - }); - ; - try { - const filePath = await writeToFile(content_0.text, content_0.filename); - onDone(`Written to ${filePath}`); - } catch (t7) { - const e = t7; - onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`); - } - }; - t6 = function handleKeyDown(e_0) { - if (e_0.key === "w") { - e_0.preventDefault(); - handleWrite(focusedRef.current); - } - }; - $[14] = codeBlocks.length; - $[15] = getSelectionContent; - $[16] = messageAge; - $[17] = onDone; - $[18] = t6; - } else { - t6 = $[18]; - } - const handleKeyDown = t6; - let t7; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Select content to copy:; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t8 = value => { - focusedRef.current = value; - }; - $[20] = t8; - } else { - t8 = $[20]; - } - let t9; - if ($[21] !== handleSelect) { - t9 = selected_2 => { - handleSelect(selected_2); - }; - $[21] = handleSelect; - $[22] = t9; - } else { - t9 = $[22]; - } - let t10; - if ($[23] !== onDone) { - t10 = () => { - onDone("Copy cancelled", { - display: "system" - }); - }; - $[23] = onDone; - $[24] = t10; - } else { - t10 = $[24]; - } - let t11; - if ($[25] !== options || $[26] !== t10 || $[27] !== t9) { - t11 = { - setSelectedValue(value_0); - handleSelectIDE(value_0); - }} />; - $[19] = availableIDEs.length; - $[20] = handleSelectIDE; - $[21] = options; - $[22] = selectedValue; - $[23] = t6; - } else { - t6 = $[23]; - } - let t7; - if ($[24] !== availableIDEs) { - t7 = availableIDEs.length !== 0 && availableIDEs.some(_temp2) && Note: Only one Claude Code instance can be connected to VS Code at a time.; - $[24] = availableIDEs; - $[25] = t7; - } else { - t7 = $[25]; - } - let t8; - if ($[26] !== availableIDEs.length) { - t8 = availableIDEs.length !== 0 && !isSupportedTerminal() && Tip: You can enable auto-connect to IDE in /config or with the --ide flag; - $[26] = availableIDEs.length; - $[27] = t8; - } else { - t8 = $[27]; - } - let t9; - if ($[28] !== unavailableIDEs) { - t9 = unavailableIDEs.length > 0 && Found {unavailableIDEs.length} other running IDE(s). However, their workspace/project directories do not match the current cwd.{unavailableIDEs.map(_temp3)}; - $[28] = unavailableIDEs; - $[29] = t9; - } else { - t9 = $[29]; + return ( + { + // Always disconnect when user selects "None", regardless of their + // choice about disabling auto-connect + onSelect(undefined) + }} + /> + ) } - let t10; - if ($[30] !== t5 || $[31] !== t6 || $[32] !== t7 || $[33] !== t8 || $[34] !== t9) { - t10 = {t5}{t6}{t7}{t8}{t9}; - $[30] = t5; - $[31] = t6; - $[32] = t7; - $[33] = t8; - $[34] = t9; - $[35] = t10; - } else { - t10 = $[35]; - } - let t11; - if ($[36] !== onClose || $[37] !== t10) { - t11 = {t10}; - $[36] = onClose; - $[37] = t10; - $[38] = t11; - } else { - t11 = $[38]; - } - return t11; -} -function _temp3(ide_3, index) { - return • {ide_3.name}: {formatWorkspaceFolders(ide_3.workspaceFolders)}; -} -function _temp2(ide_2) { - return ide_2.name === "VS Code" || ide_2.name === "Visual Studio Code"; -} -function _temp(acc, ide_0) { - acc[ide_0.name] = (acc[ide_0.name] || 0) + 1; - return acc; + + return ( + + + {availableIDEs.length === 0 && ( + + {isSupportedJetBrainsTerminal() + ? 'No available IDEs detected. Please install the plugin and restart your IDE:\n' + + 'https://docs.claude.com/s/claude-code-jetbrains' + : 'No available IDEs detected. Make sure your IDE has the Claude Code extension or plugin installed and is running.'} + + )} + + {availableIDEs.length !== 0 && ( + ; - $[11] = options; - $[12] = selectedValue; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] !== handleCancel || $[16] !== t6) { - t7 = {t6}; - $[15] = handleCancel; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; - } - return t7; + availableIDEs: DetectedIDEInfo[] + onSelectIDE: (ide?: DetectedIDEInfo) => void + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void } -function _temp4(ide_0) { - return { - label: ide_0.name, - value: ide_0.port.toString() - }; -} -function RunningIDESelector(t0) { - const $ = _c(15); - const { - runningIDEs, - onSelectIDE, - onDone - } = t0; - const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? ""); - let t1; - if ($[0] !== onSelectIDE) { - t1 = value => { - onSelectIDE(value as IdeType); - }; - $[0] = onSelectIDE; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSelectIDE = t1; - let t2; - if ($[2] !== runningIDEs) { - t2 = runningIDEs.map(_temp5); - $[2] = runningIDEs; - $[3] = t2; - } else { - t2 = $[3]; - } - const options = t2; - let t3; - if ($[4] !== onDone) { - t3 = function handleCancel() { - onDone("IDE selection cancelled", { - display: "system" - }); - }; - $[4] = onDone; - $[5] = t3; - } else { - t3 = $[5]; - } - const handleCancel = t3; - let t4; - if ($[6] !== handleSelectIDE) { - t4 = value_0 => { - setSelectedValue(value_0); - handleSelectIDE(value_0); - }; - $[6] = handleSelectIDE; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== options || $[9] !== selectedValue || $[10] !== t4) { - t5 = { + setSelectedValue(value) + handleSelectIDE(value) + }} + /> + + ) } -function _temp5(ide) { - return { + +function RunningIDESelector({ + runningIDEs, + onSelectIDE, + onDone, +}: { + runningIDEs: IdeType[] + onSelectIDE: (ide: IdeType) => void + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +}): React.ReactNode { + const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '') + + const handleSelectIDE = useCallback( + (value: string) => { + onSelectIDE(value as IdeType) + }, + [onSelectIDE], + ) + + const options = runningIDEs.map(ide => ({ label: toIDEDisplayName(ide), - value: ide - }; -} -function InstallOnMount(t0) { - const $ = _c(4); - const { - ide, - onInstall - } = t0; - let t1; - let t2; - if ($[0] !== ide || $[1] !== onInstall) { - t1 = () => { - onInstall(ide); - }; - t2 = [ide, onInstall]; - $[0] = ide; - $[1] = onInstall; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; + value: ide, + })) + + function handleCancel(): void { + onDone('IDE selection cancelled', { display: 'system' }) } - useEffect(t1, t2); - return null; + + return ( + + ; - $[9] = handleCancel; - $[10] = handleSelect; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t8 = View the latest workflow template at:{" "}https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] !== t5 || $[14] !== t7) { - t9 = {t5}{t6}{t7}{t8}; - $[13] = t5; - $[14] = t7; - $[15] = t9; - } else { - t9 = $[15]; - } - return t9; + + return ( + + + Existing Workflow Found + Repository: {repoName} + + + + + A Claude workflow file already exists at{' '} + .github/workflows/claude.yml + + What would you like to do? + + + + ; - $[19] = handleSelect; - $[20] = options; - $[21] = t6; - } else { - t6 = $[21]; - } - let t7; - if ($[22] !== handleCancel || $[23] !== t6) { - t7 = {t6}; - $[22] = handleCancel; - $[23] = t6; - $[24] = t7; - } else { - t7 = $[24]; + return subCommandJSX } - return t7; + + return ( + + + options={options} + onChange={handleSelect} + visibleOptionCount={options.length} + /> + + ) } -export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext): Promise { - return ; + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext & LocalJSXCommandContext, +): Promise { + return } diff --git a/src/commands/remote-env/remote-env.tsx b/src/commands/remote-env/remote-env.tsx index 65e0a5cb6..1c5f3feb6 100644 --- a/src/commands/remote-env/remote-env.tsx +++ b/src/commands/remote-env/remote-env.tsx @@ -1,6 +1,9 @@ -import * as React from 'react'; -import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone): Promise { - return ; +import * as React from 'react' +import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, +): Promise { + return } diff --git a/src/commands/remote-setup/remote-setup.tsx b/src/commands/remote-setup/remote-setup.tsx index e51f2e8d7..05813453d 100644 --- a/src/commands/remote-setup/remote-setup.tsx +++ b/src/commands/remote-setup/remote-setup.tsx @@ -1,163 +1,162 @@ -import { execa } from 'execa'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Select } from '../../components/CustomSelect/index.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { LoadingState } from '../../components/design-system/LoadingState.js'; -import { Box, Text } from '../../ink.js'; -import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString } from '../../services/analytics/index.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import { openBrowser } from '../../utils/browser.js'; -import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js'; -import { createDefaultEnvironment, getCodeWebUrl, type ImportTokenError, importGithubToken, isSignedIn, RedactedGithubToken } from './api.js'; -type CheckResult = { - status: 'not_signed_in'; -} | { - status: 'has_gh_token'; - token: RedactedGithubToken; -} | { - status: 'gh_not_installed'; -} | { - status: 'gh_not_authenticated'; -}; +import { execa } from 'execa' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Select } from '../../components/CustomSelect/index.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { LoadingState } from '../../components/design-system/LoadingState.js' +import { Box, Text } from '../../ink.js' +import { + logEvent, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString, +} from '../../services/analytics/index.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import { openBrowser } from '../../utils/browser.js' +import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js' +import { + createDefaultEnvironment, + getCodeWebUrl, + type ImportTokenError, + importGithubToken, + isSignedIn, + RedactedGithubToken, +} from './api.js' + +type CheckResult = + | { status: 'not_signed_in' } + | { status: 'has_gh_token'; token: RedactedGithubToken } + | { status: 'gh_not_installed' } + | { status: 'gh_not_authenticated' } + async function checkLoginState(): Promise { if (!(await isSignedIn())) { - return { - status: 'not_signed_in' - }; + return { status: 'not_signed_in' } } - const ghStatus = await getGhAuthStatus(); + + const ghStatus = await getGhAuthStatus() if (ghStatus === 'not_installed') { - return { - status: 'gh_not_installed' - }; + return { status: 'gh_not_installed' } } if (ghStatus === 'not_authenticated') { - return { - status: 'gh_not_authenticated' - }; + return { status: 'gh_not_authenticated' } } // ghStatus === 'authenticated'. getGhAuthStatus spawns with stdout:'ignore' // (telemetry-safe); spawn once more with stdout:'pipe' to read the token. - const { - stdout - } = await execa('gh', ['auth', 'token'], { + const { stdout } = await execa('gh', ['auth', 'token'], { stdout: 'pipe', stderr: 'ignore', timeout: 5000, - reject: false - }); - const trimmed = stdout.trim(); + reject: false, + }) + const trimmed = stdout.trim() if (!trimmed) { - return { - status: 'gh_not_authenticated' - }; + return { status: 'gh_not_authenticated' } } - return { - status: 'has_gh_token', - token: new RedactedGithubToken(trimmed) - }; + return { status: 'has_gh_token', token: new RedactedGithubToken(trimmed) } } + function errorMessage(err: ImportTokenError, codeUrl: string): string { switch (err.kind) { case 'not_signed_in': - return `Login failed. Please visit ${codeUrl} and login using the GitHub App`; + return `Login failed. Please visit ${codeUrl} and login using the GitHub App` case 'invalid_token': - return 'GitHub rejected that token. Run `gh auth login` and try again.'; + return 'GitHub rejected that token. Run `gh auth login` and try again.' case 'server': - return `Server error (${err.status}). Try again in a moment.`; + return `Server error (${err.status}). Try again in a moment.` case 'network': - return "Couldn't reach the server. Check your connection."; + return "Couldn't reach the server. Check your connection." } } -type Step = { - name: 'checking'; -} | { - name: 'confirm'; - token: RedactedGithubToken; -} | { - name: 'uploading'; -}; -function Web({ - onDone -}: { - onDone: LocalJSXCommandOnDone; -}) { - const [step, setStep] = useState({ - name: 'checking' - }); + +type Step = + | { name: 'checking' } + | { name: 'confirm'; token: RedactedGithubToken } + | { name: 'uploading' } + +function Web({ onDone }: { onDone: LocalJSXCommandOnDone }) { + const [step, setStep] = useState({ name: 'checking' }) + useEffect(() => { - logEvent('tengu_remote_setup_started', {}); + logEvent('tengu_remote_setup_started', {}) void checkLoginState().then(async result => { switch (result.status) { case 'not_signed_in': logEvent('tengu_remote_setup_result', { - result: 'not_signed_in' as SafeString - }); - onDone('Not signed in to Claude. Run /login first.'); - return; + result: 'not_signed_in' as SafeString, + }) + onDone('Not signed in to Claude. Run /login first.') + return case 'gh_not_installed': - case 'gh_not_authenticated': - { - const url = `${getCodeWebUrl()}/onboarding?step=alt-auth`; - await openBrowser(url); - logEvent('tengu_remote_setup_result', { - result: result.status as SafeString - }); - onDone(result.status === 'gh_not_installed' ? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}` : `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`); - return; - } + case 'gh_not_authenticated': { + const url = `${getCodeWebUrl()}/onboarding?step=alt-auth` + await openBrowser(url) + logEvent('tengu_remote_setup_result', { + result: result.status as SafeString, + }) + onDone( + result.status === 'gh_not_installed' + ? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}` + : `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`, + ) + return + } case 'has_gh_token': - setStep({ - name: 'confirm', - token: result.token - }); + setStep({ name: 'confirm', token: result.token }) } - }); + }) // onDone is stable across renders; intentionally not in deps. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []) + const handleCancel = () => { logEvent('tengu_remote_setup_result', { - result: 'cancelled' as SafeString - }); - onDone(); - }; + result: 'cancelled' as SafeString, + }) + onDone() + } + const handleConfirm = async (token: RedactedGithubToken) => { - setStep({ - name: 'uploading' - }); - const result = await importGithubToken(token); + setStep({ name: 'uploading' }) + + const result = await importGithubToken(token) if (!result.ok) { - const importErr = (result as { ok: false; error: ImportTokenError }).error; logEvent('tengu_remote_setup_result', { result: 'import_failed' as SafeString, - error_kind: importErr.kind as SafeString - }); - onDone(errorMessage(importErr, getCodeWebUrl())); - return; + error_kind: result.error.kind as SafeString, + }) + onDone(errorMessage(result.error, getCodeWebUrl())) + return } // Token import succeeded. Environment creation is best-effort — if it // fails, the web state machine routes to env-setup on landing, which is // one extra click but still better than the OAuth dance. - await createDefaultEnvironment(); - const url = getCodeWebUrl(); - await openBrowser(url); + await createDefaultEnvironment() + + const url = getCodeWebUrl() + await openBrowser(url) + logEvent('tengu_remote_setup_result', { - result: 'success' as SafeString - }); - onDone(`Connected as ${result.result.github_username}. Opened ${url}`); - }; + result: 'success' as SafeString, + }) + onDone(`Connected as ${result.result.github_username}. Opened ${url}`) + } + if (step.name === 'checking') { - return ; + return } + if (step.name === 'uploading') { - return ; + return } - const token = step.token; - return + + const token = step.token + return ( + Claude on the web requires connecting to your GitHub account to clone @@ -167,21 +166,26 @@ function Web({ Your local credentials are used to authenticate with GitHub - { + if (value === 'send') { + void handleConfirm(token) + } else { + handleCancel() + } + }} + onCancel={handleCancel} + /> + + ) } -export async function call(onDone: LocalJSXCommandOnDone): Promise { - return ; + +export async function call( + onDone: LocalJSXCommandOnDone, +): Promise { + return } diff --git a/src/commands/resume/resume.tsx b/src/commands/resume/resume.tsx index 4764089c8..f66d654c6 100644 --- a/src/commands/resume/resume.tsx +++ b/src/commands/resume/resume.tsx @@ -1,257 +1,300 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import type { UUID } from 'crypto'; -import figures from 'figures'; -import * as React from 'react'; -import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'; -import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js'; -import { LogSelector } from '../../components/LogSelector.js'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { Spinner } from '../../components/Spinner.js'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { setClipboard } from '../../ink/termio/osc.js'; -import { Box, Text } from '../../ink.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -import type { LogOption } from '../../types/logs.js'; -import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js'; -import { checkCrossProjectResume } from '../../utils/crossProjectResume.js'; -import { getWorktreePaths } from '../../utils/getWorktreePaths.js'; -import { logError } from '../../utils/log.js'; -import { getLastSessionLog, getSessionIdFromLog, isCustomTitleEnabled, isLiteLog, loadAllProjectsMessageLogs, loadFullLog, loadSameRepoMessageLogs, searchSessionsByCustomTitle } from '../../utils/sessionStorage.js'; -import { validateUuid } from '../../utils/uuid.js'; -type ResumeResult = { - resultType: 'sessionNotFound'; - arg: string; -} | { - resultType: 'multipleMatches'; - arg: string; - count: number; -}; +import chalk from 'chalk' +import type { UUID } from 'crypto' +import figures from 'figures' +import * as React from 'react' +import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' +import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js' +import { LogSelector } from '../../components/LogSelector.js' +import { MessageResponse } from '../../components/MessageResponse.js' +import { Spinner } from '../../components/Spinner.js' +import { useIsInsideModal } from '../../context/modalContext.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { setClipboard } from '../../ink/termio/osc.js' +import { Box, Text } from '../../ink.js' +import type { LocalJSXCommandCall } from '../../types/command.js' +import type { LogOption } from '../../types/logs.js' +import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js' +import { checkCrossProjectResume } from '../../utils/crossProjectResume.js' +import { getWorktreePaths } from '../../utils/getWorktreePaths.js' +import { logError } from '../../utils/log.js' +import { + getLastSessionLog, + getSessionIdFromLog, + isCustomTitleEnabled, + isLiteLog, + loadAllProjectsMessageLogs, + loadFullLog, + loadSameRepoMessageLogs, + searchSessionsByCustomTitle, +} from '../../utils/sessionStorage.js' +import { validateUuid } from '../../utils/uuid.js' + +type ResumeResult = + | { resultType: 'sessionNotFound'; arg: string } + | { resultType: 'multipleMatches'; arg: string; count: number } + function resumeHelpMessage(result: ResumeResult): string { switch (result.resultType) { case 'sessionNotFound': - return `Session ${chalk.bold(result.arg)} was not found.`; + return `Session ${chalk.bold(result.arg)} was not found.` case 'multipleMatches': - return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`; + return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.` } } -function ResumeError(t0) { - const $ = _c(10); - const { - message, - args, - onDone - } = t0; - let t1; - let t2; - if ($[0] !== onDone) { - t1 = () => { - const timer = setTimeout(onDone, 0); - return () => clearTimeout(timer); - }; - t2 = [onDone]; - $[0] = onDone; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - React.useEffect(t1, t2); - let t3; - if ($[3] !== args) { - t3 = {figures.pointer} /resume {args}; - $[3] = args; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== message) { - t4 = {message}; - $[5] = message; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t3 || $[8] !== t4) { - t5 = {t3}{t4}; - $[7] = t3; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + +function ResumeError({ + message, + args, + onDone, +}: { + message: string + args: string + onDone: () => void +}): React.ReactNode { + React.useEffect(() => { + const timer = setTimeout(onDone, 0) + return () => clearTimeout(timer) + }, [onDone]) + + return ( + + + {figures.pointer} /resume {args} + + + {message} + + + ) } + function ResumeCommand({ onDone, - onResume + onResume, }: { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onResume: (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => Promise; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onResume: ( + sessionId: UUID, + log: LogOption, + entrypoint: ResumeEntrypoint, + ) => Promise }): React.ReactNode { - const [logs, setLogs] = React.useState([]); - const [worktreePaths, setWorktreePaths] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [resuming, setResuming] = React.useState(false); - const [showAllProjects, setShowAllProjects] = React.useState(false); - const { - rows - } = useTerminalSize(); - const insideModal = useIsInsideModal(); - const loadLogs = React.useCallback(async (allProjects: boolean, paths: string[]) => { - setLoading(true); - try { - const allLogs = allProjects ? await loadAllProjectsMessageLogs() : await loadSameRepoMessageLogs(paths); - const resumable = filterResumableSessions(allLogs, getSessionId()); - if (resumable.length === 0) { - onDone('No conversations found to resume'); - return; + const [logs, setLogs] = React.useState([]) + const [worktreePaths, setWorktreePaths] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [resuming, setResuming] = React.useState(false) + const [showAllProjects, setShowAllProjects] = React.useState(false) + const { rows } = useTerminalSize() + const insideModal = useIsInsideModal() + + const loadLogs = React.useCallback( + async (allProjects: boolean, paths: string[]) => { + setLoading(true) + try { + const allLogs = allProjects + ? await loadAllProjectsMessageLogs() + : await loadSameRepoMessageLogs(paths) + const resumable = filterResumableSessions(allLogs, getSessionId()) + if (resumable.length === 0) { + onDone('No conversations found to resume') + return + } + setLogs(resumable) + } catch (_err) { + onDone('Failed to load conversations') + } finally { + setLoading(false) } - setLogs(resumable); - } catch (_err) { - onDone('Failed to load conversations'); - } finally { - setLoading(false); - } - }, [onDone]); + }, + [onDone], + ) + React.useEffect(() => { async function init() { - const paths_0 = await getWorktreePaths(getOriginalCwd()); - setWorktreePaths(paths_0); - void loadLogs(false, paths_0); + const paths = await getWorktreePaths(getOriginalCwd()) + setWorktreePaths(paths) + void loadLogs(false, paths) } - void init(); - }, [loadLogs]); + void init() + }, [loadLogs]) + const handleToggleAllProjects = React.useCallback(() => { - const newValue = !showAllProjects; - setShowAllProjects(newValue); - void loadLogs(newValue, worktreePaths); - }, [showAllProjects, loadLogs, worktreePaths]); + const newValue = !showAllProjects + setShowAllProjects(newValue) + void loadLogs(newValue, worktreePaths) + }, [showAllProjects, loadLogs, worktreePaths]) + async function handleSelect(log: LogOption) { - const sessionId = validateUuid(getSessionIdFromLog(log)); + const sessionId = validateUuid(getSessionIdFromLog(log)) if (!sessionId) { - onDone('Failed to resume conversation'); - return; + onDone('Failed to resume conversation') + return } // Load full messages for lite logs - const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log // Check if this conversation is from a different directory - const crossProjectCheck = checkCrossProjectResume(fullLog, showAllProjects, worktreePaths); + const crossProjectCheck = checkCrossProjectResume( + fullLog, + showAllProjects, + worktreePaths, + ) if (crossProjectCheck.isCrossProject) { if (crossProjectCheck.isSameRepoWorktree) { // Same repo worktree - can resume directly - setResuming(true); - void onResume(sessionId, fullLog, 'slash_command_picker'); - return; + setResuming(true) + void onResume(sessionId, fullLog, 'slash_command_picker') + return } // Different project - show command instead of resuming - const crossCmd = (crossProjectCheck as { isCrossProject: true; isSameRepoWorktree: false; command: string }).command; - const raw = await setClipboard(crossCmd); - if (raw) process.stdout.write(raw); + const raw = await setClipboard(crossProjectCheck.command) + if (raw) process.stdout.write(raw) // Format the output message - const message = ['', 'This conversation is from a different directory.', '', 'To resume, run:', ` ${crossCmd}`, '', '(Command copied to clipboard)', ''].join('\n'); - onDone(message, { - display: 'user' - }); - return; + const message = [ + '', + 'This conversation is from a different directory.', + '', + 'To resume, run:', + ` ${crossProjectCheck.command}`, + '', + '(Command copied to clipboard)', + '', + ].join('\n') + + onDone(message, { display: 'user' }) + return } // Same directory - proceed with resume - setResuming(true); - void onResume(sessionId, fullLog, 'slash_command_picker'); + setResuming(true) + void onResume(sessionId, fullLog, 'slash_command_picker') } + function handleCancel() { - onDone('Resume cancelled', { - display: 'system' - }); + onDone('Resume cancelled', { display: 'system' }) } + if (loading) { - return + return ( + Loading conversations… - ; + + ) } + if (resuming) { - return + return ( + Resuming conversation… - ; + + ) } - return loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />; + + return ( + loadLogs(showAllProjects, worktreePaths)} + showAllProjects={showAllProjects} + onToggleAllProjects={handleToggleAllProjects} + onAgenticSearch={agenticSessionSearch} + /> + ) } -export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] { - return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId); + +export function filterResumableSessions( + logs: LogOption[], + currentSessionId: string, +): LogOption[] { + return logs.filter( + l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId, + ) } + export const call: LocalJSXCommandCall = async (onDone, context, args) => { - const onResume = async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { + const onResume = async ( + sessionId: UUID, + log: LogOption, + entrypoint: ResumeEntrypoint, + ) => { try { - await context.resume?.(sessionId, log, entrypoint); - onDone(undefined, { - display: 'skip' - }); + await context.resume?.(sessionId, log, entrypoint) + onDone(undefined, { display: 'skip' }) } catch (error) { - logError(error as Error); - onDone(`Failed to resume: ${(error as Error).message}`); + logError(error as Error) + onDone(`Failed to resume: ${(error as Error).message}`) } - }; - const arg = args?.trim(); + } + + const arg = args?.trim() // No argument provided - show picker if (!arg) { - return ; + return ( + + ) } // Load logs to search (includes same-repo worktrees) - const worktreePaths = await getWorktreePaths(getOriginalCwd()); - const logs = await loadSameRepoMessageLogs(worktreePaths); + const worktreePaths = await getWorktreePaths(getOriginalCwd()) + const logs = await loadSameRepoMessageLogs(worktreePaths) if (logs.length === 0) { - const message = 'No conversations found to resume.'; - return onDone(message)} />; + const message = 'No conversations found to resume.' + return ( + onDone(message)} + /> + ) } // First, check if arg is a valid UUID - const maybeSessionId = validateUuid(arg); + const maybeSessionId = validateUuid(arg) if (maybeSessionId) { - const matchingLogs = logs.filter(l => getSessionIdFromLog(l) === maybeSessionId).sort((a, b) => b.modified.getTime() - a.modified.getTime()); + const matchingLogs = logs + .filter(l => getSessionIdFromLog(l) === maybeSessionId) + .sort((a, b) => b.modified.getTime() - a.modified.getTime()) + if (matchingLogs.length > 0) { - const log = matchingLogs[0]!; - const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; - void onResume(maybeSessionId, fullLog, 'slash_command_session_id'); - return null; + const log = matchingLogs[0]! + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log + void onResume(maybeSessionId, fullLog, 'slash_command_session_id') + return null } // Enriched logs didn't find it — try direct file lookup. This handles // sessions filtered out by enrichLogs (e.g., first message >16KB makes // firstPrompt extraction fail, causing the session to be dropped). - const directLog = await getLastSessionLog(maybeSessionId); + const directLog = await getLastSessionLog(maybeSessionId) if (directLog) { - void onResume(maybeSessionId, directLog, 'slash_command_session_id'); - return null; + void onResume(maybeSessionId, directLog, 'slash_command_session_id') + return null } } // Next, try exact custom title match (only if feature is enabled) if (isCustomTitleEnabled()) { const titleMatches = await searchSessionsByCustomTitle(arg, { - exact: true - }); + exact: true, + }) if (titleMatches.length === 1) { - const log = titleMatches[0]!; - const sessionId = getSessionIdFromLog(log); + const log = titleMatches[0]! + const sessionId = getSessionIdFromLog(log) if (sessionId) { - const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; - void onResume(sessionId, fullLog, 'slash_command_title'); - return null; + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log + void onResume(sessionId, fullLog, 'slash_command_title') + return null } } @@ -260,16 +303,21 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { const message = resumeHelpMessage({ resultType: 'multipleMatches', arg, - count: titleMatches.length - }); - return onDone(message)} />; + count: titleMatches.length, + }) + return ( + onDone(message)} + /> + ) } } // No match found - show error - const message = resumeHelpMessage({ - resultType: 'sessionNotFound', - arg - }); - return onDone(message)} />; -}; + const message = resumeHelpMessage({ resultType: 'sessionNotFound', arg }) + return ( + onDone(message)} /> + ) +} diff --git a/src/commands/review/UltrareviewOverageDialog.tsx b/src/commands/review/UltrareviewOverageDialog.tsx index 46cb40a02..020db57f8 100644 --- a/src/commands/review/UltrareviewOverageDialog.tsx +++ b/src/commands/review/UltrareviewOverageDialog.tsx @@ -1,95 +1,71 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useRef, useState } from 'react'; -import { Select } from '../../components/CustomSelect/select.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { Box, Text } from '../../ink.js'; +import React, { useCallback, useRef, useState } from 'react' +import { Select } from '../../components/CustomSelect/select.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { Box, Text } from '../../ink.js' + type Props = { - onProceed: (signal: AbortSignal) => Promise; - onCancel: () => void; -}; -export function UltrareviewOverageDialog(t0) { - const $ = _c(15); - const { - onProceed, - onCancel - } = t0; - const [isLaunching, setIsLaunching] = useState(false); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = new AbortController(); - $[0] = t1; - } else { - t1 = $[0]; - } - const abortControllerRef = useRef(t1); - let t2; - if ($[1] !== onCancel || $[2] !== onProceed) { - t2 = value => { - if (value === "proceed") { - setIsLaunching(true); - onProceed(abortControllerRef.current.signal).catch(() => setIsLaunching(false)); + onProceed: (signal: AbortSignal) => Promise + onCancel: () => void +} + +export function UltrareviewOverageDialog({ + onProceed, + onCancel, +}: Props): React.ReactNode { + const [isLaunching, setIsLaunching] = useState(false) + const abortControllerRef = useRef(new AbortController()) + + const handleSelect = useCallback( + (value: string) => { + if (value === 'proceed') { + setIsLaunching(true) + // If onProceed rejects (e.g. launchRemoteReview throws), onDone is + // never called and the dialog stays mounted — restore the Select so + // the user can retry or cancel instead of staring at "Launching…". + void onProceed(abortControllerRef.current.signal).catch(() => + setIsLaunching(false), + ) } else { - onCancel(); + onCancel() } - }; - $[1] = onCancel; - $[2] = onProceed; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleSelect = t2; - let t3; - if ($[4] !== onCancel) { - t3 = () => { - abortControllerRef.current.abort(); - onCancel(); - }; - $[4] = onCancel; - $[5] = t3; - } else { - t3 = $[5]; - } - const handleCancel = t3; - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Proceed with Extra Usage billing", - value: "proceed" - }, { - label: "Cancel", - value: "cancel" - }]; - $[6] = t4; - } else { - t4 = $[6]; - } - const options = t4; - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Your free ultrareviews for this organization are used. Further reviews bill as Extra Usage (pay-per-use).; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== handleCancel || $[9] !== handleSelect || $[10] !== isLaunching) { - t6 = {t5}{isLaunching ? Launching… : + )} + + + ) } diff --git a/src/commands/review/ultrareviewCommand.tsx b/src/commands/review/ultrareviewCommand.tsx index 56e92fdf1..faad0fc2f 100644 --- a/src/commands/review/ultrareviewCommand.tsx +++ b/src/commands/review/ultrareviewCommand.tsx @@ -1,57 +1,89 @@ -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'; -import React from 'react'; -import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'; -import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js'; -import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js' +import React from 'react' +import type { + LocalJSXCommandCall, + LocalJSXCommandOnDone, +} from '../../types/command.js' +import { + checkOverageGate, + confirmOverage, + launchRemoteReview, +} from './reviewRemote.js' +import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js' + function contentBlocksToString(blocks: ContentBlockParam[]): string { - return blocks.map(b => b.type === 'text' ? b.text : '').filter(Boolean).join('\n'); + return blocks + .map(b => (b.type === 'text' ? b.text : '')) + .filter(Boolean) + .join('\n') } -async function launchAndDone(args: string, context: Parameters[1], onDone: LocalJSXCommandOnDone, billingNote: string, signal?: AbortSignal): Promise { - const result = await launchRemoteReview(args, context, billingNote); + +async function launchAndDone( + args: string, + context: Parameters[1], + onDone: LocalJSXCommandOnDone, + billingNote: string, + signal?: AbortSignal, +): Promise { + const result = await launchRemoteReview(args, context, billingNote) // User hit Escape during the ~5s launch — the dialog already showed // "cancelled" and unmounted, so skip onDone (would write to a dead // transcript slot) and let the caller skip confirmOverage. - if (signal?.aborted) return; + if (signal?.aborted) return if (result) { - onDone(contentBlocksToString(result), { - shouldQuery: true - }); + onDone(contentBlocksToString(result), { shouldQuery: true }) } else { // Precondition failures now return specific ContentBlockParam[] above. // null only reaches here on teleport failure (PR mode) or non-github // repo — both are CCR/repo connectivity issues. - onDone('Ultrareview failed to launch the remote session. Check that this is a GitHub repo and try again.', { - display: 'system' - }); + onDone( + 'Ultrareview failed to launch the remote session. Check that this is a GitHub repo and try again.', + { display: 'system' }, + ) } } + export const call: LocalJSXCommandCall = async (onDone, context, args) => { - const gate = await checkOverageGate(); + const gate = await checkOverageGate() + if (gate.kind === 'not-enabled') { - onDone('Free ultrareviews used. Enable Extra Usage at https://claude.ai/settings/billing to continue.', { - display: 'system' - }); - return null; + onDone( + 'Free ultrareviews used. Enable Extra Usage at https://claude.ai/settings/billing to continue.', + { display: 'system' }, + ) + return null } + if (gate.kind === 'low-balance') { - onDone(`Balance too low to launch ultrareview ($${gate.available.toFixed(2)} available, $10 minimum). Top up at https://claude.ai/settings/billing`, { - display: 'system' - }); - return null; + onDone( + `Balance too low to launch ultrareview ($${gate.available.toFixed(2)} available, $10 minimum). Top up at https://claude.ai/settings/billing`, + { display: 'system' }, + ) + return null } + if (gate.kind === 'needs-confirm') { - return { - await launchAndDone(args, context, onDone, ' This review bills as Extra Usage.', signal); - // Only persist the confirmation flag after a non-aborted launch — - // otherwise Escape-during-launch would leave the flag set and - // skip this dialog on the next attempt. - if (!signal.aborted) confirmOverage(); - }} onCancel={() => onDone('Ultrareview cancelled.', { - display: 'system' - })} />; + return ( + { + await launchAndDone( + args, + context, + onDone, + ' This review bills as Extra Usage.', + signal, + ) + // Only persist the confirmation flag after a non-aborted launch — + // otherwise Escape-during-launch would leave the flag set and + // skip this dialog on the next attempt. + if (!signal.aborted) confirmOverage() + }} + onCancel={() => onDone('Ultrareview cancelled.', { display: 'system' })} + /> + ) } // gate.kind === 'proceed' - await launchAndDone(args, context, onDone, gate.billingNote); - return null; -}; + await launchAndDone(args, context, onDone, gate.billingNote) + return null +} diff --git a/src/commands/sandbox-toggle/sandbox-toggle.tsx b/src/commands/sandbox-toggle/sandbox-toggle.tsx index dc70b194c..157961ad5 100644 --- a/src/commands/sandbox-toggle/sandbox-toggle.tsx +++ b/src/commands/sandbox-toggle/sandbox-toggle.tsx @@ -1,82 +1,127 @@ -import { relative } from 'path'; -import React from 'react'; -import { getCwdState } from '../../bootstrap/state.js'; -import { SandboxSettings } from '../../components/sandbox/SandboxSettings.js'; -import { color } from '../../ink.js'; -import { getPlatform } from '../../utils/platform.js'; -import { addToExcludedCommands, SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { getSettings_DEPRECATED, getSettingsFilePathForSource } from '../../utils/settings/settings.js'; -import type { ThemeName } from '../../utils/theme.js'; -export async function call(onDone: (result?: string) => void, _context: unknown, args?: string): Promise { - const settings = getSettings_DEPRECATED(); - const themeName: ThemeName = settings.theme as ThemeName || 'light'; - const platform = getPlatform(); +import { relative } from 'path' +import React from 'react' +import { getCwdState } from '../../bootstrap/state.js' +import { SandboxSettings } from '../../components/sandbox/SandboxSettings.js' +import { color } from '../../ink.js' +import { getPlatform } from '../../utils/platform.js' +import { + addToExcludedCommands, + SandboxManager, +} from '../../utils/sandbox/sandbox-adapter.js' +import { + getSettings_DEPRECATED, + getSettingsFilePathForSource, +} from '../../utils/settings/settings.js' +import type { ThemeName } from '../../utils/theme.js' + +export async function call( + onDone: (result?: string) => void, + _context: unknown, + args?: string, +): Promise { + const settings = getSettings_DEPRECATED() + const themeName: ThemeName = (settings.theme as ThemeName) || 'light' + + const platform = getPlatform() + if (!SandboxManager.isSupportedPlatform()) { // WSL1 users will see this since isSupportedPlatform returns false for WSL1 - const errorMessage = platform === 'wsl' ? 'Error: Sandboxing requires WSL2. WSL1 is not supported.' : 'Error: Sandboxing is currently only supported on macOS, Linux, and WSL2.'; - const message = color('error', themeName)(errorMessage); - onDone(message); - return null; + const errorMessage = + platform === 'wsl' + ? 'Error: Sandboxing requires WSL2. WSL1 is not supported.' + : 'Error: Sandboxing is currently only supported on macOS, Linux, and WSL2.' + const message = color('error', themeName)(errorMessage) + onDone(message) + return null } // Check dependencies - get structured result with errors/warnings - const depCheck = SandboxManager.checkDependencies(); + const depCheck = SandboxManager.checkDependencies() // Check if platform is in enabledPlatforms list (undocumented enterprise setting) if (!SandboxManager.isPlatformInEnabledList()) { - const message = color('error', themeName)(`Error: Sandboxing is disabled for this platform (${platform}) via the enabledPlatforms setting.`); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + `Error: Sandboxing is disabled for this platform (${platform}) via the enabledPlatforms setting.`, + ) + onDone(message) + return null } // Check if sandbox settings are locked by higher-priority settings if (SandboxManager.areSandboxSettingsLockedByPolicy()) { - const message = color('error', themeName)('Error: Sandbox settings are overridden by a higher-priority configuration and cannot be changed locally.'); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + 'Error: Sandbox settings are overridden by a higher-priority configuration and cannot be changed locally.', + ) + onDone(message) + return null } // Parse the arguments - const trimmedArgs = args?.trim() || ''; + const trimmedArgs = args?.trim() || '' // If no args, show the interactive menu if (!trimmedArgs) { - return ; + return } // Handle subcommands if (trimmedArgs) { - const parts = trimmedArgs.split(' '); - const subcommand = parts[0]; + const parts = trimmedArgs.split(' ') + const subcommand = parts[0] + if (subcommand === 'exclude') { // Handle exclude subcommand - const commandPattern = trimmedArgs.slice('exclude '.length).trim(); + const commandPattern = trimmedArgs.slice('exclude '.length).trim() + if (!commandPattern) { - const message = color('error', themeName)('Error: Please provide a command pattern to exclude (e.g., /sandbox exclude "npm run test:*")'); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + 'Error: Please provide a command pattern to exclude (e.g., /sandbox exclude "npm run test:*")', + ) + onDone(message) + return null } // Remove quotes if present - const cleanPattern = commandPattern.replace(/^["']|["']$/g, ''); + const cleanPattern = commandPattern.replace(/^["']|["']$/g, '') // Add to excludedCommands - addToExcludedCommands(cleanPattern); + addToExcludedCommands(cleanPattern) // Get the local settings path and make it relative to cwd - const localSettingsPath = getSettingsFilePathForSource('localSettings'); - const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.claude/settings.local.json'; - const message = color('success', themeName)(`Added "${cleanPattern}" to excluded commands in ${relativePath}`); - onDone(message); - return null; + const localSettingsPath = getSettingsFilePathForSource('localSettings') + const relativePath = localSettingsPath + ? relative(getCwdState(), localSettingsPath) + : '.claude/settings.local.json' + + const message = color( + 'success', + themeName, + )(`Added "${cleanPattern}" to excluded commands in ${relativePath}`) + + onDone(message) + return null } else { // Unknown subcommand - const message = color('error', themeName)(`Error: Unknown subcommand "${subcommand}". Available subcommand: exclude`); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + `Error: Unknown subcommand "${subcommand}". Available subcommand: exclude`, + ) + onDone(message) + return null } } // Should never reach here since we handle all cases above - return null; + return null } diff --git a/src/commands/session/session.tsx b/src/commands/session/session.tsx index b7ae9fa1c..82135a3fa 100644 --- a/src/commands/session/session.tsx +++ b/src/commands/session/session.tsx @@ -1,139 +1,83 @@ -import { c as _c } from "react/compiler-runtime"; -import { toString as qrToString } from 'qrcode'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Pane } from '../../components/design-system/Pane.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { useAppState } from '../../state/AppState.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -import { logForDebugging } from '../../utils/debug.js'; +import { toString as qrToString } from 'qrcode' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Pane } from '../../components/design-system/Pane.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { useAppState } from '../../state/AppState.js' +import type { LocalJSXCommandCall } from '../../types/command.js' +import { logForDebugging } from '../../utils/debug.js' + type Props = { - onDone: () => void; -}; -function SessionInfo(t0) { - const $ = _c(19); - const { - onDone - } = t0; - const remoteSessionUrl = useAppState(_temp); - const [qrCode, setQrCode] = useState(""); - let t1; - let t2; - if ($[0] !== remoteSessionUrl) { - t1 = () => { - if (!remoteSessionUrl) { - return; - } - const url = remoteSessionUrl; - const generateQRCode = async function generateQRCode() { - const qr = await qrToString(url, { - type: "utf8", - errorCorrectionLevel: "L" - }); - setQrCode(qr); - }; - generateQRCode().catch(_temp2); - }; - t2 = [remoteSessionUrl]; - $[0] = remoteSessionUrl; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybinding("confirm:no", onDone, t3); - if (!remoteSessionUrl) { - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Not in remote mode. Start with `claude --remote` to use this command.(press esc to close); - $[4] = t4; - } else { - t4 = $[4]; - } - return t4; - } - let T0; - let t4; - let t5; - if ($[5] !== qrCode) { - const lines = qrCode.split("\n").filter(_temp3); - const isLoading = lines.length === 0; - T0 = Pane; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Remote session; - $[9] = t4; - } else { - t4 = $[9]; + onDone: () => void +} + +function SessionInfo({ onDone }: Props): React.ReactNode { + const remoteSessionUrl = useAppState(s => s.remoteSessionUrl) + const [qrCode, setQrCode] = useState('') + + // Generate QR code when URL is available + useEffect(() => { + if (!remoteSessionUrl) return + + const url = remoteSessionUrl + async function generateQRCode(): Promise { + const qr = await qrToString(url, { + type: 'utf8', + errorCorrectionLevel: 'L', + }) + setQrCode(qr) } - t5 = isLoading ? Generating QR code… : lines.map(_temp4); - $[5] = qrCode; - $[6] = T0; - $[7] = t4; - $[8] = t5; - } else { - T0 = $[6]; - t4 = $[7]; - t5 = $[8]; - } - let t6; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Open in browser: ; - $[10] = t6; - } else { - t6 = $[10]; - } - let t7; - if ($[11] !== remoteSessionUrl) { - t7 = {t6}{remoteSessionUrl}; - $[11] = remoteSessionUrl; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t8 = (press esc to close); - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== T0 || $[15] !== t4 || $[16] !== t5 || $[17] !== t7) { - t9 = {t4}{t5}{t7}{t8}; - $[14] = T0; - $[15] = t4; - $[16] = t5; - $[17] = t7; - $[18] = t9; - } else { - t9 = $[18]; + // Intentionally silent fail - URL is still shown so QR is non-critical + generateQRCode().catch(e => { + logForDebugging('QR code generation failed', e) + }) + }, [remoteSessionUrl]) + + // Handle ESC to dismiss + useKeybinding('confirm:no', onDone, { context: 'Confirmation' }) + + // Not in remote mode + if (!remoteSessionUrl) { + return ( + + + Not in remote mode. Start with `claude --remote` to use this command. + + (press esc to close) + + ) } - return t9; -} -function _temp4(line_0, i) { - return {line_0}; -} -function _temp3(line) { - return line.length > 0; -} -function _temp2(e) { - logForDebugging("QR code generation failed", e); -} -function _temp(s) { - return s.remoteSessionUrl; + + const lines = qrCode.split('\n').filter(line => line.length > 0) + const isLoading = lines.length === 0 + + return ( + + + Remote session + + + {/* QR Code - silently fails if generation errors, URL is still shown */} + {isLoading ? ( + Generating QR code… + ) : ( + lines.map((line, i) => {line}) + )} + + {/* URL */} + + Open in browser: + {remoteSessionUrl} + + + + (press esc to close) + + + ) } + export const call: LocalJSXCommandCall = async onDone => { - return ; -}; + return +} diff --git a/src/commands/skills/skills.tsx b/src/commands/skills/skills.tsx index a765951c3..568efdc52 100644 --- a/src/commands/skills/skills.tsx +++ b/src/commands/skills/skills.tsx @@ -1,7 +1,11 @@ -import * as React from 'react'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { SkillsMenu } from '../../components/skills/SkillsMenu.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { - return ; +import * as React from 'react' +import type { LocalJSXCommandContext } from '../../commands.js' +import { SkillsMenu } from '../../components/skills/SkillsMenu.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, +): Promise { + return } diff --git a/src/commands/stats/stats.tsx b/src/commands/stats/stats.tsx index 2fe5ca9d7..b467ee3d6 100644 --- a/src/commands/stats/stats.tsx +++ b/src/commands/stats/stats.tsx @@ -1,6 +1,7 @@ -import * as React from 'react'; -import { Stats } from '../../components/Stats.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; +import * as React from 'react' +import { Stats } from '../../components/Stats.js' +import type { LocalJSXCommandCall } from '../../types/command.js' + export const call: LocalJSXCommandCall = async onDone => { - return ; -}; + return +} diff --git a/src/commands/status/status.tsx b/src/commands/status/status.tsx index 6e0d9c342..25bb4b107 100644 --- a/src/commands/status/status.tsx +++ b/src/commands/status/status.tsx @@ -1,7 +1,11 @@ -import * as React from 'react'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { Settings } from '../../components/Settings/Settings.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { - return ; +import * as React from 'react' +import type { LocalJSXCommandContext } from '../../commands.js' +import { Settings } from '../../components/Settings/Settings.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, +): Promise { + return } diff --git a/src/commands/statusline.tsx b/src/commands/statusline.tsx index 2e5778156..d12f4ad2d 100644 --- a/src/commands/statusline.tsx +++ b/src/commands/statusline.tsx @@ -1,23 +1,31 @@ -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import type { Command } from '../commands.js'; -import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import type { Command } from '../commands.js' +import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js' + const statusline = { type: 'prompt', description: "Set up Claude Code's status line UI", - contentLength: 0, - // Dynamic content + contentLength: 0, // Dynamic content aliases: [], name: 'statusline', progressMessage: 'setting up statusLine', - allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.claude/settings.json)'], + allowedTools: [ + AGENT_TOOL_NAME, + 'Read(~/**)', + 'Edit(~/.claude/settings.json)', + ], source: 'builtin', disableNonInteractive: true, async getPromptForCommand(args): Promise { - const prompt = args.trim() || 'Configure my statusLine from my shell PS1 configuration'; - return [{ - type: 'text', - text: `Create an ${AGENT_TOOL_NAME} with subagent_type "statusline-setup" and the prompt "${prompt}"` - }]; - } -} satisfies Command; -export default statusline; + const prompt = + args.trim() || 'Configure my statusLine from my shell PS1 configuration' + return [ + { + type: 'text', + text: `Create an ${AGENT_TOOL_NAME} with subagent_type "statusline-setup" and the prompt "${prompt}"`, + }, + ] + }, +} satisfies Command + +export default statusline diff --git a/src/commands/tag/tag.tsx b/src/commands/tag/tag.tsx index e399248a2..c9d0c6524 100644 --- a/src/commands/tag/tag.tsx +++ b/src/commands/tag/tag.tsx @@ -1,214 +1,167 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import type { UUID } from 'crypto'; -import * as React from 'react'; -import { getSessionId } from '../../bootstrap/state.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Select } from '../../components/CustomSelect/select.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'; -import { Box, Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import { recursivelySanitizeUnicode } from '../../utils/sanitization.js'; -import { getCurrentSessionTag, getTranscriptPath, saveTag } from '../../utils/sessionStorage.js'; -function ConfirmRemoveTag(t0) { - const $ = _c(11); - const { - tagName, - onConfirm, - onCancel - } = t0; - const t1 = `Current tag: #${tagName}`; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = This will remove the tag from the current session.; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== onCancel || $[2] !== onConfirm) { - t3 = value => value === "yes" ? onConfirm() : onCancel(); - $[1] = onCancel; - $[2] = onConfirm; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Yes, remove tag", - value: "yes" - }, { - label: "No, keep tag", - value: "no" - }]; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== t3) { - t5 = {t2}; - $[10] = handleSelect; - $[11] = options; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - let t7; - if ($[16] !== handleCancel || $[17] !== t6) { - t7 = {t6}; - $[16] = handleCancel; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - return t7; + + return ( + + + {/* Description for first-time users */} + {!hasGenerated && ( + + Relive your year of coding with Claude. + + { + "We'll create a personalized ASCII animation celebrating your journey." + } + + + )} + + {/* Menu */} + onChange(value_0 as 'yes' | 'no')} onCancel={() => onChange("no")} />; - $[11] = onChange; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] !== t2 || $[14] !== t4 || $[15] !== t8) { - t9 = {t4}{t5}{t8}; - $[13] = t2; - $[14] = t4; - $[15] = t8; - $[16] = t9; - } else { - t9 = $[16]; + case 'no': { + saveGlobalConfig(current => ({ + ...current, + customApiKeyResponses: { + ...current.customApiKeyResponses, + rejected: [ + ...(current.customApiKeyResponses?.rejected ?? []), + customApiKeyTruncated, + ], + }, + })) + onDone(false) + break + } + } } - return t9; + + return ( + onChange('no')} + > + + ANTHROPIC_API_KEY + : sk-ant-...{customApiKeyTruncated} + + Do you want to use this API key? + ; - $[11] = onDecline; - $[12] = t7; - $[13] = t8; - $[14] = t9; - } else { - t9 = $[14]; - } - let t10; - if ($[15] !== onDecline || $[16] !== t9) { - t10 = {t3}{t9}; - $[15] = onDecline; - $[16] = t9; - $[17] = t10; - } else { - t10 = $[17]; + case 'accept-default': { + logEvent('tengu_auto_mode_opt_in_dialog_accept_default', {}) + updateSettingsForSource('userSettings', { + skipAutoPermissionPrompt: true, + permissions: { defaultMode: 'auto' }, + }) + onAccept() + break + } + case 'decline': { + logEvent('tengu_auto_mode_opt_in_dialog_decline', {}) + onDecline() + break + } + } } - return t10; -} -function _temp() { - logEvent("tengu_auto_mode_opt_in_dialog_shown", {}); + + return ( + + + {AUTO_MODE_DESCRIPTION} + + + + + onChange(value_0 as 'accept' | 'decline')} />; - $[5] = onChange; - $[6] = t5; - } else { - t5 = $[6]; + case 'decline': { + gracefulShutdownSync(1) + break + } + } } - return t5; -} -function _temp2() { - gracefulShutdownSync(0); -} -function _temp() { - logEvent("tengu_bypass_permissions_mode_dialog_shown", {}); + + const handleEscape = useCallback(() => { + gracefulShutdownSync(0) + }, []) + + return ( + + + + In Bypass Permissions mode, Claude Code will not ask for your approval + before running potentially dangerous commands. + + This mode should only be used in a sandboxed container/VM that has + restricted internet access and can easily be restored if damaged. + + + By proceeding, you accept all responsibility for actions taken while + running in Bypass Permissions mode. + + + + + + ; - $[10] = handleSelect; - $[11] = t7; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] !== handleCancel || $[14] !== t3 || $[15] !== t8) { - t9 = {t3}{t4}{t8}; - $[13] = handleCancel; - $[14] = t3; - $[15] = t8; - $[16] = t9; - } else { - t9 = $[16]; + + function handleCancel(): void { + onChoice('cancel') } - return t9; + + return ( + + + The stable channel may have an older version than what you're + currently running ({currentVersion}). + + How would you like to handle this? + onResponse('no')} /> + handleSelection(value_0 as 'yes' | 'no')} />; - $[10] = handleSelection; - $[11] = t10; - } else { - t10 = $[11]; - } - let t11; - if ($[12] !== handleEscape || $[13] !== t10 || $[14] !== t4 || $[15] !== t5 || $[16] !== t7) { - t11 = {t6}{t7}{t8}{t10}; - $[12] = handleEscape; - $[13] = t10; - $[14] = t4; - $[15] = t5; - $[16] = t7; - $[17] = t11; - } else { - t11 = $[17]; - } - return t11; -} -function _temp4(include, i) { - return {" "}{include.path}; -} -function _temp3(current_0) { - return { - ...current_0, - hasClaudeMdExternalIncludesApproved: true, - hasClaudeMdExternalIncludesWarningShown: true - }; -} -function _temp2(current) { - return { - ...current, - hasClaudeMdExternalIncludesApproved: false, - hasClaudeMdExternalIncludesWarningShown: true - }; -} -function _temp() { - logEvent("tengu_claude_md_includes_dialog_shown", {}); + + onDone() + }, + [onDone], + ) + + const handleEscape = useCallback(() => { + handleSelection('no') + }, [handleSelection]) + + return ( + + + This project's CLAUDE.md imports files outside the current working + directory. Never allow this for third-party repositories. + + + {externalIncludes && externalIncludes.length > 0 && ( + + External imports: + {externalIncludes.map((include, i) => ( + + {' '} + {include.path} + + ))} + + )} + + + Important: Only use Claude Code with files you trust. Accessing + untrusted files may pose security risks{' '} + {' '} + + + { - if (value_0 === "custom_platform") { - logEvent("tengu_custom_platform_selected", {}); - setOAuthStatus({ - state: "custom_platform", - baseUrl: process.env.ANTHROPIC_BASE_URL ?? "", - apiKey: process.env.ANTHROPIC_AUTH_TOKEN ?? "", - haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? "", - sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? "", - opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? "", - activeField: "base_url" - }); - } else if (value_0 === "openai_chat_api") { - logEvent("tengu_openai_chat_api_selected", {}); - setOAuthStatus({ - state: "openai_chat_api", - baseUrl: process.env.OPENAI_BASE_URL ?? "", - apiKey: process.env.OPENAI_API_KEY ?? "", - haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? "", - sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? "", - opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? "", - activeField: "base_url" - }); - } else if (value_0 === "platform") { - logEvent("tengu_oauth_platform_selected", {}); - setOAuthStatus({ - state: "platform_setup" - }); - } else { - setOAuthStatus({ - state: "ready_to_start" - }); - if (value_0 === "claudeai") { - logEvent("tengu_oauth_claudeai_selected", {}); - setLoginWithClaudeAi(true); + case 'idle': + return ( + + + {startingMessage + ? startingMessage + : `Claude Code can be used with your Claude subscription or billed based on API usage through your Console account.`} + + + Select login method: + + + ; - $[2] = onDone; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== onDone || $[5] !== t3) { - t4 = {t1}{t3}; - $[4] = onDone; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - return t4; + onDone: () => void +} + +export function CostThresholdDialog({ onDone }: Props): React.ReactNode { + return ( + + + Learn more about how to monitor your spending: + + + ; - $[11] = handleSelect; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; + onDone: () => void } -function _temp2(prev_0) { - if (prev_0.desktopUpsellDismissed) { - return prev_0; + +export function DesktopUpsellStartup({ onDone }: Props): React.ReactNode { + const [showHandoff, setShowHandoff] = useState(false) + + // Increment seen count on mount (guard in updater for StrictMode safety) + useEffect(() => { + const newCount = (getGlobalConfig().desktopUpsellSeenCount ?? 0) + 1 + saveGlobalConfig(prev => { + if ((prev.desktopUpsellSeenCount ?? 0) >= newCount) return prev + return { ...prev, desktopUpsellSeenCount: newCount } + }) + logEvent('tengu_desktop_upsell_shown', { seen_count: newCount }) + }, []) + + if (showHandoff) { + return onDone()} /> } - return { - ...prev_0, - desktopUpsellDismissed: true - }; -} -function _temp() { - const newCount = (getGlobalConfig().desktopUpsellSeenCount ?? 0) + 1; - saveGlobalConfig(prev => { - if ((prev.desktopUpsellSeenCount ?? 0) >= newCount) { - return prev; + + function handleSelect(value: DesktopUpsellSelection): void { + switch (value) { + case 'try': + setShowHandoff(true) + return + case 'never': + saveGlobalConfig(prev => { + if (prev.desktopUpsellDismissed) return prev + return { ...prev, desktopUpsellDismissed: true } + }) + onDone() + return + case 'not-now': + onDone() + return } - return { - ...prev, - desktopUpsellSeenCount: newCount - }; - }); - logEvent("tengu_desktop_upsell_shown", { - seen_count: newCount - }); + } + + const options = [ + { label: 'Open in Claude Code Desktop', value: 'try' as const }, + { label: 'Not now', value: 'not-now' as const }, + { label: "Don't ask again", value: 'never' as const }, + ] + + return ( + + + + + Same Claude Code with visual diffs, live app preview, parallel + sessions, and more. + + + onChange(value_0 as 'accept' | 'exit')} />; - $[9] = onChange; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t5 || $[12] !== t7) { - t8 = {t5}{t7}; - $[11] = t5; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; + channels: ChannelEntry[] + onAccept(): void } -function _temp2(c) { - return c.kind === "plugin" ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`; -} -function _temp() { - gracefulShutdownSync(0); + +export function DevChannelsDialog({ + channels, + onAccept, +}: Props): React.ReactNode { + function onChange(value: 'accept' | 'exit') { + switch (value) { + case 'accept': + onAccept() + break + case 'exit': + gracefulShutdownSync(1) + break + } + } + + const handleEscape = useCallback(() => { + gracefulShutdownSync(0) + }, []) + + return ( + + + + --dangerously-load-development-channels is for local channel + development only. Do not use this option to run channels you have + downloaded off the internet. + + Please use --channels to run a list of approved channels. + + Channels:{' '} + {channels + .map(c => + c.kind === 'plugin' + ? `plugin:${c.name}@${c.marketplace}` + : `server:${c.name}`, + ) + .join(', ')} + + + + ; - $[16] = handleSelect; - $[17] = t14; - } else { - t14 = $[17]; - } - return t14; + model: string + onDone: (selection: EffortCalloutSelection) => void } -function _temp() { - markV2Dismissed(); + +const AUTO_DISMISS_MS = 30_000 + +export function EffortCallout({ model, onDone }: Props): React.ReactNode { + const defaultEffortConfig = getOpusDefaultEffortConfig() + // Latest-ref pattern — write via effect so React Compiler can memoize. + const onDoneRef = useRef(onDone) + useEffect(() => { + onDoneRef.current = onDone + }) + + const handleCancel = useCallback((): void => { + onDoneRef.current('dismiss') + }, []) + + // Permanently dismiss on mount so it only shows once + useEffect(() => { + markV2Dismissed() + }, []) + + // 30-second auto-dismiss timer + useEffect(() => { + const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS) + return () => clearTimeout(timeoutId) + }, [handleCancel]) + + const defaultEffort = getDefaultEffortForModel(model) + const defaultLevel = defaultEffort + ? convertEffortValueToLevel(defaultEffort) + : 'high' + + const handleSelect = useCallback( + (value: EffortLevel): void => { + const effortLevel = value === defaultLevel ? undefined : value + updateSettingsForSource('userSettings', { + effortLevel: toPersistableEffort(effortLevel), + }) + onDoneRef.current(value) + }, + [defaultLevel], + ) + + const options: OptionWithDescription[] = [ + { + label: , + value: 'medium', + }, + { label: , value: 'high' }, + { label: , value: 'low' }, + ] + + return ( + + + + {defaultEffortConfig.dialogDescription} + + + + low {'·'}{' '} + medium {'·'}{' '} + high + + + : + isActive: showFilenameInput, + }) + + return ( + + {!showFilenameInput ? ( + }; - $[5] = commands.length; - $[6] = emptyMessage; - $[7] = focusHeader; - $[8] = headerFocused; - $[9] = onCancel; - $[10] = options; - $[11] = title; - $[12] = visibleCount; - $[13] = t2; - } else { - t2 = $[13]; - } - return t2; + commands: Command[] + maxHeight: number + columns: number + title: string + onCancel: () => void + emptyMessage?: string } -function _temp(a, b) { - return a.name.localeCompare(b.name); + +export function Commands({ + commands, + maxHeight, + columns, + title, + onCancel, + emptyMessage, +}: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + const maxWidth = Math.max(1, columns - 10) + const visibleCount = Math.max(1, Math.floor((maxHeight - 10) / 2)) + + const options = useMemo(() => { + // Custom commands can appear more than once (e.g. same name at user and + // project scope). Dedupe by name to avoid React key collisions in Select. + const seen = new Set() + return commands + .filter(cmd => { + if (seen.has(cmd.name)) return false + seen.add(cmd.name) + return true + }) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(cmd => ({ + label: `/${cmd.name}`, + value: cmd.name, + description: truncate(formatDescriptionWithSource(cmd), maxWidth, true), + })) + }, [commands, maxWidth]) + + return ( + + {commands.length === 0 && emptyMessage ? ( + {emptyMessage} + ) : ( + <> + {title} + + ; - $[3] = handleSelect; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = You can also configure this in /config or with the --ide flag; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== onComplete || $[7] !== t3) { - t5 = {t3}{t4}; - $[6] = onComplete; - $[7] = t3; - $[8] = t5; - } else { - t5 = $[8]; - } - return t5; + hasIdeAutoConnectDialogBeenShown: true, + })) + + onComplete() + }, + [onComplete], + ) + + const options = [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ] + + return ( + + ; - $[5] = handleSelect; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== handleCancel || $[8] !== t4) { - t5 = {t4}; - $[7] = handleCancel; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; -} -function _temp(current) { - return { - ...current, - autoConnectIde: false - }; + + onComplete(disableAutoConnect) + }, + [onComplete], + ) + + const handleCancel = useCallback(() => { + onComplete(false) + }, [onComplete]) + + const options = [ + { label: 'No', value: 'no' }, + { label: 'Yes', value: 'yes' }, + ] + + return ( + + onDone(value)} />; - $[10] = onDone; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - if ($[12] !== t3 || $[13] !== t4 || $[14] !== t9) { - t10 = {t5}{t9}; - $[12] = t3; - $[13] = t4; - $[14] = t9; - $[15] = t10; - } else { - t10 = $[15]; - } - return t10; + idleMinutes: number + totalInputTokens: number + onDone: (action: IdleReturnAction) => void +} + +export function IdleReturnDialog({ + idleMinutes, + totalInputTokens, + onDone, +}: Props): React.ReactNode { + const formattedIdle = formatIdleDuration(idleMinutes) + const formattedTokens = formatTokens(totalInputTokens) + + return ( + onDone('dismiss')} + > + + + If this is a new task, clearing context will save usage and be faster. + + + ; - $[12] = handleSelect; - $[13] = onExit; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== onExit || $[16] !== t4 || $[17] !== t7) { - t8 = {t4}{t7}; - $[15] = onExit; - $[16] = t4; - $[17] = t7; - $[18] = t8; - } else { - t8 = $[18]; - } - return t8; + + return ( + + + + The configuration file at {filePath} contains + invalid JSON. + + {errorDescription} + + + Choose an option: + ; - $[7] = handleSelect; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== onExit || $[10] !== t2 || $[11] !== t5) { - t6 = {t2}{t3}{t5}; - $[9] = onExit; - $[10] = t2; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; + + return ( + + + + Files with errors are skipped entirely, not just the invalid settings. + + { - const itemIndex = parseInt(value_0, 10); - const log_13 = displayedLogs[itemIndex]; - if (log_13) { - onSelect(log_13); - } - }} visibleOptionCount={visibleCount} onCancel={onCancel} onFocus={handleFlatOptionsSelectFocus} defaultFocusValue={focusedNode?.id.toString()} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused} onUpFromFirstItem={enterSearchMode} />; - $[202] = agenticSearchState.status; - $[203] = branchFilterEnabled; - $[204] = columns; - $[205] = displayedLogs; - $[206] = expandedGroupSessionIds; - $[207] = flatOptions; - $[208] = focusedLog; - $[209] = focusedNode?.id; - $[210] = handleFlatOptionsSelectFocus; - $[211] = handleRenameSubmit; - $[212] = handleTreeSelectFocus; - $[213] = isAgenticSearchOptionFocused; - $[214] = onCancel; - $[215] = onSelect; - $[216] = renameCursorOffset; - $[217] = renameValue; - $[218] = treeNodes; - $[219] = viewMode; - $[220] = visibleCount; - $[221] = t70; - } else { - t70 = $[221]; + return null } - let t71; - if ($[222] !== agenticSearchState.status || $[223] !== currentBranch || $[224] !== exitState.keyName || $[225] !== exitState.pending || $[226] !== getExpandCollapseHint || $[227] !== hasMultipleWorktrees || $[228] !== isAgenticSearchOptionFocused || $[229] !== isSearching || $[230] !== onToggleAllProjects || $[231] !== showAllProjects || $[232] !== showAllWorktrees || $[233] !== viewMode) { - t71 = {exitState.pending ? Press {exitState.keyName} again to exit : viewMode === "rename" ? : agenticSearchState.status === "searching" ? Searching with Claude… : isAgenticSearchOptionFocused ? : viewMode === "search" ? {isSearching && false ? "Searching\u2026" : "Type to Search"} : {onToggleAllProjects && }{currentBranch && }{hasMultipleWorktrees && }Type to search{getExpandCollapseHint() && {getExpandCollapseHint()}}}; - $[222] = agenticSearchState.status; - $[223] = currentBranch; - $[224] = exitState.keyName; - $[225] = exitState.pending; - $[226] = getExpandCollapseHint; - $[227] = hasMultipleWorktrees; - $[228] = isAgenticSearchOptionFocused; - $[229] = isSearching; - $[230] = onToggleAllProjects; - $[231] = showAllProjects; - $[232] = showAllWorktrees; - $[233] = viewMode; - $[234] = t71; - } else { - t71 = $[234]; - } - let t72; - if ($[235] !== t57 || $[236] !== t60 || $[237] !== t62 || $[238] !== t63 || $[239] !== t65 || $[240] !== t66 || $[241] !== t67 || $[242] !== t68 || $[243] !== t69 || $[244] !== t70 || $[245] !== t71) { - t72 = {t58}{t59}{t60}{t62}{t63}{t64}{t65}{t66}{t67}{t68}{t69}{t70}{t71}; - $[235] = t57; - $[236] = t60; - $[237] = t62; - $[238] = t63; - $[239] = t65; - $[240] = t66; - $[241] = t67; - $[242] = t68; - $[243] = t69; - $[244] = t70; - $[245] = t71; - $[246] = t72; - } else { - t72 = $[246]; + + // Show preview mode if active + if (viewMode === 'preview' && previewLog && isResumeWithRenameEnabled) { + return ( + { + setViewMode('list') + setPreviewLog(null) + }} + onSelect={onSelect} + /> + ) } - return t72; + + return ( + + + + + + + + + {hasTags ? ( + + ) : ( + + + Resume Session + {viewMode === 'list' && displayedLogs.length > visibleCount && ( + + {' '} + ({focusedIndex} of {displayedLogs.length}) + + )} + + + )} + + {filterIndicators.length > 0 && viewMode !== 'search' && ( + + + {filterIndicators} + + + )} + + + + + {/* Agentic search loading state */} + {agenticSearchState.status === 'searching' && ( + + + Searching… + + )} + + {/* Results header when agentic search completed with results */} + {agenticSearchState.status === 'results' && + agenticSearchState.results.length > 0 && ( + + + Claude found these results: + + + )} + + {/* Fallback message when agentic search found no results and deep search also has nothing */} + {agenticSearchState.status === 'results' && + agenticSearchState.results.length === 0 && + filteredLogs.length === 0 && ( + + + No matching sessions found. + + + )} + + {/* Error message when agentic search failed and deep search also has nothing */} + {agenticSearchState.status === 'error' && filteredLogs.length === 0 && ( + + + No matching sessions found. + + + )} + + {/* Agentic search option - first item in list when searching */} + {Boolean(searchQuery.trim()) && + onAgenticSearch && + isAgenticSearchEnabled && + agenticSearchState.status !== 'searching' && + agenticSearchState.status !== 'results' && + agenticSearchState.status !== 'error' && ( + + + + {isAgenticSearchOptionFocused ? figures.pointer : ' '} + + + Search deeply using Claude → + + + + + )} + + {/* Hide session list when agentic search is in progress */} + {agenticSearchState.status === 'searching' ? null : viewMode === + 'rename' && focusedLog ? ( + + Rename session: + + + + + ) : isResumeWithRenameEnabled ? ( + { + onSelect(node.value.log) + }} + onFocus={handleTreeSelectFocus} + onCancel={onCancel} + focusNodeId={focusedNode?.id} + visibleOptionCount={visibleCount} + layout="expanded" + isDisabled={viewMode === 'search' || isAgenticSearchOptionFocused} + hideIndexes={false} + isNodeExpanded={nodeId => { + // Always expand if in search or branch filter mode + if (viewMode === 'search' || branchFilterEnabled) { + return true + } + // Extract sessionId from node ID (format: "group:sessionId") + const sessionId = + typeof nodeId === 'string' && nodeId.startsWith('group:') + ? nodeId.substring(6) + : null + return sessionId ? expandedGroupSessionIds.has(sessionId) : false + }} + onExpand={nodeId => { + const sessionId = + typeof nodeId === 'string' && nodeId.startsWith('group:') + ? nodeId.substring(6) + : null + if (sessionId) { + setExpandedGroupSessionIds(prev => new Set(prev).add(sessionId)) + logEvent('tengu_session_group_expanded', {}) + } + }} + onCollapse={nodeId => { + const sessionId = + typeof nodeId === 'string' && nodeId.startsWith('group:') + ? nodeId.substring(6) + : null + if (sessionId) { + setExpandedGroupSessionIds(prev => { + const newSet = new Set(prev) + newSet.delete(sessionId) + return newSet + }) + } + }} + onUpFromFirstItem={enterSearchMode} + /> + ) : ( + onResponse('no')} /> + onChange(value_0 as 'yes_all' | 'yes' | 'no')} onCancel={() => onChange("no")} />; - $[7] = onChange; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== t2 || $[10] !== t3 || $[11] !== t6) { - t7 = {t4}{t6}; - $[9] = t2; - $[10] = t3; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; + case 'no': { + // Get current disabled servers from settings + const currentSettings = getSettings_DEPRECATED() || {} + const disabledServers = currentSettings.disabledMcpjsonServers || [] + + // Add server if not already disabled + if (!disabledServers.includes(serverName)) { + updateSettingsForSource('localSettings', { + disabledMcpjsonServers: [...disabledServers, serverName], + }) + } + onDone() + break + } + } } - return t7; + + return ( + onChange('no')} + > + + + onChange(value_0 as 'accept' | 'exit')} onCancel={() => onChange("exit")} />; - $[12] = onChange; - $[13] = t16; - } else { - t16 = $[13]; - } - let t17; - if ($[14] !== exitState.keyName || $[15] !== exitState.pending) { - t17 = {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to exit}; - $[14] = exitState.keyName; - $[15] = exitState.pending; - $[16] = t17; - } else { - t17 = $[16]; - } - let t18; - if ($[17] !== T1 || $[18] !== t13 || $[19] !== t16 || $[20] !== t17 || $[21] !== t9) { - t18 = {t9}{t13}{t14}{t16}{t17}; - $[17] = T1; - $[18] = t13; - $[19] = t16; - $[20] = t17; - $[21] = t9; - $[22] = t18; - } else { - t18 = $[22]; - } - let t19; - if ($[23] !== T0 || $[24] !== t18) { - t19 = {t18}; - $[23] = T0; - $[24] = t18; - $[25] = t19; - } else { - t19 = $[25]; - } - return t19; + settings: SettingsJson + onAccept: () => void + onReject: () => void } -function _temp(item, index) { - return · {item}; + +export function ManagedSettingsSecurityDialog({ + settings, + onAccept, + onReject, +}: Props): React.ReactNode { + const dangerous = extractDangerousSettings(settings) + const settingsList = formatDangerousSettingsList(dangerous) + + const exitState = useExitOnCtrlCDWithKeybindings() + + useKeybinding('confirm:no', onReject, { context: 'Confirmation' }) + + function onChange(value: 'accept' | 'exit'): void { + if (value === 'exit') { + onReject() + return + } + onAccept() + } + + return ( + + + + Your organization has configured managed settings that could allow + execution of arbitrary code or interception of your prompts and + responses. + + + + Settings requiring approval: + {settingsList.map((item, index) => ( + + + · + {item} + + + ))} + + + + Only accept if you trust your organization's IT administration + and expect these settings to be configured. + + + setSelectedRestoreOption(value as RestoreOption)} onChange={value_0 => onSelectRestoreOption(value_0 as RestoreOption)} onCancel={() => preselectedMessage ? onClose() : setMessageToRestore(undefined)} />} - {canRestoreCode_0 && + + ) : ( + ; - $[49] = handleFocus; - $[50] = handleSelect; - $[51] = initialFocusValue; - $[52] = initialValue; - $[53] = selectOptions; - $[54] = t20; - $[55] = visibleCount; - $[56] = t21; - } else { - t21 = $[56]; - } - let t22; - if ($[57] !== hiddenCount) { - t22 = hiddenCount > 0 && and {hiddenCount} more…; - $[57] = hiddenCount; - $[58] = t22; - } else { - t22 = $[58]; - } - let t23; - if ($[59] !== t21 || $[60] !== t22) { - t23 = {t21}{t22}; - $[59] = t21; - $[60] = t22; - $[61] = t23; - } else { - t23 = $[61]; - } - let t24; - if ($[62] !== displayEffort || $[63] !== focusedDefaultEffort || $[64] !== focusedModelName || $[65] !== focusedSupportsEffort) { - t24 = {focusedSupportsEffort ? {" "}{capitalize(displayEffort)} effort{displayEffort === focusedDefaultEffort ? " (default)" : ""}{" "}← → to adjust : Effort not supported{focusedModelName ? ` for ${focusedModelName}` : ""}}; - $[62] = displayEffort; - $[63] = focusedDefaultEffort; - $[64] = focusedModelName; - $[65] = focusedSupportsEffort; - $[66] = t24; - } else { - t24 = $[66]; - } - let t25; - if ($[67] !== showFastModeNotice) { - t25 = isFastModeEnabled() ? showFastModeNotice ? Fast mode is ON and available with{" "}{FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other models turn off fast mode. : isFastModeAvailable() && !isFastModeCooldown() ? Use /fast to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only). : null : null; - $[67] = showFastModeNotice; - $[68] = t25; - } else { - t25 = $[68]; - } - let t26; - if ($[69] !== t19 || $[70] !== t23 || $[71] !== t24 || $[72] !== t25) { - t26 = {t19}{t23}{t24}{t25}; - $[69] = t19; - $[70] = t23; - $[71] = t24; - $[72] = t25; - $[73] = t26; - } else { - t26 = $[73]; - } - let t27; - if ($[74] !== exitState || $[75] !== isStandaloneCommand) { - t27 = isStandaloneCommand && {exitState.pending ? <>Press {exitState.keyName} again to exit : }; - $[74] = exitState; - $[75] = isStandaloneCommand; - $[76] = t27; - } else { - t27 = $[76]; - } - let t28; - if ($[77] !== t26 || $[78] !== t27) { - t28 = {t26}{t27}; - $[77] = t26; - $[78] = t27; - $[79] = t28; - } else { - t28 = $[79]; + setAppState(prev => ({ ...prev, effortValue: effortLevel })) + } + + const selectedModel = resolveOptionModel(value) + const selectedEffort = + hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) + ? effort + : undefined + if (value === NO_PREFERENCE) { + onSelect(null, selectedEffort) + return + } + onSelect(value, selectedEffort) } - const content = t28; + + const content = ( + + + + + Select model + + + {headerText ?? + 'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.'} + + {sessionModel && ( + + Currently using {modelDisplayString(sessionModel)} for this + session (set by plan mode). Selecting a model will undo this. + + )} + + + + + { - if (value === 'install') { - // Errors already logged in setupTerminal, just swallow and proceed - void setupTerminal(theme).catch(() => {}).finally(goToNextStep); - } else { - goToNextStep(); - } - }} onCancel={() => goToNextStep()} /> + }; - $[6] = handleStyleSelect; - $[7] = initialStyle; - $[8] = isLoading; - $[9] = styleOptions; - $[10] = t8; - } else { - t8 = $[10]; - } - let t9; - if ($[11] !== onCancel || $[12] !== t5 || $[13] !== t6 || $[14] !== t8) { - t9 = {t8}; - $[11] = onCancel; - $[12] = t5; - $[13] = t6; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - return t9; + initialStyle: OutputStyle + onComplete: (style: OutputStyle) => void + onCancel: () => void + isStandaloneCommand?: boolean +} + +export function OutputStylePicker({ + initialStyle, + onComplete, + onCancel, + isStandaloneCommand, +}: OutputStylePickerProps): React.ReactNode { + const [styleOptions, setStyleOptions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + // Load all output styles including custom ones + getAllOutputStyles(getCwd()) + .then(allStyles => { + const options = mapConfigsToOptions(allStyles) + setStyleOptions(options) + setIsLoading(false) + }) + .catch(() => { + // On error, fall back to built-in styles only + const builtInOptions = mapConfigsToOptions(OUTPUT_STYLE_CONFIG) + setStyleOptions(builtInOptions) + setIsLoading(false) + }) + }, []) + + const handleStyleSelect = useCallback( + (style: string) => { + const outputStyle = style as OutputStyle + onComplete(outputStyle) + }, + [onComplete], + ) + + return ( + + + + + This changes how Claude Code communicates with you + + + {isLoading ? ( + Loading output styles… + ) : ( + + onSelect("cancel")} layout="compact-vertical" />; - $[8] = environments; - $[9] = loadingState; - $[10] = onSelect; - $[11] = selectedEnvironment.environment_id; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = ; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== onCancel || $[15] !== subtitle || $[16] !== t5) { - t7 = {t4}{t5}{t6}; - $[14] = onCancel; - $[15] = subtitle; - $[16] = t5; - $[17] = t7; - } else { - t7 = $[17]; - } - return t7; + +function SingleEnvironmentContent({ + environment, + onDone, +}: { + environment: EnvironmentResource + onDone: () => void +}): React.ReactNode { + // Handle Enter to continue + useKeybinding('confirm:yes', onDone, { context: 'Confirmation' }) + + return ( + + + + ) } -function _temp(env) { - return { - label: {env.name} ({env.environment_id}), - value: env.environment_id - }; + +function MultipleEnvironmentsContent({ + environments, + selectedEnvironment, + selectedEnvironmentSource, + loadingState, + onSelect, + onCancel, +}: { + environments: EnvironmentResource[] + selectedEnvironment: EnvironmentResource + selectedEnvironmentSource: SettingSource | null + loadingState: LoadingState + onSelect: (value: string) => void + onCancel: () => void +}): React.ReactNode { + const sourceSuffix = + selectedEnvironmentSource && selectedEnvironmentSource !== 'localSettings' + ? ` (from ${getSettingSourceName(selectedEnvironmentSource)} settings)` + : '' + + const subtitle = ( + + Currently using: {selectedEnvironment.name} + {sourceSuffix} + + ) + + return ( + + {SETUP_HINT} + {loadingState === 'updating' ? ( + + ) : ( + if (key.ctrl && input === 'c') { - onCancel(); - return; + onCancel() + return } // Handle retry in error state with 'ctrl+r' if (key.ctrl && input === 'r' && loadErrorType) { - handleRetry(); - return; + handleRetry() + return } // Handle enter key for error states to allow continuation with regular teleport if (loadErrorType !== null && key.return) { - onCancel(); // This will continue with regular teleport flow - return; + onCancel() // This will continue with regular teleport flow + return } - }); + }) + const handleErrorComplete = useCallback(() => { - setHasCompletedTeleportErrorFlow(true); - void loadSessions(); - }, [setHasCompletedTeleportErrorFlow, loadSessions]); + setHasCompletedTeleportErrorFlow(true) + void loadSessions() + }, [setHasCompletedTeleportErrorFlow, loadSessions]) // Show error dialog if needed if (!hasCompletedTeleportErrorFlow) { - return ; + return } + if (loading) { - return + return ( + Loading Claude Code sessions… @@ -124,10 +141,13 @@ export function ResumeTask({ {retrying ? 'Retrying…' : 'Fetching your Claude Code sessions…'} - ; + + ) } + if (loadErrorType) { - return + return ( + Error loading Claude Code sessions @@ -138,10 +158,13 @@ export function ResumeTask({ Press Ctrl+R to retry · Press{' '} {escKey} to cancel - ; + + ) } + if (sessions.length === 0) { - return + return ( + No Claude Code sessions found {currentRepo && for {currentRepo}} @@ -151,42 +174,53 @@ export function ResumeTask({ Press {escKey} to cancel - ; + + ) } - const sessionMetadata = sessions.map(session_0 => ({ - ...session_0, - timeString: formatRelativeTime(new Date(session_0.updated_at)) - })); - const maxTimeStringLength = Math.max(UPDATED_STRING.length, ...sessionMetadata.map(meta => meta.timeString.length)); - const options = sessionMetadata.map(({ - timeString, - title, - id - }) => { - const paddedTime = timeString.padEnd(maxTimeStringLength, ' '); + + const sessionMetadata = sessions.map(session => ({ + ...session, + timeString: formatRelativeTime(new Date(session.updated_at)), + })) + const maxTimeStringLength = Math.max( + UPDATED_STRING.length, + ...sessionMetadata.map(meta => meta.timeString.length), + ) + + const options = sessionMetadata.map(({ timeString, title, id }) => { + const paddedTime = timeString.padEnd(maxTimeStringLength, ' ') // TODO: include branch name when API returns it return { label: `${paddedTime} ${title}`, - value: id - }; - }); + value: id, + } + }) // Adjust layout for embedded vs full-screen rendering // Overhead: padding (2) + title (1) + marginY (2) + header (1) + footer (1) = 7 - const layoutOverhead = 7; - const maxVisibleOptions = Math.max(1, isEmbedded ? Math.min(sessions.length, 5, rows - 6 - layoutOverhead) : Math.min(sessions.length, rows - 1 - layoutOverhead)); - const maxHeight = maxVisibleOptions + layoutOverhead; + const layoutOverhead = 7 + const maxVisibleOptions = Math.max( + 1, + isEmbedded + ? Math.min(sessions.length, 5, rows - 6 - layoutOverhead) + : Math.min(sessions.length, rows - 1 - layoutOverhead), + ) + const maxHeight = maxVisibleOptions + layoutOverhead // Show scroll position in title when list needs scrolling - const showScrollPosition = sessions.length > maxVisibleOptions; - return + const showScrollPosition = sessions.length > maxVisibleOptions + + return ( + Select a session to resume - {showScrollPosition && + {showScrollPosition && ( + {' '} ({focusedIndex} of {sessions.length}) - } + + )} {currentRepo && ({currentRepo})}: @@ -197,71 +231,117 @@ export function ResumeTask({ {'Session Title'} - { + const session = sessions.find(s => s.id === value) + if (session) { + onSelect(session) + } + }} + onFocus={value => { + const index = options.findIndex(o => o.value === value) + if (index >= 0) { + setFocusedIndex(index + 1) + } + }} + /> - + - ; + + ) } /** * Determines the type of error based on the error message */ function determineErrorType(errorMessage: string): LoadErrorType { - const message = errorMessage.toLowerCase(); - if (message.includes('fetch') || message.includes('network') || message.includes('timeout')) { - return 'network'; + const message = errorMessage.toLowerCase() + + if ( + message.includes('fetch') || + message.includes('network') || + message.includes('timeout') + ) { + return 'network' } - if (message.includes('auth') || message.includes('token') || message.includes('permission') || message.includes('oauth') || message.includes('not authenticated') || message.includes('/login') || message.includes('console account') || message.includes('403')) { - return 'auth'; + + if ( + message.includes('auth') || + message.includes('token') || + message.includes('permission') || + message.includes('oauth') || + message.includes('not authenticated') || + message.includes('/login') || + message.includes('console account') || + message.includes('403') + ) { + return 'auth' } - if (message.includes('api') || message.includes('rate limit') || message.includes('500') || message.includes('529')) { - return 'api'; + + if ( + message.includes('api') || + message.includes('rate limit') || + message.includes('500') || + message.includes('529') + ) { + return 'api' } - return 'other'; + + return 'other' } /** * Renders error-specific troubleshooting guidance */ -function renderErrorSpecificGuidance(errorType: LoadErrorType): React.ReactNode { +function renderErrorSpecificGuidance( + errorType: LoadErrorType, +): React.ReactNode { switch (errorType) { case 'network': - return + return ( + Check your internet connection - ; + + ) + case 'auth': - return + return ( + Teleport requires a Claude account Run /login and select "Claude account with subscription" - ; + + ) + case 'api': - return + return ( + Sorry, Claude encountered an error - ; + + ) + case 'other': - return + return ( + Sorry, Claude Code encountered an error - ; + + ) } } diff --git a/src/components/SandboxViolationExpandedView.tsx b/src/components/SandboxViolationExpandedView.tsx index 5762ceaed..4b8bbbd7a 100644 --- a/src/components/SandboxViolationExpandedView.tsx +++ b/src/components/SandboxViolationExpandedView.tsx @@ -1,98 +1,68 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { type ReactNode, useEffect, useState } from 'react'; -import { Box, Text } from '../ink.js'; -import type { SandboxViolationEvent } from '../utils/sandbox/sandbox-adapter.js'; -import { SandboxManager } from '../utils/sandbox/sandbox-adapter.js'; +import * as React from 'react' +import { type ReactNode, useEffect, useState } from 'react' +import { Box, Text } from '../ink.js' +import type { SandboxViolationEvent } from '../utils/sandbox/sandbox-adapter.js' +import { SandboxManager } from '../utils/sandbox/sandbox-adapter.js' /** * Format a timestamp as "h:mm:ssa" (e.g., "1:30:45pm"). * Replaces date-fns format() to avoid pulling in a 39MB dependency for one call. */ function formatTime(date: Date): string { - const h = date.getHours() % 12 || 12; - const m = String(date.getMinutes()).padStart(2, '0'); - const s = String(date.getSeconds()).padStart(2, '0'); - const ampm = date.getHours() < 12 ? 'am' : 'pm'; - return `${h}:${m}:${s}${ampm}`; + const h = date.getHours() % 12 || 12 + const m = String(date.getMinutes()).padStart(2, '0') + const s = String(date.getSeconds()).padStart(2, '0') + const ampm = date.getHours() < 12 ? 'am' : 'pm' + return `${h}:${m}:${s}${ampm}` } -import { getPlatform } from 'src/utils/platform.js'; -export function SandboxViolationExpandedView() { - const $ = _c(15); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = []; - $[0] = t0; - } else { - t0 = $[0]; - } - const [violations, setViolations] = useState(t0); - const [totalCount, setTotalCount] = useState(0); - let t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - const store = SandboxManager.getSandboxViolationStore(); - const unsubscribe = store.subscribe(allViolations => { - setViolations(allViolations.slice(-10)); - setTotalCount(store.getTotalCount()); - }); - return unsubscribe; - }; - t2 = []; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - if (!SandboxManager.isSandboxingEnabled() || getPlatform() === "linux") { - return null; + +import { getPlatform } from 'src/utils/platform.js' + +export function SandboxViolationExpandedView(): ReactNode { + const [violations, setViolations] = useState([]) + const [totalCount, setTotalCount] = useState(0) + + useEffect(() => { + // This is harmless if sandboxing is not enabled + const store = SandboxManager.getSandboxViolationStore() + const unsubscribe = store.subscribe( + (allViolations: SandboxViolationEvent[]) => { + setViolations(allViolations.slice(-10)) + setTotalCount(store.getTotalCount()) + }, + ) + return unsubscribe + }, []) + + if (!SandboxManager.isSandboxingEnabled() || getPlatform() === 'linux') { + return null } + if (totalCount === 0) { - return null; + return null } - const t3 = totalCount === 1 ? "operation" : "operations"; - let t4; - if ($[3] !== t3 || $[4] !== totalCount) { - t4 = ⧈ Sandbox blocked {totalCount} total{" "}{t3}; - $[3] = t3; - $[4] = totalCount; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== violations) { - t5 = violations.map(_temp); - $[6] = violations; - $[7] = t5; - } else { - t5 = $[7]; - } - const t6 = Math.min(10, violations.length); - let t7; - if ($[8] !== t6 || $[9] !== totalCount) { - t7 = … showing last {t6} of {totalCount}; - $[8] = t6; - $[9] = totalCount; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t4 || $[12] !== t5 || $[13] !== t7) { - t8 = {t4}{t5}{t7}; - $[11] = t4; - $[12] = t5; - $[13] = t7; - $[14] = t8; - } else { - t8 = $[14]; - } - return t8; -} -function _temp(v, i) { - return {formatTime(v.timestamp)}{v.command ? ` ${v.command}:` : ""} {v.line}; + + return ( + + + + ⧈ Sandbox blocked {totalCount} total{' '} + {totalCount === 1 ? 'operation' : 'operations'} + + + {violations.map((v, i) => ( + + + {formatTime(v.timestamp)} + {v.command ? ` ${v.command}:` : ''} {v.line} + + + ))} + + + … showing last {Math.min(10, violations.length)} of {totalCount} + + + + ) } diff --git a/src/components/ScrollKeybindingHandler.tsx b/src/components/ScrollKeybindingHandler.tsx index 16725fa0d..e51787f9f 100644 --- a/src/components/ScrollKeybindingHandler.tsx +++ b/src/components/ScrollKeybindingHandler.tsx @@ -1,29 +1,33 @@ -import React, { type RefObject, useEffect, useRef } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { useCopyOnSelect, useSelectionBgColor } from '../hooks/useCopyOnSelect.js'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import { useSelection } from '../ink/hooks/use-selection.js'; -import type { FocusMove, SelectionState } from '../ink/selection.js'; -import { isXtermJs } from '../ink/terminal.js'; -import { getClipboardPath } from '../ink/termio/osc.js'; +import React, { type RefObject, useEffect, useRef } from 'react' +import { useNotifications } from '../context/notifications.js' +import { + useCopyOnSelect, + useSelectionBgColor, +} from '../hooks/useCopyOnSelect.js' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import { useSelection } from '../ink/hooks/use-selection.js' +import type { FocusMove, SelectionState } from '../ink/selection.js' +import { isXtermJs } from '../ink/terminal.js' +import { getClipboardPath } from '../ink/termio/osc.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- Esc needs conditional propagation based on selection state -import { type Key, useInput } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { logForDebugging } from '../utils/debug.js'; +import { type Key, useInput } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { logForDebugging } from '../utils/debug.js' + type Props = { - scrollRef: RefObject; - isActive: boolean; + scrollRef: RefObject + isActive: boolean /** Called after every scroll action with the resulting sticky state and * the handle (for reading scrollTop/scrollHeight post-scroll). */ - onScroll?: (sticky: boolean, handle: ScrollBoxHandle) => void; + onScroll?: (sticky: boolean, handle: ScrollBoxHandle) => void /** Enables modal pager keys (g/G, ctrl+u/d/b/f). Only safe when there * is no text input competing for those characters — i.e. transcript * mode. Defaults to false. When true, G works regardless of editorMode * and sticky state; ctrl+u/d/b/f don't conflict with kill-line/exit/ * task:background/kill-agents (none are mounted, or they mount after * this component so stopImmediatePropagation wins). */ - isModal?: boolean; -}; + isModal?: boolean +} // Terminals send one SGR wheel event per intended row (verified in Ghostty // src/Surface.zig: `for (0..@abs(y.delta)) |_| { mouseReport(.four, ...) }`). @@ -44,9 +48,9 @@ type Props = { // iTerm2 "faster scroll" similar) — base=1 is correct there. Others send 1 // event/notch — users on those can set CLAUDE_CODE_SCROLL_SPEED=3 to match // vim/nvim/opencode app-side defaults. We can't detect which, so knob it. -const WHEEL_ACCEL_WINDOW_MS = 40; -const WHEEL_ACCEL_STEP = 0.3; -const WHEEL_ACCEL_MAX = 6; +const WHEEL_ACCEL_WINDOW_MS = 40 +const WHEEL_ACCEL_STEP = 0.3 +const WHEEL_ACCEL_MAX = 6 // Encoder bounce debounce + wheel-mode decay curve. Worn/cheap optical // encoders emit spurious reverse-direction ticks during fast spins — measured @@ -62,24 +66,24 @@ const WHEEL_ACCEL_MAX = 6; // threshold needed, large gaps just have m≈0 → mult→1. Wheel mode is STICKY: // once a bounce confirms it's a mouse, the decay curve applies until an idle // gap or trackpad-flick-burst signals a possible device switch. -const WHEEL_BOUNCE_GAP_MAX_MS = 200; // flip-back must arrive within this +const WHEEL_BOUNCE_GAP_MAX_MS = 200 // flip-back must arrive within this // Mouse is ~9 events/sec vs VS Code's ~30 — STEP is 3× xterm.js's 5 to // compensate. At gap=100ms (m≈0.63): one click gives 1+15*0.63≈10.5. -const WHEEL_MODE_STEP = 15; -const WHEEL_MODE_CAP = 15; +const WHEEL_MODE_STEP = 15 +const WHEEL_MODE_CAP = 15 // Max mult growth per event. Without this, the +STEP*m term jumps mult // from 1→10 in one event when wheelMode engages mid-scroll (bounce // detected after N events in trackpad mode at mult=1). User sees scroll // suddenly go 10× faster. Cap=3 gives 1→4→7→10→13→15 over ~0.5s at // 9 events/sec — smooth ramp instead of a jump. Decay is unaffected // (target1500ms OR a * trackpad-signature burst (see burstCount). State lives in a useRef so * it persists across device switches; the disengages handle mouse→trackpad. */ - wheelMode: boolean; + wheelMode: boolean /** Consecutive <5ms events. Trackpad flick produces 100+ at <5ms; mouse * produces ≤3 (verified in /tmp/wheel-tune.txt). 5+ in a row → trackpad * signature → disengage wheel mode so device-switch doesn't leak mouse * accel to trackpad. */ - burstCount: number; -}; + burstCount: number +} /** Compute rows for one wheel event, mutating accel state. Returns 0 when * a direction flip is deferred for bounce detection — call sites no-op on * step=0 (scrollBy(0) is a no-op, onScroll(false) is idempotent). Exported * for tests. */ -export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: number): number { +export function computeWheelStep( + state: WheelAccelState, + dir: 1 | -1, + now: number, +): number { if (!state.xtermJs) { // Device-switch guard ①: idle disengage. Runs BEFORE pendingFlip resolve // so a pending bounce (28% of last-mouse-events) doesn't bypass it via // the real-reversal early return. state.time is either the last committed // event OR the deferred flip — both count as "last activity". if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) { - state.wheelMode = false; - state.burstCount = 0; - state.mult = state.base; + state.wheelMode = false + state.burstCount = 0 + state.mult = state.base } // Resolve any deferred flip BEFORE touching state.time/dir — we need the // pre-flip state.dir to distinguish bounce (flip-back) from real reversal // (flip persisted), and state.time (= bounce timestamp) for the gap check. if (state.pendingFlip) { - state.pendingFlip = false; + state.pendingFlip = false if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) { // Real reversal: new dir persisted, OR flip-back arrived too late. // Commit. The deferred event's 1 row is lost (acceptable latency). - state.dir = dir; - state.time = now; - state.mult = state.base; - return Math.floor(state.mult); + state.dir = dir + state.time = now + state.mult = state.base + return Math.floor(state.mult) } // Bounce confirmed: flipped back to original dir within the window. // state.dir/mult unchanged from pre-bounce. state.time was advanced to // the bounce below, so gap here = flip-back interval — reflects the // user's actual click cadence (bounce IS a physical click, just noisy). - state.wheelMode = true; + state.wheelMode = true } - const gap = now - state.time; + + const gap = now - state.time if (dir !== state.dir && state.dir !== 0) { // Flip. Defer — next event decides bounce vs. real reversal. Advance // time (but NOT dir/mult): if this turns out to be a bounce, the // confirm event's gap will be the flip-back interval, which reflects // the user's actual click rate. The bounce IS a physical wheel click, // just misread by the encoder — it should count toward cadence. - state.pendingFlip = true; - state.time = now; - return 0; + state.pendingFlip = true + state.time = now + return 0 } - state.dir = dir; - state.time = now; + state.dir = dir + state.time = now // ─── MOUSE (wheel mode, sticky until device-switch signal) ─── if (state.wheelMode) { @@ -229,14 +247,14 @@ export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: numbe // Device-switch guard ②: trackpad flick produces 100+ events at <5ms // (measured); mouse produces ≤3. 5+ consecutive → trackpad flick. if (++state.burstCount >= 5) { - state.wheelMode = false; - state.burstCount = 0; - state.mult = state.base; + state.wheelMode = false + state.burstCount = 0 + state.mult = state.base } else { - return 1; + return 1 } } else { - state.burstCount = 0; + state.burstCount = 0 } } // Re-check: may have disengaged above. @@ -245,11 +263,11 @@ export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: numbe // the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac — // rounding loss is minor at high mult, and frac persisting across idle // was causing off-by-one on the first click back. - const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS); - const cap = Math.max(WHEEL_MODE_CAP, state.base * 2); - const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m; - state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP); - return Math.floor(state.mult); + const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) + const cap = Math.max(WHEEL_MODE_CAP, state.base * 2) + const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m + state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP) + return Math.floor(state.mult) } // ─── TRACKPAD / HI-RES (native, non-wheel-mode) ─── @@ -257,43 +275,44 @@ export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: numbe // Trackpad flick delivers 200+ events at <20ms gaps → rails to cap 6. // Trackpad slow swipe at 40-400ms gaps → resets every event → 1 row each. if (gap > WHEEL_ACCEL_WINDOW_MS) { - state.mult = state.base; + state.mult = state.base } else { - const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2); - state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP); + const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2) + state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP) } - return Math.floor(state.mult); + return Math.floor(state.mult) } // ─── VSCODE (xterm.js, browser wheel events) ─── // Browser wheel events — no encoder bounce, no SGR bursts. Decay curve // unchanged from the original tuning. Same formula shape as wheel mode // above (keep in sync) but STEP=5 not 15 — higher event rate here. - const gap = now - state.time; - const sameDir = dir === state.dir; - state.time = now; - state.dir = dir; + const gap = now - state.time + const sameDir = dir === state.dir + state.time = now + state.dir = dir // xterm.js path. Debug log shows two patterns: (a) 20-50ms gaps during // sustained scroll (~30 Hz), (b) <5ms same-batch bursts on flicks. For // (b) give 1 row/event — the burst count IS the acceleration, same as // native. For (a) the decay curve gives 3-5 rows. For sparse events // (100ms+, slow deliberate scroll) the curve gives 1-3. - if (sameDir && gap < WHEEL_BURST_MS) return 1; + if (sameDir && gap < WHEEL_BURST_MS) return 1 if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) { // Direction reversal or long idle: start at 2 (not 1) so the first // click after a pause moves a visible amount. Without this, idle- // then-resume in the same direction decays to mult≈1 (1 row). - state.mult = 2; - state.frac = 0; + state.mult = 2 + state.frac = 0 } else { - const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS); - const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST; - state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m); + const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) + const cap = + gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST + state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m) } - const total = state.mult + state.frac; - const rows = Math.floor(total); - state.frac = total - rows; - return rows; + const total = state.mult + state.frac + const rows = Math.floor(total) + state.frac = total - rows + return rows } /** Read CLAUDE_CODE_SCROLL_SPEED, default 1, clamp (0, 20]. @@ -303,10 +322,10 @@ export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: numbe * detect which kind of terminal we're in, hence the knob. Called lazily * from initAndLogWheelAccel so globalSettings.env has loaded. */ export function readScrollSpeedBase(): number { - const raw = process.env.CLAUDE_CODE_SCROLL_SPEED; - if (!raw) return 1; - const n = parseFloat(raw); - return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20); + const raw = process.env.CLAUDE_CODE_SCROLL_SPEED + if (!raw) return 1 + const n = parseFloat(raw) + return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20) } /** Initial wheel accel state. xtermJs=true selects the decay curve. @@ -321,8 +340,8 @@ export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { base, pendingFlip: false, wheelMode: false, - burstCount: 0 - }; + burstCount: 0, + } } // Lazy-init helper. isXtermJs() combines the TERM_PROGRAM env check + async @@ -332,23 +351,25 @@ export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { // The renderer also calls isXtermJsHost() (in render-node-to-output) to // select the drain algorithm — no state to pass through. function initAndLogWheelAccel(): WheelAccelState { - const xtermJs = isXtermJs(); - const base = readScrollSpeedBase(); - logForDebugging(`wheel accel: ${xtermJs ? 'decay (xterm.js)' : 'window (native)'} · base=${base} · TERM_PROGRAM=${process.env.TERM_PROGRAM ?? 'unset'}`); - return initWheelAccel(xtermJs, base); + const xtermJs = isXtermJs() + const base = readScrollSpeedBase() + logForDebugging( + `wheel accel: ${xtermJs ? 'decay (xterm.js)' : 'window (native)'} · base=${base} · TERM_PROGRAM=${process.env.TERM_PROGRAM ?? 'unset'}`, + ) + return initWheelAccel(xtermJs, base) } // Drag-to-scroll: when dragging past the viewport edge, scroll by this many // rows every AUTOSCROLL_INTERVAL_MS. Mode 1002 mouse tracking only fires on // cell change, so a timer is needed to continue scrolling while stationary. -const AUTOSCROLL_LINES = 2; -const AUTOSCROLL_INTERVAL_MS = 50; +const AUTOSCROLL_LINES = 2 +const AUTOSCROLL_INTERVAL_MS = 50 // Hard cap on consecutive auto-scroll ticks. If the release event is lost // (mouse released outside terminal window — some emulators don't capture the // pointer and drop the release), isDragging stays true and the timer would // run until a scroll boundary. Cap bounds the damage; any new drag motion // event restarts the count via check()→start(). -const AUTOSCROLL_MAX_TICKS = 200; // 10s @ 50ms +const AUTOSCROLL_MAX_TICKS = 200 // 10s @ 50ms /** * Keyboard scroll navigation for the fullscreen layout's message scroll box. @@ -360,45 +381,45 @@ export function ScrollKeybindingHandler({ scrollRef, isActive, onScroll, - isModal = false + isModal = false, }: Props): React.ReactNode { - const selection = useSelection(); - const { - addNotification - } = useNotifications(); + const selection = useSelection() + const { addNotification } = useNotifications() // Lazy-inited on first wheel event so the XTVERSION probe (fired at // raw-mode-enable time) has resolved by then — initializing in useRef() // would read getWheelBase() before the probe reply arrives over SSH. - const wheelAccel = useRef(null); + const wheelAccel = useRef(null) + function showCopiedToast(text: string): void { // getClipboardPath reads env synchronously — predicts what setClipboard // did (native pbcopy / tmux load-buffer / raw OSC 52) so we can tell // the user whether paste will Just Work or needs prefix+]. - const path = getClipboardPath(); - const n = text.length; - let msg: string; + const path = getClipboardPath() + const n = text.length + let msg: string switch (path) { case 'native': - msg = `copied ${n} chars to clipboard`; - break; + msg = `copied ${n} chars to clipboard` + break case 'tmux-buffer': - msg = `copied ${n} chars to tmux buffer · paste with prefix + ]`; - break; + msg = `copied ${n} chars to tmux buffer · paste with prefix + ]` + break case 'osc52': - msg = `sent ${n} chars via OSC 52 · check terminal clipboard settings if paste fails`; - break; + msg = `sent ${n} chars via OSC 52 · check terminal clipboard settings if paste fails` + break } addNotification({ key: 'selection-copied', text: msg, color: 'suggestion', priority: 'immediate', - timeoutMs: path === 'native' ? 2000 : 4000 - }); + timeoutMs: path === 'native' ? 2000 : 4000, + }) } + function copyAndToast(): void { - const text_0 = selection.copySelection(); - if (text_0) showCopiedToast(text_0); + const text = selection.copySelection() + if (text) showCopiedToast(text) } // Translate selection to track a keyboard page jump. Selection coords are @@ -411,148 +432,152 @@ export function ScrollKeybindingHandler({ // still clears — its async pendingScrollDelta drain means the actual // delta isn't known synchronously (follow-up). function translateSelectionForJump(s: ScrollBoxHandle, delta: number): void { - const sel = selection.getState(); - if (!sel?.anchor || !sel.focus) return; - const top = s.getViewportTop(); - const bottom = top + s.getViewportHeight() - 1; + const sel = selection.getState() + if (!sel?.anchor || !sel.focus) return + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 // Only translate if the selection is ON scrollbox content. Selections // in the footer/prompt/StickyPromptHeader are on static text — the // scroll doesn't move what's under them. Same guard as ink.tsx's // auto-follow translate (commit 36a8d154). - if (sel.anchor.row < top || sel.anchor.row > bottom) return; + if (sel.anchor.row < top || sel.anchor.row > bottom) return // Cross-boundary: anchor in scrollbox, focus in footer/header. Mirror // ink.tsx's Flag-3 guard — fall through without shifting OR capturing. // The static endpoint pins the selection; shifting would teleport it // into scrollbox content. - if (sel.focus.row < top || sel.focus.row > bottom) return; - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); - const cur = s.getScrollTop() + s.getPendingDelta(); + if (sel.focus.row < top || sel.focus.row > bottom) return + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + const cur = s.getScrollTop() + s.getPendingDelta() // Actual scroll distance after boundary clamp. jumpBy may call // scrollToBottom when target >= max but the view can't move past max, // so the selection shift is bounded here. - const actual = Math.max(0, Math.min(max, cur + delta)) - cur; - if (actual === 0) return; + const actual = Math.max(0, Math.min(max, cur + delta)) - cur + if (actual === 0) return if (actual > 0) { // Scrolling down: content moves up. Rows at the TOP leave viewport. // Anchor+focus shift -actual so they track the content that moved up. - selection.captureScrolledRows(top, top + actual - 1, 'above'); - selection.shiftSelection(-actual, top, bottom); + selection.captureScrolledRows(top, top + actual - 1, 'above') + selection.shiftSelection(-actual, top, bottom) } else { // Scrolling up: content moves down. Rows at the BOTTOM leave viewport. - const a = -actual; - selection.captureScrolledRows(bottom - a + 1, bottom, 'below'); - selection.shiftSelection(a, top, bottom); + const a = -actual + selection.captureScrolledRows(bottom - a + 1, bottom, 'below') + selection.shiftSelection(a, top, bottom) } } - useKeybindings({ - 'scroll:pageUp': () => { - const s_0 = scrollRef.current; - if (!s_0) return; - const d = -Math.max(1, Math.floor(s_0.getViewportHeight() / 2)); - translateSelectionForJump(s_0, d); - const sticky = jumpBy(s_0, d); - onScroll?.(sticky, s_0); - }, - 'scroll:pageDown': () => { - const s_1 = scrollRef.current; - if (!s_1) return; - const d_0 = Math.max(1, Math.floor(s_1.getViewportHeight() / 2)); - translateSelectionForJump(s_1, d_0); - const sticky_0 = jumpBy(s_1, d_0); - onScroll?.(sticky_0, s_1); - }, - 'scroll:lineUp': () => { - // Wheel: scrollBy accumulates into pendingScrollDelta, drained async - // by the renderer. captureScrolledRows can't read the outgoing rows - // before they leave (drain is non-deterministic). Clear for now. - selection.clearSelection(); - const s_2 = scrollRef.current; - // Return false (not consumed) when the ScrollBox content fits — - // scroll would be a no-op. Lets a child component's handler take - // the wheel event instead (e.g. Settings Config's list navigation - // inside the centered Modal, where the paginated slice always fits). - if (!s_2 || s_2.getScrollHeight() <= s_2.getViewportHeight()) return false; - wheelAccel.current ??= initAndLogWheelAccel(); - scrollUp(s_2, computeWheelStep(wheelAccel.current, -1, performance.now())); - onScroll?.(false, s_2); - }, - 'scroll:lineDown': () => { - selection.clearSelection(); - const s_3 = scrollRef.current; - if (!s_3 || s_3.getScrollHeight() <= s_3.getViewportHeight()) return false; - wheelAccel.current ??= initAndLogWheelAccel(); - const step = computeWheelStep(wheelAccel.current, 1, performance.now()); - const reachedBottom = scrollDown(s_3, step); - onScroll?.(reachedBottom, s_3); - }, - 'scroll:top': () => { - const s_4 = scrollRef.current; - if (!s_4) return; - translateSelectionForJump(s_4, -(s_4.getScrollTop() + s_4.getPendingDelta())); - s_4.scrollTo(0); - onScroll?.(false, s_4); - }, - 'scroll:bottom': () => { - const s_5 = scrollRef.current; - if (!s_5) return; - const max_0 = Math.max(0, s_5.getScrollHeight() - s_5.getViewportHeight()); - translateSelectionForJump(s_5, max_0 - (s_5.getScrollTop() + s_5.getPendingDelta())); - // scrollTo(max) eager-writes scrollTop so the render-phase sticky - // follow computes followDelta=0. Without this, scrollToBottom() - // alone leaves scrollTop stale → followDelta=max-stale → - // shiftSelectionForFollow applies the SAME shift we already did - // above, 2× offset. scrollToBottom() then re-enables sticky. - s_5.scrollTo(max_0); - s_5.scrollToBottom(); - onScroll?.(true, s_5); + + useKeybindings( + { + 'scroll:pageUp': () => { + const s = scrollRef.current + if (!s) return + const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2)) + translateSelectionForJump(s, d) + const sticky = jumpBy(s, d) + onScroll?.(sticky, s) + }, + 'scroll:pageDown': () => { + const s = scrollRef.current + if (!s) return + const d = Math.max(1, Math.floor(s.getViewportHeight() / 2)) + translateSelectionForJump(s, d) + const sticky = jumpBy(s, d) + onScroll?.(sticky, s) + }, + 'scroll:lineUp': () => { + // Wheel: scrollBy accumulates into pendingScrollDelta, drained async + // by the renderer. captureScrolledRows can't read the outgoing rows + // before they leave (drain is non-deterministic). Clear for now. + selection.clearSelection() + const s = scrollRef.current + // Return false (not consumed) when the ScrollBox content fits — + // scroll would be a no-op. Lets a child component's handler take + // the wheel event instead (e.g. Settings Config's list navigation + // inside the centered Modal, where the paginated slice always fits). + if (!s || s.getScrollHeight() <= s.getViewportHeight()) return false + wheelAccel.current ??= initAndLogWheelAccel() + scrollUp(s, computeWheelStep(wheelAccel.current, -1, performance.now())) + onScroll?.(false, s) + }, + 'scroll:lineDown': () => { + selection.clearSelection() + const s = scrollRef.current + if (!s || s.getScrollHeight() <= s.getViewportHeight()) return false + wheelAccel.current ??= initAndLogWheelAccel() + const step = computeWheelStep(wheelAccel.current, 1, performance.now()) + const reachedBottom = scrollDown(s, step) + onScroll?.(reachedBottom, s) + }, + 'scroll:top': () => { + const s = scrollRef.current + if (!s) return + translateSelectionForJump(s, -(s.getScrollTop() + s.getPendingDelta())) + s.scrollTo(0) + onScroll?.(false, s) + }, + 'scroll:bottom': () => { + const s = scrollRef.current + if (!s) return + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + translateSelectionForJump( + s, + max - (s.getScrollTop() + s.getPendingDelta()), + ) + // scrollTo(max) eager-writes scrollTop so the render-phase sticky + // follow computes followDelta=0. Without this, scrollToBottom() + // alone leaves scrollTop stale → followDelta=max-stale → + // shiftSelectionForFollow applies the SAME shift we already did + // above, 2× offset. scrollToBottom() then re-enables sticky. + s.scrollTo(max) + s.scrollToBottom() + onScroll?.(true, s) + }, + 'selection:copy': copyAndToast, }, - 'selection:copy': copyAndToast - }, { - context: 'Scroll', - isActive - }); + { context: 'Scroll', isActive }, + ) // scroll:halfPage*/fullPage* have no default key bindings — ctrl+u/d/b/f // all have real owners in normal mode (kill-line/exit/task:background/ // kill-agents). Transcript mode gets them via the isModal raw useInput // below. These handlers stay for custom rebinds only. - useKeybindings({ - 'scroll:halfPageUp': () => { - const s_6 = scrollRef.current; - if (!s_6) return; - const d_1 = -Math.max(1, Math.floor(s_6.getViewportHeight() / 2)); - translateSelectionForJump(s_6, d_1); - const sticky_1 = jumpBy(s_6, d_1); - onScroll?.(sticky_1, s_6); + useKeybindings( + { + 'scroll:halfPageUp': () => { + const s = scrollRef.current + if (!s) return + const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2)) + translateSelectionForJump(s, d) + const sticky = jumpBy(s, d) + onScroll?.(sticky, s) + }, + 'scroll:halfPageDown': () => { + const s = scrollRef.current + if (!s) return + const d = Math.max(1, Math.floor(s.getViewportHeight() / 2)) + translateSelectionForJump(s, d) + const sticky = jumpBy(s, d) + onScroll?.(sticky, s) + }, + 'scroll:fullPageUp': () => { + const s = scrollRef.current + if (!s) return + const d = -Math.max(1, s.getViewportHeight()) + translateSelectionForJump(s, d) + const sticky = jumpBy(s, d) + onScroll?.(sticky, s) + }, + 'scroll:fullPageDown': () => { + const s = scrollRef.current + if (!s) return + const d = Math.max(1, s.getViewportHeight()) + translateSelectionForJump(s, d) + const sticky = jumpBy(s, d) + onScroll?.(sticky, s) + }, }, - 'scroll:halfPageDown': () => { - const s_7 = scrollRef.current; - if (!s_7) return; - const d_2 = Math.max(1, Math.floor(s_7.getViewportHeight() / 2)); - translateSelectionForJump(s_7, d_2); - const sticky_2 = jumpBy(s_7, d_2); - onScroll?.(sticky_2, s_7); - }, - 'scroll:fullPageUp': () => { - const s_8 = scrollRef.current; - if (!s_8) return; - const d_3 = -Math.max(1, s_8.getViewportHeight()); - translateSelectionForJump(s_8, d_3); - const sticky_3 = jumpBy(s_8, d_3); - onScroll?.(sticky_3, s_8); - }, - 'scroll:fullPageDown': () => { - const s_9 = scrollRef.current; - if (!s_9) return; - const d_4 = Math.max(1, s_9.getViewportHeight()); - translateSelectionForJump(s_9, d_4); - const sticky_4 = jumpBy(s_9, d_4); - onScroll?.(sticky_4, s_9); - } - }, { - context: 'Scroll', - isActive - }); + { context: 'Scroll', isActive }, + ) // Modal pager keys — transcript mode only. less/tmux copy-mode lineage: // ctrl+u/d (half-page), ctrl+b/f (full-page), g/G (top/bottom). Tom's @@ -570,16 +595,19 @@ export function ScrollKeybindingHandler({ // anchorY already solve scroll-to-index. jumpToPrevTurn is the n/N // template. Single-shot via OVERSCAN_ROWS=80; two-phase was tried and // abandoned (❯ oscillation). See team memory scroll-copy-mode-design.md. - useInput((input, key, event) => { - const s_10 = scrollRef.current; - if (!s_10) return; - const sticky_5 = applyModalPagerAction(s_10, modalPagerAction(input, key), d_5 => translateSelectionForJump(s_10, d_5)); - if (sticky_5 === null) return; - onScroll?.(sticky_5, s_10); - event.stopImmediatePropagation(); - }, { - isActive: isActive && isModal - }); + useInput( + (input, key, event) => { + const s = scrollRef.current + if (!s) return + const sticky = applyModalPagerAction(s, modalPagerAction(input, key), d => + translateSelectionForJump(s, d), + ) + if (sticky === null) return + onScroll?.(sticky, s) + event.stopImmediatePropagation() + }, + { isActive: isActive && isModal }, + ) // Esc clears selection; any other keystroke also clears it (matches // native terminal behavior where selection disappears on input). @@ -592,34 +620,37 @@ export function ScrollKeybindingHandler({ // propagation — they're observed to clear selection as a side-effect. // The selection:copy keybinding (ctrl+shift+c / cmd+c) registers above // via useKeybindings and consumes its event before reaching here. - useInput((input_0, key_0, event_0) => { - if (!selection.hasSelection()) return; - if (key_0.escape) { - selection.clearSelection(); - event_0.stopImmediatePropagation(); - return; - } - if (key_0.ctrl && !key_0.shift && !key_0.meta && input_0 === 'c') { - copyAndToast(); - event_0.stopImmediatePropagation(); - return; - } - const move = selectionFocusMoveForKey(key_0); - if (move) { - selection.moveFocus(move); - event_0.stopImmediatePropagation(); - return; - } - if (shouldClearSelectionOnKey(key_0)) { - selection.clearSelection(); - } - }, { - isActive - }); - useDragToScroll(scrollRef, selection, isActive, onScroll); - useCopyOnSelect(selection, isActive, showCopiedToast); - useSelectionBgColor(selection); - return null; + useInput( + (input, key, event) => { + if (!selection.hasSelection()) return + if (key.escape) { + selection.clearSelection() + event.stopImmediatePropagation() + return + } + if (key.ctrl && !key.shift && !key.meta && input === 'c') { + copyAndToast() + event.stopImmediatePropagation() + return + } + const move = selectionFocusMoveForKey(key) + if (move) { + selection.moveFocus(move) + event.stopImmediatePropagation() + return + } + if (shouldClearSelectionOnKey(key)) { + selection.clearSelection() + } + }, + { isActive }, + ) + + useDragToScroll(scrollRef, selection, isActive, onScroll) + useCopyOnSelect(selection, isActive, showCopiedToast) + useSelectionBgColor(selection) + + return null } /** @@ -634,37 +665,51 @@ export function ScrollKeybindingHandler({ * scrolledOffBelow before each scroll step and joined back in by * getSelectedText. */ -function useDragToScroll(scrollRef: RefObject, selection: ReturnType, isActive: boolean, onScroll: Props['onScroll']): void { - const timerRef = useRef(null); - const dirRef = useRef<-1 | 0 | 1>(0); // -1 scrolling up, +1 down, 0 idle +function useDragToScroll( + scrollRef: RefObject, + selection: ReturnType, + isActive: boolean, + onScroll: Props['onScroll'], +): void { + const timerRef = useRef(null) + const dirRef = useRef<-1 | 0 | 1>(0) // -1 scrolling up, +1 down, 0 idle // Survives stop() — reset only on drag-finish. See check() for semantics. - const lastScrolledDirRef = useRef<-1 | 0 | 1>(0); - const ticksRef = useRef(0); + const lastScrolledDirRef = useRef<-1 | 0 | 1>(0) + const ticksRef = useRef(0) // onScroll may change identity every render (if not memoized by caller). // Read through a ref so the effect doesn't re-subscribe and kill the timer // on each scroll-induced re-render. - const onScrollRef = useRef(onScroll); - onScrollRef.current = onScroll; + const onScrollRef = useRef(onScroll) + onScrollRef.current = onScroll + useEffect(() => { - if (!isActive) return; + if (!isActive) return + function stop(): void { - dirRef.current = 0; + dirRef.current = 0 if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; + clearInterval(timerRef.current) + timerRef.current = null } } + function tick(): void { - const sel = selection.getState(); - const s = scrollRef.current; - const dir = dirRef.current; + const sel = selection.getState() + const s = scrollRef.current + const dir = dirRef.current // dir === 0 defends against a stale interval (start() may have set one // after the immediate tick already called stop() at a scroll boundary). // ticks cap defends against a lost release event (mouse released // outside terminal window) leaving isDragging stuck true. - if (!sel?.isDragging || !sel.focus || !s || dir === 0 || ++ticksRef.current > AUTOSCROLL_MAX_TICKS) { - stop(); - return; + if ( + !sel?.isDragging || + !sel.focus || + !s || + dir === 0 || + ++ticksRef.current > AUTOSCROLL_MAX_TICKS + ) { + stop() + return } // scrollBy accumulates into pendingScrollDelta; the screen buffer // doesn't update until the next render drains it. If a previous @@ -673,61 +718,62 @@ function useDragToScroll(scrollRef: RefObject, selection // accumulator AND missing the rows that actually scrolled out). // Skip this tick; the 50ms interval will retry after Ink's 16ms // render catches up. Also prevents shiftAnchor from desyncing. - if (s.getPendingDelta() !== 0) return; - const top = s.getViewportTop(); - const bottom = top + s.getViewportHeight() - 1; + if (s.getPendingDelta() !== 0) return + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 // Clamp anchor within [top, bottom]. Not [0, bottom]: the ScrollBox // padding row at 0 would produce a blank line between scrolledOffAbove // and the on-screen content in getSelectedText. The padding-row // highlight was a minor visual nicety; text correctness wins. if (dir < 0) { if (s.getScrollTop() <= 0) { - stop(); - return; + stop() + return } // Scrolling up: content moves down in viewport, so anchor row +N. // Clamp to actual scroll distance so anchor stays in sync when near // the top boundary (renderer clamps scrollTop to 0 on drain). - const actual = Math.min(AUTOSCROLL_LINES, s.getScrollTop()); + const actual = Math.min(AUTOSCROLL_LINES, s.getScrollTop()) // Capture rows about to scroll out the BOTTOM before scrollBy // overwrites them. Only rows inside the selection are captured // (captureScrolledRows intersects with selection bounds). - selection.captureScrolledRows(bottom - actual + 1, bottom, 'below'); - selection.shiftAnchor(actual, 0, bottom); - s.scrollBy(-AUTOSCROLL_LINES); + selection.captureScrolledRows(bottom - actual + 1, bottom, 'below') + selection.shiftAnchor(actual, 0, bottom) + s.scrollBy(-AUTOSCROLL_LINES) } else { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) if (s.getScrollTop() >= max) { - stop(); - return; + stop() + return } // Scrolling down: content moves up in viewport, so anchor row -N. // Clamp to actual scroll distance so anchor stays in sync when near // the bottom boundary (renderer clamps scrollTop to max on drain). - const actual_0 = Math.min(AUTOSCROLL_LINES, max - s.getScrollTop()); + const actual = Math.min(AUTOSCROLL_LINES, max - s.getScrollTop()) // Capture rows about to scroll out the TOP. - selection.captureScrolledRows(top, top + actual_0 - 1, 'above'); - selection.shiftAnchor(-actual_0, top, bottom); - s.scrollBy(AUTOSCROLL_LINES); + selection.captureScrolledRows(top, top + actual - 1, 'above') + selection.shiftAnchor(-actual, top, bottom) + s.scrollBy(AUTOSCROLL_LINES) } - onScrollRef.current?.(false, s); + onScrollRef.current?.(false, s) } - function start(dir_0: -1 | 1): void { + + function start(dir: -1 | 1): void { // Record BEFORE early-return: the empty-accumulator reset in check() // may have zeroed this during the pre-crossing phase (accumulators // empty until the anchor row enters the capture range). Re-record // on every call so the corruption is instantly healed. - lastScrolledDirRef.current = dir_0; - if (dirRef.current === dir_0) return; // already going this way - stop(); - dirRef.current = dir_0; - ticksRef.current = 0; - tick(); + lastScrolledDirRef.current = dir + if (dirRef.current === dir) return // already going this way + stop() + dirRef.current = dir + ticksRef.current = 0 + tick() // tick() may have hit a scroll boundary and called stop() (dir reset to // 0). Only start the interval if we're still going — otherwise the // interval would run forever with dir === 0 doing nothing useful. - if (dirRef.current === dir_0) { - timerRef.current = setInterval(tick, AUTOSCROLL_INTERVAL_MS); + if (dirRef.current === dir) { + timerRef.current = setInterval(tick, AUTOSCROLL_INTERVAL_MS) } } @@ -739,14 +785,14 @@ function useDragToScroll(scrollRef: RefObject, selection // scrolling, highlight walks up with the text). Keeping sticky also // avoids useVirtualScroll's tail-walk → forward-walk phantom growth. function check(): void { - const s_0 = scrollRef.current; - if (!s_0) { - stop(); - return; + const s = scrollRef.current + if (!s) { + stop() + return } - const top_0 = s_0.getViewportTop(); - const bottom_0 = top_0 + s_0.getViewportHeight() - 1; - const sel_0 = selection.getState(); + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 + const sel = selection.getState() // Pass the LAST-scrolled direction (not dirRef) so the anchor guard is // bypassed after shiftAnchor has clamped anchor toward row 0. Using // lastScrolledDirRef (survives stop()) lets autoscroll resume after a @@ -759,36 +805,45 @@ function useDragToScroll(scrollRef: RefObject, selection // stuck true, the reason AUTOSCROLL_MAX_TICKS exists) still resets. // Safe: start() below re-records lastScrolledDirRef before its // early-return, so a mid-scroll reset here is instantly undone. - if (!sel_0?.isDragging || sel_0.scrolledOffAbove.length === 0 && sel_0.scrolledOffBelow.length === 0) { - lastScrolledDirRef.current = 0; + if ( + !sel?.isDragging || + (sel.scrolledOffAbove.length === 0 && sel.scrolledOffBelow.length === 0) + ) { + lastScrolledDirRef.current = 0 } - const dir_1 = dragScrollDirection(sel_0, top_0, bottom_0, lastScrolledDirRef.current); - if (dir_1 === 0) { + const dir = dragScrollDirection( + sel, + top, + bottom, + lastScrolledDirRef.current, + ) + if (dir === 0) { // Blocked reversal: focus jumped to the opposite edge (off-window // drag return, fast flick). handleSelectionDrag already moved focus // past the anchor, flipping selectionBounds — the accumulator is // now orphaned (holds rows on the wrong side). Clear it so // getSelectedText matches the visible highlight. - if (lastScrolledDirRef.current !== 0 && sel_0?.focus) { - const want = sel_0.focus.row < top_0 ? -1 : sel_0.focus.row > bottom_0 ? 1 : 0; + if (lastScrolledDirRef.current !== 0 && sel?.focus) { + const want = sel.focus.row < top ? -1 : sel.focus.row > bottom ? 1 : 0 if (want !== 0 && want !== lastScrolledDirRef.current) { - sel_0.scrolledOffAbove = []; - sel_0.scrolledOffBelow = []; - sel_0.scrolledOffAboveSW = []; - sel_0.scrolledOffBelowSW = []; - lastScrolledDirRef.current = 0; + sel.scrolledOffAbove = [] + sel.scrolledOffBelow = [] + sel.scrolledOffAboveSW = [] + sel.scrolledOffBelowSW = [] + lastScrolledDirRef.current = 0 } } - stop(); - } else start(dir_1); + stop() + } else start(dir) } - const unsubscribe = selection.subscribe(check); + + const unsubscribe = selection.subscribe(check) return () => { - unsubscribe(); - stop(); - lastScrolledDirRef.current = 0; - }; - }, [isActive, scrollRef, selection]); + unsubscribe() + stop() + lastScrolledDirRef.current = 0 + } + }, [isActive, scrollRef, selection]) } /** @@ -807,21 +862,26 @@ function useDragToScroll(scrollRef: RefObject, selection * returns 0 to stop — reversing without clearing scrolledOffAbove/Below * would duplicate captured rows when they scroll back on-screen. */ -export function dragScrollDirection(sel: SelectionState | null, top: number, bottom: number, alreadyScrollingDir: -1 | 0 | 1 = 0): -1 | 0 | 1 { - if (!sel?.isDragging || !sel.anchor || !sel.focus) return 0; - const row = sel.focus.row; - const want: -1 | 0 | 1 = row < top ? -1 : row > bottom ? 1 : 0; +export function dragScrollDirection( + sel: SelectionState | null, + top: number, + bottom: number, + alreadyScrollingDir: -1 | 0 | 1 = 0, +): -1 | 0 | 1 { + if (!sel?.isDragging || !sel.anchor || !sel.focus) return 0 + const row = sel.focus.row + const want: -1 | 0 | 1 = row < top ? -1 : row > bottom ? 1 : 0 if (alreadyScrollingDir !== 0) { // Same-direction only. Focus on the opposite side, or back inside the // viewport, stops the scroll — captured rows stay in scrolledOffAbove/ // Below but never scroll back on-screen, so getSelectedText is correct. - return want === alreadyScrollingDir ? want : 0; + return want === alreadyScrollingDir ? want : 0 } // Anchor must be inside the viewport for us to own this drag. If the // user started selecting in the input box or header, autoscrolling the // message history is surprising and corrupts the anchor via shiftAnchor. - if (sel.anchor.row < top || sel.anchor.row > bottom) return 0; - return want; + if (sel.anchor.row < top || sel.anchor.row > bottom) return 0 + return want } // Keyboard page jumps: scrollTo() writes scrollTop directly and clears @@ -832,36 +892,36 @@ export function dragScrollDirection(sel: SelectionState | null, top: number, bot // Target is relative to scrollTop+pendingDelta so a jump mid-wheel-burst // lands where the wheel was heading. export function jumpBy(s: ScrollBoxHandle, delta: number): boolean { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); - const target = s.getScrollTop() + s.getPendingDelta() + delta; + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + const target = s.getScrollTop() + s.getPendingDelta() + delta if (target >= max) { // Eager-write scrollTop so follow-scroll sees followDelta=0. Callers // that ran translateSelectionForJump already shifted; scrollToBottom() // alone would double-shift via the render-phase sticky follow. - s.scrollTo(max); - s.scrollToBottom(); - return true; + s.scrollTo(max) + s.scrollToBottom() + return true } - s.scrollTo(Math.max(0, target)); - return false; + s.scrollTo(Math.max(0, target)) + return false } // Wheel-down past maxScroll re-enables sticky so wheeling at the bottom // naturally re-pins (matches typical chat-app behavior). Returns the // resulting sticky state so callers can propagate it. function scrollDown(s: ScrollBoxHandle, amount: number): boolean { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) // Include pendingDelta: scrollBy accumulates into pendingScrollDelta // without updating scrollTop, so getScrollTop() alone is stale within // a batch of wheel events. Without this, wheeling to the bottom never // re-enables sticky scroll. - const effectiveTop = s.getScrollTop() + s.getPendingDelta(); + const effectiveTop = s.getScrollTop() + s.getPendingDelta() if (effectiveTop + amount >= max) { - s.scrollToBottom(); - return true; + s.scrollToBottom() + return true } - s.scrollBy(amount); - return false; + s.scrollBy(amount) + return false } // Wheel-up past scrollTop=0 clamps via scrollTo(0), clearing @@ -873,14 +933,23 @@ function scrollDown(s: ScrollBoxHandle, amount: number): boolean { export function scrollUp(s: ScrollBoxHandle, amount: number): void { // Include pendingDelta: scrollBy accumulates without updating scrollTop, // so getScrollTop() alone is stale within a batch of wheel events. - const effectiveTop = s.getScrollTop() + s.getPendingDelta(); + const effectiveTop = s.getScrollTop() + s.getPendingDelta() if (effectiveTop - amount <= 0) { - s.scrollTo(0); - return; + s.scrollTo(0) + return } - s.scrollBy(-amount); + s.scrollBy(-amount) } -export type ModalPagerAction = 'lineUp' | 'lineDown' | 'halfPageUp' | 'halfPageDown' | 'fullPageUp' | 'fullPageDown' | 'top' | 'bottom'; + +export type ModalPagerAction = + | 'lineUp' + | 'lineDown' + | 'halfPageUp' + | 'halfPageDown' + | 'fullPageUp' + | 'fullPageDown' + | 'top' + | 'bottom' /** * Maps a keystroke to a modal pager action. Exported for testing. @@ -897,65 +966,71 @@ export type ModalPagerAction = 'lineUp' | 'lineDown' | 'halfPageUp' | 'halfPageD * count is irrelevant (consuming the batch just prevents it from leaking * to the selection-clear-on-printable handler). */ -export function modalPagerAction(input: string, key: Pick): ModalPagerAction | null { - if (key.meta) return null; +export function modalPagerAction( + input: string, + key: Pick< + Key, + 'ctrl' | 'meta' | 'shift' | 'upArrow' | 'downArrow' | 'home' | 'end' + >, +): ModalPagerAction | null { + if (key.meta) return null // Special keys first — arrows/home/end arrive with empty or junk input, // so these must be checked before any input-string logic. shift is // reserved for selection-extend (selectionFocusMoveForKey); ctrl+home/end // already has a useKeybindings route to scroll:top/bottom. if (!key.ctrl && !key.shift) { - if (key.upArrow) return 'lineUp'; - if (key.downArrow) return 'lineDown'; - if (key.home) return 'top'; - if (key.end) return 'bottom'; + if (key.upArrow) return 'lineUp' + if (key.downArrow) return 'lineDown' + if (key.home) return 'top' + if (key.end) return 'bottom' } if (key.ctrl) { - if (key.shift) return null; + if (key.shift) return null switch (input) { case 'u': - return 'halfPageUp'; + return 'halfPageUp' case 'd': - return 'halfPageDown'; + return 'halfPageDown' case 'b': - return 'fullPageUp'; + return 'fullPageUp' case 'f': - return 'fullPageDown'; + return 'fullPageDown' // emacs-style line scroll (less accepts both ctrl+n/p and ctrl+e/y). // Works during search nav — fine-adjust after a jump without // leaving modal. No !searchOpen gate on this useInput's isActive. case 'n': - return 'lineDown'; + return 'lineDown' case 'p': - return 'lineUp'; + return 'lineUp' default: - return null; + return null } } // Bare letters. Key-repeat batches: only act on uniform runs. - const c = input[0]; - if (!c || input !== c.repeat(input.length)) return null; + const c = input[0] + if (!c || input !== c.repeat(input.length)) return null // kitty sends G as input='g' shift=true; legacy as 'G' shift=false. // Check BEFORE the shift-gate so both hit 'bottom'. - if (c === 'G' || c === 'g' && key.shift) return 'bottom'; - if (key.shift) return null; + if (c === 'G' || (c === 'g' && key.shift)) return 'bottom' + if (key.shift) return null switch (c) { case 'g': - return 'top'; + return 'top' // j/k re-added per Tom Mar 18 — reversal of Mar 16 removal. Works // during search nav (fine-adjust after n/N lands) since isModal is // independent of searchOpen. case 'j': - return 'lineDown'; + return 'lineDown' case 'k': - return 'lineUp'; + return 'lineUp' // less: space = page down, b = page up. ctrl+b already maps above; // bare b is the less-native version. case ' ': - return 'fullPageDown'; + return 'fullPageDown' case 'b': - return 'fullPageUp'; + return 'fullPageUp' default: - return null; + return null } } @@ -966,46 +1041,46 @@ export function modalPagerAction(input: string, key: Pick void): boolean | null { +export function applyModalPagerAction( + s: ScrollBoxHandle, + act: ModalPagerAction | null, + onBeforeJump: (delta: number) => void, +): boolean | null { switch (act) { case null: - return null; + return null case 'lineUp': - case 'lineDown': - { - const d = act === 'lineDown' ? 1 : -1; - onBeforeJump(d); - return jumpBy(s, d); - } + case 'lineDown': { + const d = act === 'lineDown' ? 1 : -1 + onBeforeJump(d) + return jumpBy(s, d) + } case 'halfPageUp': - case 'halfPageDown': - { - const half = Math.max(1, Math.floor(s.getViewportHeight() / 2)); - const d = act === 'halfPageDown' ? half : -half; - onBeforeJump(d); - return jumpBy(s, d); - } + case 'halfPageDown': { + const half = Math.max(1, Math.floor(s.getViewportHeight() / 2)) + const d = act === 'halfPageDown' ? half : -half + onBeforeJump(d) + return jumpBy(s, d) + } case 'fullPageUp': - case 'fullPageDown': - { - const page = Math.max(1, s.getViewportHeight()); - const d = act === 'fullPageDown' ? page : -page; - onBeforeJump(d); - return jumpBy(s, d); - } + case 'fullPageDown': { + const page = Math.max(1, s.getViewportHeight()) + const d = act === 'fullPageDown' ? page : -page + onBeforeJump(d) + return jumpBy(s, d) + } case 'top': - onBeforeJump(-(s.getScrollTop() + s.getPendingDelta())); - s.scrollTo(0); - return false; - case 'bottom': - { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); - onBeforeJump(max - (s.getScrollTop() + s.getPendingDelta())); - // Eager-write scrollTop before scrollToBottom — same double-shift - // fix as scroll:bottom and jumpBy's max branch. - s.scrollTo(max); - s.scrollToBottom(); - return true; - } + onBeforeJump(-(s.getScrollTop() + s.getPendingDelta())) + s.scrollTo(0) + return false + case 'bottom': { + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + onBeforeJump(max - (s.getScrollTop() + s.getPendingDelta())) + // Eager-write scrollTop before scrollToBottom — same double-shift + // fix as scroll:bottom and jumpBy's max branch. + s.scrollTo(max) + s.scrollToBottom() + return true + } } } diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx index ff13140bc..d35d67edd 100644 --- a/src/components/SearchBox.tsx +++ b/src/components/SearchBox.tsx @@ -1,71 +1,71 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../ink.js'; +import React from 'react' +import { Box, Text } from '../ink.js' + type Props = { - query: string; - placeholder?: string; - isFocused: boolean; - isTerminalFocused: boolean; - prefix?: string; - width?: number | string; - cursorOffset?: number; - borderless?: boolean; -}; -export function SearchBox(t0) { - const $ = _c(17); - const { - query, - placeholder: t1, - isFocused, - isTerminalFocused, - prefix: t2, - width, - cursorOffset, - borderless: t3 - } = t0; - const placeholder = t1 === undefined ? "Search\u2026" : t1; - const prefix = t2 === undefined ? "\u2315" : t2; - const borderless = t3 === undefined ? false : t3; - const offset = cursorOffset ?? query.length; - const t4 = borderless ? undefined : "round"; - const t5 = isFocused ? "suggestion" : undefined; - const t6 = !isFocused; - const t7 = borderless ? 0 : 1; - const t8 = !isFocused; - let t9; - if ($[0] !== isFocused || $[1] !== isTerminalFocused || $[2] !== offset || $[3] !== placeholder || $[4] !== query) { - t9 = isFocused ? <>{query ? isTerminalFocused ? <>{query.slice(0, offset)}{offset < query.length ? query[offset] : " "}{offset < query.length && {query.slice(offset + 1)}} : {query} : isTerminalFocused ? <>{placeholder.charAt(0)}{placeholder.slice(1)} : {placeholder}} : query ? {query} : {placeholder}; - $[0] = isFocused; - $[1] = isTerminalFocused; - $[2] = offset; - $[3] = placeholder; - $[4] = query; - $[5] = t9; - } else { - t9 = $[5]; - } - let t10; - if ($[6] !== prefix || $[7] !== t8 || $[8] !== t9) { - t10 = {prefix}{" "}{t9}; - $[6] = prefix; - $[7] = t8; - $[8] = t9; - $[9] = t10; - } else { - t10 = $[9]; - } - let t11; - if ($[10] !== t10 || $[11] !== t4 || $[12] !== t5 || $[13] !== t6 || $[14] !== t7 || $[15] !== width) { - t11 = {t10}; - $[10] = t10; - $[11] = t4; - $[12] = t5; - $[13] = t6; - $[14] = t7; - $[15] = width; - $[16] = t11; - } else { - t11 = $[16]; - } - return t11; + query: string + placeholder?: string + isFocused: boolean + isTerminalFocused: boolean + prefix?: string + width?: number | string + cursorOffset?: number + borderless?: boolean +} + +export function SearchBox({ + query, + placeholder = 'Search…', + isFocused, + isTerminalFocused, + prefix = '⌕', + width, + cursorOffset, + borderless = false, +}: Props): React.ReactNode { + const offset = cursorOffset ?? query.length + + return ( + + + {prefix}{' '} + {isFocused ? ( + <> + {query ? ( + isTerminalFocused ? ( + <> + {query.slice(0, offset)} + + {offset < query.length ? query[offset] : ' '} + + {offset < query.length && ( + {query.slice(offset + 1)} + )} + + ) : ( + {query} + ) + ) : isTerminalFocused ? ( + <> + {placeholder.charAt(0)} + {placeholder.slice(1)} + + ) : ( + {placeholder} + )} + + ) : query ? ( + {query} + ) : ( + {placeholder} + )} + + + ) } diff --git a/src/components/SessionBackgroundHint.tsx b/src/components/SessionBackgroundHint.tsx index 53e56aea5..a7f5e8f59 100644 --- a/src/components/SessionBackgroundHint.tsx +++ b/src/components/SessionBackgroundHint.tsx @@ -1,20 +1,27 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback, useState } from 'react'; -import { useDoublePress } from '../hooks/useDoublePress.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js'; -import { backgroundAll, hasForegroundTasks } from '../tasks/LocalShellTask/LocalShellTask.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import { env } from '../utils/env.js'; -import { isEnvTruthy } from '../utils/envUtils.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import * as React from 'react' +import { useCallback, useState } from 'react' +import { useDoublePress } from '../hooks/useDoublePress.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { + useAppState, + useAppStateStore, + useSetAppState, +} from '../state/AppState.js' +import { + backgroundAll, + hasForegroundTasks, +} from '../tasks/LocalShellTask/LocalShellTask.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { env } from '../utils/env.js' +import { isEnvTruthy } from '../utils/envUtils.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' + type Props = { - onBackgroundSession: () => void; - isLoading: boolean; -}; + onBackgroundSession: () => void + isLoading: boolean +} /** * Shows a hint when user presses Ctrl+B to background the current session. @@ -24,84 +31,71 @@ type Props = { * 1. isLoading is true (a query is in progress) * 2. No foreground tasks (bash/agent) are running (those take priority for Ctrl+B) */ -export function SessionBackgroundHint(t0) { - const $ = _c(10); - const { +export function SessionBackgroundHint({ + onBackgroundSession, + isLoading, +}: Props): React.ReactElement | null { + const setAppState = useSetAppState() + const appStateStore = useAppStateStore() + + const [showSessionHint, setShowSessionHint] = useState(false) + + const handleDoublePress = useDoublePress( + setShowSessionHint, onBackgroundSession, - isLoading - } = t0; - const setAppState = useSetAppState(); - const appStateStore = useAppStateStore(); - const [showSessionHint, setShowSessionHint] = useState(false); - const handleDoublePress = useDoublePress(setShowSessionHint, onBackgroundSession, _temp); - let t1; - if ($[0] !== appStateStore || $[1] !== handleDoublePress || $[2] !== isLoading || $[3] !== setAppState) { - t1 = () => { - if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) { - return; - } - const state = appStateStore.getState(); - if (hasForegroundTasks(state)) { - backgroundAll(() => appStateStore.getState(), setAppState); - if (!getGlobalConfig().hasUsedBackgroundTask) { - saveGlobalConfig(_temp2); - } - } else { - if (isEnvTruthy("false") && isLoading) { - handleDoublePress(); - } + () => {}, // First press just shows the hint + ) + + // Handler for task:background - prioritizes foreground tasks, falls back to session backgrounding + // Skip all background functionality if background tasks are disabled + const handleBackground = useCallback(() => { + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) { + return + } + const state = appStateStore.getState() + if (hasForegroundTasks(state)) { + // Existing behavior - background running bash/agent tasks + backgroundAll(() => appStateStore.getState(), setAppState) + if (!getGlobalConfig().hasUsedBackgroundTask) { + saveGlobalConfig(c => + c.hasUsedBackgroundTask ? c : { ...c, hasUsedBackgroundTask: true }, + ) } - }; - $[0] = appStateStore; - $[1] = handleDoublePress; - $[2] = isLoading; - $[3] = setAppState; - $[4] = t1; - } else { - t1 = $[4]; - } - const handleBackground = t1; - const hasForeground = useAppState(hasForegroundTasks); - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = isEnvTruthy("false"); - $[5] = t2; - } else { - t2 = $[5]; - } - const sessionBgEnabled = t2; - const t3 = hasForeground || sessionBgEnabled && isLoading; - let t4; - if ($[6] !== t3) { - t4 = { - context: "Task", - isActive: t3 - }; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - useKeybinding("task:background", handleBackground, t4); - const baseShortcut = useShortcutDisplay("task:background", "Task", "ctrl+b"); - const shortcut = env.terminal === "tmux" && baseShortcut === "ctrl+b" ? "ctrl+b ctrl+b" : baseShortcut; + } else if ( + isEnvTruthy("false") && + isLoading + ) { + // New behavior - double-press to background session (gated) + handleDoublePress() + } + }, [setAppState, appStateStore, isLoading, handleDoublePress]) + + // Only eat ctrl+b when there's something to background. Without this gate + // the binding double-fires with readline backward-char at an idle prompt. + const hasForeground = useAppState(hasForegroundTasks) + const sessionBgEnabled = isEnvTruthy("false") + useKeybinding('task:background', handleBackground, { + context: 'Task', + isActive: hasForeground || (sessionBgEnabled && isLoading), + }) + + // Get the configured shortcut for task:background + const baseShortcut = useShortcutDisplay('task:background', 'Task', 'ctrl+b') + // In tmux, ctrl+b is the prefix key, so users need to press it twice to send ctrl+b + const shortcut = + env.terminal === 'tmux' && baseShortcut === 'ctrl+b' + ? 'ctrl+b ctrl+b' + : baseShortcut + if (!isLoading || !showSessionHint) { - return null; + return null } - let t5; - if ($[8] !== shortcut) { - t5 = ; - $[8] = shortcut; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; -} -function _temp2(c) { - return c.hasUsedBackgroundTask ? c : { - ...c, - hasUsedBackgroundTask: true - }; + + return ( + + + + + + ) } -function _temp() {} diff --git a/src/components/SessionPreview.tsx b/src/components/SessionPreview.tsx index 864750ea5..2d0e10a97 100644 --- a/src/components/SessionPreview.tsx +++ b/src/components/SessionPreview.tsx @@ -1,193 +1,124 @@ -import { c as _c } from "react/compiler-runtime"; -import type { UUID } from 'crypto'; -import React, { useCallback } from 'react'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { getAllBaseTools } from '../tools.js'; -import type { LogOption } from '../types/logs.js'; -import { formatRelativeTimeAgo } from '../utils/format.js'; -import { getSessionIdFromLog, isLiteLog, loadFullLog } from '../utils/sessionStorage.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Byline } from './design-system/Byline.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { LoadingState } from './design-system/LoadingState.js'; -import { Messages } from './Messages.js'; +import type { UUID } from 'crypto' +import React, { useCallback } from 'react' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { getAllBaseTools } from '../tools.js' +import type { LogOption } from '../types/logs.js' +import { formatRelativeTimeAgo } from '../utils/format.js' +import { + getSessionIdFromLog, + isLiteLog, + loadFullLog, +} from '../utils/sessionStorage.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Byline } from './design-system/Byline.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { LoadingState } from './design-system/LoadingState.js' +import { Messages } from './Messages.js' + type Props = { - log: LogOption; - onExit: () => void; - onSelect: (log: LogOption) => void; -}; -export function SessionPreview(t0) { - const $ = _c(33); - const { - log, - onExit, - onSelect - } = t0; - const [fullLog, setFullLog] = React.useState(null); - let t1; - let t2; - if ($[0] !== log) { - t1 = () => { - setFullLog(null); - if (isLiteLog(log)) { - loadFullLog(log).then(setFullLog); - } - }; - t2 = [log]; - $[0] = log; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - React.useEffect(t1, t2); - const isLoading = isLiteLog(log) && fullLog === null; - const displayLog = fullLog ?? log; - let t3; - if ($[3] !== displayLog) { - t3 = getSessionIdFromLog(displayLog) || "" as UUID; - $[3] = displayLog; - $[4] = t3; - } else { - t3 = $[4]; - } - const conversationId = t3; - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = getAllBaseTools(); - $[5] = t4; - } else { - t4 = $[5]; - } - const tools = t4; - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - context: "Confirmation" - }; - $[6] = t5; - } else { - t5 = $[6]; - } - useKeybinding("confirm:no", onExit, t5); - let t6; - if ($[7] !== fullLog || $[8] !== log || $[9] !== onSelect) { - t6 = () => { - onSelect(fullLog ?? log); - }; - $[7] = fullLog; - $[8] = log; - $[9] = onSelect; - $[10] = t6; - } else { - t6 = $[10]; - } - const handleSelect = t6; - let t7; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - context: "Confirmation" - }; - $[11] = t7; - } else { - t7 = $[11]; - } - useKeybinding("confirm:yes", handleSelect, t7); - if (isLoading) { - let t8; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t8 = ; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t9 = {t8}; - $[13] = t9; - } else { - t9 = $[13]; + log: LogOption + onExit: () => void + onSelect: (log: LogOption) => void +} + +export function SessionPreview({ + log, + onExit, + onSelect, +}: Props): React.ReactNode { + // fullLog holds the complete log with messages loaded. + // The input `log` may be a "lite log" (empty messages array), + // so we load the full messages on mount and store them here. + const [fullLog, setFullLog] = React.useState(null) + + // Load full messages if this is a lite log + React.useEffect(() => { + setFullLog(null) + if (isLiteLog(log)) { + void loadFullLog(log).then(setFullLog) } - return t9; - } - let t8; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t8 = []; - $[14] = t8; - } else { - t8 = $[14]; - } - let t10; - let t9; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t9 = []; - t10 = new Set(); - $[15] = t10; - $[16] = t9; - } else { - t10 = $[15]; - t9 = $[16]; - } - let t11; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t11 = []; - $[17] = t11; - } else { - t11 = $[17]; - } - let t12; - if ($[18] !== conversationId || $[19] !== displayLog.messages) { - t12 = ; - $[18] = conversationId; - $[19] = displayLog.messages; - $[20] = t12; - } else { - t12 = $[20]; - } - let t13; - if ($[21] !== displayLog.modified) { - t13 = formatRelativeTimeAgo(displayLog.modified); - $[21] = displayLog.modified; - $[22] = t13; - } else { - t13 = $[22]; - } - const t14 = displayLog.gitBranch ? ` · ${displayLog.gitBranch}` : ""; - let t15; - if ($[23] !== displayLog.messageCount || $[24] !== t13 || $[25] !== t14) { - t15 = {t13} ·{" "}{displayLog.messageCount} messages{t14}; - $[23] = displayLog.messageCount; - $[24] = t13; - $[25] = t14; - $[26] = t15; - } else { - t15 = $[26]; - } - let t16; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t16 = ; - $[27] = t16; - } else { - t16 = $[27]; - } - let t17; - if ($[28] !== t15) { - t17 = {t15}{t16}; - $[28] = t15; - $[29] = t17; - } else { - t17 = $[29]; - } - let t18; - if ($[30] !== t12 || $[31] !== t17) { - t18 = {t12}{t17}; - $[30] = t12; - $[31] = t17; - $[32] = t18; - } else { - t18 = $[32]; - } - return t18; + }, [log]) + + const isLoading = isLiteLog(log) && fullLog === null + const displayLog = fullLog ?? log + const conversationId = getSessionIdFromLog(displayLog) || ('' as UUID) + + // Get all base tools for preview (no permissions needed for read-only view) + const tools = getAllBaseTools() + + // Handle keyboard input via keybindings + useKeybinding('confirm:no', onExit, { context: 'Confirmation' }) + + const handleSelect = useCallback(() => { + onSelect(fullLog ?? log) + }, [onSelect, fullLog, log]) + + useKeybinding('confirm:yes', handleSelect, { context: 'Confirmation' }) + + // Show loading state while fetching full log + if (isLoading) { + return ( + + + + + + + + + ) + } + + return ( + + + + + {formatRelativeTimeAgo(displayLog.modified)} ·{' '} + {displayLog.messageCount} messages + {displayLog.gitBranch ? ` · ${displayLog.gitBranch}` : ''} + + + + + + + + + + ) } diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 7d0595f3a..09f832f0c 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -1,1086 +1,1292 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle'; -import { Box, Text, useTheme, useThemeSetting, useTerminalFocus } from '../../ink.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import * as React from 'react'; -import { useState, useCallback } from 'react'; -import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; -import figures from 'figures'; -import { type GlobalConfig, saveGlobalConfig, getCurrentProjectConfig, type OutputStyle } from '../../utils/config.js'; -import { normalizeApiKeyForConfig } from '../../utils/authPortable.js'; -import { getGlobalConfig, getAutoUpdaterDisabledReason, formatAutoUpdaterDisabledReason, getRemoteControlAtStartup } from '../../utils/config.js'; -import chalk from 'chalk'; -import { permissionModeTitle, permissionModeFromString, toExternalPermissionMode, isExternalPermissionMode, EXTERNAL_PERMISSION_MODES, PERMISSION_MODES, type ExternalPermissionMode, type PermissionMode } from '../../utils/permissions/PermissionMode.js'; -import { getAutoModeEnabledState, hasAutoModeOptInAnySource, transitionPlanAutoMode } from '../../utils/permissions/permissionSetup.js'; -import { logError } from '../../utils/log.js'; -import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js'; -import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'; -import { ThemePicker } from '../ThemePicker.js'; -import { useAppState, useSetAppState, useAppStateStore } from '../../state/AppState.js'; -import { ModelPicker } from '../ModelPicker.js'; -import { modelDisplayString, isOpus1mMergeEnabled } from '../../utils/model/model.js'; -import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; -import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js'; -import { ChannelDowngradeDialog, type ChannelDowngradeChoice } from '../ChannelDowngradeDialog.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { Select } from '../CustomSelect/index.js'; -import { OutputStylePicker } from '../OutputStylePicker.js'; -import { LanguagePicker } from '../LanguagePicker.js'; -import { getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes, type MemoryFileInfo } from 'src/utils/claudemd.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { useTabHeaderFocus } from '../design-system/Tabs.js'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { SearchBox } from '../SearchBox.js'; -import { isSupportedTerminal, hasAccessToIDEExtensionDiffFeature } from '../../utils/ide.js'; -import { getInitialSettings, getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; -import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js'; -import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js'; -import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js'; -import type { LocalJSXCommandContext, CommandResultDisplay } from '../../commands.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { getCliTeammateModeOverride, clearCliTeammateModeOverride } from '../../utils/swarm/backends/teammateModeSnapshot.js'; -import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js'; -import { useSearchInput } from '../../hooks/useSearchInput.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel } from '../../utils/fastMode.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import { feature } from 'bun:bundle' +import { + Box, + Text, + useTheme, + useThemeSetting, + useTerminalFocus, +} from '../../ink.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import * as React from 'react' +import { useState, useCallback } from 'react' +import { + useKeybinding, + useKeybindings, +} from '../../keybindings/useKeybinding.js' +import figures from 'figures' +import { + type GlobalConfig, + saveGlobalConfig, + getCurrentProjectConfig, + type OutputStyle, +} from '../../utils/config.js' +import { normalizeApiKeyForConfig } from '../../utils/authPortable.js' +import { + getGlobalConfig, + getAutoUpdaterDisabledReason, + formatAutoUpdaterDisabledReason, + getRemoteControlAtStartup, +} from '../../utils/config.js' +import chalk from 'chalk' +import { + permissionModeTitle, + permissionModeFromString, + toExternalPermissionMode, + isExternalPermissionMode, + EXTERNAL_PERMISSION_MODES, + PERMISSION_MODES, + type ExternalPermissionMode, + type PermissionMode, +} from '../../utils/permissions/PermissionMode.js' +import { + getAutoModeEnabledState, + hasAutoModeOptInAnySource, + transitionPlanAutoMode, +} from '../../utils/permissions/permissionSetup.js' +import { logError } from '../../utils/log.js' +import { + logEvent, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, +} from 'src/services/analytics/index.js' +import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' +import { ThemePicker } from '../ThemePicker.js' +import { + useAppState, + useSetAppState, + useAppStateStore, +} from '../../state/AppState.js' +import { ModelPicker } from '../ModelPicker.js' +import { + modelDisplayString, + isOpus1mMergeEnabled, +} from '../../utils/model/model.js' +import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' +import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js' +import { + ChannelDowngradeDialog, + type ChannelDowngradeChoice, +} from '../ChannelDowngradeDialog.js' +import { Dialog } from '../design-system/Dialog.js' +import { Select } from '../CustomSelect/index.js' +import { OutputStylePicker } from '../OutputStylePicker.js' +import { LanguagePicker } from '../LanguagePicker.js' +import { + getExternalClaudeMdIncludes, + getMemoryFiles, + hasExternalClaudeMdIncludes, +} from 'src/utils/claudemd.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { useTabHeaderFocus } from '../design-system/Tabs.js' +import { useIsInsideModal } from '../../context/modalContext.js' +import { SearchBox } from '../SearchBox.js' +import { + isSupportedTerminal, + hasAccessToIDEExtensionDiffFeature, +} from '../../utils/ide.js' +import { + getInitialSettings, + getSettingsForSource, + updateSettingsForSource, +} from '../../utils/settings/settings.js' +import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js' +import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js' +import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js' +import type { + LocalJSXCommandContext, + CommandResultDisplay, +} from '../../commands.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { + getCliTeammateModeOverride, + clearCliTeammateModeOverride, +} from '../../utils/swarm/backends/teammateModeSnapshot.js' +import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js' +import { useSearchInput } from '../../hooks/useSearchInput.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { + clearFastModeCooldown, + FAST_MODE_MODEL_DISPLAY, + isFastModeAvailable, + isFastModeEnabled, + getFastModeModel, + isFastModeSupportedByModel, +} from '../../utils/fastMode.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' + type Props = { - onClose: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - context: LocalJSXCommandContext; - setTabsHidden: (hidden: boolean) => void; - onIsSearchModeChange?: (inSearchMode: boolean) => void; - contentHeight?: number; -}; -type SettingBase = { - id: string; - label: string; -} | { - id: string; - label: React.ReactNode; - searchText: string; -}; -type Setting = (SettingBase & { - value: boolean; - onChange(value: boolean): void; - type: 'boolean'; -}) | (SettingBase & { - value: string; - options: string[]; - onChange(value: string): void; - type: 'enum'; -}) | (SettingBase & { - // For enums that are set by a custom component, we don't need to pass options, - // but we still need a value to display in the top-level config menu - value: string; - onChange(value: string): void; - type: 'managedEnum'; -}); -type SubMenu = 'Theme' | 'Model' | 'TeammateModel' | 'ExternalIncludes' | 'OutputStyle' | 'ChannelDowngrade' | 'Language' | 'EnableAutoUpdates'; + onClose: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + context: LocalJSXCommandContext + setTabsHidden: (hidden: boolean) => void + onIsSearchModeChange?: (inSearchMode: boolean) => void + contentHeight?: number +} + +type SettingBase = + | { + id: string + label: string + } + | { + id: string + label: React.ReactNode + searchText: string + } + +type Setting = + | (SettingBase & { + value: boolean + onChange(value: boolean): void + type: 'boolean' + }) + | (SettingBase & { + value: string + options: string[] + onChange(value: string): void + type: 'enum' + }) + | (SettingBase & { + // For enums that are set by a custom component, we don't need to pass options, + // but we still need a value to display in the top-level config menu + value: string + onChange(value: string): void + type: 'managedEnum' + }) + +type SubMenu = + | 'Theme' + | 'Model' + | 'TeammateModel' + | 'ExternalIncludes' + | 'OutputStyle' + | 'ChannelDowngrade' + | 'Language' + | 'EnableAutoUpdates' export function Config({ onClose, context, setTabsHidden, onIsSearchModeChange, - contentHeight + contentHeight, }: Props): React.ReactNode { - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - const insideModal = useIsInsideModal(); - const [, setTheme] = useTheme(); - const themeSetting = useThemeSetting(); - const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()); - const initialConfig = React.useRef(getGlobalConfig()); - const [settingsData, setSettingsData] = useState(getInitialSettings()); - const initialSettingsData = React.useRef(getInitialSettings()); - const [currentOutputStyle, setCurrentOutputStyle] = useState(settingsData?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME); - const initialOutputStyle = React.useRef(currentOutputStyle); - const [currentLanguage, setCurrentLanguage] = useState(settingsData?.language); - const initialLanguage = React.useRef(currentLanguage); - const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - const [isSearchMode, setIsSearchMode] = useState(true); - const isTerminalFocused = useTerminalFocus(); - const { - rows - } = useTerminalSize(); + const { headerFocused, focusHeader } = useTabHeaderFocus() + const insideModal = useIsInsideModal() + const [, setTheme] = useTheme() + const themeSetting = useThemeSetting() + const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()) + const initialConfig = React.useRef(getGlobalConfig()) + const [settingsData, setSettingsData] = useState(getInitialSettings()) + const initialSettingsData = React.useRef(getInitialSettings()) + const [currentOutputStyle, setCurrentOutputStyle] = useState( + settingsData?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME, + ) + const initialOutputStyle = React.useRef(currentOutputStyle) + const [currentLanguage, setCurrentLanguage] = useState( + settingsData?.language, + ) + const initialLanguage = React.useRef(currentLanguage) + const [selectedIndex, setSelectedIndex] = useState(0) + const [scrollOffset, setScrollOffset] = useState(0) + const [isSearchMode, setIsSearchMode] = useState(true) + const isTerminalFocused = useTerminalFocus() + const { rows } = useTerminalSize() // contentHeight is set by Settings.tsx (same value passed to Tabs to fix // pane height across all tabs — prevents layout jank when switching). // Reserve ~10 rows for chrome (search box, gaps, footer, scroll hints). // Fallback calc for standalone rendering (tests). - const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30); - const maxVisible = Math.max(5, paneCap - 10); - const mainLoopModel = useAppState(s => s.mainLoopModel); - const verbose = useAppState(s_0 => s_0.verbose); - const thinkingEnabled = useAppState(s_1 => s_1.thinkingEnabled); - const isFastMode = useAppState(s_2 => isFastModeEnabled() ? s_2.fastMode : false); - const promptSuggestionEnabled = useAppState(s_3 => s_3.promptSuggestionEnabled); + const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30) + const maxVisible = Math.max(5, paneCap - 10) + const mainLoopModel = useAppState(s => s.mainLoopModel) + const verbose = useAppState(s => s.verbose) + const thinkingEnabled = useAppState(s => s.thinkingEnabled) + const isFastMode = useAppState(s => + isFastModeEnabled() ? s.fastMode : false, + ) + const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled) // Show auto in the default-mode dropdown when the user has opted in OR the // config is fully 'enabled' — even if currently circuit-broken ('disabled'), // an opted-in user should still see it in settings (it's a temporary state). - const showAutoInDefaultModePicker = feature('TRANSCRIPT_CLASSIFIER') ? hasAutoModeOptInAnySource() || getAutoModeEnabledState() === 'enabled' : false; + const showAutoInDefaultModePicker = feature('TRANSCRIPT_CLASSIFIER') + ? hasAutoModeOptInAnySource() || getAutoModeEnabledState() === 'enabled' + : false // Chat/Transcript view picker is visible to entitled users (pass the GB // gate) even if they haven't opted in this session — it IS the persistent // opt-in. 'chat' written here is read at next startup by main.tsx which // sets userMsgOptIn if still entitled. /* eslint-disable @typescript-eslint/no-require-imports */ - const showDefaultViewPicker = feature('KAIROS') || feature('KAIROS_BRIEF') ? (require('../../tools/BriefTool/BriefTool.js') as typeof import('../../tools/BriefTool/BriefTool.js')).isBriefEntitled() : false; + const showDefaultViewPicker = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? ( + require('../../tools/BriefTool/BriefTool.js') as typeof import('../../tools/BriefTool/BriefTool.js') + ).isBriefEntitled() + : false /* eslint-enable @typescript-eslint/no-require-imports */ - const setAppState = useSetAppState(); - const [changes, setChanges] = useState<{ - [key: string]: unknown; - }>({}); - const initialThinkingEnabled = React.useRef(thinkingEnabled); + const setAppState = useSetAppState() + const [changes, setChanges] = useState<{ [key: string]: unknown }>({}) + const initialThinkingEnabled = React.useRef(thinkingEnabled) // Per-source settings snapshots for revert-on-escape. getInitialSettings() // returns merged-across-sources which can't tell us what to delete vs // restore; per-source snapshots + updateSettingsForSource's // undefined-deletes-key semantics can. Lazy-init via useState (no setter) to // avoid reading settings files on every render — useRef evaluates its arg // eagerly even though only the first result is kept. - const [initialLocalSettings] = useState(() => getSettingsForSource('localSettings')); - const [initialUserSettings] = useState(() => getSettingsForSource('userSettings')); - const initialThemeSetting = React.useRef(themeSetting); + const [initialLocalSettings] = useState(() => + getSettingsForSource('localSettings'), + ) + const [initialUserSettings] = useState(() => + getSettingsForSource('userSettings'), + ) + const initialThemeSetting = React.useRef(themeSetting) // AppState fields Config may modify — snapshot once at mount. - const store = useAppStateStore(); + const store = useAppStateStore() const [initialAppState] = useState(() => { - const s_4 = store.getState(); + const s = store.getState() return { - mainLoopModel: s_4.mainLoopModel, - mainLoopModelForSession: s_4.mainLoopModelForSession, - verbose: s_4.verbose, - thinkingEnabled: s_4.thinkingEnabled, - fastMode: s_4.fastMode, - promptSuggestionEnabled: s_4.promptSuggestionEnabled, - isBriefOnly: s_4.isBriefOnly, - replBridgeEnabled: s_4.replBridgeEnabled, - replBridgeOutboundOnly: s_4.replBridgeOutboundOnly, - settings: s_4.settings - }; - }); + mainLoopModel: s.mainLoopModel, + mainLoopModelForSession: s.mainLoopModelForSession, + verbose: s.verbose, + thinkingEnabled: s.thinkingEnabled, + fastMode: s.fastMode, + promptSuggestionEnabled: s.promptSuggestionEnabled, + isBriefOnly: s.isBriefOnly, + replBridgeEnabled: s.replBridgeEnabled, + replBridgeOutboundOnly: s.replBridgeOutboundOnly, + settings: s.settings, + } + }) // Bootstrap state snapshot — userMsgOptIn is outside AppState, so // revertChanges needs to restore it separately. Without this, cycling // defaultView to 'chat' then Escape leaves the tool active while the // display filter reverts — the exact ambient-activation behavior this // PR's entitlement/opt-in split is meant to prevent. - const [initialUserMsgOptIn] = useState(() => getUserMsgOptIn()); + const [initialUserMsgOptIn] = useState(() => getUserMsgOptIn()) // Set on first user-visible change; gates revertChanges() on Escape so // opening-then-closing doesn't trigger redundant disk writes. - const isDirty = React.useRef(false); - const [showThinkingWarning, setShowThinkingWarning] = useState(false); - const [showSubmenu, setShowSubmenu] = useState(null); + const isDirty = React.useRef(false) + const [showThinkingWarning, setShowThinkingWarning] = useState(false) + const [showSubmenu, setShowSubmenu] = useState(null) const { query: searchQuery, setQuery: setSearchQuery, - cursorOffset: searchCursorOffset + cursorOffset: searchCursorOffset, } = useSearchInput({ isActive: isSearchMode && showSubmenu === null && !headerFocused, onExit: () => setIsSearchMode(false), onExitUp: focusHeader, // Ctrl+C/D must reach Settings' useExitOnCtrlCD; 'd' also avoids // double-action (delete-char + exit-pending). - passthroughCtrlKeys: ['c', 'd'] - }); + passthroughCtrlKeys: ['c', 'd'], + }) // Tell the parent when Config's own Esc handler is active so Settings cedes // confirm:no. Only true when search mode owns the keyboard — not when the // tab header is focused (then Settings must handle Esc-to-close). - const ownsEsc = isSearchMode && !headerFocused; + const ownsEsc = isSearchMode && !headerFocused React.useEffect(() => { - onIsSearchModeChange?.(ownsEsc); - }, [ownsEsc, onIsSearchModeChange]); - const isConnectedToIde = hasAccessToIDEExtensionDiffFeature(context.options.mcpClients); - const isFileCheckpointingAvailable = !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING); - const memoryFiles = React.use(getMemoryFiles(true)) as MemoryFileInfo[]; - const shouldShowExternalIncludesToggle = hasExternalClaudeMdIncludes(memoryFiles); - const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason(); + onIsSearchModeChange?.(ownsEsc) + }, [ownsEsc, onIsSearchModeChange]) + + const isConnectedToIde = hasAccessToIDEExtensionDiffFeature( + context.options.mcpClients, + ) + + const isFileCheckpointingAvailable = !isEnvTruthy( + process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING, + ) + + const memoryFiles = React.use(getMemoryFiles(true)) + const shouldShowExternalIncludesToggle = + hasExternalClaudeMdIncludes(memoryFiles) + + const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason() + function onChangeMainModelConfig(value: string | null): void { - const previousModel = mainLoopModel; + const previousModel = mainLoopModel logEvent('tengu_config_model_changed', { - from_model: previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - to_model: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + from_model: + previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + to_model: + value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) setAppState(prev => ({ ...prev, mainLoopModel: value, - mainLoopModelForSession: null - })); - setChanges(prev_0 => { - const valStr = modelDisplayString(value) + (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) ? ' · Billed as extra usage' : ''); - if ('model' in prev_0) { - const { - model, - ...rest - } = prev_0; - return { - ...rest, - model: valStr - }; + mainLoopModelForSession: null, + })) + setChanges(prev => { + const valStr = + modelDisplayString(value) + + (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) + ? ' · Billed as extra usage' + : '') + if ('model' in prev) { + const { model, ...rest } = prev + return { ...rest, model: valStr } } - return { - ...prev_0, - model: valStr - }; - }); + return { ...prev, model: valStr } + }) } - function onChangeVerbose(value_0: boolean): void { + + function onChangeVerbose(value: boolean): void { // Update the global config to persist the setting - saveGlobalConfig(current => ({ - ...current, - verbose: value_0 - })); - setGlobalConfig({ - ...getGlobalConfig(), - verbose: value_0 - }); + saveGlobalConfig(current => ({ ...current, verbose: value })) + setGlobalConfig({ ...getGlobalConfig(), verbose: value }) // Update the app state for immediate UI feedback - setAppState(prev_1 => ({ - ...prev_1, - verbose: value_0 - })); - setChanges(prev_2 => { - if ('verbose' in prev_2) { - const { - verbose: verbose_0, - ...rest_0 - } = prev_2; - return rest_0; + setAppState(prev => ({ + ...prev, + verbose: value, + })) + setChanges(prev => { + if ('verbose' in prev) { + const { verbose, ...rest } = prev + return rest } - return { - ...prev_2, - verbose: value_0 - }; - }); + return { ...prev, verbose: value } + }) } // TODO: Add MCP servers const settingsItems: Setting[] = [ - // Global settings - { - id: 'autoCompactEnabled', - label: 'Auto-compact', - value: globalConfig.autoCompactEnabled, - type: 'boolean' as const, - onChange(autoCompactEnabled: boolean) { - saveGlobalConfig(current_0 => ({ - ...current_0, - autoCompactEnabled - })); - setGlobalConfig({ - ...getGlobalConfig(), - autoCompactEnabled - }); - logEvent('tengu_auto_compact_setting_changed', { - enabled: autoCompactEnabled - }); - } - }, { - id: 'spinnerTipsEnabled', - label: 'Show tips', - value: settingsData?.spinnerTipsEnabled ?? true, - type: 'boolean' as const, - onChange(spinnerTipsEnabled: boolean) { - updateSettingsForSource('localSettings', { - spinnerTipsEnabled - }); - // Update local state to reflect the change immediately - setSettingsData(prev_3 => ({ - ...prev_3, - spinnerTipsEnabled - })); - logEvent('tengu_tips_setting_changed', { - enabled: spinnerTipsEnabled - }); - } - }, { - id: 'prefersReducedMotion', - label: 'Reduce motion', - value: settingsData?.prefersReducedMotion ?? false, - type: 'boolean' as const, - onChange(prefersReducedMotion: boolean) { - updateSettingsForSource('localSettings', { - prefersReducedMotion - }); - setSettingsData(prev_4 => ({ - ...prev_4, - prefersReducedMotion - })); - // Sync to AppState so components react immediately - setAppState(prev_5 => ({ - ...prev_5, - settings: { - ...prev_5.settings, - prefersReducedMotion - } - })); - logEvent('tengu_reduce_motion_setting_changed', { - enabled: prefersReducedMotion - }); - } - }, { - id: 'thinkingEnabled', - label: 'Thinking mode', - value: thinkingEnabled ?? true, - type: 'boolean' as const, - onChange(enabled: boolean) { - setAppState(prev_6 => ({ - ...prev_6, - thinkingEnabled: enabled - })); - updateSettingsForSource('userSettings', { - alwaysThinkingEnabled: enabled ? undefined : false - }); - logEvent('tengu_thinking_toggled', { - enabled - }); - } - }, - // Fast mode toggle (ant-only, eliminated from external builds) - ...(isFastModeEnabled() && isFastModeAvailable() ? [{ - id: 'fastMode', - label: `Fast mode (${FAST_MODE_MODEL_DISPLAY} only)`, - value: !!isFastMode, - type: 'boolean' as const, - onChange(enabled_0: boolean) { - clearFastModeCooldown(); - updateSettingsForSource('userSettings', { - fastMode: enabled_0 ? true : undefined - }); - if (enabled_0) { - setAppState(prev_7 => ({ - ...prev_7, - mainLoopModel: getFastModeModel(), - mainLoopModelForSession: null, - fastMode: true - })); - setChanges(prev_8 => ({ - ...prev_8, - model: getFastModeModel(), - 'Fast mode': 'ON' - })); - } else { - setAppState(prev_9 => ({ - ...prev_9, - fastMode: false - })); - setChanges(prev_10 => ({ - ...prev_10, - 'Fast mode': 'OFF' - })); - } - } - }] : []), ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_chomp_inflection', false) ? [{ - id: 'promptSuggestionEnabled', - label: 'Prompt suggestions', - value: promptSuggestionEnabled, - type: 'boolean' as const, - onChange(enabled_1: boolean) { - setAppState(prev_11 => ({ - ...prev_11, - promptSuggestionEnabled: enabled_1 - })); - updateSettingsForSource('userSettings', { - promptSuggestionEnabled: enabled_1 ? undefined : false - }); - } - }] : []), - // Speculation toggle (ant-only) - ...((process.env.USER_TYPE) === 'ant' ? [{ - id: 'speculationEnabled', - label: 'Speculative execution', - value: globalConfig.speculationEnabled ?? true, - type: 'boolean' as const, - onChange(enabled_2: boolean) { - saveGlobalConfig(current_1 => { - if (current_1.speculationEnabled === enabled_2) return current_1; - return { - ...current_1, - speculationEnabled: enabled_2 - }; - }); - setGlobalConfig({ - ...getGlobalConfig(), - speculationEnabled: enabled_2 - }); - logEvent('tengu_speculation_setting_changed', { - enabled: enabled_2 - }); - } - }] : []), ...(isFileCheckpointingAvailable ? [{ - id: 'fileCheckpointingEnabled', - label: 'Rewind code (checkpoints)', - value: globalConfig.fileCheckpointingEnabled, - type: 'boolean' as const, - onChange(enabled_3: boolean) { - saveGlobalConfig(current_2 => ({ - ...current_2, - fileCheckpointingEnabled: enabled_3 - })); - setGlobalConfig({ - ...getGlobalConfig(), - fileCheckpointingEnabled: enabled_3 - }); - logEvent('tengu_file_history_snapshots_setting_changed', { - enabled: enabled_3 - }); - } - }] : []), { - id: 'verbose', - label: 'Verbose output', - value: verbose, - type: 'boolean', - onChange: onChangeVerbose - }, { - id: 'terminalProgressBarEnabled', - label: 'Terminal progress bar', - value: globalConfig.terminalProgressBarEnabled, - type: 'boolean' as const, - onChange(terminalProgressBarEnabled: boolean) { - saveGlobalConfig(current_3 => ({ - ...current_3, - terminalProgressBarEnabled - })); - setGlobalConfig({ - ...getGlobalConfig(), - terminalProgressBarEnabled - }); - logEvent('tengu_terminal_progress_bar_setting_changed', { - enabled: terminalProgressBarEnabled - }); - } - }, ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false) ? [{ - id: 'showStatusInTerminalTab', - label: 'Show status in terminal tab', - value: globalConfig.showStatusInTerminalTab ?? false, - type: 'boolean' as const, - onChange(showStatusInTerminalTab: boolean) { - saveGlobalConfig(current_4 => ({ - ...current_4, - showStatusInTerminalTab - })); - setGlobalConfig({ - ...getGlobalConfig(), - showStatusInTerminalTab - }); - logEvent('tengu_terminal_tab_status_setting_changed', { - enabled: showStatusInTerminalTab - }); - } - }] : []), { - id: 'showTurnDuration', - label: 'Show turn duration', - value: globalConfig.showTurnDuration, - type: 'boolean' as const, - onChange(showTurnDuration: boolean) { - saveGlobalConfig(current_5 => ({ - ...current_5, - showTurnDuration - })); - setGlobalConfig({ - ...getGlobalConfig(), - showTurnDuration - }); - logEvent('tengu_show_turn_duration_setting_changed', { - enabled: showTurnDuration - }); - } - }, { - id: 'defaultPermissionMode', - label: 'Default permission mode', - value: settingsData?.permissions?.defaultMode || 'default', - options: (() => { - const priorityOrder: PermissionMode[] = ['default', 'plan']; - const allModes: readonly PermissionMode[] = feature('TRANSCRIPT_CLASSIFIER') ? PERMISSION_MODES : EXTERNAL_PERMISSION_MODES; - const excluded: PermissionMode[] = ['bypassPermissions']; - if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) { - excluded.push('auto'); - } - return [...priorityOrder, ...allModes.filter(m => !priorityOrder.includes(m) && !excluded.includes(m))]; - })(), - type: 'enum' as const, - onChange(mode: string) { - const parsedMode = permissionModeFromString(mode); - // Internal modes (e.g. auto) are stored directly - const validatedMode = isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode; - const result = updateSettingsForSource('userSettings', { - permissions: { - ...settingsData?.permissions, - defaultMode: validatedMode as ExternalPermissionMode + // Global settings + { + id: 'autoCompactEnabled', + label: 'Auto-compact', + value: globalConfig.autoCompactEnabled, + type: 'boolean' as const, + onChange(autoCompactEnabled: boolean) { + saveGlobalConfig(current => ({ ...current, autoCompactEnabled })) + setGlobalConfig({ ...getGlobalConfig(), autoCompactEnabled }) + logEvent('tengu_auto_compact_setting_changed', { + enabled: autoCompactEnabled, + }) + }, + }, + { + id: 'spinnerTipsEnabled', + label: 'Show tips', + value: settingsData?.spinnerTipsEnabled ?? true, + type: 'boolean' as const, + onChange(spinnerTipsEnabled: boolean) { + updateSettingsForSource('localSettings', { + spinnerTipsEnabled, + }) + // Update local state to reflect the change immediately + setSettingsData(prev => ({ + ...prev, + spinnerTipsEnabled, + })) + logEvent('tengu_tips_setting_changed', { + enabled: spinnerTipsEnabled, + }) + }, + }, + { + id: 'prefersReducedMotion', + label: 'Reduce motion', + value: settingsData?.prefersReducedMotion ?? false, + type: 'boolean' as const, + onChange(prefersReducedMotion: boolean) { + updateSettingsForSource('localSettings', { + prefersReducedMotion, + }) + setSettingsData(prev => ({ + ...prev, + prefersReducedMotion, + })) + // Sync to AppState so components react immediately + setAppState(prev => ({ + ...prev, + settings: { ...prev.settings, prefersReducedMotion }, + })) + logEvent('tengu_reduce_motion_setting_changed', { + enabled: prefersReducedMotion, + }) + }, + }, + { + id: 'thinkingEnabled', + label: 'Thinking mode', + value: thinkingEnabled ?? true, + type: 'boolean' as const, + onChange(enabled: boolean) { + setAppState(prev => ({ ...prev, thinkingEnabled: enabled })) + updateSettingsForSource('userSettings', { + alwaysThinkingEnabled: enabled ? undefined : false, + }) + logEvent('tengu_thinking_toggled', { enabled }) + }, + }, + // Fast mode toggle (ant-only, eliminated from external builds) + ...(isFastModeEnabled() && isFastModeAvailable() + ? [ + { + id: 'fastMode', + label: `Fast mode (${FAST_MODE_MODEL_DISPLAY} only)`, + value: !!isFastMode, + type: 'boolean' as const, + onChange(enabled: boolean) { + clearFastModeCooldown() + updateSettingsForSource('userSettings', { + fastMode: enabled ? true : undefined, + }) + if (enabled) { + setAppState(prev => ({ + ...prev, + mainLoopModel: getFastModeModel(), + mainLoopModelForSession: null, + fastMode: true, + })) + setChanges(prev => ({ + ...prev, + model: getFastModeModel(), + 'Fast mode': 'ON', + })) + } else { + setAppState(prev => ({ + ...prev, + fastMode: false, + })) + setChanges(prev => ({ ...prev, 'Fast mode': 'OFF' })) + } + }, + }, + ] + : []), + ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_chomp_inflection', false) + ? [ + { + id: 'promptSuggestionEnabled', + label: 'Prompt suggestions', + value: promptSuggestionEnabled, + type: 'boolean' as const, + onChange(enabled: boolean) { + setAppState(prev => ({ + ...prev, + promptSuggestionEnabled: enabled, + })) + updateSettingsForSource('userSettings', { + promptSuggestionEnabled: enabled ? undefined : false, + }) + }, + }, + ] + : []), + // Speculation toggle (ant-only) + ...(process.env.USER_TYPE === 'ant' + ? [ + { + id: 'speculationEnabled', + label: 'Speculative execution', + value: globalConfig.speculationEnabled ?? true, + type: 'boolean' as const, + onChange(enabled: boolean) { + saveGlobalConfig(current => { + if (current.speculationEnabled === enabled) return current + return { + ...current, + speculationEnabled: enabled, + } + }) + setGlobalConfig({ + ...getGlobalConfig(), + speculationEnabled: enabled, + }) + logEvent('tengu_speculation_setting_changed', { + enabled, + }) + }, + }, + ] + : []), + ...(isFileCheckpointingAvailable + ? [ + { + id: 'fileCheckpointingEnabled', + label: 'Rewind code (checkpoints)', + value: globalConfig.fileCheckpointingEnabled, + type: 'boolean' as const, + onChange(enabled: boolean) { + saveGlobalConfig(current => ({ + ...current, + fileCheckpointingEnabled: enabled, + })) + setGlobalConfig({ + ...getGlobalConfig(), + fileCheckpointingEnabled: enabled, + }) + logEvent('tengu_file_history_snapshots_setting_changed', { + enabled: enabled, + }) + }, + }, + ] + : []), + { + id: 'verbose', + label: 'Verbose output', + value: verbose, + type: 'boolean', + onChange: onChangeVerbose, + }, + { + id: 'terminalProgressBarEnabled', + label: 'Terminal progress bar', + value: globalConfig.terminalProgressBarEnabled, + type: 'boolean' as const, + onChange(terminalProgressBarEnabled: boolean) { + saveGlobalConfig(current => ({ + ...current, + terminalProgressBarEnabled, + })) + setGlobalConfig({ ...getGlobalConfig(), terminalProgressBarEnabled }) + logEvent('tengu_terminal_progress_bar_setting_changed', { + enabled: terminalProgressBarEnabled, + }) + }, + }, + ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false) + ? [ + { + id: 'showStatusInTerminalTab', + label: 'Show status in terminal tab', + value: globalConfig.showStatusInTerminalTab ?? false, + type: 'boolean' as const, + onChange(showStatusInTerminalTab: boolean) { + saveGlobalConfig(current => ({ + ...current, + showStatusInTerminalTab, + })) + setGlobalConfig({ + ...getGlobalConfig(), + showStatusInTerminalTab, + }) + logEvent('tengu_terminal_tab_status_setting_changed', { + enabled: showStatusInTerminalTab, + }) + }, + }, + ] + : []), + { + id: 'showTurnDuration', + label: 'Show turn duration', + value: globalConfig.showTurnDuration, + type: 'boolean' as const, + onChange(showTurnDuration: boolean) { + saveGlobalConfig(current => ({ ...current, showTurnDuration })) + setGlobalConfig({ ...getGlobalConfig(), showTurnDuration }) + logEvent('tengu_show_turn_duration_setting_changed', { + enabled: showTurnDuration, + }) + }, + }, + { + id: 'defaultPermissionMode', + label: 'Default permission mode', + value: settingsData?.permissions?.defaultMode || 'default', + options: (() => { + const priorityOrder: PermissionMode[] = ['default', 'plan'] + const allModes: readonly PermissionMode[] = feature( + 'TRANSCRIPT_CLASSIFIER', + ) + ? PERMISSION_MODES + : EXTERNAL_PERMISSION_MODES + const excluded: PermissionMode[] = ['bypassPermissions'] + if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) { + excluded.push('auto') } - }); - if (result.error) { - logError(result.error); - return; - } + return [ + ...priorityOrder, + ...allModes.filter( + m => !priorityOrder.includes(m) && !excluded.includes(m), + ), + ] + })(), + type: 'enum' as const, + onChange(mode: string) { + const parsedMode = permissionModeFromString(mode) + // Internal modes (e.g. auto) are stored directly + const validatedMode = isExternalPermissionMode(parsedMode) + ? toExternalPermissionMode(parsedMode) + : parsedMode + const result = updateSettingsForSource('userSettings', { + permissions: { + ...settingsData?.permissions, + defaultMode: validatedMode as ExternalPermissionMode, + }, + }) - // Update local state to reflect the change immediately. - // validatedMode is typed as the wide PermissionMode union but at - // runtime is always a PERMISSION_MODES member (the options dropdown - // is built from that array above), so this narrowing is sound. - setSettingsData(prev_12 => ({ - ...prev_12, - permissions: { - ...prev_12?.permissions, - defaultMode: validatedMode as (typeof PERMISSION_MODES)[number] + if (result.error) { + logError(result.error) + return } - })); - // Track changes - setChanges(prev_13 => ({ - ...prev_13, - defaultPermissionMode: mode - })); - logEvent('tengu_config_changed', { - setting: 'defaultPermissionMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }, ...(feature('TRANSCRIPT_CLASSIFIER') && showAutoInDefaultModePicker ? [{ - id: 'useAutoModeDuringPlan', - label: 'Use auto mode during plan', - value: (settingsData as { - useAutoModeDuringPlan?: boolean; - } | undefined)?.useAutoModeDuringPlan ?? true, - type: 'boolean' as const, - onChange(useAutoModeDuringPlan: boolean) { - updateSettingsForSource('userSettings', { - useAutoModeDuringPlan - }); - setSettingsData(prev_14 => ({ - ...prev_14, - useAutoModeDuringPlan - })); - // Internal writes suppress the file watcher, so - // applySettingsChange won't fire. Reconcile directly so - // mid-plan toggles take effect immediately. - setAppState(prev_15 => { - const next = transitionPlanAutoMode(prev_15.toolPermissionContext); - if (next === prev_15.toolPermissionContext) return prev_15; - return { - ...prev_15, - toolPermissionContext: next - }; - }); - setChanges(prev_16 => ({ - ...prev_16, - 'Use auto mode during plan': useAutoModeDuringPlan - })); - } - }] : []), { - id: 'respectGitignore', - label: 'Respect .gitignore in file picker', - value: globalConfig.respectGitignore, - type: 'boolean' as const, - onChange(respectGitignore: boolean) { - saveGlobalConfig(current_6 => ({ - ...current_6, - respectGitignore - })); - setGlobalConfig({ - ...getGlobalConfig(), - respectGitignore - }); - logEvent('tengu_respect_gitignore_setting_changed', { - enabled: respectGitignore - }); - } - }, { - id: 'copyFullResponse', - label: 'Always copy full response (skip /copy picker)', - value: globalConfig.copyFullResponse, - type: 'boolean' as const, - onChange(copyFullResponse: boolean) { - saveGlobalConfig(current_7 => ({ - ...current_7, - copyFullResponse - })); - setGlobalConfig({ - ...getGlobalConfig(), - copyFullResponse - }); - logEvent('tengu_config_changed', { - setting: 'copyFullResponse' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: String(copyFullResponse) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }, - // Copy-on-select is only meaningful with in-app selection (fullscreen - // alt-screen mode). In inline mode the terminal emulator owns selection. - ...(isFullscreenEnvEnabled() ? [{ - id: 'copyOnSelect', - label: 'Copy on select', - value: globalConfig.copyOnSelect ?? true, - type: 'boolean' as const, - onChange(copyOnSelect: boolean) { - saveGlobalConfig(current_8 => ({ - ...current_8, - copyOnSelect - })); - setGlobalConfig({ - ...getGlobalConfig(), - copyOnSelect - }); - logEvent('tengu_config_changed', { - setting: 'copyOnSelect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: String(copyOnSelect) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }] : []), - // autoUpdates setting is hidden - use DISABLE_AUTOUPDATER env var to control - autoUpdaterDisabledReason ? { - id: 'autoUpdatesChannel', - label: 'Auto-update channel', - value: 'disabled', - type: 'managedEnum' as const, - onChange() {} - } : { - id: 'autoUpdatesChannel', - label: 'Auto-update channel', - value: settingsData?.autoUpdatesChannel ?? 'latest', - type: 'managedEnum' as const, - onChange() { - // Handled via toggleSetting -> 'ChannelDowngrade' - } - }, { - id: 'theme', - label: 'Theme', - value: themeSetting, - type: 'managedEnum', - onChange: setTheme - }, { - id: 'notifChannel', - label: feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') ? 'Local notifications' : 'Notifications', - value: globalConfig.preferredNotifChannel, - options: ['auto', 'iterm2', 'terminal_bell', 'iterm2_with_bell', 'kitty', 'ghostty', 'notifications_disabled'], - type: 'enum', - onChange(notifChannel: GlobalConfig['preferredNotifChannel']) { - saveGlobalConfig(current_9 => ({ - ...current_9, - preferredNotifChannel: notifChannel - })); - setGlobalConfig({ - ...getGlobalConfig(), - preferredNotifChannel: notifChannel - }); - } - }, ...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') ? [{ - id: 'taskCompleteNotifEnabled', - label: 'Push when idle', - value: globalConfig.taskCompleteNotifEnabled ?? false, - type: 'boolean' as const, - onChange(taskCompleteNotifEnabled: boolean) { - saveGlobalConfig(current_10 => ({ - ...current_10, - taskCompleteNotifEnabled - })); - setGlobalConfig({ - ...getGlobalConfig(), - taskCompleteNotifEnabled - }); - } - }, { - id: 'inputNeededNotifEnabled', - label: 'Push when input needed', - value: globalConfig.inputNeededNotifEnabled ?? false, - type: 'boolean' as const, - onChange(inputNeededNotifEnabled: boolean) { - saveGlobalConfig(current_11 => ({ - ...current_11, - inputNeededNotifEnabled - })); - setGlobalConfig({ - ...getGlobalConfig(), - inputNeededNotifEnabled - }); - } - }, { - id: 'agentPushNotifEnabled', - label: 'Push when Claude decides', - value: globalConfig.agentPushNotifEnabled ?? false, - type: 'boolean' as const, - onChange(agentPushNotifEnabled: boolean) { - saveGlobalConfig(current_12 => ({ - ...current_12, - agentPushNotifEnabled - })); - setGlobalConfig({ - ...getGlobalConfig(), - agentPushNotifEnabled - }); - } - }] : []), { - id: 'outputStyle', - label: 'Output style', - value: currentOutputStyle, - type: 'managedEnum' as const, - onChange: () => {} // handled by OutputStylePicker submenu - }, ...(showDefaultViewPicker ? [{ - id: 'defaultView', - label: 'What you see by default', - // 'default' means the setting is unset — currently resolves to - // transcript (main.tsx falls through when defaultView !== 'chat'). - // String() narrows the conditional-schema-spread union to string. - value: settingsData?.defaultView === undefined ? 'default' : String(settingsData.defaultView), - options: ['transcript', 'chat', 'default'], - type: 'enum' as const, - onChange(selected: string) { - const defaultView = selected === 'default' ? undefined : selected as 'chat' | 'transcript'; - updateSettingsForSource('localSettings', { - defaultView - }); - setSettingsData(prev_17 => ({ - ...prev_17, - defaultView - })); - const nextBrief = defaultView === 'chat'; - setAppState(prev_18 => { - if (prev_18.isBriefOnly === nextBrief) return prev_18; - return { - ...prev_18, - isBriefOnly: nextBrief - }; - }); - // Keep userMsgOptIn in sync so the tool list follows the view. - // Two-way now (same as /brief) — accepting a cache invalidation - // is better than leaving the tool on after switching away. - // Reverted on Escape via initialUserMsgOptIn snapshot. - setUserMsgOptIn(nextBrief); - setChanges(prev_19 => ({ - ...prev_19, - 'Default view': selected - })); - logEvent('tengu_default_view_setting_changed', { - value: (defaultView ?? 'unset') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }] : []), { - id: 'language', - label: 'Language', - value: currentLanguage ?? 'Default (English)', - type: 'managedEnum' as const, - onChange: () => {} // handled by LanguagePicker submenu - }, { - id: 'editorMode', - label: 'Editor mode', - // Convert 'emacs' to 'normal' for backward compatibility - value: globalConfig.editorMode === 'emacs' ? 'normal' : globalConfig.editorMode || 'normal', - options: ['normal', 'vim'], - type: 'enum', - onChange(value_1: string) { - saveGlobalConfig(current_13 => ({ - ...current_13, - editorMode: value_1 as GlobalConfig['editorMode'] - })); - setGlobalConfig({ - ...getGlobalConfig(), - editorMode: value_1 as GlobalConfig['editorMode'] - }); - logEvent('tengu_editor_mode_changed', { - mode: value_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }, { - id: 'prStatusFooterEnabled', - label: 'Show PR status footer', - value: globalConfig.prStatusFooterEnabled ?? true, - type: 'boolean' as const, - onChange(enabled_4: boolean) { - saveGlobalConfig(current_14 => { - if (current_14.prStatusFooterEnabled === enabled_4) return current_14; - return { - ...current_14, - prStatusFooterEnabled: enabled_4 - }; - }); - setGlobalConfig({ - ...getGlobalConfig(), - prStatusFooterEnabled: enabled_4 - }); - logEvent('tengu_pr_status_footer_setting_changed', { - enabled: enabled_4 - }); - } - }, { - id: 'model', - label: 'Model', - value: mainLoopModel === null ? 'Default (recommended)' : mainLoopModel, - type: 'managedEnum' as const, - onChange: onChangeMainModelConfig - }, ...(isConnectedToIde ? [{ - id: 'diffTool', - label: 'Diff tool', - value: globalConfig.diffTool ?? 'auto', - options: ['terminal', 'auto'], - type: 'enum' as const, - onChange(diffTool: string) { - saveGlobalConfig(current_15 => ({ - ...current_15, - diffTool: diffTool as GlobalConfig['diffTool'] - })); - setGlobalConfig({ - ...getGlobalConfig(), - diffTool: diffTool as GlobalConfig['diffTool'] - }); - logEvent('tengu_diff_tool_changed', { - tool: diffTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }] : []), ...(!isSupportedTerminal() ? [{ - id: 'autoConnectIde', - label: 'Auto-connect to IDE (external terminal)', - value: globalConfig.autoConnectIde ?? false, - type: 'boolean' as const, - onChange(autoConnectIde: boolean) { - saveGlobalConfig(current_16 => ({ - ...current_16, - autoConnectIde - })); - setGlobalConfig({ - ...getGlobalConfig(), - autoConnectIde - }); - logEvent('tengu_auto_connect_ide_changed', { - enabled: autoConnectIde, - source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }] : []), ...(isSupportedTerminal() ? [{ - id: 'autoInstallIdeExtension', - label: 'Auto-install IDE extension', - value: globalConfig.autoInstallIdeExtension ?? true, - type: 'boolean' as const, - onChange(autoInstallIdeExtension: boolean) { - saveGlobalConfig(current_17 => ({ - ...current_17, - autoInstallIdeExtension - })); - setGlobalConfig({ - ...getGlobalConfig(), - autoInstallIdeExtension - }); - logEvent('tengu_auto_install_ide_extension_changed', { - enabled: autoInstallIdeExtension, - source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }] : []), { - id: 'claudeInChromeDefaultEnabled', - label: 'Claude in Chrome enabled by default', - value: globalConfig.claudeInChromeDefaultEnabled ?? true, - type: 'boolean' as const, - onChange(enabled_5: boolean) { - saveGlobalConfig(current_18 => ({ - ...current_18, - claudeInChromeDefaultEnabled: enabled_5 - })); - setGlobalConfig({ - ...getGlobalConfig(), - claudeInChromeDefaultEnabled: enabled_5 - }); - logEvent('tengu_claude_in_chrome_setting_changed', { - enabled: enabled_5 - }); - } - }, - // Teammate mode (only shown when agent swarms are enabled) - ...(isAgentSwarmsEnabled() ? (() => { - const cliOverride = getCliTeammateModeOverride(); - const label = cliOverride ? `Teammate mode [overridden: ${cliOverride}]` : 'Teammate mode'; - return [{ - id: 'teammateMode', - label, - value: globalConfig.teammateMode ?? 'auto', - options: ['auto', 'tmux', 'in-process'], - type: 'enum' as const, - onChange(mode_0: string) { - if (mode_0 !== 'auto' && mode_0 !== 'tmux' && mode_0 !== 'in-process') { - return; + + // Update local state to reflect the change immediately. + // validatedMode is typed as the wide PermissionMode union but at + // runtime is always a PERMISSION_MODES member (the options dropdown + // is built from that array above), so this narrowing is sound. + setSettingsData(prev => ({ + ...prev, + permissions: { + ...prev?.permissions, + defaultMode: validatedMode as (typeof PERMISSION_MODES)[number], + }, + })) + // Track changes + setChanges(prev => ({ ...prev, defaultPermissionMode: mode })) + logEvent('tengu_config_changed', { + setting: + 'defaultPermissionMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: + mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + ...(feature('TRANSCRIPT_CLASSIFIER') && showAutoInDefaultModePicker + ? [ + { + id: 'useAutoModeDuringPlan', + label: 'Use auto mode during plan', + value: + (settingsData as { useAutoModeDuringPlan?: boolean } | undefined) + ?.useAutoModeDuringPlan ?? true, + type: 'boolean' as const, + onChange(useAutoModeDuringPlan: boolean) { + updateSettingsForSource('userSettings', { + useAutoModeDuringPlan, + }) + setSettingsData(prev => ({ + ...prev, + useAutoModeDuringPlan, + })) + // Internal writes suppress the file watcher, so + // applySettingsChange won't fire. Reconcile directly so + // mid-plan toggles take effect immediately. + setAppState(prev => { + const next = transitionPlanAutoMode(prev.toolPermissionContext) + if (next === prev.toolPermissionContext) return prev + return { ...prev, toolPermissionContext: next } + }) + setChanges(prev => ({ + ...prev, + 'Use auto mode during plan': useAutoModeDuringPlan, + })) + }, + }, + ] + : []), + { + id: 'respectGitignore', + label: 'Respect .gitignore in file picker', + value: globalConfig.respectGitignore, + type: 'boolean' as const, + onChange(respectGitignore: boolean) { + saveGlobalConfig(current => ({ ...current, respectGitignore })) + setGlobalConfig({ ...getGlobalConfig(), respectGitignore }) + logEvent('tengu_respect_gitignore_setting_changed', { + enabled: respectGitignore, + }) + }, + }, + { + id: 'copyFullResponse', + label: 'Always copy full response (skip /copy picker)', + value: globalConfig.copyFullResponse, + type: 'boolean' as const, + onChange(copyFullResponse: boolean) { + saveGlobalConfig(current => ({ ...current, copyFullResponse })) + setGlobalConfig({ ...getGlobalConfig(), copyFullResponse }) + logEvent('tengu_config_changed', { + setting: + 'copyFullResponse' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: String( + copyFullResponse, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + // Copy-on-select is only meaningful with in-app selection (fullscreen + // alt-screen mode). In inline mode the terminal emulator owns selection. + ...(isFullscreenEnvEnabled() + ? [ + { + id: 'copyOnSelect', + label: 'Copy on select', + value: globalConfig.copyOnSelect ?? true, + type: 'boolean' as const, + onChange(copyOnSelect: boolean) { + saveGlobalConfig(current => ({ ...current, copyOnSelect })) + setGlobalConfig({ ...getGlobalConfig(), copyOnSelect }) + logEvent('tengu_config_changed', { + setting: + 'copyOnSelect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: String( + copyOnSelect, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + ] + : []), + // autoUpdates setting is hidden - use DISABLE_AUTOUPDATER env var to control + autoUpdaterDisabledReason + ? { + id: 'autoUpdatesChannel', + label: 'Auto-update channel', + value: 'disabled', + type: 'managedEnum' as const, + onChange() {}, } - // Clear CLI override and set new mode (pass mode to avoid race condition) - clearCliTeammateModeOverride(mode_0); - saveGlobalConfig(current_19 => ({ - ...current_19, - teammateMode: mode_0 - })); + : { + id: 'autoUpdatesChannel', + label: 'Auto-update channel', + value: settingsData?.autoUpdatesChannel ?? 'latest', + type: 'managedEnum' as const, + onChange() { + // Handled via toggleSetting -> 'ChannelDowngrade' + }, + }, + { + id: 'theme', + label: 'Theme', + value: themeSetting, + type: 'managedEnum', + onChange: setTheme, + }, + { + id: 'notifChannel', + label: + feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') + ? 'Local notifications' + : 'Notifications', + value: globalConfig.preferredNotifChannel, + options: [ + 'auto', + 'iterm2', + 'terminal_bell', + 'iterm2_with_bell', + 'kitty', + 'ghostty', + 'notifications_disabled', + ], + type: 'enum', + onChange(notifChannel: GlobalConfig['preferredNotifChannel']) { + saveGlobalConfig(current => ({ + ...current, + preferredNotifChannel: notifChannel, + })) setGlobalConfig({ ...getGlobalConfig(), - teammateMode: mode_0 - }); - logEvent('tengu_teammate_mode_changed', { - mode: mode_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - }, { - id: 'teammateDefaultModel', - label: 'Default teammate model', - value: teammateModelDisplayString(globalConfig.teammateDefaultModel), + preferredNotifChannel: notifChannel, + }) + }, + }, + ...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') + ? [ + { + id: 'taskCompleteNotifEnabled', + label: 'Push when idle', + value: globalConfig.taskCompleteNotifEnabled ?? false, + type: 'boolean' as const, + onChange(taskCompleteNotifEnabled: boolean) { + saveGlobalConfig(current => ({ + ...current, + taskCompleteNotifEnabled, + })) + setGlobalConfig({ + ...getGlobalConfig(), + taskCompleteNotifEnabled, + }) + }, + }, + { + id: 'inputNeededNotifEnabled', + label: 'Push when input needed', + value: globalConfig.inputNeededNotifEnabled ?? false, + type: 'boolean' as const, + onChange(inputNeededNotifEnabled: boolean) { + saveGlobalConfig(current => ({ + ...current, + inputNeededNotifEnabled, + })) + setGlobalConfig({ + ...getGlobalConfig(), + inputNeededNotifEnabled, + }) + }, + }, + { + id: 'agentPushNotifEnabled', + label: 'Push when Claude decides', + value: globalConfig.agentPushNotifEnabled ?? false, + type: 'boolean' as const, + onChange(agentPushNotifEnabled: boolean) { + saveGlobalConfig(current => ({ + ...current, + agentPushNotifEnabled, + })) + setGlobalConfig({ + ...getGlobalConfig(), + agentPushNotifEnabled, + }) + }, + }, + ] + : []), + { + id: 'outputStyle', + label: 'Output style', + value: currentOutputStyle, type: 'managedEnum' as const, - onChange() {} - }]; - })() : []), - // Remote at startup toggle — gated on build flag + GrowthBook + policy - ...(feature('BRIDGE_MODE') && isBridgeEnabled() ? [{ - id: 'remoteControlAtStartup', - label: 'Enable Remote Control for all sessions', - value: globalConfig.remoteControlAtStartup === undefined ? 'default' : String(globalConfig.remoteControlAtStartup), - options: ['true', 'false', 'default'], - type: 'enum' as const, - onChange(selected_0: string) { - if (selected_0 === 'default') { - // Unset the config key so it falls back to the platform default - saveGlobalConfig(current_20 => { - if (current_20.remoteControlAtStartup === undefined) return current_20; - const next_0 = { - ...current_20 - }; - delete next_0.remoteControlAtStartup; - return next_0; - }); + onChange: () => {}, // handled by OutputStylePicker submenu + }, + ...(showDefaultViewPicker + ? [ + { + id: 'defaultView', + label: 'What you see by default', + // 'default' means the setting is unset — currently resolves to + // transcript (main.tsx falls through when defaultView !== 'chat'). + // String() narrows the conditional-schema-spread union to string. + value: + settingsData?.defaultView === undefined + ? 'default' + : String(settingsData.defaultView), + options: ['transcript', 'chat', 'default'], + type: 'enum' as const, + onChange(selected: string) { + const defaultView = + selected === 'default' + ? undefined + : (selected as 'chat' | 'transcript') + updateSettingsForSource('localSettings', { defaultView }) + setSettingsData(prev => ({ ...prev, defaultView })) + const nextBrief = defaultView === 'chat' + setAppState(prev => { + if (prev.isBriefOnly === nextBrief) return prev + return { ...prev, isBriefOnly: nextBrief } + }) + // Keep userMsgOptIn in sync so the tool list follows the view. + // Two-way now (same as /brief) — accepting a cache invalidation + // is better than leaving the tool on after switching away. + // Reverted on Escape via initialUserMsgOptIn snapshot. + setUserMsgOptIn(nextBrief) + setChanges(prev => ({ ...prev, 'Default view': selected })) + logEvent('tengu_default_view_setting_changed', { + value: (defaultView ?? + 'unset') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + ] + : []), + { + id: 'language', + label: 'Language', + value: currentLanguage ?? 'Default (English)', + type: 'managedEnum' as const, + onChange: () => {}, // handled by LanguagePicker submenu + }, + { + id: 'editorMode', + label: 'Editor mode', + // Convert 'emacs' to 'normal' for backward compatibility + value: + globalConfig.editorMode === 'emacs' + ? 'normal' + : globalConfig.editorMode || 'normal', + options: ['normal', 'vim'], + type: 'enum', + onChange(value: string) { + saveGlobalConfig(current => ({ + ...current, + editorMode: value as GlobalConfig['editorMode'], + })) setGlobalConfig({ ...getGlobalConfig(), - remoteControlAtStartup: undefined - }); - } else { - const enabled_6 = selected_0 === 'true'; - saveGlobalConfig(current_21 => { - if (current_21.remoteControlAtStartup === enabled_6) return current_21; + editorMode: value as GlobalConfig['editorMode'], + }) + + logEvent('tengu_editor_mode_changed', { + mode: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + { + id: 'prStatusFooterEnabled', + label: 'Show PR status footer', + value: globalConfig.prStatusFooterEnabled ?? true, + type: 'boolean' as const, + onChange(enabled: boolean) { + saveGlobalConfig(current => { + if (current.prStatusFooterEnabled === enabled) return current return { - ...current_21, - remoteControlAtStartup: enabled_6 - }; - }); + ...current, + prStatusFooterEnabled: enabled, + } + }) setGlobalConfig({ ...getGlobalConfig(), - remoteControlAtStartup: enabled_6 - }); - } - // Sync to AppState so useReplBridge reacts immediately - const resolved = getRemoteControlAtStartup(); - setAppState(prev_20 => { - if (prev_20.replBridgeEnabled === resolved && !prev_20.replBridgeOutboundOnly) return prev_20; - return { - ...prev_20, - replBridgeEnabled: resolved, - replBridgeOutboundOnly: false - }; - }); - } - }] : []), ...(shouldShowExternalIncludesToggle ? [{ - id: 'showExternalIncludesDialog', - label: 'External CLAUDE.md includes', - value: (() => { - const projectConfig = getCurrentProjectConfig(); - if (projectConfig.hasClaudeMdExternalIncludesApproved) { - return 'true'; - } else { - return 'false'; - } - })(), - type: 'managedEnum' as const, - onChange() { - // Will be handled by toggleSetting function - } - }] : []), ...(process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace() ? [{ - id: 'apiKey', - label: + prStatusFooterEnabled: enabled, + }) + logEvent('tengu_pr_status_footer_setting_changed', { + enabled, + }) + }, + }, + { + id: 'model', + label: 'Model', + value: mainLoopModel === null ? 'Default (recommended)' : mainLoopModel, + type: 'managedEnum' as const, + onChange: onChangeMainModelConfig, + }, + ...(isConnectedToIde + ? [ + { + id: 'diffTool', + label: 'Diff tool', + value: globalConfig.diffTool ?? 'auto', + options: ['terminal', 'auto'], + type: 'enum' as const, + onChange(diffTool: string) { + saveGlobalConfig(current => ({ + ...current, + diffTool: diffTool as GlobalConfig['diffTool'], + })) + setGlobalConfig({ + ...getGlobalConfig(), + diffTool: diffTool as GlobalConfig['diffTool'], + }) + + logEvent('tengu_diff_tool_changed', { + tool: diffTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + ] + : []), + ...(!isSupportedTerminal() + ? [ + { + id: 'autoConnectIde', + label: 'Auto-connect to IDE (external terminal)', + value: globalConfig.autoConnectIde ?? false, + type: 'boolean' as const, + onChange(autoConnectIde: boolean) { + saveGlobalConfig(current => ({ ...current, autoConnectIde })) + setGlobalConfig({ ...getGlobalConfig(), autoConnectIde }) + + logEvent('tengu_auto_connect_ide_changed', { + enabled: autoConnectIde, + source: + 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + ] + : []), + ...(isSupportedTerminal() + ? [ + { + id: 'autoInstallIdeExtension', + label: 'Auto-install IDE extension', + value: globalConfig.autoInstallIdeExtension ?? true, + type: 'boolean' as const, + onChange(autoInstallIdeExtension: boolean) { + saveGlobalConfig(current => ({ + ...current, + autoInstallIdeExtension, + })) + setGlobalConfig({ ...getGlobalConfig(), autoInstallIdeExtension }) + + logEvent('tengu_auto_install_ide_extension_changed', { + enabled: autoInstallIdeExtension, + source: + 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + ] + : []), + { + id: 'claudeInChromeDefaultEnabled', + label: 'Claude in Chrome enabled by default', + value: globalConfig.claudeInChromeDefaultEnabled ?? true, + type: 'boolean' as const, + onChange(enabled: boolean) { + saveGlobalConfig(current => ({ + ...current, + claudeInChromeDefaultEnabled: enabled, + })) + setGlobalConfig({ + ...getGlobalConfig(), + claudeInChromeDefaultEnabled: enabled, + }) + logEvent('tengu_claude_in_chrome_setting_changed', { + enabled, + }) + }, + }, + // Teammate mode (only shown when agent swarms are enabled) + ...(isAgentSwarmsEnabled() + ? (() => { + const cliOverride = getCliTeammateModeOverride() + const label = cliOverride + ? `Teammate mode [overridden: ${cliOverride}]` + : 'Teammate mode' + return [ + { + id: 'teammateMode', + label, + value: globalConfig.teammateMode ?? 'auto', + options: ['auto', 'tmux', 'in-process'], + type: 'enum' as const, + onChange(mode: string) { + if ( + mode !== 'auto' && + mode !== 'tmux' && + mode !== 'in-process' + ) { + return + } + // Clear CLI override and set new mode (pass mode to avoid race condition) + clearCliTeammateModeOverride(mode) + saveGlobalConfig(current => ({ + ...current, + teammateMode: mode, + })) + setGlobalConfig({ + ...getGlobalConfig(), + teammateMode: mode, + }) + logEvent('tengu_teammate_mode_changed', { + mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + }, + { + id: 'teammateDefaultModel', + label: 'Default teammate model', + value: teammateModelDisplayString( + globalConfig.teammateDefaultModel, + ), + type: 'managedEnum' as const, + onChange() {}, + }, + ] + })() + : []), + // Remote at startup toggle — gated on build flag + GrowthBook + policy + ...(feature('BRIDGE_MODE') && isBridgeEnabled() + ? [ + { + id: 'remoteControlAtStartup', + label: 'Enable Remote Control for all sessions', + value: + globalConfig.remoteControlAtStartup === undefined + ? 'default' + : String(globalConfig.remoteControlAtStartup), + options: ['true', 'false', 'default'], + type: 'enum' as const, + onChange(selected: string) { + if (selected === 'default') { + // Unset the config key so it falls back to the platform default + saveGlobalConfig(current => { + if (current.remoteControlAtStartup === undefined) + return current + const next = { ...current } + delete next.remoteControlAtStartup + return next + }) + setGlobalConfig({ + ...getGlobalConfig(), + remoteControlAtStartup: undefined, + }) + } else { + const enabled = selected === 'true' + saveGlobalConfig(current => { + if (current.remoteControlAtStartup === enabled) return current + return { ...current, remoteControlAtStartup: enabled } + }) + setGlobalConfig({ + ...getGlobalConfig(), + remoteControlAtStartup: enabled, + }) + } + // Sync to AppState so useReplBridge reacts immediately + const resolved = getRemoteControlAtStartup() + setAppState(prev => { + if ( + prev.replBridgeEnabled === resolved && + !prev.replBridgeOutboundOnly + ) + return prev + return { + ...prev, + replBridgeEnabled: resolved, + replBridgeOutboundOnly: false, + } + }) + }, + }, + ] + : []), + ...(shouldShowExternalIncludesToggle + ? [ + { + id: 'showExternalIncludesDialog', + label: 'External CLAUDE.md includes', + value: (() => { + const projectConfig = getCurrentProjectConfig() + if (projectConfig.hasClaudeMdExternalIncludesApproved) { + return 'true' + } else { + return 'false' + } + })(), + type: 'managedEnum' as const, + onChange() { + // Will be handled by toggleSetting function + }, + }, + ] + : []), + ...(process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace() + ? [ + { + id: 'apiKey', + label: ( + Use custom API key:{' '} {normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)} - , - searchText: 'Use custom API key', - value: Boolean(process.env.ANTHROPIC_API_KEY && globalConfig.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY))), - type: 'boolean' as const, - onChange(useCustomKey: boolean) { - saveGlobalConfig(current_22 => { - const updated = { - ...current_22 - }; - if (!updated.customApiKeyResponses) { - updated.customApiKeyResponses = { - approved: [], - rejected: [] - }; - } - if (!updated.customApiKeyResponses.approved) { - updated.customApiKeyResponses = { - ...updated.customApiKeyResponses, - approved: [] - }; - } - if (!updated.customApiKeyResponses.rejected) { - updated.customApiKeyResponses = { - ...updated.customApiKeyResponses, - rejected: [] - }; - } - if (process.env.ANTHROPIC_API_KEY) { - const truncatedKey = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); - if (useCustomKey) { - updated.customApiKeyResponses = { - ...updated.customApiKeyResponses, - approved: [...(updated.customApiKeyResponses.approved ?? []).filter(k => k !== truncatedKey), truncatedKey], - rejected: (updated.customApiKeyResponses.rejected ?? []).filter(k_0 => k_0 !== truncatedKey) - }; - } else { - updated.customApiKeyResponses = { - ...updated.customApiKeyResponses, - approved: (updated.customApiKeyResponses.approved ?? []).filter(k_1 => k_1 !== truncatedKey), - rejected: [...(updated.customApiKeyResponses.rejected ?? []).filter(k_2 => k_2 !== truncatedKey), truncatedKey] - }; - } - } - return updated; - }); - setGlobalConfig(getGlobalConfig()); - } - }] : [])]; + + ), + searchText: 'Use custom API key', + value: Boolean( + process.env.ANTHROPIC_API_KEY && + globalConfig.customApiKeyResponses?.approved?.includes( + normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY), + ), + ), + type: 'boolean' as const, + onChange(useCustomKey: boolean) { + saveGlobalConfig(current => { + const updated = { ...current } + if (!updated.customApiKeyResponses) { + updated.customApiKeyResponses = { + approved: [], + rejected: [], + } + } + if (!updated.customApiKeyResponses.approved) { + updated.customApiKeyResponses = { + ...updated.customApiKeyResponses, + approved: [], + } + } + if (!updated.customApiKeyResponses.rejected) { + updated.customApiKeyResponses = { + ...updated.customApiKeyResponses, + rejected: [], + } + } + if (process.env.ANTHROPIC_API_KEY) { + const truncatedKey = normalizeApiKeyForConfig( + process.env.ANTHROPIC_API_KEY, + ) + if (useCustomKey) { + updated.customApiKeyResponses = { + ...updated.customApiKeyResponses, + approved: [ + ...( + updated.customApiKeyResponses.approved ?? [] + ).filter(k => k !== truncatedKey), + truncatedKey, + ], + rejected: ( + updated.customApiKeyResponses.rejected ?? [] + ).filter(k => k !== truncatedKey), + } + } else { + updated.customApiKeyResponses = { + ...updated.customApiKeyResponses, + approved: ( + updated.customApiKeyResponses.approved ?? [] + ).filter(k => k !== truncatedKey), + rejected: [ + ...( + updated.customApiKeyResponses.rejected ?? [] + ).filter(k => k !== truncatedKey), + truncatedKey, + ], + } + } + } + return updated + }) + setGlobalConfig(getGlobalConfig()) + }, + }, + ] + : []), + ] // Filter settings based on search query const filteredSettingsItems = React.useMemo(() => { - if (!searchQuery) return settingsItems; - const lowerQuery = searchQuery.toLowerCase(); + if (!searchQuery) return settingsItems + const lowerQuery = searchQuery.toLowerCase() return settingsItems.filter(setting => { - if (setting.id.toLowerCase().includes(lowerQuery)) return true; - const searchableText = 'searchText' in setting ? setting.searchText : setting.label; - return searchableText.toLowerCase().includes(lowerQuery); - }); - }, [settingsItems, searchQuery]); + if (setting.id.toLowerCase().includes(lowerQuery)) return true + const searchableText = + 'searchText' in setting ? setting.searchText : setting.label + return searchableText.toLowerCase().includes(lowerQuery) + }) + }, [settingsItems, searchQuery]) // Adjust selected index when filtered list shrinks, and keep the selected // item visible when maxVisible changes (e.g., terminal resize). React.useEffect(() => { if (selectedIndex >= filteredSettingsItems.length) { - const newIndex = Math.max(0, filteredSettingsItems.length - 1); - setSelectedIndex(newIndex); - setScrollOffset(Math.max(0, newIndex - maxVisible + 1)); - return; - } - setScrollOffset(prev_21 => { - if (selectedIndex < prev_21) return selectedIndex; - if (selectedIndex >= prev_21 + maxVisible) return selectedIndex - maxVisible + 1; - return prev_21; - }); - }, [filteredSettingsItems.length, selectedIndex, maxVisible]); + const newIndex = Math.max(0, filteredSettingsItems.length - 1) + setSelectedIndex(newIndex) + setScrollOffset(Math.max(0, newIndex - maxVisible + 1)) + return + } + setScrollOffset(prev => { + if (selectedIndex < prev) return selectedIndex + if (selectedIndex >= prev + maxVisible) + return selectedIndex - maxVisible + 1 + return prev + }) + }, [filteredSettingsItems.length, selectedIndex, maxVisible]) // Keep the selected item visible within the scroll window. // Called synchronously from navigation handlers to avoid a render frame // where the selected item falls outside the visible window. - const adjustScrollOffset = useCallback((newIndex_0: number) => { - setScrollOffset(prev_22 => { - if (newIndex_0 < prev_22) return newIndex_0; - if (newIndex_0 >= prev_22 + maxVisible) return newIndex_0 - maxVisible + 1; - return prev_22; - }); - }, [maxVisible]); + const adjustScrollOffset = useCallback( + (newIndex: number) => { + setScrollOffset(prev => { + if (newIndex < prev) return newIndex + if (newIndex >= prev + maxVisible) return newIndex - maxVisible + 1 + return prev + }) + }, + [maxVisible], + ) // Enter: keep all changes (already persisted by onChange handlers), close // with a summary of what changed. @@ -1088,90 +1294,178 @@ export function Config({ // Submenu handling: each submenu has its own Enter/Esc — don't close // the whole panel while one is open. if (showSubmenu !== null) { - return; + return } // Log any changes that were made // TODO: Make these proper messages - const formattedChanges: string[] = Object.entries(changes).map(([key, value_2]) => { - logEvent('tengu_config_changed', { - key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: value_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - return `Set ${key} to ${chalk.bold(value_2)}`; - }); + const formattedChanges: string[] = Object.entries(changes).map( + ([key, value]) => { + logEvent('tengu_config_changed', { + key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: + value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return `Set ${key} to ${chalk.bold(value)}` + }, + ) // Check for API key changes // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). - const effectiveApiKey = isRunningOnHomespace() ? undefined : process.env.ANTHROPIC_API_KEY; - const initialUsingCustomKey = Boolean(effectiveApiKey && initialConfig.current.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(effectiveApiKey))); - const currentUsingCustomKey = Boolean(effectiveApiKey && globalConfig.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(effectiveApiKey))); + const effectiveApiKey = isRunningOnHomespace() + ? undefined + : process.env.ANTHROPIC_API_KEY + const initialUsingCustomKey = Boolean( + effectiveApiKey && + initialConfig.current.customApiKeyResponses?.approved?.includes( + normalizeApiKeyForConfig(effectiveApiKey), + ), + ) + const currentUsingCustomKey = Boolean( + effectiveApiKey && + globalConfig.customApiKeyResponses?.approved?.includes( + normalizeApiKeyForConfig(effectiveApiKey), + ), + ) if (initialUsingCustomKey !== currentUsingCustomKey) { - formattedChanges.push(`${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`); + formattedChanges.push( + `${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`, + ) logEvent('tengu_config_changed', { key: 'env.ANTHROPIC_API_KEY' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: currentUsingCustomKey as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + value: + currentUsingCustomKey as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } if (globalConfig.theme !== initialConfig.current.theme) { - formattedChanges.push(`Set theme to ${chalk.bold(globalConfig.theme)}`); + formattedChanges.push(`Set theme to ${chalk.bold(globalConfig.theme)}`) } - if (globalConfig.preferredNotifChannel !== initialConfig.current.preferredNotifChannel) { - formattedChanges.push(`Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`); + if ( + globalConfig.preferredNotifChannel !== + initialConfig.current.preferredNotifChannel + ) { + formattedChanges.push( + `Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`, + ) } if (currentOutputStyle !== initialOutputStyle.current) { - formattedChanges.push(`Set output style to ${chalk.bold(currentOutputStyle)}`); + formattedChanges.push( + `Set output style to ${chalk.bold(currentOutputStyle)}`, + ) } if (currentLanguage !== initialLanguage.current) { - formattedChanges.push(`Set response language to ${chalk.bold(currentLanguage ?? 'Default (English)')}`); + formattedChanges.push( + `Set response language to ${chalk.bold(currentLanguage ?? 'Default (English)')}`, + ) } if (globalConfig.editorMode !== initialConfig.current.editorMode) { - formattedChanges.push(`Set editor mode to ${chalk.bold(globalConfig.editorMode || 'emacs')}`); + formattedChanges.push( + `Set editor mode to ${chalk.bold(globalConfig.editorMode || 'emacs')}`, + ) } if (globalConfig.diffTool !== initialConfig.current.diffTool) { - formattedChanges.push(`Set diff tool to ${chalk.bold(globalConfig.diffTool)}`); + formattedChanges.push( + `Set diff tool to ${chalk.bold(globalConfig.diffTool)}`, + ) } if (globalConfig.autoConnectIde !== initialConfig.current.autoConnectIde) { - formattedChanges.push(`${globalConfig.autoConnectIde ? 'Enabled' : 'Disabled'} auto-connect to IDE`); - } - if (globalConfig.autoInstallIdeExtension !== initialConfig.current.autoInstallIdeExtension) { - formattedChanges.push(`${globalConfig.autoInstallIdeExtension ? 'Enabled' : 'Disabled'} auto-install IDE extension`); - } - if (globalConfig.autoCompactEnabled !== initialConfig.current.autoCompactEnabled) { - formattedChanges.push(`${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`); - } - if (globalConfig.respectGitignore !== initialConfig.current.respectGitignore) { - formattedChanges.push(`${globalConfig.respectGitignore ? 'Enabled' : 'Disabled'} respect .gitignore in file picker`); - } - if (globalConfig.copyFullResponse !== initialConfig.current.copyFullResponse) { - formattedChanges.push(`${globalConfig.copyFullResponse ? 'Enabled' : 'Disabled'} always copy full response`); + formattedChanges.push( + `${globalConfig.autoConnectIde ? 'Enabled' : 'Disabled'} auto-connect to IDE`, + ) + } + if ( + globalConfig.autoInstallIdeExtension !== + initialConfig.current.autoInstallIdeExtension + ) { + formattedChanges.push( + `${globalConfig.autoInstallIdeExtension ? 'Enabled' : 'Disabled'} auto-install IDE extension`, + ) + } + if ( + globalConfig.autoCompactEnabled !== + initialConfig.current.autoCompactEnabled + ) { + formattedChanges.push( + `${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`, + ) + } + if ( + globalConfig.respectGitignore !== initialConfig.current.respectGitignore + ) { + formattedChanges.push( + `${globalConfig.respectGitignore ? 'Enabled' : 'Disabled'} respect .gitignore in file picker`, + ) + } + if ( + globalConfig.copyFullResponse !== initialConfig.current.copyFullResponse + ) { + formattedChanges.push( + `${globalConfig.copyFullResponse ? 'Enabled' : 'Disabled'} always copy full response`, + ) } if (globalConfig.copyOnSelect !== initialConfig.current.copyOnSelect) { - formattedChanges.push(`${globalConfig.copyOnSelect ? 'Enabled' : 'Disabled'} copy on select`); - } - if (globalConfig.terminalProgressBarEnabled !== initialConfig.current.terminalProgressBarEnabled) { - formattedChanges.push(`${globalConfig.terminalProgressBarEnabled ? 'Enabled' : 'Disabled'} terminal progress bar`); - } - if (globalConfig.showStatusInTerminalTab !== initialConfig.current.showStatusInTerminalTab) { - formattedChanges.push(`${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`); - } - if (globalConfig.showTurnDuration !== initialConfig.current.showTurnDuration) { - formattedChanges.push(`${globalConfig.showTurnDuration ? 'Enabled' : 'Disabled'} turn duration`); - } - if (globalConfig.remoteControlAtStartup !== initialConfig.current.remoteControlAtStartup) { - const remoteLabel = globalConfig.remoteControlAtStartup === undefined ? 'Reset Remote Control to default' : `${globalConfig.remoteControlAtStartup ? 'Enabled' : 'Disabled'} Remote Control for all sessions`; - formattedChanges.push(remoteLabel); - } - if (settingsData?.autoUpdatesChannel !== initialSettingsData.current?.autoUpdatesChannel) { - formattedChanges.push(`Set auto-update channel to ${chalk.bold(settingsData?.autoUpdatesChannel ?? 'latest')}`); + formattedChanges.push( + `${globalConfig.copyOnSelect ? 'Enabled' : 'Disabled'} copy on select`, + ) + } + if ( + globalConfig.terminalProgressBarEnabled !== + initialConfig.current.terminalProgressBarEnabled + ) { + formattedChanges.push( + `${globalConfig.terminalProgressBarEnabled ? 'Enabled' : 'Disabled'} terminal progress bar`, + ) + } + if ( + globalConfig.showStatusInTerminalTab !== + initialConfig.current.showStatusInTerminalTab + ) { + formattedChanges.push( + `${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`, + ) + } + if ( + globalConfig.showTurnDuration !== initialConfig.current.showTurnDuration + ) { + formattedChanges.push( + `${globalConfig.showTurnDuration ? 'Enabled' : 'Disabled'} turn duration`, + ) + } + if ( + globalConfig.remoteControlAtStartup !== + initialConfig.current.remoteControlAtStartup + ) { + const remoteLabel = + globalConfig.remoteControlAtStartup === undefined + ? 'Reset Remote Control to default' + : `${globalConfig.remoteControlAtStartup ? 'Enabled' : 'Disabled'} Remote Control for all sessions` + formattedChanges.push(remoteLabel) + } + if ( + settingsData?.autoUpdatesChannel !== + initialSettingsData.current?.autoUpdatesChannel + ) { + formattedChanges.push( + `Set auto-update channel to ${chalk.bold(settingsData?.autoUpdatesChannel ?? 'latest')}`, + ) } if (formattedChanges.length > 0) { - onClose(formattedChanges.join('\n')); + onClose(formattedChanges.join('\n')) } else { - onClose('Config dialog dismissed', { - display: 'system' - }); - } - }, [showSubmenu, changes, globalConfig, mainLoopModel, currentOutputStyle, currentLanguage, settingsData?.autoUpdatesChannel, isFastModeEnabled() ? (settingsData as Record | undefined)?.fastMode : undefined, onClose]); + onClose('Config dialog dismissed', { display: 'system' }) + } + }, [ + showSubmenu, + changes, + globalConfig, + mainLoopModel, + currentOutputStyle, + currentLanguage, + settingsData?.autoUpdatesChannel, + isFastModeEnabled() + ? (settingsData as Record | undefined)?.fastMode + : undefined, + onClose, + ]) // Restore all state stores to their mount-time snapshots. Changes are // applied to disk/AppState immediately on toggle, so "cancel" means @@ -1181,22 +1475,22 @@ export function Config({ // config overwrite since setTheme internally calls saveGlobalConfig with // a partial update — we want the full snapshot to be the last write. if (themeSetting !== initialThemeSetting.current) { - setTheme(initialThemeSetting.current); + setTheme(initialThemeSetting.current) } // Global config: full overwrite from snapshot. saveGlobalConfig skips if // the returned ref equals current (test mode checks ref; prod writes to // disk but content is identical). - saveGlobalConfig(() => initialConfig.current); + saveGlobalConfig(() => initialConfig.current) // Settings files: restore each key Config may have touched. undefined // deletes the key (updateSettingsForSource customizer at settings.ts:368). - const il = initialLocalSettings; + const il = initialLocalSettings updateSettingsForSource('localSettings', { spinnerTipsEnabled: il?.spinnerTipsEnabled, prefersReducedMotion: il?.prefersReducedMotion, defaultView: il?.defaultView, - outputStyle: il?.outputStyle - }); - const iu = initialUserSettings; + outputStyle: il?.outputStyle, + }) + const iu = initialUserSettings updateSettingsForSource('userSettings', { alwaysThinkingEnabled: iu?.alwaysThinkingEnabled, fastMode: iu?.fastMode, @@ -1204,11 +1498,13 @@ export function Config({ autoUpdatesChannel: iu?.autoUpdatesChannel, minimumVersion: iu?.minimumVersion, language: iu?.language, - ...(feature('TRANSCRIPT_CLASSIFIER') ? { - useAutoModeDuringPlan: (iu as { - useAutoModeDuringPlan?: boolean; - } | undefined)?.useAutoModeDuringPlan - } : {}), + ...(feature('TRANSCRIPT_CLASSIFIER') + ? { + useAutoModeDuringPlan: ( + iu as { useAutoModeDuringPlan?: boolean } | undefined + )?.useAutoModeDuringPlan, + } + : {}), // ThemePicker's Ctrl+T writes this key directly — include it so the // disk state reverts along with the in-memory AppState.settings restore. syntaxHighlightingDisabled: iu?.syntaxHighlightingDisabled, @@ -1218,15 +1514,15 @@ export function Config({ // mergeWith array-customizer (settings.ts:375) replaces leaked arrays. // Explicitly include defaultMode so undefined triggers the customizer's // delete path even when iu.permissions lacks that key. - permissions: iu?.permissions === undefined ? undefined : { - ...iu.permissions, - defaultMode: iu.permissions.defaultMode - } - }); + permissions: + iu?.permissions === undefined + ? undefined + : { ...iu.permissions, defaultMode: iu.permissions.defaultMode }, + }) // AppState: batch-restore all possibly-touched fields. - const ia = initialAppState; - setAppState(prev_23 => ({ - ...prev_23, + const ia = initialAppState + setAppState(prev => ({ + ...prev, mainLoopModel: ia.mainLoopModel, mainLoopModelForSession: ia.mainLoopModelForSession, verbose: ia.verbose, @@ -1239,509 +1535,778 @@ export function Config({ settings: ia.settings, // Reconcile auto-mode state after useAutoModeDuringPlan revert above — // the onChange handler may have activated/deactivated auto mid-plan. - toolPermissionContext: transitionPlanAutoMode(prev_23.toolPermissionContext) - })); + toolPermissionContext: transitionPlanAutoMode(prev.toolPermissionContext), + })) // Bootstrap state: restore userMsgOptIn. Only touched by the defaultView // onChange above, so no feature() guard needed here (that path only // exists when showDefaultViewPicker is true). if (getUserMsgOptIn() !== initialUserMsgOptIn) { - setUserMsgOptIn(initialUserMsgOptIn); - } - }, [themeSetting, setTheme, initialLocalSettings, initialUserSettings, initialAppState, initialUserMsgOptIn, setAppState]); + setUserMsgOptIn(initialUserMsgOptIn) + } + }, [ + themeSetting, + setTheme, + initialLocalSettings, + initialUserSettings, + initialAppState, + initialUserMsgOptIn, + setAppState, + ]) // Escape: revert all changes (if any) and close. const handleEscape = useCallback(() => { if (showSubmenu !== null) { - return; + return } if (isDirty.current) { - revertChanges(); + revertChanges() } - onClose('Config dialog dismissed', { - display: 'system' - }); - }, [showSubmenu, revertChanges, onClose]); + onClose('Config dialog dismissed', { display: 'system' }) + }, [showSubmenu, revertChanges, onClose]) // Disable when submenu is open so the submenu's Dialog handles ESC, and in // search mode so the onKeyDown handler (which clears-then-exits search) // wins — otherwise Escape in search would jump straight to revert+close. useKeybinding('confirm:no', handleEscape, { context: 'Settings', - isActive: showSubmenu === null && !isSearchMode && !headerFocused - }); + isActive: showSubmenu === null && !isSearchMode && !headerFocused, + }) // Save-and-close fires on Enter only when not in search mode (Enter there // exits search to the list — see the isSearchMode branch in handleKeyDown). useKeybinding('settings:close', handleSaveAndClose, { context: 'Settings', - isActive: showSubmenu === null && !isSearchMode && !headerFocused - }); + isActive: showSubmenu === null && !isSearchMode && !headerFocused, + }) // Settings navigation and toggle actions via configurable keybindings. // Only active when not in search mode and no submenu is open. const toggleSetting = useCallback(() => { - const setting_0 = filteredSettingsItems[selectedIndex]; - if (!setting_0 || !setting_0.onChange) { - return; + const setting = filteredSettingsItems[selectedIndex] + if (!setting || !setting.onChange) { + return } - if (setting_0.type === 'boolean') { - isDirty.current = true; - setting_0.onChange(!setting_0.value); - if (setting_0.id === 'thinkingEnabled') { - const newValue = !setting_0.value; - const backToInitial = newValue === initialThinkingEnabled.current; + + if (setting.type === 'boolean') { + isDirty.current = true + setting.onChange(!setting.value) + if (setting.id === 'thinkingEnabled') { + const newValue = !setting.value + const backToInitial = newValue === initialThinkingEnabled.current if (backToInitial) { - setShowThinkingWarning(false); - } else if (context.messages.some(m_0 => m_0.type === 'assistant')) { - setShowThinkingWarning(true); + setShowThinkingWarning(false) + } else if (context.messages.some(m => m.type === 'assistant')) { + setShowThinkingWarning(true) } } - return; + return } - if (setting_0.id === 'theme' || setting_0.id === 'model' || setting_0.id === 'teammateDefaultModel' || setting_0.id === 'showExternalIncludesDialog' || setting_0.id === 'outputStyle' || setting_0.id === 'language') { + + if ( + setting.id === 'theme' || + setting.id === 'model' || + setting.id === 'teammateDefaultModel' || + setting.id === 'showExternalIncludesDialog' || + setting.id === 'outputStyle' || + setting.id === 'language' + ) { // managedEnum items open a submenu — isDirty is set by the submenu's // completion callback, not here (submenu may be cancelled). - switch (setting_0.id) { + switch (setting.id) { case 'theme': - setShowSubmenu('Theme'); - setTabsHidden(true); - return; + setShowSubmenu('Theme') + setTabsHidden(true) + return case 'model': - setShowSubmenu('Model'); - setTabsHidden(true); - return; + setShowSubmenu('Model') + setTabsHidden(true) + return case 'teammateDefaultModel': - setShowSubmenu('TeammateModel'); - setTabsHidden(true); - return; + setShowSubmenu('TeammateModel') + setTabsHidden(true) + return case 'showExternalIncludesDialog': - setShowSubmenu('ExternalIncludes'); - setTabsHidden(true); - return; + setShowSubmenu('ExternalIncludes') + setTabsHidden(true) + return case 'outputStyle': - setShowSubmenu('OutputStyle'); - setTabsHidden(true); - return; + setShowSubmenu('OutputStyle') + setTabsHidden(true) + return case 'language': - setShowSubmenu('Language'); - setTabsHidden(true); - return; + setShowSubmenu('Language') + setTabsHidden(true) + return } } - if (setting_0.id === 'autoUpdatesChannel') { + + if (setting.id === 'autoUpdatesChannel') { if (autoUpdaterDisabledReason) { // Auto-updates are disabled - show enable dialog instead - setShowSubmenu('EnableAutoUpdates'); - setTabsHidden(true); - return; + setShowSubmenu('EnableAutoUpdates') + setTabsHidden(true) + return } - const currentChannel = settingsData?.autoUpdatesChannel ?? 'latest'; + const currentChannel = settingsData?.autoUpdatesChannel ?? 'latest' if (currentChannel === 'latest') { // Switching to stable - show downgrade dialog - setShowSubmenu('ChannelDowngrade'); - setTabsHidden(true); + setShowSubmenu('ChannelDowngrade') + setTabsHidden(true) } else { // Switching to latest - just do it and clear minimumVersion - isDirty.current = true; + isDirty.current = true updateSettingsForSource('userSettings', { autoUpdatesChannel: 'latest', - minimumVersion: undefined - }); - setSettingsData(prev_24 => ({ - ...prev_24, + minimumVersion: undefined, + }) + setSettingsData(prev => ({ + ...prev, autoUpdatesChannel: 'latest', - minimumVersion: undefined - })); + minimumVersion: undefined, + })) logEvent('tengu_autoupdate_channel_changed', { - channel: 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + channel: + 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } - return; - } - if (setting_0.type === 'enum') { - isDirty.current = true; - const currentIndex = setting_0.options.indexOf(setting_0.value); - const nextIndex = (currentIndex + 1) % setting_0.options.length; - setting_0.onChange(setting_0.options[nextIndex]!); - return; + return } - }, [autoUpdaterDisabledReason, filteredSettingsItems, selectedIndex, settingsData?.autoUpdatesChannel, setTabsHidden]); + + if (setting.type === 'enum') { + isDirty.current = true + const currentIndex = setting.options.indexOf(setting.value) + const nextIndex = (currentIndex + 1) % setting.options.length + setting.onChange(setting.options[nextIndex]!) + return + } + }, [ + autoUpdaterDisabledReason, + filteredSettingsItems, + selectedIndex, + settingsData?.autoUpdatesChannel, + setTabsHidden, + ]) + const moveSelection = (delta: -1 | 1): void => { - setShowThinkingWarning(false); - const newIndex_1 = Math.max(0, Math.min(filteredSettingsItems.length - 1, selectedIndex + delta)); - setSelectedIndex(newIndex_1); - adjustScrollOffset(newIndex_1); - }; - useKeybindings({ - 'select:previous': () => { - if (selectedIndex === 0) { - // ↑ at top enters search mode so users can type-to-filter after - // reaching the list boundary. Wheel-up (scroll:lineUp) clamps - // instead — overshoot shouldn't move focus away from the list. - setShowThinkingWarning(false); - setIsSearchMode(true); - setScrollOffset(0); - } else { - moveSelection(-1); - } + setShowThinkingWarning(false) + const newIndex = Math.max( + 0, + Math.min(filteredSettingsItems.length - 1, selectedIndex + delta), + ) + setSelectedIndex(newIndex) + adjustScrollOffset(newIndex) + } + + useKeybindings( + { + 'select:previous': () => { + if (selectedIndex === 0) { + // ↑ at top enters search mode so users can type-to-filter after + // reaching the list boundary. Wheel-up (scroll:lineUp) clamps + // instead — overshoot shouldn't move focus away from the list. + setShowThinkingWarning(false) + setIsSearchMode(true) + setScrollOffset(0) + } else { + moveSelection(-1) + } + }, + 'select:next': () => moveSelection(1), + // Wheel. ScrollKeybindingHandler's scroll:line* returns false (not + // consumed) when the ScrollBox content fits — which it always does + // here because the list is paginated (slice). The event falls through + // to this handler which navigates the list, clamping at boundaries. + 'scroll:lineUp': () => moveSelection(-1), + 'scroll:lineDown': () => moveSelection(1), + 'select:accept': toggleSetting, + 'settings:search': () => { + setIsSearchMode(true) + setSearchQuery('') + }, }, - 'select:next': () => moveSelection(1), - // Wheel. ScrollKeybindingHandler's scroll:line* returns false (not - // consumed) when the ScrollBox content fits — which it always does - // here because the list is paginated (slice). The event falls through - // to this handler which navigates the list, clamping at boundaries. - 'scroll:lineUp': () => moveSelection(-1), - 'scroll:lineDown': () => moveSelection(1), - 'select:accept': toggleSetting, - 'settings:search': () => { - setIsSearchMode(true); - setSearchQuery(''); - } - }, { - context: 'Settings', - isActive: showSubmenu === null && !isSearchMode && !headerFocused - }); + { + context: 'Settings', + isActive: showSubmenu === null && !isSearchMode && !headerFocused, + }, + ) // Combined key handling across search/list modes. Branch order mirrors // the original useInput gate priority: submenu and header short-circuit // first (their own handlers own input), then search vs. list. - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (showSubmenu !== null) return; - if (headerFocused) return; - // Search mode: Esc clears then exits, Enter/↓ moves to the list. - if (isSearchMode) { - if (e.key === 'escape') { - e.preventDefault(); - if (searchQuery.length > 0) { - setSearchQuery(''); - } else { - setIsSearchMode(false); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (showSubmenu !== null) return + if (headerFocused) return + // Search mode: Esc clears then exits, Enter/↓ moves to the list. + if (isSearchMode) { + if (e.key === 'escape') { + e.preventDefault() + if (searchQuery.length > 0) { + setSearchQuery('') + } else { + setIsSearchMode(false) + } + return + } + if (e.key === 'return' || e.key === 'down' || e.key === 'wheeldown') { + e.preventDefault() + setIsSearchMode(false) + setSelectedIndex(0) + setScrollOffset(0) } - return; + return } - if (e.key === 'return' || e.key === 'down' || e.key === 'wheeldown') { - e.preventDefault(); - setIsSearchMode(false); - setSelectedIndex(0); - setScrollOffset(0); + // List mode: left/right/tab cycle the selected option's value. These + // keys used to switch tabs; now they only do so when the tab row is + // explicitly focused (see headerFocused in Settings.tsx). + if (e.key === 'left' || e.key === 'right' || e.key === 'tab') { + e.preventDefault() + toggleSetting() + return } - return; - } - // List mode: left/right/tab cycle the selected option's value. These - // keys used to switch tabs; now they only do so when the tab row is - // explicitly focused (see headerFocused in Settings.tsx). - if (e.key === 'left' || e.key === 'right' || e.key === 'tab') { - e.preventDefault(); - toggleSetting(); - return; - } - // Fallback: printable characters (other than those bound to actions) - // enter search mode. Carve out j/k// — useKeybindings (still on the - // useInput path) consumes these via stopImmediatePropagation, but - // onKeyDown dispatches independently so we must skip them explicitly. - if (e.ctrl || e.meta) return; - if (e.key === 'j' || e.key === 'k' || e.key === '/') return; - if (e.key.length === 1 && e.key !== ' ') { - e.preventDefault(); - setIsSearchMode(true); - setSearchQuery(e.key); - } - }, [showSubmenu, headerFocused, isSearchMode, searchQuery, setSearchQuery, toggleSetting]); - return - {showSubmenu === 'Theme' ? <> - { - isDirty.current = true; - setTheme(setting_1); - setShowSubmenu(null); - setTabsHidden(false); - }} onCancel={() => { - setShowSubmenu(null); - setTabsHidden(false); - }} hideEscToCancel skipExitHandling={true} // Skip exit handling as Config already handles it - /> + // Fallback: printable characters (other than those bound to actions) + // enter search mode. Carve out j/k// — useKeybindings (still on the + // useInput path) consumes these via stopImmediatePropagation, but + // onKeyDown dispatches independently so we must skip them explicitly. + if (e.ctrl || e.meta) return + if (e.key === 'j' || e.key === 'k' || e.key === '/') return + if (e.key.length === 1 && e.key !== ' ') { + e.preventDefault() + setIsSearchMode(true) + setSearchQuery(e.key) + } + }, + [ + showSubmenu, + headerFocused, + isSearchMode, + searchQuery, + setSearchQuery, + toggleSetting, + ], + ) + + return ( + + {showSubmenu === 'Theme' ? ( + <> + { + isDirty.current = true + setTheme(setting) + setShowSubmenu(null) + setTabsHidden(false) + }} + onCancel={() => { + setShowSubmenu(null) + setTabsHidden(false) + }} + hideEscToCancel + skipExitHandling={true} // Skip exit handling as Config already handles it + /> - + - : showSubmenu === 'Model' ? <> - { - isDirty.current = true; - onChangeMainModelConfig(model_0); - setShowSubmenu(null); - setTabsHidden(false); - }} onCancel={() => { - setShowSubmenu(null); - setTabsHidden(false); - }} showFastModeNotice={isFastModeEnabled() ? isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable() : false} /> + + ) : showSubmenu === 'Model' ? ( + <> + { + isDirty.current = true + onChangeMainModelConfig(model) + setShowSubmenu(null) + setTabsHidden(false) + }} + onCancel={() => { + setShowSubmenu(null) + setTabsHidden(false) + }} + showFastModeNotice={ + isFastModeEnabled() + ? isFastMode && + isFastModeSupportedByModel(mainLoopModel) && + isFastModeAvailable() + : false + } + /> - + - : showSubmenu === 'TeammateModel' ? <> - { - setShowSubmenu(null); - setTabsHidden(false); - // First-open-then-Enter from unset: picker highlights "Default" - // (initial=null) and confirming would write null, silently - // switching Opus-fallback → follow-leader. Treat as no-op. - if (globalConfig.teammateDefaultModel === undefined && model_1 === null) { - return; - } - isDirty.current = true; - saveGlobalConfig(current_23 => current_23.teammateDefaultModel === model_1 ? current_23 : { - ...current_23, - teammateDefaultModel: model_1 - }); - setGlobalConfig({ - ...getGlobalConfig(), - teammateDefaultModel: model_1 - }); - setChanges(prev_25 => ({ - ...prev_25, - teammateDefaultModel: teammateModelDisplayString(model_1) - })); - logEvent('tengu_teammate_default_model_changed', { - model: model_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }} onCancel={() => { - setShowSubmenu(null); - setTabsHidden(false); - }} /> + + ) : showSubmenu === 'TeammateModel' ? ( + <> + { + setShowSubmenu(null) + setTabsHidden(false) + // First-open-then-Enter from unset: picker highlights "Default" + // (initial=null) and confirming would write null, silently + // switching Opus-fallback → follow-leader. Treat as no-op. + if ( + globalConfig.teammateDefaultModel === undefined && + model === null + ) { + return + } + isDirty.current = true + saveGlobalConfig(current => + current.teammateDefaultModel === model + ? current + : { ...current, teammateDefaultModel: model }, + ) + setGlobalConfig({ + ...getGlobalConfig(), + teammateDefaultModel: model, + }) + setChanges(prev => ({ + ...prev, + teammateDefaultModel: teammateModelDisplayString(model), + })) + logEvent('tengu_teammate_default_model_changed', { + model: + model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }} + onCancel={() => { + setShowSubmenu(null) + setTabsHidden(false) + }} + /> - + - : showSubmenu === 'ExternalIncludes' ? <> - { - setShowSubmenu(null); - setTabsHidden(false); - }} externalIncludes={getExternalClaudeMdIncludes(memoryFiles)} /> + + ) : showSubmenu === 'ExternalIncludes' ? ( + <> + { + setShowSubmenu(null) + setTabsHidden(false) + }} + externalIncludes={getExternalClaudeMdIncludes(memoryFiles)} + /> - + - : showSubmenu === 'OutputStyle' ? <> - { - isDirty.current = true; - setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME); - setShowSubmenu(null); - setTabsHidden(false); + + ) : showSubmenu === 'OutputStyle' ? ( + <> + { + isDirty.current = true + setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME) + setShowSubmenu(null) + setTabsHidden(false) - // Save to local settings - updateSettingsForSource('localSettings', { - outputStyle: style - }); - void logEvent('tengu_output_style_changed', { - style: (style ?? DEFAULT_OUTPUT_STYLE_NAME) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - settings_source: 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }} onCancel={() => { - setShowSubmenu(null); - setTabsHidden(false); - }} /> + // Save to local settings + updateSettingsForSource('localSettings', { + outputStyle: style, + }) + + void logEvent('tengu_output_style_changed', { + style: (style ?? + DEFAULT_OUTPUT_STYLE_NAME) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + settings_source: + 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }} + onCancel={() => { + setShowSubmenu(null) + setTabsHidden(false) + }} + /> - + - : showSubmenu === 'Language' ? <> - { - isDirty.current = true; - setCurrentLanguage(language); - setShowSubmenu(null); - setTabsHidden(false); + + ) : showSubmenu === 'Language' ? ( + <> + { + isDirty.current = true + setCurrentLanguage(language) + setShowSubmenu(null) + setTabsHidden(false) - // Save to user settings - updateSettingsForSource('userSettings', { - language - }); - void logEvent('tengu_language_changed', { - language: (language ?? 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }} onCancel={() => { - setShowSubmenu(null); - setTabsHidden(false); - }} /> + // Save to user settings + updateSettingsForSource('userSettings', { + language, + }) + + void logEvent('tengu_language_changed', { + language: (language ?? + 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }} + onCancel={() => { + setShowSubmenu(null) + setTabsHidden(false) + }} + /> - + - : showSubmenu === 'EnableAutoUpdates' ? { - setShowSubmenu(null); - setTabsHidden(false); - }} hideBorder hideInputGuide> - {autoUpdaterDisabledReason?.type !== 'config' ? <> + + ) : showSubmenu === 'EnableAutoUpdates' ? ( + { + setShowSubmenu(null) + setTabsHidden(false) + }} + hideBorder + hideInputGuide + > + {autoUpdaterDisabledReason?.type !== 'config' ? ( + <> - {autoUpdaterDisabledReason?.type === 'env' ? 'Auto-updates are controlled by an environment variable and cannot be changed here.' : 'Auto-updates are disabled in development builds.'} + {autoUpdaterDisabledReason?.type === 'env' + ? 'Auto-updates are controlled by an environment variable and cannot be changed here.' + : 'Auto-updates are disabled in development builds.'} - {autoUpdaterDisabledReason?.type === 'env' && + {autoUpdaterDisabledReason?.type === 'env' && ( + Unset {autoUpdaterDisabledReason.envVar} to re-enable auto-updates. - } - : { + isDirty.current = true + setShowSubmenu(null) + setTabsHidden(false) + + saveGlobalConfig(current => ({ + ...current, + autoUpdates: true, + })) + setGlobalConfig({ ...getGlobalConfig(), autoUpdates: true }) + + updateSettingsForSource('userSettings', { + autoUpdatesChannel: channel as 'latest' | 'stable', + minimumVersion: undefined, + }) + setSettingsData(prev => ({ + ...prev, + autoUpdatesChannel: channel as 'latest' | 'stable', + minimumVersion: undefined, + })) + logEvent('tengu_autoupdate_enabled', { + channel: + channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }} + /> + )} + + ) : showSubmenu === 'ChannelDowngrade' ? ( + { + setShowSubmenu(null) + setTabsHidden(false) + + if (choice === 'cancel') { + // User cancelled - don't change anything + return + } + + isDirty.current = true + // Switch to stable channel + const newSettings: { + autoUpdatesChannel: 'stable' + minimumVersion?: string + } = { + autoUpdatesChannel: 'stable', + } + + if (choice === 'stay') { + // User wants to stay on current version until stable catches up + newSettings.minimumVersion = MACRO.VERSION + } + + updateSettingsForSource('userSettings', newSettings) + setSettingsData(prev => ({ + ...prev, + ...newSettings, + })) + logEvent('tengu_autoupdate_channel_changed', { + channel: + 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + minimum_version_set: choice === 'stay', + }) + }} + /> + ) : ( + + - {filteredSettingsItems.length === 0 ? + {filteredSettingsItems.length === 0 ? ( + No settings match "{searchQuery}" - : <> - {scrollOffset > 0 && + + ) : ( + <> + {scrollOffset > 0 && ( + {figures.arrowUp} {scrollOffset} more above - } - {filteredSettingsItems.slice(scrollOffset, scrollOffset + maxVisible).map((setting_2, i) => { - const actualIndex = scrollOffset + i; - const isSelected = actualIndex === selectedIndex && !headerFocused && !isSearchMode; - return + + )} + {filteredSettingsItems + .slice(scrollOffset, scrollOffset + maxVisible) + .map((setting, i) => { + const actualIndex = scrollOffset + i + const isSelected = + actualIndex === selectedIndex && + !headerFocused && + !isSearchMode + + return ( + {isSelected ? figures.pointer : ' '}{' '} - {setting_2.label} + {setting.label} - {setting_2.type === 'boolean' ? <> - - {setting_2.value.toString()} + {setting.type === 'boolean' ? ( + <> + + {setting.value.toString()} - {showThinkingWarning && setting_2.id === 'thinkingEnabled' && + {showThinkingWarning && + setting.id === 'thinkingEnabled' && ( + {' '} Changing thinking mode mid-conversation will increase latency and may reduce quality. - } - : setting_2.id === 'theme' ? - {THEME_LABELS[setting_2.value.toString()] ?? setting_2.value.toString()} - : setting_2.id === 'notifChannel' ? - - : setting_2.id === 'defaultPermissionMode' ? - {permissionModeTitle(setting_2.value as PermissionMode)} - : setting_2.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? - + + )} + + ) : setting.id === 'theme' ? ( + + {THEME_LABELS[setting.value.toString()] ?? + setting.value.toString()} + + ) : setting.id === 'notifChannel' ? ( + + + + ) : setting.id === 'defaultPermissionMode' ? ( + + {permissionModeTitle( + setting.value as PermissionMode, + )} + + ) : setting.id === 'autoUpdatesChannel' && + autoUpdaterDisabledReason ? ( + + disabled ( - {formatAutoUpdaterDisabledReason(autoUpdaterDisabledReason)} + {formatAutoUpdaterDisabledReason( + autoUpdaterDisabledReason, + )} ) - : - {setting_2.value.toString()} - } + + ) : ( + + {setting.value.toString()} + + )} - ; - })} - {scrollOffset + maxVisible < filteredSettingsItems.length && + + ) + })} + {scrollOffset + maxVisible < filteredSettingsItems.length && ( + {figures.arrowDown}{' '} {filteredSettingsItems.length - scrollOffset - maxVisible}{' '} more below - } - } + + )} + + )} - {headerFocused ? + {headerFocused ? ( + - + - : isSearchMode ? + + ) : isSearchMode ? ( + Type to filter - + - : + + ) : ( + - - - - + + + + - } - } - ; + + )} + + )} + + ) } + function teammateModelDisplayString(value: string | null | undefined): string { if (value === undefined) { - return modelDisplayString(getHardcodedTeammateModelFallback()); + return modelDisplayString(getHardcodedTeammateModelFallback()) } - if (value === null) return "Default (leader's model)"; - return modelDisplayString(value); + if (value === null) return "Default (leader's model)" + return modelDisplayString(value) } + const THEME_LABELS: Record = { auto: 'Auto (match terminal)', dark: 'Dark mode', @@ -1749,73 +2314,42 @@ const THEME_LABELS: Record = { 'dark-daltonized': 'Dark mode (colorblind-friendly)', 'light-daltonized': 'Light mode (colorblind-friendly)', 'dark-ansi': 'Dark mode (ANSI colors only)', - 'light-ansi': 'Light mode (ANSI colors only)' -}; -function NotifChannelLabel(t0) { - const $ = _c(4); - const { - value - } = t0; + 'light-ansi': 'Light mode (ANSI colors only)', +} + +function NotifChannelLabel({ value }: { value: string }): React.ReactNode { switch (value) { - case "auto": - { - return "Auto"; - } - case "iterm2": - { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = iTerm2 (OSC 9); - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - case "terminal_bell": - { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Terminal Bell (\a); - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - case "kitty": - { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Kitty (OSC 99); - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - case "ghostty": - { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Ghostty (OSC 777); - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; - } - case "iterm2_with_bell": - { - return "iTerm2 w/ Bell"; - } - case "notifications_disabled": - { - return "Disabled"; - } + case 'auto': + return 'Auto' + case 'iterm2': + return ( + + iTerm2 (OSC 9) + + ) + case 'terminal_bell': + return ( + + Terminal Bell (\a) + + ) + case 'kitty': + return ( + + Kitty (OSC 99) + + ) + case 'ghostty': + return ( + + Ghostty (OSC 777) + + ) + case 'iterm2_with_bell': + return 'iTerm2 w/ Bell' + case 'notifications_disabled': + return 'Disabled' default: - { - return value; - } + return value } } diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index cec9ecc0b..d54f6e758 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -1,136 +1,136 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import * as React from 'react'; -import { Suspense, useState } from 'react'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useIsInsideModal, useModalOrTerminalSize } from '../../context/modalContext.js'; -import { Pane } from '../design-system/Pane.js'; -import { Tabs, Tab } from '../design-system/Tabs.js'; -import { Status, buildDiagnostics } from './Status.js'; -import { Config } from './Config.js'; -import { Usage } from './Usage.js'; -import type { LocalJSXCommandContext, CommandResultDisplay } from '../../commands.js'; +import * as React from 'react' +import { Suspense, useState } from 'react' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { + useIsInsideModal, + useModalOrTerminalSize, +} from '../../context/modalContext.js' +import { Pane } from '../design-system/Pane.js' +import { Tabs, Tab } from '../design-system/Tabs.js' +import { Status, buildDiagnostics } from './Status.js' +import { Config } from './Config.js' +import { Usage } from './Usage.js' +import type { + LocalJSXCommandContext, + CommandResultDisplay, +} from '../../commands.js' + type Props = { - onClose: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - context: LocalJSXCommandContext; - defaultTab: 'Status' | 'Config' | 'Usage' | 'Gates'; -}; -export function Settings(t0) { - const $ = _c(25); - const { - onClose, - context, - defaultTab - } = t0; - const [selectedTab, setSelectedTab] = useState(defaultTab); - const [tabsHidden, setTabsHidden] = useState(false); - const [configOwnsEsc, setConfigOwnsEsc] = useState(false); - const [gatesOwnsEsc, setGatesOwnsEsc] = useState(false); - const insideModal = useIsInsideModal(); - const { - rows - } = useModalOrTerminalSize(useTerminalSize()); - const contentHeight = insideModal ? rows + 1 : Math.max(15, Math.min(Math.floor(rows * 0.8), 30)); - const [diagnosticsPromise] = useState(_temp2); - useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] !== onClose || $[1] !== tabsHidden) { - t1 = () => { - if (tabsHidden) { - return; - } - onClose("Status dialog dismissed", { - display: "system" - }); - }; - $[0] = onClose; - $[1] = tabsHidden; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleEscape = t1; - const t2 = !tabsHidden && !(selectedTab === "Config" && configOwnsEsc) && !(selectedTab === "Gates" && gatesOwnsEsc); - let t3; - if ($[3] !== t2) { - t3 = { - context: "Settings", - isActive: t2 - }; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - useKeybinding("confirm:no", handleEscape, t3); - let t4; - if ($[5] !== context || $[6] !== diagnosticsPromise) { - t4 = ; - $[5] = context; - $[6] = diagnosticsPromise; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== contentHeight || $[9] !== context || $[10] !== onClose) { - t5 = ; - $[8] = contentHeight; - $[9] = context; - $[10] = onClose; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t6 = ; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== contentHeight) { - t7 = []; - $[13] = contentHeight; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== t4 || $[16] !== t5 || $[17] !== t7) { - t8 = [t4, t5, t6, ...t7]; - $[15] = t4; - $[16] = t5; - $[17] = t7; - $[18] = t8; - } else { - t8 = $[18]; - } - const tabs = t8; - const t9 = defaultTab !== "Config" && defaultTab !== "Gates"; - const t10 = tabsHidden || insideModal ? undefined : contentHeight; - let t11; - if ($[19] !== selectedTab || $[20] !== t10 || $[21] !== t9 || $[22] !== tabs || $[23] !== tabsHidden) { - t11 = ; - $[19] = selectedTab; - $[20] = t10; - $[21] = t9; - $[22] = tabs; - $[23] = tabsHidden; - $[24] = t11; - } else { - t11 = $[24]; - } - return t11; + onClose: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + context: LocalJSXCommandContext + defaultTab: 'Status' | 'Config' | 'Usage' | 'Gates' } -function _temp2() { - return buildDiagnostics().catch(_temp); -} -function _temp() { - return []; + +export function Settings({ + onClose, + context, + defaultTab, +}: Props): React.ReactNode { + const [selectedTab, setSelectedTab] = useState(defaultTab) + const [tabsHidden, setTabsHidden] = useState(false) + // True while Config's own Esc handler is active (search mode with content + // focused). Settings must cede Esc so search can clear/exit first. + const [configOwnsEsc, setConfigOwnsEsc] = useState(false) + const [gatesOwnsEsc, setGatesOwnsEsc] = useState(false) + // Fixed content height so switching tabs doesn't shift the pane height. + // Outside modals cap at min(80% viewport, 30). Inside a Modal the modal's + // innerSize.rows IS the ScrollBox viewport — the 0.8 multiplier over- + // shrinks, leaving empty rows while Config shows "↓ N more below". + // + // Inside-modal math: Config's paneCap-10 chrome estimate was tuned for + // marginY={1} (2 rows) which is stripped inside modals → +2 to recover. + // Then -2 for Tabs' header row + its marginTop=1. Plus +1 observed gap + // from the paneCap-10 estimate being slightly generous. Net: rows + 1. + const insideModal = useIsInsideModal() + const { rows } = useModalOrTerminalSize(useTerminalSize()) + const contentHeight = insideModal + ? rows + 1 + : Math.max(15, Math.min(Math.floor(rows * 0.8), 30)) + // Kick off diagnostics once when the pane opens. Status use()s this so + // it resolves once per /config invocation — no re-fetch flash when + // tabbing back to Status (Tab unmounts children when not selected). + const [diagnosticsPromise] = useState(() => + buildDiagnostics().catch(() => []), + ) + + useExitOnCtrlCDWithKeybindings() + + // Handle escape via keybinding - only when not in submenu + const handleEscape = () => { + // Don't handle escape when a submenu is showing (tabsHidden means submenu is open) + // Let the submenu handle escape to return to the main menu + if (tabsHidden) { + return + } + // TODO: Update to "Settings" dialog once we define '/settings'. + onClose('Status dialog dismissed', { display: 'system' }) + } + + // Disable when submenu is open so the submenu's Dialog can handle ESC, + // and when Config's search mode is active so its useInput handler + // (clear query → exit search) processes Escape first. + useKeybinding('confirm:no', handleEscape, { + context: 'Settings', + isActive: + !tabsHidden && + !(selectedTab === 'Config' && configOwnsEsc) && + !(selectedTab === 'Gates' && gatesOwnsEsc), + }) + + const tabs = [ + + + , + + + + + , + + + , + ...(process.env.USER_TYPE === 'ant' + ? [ + + + , + ] + : []), + ] + + return ( + + + + ) } diff --git a/src/components/Settings/Status.tsx b/src/components/Settings/Status.tsx index e6d116da0..1cd4aac14 100644 --- a/src/components/Settings/Status.tsx +++ b/src/components/Settings/Status.tsx @@ -1,240 +1,192 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Suspense, use } from 'react'; -import { getSessionId } from '../../bootstrap/state.js'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { type AppState, useAppState } from '../../state/AppState.js'; -import { getCwd } from '../../utils/cwd.js'; -import { getCurrentSessionTitle } from '../../utils/sessionStorage.js'; -import { buildAccountProperties, buildAPIProviderProperties, buildIDEProperties, buildInstallationDiagnostics, buildInstallationHealthDiagnostics, buildMcpProperties, buildMemoryDiagnostics, buildSandboxProperties, buildSettingSourcesProperties, type Diagnostic, getModelDisplayLabel, type Property } from '../../utils/status.js'; -import type { ThemeName } from '../../utils/theme.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import figures from 'figures' +import * as React from 'react' +import { Suspense, use } from 'react' +import { getSessionId } from '../../bootstrap/state.js' +import type { LocalJSXCommandContext } from '../../commands.js' +import { useIsInsideModal } from '../../context/modalContext.js' +import { Box, Text, useTheme } from '../../ink.js' +import { type AppState, useAppState } from '../../state/AppState.js' +import { getCwd } from '../../utils/cwd.js' +import { getCurrentSessionTitle } from '../../utils/sessionStorage.js' +import { + buildAccountProperties, + buildAPIProviderProperties, + buildIDEProperties, + buildInstallationDiagnostics, + buildInstallationHealthDiagnostics, + buildMcpProperties, + buildMemoryDiagnostics, + buildSandboxProperties, + buildSettingSourcesProperties, + type Diagnostic, + getModelDisplayLabel, + type Property, +} from '../../utils/status.js' +import type { ThemeName } from '../../utils/theme.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' + type Props = { - context: LocalJSXCommandContext; - diagnosticsPromise: Promise; -}; + context: LocalJSXCommandContext + diagnosticsPromise: Promise +} + function buildPrimarySection(): Property[] { - const sessionId = getSessionId(); - const customTitle = getCurrentSessionTitle(sessionId); - const nameValue = customTitle ?? /rename to add a name; - return [{ - label: 'Version', - value: MACRO.VERSION - }, { - label: 'Session name', - value: nameValue - }, { - label: 'Session ID', - value: sessionId - }, { - label: 'cwd', - value: getCwd() - }, ...buildAccountProperties(), ...buildAPIProviderProperties()]; + const sessionId = getSessionId() + const customTitle = getCurrentSessionTitle(sessionId) + const nameValue = customTitle ?? /rename to add a name + + return [ + { label: 'Version', value: MACRO.VERSION }, + { label: 'Session name', value: nameValue }, + { label: 'Session ID', value: sessionId }, + { label: 'cwd', value: getCwd() }, + ...buildAccountProperties(), + ...buildAPIProviderProperties(), + ] } + function buildSecondarySection({ mainLoopModel, mcp, theme, - context + context, }: { - mainLoopModel: AppState['mainLoopModel']; - mcp: AppState['mcp']; - theme: ThemeName; - context: LocalJSXCommandContext; + mainLoopModel: AppState['mainLoopModel'] + mcp: AppState['mcp'] + theme: ThemeName + context: LocalJSXCommandContext }): Property[] { - const modelLabel = getModelDisplayLabel(mainLoopModel); - return [{ - label: 'Model', - value: modelLabel - }, ...buildIDEProperties(mcp.clients, context.options.ideInstallationStatus, theme), ...buildMcpProperties(mcp.clients, theme), ...buildSandboxProperties(), ...buildSettingSourcesProperties()]; + const modelLabel = getModelDisplayLabel(mainLoopModel) + + return [ + { label: 'Model', value: modelLabel }, + ...buildIDEProperties( + mcp.clients, + context.options.ideInstallationStatus, + theme, + ), + ...buildMcpProperties(mcp.clients, theme), + ...buildSandboxProperties(), + ...buildSettingSourcesProperties(), + ] } + export async function buildDiagnostics(): Promise { - return [...(await buildInstallationDiagnostics()), ...(await buildInstallationHealthDiagnostics()), ...(await buildMemoryDiagnostics())]; + return [ + ...(await buildInstallationDiagnostics()), + ...(await buildInstallationHealthDiagnostics()), + ...(await buildMemoryDiagnostics()), + ] } -function PropertyValue(t0) { - const $ = _c(8); - const { - value - } = t0; + +function PropertyValue({ + value, +}: { + value: Property['value'] +}): React.ReactNode { if (Array.isArray(value)) { - let t1; - if ($[0] !== value) { - let t2; - if ($[2] !== value.length) { - t2 = (item, i) => {item}{i < value.length - 1 ? "," : ""}; - $[2] = value.length; - $[3] = t2; - } else { - t2 = $[3]; - } - t1 = value.map(t2); - $[0] = value; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[4] !== t1) { - t2 = {t1}; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; - } - if (typeof value === "string") { - let t1; - if ($[6] !== value) { - t1 = {value}; - $[6] = value; - $[7] = t1; - } else { - t1 = $[7]; - } - return t1; - } - return value; -} -export function Status(t0) { - const $ = _c(20); - const { - context, - diagnosticsPromise - } = t0; - const mainLoopModel = useAppState(_temp); - const mcp = useAppState(_temp2); - const [theme] = useTheme(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = buildPrimarySection(); - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== context || $[2] !== mainLoopModel || $[3] !== mcp || $[4] !== theme) { - t2 = buildSecondarySection({ - mainLoopModel, - mcp, - theme, - context - }); - $[1] = context; - $[2] = mainLoopModel; - $[3] = mcp; - $[4] = theme; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== t2) { - t3 = [t1, t2]; - $[6] = t2; - $[7] = t3; - } else { - t3 = $[7]; - } - const sections = t3; - const grow = useIsInsideModal() ? 1 : undefined; - let t4; - if ($[8] !== sections) { - t4 = sections.map(_temp4); - $[8] = sections; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== diagnosticsPromise) { - t5 = ; - $[10] = diagnosticsPromise; - $[11] = t5; - } else { - t5 = $[11]; + return ( + + {value.map((item, i) => { + return ( + + {item} + {i < value.length - 1 ? ',' : ''} + + ) + })} + + ) } - let t6; - if ($[12] !== grow || $[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[12] = grow; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; + + if (typeof value === 'string') { + return {value} } - let t7; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t7 = ; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== grow || $[18] !== t6) { - t8 = {t6}{t7}; - $[17] = grow; - $[18] = t6; - $[19] = t8; - } else { - t8 = $[19]; - } - return t8; -} -function _temp4(properties, i) { - return properties.length > 0 && {properties.map(_temp3)}; + + return value } -function _temp3(t0, j) { - const { - label, - value - } = t0; - return {label !== undefined && {label}:}; + +export function Status({ + context, + diagnosticsPromise, +}: Props): React.ReactNode { + const mainLoopModel = useAppState(s => s.mainLoopModel) + const mcp = useAppState(s => s.mcp) + const [theme] = useTheme() + + // Sections are synchronous — compute in render so they're never empty. + // diagnosticsPromise is created once in Settings.tsx so it resolves once + // per pane invocation instead of re-fetching on every tab switch (Tab + // unmounts children when not selected, which was causing the flash). + const sections = React.useMemo( + () => [ + buildPrimarySection(), + buildSecondarySection({ mainLoopModel, mcp, theme, context }), + ], + [mainLoopModel, mcp, theme, context], + ) + + // flexGrow so the "Esc to cancel" footer pins to the bottom of the + // Modal's inner ScrollBox when content is short. The ScrollBox content + // wrapper has flexGrow:1 (fills at least the viewport), so this stretches + // to match. Without it, short Status content floats at the top and the + // footer sits mid-modal with 2-3 trailing blank rows below. Outside a + // Modal (non-fullscreen), leave layout alone — no ScrollBox to fill. + const grow = useIsInsideModal() ? 1 : undefined + + return ( + + + {sections.map( + (properties, i) => + properties.length > 0 && ( + + {properties.map(({ label, value }, j) => ( + + {label !== undefined && {label}:} + + + ))} + + ), + )} + + + + + + + + + + ) } -function _temp2(s_0) { - return s_0.mcp; -} -function _temp(s) { - return s.mainLoopModel; -} -function Diagnostics(t0) { - const $ = _c(5); - const { - promise - } = t0; - const diagnostics = use(promise) as any[]; - if (diagnostics.length === 0) { - return null; - } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = System Diagnostics; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== diagnostics) { - t2 = diagnostics.map(_temp5); - $[1] = diagnostics; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t1}{t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} -function _temp5(diagnostic, i) { - return {figures.warning}{typeof diagnostic === "string" ? {diagnostic} : diagnostic}; + +function Diagnostics({ + promise, +}: { + promise: Promise +}): React.ReactNode { + const diagnostics = use(promise) + if (diagnostics.length === 0) return null + return ( + + System Diagnostics + {diagnostics.map((diagnostic, i) => ( + + {figures.warning} + {typeof diagnostic === 'string' ? ( + {diagnostic} + ) : ( + diagnostic + )} + + ))} + + ) } diff --git a/src/components/Settings/Usage.tsx b/src/components/Settings/Usage.tsx index 29e9882fe..d52d19577 100644 --- a/src/components/Settings/Usage.tsx +++ b/src/components/Settings/Usage.tsx @@ -1,376 +1,322 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { extraUsage as extraUsageCommand } from 'src/commands/extra-usage/index.js'; -import { formatCost } from 'src/cost-tracker.js'; -import { getSubscriptionType } from 'src/utils/auth.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { type ExtraUsage, fetchUtilization, type RateLimit, type Utilization } from '../../services/api/usage.js'; -import { formatResetText } from '../../utils/format.js'; -import { logError } from '../../utils/log.js'; -import { jsonStringify } from '../../utils/slowOperations.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { ProgressBar } from '../design-system/ProgressBar.js'; -import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js'; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { extraUsage as extraUsageCommand } from 'src/commands/extra-usage/index.js' +import { formatCost } from 'src/cost-tracker.js' +import { getSubscriptionType } from 'src/utils/auth.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { + type ExtraUsage, + fetchUtilization, + type RateLimit, + type Utilization, +} from '../../services/api/usage.js' +import { formatResetText } from '../../utils/format.js' +import { logError } from '../../utils/log.js' +import { jsonStringify } from '../../utils/slowOperations.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { ProgressBar } from '../design-system/ProgressBar.js' +import { + isEligibleForOverageCreditGrant, + OverageCreditUpsell, +} from '../LogoV2/OverageCreditUpsell.js' + type LimitBarProps = { - title: string; - limit: RateLimit; - maxWidth: number; - showTimeInReset?: boolean; - extraSubtext?: string; -}; -function LimitBar(t0) { - const $ = _c(34); - const { - title, - limit, - maxWidth, - showTimeInReset: t1, - extraSubtext - } = t0; - const showTimeInReset = t1 === undefined ? true : t1; - const { - utilization, - resets_at - } = limit; + title: string + limit: RateLimit + maxWidth: number + showTimeInReset?: boolean + extraSubtext?: string +} + +function LimitBar({ + title, + limit, + maxWidth, + showTimeInReset = true, + extraSubtext, +}: LimitBarProps): React.ReactNode { + const { utilization, resets_at } = limit if (utilization === null) { - return null; + return null } - const usedText = `${Math.floor(utilization)}% used`; - let subtext; + + // Calculate usage percentage + const usedText = `${Math.floor(utilization)}% used` + + let subtext: string | undefined if (resets_at) { - let t2; - if ($[0] !== resets_at || $[1] !== showTimeInReset) { - t2 = formatResetText(resets_at, true, showTimeInReset); - $[0] = resets_at; - $[1] = showTimeInReset; - $[2] = t2; - } else { - t2 = $[2]; - } - subtext = `Resets ${t2}`; + subtext = `Resets ${formatResetText(resets_at, true, showTimeInReset)}` } + if (extraSubtext) { if (subtext) { - subtext = `${extraSubtext} · ${subtext}`; + subtext = `${extraSubtext} · ${subtext}` } else { - subtext = extraSubtext; + subtext = extraSubtext } } - if (maxWidth >= 62) { - let t2; - if ($[3] !== title) { - t2 = {title}; - $[3] = title; - $[4] = t2; - } else { - t2 = $[4]; - } - const t3 = utilization / 100; - let t4; - if ($[5] !== t3) { - t4 = ; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== usedText) { - t5 = {usedText}; - $[7] = usedText; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== t4 || $[10] !== t5) { - t6 = {t4}{t5}; - $[9] = t4; - $[10] = t5; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] !== subtext) { - t7 = subtext && {subtext}; - $[12] = subtext; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] !== t2 || $[15] !== t6 || $[16] !== t7) { - t8 = {t2}{t6}{t7}; - $[14] = t2; - $[15] = t6; - $[16] = t7; - $[17] = t8; - } else { - t8 = $[17]; - } - return t8; + + const maxBarWidth = 50 + const usedLabelSpace = 12 + if (maxWidth >= maxBarWidth + usedLabelSpace) { + return ( + + {title} + + + {usedText} + + {subtext && {subtext}} + + ) } else { - let t2; - if ($[18] !== title) { - t2 = {title}; - $[18] = title; - $[19] = t2; - } else { - t2 = $[19]; - } - let t3; - if ($[20] !== subtext) { - t3 = subtext && <> · {subtext}; - $[20] = subtext; - $[21] = t3; - } else { - t3 = $[21]; - } - let t4; - if ($[22] !== t2 || $[23] !== t3) { - t4 = {t2}{t3}; - $[22] = t2; - $[23] = t3; - $[24] = t4; - } else { - t4 = $[24]; - } - const t5 = utilization / 100; - let t6; - if ($[25] !== maxWidth || $[26] !== t5) { - t6 = ; - $[25] = maxWidth; - $[26] = t5; - $[27] = t6; - } else { - t6 = $[27]; - } - let t7; - if ($[28] !== usedText) { - t7 = {usedText}; - $[28] = usedText; - $[29] = t7; - } else { - t7 = $[29]; - } - let t8; - if ($[30] !== t4 || $[31] !== t6 || $[32] !== t7) { - t8 = {t4}{t6}{t7}; - $[30] = t4; - $[31] = t6; - $[32] = t7; - $[33] = t8; - } else { - t8 = $[33]; - } - return t8; + return ( + + + {title} + {subtext && ( + <> + + · {subtext} + + )} + + + {usedText} + + ) } } + export function Usage(): React.ReactNode { - const [utilization, setUtilization] = useState(null); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const { - columns - } = useTerminalSize(); - const availableWidth = columns - 2; // 2 for screen padding - const maxWidth = Math.min(availableWidth, 80); + const [utilization, setUtilization] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const { columns } = useTerminalSize() + + const availableWidth = columns - 2 // 2 for screen padding + const maxWidth = Math.min(availableWidth, 80) + const loadUtilization = React.useCallback(async () => { - setIsLoading(true); - setError(null); + setIsLoading(true) + setError(null) try { - const data = await fetchUtilization(); - setUtilization(data); + const data = await fetchUtilization() + setUtilization(data) } catch (err) { - logError(err as Error); - const axiosError = err as { - response?: { - data?: unknown; - }; - }; - const responseBody = axiosError.response?.data ? jsonStringify(axiosError.response.data) : undefined; - setError(responseBody ? `Failed to load usage data: ${responseBody}` : 'Failed to load usage data'); + logError(err as Error) + const axiosError = err as { response?: { data?: unknown } } + const responseBody = axiosError.response?.data + ? jsonStringify(axiosError.response.data) + : undefined + setError( + responseBody + ? `Failed to load usage data: ${responseBody}` + : 'Failed to load usage data', + ) } finally { - setIsLoading(false); + setIsLoading(false) } - }, []); + }, []) + useEffect(() => { - void loadUtilization(); - }, [loadUtilization]); - useKeybinding('settings:retry', () => { - void loadUtilization(); - }, { - context: 'Settings', - isActive: !!error && !isLoading - }); + void loadUtilization() + }, [loadUtilization]) + + useKeybinding( + 'settings:retry', + () => { + void loadUtilization() + }, + { context: 'Settings', isActive: !!error && !isLoading }, + ) + if (error) { - return + return ( + Error: {error} - - + + - ; + + ) } + if (!utilization) { - return + return ( + Loading usage data… - + - ; + + ) } // Only Max and Team plans have a Sonnet limit that differs from the weekly // limit (see rateLimitMessages.ts). For other plans the bar is redundant. // Show for null (unknown plan) to stay consistent with rateLimitMessages.ts, // which labels it "Sonnet limit" in that case. - const subscriptionType = getSubscriptionType(); - const showSonnetBar = subscriptionType === 'max' || subscriptionType === 'team' || subscriptionType === null; - const limits = [{ - title: 'Current session', - limit: utilization.five_hour - }, { - title: 'Current week (all models)', - limit: utilization.seven_day - }, ...(showSonnetBar ? [{ - title: 'Current week (Sonnet only)', - limit: utilization.seven_day_sonnet - }] : [])]; - return - {limits.some(({ - limit - }) => limit) || /usage is only available for subscription plans.} + const subscriptionType = getSubscriptionType() + const showSonnetBar = + subscriptionType === 'max' || + subscriptionType === 'team' || + subscriptionType === null - {limits.map(({ - title, - limit: limit_0 - }) => limit_0 && )} + const limits = [ + { + title: 'Current session', + limit: utilization.five_hour, + }, + { + title: 'Current week (all models)', + limit: utilization.seven_day, + }, + ...(showSonnetBar + ? [ + { + title: 'Current week (Sonnet only)', + limit: utilization.seven_day_sonnet, + }, + ] + : []), + ] - {utilization.extra_usage && } + return ( + + {limits.some(({ limit }) => limit) || ( + /usage is only available for subscription plans. + )} - {isEligibleForOverageCreditGrant() && } + {limits.map( + ({ title, limit }) => + limit && ( + + ), + )} + + {utilization.extra_usage && ( + + )} + + {isEligibleForOverageCreditGrant() && ( + + )} - + - ; + + ) } + type ExtraUsageSectionProps = { - extraUsage: ExtraUsage; - maxWidth: number; -}; -const EXTRA_USAGE_SECTION_TITLE = 'Extra usage'; -function ExtraUsageSection(t0) { - const $ = _c(20); - const { - extraUsage, - maxWidth - } = t0; - const subscriptionType = getSubscriptionType(); - const isProOrMax = subscriptionType === "pro" || subscriptionType === "max"; + extraUsage: ExtraUsage + maxWidth: number +} + +const EXTRA_USAGE_SECTION_TITLE = 'Extra usage' + +function ExtraUsageSection({ + extraUsage, + maxWidth, +}: ExtraUsageSectionProps): React.ReactNode { + const subscriptionType = getSubscriptionType() + const isProOrMax = subscriptionType === 'pro' || subscriptionType === 'max' if (!isProOrMax) { - return false; + // Only show to Pro and Max, consistent with claude.ai non-admin usage settings + return false } + if (!extraUsage.is_enabled) { if (extraUsageCommand.isEnabled()) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {EXTRA_USAGE_SECTION_TITLE}Extra usage not enabled · /extra-usage to enable; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + return ( + + {EXTRA_USAGE_SECTION_TITLE} + Extra usage not enabled · /extra-usage to enable + + ) } - return null; + + return null } + if (extraUsage.monthly_limit === null) { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {EXTRA_USAGE_SECTION_TITLE}Unlimited; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - if (typeof extraUsage.used_credits !== "number" || typeof extraUsage.utilization !== "number") { - return null; - } - const t1 = extraUsage.used_credits / 100; - let t2; - if ($[2] !== t1) { - t2 = formatCost(t1, 2); - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - const formattedUsedCredits = t2; - const t3 = extraUsage.monthly_limit / 100; - let t4; - if ($[4] !== t3) { - t4 = formatCost(t3, 2); - $[4] = t3; - $[5] = t4; - } else { - t4 = $[5]; + return ( + + {EXTRA_USAGE_SECTION_TITLE} + Unlimited + + ) } - const formattedMonthlyLimit = t4; - let T0; - let t5; - let t6; - let t7; - if ($[6] !== extraUsage.utilization) { - const now = new Date(); - const oneMonthReset = new Date(now.getFullYear(), now.getMonth() + 1, 1); - T0 = LimitBar; - t7 = EXTRA_USAGE_SECTION_TITLE; - t5 = extraUsage.utilization; - t6 = oneMonthReset.toISOString(); - $[6] = extraUsage.utilization; - $[7] = T0; - $[8] = t5; - $[9] = t6; - $[10] = t7; - } else { - T0 = $[7]; - t5 = $[8]; - t6 = $[9]; - t7 = $[10]; - } - let t8; - if ($[11] !== t5 || $[12] !== t6) { - t8 = { - utilization: t5, - resets_at: t6 - }; - $[11] = t5; - $[12] = t6; - $[13] = t8; - } else { - t8 = $[13]; - } - const t9 = `${formattedUsedCredits} / ${formattedMonthlyLimit} spent`; - let t10; - if ($[14] !== T0 || $[15] !== maxWidth || $[16] !== t7 || $[17] !== t8 || $[18] !== t9) { - t10 = ; - $[14] = T0; - $[15] = maxWidth; - $[16] = t7; - $[17] = t8; - $[18] = t9; - $[19] = t10; - } else { - t10 = $[19]; + + if ( + typeof extraUsage.used_credits !== 'number' || + typeof extraUsage.utilization !== 'number' + ) { + return null } - return t10; + + const formattedUsedCredits = formatCost(extraUsage.used_credits / 100, 2) + const formattedMonthlyLimit = formatCost(extraUsage.monthly_limit / 100, 2) + const now = new Date() + const oneMonthReset = new Date(now.getFullYear(), now.getMonth() + 1, 1) + + return ( + + ) } diff --git a/src/components/ShowInIDEPrompt.tsx b/src/components/ShowInIDEPrompt.tsx index 8c857bd77..e5ff331a3 100644 --- a/src/components/ShowInIDEPrompt.tsx +++ b/src/components/ShowInIDEPrompt.tsx @@ -1,169 +1,102 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename, relative } from 'path'; -import React from 'react'; -import { Box, Text } from '../ink.js'; -import { getCwd } from '../utils/cwd.js'; -import { isSupportedVSCodeTerminal } from '../utils/ide.js'; -import { Select } from './CustomSelect/index.js'; -import { Pane } from './design-system/Pane.js'; -import type { PermissionOption, PermissionOptionWithLabel } from './permissions/FilePermissionDialog/permissionOptions.js'; +import { basename, relative } from 'path' +import React from 'react' +import { Box, Text } from '../ink.js' +import { getCwd } from '../utils/cwd.js' +import { isSupportedVSCodeTerminal } from '../utils/ide.js' +import { Select } from './CustomSelect/index.js' +import { Pane } from './design-system/Pane.js' +import type { + PermissionOption, + PermissionOptionWithLabel, +} from './permissions/FilePermissionDialog/permissionOptions.js' + type Props = { - filePath: string; - input: A; - onChange: (option: PermissionOption, args: A, feedback?: string) => void; - options: PermissionOptionWithLabel[]; - ideName: string; - symlinkTarget?: string | null; - rejectFeedback: string; - acceptFeedback: string; - setFocusedOption: (value: string) => void; - onInputModeToggle: (value: string) => void; - focusedOption: string; - yesInputMode: boolean; - noInputMode: boolean; -}; -export function ShowInIDEPrompt(t0) { - const $ = _c(36); - const { - onChange, - options, - input, - filePath, - ideName, - symlinkTarget, - rejectFeedback, - acceptFeedback, - setFocusedOption, - onInputModeToggle, - focusedOption, - yesInputMode, - noInputMode - } = t0; - let t1; - if ($[0] !== ideName) { - t1 = Opened changes in {ideName} ⧉; - $[0] = ideName; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== symlinkTarget) { - t2 = symlinkTarget && {relative(getCwd(), symlinkTarget).startsWith("..") ? `This will modify ${symlinkTarget} (outside working directory) via a symlink` : `Symlink target: ${symlinkTarget}`}; - $[2] = symlinkTarget; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = isSupportedVSCodeTerminal() && Save file to continue…; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== filePath) { - t4 = basename(filePath); - $[5] = filePath; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t4) { - t5 = Do you want to make this edit to{" "}{t4}?; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== acceptFeedback || $[10] !== input || $[11] !== onChange || $[12] !== options || $[13] !== rejectFeedback) { - t6 = value => { - const selected = options.find(opt => opt.value === value); - if (selected) { - if (selected.option.type === "reject") { - const trimmedFeedback = rejectFeedback.trim(); - onChange(selected.option, input, trimmedFeedback || undefined); - return; - } - if (selected.option.type === "accept-once") { - const trimmedFeedback_0 = acceptFeedback.trim(); - onChange(selected.option, input, trimmedFeedback_0 || undefined); - return; - } - onChange(selected.option, input); - } - }; - $[9] = acceptFeedback; - $[10] = input; - $[11] = onChange; - $[12] = options; - $[13] = rejectFeedback; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] !== input || $[16] !== onChange) { - t7 = () => onChange({ - type: "reject" - }, input); - $[15] = input; - $[16] = onChange; - $[17] = t7; - } else { - t7 = $[17]; - } - let t8; - if ($[18] !== setFocusedOption) { - t8 = value_0 => setFocusedOption(value_0); - $[18] = setFocusedOption; - $[19] = t8; - } else { - t8 = $[19]; - } - let t9; - if ($[20] !== onInputModeToggle || $[21] !== options || $[22] !== t6 || $[23] !== t7 || $[24] !== t8) { - t9 = { + const selected = options.find(opt => opt.value === value) + if (selected) { + // For reject option + if (selected.option.type === 'reject') { + const trimmedFeedback = rejectFeedback.trim() + onChange(selected.option, input, trimmedFeedback || undefined) + return + } + // For accept-once option, pass accept feedback if present + if (selected.option.type === 'accept-once') { + const trimmedFeedback = acceptFeedback.trim() + onChange(selected.option, input, trimmedFeedback || undefined) + return + } + onChange(selected.option, input) + } + }} + onCancel={() => onChange({ type: 'reject' }, input)} + onFocus={value => setFocusedOption(value)} + onInputModeToggle={onInputModeToggle} + /> + + + + Esc to cancel + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} + + + + + ) } diff --git a/src/components/SkillImprovementSurvey.tsx b/src/components/SkillImprovementSurvey.tsx index 31803de0a..f42e27f23 100644 --- a/src/components/SkillImprovementSurvey.tsx +++ b/src/components/SkillImprovementSurvey.tsx @@ -1,151 +1,112 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useRef } from 'react'; -import { BLACK_CIRCLE, BULLET_OPERATOR } from '../constants/figures.js'; -import { Box, Text } from '../ink.js'; -import type { SkillUpdate } from '../utils/hooks/skillImprovement.js'; -import { normalizeFullWidthDigits } from '../utils/stringUtils.js'; -import { isValidResponseInput } from './FeedbackSurvey/FeedbackSurveyView.js'; -import type { FeedbackSurveyResponse } from './FeedbackSurvey/utils.js'; +import React, { useEffect, useRef } from 'react' +import { BLACK_CIRCLE, BULLET_OPERATOR } from '../constants/figures.js' +import { Box, Text } from '../ink.js' +import type { SkillUpdate } from '../utils/hooks/skillImprovement.js' +import { normalizeFullWidthDigits } from '../utils/stringUtils.js' +import { isValidResponseInput } from './FeedbackSurvey/FeedbackSurveyView.js' +import type { FeedbackSurveyResponse } from './FeedbackSurvey/utils.js' + type Props = { - isOpen: boolean; - skillName: string; - updates: SkillUpdate[]; - handleSelect: (selected: FeedbackSurveyResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; -}; -export function SkillImprovementSurvey(t0) { - const $ = _c(6); - const { - isOpen, - skillName, - updates, - handleSelect, - inputValue, - setInputValue - } = t0; + isOpen: boolean + skillName: string + updates: SkillUpdate[] + handleSelect: (selected: FeedbackSurveyResponse) => void + inputValue: string + setInputValue: (value: string) => void +} + +export function SkillImprovementSurvey({ + isOpen, + skillName, + updates, + handleSelect, + inputValue, + setInputValue, +}: Props): React.ReactNode { if (!isOpen) { - return null; + return null } + + // Hide the survey if the user is typing anything other than a survey response if (inputValue && !isValidResponseInput(inputValue)) { - return null; - } - let t1; - if ($[0] !== handleSelect || $[1] !== inputValue || $[2] !== setInputValue || $[3] !== skillName || $[4] !== updates) { - t1 = ; - $[0] = handleSelect; - $[1] = inputValue; - $[2] = setInputValue; - $[3] = skillName; - $[4] = updates; - $[5] = t1; - } else { - t1 = $[5]; + return null } - return t1; + + return ( + + ) } + type ViewProps = { - skillName: string; - updates: SkillUpdate[]; - onSelect: (option: FeedbackSurveyResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; -}; + skillName: string + updates: SkillUpdate[] + onSelect: (option: FeedbackSurveyResponse) => void + inputValue: string + setInputValue: (value: string) => void +} // Only 1 (apply) and 0 (dismiss) are valid for this survey -const VALID_INPUTS = ['0', '1'] as const; +const VALID_INPUTS = ['0', '1'] as const + function isValidInput(input: string): boolean { - return (VALID_INPUTS as readonly string[]).includes(input); + return (VALID_INPUTS as readonly string[]).includes(input) } -function SkillImprovementSurveyView(t0) { - const $ = _c(17); - const { - skillName, - updates, - onSelect, - inputValue, - setInputValue - } = t0; - const initialInputValue = useRef(inputValue); - let t1; - let t2; - if ($[0] !== inputValue || $[1] !== onSelect || $[2] !== setInputValue) { - t1 = () => { - if (inputValue !== initialInputValue.current) { - const lastChar = normalizeFullWidthDigits(inputValue.slice(-1)); - if (isValidInput(lastChar)) { - setInputValue(inputValue.slice(0, -1)); - onSelect(lastChar === "1" ? "good" : "dismissed"); - } + +function SkillImprovementSurveyView({ + skillName, + updates, + onSelect, + inputValue, + setInputValue, +}: ViewProps): React.ReactNode { + const initialInputValue = useRef(inputValue) + + useEffect(() => { + if (inputValue !== initialInputValue.current) { + const lastChar = normalizeFullWidthDigits(inputValue.slice(-1)) + if (isValidInput(lastChar)) { + setInputValue(inputValue.slice(0, -1)) + // Map: 1 = "good" (apply), 0 = "dismissed" + onSelect(lastChar === '1' ? 'good' : 'dismissed') } - }; - t2 = [inputValue, onSelect, setInputValue]; - $[0] = inputValue; - $[1] = onSelect; - $[2] = setInputValue; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - useEffect(t1, t2); - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {BLACK_CIRCLE} ; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== skillName) { - t4 = {t3}Skill improvement suggested for "{skillName}"; - $[6] = skillName; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== updates) { - t5 = updates.map(_temp); - $[8] = updates; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t5) { - t6 = {t5}; - $[10] = t5; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t7 = 1: Apply; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t8 = {t7}0: Dismiss; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== t4 || $[15] !== t6) { - t9 = {t4}{t6}{t8}; - $[14] = t4; - $[15] = t6; - $[16] = t9; - } else { - t9 = $[16]; - } - return t9; -} -function _temp(u, i) { - return {BULLET_OPERATOR} {u.change}; + } + }, [inputValue, onSelect, setInputValue]) + + return ( + + + {BLACK_CIRCLE} + + Skill improvement suggested for "{skillName}" + + + + + {updates.map((u, i) => ( + + {BULLET_OPERATOR} {u.change} + + ))} + + + + + + 1: Apply + + + + + 0: Dismiss + + + + + ) } diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 502f97a5a..dc8692215 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -1,89 +1,95 @@ -import { c as _c } from 'react/compiler-runtime'; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { Box, Text } from '../ink.js'; -import * as React from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { computeGlimmerIndex, computeShimmerSegments, SHIMMER_INTERVAL_MS } from '../bridge/bridgeStatusUtil.js'; -import { feature } from 'bun:bundle'; -import { getKairosActive, getUserMsgOptIn } from '../bootstrap/state.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { isEnvTruthy } from '../utils/envUtils.js'; -import { count } from '../utils/array.js'; -import sample from 'lodash-es/sample.js'; -import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js'; -import type { Theme } from 'src/utils/theme.js'; -import { activityManager } from '../utils/activityManager.js'; -import { getSpinnerVerbs } from '../constants/spinnerVerbs.js'; -import { MessageResponse } from './MessageResponse.js'; -import { TaskListV2 } from './TaskListV2.js'; -import { useTasksV2 } from '../hooks/useTasksV2.js'; -import type { Task } from '../utils/tasks.js'; -import { useAppState } from '../state/AppState.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js'; -import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js'; -import { useSettings } from '../hooks/useSettings.js'; -import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; -import { isBackgroundTask } from '../tasks/types.js'; -import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import { getEffortSuffix } from '../utils/effort.js'; -import { getMainLoopModel } from '../utils/model/model.js'; -import { getViewedTeammateTask } from '../state/selectors.js'; -import { TEARDROP_ASTERISK } from '../constants/figures.js'; -import figures from 'figures'; -import { getCurrentTurnTokenBudget, getTurnOutputTokens } from '../bootstrap/state.js'; -import { TeammateSpinnerTree } from './Spinner/TeammateSpinnerTree.js'; -import { useAnimationFrame } from '../ink.js'; -import { getGlobalConfig } from '../utils/config.js'; -export type { SpinnerMode } from './Spinner/index.js'; -const DEFAULT_CHARACTERS = getDefaultCharacters(); -const SPINNER_FRAMES = [...DEFAULT_CHARACTERS, ...[...DEFAULT_CHARACTERS].reverse()]; +import { Box, Text } from '../ink.js' +import * as React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + computeGlimmerIndex, + computeShimmerSegments, + SHIMMER_INTERVAL_MS, +} from '../bridge/bridgeStatusUtil.js' +import { feature } from 'bun:bundle' +import { getKairosActive, getUserMsgOptIn } from '../bootstrap/state.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { isEnvTruthy } from '../utils/envUtils.js' +import { count } from '../utils/array.js' +import sample from 'lodash-es/sample.js' +import { + formatDuration, + formatNumber, + formatSecondsShort, +} from '../utils/format.js' +import type { Theme } from 'src/utils/theme.js' +import { activityManager } from '../utils/activityManager.js' +import { getSpinnerVerbs } from '../constants/spinnerVerbs.js' +import { MessageResponse } from './MessageResponse.js' +import { TaskListV2 } from './TaskListV2.js' +import { useTasksV2 } from '../hooks/useTasksV2.js' +import type { Task } from '../utils/tasks.js' +import { useAppState } from '../state/AppState.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js' +import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js' +import { useSettings } from '../hooks/useSettings.js' +import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' +import { isBackgroundTask } from '../tasks/types.js' +import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import { getEffortSuffix } from '../utils/effort.js' +import { getMainLoopModel } from '../utils/model/model.js' +import { getViewedTeammateTask } from '../state/selectors.js' +import { TEARDROP_ASTERISK } from '../constants/figures.js' +import figures from 'figures' +import { + getCurrentTurnTokenBudget, + getTurnOutputTokens, +} from '../bootstrap/state.js' + +import { TeammateSpinnerTree } from './Spinner/TeammateSpinnerTree.js' +import { useAnimationFrame } from '../ink.js' +import { getGlobalConfig } from '../utils/config.js' +export type { SpinnerMode } from './Spinner/index.js' + +const DEFAULT_CHARACTERS = getDefaultCharacters() + +const SPINNER_FRAMES = [ + ...DEFAULT_CHARACTERS, + ...[...DEFAULT_CHARACTERS].reverse(), +] + + type Props = { - mode: SpinnerMode; - loadingStartTimeRef: React.RefObject; - totalPausedMsRef: React.RefObject; - pauseStartTimeRef: React.RefObject; - spinnerTip?: string; - responseLengthRef: React.RefObject; - apiMetricsRef?: React.RefObject< - Array<{ - ttftMs: number; - firstTokenTime: number; - lastTokenTime: number; - responseLengthBaseline: number; - endResponseLength: number; - }> - >; - overrideColor?: keyof Theme | null; - overrideShimmerColor?: keyof Theme | null; - overrideMessage?: string | null; - spinnerSuffix?: string | null; - verbose: boolean; - hasActiveTools?: boolean; + mode: SpinnerMode + loadingStartTimeRef: React.RefObject + totalPausedMsRef: React.RefObject + pauseStartTimeRef: React.RefObject + spinnerTip?: string + responseLengthRef: React.RefObject + overrideColor?: keyof Theme | null + overrideShimmerColor?: keyof Theme | null + overrideMessage?: string | null + spinnerSuffix?: string | null + verbose: boolean + hasActiveTools?: boolean /** Leader's turn has completed (no active query). Used to suppress stall-red spinner when only teammates are running. */ - leaderIsIdle?: boolean; -}; - -// Polyfill ant-only global functions that are normally injected by the bundler. -const computeTtftText = (metrics: ApiMetricEntry[]): string => ''; + leaderIsIdle?: boolean +} // Thin wrapper: branches on isBriefOnly so the two variants have independent // hook call chains. Without this split, toggling /brief mid-render would // violate Rules of Hooks (the inner variant calls ~10 more hooks). export function SpinnerWithVerb(props: Props): React.ReactNode { - const isBriefOnly = useAppState(s => s.isBriefOnly); + const isBriefOnly = useAppState(s => s.isBriefOnly) // REPL overrides isBriefOnly→false when viewing a teammate transcript // (see isBriefOnly={viewedTeammateTask ? false : isBriefOnly}). That // prop isn't threaded here, so replicate the gate from the store — // teammate view needs the real spinner (which shows teammate status). - const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) // Hoisted to mount-time — this component re-renders at animation framerate. const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) - : false; + : false // Runtime gate mirrors isBriefEnabled() but inlined — importing from // BriefTool.ts would leak tool-name strings into external builds. Single @@ -91,14 +97,20 @@ export function SpinnerWithVerb(props: Props): React.ReactNode { if ( (feature('KAIROS') || feature('KAIROS_BRIEF')) && (getKairosActive() || - (getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) && + (getUserMsgOptIn() && + (briefEnvEnabled || + getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) && isBriefOnly && !viewingAgentTaskId ) { - return ; + return ( + + ) } - return ; + + return } + function SpinnerWithVerbInner({ mode, loadingStartTimeRef, @@ -106,7 +118,6 @@ function SpinnerWithVerbInner({ pauseStartTimeRef, spinnerTip, responseLengthRef, - apiMetricsRef, overrideColor, overrideShimmerColor, overrideMessage, @@ -115,8 +126,8 @@ function SpinnerWithVerbInner({ hasActiveTools = false, leaderIsIdle = false, }: Props): React.ReactNode { - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false // NOTE: useAnimationFrame(50) lives in SpinnerAnimationRow, not here. // This component only re-renders when props or app state change — @@ -124,100 +135,114 @@ function SpinnerWithVerbInner({ // (frame, glimmer, stalled intensity, token counter, thinking shimmer, // elapsed-time timer) are computed inside the child. - const tasks = useAppState(s => s.tasks); - const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId); - const expandedView = useAppState(s_1 => s_1.expandedView); - const showExpandedTodos = expandedView === 'tasks'; - const showSpinnerTree = expandedView === 'teammates'; - const selectedIPAgentIndex = useAppState(s_2 => s_2.selectedIPAgentIndex); - const viewSelectionMode = useAppState(s_3 => s_3.viewSelectionMode); + const tasks = useAppState(s => s.tasks) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const expandedView = useAppState(s => s.expandedView) + const showExpandedTodos = expandedView === 'tasks' + const showSpinnerTree = expandedView === 'teammates' + const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex) + const viewSelectionMode = useAppState(s => s.viewSelectionMode) // Get foregrounded teammate (if viewing a teammate's transcript) const foregroundedTeammate = viewingAgentTaskId - ? getViewedTeammateTask({ - viewingAgentTaskId, - tasks, - }) - : undefined; - const { columns } = useTerminalSize(); - const tasksV2 = useTasksV2(); + ? getViewedTeammateTask({ viewingAgentTaskId, tasks }) + : undefined + const { columns } = useTerminalSize() + const tasksV2 = useTasksV2() // Track thinking status: 'thinking' | number (duration in ms) | null // Shows each state for minimum 2s to avoid UI jank - const [thinkingStatus, setThinkingStatus] = useState<'thinking' | number | null>(null); - const thinkingStartRef = useRef(null); + const [thinkingStatus, setThinkingStatus] = useState< + 'thinking' | number | null + >(null) + const thinkingStartRef = useRef(null) + useEffect(() => { - let showDurationTimer: ReturnType | null = null; - let clearStatusTimer: ReturnType | null = null; + let showDurationTimer: ReturnType | null = null + let clearStatusTimer: ReturnType | null = null + if (mode === 'thinking') { // Started thinking if (thinkingStartRef.current === null) { - thinkingStartRef.current = Date.now(); - setThinkingStatus('thinking'); + thinkingStartRef.current = Date.now() + setThinkingStatus('thinking') } } else if (thinkingStartRef.current !== null) { // Stopped thinking - calculate duration and ensure 2s minimum display - const duration = Date.now() - thinkingStartRef.current; - const elapsed = Date.now() - thinkingStartRef.current; - const remainingThinkingTime = Math.max(0, 2000 - elapsed); - thinkingStartRef.current = null; + const duration = Date.now() - thinkingStartRef.current + const elapsed = Date.now() - thinkingStartRef.current + const remainingThinkingTime = Math.max(0, 2000 - elapsed) + + thinkingStartRef.current = null // Show "thinking..." for remaining time if < 2s elapsed, then show duration const showDuration = (): void => { - setThinkingStatus(duration); + setThinkingStatus(duration) // Clear after 2s - clearStatusTimer = setTimeout(setThinkingStatus, 2000, null); - }; + clearStatusTimer = setTimeout(setThinkingStatus, 2000, null) + } + if (remainingThinkingTime > 0) { - showDurationTimer = setTimeout(showDuration, remainingThinkingTime); + showDurationTimer = setTimeout(showDuration, remainingThinkingTime) } else { - showDuration(); + showDuration() } } + return () => { - if (showDurationTimer) clearTimeout(showDurationTimer); - if (clearStatusTimer) clearTimeout(clearStatusTimer); - }; - }, [mode]); + if (showDurationTimer) clearTimeout(showDurationTimer) + if (clearStatusTimer) clearTimeout(clearStatusTimer) + } + }, [mode]) // Find the current in-progress task and next pending task - const currentTodo = tasksV2?.find(task => task.status !== 'pending' && task.status !== 'completed'); - const nextTask = findNextPendingTask(tasksV2); + const currentTodo = tasksV2?.find( + task => task.status !== 'pending' && task.status !== 'completed', + ) + const nextTask = findNextPendingTask(tasksV2) // Use useState with initializer to pick a random verb once on mount - const [randomVerb] = useState(() => sample(getSpinnerVerbs())); + const [randomVerb] = useState(() => sample(getSpinnerVerbs())) // Leader's own verb (always the leader's, regardless of who is foregrounded) - const leaderVerb = overrideMessage ?? currentTodo?.activeForm ?? currentTodo?.subject ?? randomVerb; + const leaderVerb = + overrideMessage ?? + currentTodo?.activeForm ?? + currentTodo?.subject ?? + randomVerb + const effectiveVerb = foregroundedTeammate && !foregroundedTeammate.isIdle ? (foregroundedTeammate.spinnerVerb ?? randomVerb) - : leaderVerb; - const message = effectiveVerb + '…'; + : leaderVerb + const message = effectiveVerb + '…' // Track CLI activity when spinner is active useEffect(() => { - const operationId = 'spinner-' + mode; - activityManager.startCLIActivity(operationId); + const operationId = 'spinner-' + mode + activityManager.startCLIActivity(operationId) return () => { - activityManager.endCLIActivity(operationId); - }; - }, [mode]); - const effortValue = useAppState(s_4 => s_4.effortValue); - const effortSuffix = getEffortSuffix(getMainLoopModel(), effortValue); + activityManager.endCLIActivity(operationId) + } + }, [mode]) + + const effortValue = useAppState(s => s.effortValue) + const effortSuffix = getEffortSuffix(getMainLoopModel(), effortValue) // Check if any running in-process teammates exist (needed for both modes) - const runningTeammates = getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running'); - const hasRunningTeammates = runningTeammates.length > 0; - const allIdle = hasRunningTeammates && runningTeammates.every(t_0 => t_0.isIdle); + const runningTeammates = getAllInProcessTeammateTasks(tasks).filter( + t => t.status === 'running', + ) + const hasRunningTeammates = runningTeammates.length > 0 + const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle) // Gather aggregate token stats from all running swarm teammates // In spinner-tree mode, skip aggregation (teammates have their own lines in the tree) - let teammateTokens = 0; + let teammateTokens = 0 if (!showSpinnerTree) { - for (const task_0 of Object.values(tasks)) { - if (isInProcessTeammateTask(task_0) && task_0.status === 'running') { - if (task_0.progress?.tokenCount) { - teammateTokens += task_0.progress.tokenCount; + for (const task of Object.values(tasks)) { + if (isInProcessTeammateTask(task) && task.status === 'running') { + if (task.progress?.tokenCount) { + teammateTokens += task.progress.tokenCount } } } @@ -228,26 +253,33 @@ function SpinnerWithVerbInner({ // a coarse 30s threshold. const elapsedSnapshot = pauseStartTimeRef.current !== null - ? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current - : Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current; + ? pauseStartTimeRef.current - + loadingStartTimeRef.current - + totalPausedMsRef.current + : Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current // Leader token count for TeammateSpinnerTree — read raw (non-animated) from // the ref. The tree is only shown when teammates are running; teammate // progress updates to s.tasks trigger re-renders that keep this fresh. - const leaderTokenCount = Math.round(responseLengthRef.current / 4); - const defaultColor: keyof Theme = 'claude'; - const defaultShimmerColor = 'claudeShimmer'; - const messageColor = overrideColor ?? defaultColor; - const shimmerColor = overrideShimmerColor ?? defaultShimmerColor; + const leaderTokenCount = Math.round(responseLengthRef.current / 4) + + const defaultColor: keyof Theme = 'claude' + const defaultShimmerColor = 'claudeShimmer' + const messageColor = overrideColor ?? defaultColor + const shimmerColor = overrideShimmerColor ?? defaultShimmerColor // Compute TTFT string here (off the 50ms animation clock) and pass to // SpinnerAnimationRow so it folds into the `(thought for Ns · ...)` status // line instead of taking a separate row. apiMetricsRef is a ref so this // doesn't trigger re-renders; we pick up updates on the parent's ~25x/turn // re-render cadence, same as the old ApiMetricsLine did. - let ttftText: string | null = null; - if (process.env.USER_TYPE === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) { - ttftText = computeTtftText(apiMetricsRef.current); + let ttftText: string | null = null + if ( + process.env.USER_TYPE === 'ant' && + apiMetricsRef?.current && + apiMetricsRef.current.length > 0 + ) { + ttftText = computeTtftText(apiMetricsRef.current) } // When leader is idle but teammates are running (and we're viewing the leader), @@ -272,14 +304,14 @@ function SpinnerWithVerbInner({ /> )} - ); + ) } // When viewing an idle teammate, show static idle display instead of animated spinner if (foregroundedTeammate?.isIdle) { const idleText = allIdle ? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}` - : `${TEARDROP_ASTERISK} Idle`; + : `${TEARDROP_ASTERISK} Idle` return ( @@ -296,46 +328,50 @@ function SpinnerWithVerbInner({ /> )} - ); + ) } // Time-based tip overrides: coarse thresholds so a stale ref read (we're // off the 50ms clock) is fine. Other triggers (mode change, setMessages) // cause re-renders that refresh this in practice. - let contextTipsActive = false; - const tipsEnabled = settings.spinnerTipsEnabled !== false; - const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000; - const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount; + let contextTipsActive = false + const tipsEnabled = settings.spinnerTipsEnabled !== false + const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000 + const showBtwTip = + tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount + const effectiveTip = contextTipsActive ? undefined : showClearTip && !nextTask ? 'Use /clear to start fresh when switching topics and free up context' : showBtwTip && !nextTask ? "Use /btw to ask a quick side question without interrupting Claude's current work" - : spinnerTip; + : spinnerTip // Budget text (ant-only) — shown above the tip line - let budgetText: string | null = null; + let budgetText: string | null = null if (feature('TOKEN_BUDGET')) { - const budget = getCurrentTurnTokenBudget(); + const budget = getCurrentTurnTokenBudget() if (budget !== null && budget > 0) { - const tokens = getTurnOutputTokens(); + const tokens = getTurnOutputTokens() if (tokens >= budget) { - budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})`; + budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})` } else { - const pct = Math.round((tokens / budget) * 100); - const remaining = budget - tokens; - const rate = elapsedSnapshot > 5000 && tokens >= 2000 ? tokens / elapsedSnapshot : 0; + const pct = Math.round((tokens / budget) * 100) + const remaining = budget - tokens + const rate = + elapsedSnapshot > 5000 && tokens >= 2000 + ? tokens / elapsedSnapshot + : 0 const eta = rate > 0 - ? ` \u00B7 ~${formatDuration(remaining / rate, { - mostSignificantOnly: true, - })}` - : ''; - budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}`; + ? ` \u00B7 ~${formatDuration(remaining / rate, { mostSignificantOnly: true })}` + : '' + budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}` } } } + return ( - {nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`} + + {nextTask + ? `Next: ${nextTask.subject}` + : `Tip: ${effectiveTip}`} + )} ) : null} - ); + ) } // Brief/assistant mode spinner: single status line. PromptInput drops its @@ -406,288 +446,173 @@ function SpinnerWithVerbInner({ // spinner, not over the spinner content. Paired with BriefIdleStatus which // keeps the same footprint when idle. type BriefSpinnerProps = { - mode: SpinnerMode; - overrideMessage?: string | null; -}; -function BriefSpinner(t0) { - const $ = _c(31); - const { mode, overrideMessage } = t0; - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const [randomVerb] = useState(_temp4); - const verb = overrideMessage ?? randomVerb; - const connStatus = useAppState(_temp5); - let t1; - let t2; - if ($[0] !== mode) { - t1 = () => { - const operationId = 'spinner-' + mode; - activityManager.startCLIActivity(operationId); - return () => { - activityManager.endCLIActivity(operationId); - }; - }; - t2 = [mode]; - $[0] = mode; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - const [, time] = useAnimationFrame(reducedMotion ? null : 120); - const runningCount = useAppState(_temp6); - const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected'; - const connText = connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected'; - const dotFrame = Math.floor(time / 300) % 3; - let t3; - if ($[3] !== dotFrame || $[4] !== reducedMotion) { - t3 = reducedMotion ? '\u2026 ' : '.'.repeat(dotFrame + 1).padEnd(3); - $[3] = dotFrame; - $[4] = reducedMotion; - $[5] = t3; - } else { - t3 = $[5]; - } - const dots = t3; - let t4; - if ($[6] !== verb) { - t4 = stringWidth(verb); - $[6] = verb; - $[7] = t4; - } else { - t4 = $[7]; - } - const verbWidth = t4; - let t5; - if ($[8] !== reducedMotion || $[9] !== showConnWarning || $[10] !== time || $[11] !== verb || $[12] !== verbWidth) { - const glimmerIndex = - reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth); - t5 = computeShimmerSegments(verb, glimmerIndex); - $[8] = reducedMotion; - $[9] = showConnWarning; - $[10] = time; - $[11] = verb; - $[12] = verbWidth; - $[13] = t5; - } else { - t5 = $[13]; - } - const { before, shimmer, after } = t5; - const { columns } = useTerminalSize(); - const rightText = runningCount > 0 ? `${runningCount} in background` : ''; - let t6; - if ($[14] !== connText || $[15] !== showConnWarning || $[16] !== verbWidth) { - t6 = showConnWarning ? stringWidth(connText) : verbWidth; - $[14] = connText; - $[15] = showConnWarning; - $[16] = verbWidth; - $[17] = t6; - } else { - t6 = $[17]; - } - const leftWidth = t6 + 3; - const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText)); - let t7; - if ( - $[18] !== after || - $[19] !== before || - $[20] !== connText || - $[21] !== dots || - $[22] !== shimmer || - $[23] !== showConnWarning - ) { - t7 = showConnWarning ? ( - {connText + dots} - ) : ( - <> - {before ? {before} : null} - {shimmer ? {shimmer} : null} - {after ? {after} : null} - {dots} - - ); - $[18] = after; - $[19] = before; - $[20] = connText; - $[21] = dots; - $[22] = shimmer; - $[23] = showConnWarning; - $[24] = t7; - } else { - t7 = $[24]; - } - let t8; - if ($[25] !== pad || $[26] !== rightText) { - t8 = rightText ? ( - <> - {' '.repeat(pad)} - {rightText} - - ) : null; - $[25] = pad; - $[26] = rightText; - $[27] = t8; - } else { - t8 = $[27]; - } - let t9; - if ($[28] !== t7 || $[29] !== t8) { - t9 = ( - - {t7} - {t8} - - ); - $[28] = t7; - $[29] = t8; - $[30] = t9; - } else { - t9 = $[30]; - } - return t9; + mode: SpinnerMode + overrideMessage?: string | null +} + +function BriefSpinner({ + mode, + overrideMessage, +}: BriefSpinnerProps): React.ReactNode { + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + const [randomVerb] = useState(() => sample(getSpinnerVerbs()) ?? 'Working') + const verb = overrideMessage ?? randomVerb + const connStatus = useAppState(s => s.remoteConnectionStatus) + + // Track CLI activity so OS/IDE "busy" indicators fire in brief mode too + useEffect(() => { + const operationId = 'spinner-' + mode + activityManager.startCLIActivity(operationId) + return () => { + activityManager.endCLIActivity(operationId) + } + }, [mode]) + + // Drive both dot cycle and shimmer from the shared clock. The viewport + // ref is unused — the spinner unmounts on turn end so viewport-based + // pausing isn't needed. + const [, time] = useAnimationFrame(reducedMotion ? null : 120) + + // Local tasks + remote tasks are mutually exclusive (viewer mode has an + // empty local AppState.tasks; local mode has remoteBackgroundTaskCount=0). + // Summing avoids a mode branch. + const runningCount = useAppState( + s => + count(Object.values(s.tasks), isBackgroundTask) + + s.remoteBackgroundTaskCount, + ) + + // Connection trouble overrides the verb — `claude assistant` is a pure viewer, + // nothing useful is happening while the WS is down. + const showConnWarning = + connStatus === 'reconnecting' || connStatus === 'disconnected' + const connText = + connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected' + + // Dots padded to a fixed 3 columns so the right-aligned count doesn't + // jitter as the cycle advances. + const dotFrame = Math.floor(time / 300) % 3 + const dots = reducedMotion ? '… ' : '.'.repeat(dotFrame + 1).padEnd(3) + + // Shimmer: reverse-sweep highlight across the verb. Skip for connection + // warnings (shimmer reads as "working"; Reconnecting/Disconnected is not). + const verbWidth = useMemo(() => stringWidth(verb), [verb]) + const glimmerIndex = + reducedMotion || showConnWarning + ? -100 + : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth) + const { before, shimmer, after } = computeShimmerSegments(verb, glimmerIndex) + + const { columns } = useTerminalSize() + const rightText = runningCount > 0 ? `${runningCount} in background` : '' + // Manual right-align via space padding — flexGrow spacers inside + // FullscreenLayout's `main` slot don't resolve a width and caused the + // diff engine to miss dot-frame updates. + const leftWidth = (showConnWarning ? stringWidth(connText) : verbWidth) + 3 + const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText)) + + return ( + + {showConnWarning ? ( + {connText + dots} + ) : ( + <> + {before ? {before} : null} + {shimmer ? {shimmer} : null} + {after ? {after} : null} + {dots} + + )} + {rightText ? ( + <> + {' '.repeat(pad)} + {rightText} + + ) : null} + + ) } // Idle placeholder for brief mode. Same 2-row [blank, content] footprint // as BriefSpinner so the input bar never jumps when toggling between // working/idle/disconnected. See BriefSpinner's comment for the // Notifications overlay coupling. -function _temp6(s_0) { - return count(Object.values(s_0.tasks), isBackgroundTask) + s_0.remoteBackgroundTaskCount; -} -function _temp5(s) { - return s.remoteConnectionStatus; -} -function _temp4() { - return sample(getSpinnerVerbs()) ?? 'Working'; -} -export function BriefIdleStatus() { - const $ = _c(9); - const connStatus = useAppState(_temp7); - const runningCount = useAppState(_temp8); - const { columns } = useTerminalSize(); - const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected'; - const connText = connStatus === 'reconnecting' ? 'Reconnecting\u2026' : 'Disconnected'; - const leftText = showConnWarning ? connText : ''; - const rightText = runningCount > 0 ? `${runningCount} in background` : ''; - if (!leftText && !rightText) { - let t0; - if ($[0] === Symbol.for('react.memo_cache_sentinel')) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; - } - const pad = Math.max(1, columns - 2 - stringWidth(leftText) - stringWidth(rightText)); - let t0; - if ($[1] !== leftText) { - t0 = leftText ? {leftText} : null; - $[1] = leftText; - $[2] = t0; - } else { - t0 = $[2]; - } - let t1; - if ($[3] !== pad || $[4] !== rightText) { - t1 = rightText ? ( - <> - {' '.repeat(pad)} - {rightText} - - ) : null; - $[3] = pad; - $[4] = rightText; - $[5] = t1; - } else { - t1 = $[5]; - } - let t2; - if ($[6] !== t0 || $[7] !== t1) { - t2 = ( - - - {t0} - {t1} - - - ); - $[6] = t0; - $[7] = t1; - $[8] = t2; - } else { - t2 = $[8]; - } - return t2; -} -function _temp8(s_0) { - return count(Object.values(s_0.tasks), isBackgroundTask) + s_0.remoteBackgroundTaskCount; -} -function _temp7(s) { - return s.remoteConnectionStatus; +export function BriefIdleStatus(): React.ReactNode { + const connStatus = useAppState(s => s.remoteConnectionStatus) + const runningCount = useAppState( + s => + count(Object.values(s.tasks), isBackgroundTask) + + s.remoteBackgroundTaskCount, + ) + const { columns } = useTerminalSize() + + const showConnWarning = + connStatus === 'reconnecting' || connStatus === 'disconnected' + const connText = + connStatus === 'reconnecting' ? 'Reconnecting…' : 'Disconnected' + const leftText = showConnWarning ? connText : '' + const rightText = runningCount > 0 ? `${runningCount} in background` : '' + + if (!leftText && !rightText) return + + const pad = Math.max( + 1, + columns - 2 - stringWidth(leftText) - stringWidth(rightText), + ) + return ( + + + {leftText ? {leftText} : null} + {rightText ? ( + <> + {' '.repeat(pad)} + {rightText} + + ) : null} + + + ) } -export function Spinner() { - const $ = _c(8); - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const [ref, time] = useAnimationFrame(reducedMotion ? null : 120); + +export function Spinner(): React.ReactNode { + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + const [ref, time] = useAnimationFrame(reducedMotion ? null : 120) + + // Reduced motion: static dot instead of animated spinner if (reducedMotion) { - let t0; - if ($[0] === Symbol.for('react.memo_cache_sentinel')) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] !== ref) { - t1 = ( - - {t0} - - ); - $[1] = ref; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - const frame = Math.floor(time / 120) % SPINNER_FRAMES.length; - const t0 = SPINNER_FRAMES[frame]; - let t1; - if ($[3] !== t0) { - t1 = {t0}; - $[3] = t0; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] !== ref || $[6] !== t1) { - t2 = ( + return ( - {t1} + - ); - $[5] = ref; - $[6] = t1; - $[7] = t2; - } else { - t2 = $[7]; + ) } - return t2; + + // Derive frame from synced time - all spinners animate together + const frame = Math.floor(time / 120) % SPINNER_FRAMES.length + + return ( + + {SPINNER_FRAMES[frame]} + + ) } + + function findNextPendingTask(tasks: Task[] | undefined): Task | undefined { if (!tasks) { - return undefined; + return undefined } - const pendingTasks = tasks.filter(t => t.status === 'pending'); + const pendingTasks = tasks.filter(t => t.status === 'pending') if (pendingTasks.length === 0) { - return undefined; + return undefined } - const unresolvedIds = new Set(tasks.filter(t => t.status !== 'completed').map(t => t.id)); - return pendingTasks.find(t => !t.blockedBy.some(id => unresolvedIds.has(id))) ?? pendingTasks[0]; + const unresolvedIds = new Set( + tasks.filter(t => t.status !== 'completed').map(t => t.id), + ) + return ( + pendingTasks.find(t => !t.blockedBy.some(id => unresolvedIds.has(id))) ?? + pendingTasks[0] + ) } diff --git a/src/components/Spinner/FlashingChar.tsx b/src/components/Spinner/FlashingChar.tsx index 689b2e2e8..7f67a47ad 100644 --- a/src/components/Spinner/FlashingChar.tsx +++ b/src/components/Spinner/FlashingChar.tsx @@ -1,60 +1,39 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text, useTheme } from '../../ink.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { interpolateColor, parseRGB, toRGBColor } from './utils.js'; +import * as React from 'react' +import { Text, useTheme } from '../../ink.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { interpolateColor, parseRGB, toRGBColor } from './utils.js' + type Props = { - char: string; - flashOpacity: number; - messageColor: keyof Theme; - shimmerColor: keyof Theme; -}; -export function FlashingChar(t0) { - const $ = _c(9); - const { - char, - flashOpacity, - messageColor, - shimmerColor - } = t0; - const [themeName] = useTheme(); - let t1; - if ($[0] !== char || $[1] !== flashOpacity || $[2] !== messageColor || $[3] !== shimmerColor || $[4] !== themeName) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const theme = getTheme(themeName); - const baseColorStr = theme[messageColor]; - const shimmerColorStr = theme[shimmerColor]; - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null; - const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null; - if (baseRGB && shimmerRGB) { - const interpolated = interpolateColor(baseRGB, shimmerRGB, flashOpacity); - t1 = {char}; - break bb0; - } - } - $[0] = char; - $[1] = flashOpacity; - $[2] = messageColor; - $[3] = shimmerColor; - $[4] = themeName; - $[5] = t1; - } else { - t1 = $[5]; - } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - const shouldUseShimmer = flashOpacity > 0.5; - const t2 = shouldUseShimmer ? shimmerColor : messageColor; - let t3; - if ($[6] !== char || $[7] !== t2) { - t3 = {char}; - $[6] = char; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; + char: string + flashOpacity: number + messageColor: keyof Theme + shimmerColor: keyof Theme +} + +export function FlashingChar({ + char, + flashOpacity, + messageColor, + shimmerColor, +}: Props): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + + const baseColorStr = theme[messageColor] + const shimmerColorStr = theme[shimmerColor] + + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null + const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null + + if (baseRGB && shimmerRGB) { + // Smooth interpolation between colors + const interpolated = interpolateColor(baseRGB, shimmerRGB, flashOpacity) + return {char} } - return t3; + + // Fallback for ANSI themes: binary switch + const shouldUseShimmer = flashOpacity > 0.5 + return ( + {char} + ) } diff --git a/src/components/Spinner/GlimmerMessage.tsx b/src/components/Spinner/GlimmerMessage.tsx index 4b8020a06..3e488f9a1 100644 --- a/src/components/Spinner/GlimmerMessage.tsx +++ b/src/components/Spinner/GlimmerMessage.tsx @@ -1,327 +1,142 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Text, useTheme } from '../../ink.js'; -import { getGraphemeSegmenter } from '../../utils/intl.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import type { SpinnerMode } from './types.js'; -import { interpolateColor, parseRGB, toRGBColor } from './utils.js'; +import * as React from 'react' +import { stringWidth } from '../../ink/stringWidth.js' +import { Text, useTheme } from '../../ink.js' +import { getGraphemeSegmenter } from '../../utils/intl.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import type { SpinnerMode } from './types.js' +import { interpolateColor, parseRGB, toRGBColor } from './utils.js' + type Props = { - message: string; - mode: SpinnerMode; - messageColor: keyof Theme; - glimmerIndex: number; - flashOpacity: number; - shimmerColor: keyof Theme; - stalledIntensity?: number; -}; -const ERROR_RED = { - r: 171, - g: 43, - b: 63 -}; -export function GlimmerMessage(t0) { - const $ = _c(75); - const { - message, - mode, - messageColor, - glimmerIndex, - flashOpacity, - shimmerColor, - stalledIntensity: t1 - } = t0; - const stalledIntensity = t1 === undefined ? 0 : t1; - const [themeName] = useTheme(); - let messageWidth; - let segments; - let t2; - if ($[0] !== flashOpacity || $[1] !== message || $[2] !== messageColor || $[3] !== mode || $[4] !== shimmerColor || $[5] !== stalledIntensity || $[6] !== themeName) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const theme = getTheme(themeName); - let segs; - if ($[10] !== message) { - segs = []; - for (const { - segment - } of getGraphemeSegmenter().segment(message)) { - segs.push({ - segment, - width: stringWidth(segment) - }); - } - $[10] = message; - $[11] = segs; - } else { - segs = $[11]; - } - let t3; - if ($[12] !== message) { - t3 = stringWidth(message); - $[12] = message; - $[13] = t3; - } else { - t3 = $[13]; - } - let t4; - if ($[14] !== segs || $[15] !== t3) { - t4 = { - segments: segs, - messageWidth: t3 - }; - $[14] = segs; - $[15] = t3; - $[16] = t4; - } else { - t4 = $[16]; - } - ({ - segments, - messageWidth - } = t4); - if (!message) { - t2 = null; - break bb0; - } - if (stalledIntensity > 0) { - const baseColorStr = theme[messageColor]; - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null; - if (baseRGB) { - const interpolated = interpolateColor(baseRGB, ERROR_RED, stalledIntensity); - const color = toRGBColor(interpolated); - let t5; - if ($[17] !== color) { - t5 = ; - $[17] = color; - $[18] = t5; - } else { - t5 = $[18]; - } - t2 = <>{message}{t5}; - break bb0; - } - const color_0 = stalledIntensity > 0.5 ? "error" : messageColor; - let t5; - if ($[19] !== color_0 || $[20] !== message) { - t5 = {message}; - $[19] = color_0; - $[20] = message; - $[21] = t5; - } else { - t5 = $[21]; - } - let t6; - if ($[22] !== color_0) { - t6 = ; - $[22] = color_0; - $[23] = t6; - } else { - t6 = $[23]; - } - let t7; - if ($[24] !== t5 || $[25] !== t6) { - t7 = <>{t5}{t6}; - $[24] = t5; - $[25] = t6; - $[26] = t7; - } else { - t7 = $[26]; - } - t2 = t7; - break bb0; - } - if (mode === "tool-use") { - const baseColorStr_0 = theme[messageColor]; - const shimmerColorStr = theme[shimmerColor]; - const baseRGB_0 = baseColorStr_0 ? parseRGB(baseColorStr_0) : null; - const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null; - if (baseRGB_0 && shimmerRGB) { - const interpolated_0 = interpolateColor(baseRGB_0, shimmerRGB, flashOpacity); - const t5 = {message}; - let t6; - if ($[27] !== messageColor) { - t6 = ; - $[27] = messageColor; - $[28] = t6; - } else { - t6 = $[28]; - } - let t7; - if ($[29] !== t5 || $[30] !== t6) { - t7 = <>{t5}{t6}; - $[29] = t5; - $[30] = t6; - $[31] = t7; - } else { - t7 = $[31]; - } - t2 = t7; - break bb0; - } - const color_1 = flashOpacity > 0.5 ? shimmerColor : messageColor; - let t5; - if ($[32] !== color_1 || $[33] !== message) { - t5 = {message}; - $[32] = color_1; - $[33] = message; - $[34] = t5; - } else { - t5 = $[34]; - } - let t6; - if ($[35] !== messageColor) { - t6 = ; - $[35] = messageColor; - $[36] = t6; - } else { - t6 = $[36]; - } - let t7; - if ($[37] !== t5 || $[38] !== t6) { - t7 = <>{t5}{t6}; - $[37] = t5; - $[38] = t6; - $[39] = t7; - } else { - t7 = $[39]; - } - t2 = t7; - break bb0; - } - } - $[0] = flashOpacity; - $[1] = message; - $[2] = messageColor; - $[3] = mode; - $[4] = shimmerColor; - $[5] = stalledIntensity; - $[6] = themeName; - $[7] = messageWidth; - $[8] = segments; - $[9] = t2; - } else { - messageWidth = $[7]; - segments = $[8]; - t2 = $[9]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - const shimmerStart = glimmerIndex - 1; - const shimmerEnd = glimmerIndex + 1; - if (shimmerStart >= messageWidth || shimmerEnd < 0) { - let t3; - if ($[40] !== message || $[41] !== messageColor) { - t3 = {message}; - $[40] = message; - $[41] = messageColor; - $[42] = t3; - } else { - t3 = $[42]; + message: string + mode: SpinnerMode + messageColor: keyof Theme + glimmerIndex: number + flashOpacity: number + shimmerColor: keyof Theme + stalledIntensity?: number +} + +const ERROR_RED = { r: 171, g: 43, b: 63 } + +export function GlimmerMessage({ + message, + mode, + messageColor, + glimmerIndex, + flashOpacity, + shimmerColor, + stalledIntensity = 0, +}: Props): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + + // This component re-renders at 20fps (glimmerIndex changes every 50ms) but + // message is stable within a turn. Precompute grapheme segmentation + widths + // once per message instead of per frame. Measured -81% on the shimmer path. + const { segments, messageWidth } = React.useMemo(() => { + const segs: { segment: string; width: number }[] = [] + for (const { segment } of getGraphemeSegmenter().segment(message)) { + segs.push({ segment, width: stringWidth(segment) }) } - let t4; - if ($[43] !== messageColor) { - t4 = ; - $[43] = messageColor; - $[44] = t4; - } else { - t4 = $[44]; + return { segments: segs, messageWidth: stringWidth(message) } + }, [message]) + + if (!message) return null + + // When stalled, show text that smoothly transitions to red + if (stalledIntensity > 0) { + const baseColorStr = theme[messageColor] + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null + + if (baseRGB) { + const interpolated = interpolateColor( + baseRGB, + ERROR_RED, + stalledIntensity, + ) + const color = toRGBColor(interpolated) + return ( + <> + {message} + + + ) } - let t5; - if ($[45] !== t3 || $[46] !== t4) { - t5 = <>{t3}{t4}; - $[45] = t3; - $[46] = t4; - $[47] = t5; - } else { - t5 = $[47]; - } - return t5; + + // Fallback for ANSI themes: use messageColor until fully stalled, then error + const color = stalledIntensity > 0.5 ? 'error' : messageColor + return ( + <> + {message} + + + ) } - const clampedStart = Math.max(0, shimmerStart); - let colPos = 0; - let before = ""; - let shim = ""; - let after = ""; - if ($[48] !== after || $[49] !== before || $[50] !== clampedStart || $[51] !== colPos || $[52] !== segments || $[53] !== shim || $[54] !== shimmerEnd) { - for (const { - segment: segment_0, - width - } of segments) { - if (colPos + width <= clampedStart) { - before = before + segment_0; - } else { - if (colPos > shimmerEnd) { - after = after + segment_0; - } else { - shim = shim + segment_0; - } - } - colPos = colPos + width; + + // tool-use mode: all chars flash with the same opacity, so render as a + // single instead of N individual FlashingChar components. + if (mode === 'tool-use') { + const baseColorStr = theme[messageColor] + const shimmerColorStr = theme[shimmerColor] + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null + const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null + + if (baseRGB && shimmerRGB) { + const interpolated = interpolateColor(baseRGB, shimmerRGB, flashOpacity) + return ( + <> + {message} + + + ) } - $[48] = after; - $[49] = before; - $[50] = clampedStart; - $[51] = colPos; - $[52] = segments; - $[53] = shim; - $[54] = shimmerEnd; - $[55] = before; - $[56] = after; - $[57] = shim; - $[58] = colPos; - } else { - before = $[55] as string; - after = $[56] as string; - shim = $[57] as string; - colPos = $[58] as number; - } - let t3; - if ($[59] !== before || $[60] !== messageColor) { - t3 = before && {before}; - $[59] = before; - $[60] = messageColor; - $[61] = t3; - } else { - t3 = $[61]; - } - let t4; - if ($[62] !== shim || $[63] !== shimmerColor) { - t4 = {shim}; - $[62] = shim; - $[63] = shimmerColor; - $[64] = t4; - } else { - t4 = $[64]; - } - let t5; - if ($[65] !== after || $[66] !== messageColor) { - t5 = after && {after}; - $[65] = after; - $[66] = messageColor; - $[67] = t5; - } else { - t5 = $[67]; + + const color = flashOpacity > 0.5 ? shimmerColor : messageColor + return ( + <> + {message} + + + ) } - let t6; - if ($[68] !== messageColor) { - t6 = ; - $[68] = messageColor; - $[69] = t6; - } else { - t6 = $[69]; + + // Shimmer mode: only chars within ±1 of glimmerIndex need the shimmer + // color. When glimmer is offscreen, render as a single . + const shimmerStart = glimmerIndex - 1 + const shimmerEnd = glimmerIndex + 1 + + if (shimmerStart >= messageWidth || shimmerEnd < 0) { + return ( + <> + {message} + + + ) } - let t7; - if ($[70] !== t3 || $[71] !== t4 || $[72] !== t5 || $[73] !== t6) { - t7 = <>{t3}{t4}{t5}{t6}; - $[70] = t3; - $[71] = t4; - $[72] = t5; - $[73] = t6; - $[74] = t7; - } else { - t7 = $[74]; + + // Split into at most 3 segments by visual column position + const clampedStart = Math.max(0, shimmerStart) + let colPos = 0 + let before = '' + let shim = '' + let after = '' + for (const { segment, width } of segments) { + if (colPos + width <= clampedStart) { + before += segment + } else if (colPos > shimmerEnd) { + after += segment + } else { + shim += segment + } + colPos += width } - return t7; + + return ( + <> + {before && {before}} + {shim} + {after && {after}} + + + ) } diff --git a/src/components/Spinner/ShimmerChar.tsx b/src/components/Spinner/ShimmerChar.tsx index 356dd0499..038ffb33d 100644 --- a/src/components/Spinner/ShimmerChar.tsx +++ b/src/components/Spinner/ShimmerChar.tsx @@ -1,35 +1,27 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; +import * as React from 'react' +import { Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' + type Props = { - char: string; - index: number; - glimmerIndex: number; - messageColor: keyof Theme; - shimmerColor: keyof Theme; -}; -export function ShimmerChar(t0) { - const $ = _c(3); - const { - char, - index, - glimmerIndex, - messageColor, - shimmerColor - } = t0; - const isHighlighted = index === glimmerIndex; - const isNearHighlight = Math.abs(index - glimmerIndex) === 1; - const shouldUseShimmer = isHighlighted || isNearHighlight; - const t1 = shouldUseShimmer ? shimmerColor : messageColor; - let t2; - if ($[0] !== char || $[1] !== t1) { - t2 = {char}; - $[0] = char; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + char: string + index: number + glimmerIndex: number + messageColor: keyof Theme + shimmerColor: keyof Theme +} + +export function ShimmerChar({ + char, + index, + glimmerIndex, + messageColor, + shimmerColor, +}: Props): React.ReactNode { + const isHighlighted = index === glimmerIndex + const isNearHighlight = Math.abs(index - glimmerIndex) === 1 + const shouldUseShimmer = isHighlighted || isNearHighlight + + return ( + {char} + ) } diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index 2057f389d..93b2fc64a 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -1,72 +1,66 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useMemo, useRef } from 'react'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text, useAnimationFrame } from '../../ink.js'; -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { formatDuration, formatNumber } from '../../utils/format.js'; -import { toInkColor } from '../../utils/ink.js'; -import type { Theme } from '../../utils/theme.js'; -import { Byline } from '../design-system/Byline.js'; -import { GlimmerMessage } from './GlimmerMessage.js'; -import { SpinnerGlyph } from './SpinnerGlyph.js'; -import type { SpinnerMode } from './types.js'; -import { useStalledAnimation } from './useStalledAnimation.js'; -import { interpolateColor, toRGBColor } from './utils.js'; -const SEP_WIDTH = stringWidth(' · '); -const THINKING_BARE_WIDTH = stringWidth('thinking'); -const SHOW_TOKENS_AFTER_MS = 30_000; +import figures from 'figures' +import * as React from 'react' +import { useMemo, useRef } from 'react' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text, useAnimationFrame } from '../../ink.js' +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' +import { formatDuration, formatNumber } from '../../utils/format.js' +import { toInkColor } from '../../utils/ink.js' +import type { Theme } from '../../utils/theme.js' +import { Byline } from '../design-system/Byline.js' +import { GlimmerMessage } from './GlimmerMessage.js' +import { SpinnerGlyph } from './SpinnerGlyph.js' +import type { SpinnerMode } from './types.js' +import { useStalledAnimation } from './useStalledAnimation.js' +import { interpolateColor, toRGBColor } from './utils.js' + +const SEP_WIDTH = stringWidth(' · ') +const THINKING_BARE_WIDTH = stringWidth('thinking') +const SHOW_TOKENS_AFTER_MS = 30_000 // Thinking shimmer constants. Previously lived in a separate ThinkingShimmerText // component with its own useAnimationFrame(50) — inlined here to reuse our // existing 50ms clock and eliminate the redundant subscriber. -const THINKING_INACTIVE = { - r: 153, - g: 153, - b: 153 -}; -const THINKING_INACTIVE_SHIMMER = { - r: 185, - g: 185, - b: 185 -}; -const THINKING_DELAY_MS = 3000; -const THINKING_GLOW_PERIOD_S = 2; +const THINKING_INACTIVE = { r: 153, g: 153, b: 153 } +const THINKING_INACTIVE_SHIMMER = { r: 185, g: 185, b: 185 } +const THINKING_DELAY_MS = 3000 +const THINKING_GLOW_PERIOD_S = 2 + export type SpinnerAnimationRowProps = { // Animation inputs - mode: SpinnerMode; - reducedMotion: boolean; - hasActiveTools: boolean; - responseLengthRef: React.RefObject; + mode: SpinnerMode + reducedMotion: boolean + hasActiveTools: boolean + responseLengthRef: React.RefObject // Message (stable within a turn) - message: string; - messageColor: keyof Theme; - shimmerColor: keyof Theme; - overrideColor?: keyof Theme | null; + message: string + messageColor: keyof Theme + shimmerColor: keyof Theme + overrideColor?: keyof Theme | null // Timer refs (stable references) - loadingStartTimeRef: React.RefObject; - totalPausedMsRef: React.RefObject; - pauseStartTimeRef: React.RefObject; + loadingStartTimeRef: React.RefObject + totalPausedMsRef: React.RefObject + pauseStartTimeRef: React.RefObject // Display flags - spinnerSuffix?: string | null; - verbose: boolean; - columns: number; + spinnerSuffix?: string | null + verbose: boolean + columns: number // Teammate-derived (computed by parent from tasks) - hasRunningTeammates: boolean; - teammateTokens: number; - foregroundedTeammate: InProcessTeammateTaskState | undefined; + hasRunningTeammates: boolean + teammateTokens: number + foregroundedTeammate: InProcessTeammateTaskState | undefined /** Leader's turn has completed. Suppresses stall-red since responseLengthRef/hasActiveTools track leader state only. */ - leaderIsIdle?: boolean; + leaderIsIdle?: boolean // Thinking (state owned by parent, mode-dependent) - thinkingStatus: 'thinking' | number | null; - effortSuffix: string; -}; + thinkingStatus: 'thinking' | number | null + effortSuffix: string + +} /** * The 50ms-animated portion of SpinnerWithVerb. Owns useAnimationFrame(50) @@ -98,167 +92,282 @@ export function SpinnerAnimationRow({ foregroundedTeammate, leaderIsIdle = false, thinkingStatus, - effortSuffix + effortSuffix, }: SpinnerAnimationRowProps): React.ReactNode { - const [viewportRef, time] = useAnimationFrame(reducedMotion ? null : 50); + const [viewportRef, time] = useAnimationFrame(reducedMotion ? null : 50) // === Elapsed time (wall-clock, derived from refs each frame) === - const now = Date.now(); - const elapsedTimeMs = pauseStartTimeRef.current !== null ? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current : now - loadingStartTimeRef.current - totalPausedMsRef.current; + const now = Date.now() + const elapsedTimeMs = + pauseStartTimeRef.current !== null + ? pauseStartTimeRef.current - + loadingStartTimeRef.current - + totalPausedMsRef.current + : now - loadingStartTimeRef.current - totalPausedMsRef.current // Track wall-clock turn start for teammates. While a swarm is running the // leader's elapsedTimeMs may jump around (new API calls reset // loadingStartTimeRef; pauses freeze it), so we anchor to the earliest // derived start seen so far. When no teammates are running this just tracks // derivedStart every frame, effectively resetting for the next swarm. - const derivedStart = now - elapsedTimeMs; - const turnStartRef = useRef(derivedStart); + const derivedStart = now - elapsedTimeMs + const turnStartRef = useRef(derivedStart) if (!hasRunningTeammates || derivedStart < turnStartRef.current) { - turnStartRef.current = derivedStart; + turnStartRef.current = derivedStart } // === Animation derivations from `time` === - const currentResponseLength = responseLengthRef.current; + const currentResponseLength = responseLengthRef.current // Suppress stall detection when leader is idle — responseLengthRef and // hasActiveTools both track leader state. When viewing an active teammate // while leader is idle, they'd otherwise flag a false stall after 3s. // Treating leaderIsIdle like hasActiveTools resets the stall timer. - const { - isStalled, - stalledIntensity - } = useStalledAnimation(time, currentResponseLength, hasActiveTools || leaderIsIdle, reducedMotion); - const frame = reducedMotion ? 0 : Math.floor(time / 120); - const glimmerSpeed = mode === 'requesting' ? 50 : 200; + const { isStalled, stalledIntensity } = useStalledAnimation( + time, + currentResponseLength, + hasActiveTools || leaderIsIdle, + reducedMotion, + ) + + const frame = reducedMotion ? 0 : Math.floor(time / 120) + + const glimmerSpeed = mode === 'requesting' ? 50 : 200 // message is stable within a turn; stringWidth is expensive enough (Bun native // call per code point) to memoize explicitly across the 50ms loop. - const glimmerMessageWidth = useMemo(() => stringWidth(message), [message]); - const cycleLength = glimmerMessageWidth + 20; - const cyclePosition = Math.floor(time / glimmerSpeed); - const glimmerIndex = reducedMotion ? -100 : isStalled ? -100 : mode === 'requesting' ? cyclePosition % cycleLength - 10 : glimmerMessageWidth + 10 - cyclePosition % cycleLength; - const flashOpacity = reducedMotion ? 0 : mode === 'tool-use' ? (Math.sin(time / 1000 * Math.PI) + 1) / 2 : 0; + const glimmerMessageWidth = useMemo(() => stringWidth(message), [message]) + const cycleLength = glimmerMessageWidth + 20 + const cyclePosition = Math.floor(time / glimmerSpeed) + const glimmerIndex = reducedMotion + ? -100 + : isStalled + ? -100 + : mode === 'requesting' + ? (cyclePosition % cycleLength) - 10 + : glimmerMessageWidth + 10 - (cyclePosition % cycleLength) + + const flashOpacity = reducedMotion + ? 0 + : mode === 'tool-use' + ? (Math.sin((time / 1000) * Math.PI) + 1) / 2 + : 0 // === Token counter animation (smooth increment, driven by 50ms clock) === - const tokenCounterRef = useRef(currentResponseLength); + const tokenCounterRef = useRef(currentResponseLength) if (reducedMotion) { - tokenCounterRef.current = currentResponseLength; + tokenCounterRef.current = currentResponseLength } else { - const gap = currentResponseLength - tokenCounterRef.current; + const gap = currentResponseLength - tokenCounterRef.current if (gap > 0) { - let increment; + let increment if (gap < 70) { - increment = 3; + increment = 3 } else if (gap < 200) { - increment = Math.max(8, Math.ceil(gap * 0.15)); + increment = Math.max(8, Math.ceil(gap * 0.15)) } else { - increment = 50; + increment = 50 } - tokenCounterRef.current = Math.min(tokenCounterRef.current + increment, currentResponseLength); + tokenCounterRef.current = Math.min( + tokenCounterRef.current + increment, + currentResponseLength, + ) } } - const displayedResponseLength = tokenCounterRef.current; - const leaderTokens = Math.round(displayedResponseLength / 4); - const effectiveElapsedMs = hasRunningTeammates ? Math.max(elapsedTimeMs, now - turnStartRef.current) : elapsedTimeMs; - const timerText = formatDuration(effectiveElapsedMs); - const timerWidth = stringWidth(timerText); + const displayedResponseLength = tokenCounterRef.current + const leaderTokens = Math.round(displayedResponseLength / 4) + + const effectiveElapsedMs = hasRunningTeammates + ? Math.max(elapsedTimeMs, now - turnStartRef.current) + : elapsedTimeMs + const timerText = formatDuration(effectiveElapsedMs) + const timerWidth = stringWidth(timerText) // === Token count (leader + teammates, or foregrounded teammate) === - const totalTokens = foregroundedTeammate && !foregroundedTeammate.isIdle ? foregroundedTeammate.progress?.tokenCount ?? 0 : leaderTokens + teammateTokens; - const tokenCount = formatNumber(totalTokens); - const tokensText = hasRunningTeammates ? `${tokenCount} tokens` : `${figures.arrowDown} ${tokenCount} tokens`; - const tokensWidth = stringWidth(tokensText); + const totalTokens = + foregroundedTeammate && !foregroundedTeammate.isIdle + ? (foregroundedTeammate.progress?.tokenCount ?? 0) + : leaderTokens + teammateTokens + const tokenCount = formatNumber(totalTokens) + const tokensText = hasRunningTeammates + ? `${tokenCount} tokens` + : `${figures.arrowDown} ${tokenCount} tokens` + const tokensWidth = stringWidth(tokensText) // === Thinking text (may shrink to fit) === - let thinkingText = thinkingStatus === 'thinking' ? `thinking${effortSuffix}` : typeof thinkingStatus === 'number' ? `thought for ${Math.max(1, Math.round(thinkingStatus / 1000))}s` : null; - let thinkingWidthValue = thinkingText ? stringWidth(thinkingText) : 0; + let thinkingText = + thinkingStatus === 'thinking' + ? `thinking${effortSuffix}` + : typeof thinkingStatus === 'number' + ? `thought for ${Math.max(1, Math.round(thinkingStatus / 1000))}s` + : null + let thinkingWidthValue = thinkingText ? stringWidth(thinkingText) : 0 // === Progressive width gating === - const messageWidth = glimmerMessageWidth + 2; - const sep = SEP_WIDTH; - const wantsThinking = thinkingStatus !== null; - const wantsTimerAndTokens = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS; - const availableSpace = columns - messageWidth - 5; - let showThinking = wantsThinking && availableSpace > thinkingWidthValue; - if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && effortSuffix) { + const messageWidth = glimmerMessageWidth + 2 + const sep = SEP_WIDTH + + const wantsThinking = thinkingStatus !== null + const wantsTimerAndTokens = + verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS + + const availableSpace = columns - messageWidth - 5 + + let showThinking = wantsThinking && availableSpace > thinkingWidthValue + if ( + !showThinking && + wantsThinking && + thinkingStatus === 'thinking' && + effortSuffix + ) { if (availableSpace > THINKING_BARE_WIDTH) { - thinkingText = 'thinking'; - thinkingWidthValue = THINKING_BARE_WIDTH; - showThinking = true; + thinkingText = 'thinking' + thinkingWidthValue = THINKING_BARE_WIDTH + showThinking = true } } - const usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0; - const showTimer = wantsTimerAndTokens && availableSpace > usedAfterThinking + timerWidth; - const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0); - const showTokens = wantsTimerAndTokens && totalTokens > 0 && availableSpace > usedAfterTimer + tokensWidth; - const thinkingOnly = showThinking && thinkingStatus === 'thinking' && !spinnerSuffix && !showTimer && !showTokens && true; + const usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0 + + const showTimer = + wantsTimerAndTokens && availableSpace > usedAfterThinking + timerWidth + const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0) + + const showTokens = + wantsTimerAndTokens && + totalTokens > 0 && + availableSpace > usedAfterTimer + tokensWidth + + + const thinkingOnly = + showThinking && + thinkingStatus === 'thinking' && + !spinnerSuffix && + !showTimer && + !showTokens && + true // === Thinking shimmer color (formerly ThinkingShimmerText's own timer) === // Same sine-wave opacity, but derived from our shared `time` instead of a // second useAnimationFrame(50) subscription. - const thinkingElapsedSec = (time - THINKING_DELAY_MS) / 1000; - const thinkingOpacity = time < THINKING_DELAY_MS ? 0 : (Math.sin(thinkingElapsedSec * Math.PI * 2 / THINKING_GLOW_PERIOD_S) + 1) / 2; - const thinkingShimmerColor = toRGBColor(interpolateColor(THINKING_INACTIVE, THINKING_INACTIVE_SHIMMER, thinkingOpacity)); + const thinkingElapsedSec = (time - THINKING_DELAY_MS) / 1000 + const thinkingOpacity = + time < THINKING_DELAY_MS + ? 0 + : (Math.sin((thinkingElapsedSec * Math.PI * 2) / THINKING_GLOW_PERIOD_S) + + 1) / + 2 + const thinkingShimmerColor = toRGBColor( + interpolateColor( + THINKING_INACTIVE, + THINKING_INACTIVE_SHIMMER, + thinkingOpacity, + ), + ) // === Build status parts === - const parts = [...(spinnerSuffix ? [ + const parts = [ + ...(spinnerSuffix + ? [ + {spinnerSuffix} - ] : []), ...(showTimer ? [ + , + ] + : []), + ...(showTimer + ? [ + {timerText} - ] : []), ...(showTokens ? [ + , + ] + : []), + ...(showTokens + ? [ + {!hasRunningTeammates && } {tokenCount} tokens - ] : []), ...(showThinking && thinkingText ? [thinkingStatus === 'thinking' && !reducedMotion ? + , + ] + : []), + ...(showThinking && thinkingText + ? [ + thinkingStatus === 'thinking' && !reducedMotion ? ( + {thinkingOnly ? `(${thinkingText})` : thinkingText} - : + + ) : ( + {thinkingText} - ] : [])]; - const status = foregroundedTeammate && !foregroundedTeammate.isIdle ? <> + + ), + ] + : []), + ] + + const status = + foregroundedTeammate && !foregroundedTeammate.isIdle ? ( + <> (esc to interrupt {foregroundedTeammate.identity.agentName} ) - : !foregroundedTeammate && parts.length > 0 ? thinkingOnly ? {parts} : <> + + ) : !foregroundedTeammate && parts.length > 0 ? ( + thinkingOnly ? ( + {parts} + ) : ( + <> ( {parts} ) - : null; - return - - + + ) + ) : null + + return ( + + + {status} - ; + + ) } -function SpinnerModeGlyph(t0) { - const $ = _c(2); - const { - mode - } = t0; + +function SpinnerModeGlyph({ mode }: { mode: SpinnerMode }): React.ReactNode { switch (mode) { - case "tool-input": - case "tool-use": - case "responding": - case "thinking": - { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {figures.arrowDown}; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - case "requesting": - { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {figures.arrowUp}; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } + case 'tool-input': + case 'tool-use': + case 'responding': + case 'thinking': + return ( + + {figures.arrowDown} + + ) + case 'requesting': + return ( + + {figures.arrowUp} + + ) } } diff --git a/src/components/Spinner/SpinnerGlyph.tsx b/src/components/Spinner/SpinnerGlyph.tsx index 207108453..242d05971 100644 --- a/src/components/Spinner/SpinnerGlyph.tsx +++ b/src/components/Spinner/SpinnerGlyph.tsx @@ -1,79 +1,86 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text, useTheme } from '../../ink.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { getDefaultCharacters, interpolateColor, parseRGB, toRGBColor } from './utils.js'; -const DEFAULT_CHARACTERS = getDefaultCharacters(); -const SPINNER_FRAMES = [...DEFAULT_CHARACTERS, ...[...DEFAULT_CHARACTERS].reverse()]; -const REDUCED_MOTION_DOT = '●'; -const REDUCED_MOTION_CYCLE_MS = 2000; // 2-second cycle: 1s visible, 1s dim -const ERROR_RED = { - r: 171, - g: 43, - b: 63 -}; +import * as React from 'react' +import { Box, Text, useTheme } from '../../ink.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { + getDefaultCharacters, + interpolateColor, + parseRGB, + toRGBColor, +} from './utils.js' + +const DEFAULT_CHARACTERS = getDefaultCharacters() + +const SPINNER_FRAMES = [ + ...DEFAULT_CHARACTERS, + ...[...DEFAULT_CHARACTERS].reverse(), +] + +const REDUCED_MOTION_DOT = '●' +const REDUCED_MOTION_CYCLE_MS = 2000 // 2-second cycle: 1s visible, 1s dim +const ERROR_RED = { r: 171, g: 43, b: 63 } + type Props = { - frame: number; - messageColor: keyof Theme; - stalledIntensity?: number; - reducedMotion?: boolean; - time?: number; -}; -export function SpinnerGlyph(t0) { - const $ = _c(9); - const { - frame, - messageColor, - stalledIntensity: t1, - reducedMotion: t2, - time: t3 - } = t0; - const stalledIntensity = t1 === undefined ? 0 : t1; - const reducedMotion = t2 === undefined ? false : t2; - const time = t3 === undefined ? 0 : t3; - const [themeName] = useTheme(); - const theme = getTheme(themeName); + frame: number + messageColor: keyof Theme + stalledIntensity?: number + reducedMotion?: boolean + time?: number +} + +export function SpinnerGlyph({ + frame, + messageColor, + stalledIntensity = 0, + reducedMotion = false, + time = 0, +}: Props): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + + // Reduced motion: slowly flashing orange dot if (reducedMotion) { - const isDim = Math.floor(time / (REDUCED_MOTION_CYCLE_MS / 2)) % 2 === 1; - let t4; - if ($[0] !== isDim || $[1] !== messageColor) { - t4 = {REDUCED_MOTION_DOT}; - $[0] = isDim; - $[1] = messageColor; - $[2] = t4; - } else { - t4 = $[2]; - } - return t4; + const isDim = Math.floor(time / (REDUCED_MOTION_CYCLE_MS / 2)) % 2 === 1 + return ( + + + {REDUCED_MOTION_DOT} + + + ) } - const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]; + + const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] + + // Smoothly interpolate from current color to red when stalled if (stalledIntensity > 0) { - const baseColorStr = theme[messageColor]; - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null; + const baseColorStr = theme[messageColor] + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null + if (baseRGB) { - const interpolated = interpolateColor(baseRGB, ERROR_RED, stalledIntensity); - return {spinnerChar}; + const interpolated = interpolateColor( + baseRGB, + ERROR_RED, + stalledIntensity, + ) + return ( + + {spinnerChar} + + ) } - const color = stalledIntensity > 0.5 ? "error" : messageColor; - let t4; - if ($[3] !== color || $[4] !== spinnerChar) { - t4 = {spinnerChar}; - $[3] = color; - $[4] = spinnerChar; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; - } - let t4; - if ($[6] !== messageColor || $[7] !== spinnerChar) { - t4 = {spinnerChar}; - $[6] = messageColor; - $[7] = spinnerChar; - $[8] = t4; - } else { - t4 = $[8]; + + // Fallback for ANSI themes + const color = stalledIntensity > 0.5 ? 'error' : messageColor + return ( + + {spinnerChar} + + ) } - return t4; + + return ( + + {spinnerChar} + + ) } diff --git a/src/components/Spinner/TeammateSpinnerLine.tsx b/src/components/Spinner/TeammateSpinnerLine.tsx index 7f2e31ffc..ee6807f76 100644 --- a/src/components/Spinner/TeammateSpinnerLine.tsx +++ b/src/components/Spinner/TeammateSpinnerLine.tsx @@ -1,205 +1,265 @@ -import figures from 'figures'; -import sample from 'lodash-es/sample.js'; -import * as React from 'react'; -import { useRef, useState } from 'react'; -import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js'; -import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { summarizeRecentActivities } from '../../utils/collapseReadSearch.js'; -import { formatDuration, formatNumber, truncateToWidth } from '../../utils/format.js'; -import { toInkColor } from '../../utils/ink.js'; -import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js'; +import figures from 'figures' +import sample from 'lodash-es/sample.js' +import * as React from 'react' +import { useRef, useState } from 'react' +import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js' +import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' +import { summarizeRecentActivities } from '../../utils/collapseReadSearch.js' +import { + formatDuration, + formatNumber, + truncateToWidth, +} from '../../utils/format.js' +import { toInkColor } from '../../utils/ink.js' +import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js' + type Props = { - teammate: InProcessTeammateTaskState; - isLast: boolean; - isSelected?: boolean; - isForegrounded?: boolean; - allIdle?: boolean; - showPreview?: boolean; -}; + teammate: InProcessTeammateTaskState + isLast: boolean + isSelected?: boolean + isForegrounded?: boolean + allIdle?: boolean + showPreview?: boolean +} /** * Extract the last 3 lines of content from a teammate's conversation. * Shows recent activity from any message type (user or assistant). */ -function getMessagePreview(messages: InProcessTeammateTaskState['messages']): string[] { - if (!messages?.length) return []; - const allLines: string[] = []; - const maxLineLength = 80; +function getMessagePreview( + messages: InProcessTeammateTaskState['messages'], +): string[] { + if (!messages?.length) return [] + + const allLines: string[] = [] + const maxLineLength = 80 // Collect lines from recent messages (newest first) for (let i = messages.length - 1; i >= 0 && allLines.length < 3; i--) { - const msg = messages[i]; + const msg = messages[i] // Only process messages that have content (user/assistant messages) - if (!msg || msg.type !== 'user' && msg.type !== 'assistant' || !msg.message?.content?.length) { - continue; + if ( + !msg || + (msg.type !== 'user' && msg.type !== 'assistant') || + !msg.message?.content?.length + ) { + continue } - const content = msg.message.content; + const content = msg.message.content + for (const block of content) { - if (allLines.length >= 3) break; - if (!block || typeof block !== 'object') continue; + if (allLines.length >= 3) break + if (!block || typeof block !== 'object') continue + if ('type' in block && block.type === 'tool_use' && 'name' in block) { // Try to show meaningful info from tool input - const input = 'input' in block ? block.input as Record : null; - let toolLine = `Using ${block.name}…`; + const input = + 'input' in block ? (block.input as Record) : null + let toolLine = `Using ${block.name}…` if (input) { // Look for common descriptive fields - const desc = input.description as string | undefined || input.prompt as string | undefined || input.command as string | undefined || input.query as string | undefined || input.pattern as string | undefined; + const desc = + (input.description as string | undefined) || + (input.prompt as string | undefined) || + (input.command as string | undefined) || + (input.query as string | undefined) || + (input.pattern as string | undefined) if (desc) { - toolLine = desc.split('\n')[0] ?? toolLine; + toolLine = desc.split('\n')[0] ?? toolLine } } - allLines.push(truncateToWidth(toolLine, maxLineLength)); + allLines.push(truncateToWidth(toolLine, maxLineLength)) } else if ('type' in block && block.type === 'text' && 'text' in block) { - const textLines = (block.text as string).split('\n').filter(l => l.trim()); + const textLines = (block.text as string) + .split('\n') + .filter(l => l.trim()) // Take from end of text (most recent lines) for (let j = textLines.length - 1; j >= 0 && allLines.length < 3; j--) { - const line = textLines[j]; - if (!line) continue; - allLines.push(truncateToWidth(line, maxLineLength)); + const line = textLines[j] + if (!line) continue + allLines.push(truncateToWidth(line, maxLineLength)) } } } } // Reverse so oldest of the 3 is first (reading order) - return allLines.reverse(); + return allLines.reverse() } + export function TeammateSpinnerLine({ teammate, isLast, isSelected, isForegrounded, allIdle, - showPreview + showPreview, }: Props): React.ReactNode { - const [randomVerb] = useState(() => teammate.spinnerVerb ?? sample(getSpinnerVerbs())); - const [pastTenseVerb] = useState(() => teammate.pastTenseVerb ?? sample(TURN_COMPLETION_VERBS)); - const isHighlighted = isSelected || isForegrounded; - const treeChar = isHighlighted ? isLast ? '╘═' : '╞═' : isLast ? '└─' : '├─'; - const nameColor = toInkColor(teammate.identity.color); - const { - columns - } = useTerminalSize(); + const [randomVerb] = useState( + () => teammate.spinnerVerb ?? sample(getSpinnerVerbs()), + ) + const [pastTenseVerb] = useState( + () => teammate.pastTenseVerb ?? sample(TURN_COMPLETION_VERBS), + ) + const isHighlighted = isSelected || isForegrounded + const treeChar = isHighlighted ? (isLast ? '╘═' : '╞═') : isLast ? '└─' : '├─' + const nameColor = toInkColor(teammate.identity.color) + const { columns } = useTerminalSize() // Track when teammate became idle (for "Idle for X..." display) - const idleStartRef = useRef(null); + const idleStartRef = useRef(null) // Freeze elapsed time when entering all-idle state - const frozenDurationRef = useRef(null); + const frozenDurationRef = useRef(null) // Track idle start time if (teammate.isIdle && idleStartRef.current === null) { - idleStartRef.current = Date.now(); + idleStartRef.current = Date.now() } else if (!teammate.isIdle) { - idleStartRef.current = null; + idleStartRef.current = null } // Reset frozen duration when leaving all-idle state if (!allIdle && frozenDurationRef.current !== null) { - frozenDurationRef.current = null; + frozenDurationRef.current = null } // Get elapsed idle time (how long they've been idle) - for "Idle for X..." display - const idleElapsedTime = useElapsedTime(idleStartRef.current ?? Date.now(), teammate.isIdle && !allIdle); + const idleElapsedTime = useElapsedTime( + idleStartRef.current ?? Date.now(), + teammate.isIdle && !allIdle, + ) // Freeze the duration when we first detect all idle // Use the teammate's actual work time (since task started) for the past-tense display if (allIdle && frozenDurationRef.current === null) { - frozenDurationRef.current = formatDuration(Math.max(0, Date.now() - teammate.startTime - (teammate.totalPausedMs ?? 0))); + frozenDurationRef.current = formatDuration( + Math.max( + 0, + Date.now() - teammate.startTime - (teammate.totalPausedMs ?? 0), + ), + ) } // Use frozen work duration when all idle, otherwise use idle elapsed time - const displayTime = allIdle ? frozenDurationRef.current ?? (() => { - throw new Error(`frozenDurationRef is null for idle teammate ${teammate.identity.agentName}`); - })() : idleElapsedTime; + const displayTime = allIdle + ? (frozenDurationRef.current ?? + (() => { + throw new Error( + `frozenDurationRef is null for idle teammate ${teammate.identity.agentName}`, + ) + })()) + : idleElapsedTime // Layout: paddingLeft(3) + pointer(1) + space(1) + treeChar(2) + space(1) = 8 fixed chars // Then optionally: @name + ": " OR just ": " // Then: activity text + optional extras (stats, hints) - const basePrefix = 8; - const fullAgentName = `@${teammate.identity.agentName}`; - const fullNameWidth = stringWidth(fullAgentName); + const basePrefix = 8 + const fullAgentName = `@${teammate.identity.agentName}` + const fullNameWidth = stringWidth(fullAgentName) // Get stats from progress - const toolUseCount = teammate.progress?.toolUseCount ?? 0; - const tokenCount = teammate.progress?.tokenCount ?? 0; - const statsText = ` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · ${formatNumber(tokenCount)} tokens`; - const statsWidth = stringWidth(statsText); - const selectHintText = ` · ${TEAMMATE_SELECT_HINT}`; - const selectHintWidth = stringWidth(selectHintText); - const viewHintText = ' · enter to view'; - const viewHintWidth = stringWidth(viewHintText); + const toolUseCount = teammate.progress?.toolUseCount ?? 0 + const tokenCount = teammate.progress?.tokenCount ?? 0 + const statsText = ` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · ${formatNumber(tokenCount)} tokens` + const statsWidth = stringWidth(statsText) + const selectHintText = ` · ${TEAMMATE_SELECT_HINT}` + const selectHintWidth = stringWidth(selectHintText) + const viewHintText = ' · enter to view' + const viewHintWidth = stringWidth(viewHintText) // Progressive responsive layout: // Wide (80+): full name + activity + stats + hint // Medium (60-80): full name + activity // Narrow (<60): hide name, just show activity - const minActivityWidth = 25; + const minActivityWidth = 25 // Hide name on narrow terminals (< 60 cols) or if there's not enough room - const spaceWithFullName = columns - basePrefix - fullNameWidth - 2; - const showName = columns >= 60 && spaceWithFullName >= minActivityWidth; - const nameWidth = showName ? fullNameWidth + 2 : 0; // +2 for ": " when name shown - const availableForActivity = columns - basePrefix - nameWidth; + const spaceWithFullName = columns - basePrefix - fullNameWidth - 2 + const showName = columns >= 60 && spaceWithFullName >= minActivityWidth + const nameWidth = showName ? fullNameWidth + 2 : 0 // +2 for ": " when name shown + const availableForActivity = columns - basePrefix - nameWidth // Progressive hiding: view hint → select hint → stats // Stats always visible (dimmed when not selected); hints only when highlighted/selected - const showViewHint = isSelected && !isForegrounded && availableForActivity > viewHintWidth + statsWidth + minActivityWidth + 5; - const showSelectHint = isHighlighted && availableForActivity > selectHintWidth + (showViewHint ? viewHintWidth : 0) + statsWidth + minActivityWidth + 5; - const showStats = availableForActivity > statsWidth + minActivityWidth + 5; + const showViewHint = + isSelected && + !isForegrounded && + availableForActivity > viewHintWidth + statsWidth + minActivityWidth + 5 + const showSelectHint = + isHighlighted && + availableForActivity > + selectHintWidth + + (showViewHint ? viewHintWidth : 0) + + statsWidth + + minActivityWidth + + 5 + const showStats = availableForActivity > statsWidth + minActivityWidth + 5 // Activity text gets remaining space - const extrasCost = (showStats ? statsWidth : 0) + (showSelectHint ? selectHintWidth : 0) + (showViewHint ? viewHintWidth : 0); - const activityMaxWidth = Math.max(minActivityWidth, availableForActivity - extrasCost - 1); + const extrasCost = + (showStats ? statsWidth : 0) + + (showSelectHint ? selectHintWidth : 0) + + (showViewHint ? viewHintWidth : 0) + const activityMaxWidth = Math.max( + minActivityWidth, + availableForActivity - extrasCost - 1, + ) // Format the activity text for active teammates, rolling up search/read ops const activityText = (() => { - const activities = teammate.progress?.recentActivities; + const activities = teammate.progress?.recentActivities if (activities && activities.length > 0) { - const summary = summarizeRecentActivities(activities); - if (summary) return truncateToWidth(summary, activityMaxWidth); + const summary = summarizeRecentActivities(activities) + if (summary) return truncateToWidth(summary, activityMaxWidth) } - const desc = teammate.progress?.lastActivity?.activityDescription; - if (desc) return truncateToWidth(desc, activityMaxWidth); - return randomVerb; - })(); + const desc = teammate.progress?.lastActivity?.activityDescription + if (desc) return truncateToWidth(desc, activityMaxWidth) + return randomVerb + })() // Status rendering logic const renderStatus = (): React.ReactNode => { if (teammate.shutdownRequested) { - return [stopping]; + return [stopping] } if (teammate.awaitingPlanApproval) { - return [awaiting approval]; + return [awaiting approval] } if (teammate.isIdle) { if (allIdle) { - return + return ( + {pastTenseVerb} for {displayTime} - ; + + ) } - return Idle for {idleElapsedTime}; + return Idle for {idleElapsedTime} } // Active - show spinner glyph + activity description (only when not highlighted; // when highlighted, the main spinner above already shows the verb) if (isHighlighted) { - return null; + return null } - return + return ( + {activityText?.endsWith('…') ? activityText : `${activityText}…`} - ; - }; + + ) + } // Get preview lines if enabled - const previewLines = showPreview ? getMessagePreview(teammate.messages) : []; + const previewLines = showPreview ? getMessagePreview(teammate.messages) : [] // Tree continuation character for preview lines - const previewTreeChar = isLast ? ' ' : '│ '; - return + const previewTreeChar = isLast ? ' ' : '│ ' + + return ( + {/* Selection indicator: pointer when selected, otherwise space */} @@ -207,26 +267,33 @@ export function TeammateSpinnerLine({ {treeChar} {/* Agent name: hidden on very narrow screens */} - {showName && + {showName && ( + @{teammate.identity.agentName} - } + + )} {showName && : } {renderStatus()} {/* Stats: only shown when selected and terminal is wide enough */} - {showStats && + {showStats && ( + {' '} · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} ·{' '} {formatNumber(tokenCount)} tokens - } + + )} {/* Hints: select hint when highlighted, view hint when selected but not foregrounded */} {showSelectHint && · {TEAMMATE_SELECT_HINT}} {showViewHint && · enter to view} {/* Preview lines */} - {previewLines.map((line, idx) => + {previewLines.map((line, idx) => ( + {previewTreeChar} {line} - )} - ; + + ))} + + ) } diff --git a/src/components/Spinner/TeammateSpinnerTree.tsx b/src/components/Spinner/TeammateSpinnerTree.tsx index 7528aeca3..331126f71 100644 --- a/src/components/Spinner/TeammateSpinnerTree.tsx +++ b/src/components/Spinner/TeammateSpinnerTree.tsx @@ -1,271 +1,130 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Box, Text, type TextProps } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import { formatNumber } from '../../utils/format.js'; -import { TeammateSpinnerLine } from './TeammateSpinnerLine.js'; -import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js'; +import figures from 'figures' +import * as React from 'react' +import { Box, Text, type TextProps } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import { formatNumber } from '../../utils/format.js' +import { TeammateSpinnerLine } from './TeammateSpinnerLine.js' +import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js' + type Props = { - selectedIndex?: number; - isInSelectionMode?: boolean; - allIdle?: boolean; + selectedIndex?: number + isInSelectionMode?: boolean + allIdle?: boolean /** Leader's active verb (when leader is actively processing) */ - leaderVerb?: string; + leaderVerb?: string /** Leader's token count (when leader is actively processing) */ - leaderTokenCount?: number; + leaderTokenCount?: number /** Leader's idle status text (when leader is idle, e.g. "✻ Idle for 3s") */ - leaderIdleText?: string; -}; -export function TeammateSpinnerTree(t0) { - const $ = _c(61); - const { - selectedIndex, - isInSelectionMode, - allIdle, - leaderVerb, - leaderTokenCount, - leaderIdleText - } = t0; - const tasks = useAppState(_temp); - const viewingAgentTaskId = useAppState(_temp2); - const showTeammateMessagePreview = useAppState(_temp3); - let T0; - let isHideSelected; - let t1; - let t2; - let t3; - let t4; - let t5; - if ($[0] !== allIdle || $[1] !== isInSelectionMode || $[2] !== leaderIdleText || $[3] !== leaderTokenCount || $[4] !== leaderVerb || $[5] !== selectedIndex || $[6] !== showTeammateMessagePreview || $[7] !== tasks || $[8] !== viewingAgentTaskId) { - t5 = Symbol.for("react.early_return_sentinel"); - bb0: { - const teammateTasks = getRunningTeammatesSorted(tasks); - if (teammateTasks.length === 0) { - t5 = null; - break bb0; - } - const isLeaderForegrounded = viewingAgentTaskId === undefined; - const isLeaderSelected = isInSelectionMode && selectedIndex === -1; - const isLeaderHighlighted = isLeaderForegrounded || isLeaderSelected; - isHideSelected = isInSelectionMode === true && selectedIndex === teammateTasks.length; - T0 = Box; - t1 = "column"; - t2 = 1; - const t6 = isLeaderSelected ? "suggestion" : undefined; - const t7 = isLeaderSelected ? figures.pointer : " "; - let t8; - if ($[16] !== isLeaderHighlighted || $[17] !== t6 || $[18] !== t7) { - t8 = {t7}; - $[16] = isLeaderHighlighted; - $[17] = t6; - $[18] = t7; - $[19] = t8; - } else { - t8 = $[19]; - } - const t9 = !isLeaderHighlighted; - const t10 = isLeaderHighlighted ? "\u2552\u2550" : "\u250C\u2500"; - let t11; - if ($[20] !== isLeaderHighlighted || $[21] !== t10 || $[22] !== t9) { - t11 = {t10}{" "}; - $[20] = isLeaderHighlighted; - $[21] = t10; - $[22] = t9; - $[23] = t11; - } else { - t11 = $[23]; - } - const t12 = isLeaderSelected ? "suggestion" : "cyan_FOR_SUBAGENTS_ONLY"; - let t13; - if ($[24] !== isLeaderHighlighted || $[25] !== t12) { - t13 = team-lead; - $[24] = isLeaderHighlighted; - $[25] = t12; - $[26] = t13; - } else { - t13 = $[26]; - } - let t14; - if ($[27] !== isLeaderForegrounded || $[28] !== leaderVerb) { - t14 = !isLeaderForegrounded && leaderVerb && : {leaderVerb}…; - $[27] = isLeaderForegrounded; - $[28] = leaderVerb; - $[29] = t14; - } else { - t14 = $[29]; - } - let t15; - if ($[30] !== isLeaderForegrounded || $[31] !== leaderIdleText || $[32] !== leaderVerb) { - t15 = !isLeaderForegrounded && !leaderVerb && leaderIdleText && : {leaderIdleText}; - $[30] = isLeaderForegrounded; - $[31] = leaderIdleText; - $[32] = leaderVerb; - $[33] = t15; - } else { - t15 = $[33]; - } - let t16; - if ($[34] !== isLeaderHighlighted || $[35] !== leaderTokenCount) { - t16 = leaderTokenCount !== undefined && leaderTokenCount > 0 && {" "}· {formatNumber(leaderTokenCount)} tokens; - $[34] = isLeaderHighlighted; - $[35] = leaderTokenCount; - $[36] = t16; - } else { - t16 = $[36]; - } - let t17; - if ($[37] !== isLeaderHighlighted) { - t17 = isLeaderHighlighted && · {TEAMMATE_SELECT_HINT}; - $[37] = isLeaderHighlighted; - $[38] = t17; - } else { - t17 = $[38]; - } - let t18; - if ($[39] !== isLeaderForegrounded || $[40] !== isLeaderSelected) { - t18 = isLeaderSelected && !isLeaderForegrounded && · enter to view; - $[39] = isLeaderForegrounded; - $[40] = isLeaderSelected; - $[41] = t18; - } else { - t18 = $[41]; - } - if ($[42] !== t11 || $[43] !== t13 || $[44] !== t14 || $[45] !== t15 || $[46] !== t16 || $[47] !== t17 || $[48] !== t18 || $[49] !== t8) { - t3 = {t8}{t11}{t13}{t14}{t15}{t16}{t17}{t18}; - $[42] = t11; - $[43] = t13; - $[44] = t14; - $[45] = t15; - $[46] = t16; - $[47] = t17; - $[48] = t18; - $[49] = t8; - $[50] = t3; - } else { - t3 = $[50]; - } - t4 = teammateTasks.map((teammate, index) => ); - } - $[0] = allIdle; - $[1] = isInSelectionMode; - $[2] = leaderIdleText; - $[3] = leaderTokenCount; - $[4] = leaderVerb; - $[5] = selectedIndex; - $[6] = showTeammateMessagePreview; - $[7] = tasks; - $[8] = viewingAgentTaskId; - $[9] = T0; - $[10] = isHideSelected; - $[11] = t1; - $[12] = t2; - $[13] = t3; - $[14] = t4; - $[15] = t5; - } else { - T0 = $[9]; - isHideSelected = $[10]; - t1 = $[11]; - t2 = $[12]; - t3 = $[13]; - t4 = $[14]; - t5 = $[15]; - } - if (t5 !== Symbol.for("react.early_return_sentinel")) { - return t5; - } - let t6; - if ($[51] !== isHideSelected || $[52] !== isInSelectionMode) { - t6 = isInSelectionMode && ; - $[51] = isHideSelected; - $[52] = isInSelectionMode; - $[53] = t6; - } else { - t6 = $[53]; - } - let t7; - if ($[54] !== T0 || $[55] !== t1 || $[56] !== t2 || $[57] !== t3 || $[58] !== t4 || $[59] !== t6) { - t7 = {t3}{t4}{t6}; - $[54] = T0; - $[55] = t1; - $[56] = t2; - $[57] = t3; - $[58] = t4; - $[59] = t6; - $[60] = t7; - } else { - t7 = $[60]; - } - return t7; -} -function _temp3(s_1) { - return s_1.showTeammateMessagePreview; + leaderIdleText?: string } -function _temp2(s_0) { - return s_0.viewingAgentTaskId; -} -function _temp(s) { - return s.tasks; -} -function HideRow(t0) { - const $ = _c(18); - const { - isSelected - } = t0; - const t1 = isSelected ? "suggestion" : undefined; - const t2 = isSelected ? figures.pointer : " "; - let t3; - if ($[0] !== isSelected || $[1] !== t1 || $[2] !== t2) { - t3 = {t2}; - $[0] = isSelected; - $[1] = t1; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const t4 = !isSelected; - const t5 = isSelected ? "\u2558\u2550" : "\u2514\u2500"; - let t6; - if ($[4] !== isSelected || $[5] !== t4 || $[6] !== t5) { - t6 = {t5}{" "}; - $[4] = isSelected; - $[5] = t4; - $[6] = t5; - $[7] = t6; - } else { - t6 = $[7]; + +export function TeammateSpinnerTree({ + selectedIndex, + isInSelectionMode, + allIdle, + leaderVerb, + leaderTokenCount, + leaderIdleText, +}: Props): React.ReactNode { + const tasks = useAppState(s => s.tasks) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const showTeammateMessagePreview = useAppState( + s => s.showTeammateMessagePreview, + ) + + const teammateTasks = getRunningTeammatesSorted(tasks) + + // Don't render if no running teammates + if (teammateTasks.length === 0) { + return null } - const t7 = !isSelected; - let t8; - if ($[8] !== isSelected || $[9] !== t7) { - t8 = hide; - $[8] = isSelected; - $[9] = t7; - $[10] = t8; - } else { - t8 = $[10]; - } - let t9; - if ($[11] !== isSelected) { - t9 = isSelected && · enter to collapse; - $[11] = isSelected; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] !== t3 || $[14] !== t6 || $[15] !== t8 || $[16] !== t9) { - t10 = {t3}{t6}{t8}{t9}; - $[13] = t3; - $[14] = t6; - $[15] = t8; - $[16] = t9; - $[17] = t10; - } else { - t10 = $[17]; - } - return t10; + + // Leader highlighting follows same pattern as teammates: + // isHighlighted = isForegrounded || isSelected + const isLeaderForegrounded = viewingAgentTaskId === undefined + const isLeaderSelected = isInSelectionMode && selectedIndex === -1 + const isLeaderHighlighted = isLeaderForegrounded || isLeaderSelected + const leaderColor: TextProps['color'] = 'cyan_FOR_SUBAGENTS_ONLY' + + // Is the "hide" row selected? (index === teammateCount in selection mode) + const isHideSelected = + isInSelectionMode === true && selectedIndex === teammateTasks.length + + return ( + + {/* Leader row - always visible, uses ┌─ to enclose the tree */} + { + + + {isLeaderSelected ? figures.pointer : ' '} + + + {isLeaderHighlighted ? '╒═' : '┌─'}{' '} + + + team-lead + + {/* When backgrounded and active: show spinner + verb */} + {!isLeaderForegrounded && leaderVerb && ( + : {leaderVerb}… + )} + {/* When backgrounded and idle: show idle text */} + {!isLeaderForegrounded && !leaderVerb && leaderIdleText && ( + : {leaderIdleText} + )} + {/* Stats (tokens) - same dimColor logic as teammates */} + {leaderTokenCount !== undefined && leaderTokenCount > 0 && ( + + {' '} + · {formatNumber(leaderTokenCount)} tokens + + )} + {/* Hints - select hint when highlighted, view hint when selected but not foregrounded */} + {isLeaderHighlighted && ( + · {TEAMMATE_SELECT_HINT} + )} + {isLeaderSelected && !isLeaderForegrounded && ( + · enter to view + )} + + } + {teammateTasks.map((teammate, index) => ( + + ))} + {/* Hide row - only visible during selection mode */} + {isInSelectionMode && } + + ) +} + +function HideRow({ isSelected }: { isSelected: boolean }): React.ReactNode { + return ( + + + {isSelected ? figures.pointer : ' '} + + + {isSelected ? '╘═' : '└─'}{' '} + + + hide + + {isSelected && · enter to collapse} + + ) } diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index d3d952138..b2dc29168 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -1,59 +1,72 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import { plot as asciichart } from 'asciichart'; -import chalk from 'chalk'; -import figures from 'figures'; -import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react'; -import stripAnsi from 'strip-ansi'; -import type { CommandResultDisplay } from '../commands.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { applyColor } from '../ink/colorize.js'; -import { stringWidth as getStringWidth } from '../ink/stringWidth.js'; -import type { Color } from '../ink/styles.js'; +import { feature } from 'bun:bundle' +import { plot as asciichart } from 'asciichart' +import chalk from 'chalk' +import figures from 'figures' +import React, { + Suspense, + use, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import stripAnsi from 'strip-ansi' +import type { CommandResultDisplay } from '../commands.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { applyColor } from '../ink/colorize.js' +import { stringWidth as getStringWidth } from '../ink/stringWidth.js' +import type { Color } from '../ink/styles.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow stats navigation -import { Ansi, Box, Text, useInput } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { formatDuration, formatNumber } from '../utils/format.js'; -import { generateHeatmap } from '../utils/heatmap.js'; -import { renderModelName } from '../utils/model/model.js'; -import { copyAnsiToClipboard } from '../utils/screenshotClipboard.js'; -import { aggregateClaudeCodeStatsForRange, type ClaudeCodeStats, type DailyModelTokens, type StatsDateRange } from '../utils/stats.js'; -import { resolveThemeSetting } from '../utils/systemTheme.js'; -import { getTheme, themeColorToAnsi } from '../utils/theme.js'; -import { Pane } from './design-system/Pane.js'; -import { Tab, Tabs, useTabHeaderFocus } from './design-system/Tabs.js'; -import { Spinner } from './Spinner.js'; +import { Ansi, Box, Text, useInput } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { getGlobalConfig } from '../utils/config.js' +import { formatDuration, formatNumber } from '../utils/format.js' +import { generateHeatmap } from '../utils/heatmap.js' +import { renderModelName } from '../utils/model/model.js' +import { copyAnsiToClipboard } from '../utils/screenshotClipboard.js' +import { + aggregateClaudeCodeStatsForRange, + type ClaudeCodeStats, + type DailyModelTokens, + type StatsDateRange, +} from '../utils/stats.js' +import { resolveThemeSetting } from '../utils/systemTheme.js' +import { getTheme, themeColorToAnsi } from '../utils/theme.js' +import { Pane } from './design-system/Pane.js' +import { Tab, Tabs, useTabHeaderFocus } from './design-system/Tabs.js' +import { Spinner } from './Spinner.js' + function formatPeakDay(dateStr: string): string { - const date = new Date(dateStr); + const date = new Date(dateStr) return date.toLocaleDateString('en-US', { month: 'short', - day: 'numeric' - }); + day: 'numeric', + }) } + type Props = { - onClose: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -type StatsResult = { - type: 'success'; - data: ClaudeCodeStats; -} | { - type: 'error'; - message: string; -} | { - type: 'empty'; -}; + onClose: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type StatsResult = + | { type: 'success'; data: ClaudeCodeStats } + | { type: 'error'; message: string } + | { type: 'empty' } + const DATE_RANGE_LABELS: Record = { '7d': 'Last 7 days', '30d': 'Last 30 days', - all: 'All time' -}; -const DATE_RANGE_ORDER: StatsDateRange[] = ['all', '7d', '30d']; + all: 'All time', +} + +const DATE_RANGE_ORDER: StatsDateRange[] = ['all', '7d', '30d'] + function getNextDateRange(current: StatsDateRange): StatsDateRange { - const currentIndex = DATE_RANGE_ORDER.indexOf(current); - return DATE_RANGE_ORDER[(currentIndex + 1) % DATE_RANGE_ORDER.length]!; + const currentIndex = DATE_RANGE_ORDER.indexOf(current) + return DATE_RANGE_ORDER[(currentIndex + 1) % DATE_RANGE_ORDER.length]! } /** @@ -61,378 +74,297 @@ function getNextDateRange(current: StatsDateRange): StatsDateRange { * Always loads all-time stats for the heatmap. */ function createAllTimeStatsPromise(): Promise { - return aggregateClaudeCodeStatsForRange('all').then((data): StatsResult => { - if (!data || data.totalSessions === 0) { - return { - type: 'empty' - }; - } - return { - type: 'success', - data - }; - }).catch((err): StatsResult => { - const message = err instanceof Error ? err.message : 'Failed to load stats'; - return { - type: 'error', - message - }; - }); + return aggregateClaudeCodeStatsForRange('all') + .then((data): StatsResult => { + if (!data || data.totalSessions === 0) { + return { type: 'empty' } + } + return { type: 'success', data } + }) + .catch((err): StatsResult => { + const message = + err instanceof Error ? err.message : 'Failed to load stats' + return { type: 'error', message } + }) } -export function Stats(t0) { - const $ = _c(4); - const { - onClose - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = createAllTimeStatsPromise(); - $[0] = t1; - } else { - t1 = $[0]; - } - const allTimePromise = t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Loading your Claude Code stats…; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== onClose) { - t3 = ; - $[2] = onClose; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; + +export function Stats({ onClose }: Props): React.ReactNode { + // Always load all-time stats first (for heatmap) + const allTimePromise = useMemo(() => createAllTimeStatsPromise(), []) + + return ( + + + Loading your Claude Code stats… + + } + > + + + ) } + type StatsContentProps = { - allTimePromise: Promise; - onClose: Props['onClose']; -}; + allTimePromise: Promise + onClose: Props['onClose'] +} /** * Inner component that uses React 19's use() to read the stats promise. * Suspends while loading all-time stats, then handles date range changes without suspending. */ -function StatsContent(t0) { - const $ = _c(34); - const { - allTimePromise, - onClose - } = t0; - const allTimeResult = use(allTimePromise) as StatsResult; - const [dateRange, setDateRange] = useState("all"); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {} as Record; - $[0] = t1; - } else { - t1 = $[0] as Record; - } - const [statsCache, setStatsCache] = useState>(t1); - const [isLoadingFiltered, setIsLoadingFiltered] = useState(false); - const [activeTab, setActiveTab] = useState<"Overview" | "Models">("Overview"); - const [copyStatus, setCopyStatus] = useState(null); - let t2; - let t3; - if ($[1] !== dateRange || $[2] !== statsCache) { - t2 = () => { - if (dateRange === "all") { - return; - } - if (statsCache[dateRange]) { - return; - } - let cancelled = false; - setIsLoadingFiltered(true); - aggregateClaudeCodeStatsForRange(dateRange).then(data => { +function StatsContent({ + allTimePromise, + onClose, +}: StatsContentProps): React.ReactNode { + const allTimeResult = use(allTimePromise) + const [dateRange, setDateRange] = useState('all') + const [statsCache, setStatsCache] = useState< + Partial> + >({}) + const [isLoadingFiltered, setIsLoadingFiltered] = useState(false) + const [activeTab, setActiveTab] = useState<'Overview' | 'Models'>('Overview') + const [copyStatus, setCopyStatus] = useState(null) + + // Load filtered stats when date range changes (with caching) + useEffect(() => { + if (dateRange === 'all') { + return + } + + // Already cached + if (statsCache[dateRange]) { + return + } + + let cancelled = false + setIsLoadingFiltered(true) + + aggregateClaudeCodeStatsForRange(dateRange) + .then(data => { if (!cancelled) { - setStatsCache(prev => ({ - ...prev, - [dateRange]: data - })); - setIsLoadingFiltered(false); + setStatsCache(prev => ({ ...prev, [dateRange]: data })) + setIsLoadingFiltered(false) } - }).catch(() => { + }) + .catch(() => { if (!cancelled) { - setIsLoadingFiltered(false); + setIsLoadingFiltered(false) } - }); - return () => { - cancelled = true; - }; - }; - t3 = [dateRange, statsCache]; - $[1] = dateRange; - $[2] = statsCache; - $[3] = t2; - $[4] = t3; - } else { - t2 = $[3]; - t3 = $[4]; - } - useEffect(t2, t3); - const displayStats = dateRange === "all" ? allTimeResult.type === "success" ? allTimeResult.data : null : statsCache[dateRange] ?? (allTimeResult.type === "success" ? allTimeResult.data : null); - const allTimeStats = allTimeResult.type === "success" ? allTimeResult.data : null; - let t4; - if ($[5] !== onClose) { - t4 = () => { - onClose("Stats dialog dismissed", { - display: "system" - }); - }; - $[5] = onClose; - $[6] = t4; - } else { - t4 = $[6]; - } - const handleClose = t4; - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - context: "Confirmation" - }; - $[7] = t5; - } else { - t5 = $[7]; - } - useKeybinding("confirm:no", handleClose, t5); - let t6; - if ($[8] !== activeTab || $[9] !== dateRange || $[10] !== displayStats || $[11] !== onClose) { - t6 = (input, key) => { - if (key.ctrl && (input === "c" || input === "d")) { - onClose("Stats dialog dismissed", { - display: "system" - }); - } - if (key.tab) { - setActiveTab(_temp); - } - if (input === "r" && !key.ctrl && !key.meta) { - setDateRange(getNextDateRange(dateRange)); - } - if (key.ctrl && input === "s" && displayStats) { - handleScreenshot(displayStats, activeTab, setCopyStatus); - } - }; - $[8] = activeTab; - $[9] = dateRange; - $[10] = displayStats; - $[11] = onClose; - $[12] = t6; - } else { - t6 = $[12]; - } - useInput(t6); - if (allTimeResult.type === "error") { - let t7; - if ($[13] !== allTimeResult.message) { - t7 = Failed to load stats: {allTimeResult.message}; - $[13] = allTimeResult.message; - $[14] = t7; - } else { - t7 = $[14]; + }) + + return () => { + cancelled = true } - return t7; - } - if (allTimeResult.type === "empty") { - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = No stats available yet. Start using Claude Code!; - $[15] = t7; - } else { - t7 = $[15]; + }, [dateRange, statsCache]) + + // Use cached stats for current range + const displayStats = + dateRange === 'all' + ? allTimeResult.type === 'success' + ? allTimeResult.data + : null + : (statsCache[dateRange] ?? + (allTimeResult.type === 'success' ? allTimeResult.data : null)) + + // All-time stats for the heatmap (always use all-time) + const allTimeStats = + allTimeResult.type === 'success' ? allTimeResult.data : null + + const handleClose = useCallback(() => { + onClose('Stats dialog dismissed', { display: 'system' }) + }, [onClose]) + + useKeybinding('confirm:no', handleClose, { context: 'Confirmation' }) + + useInput((input, key) => { + // Handle ctrl+c and ctrl+d for closing + if (key.ctrl && (input === 'c' || input === 'd')) { + onClose('Stats dialog dismissed', { display: 'system' }) } - return t7; - } - if (!displayStats || !allTimeStats) { - let t7; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Loading stats…; - $[16] = t7; - } else { - t7 = $[16]; + // Track tab changes + if (key.tab) { + setActiveTab(prev => (prev === 'Overview' ? 'Models' : 'Overview')) } - return t7; - } - let t7; - if ($[17] !== allTimeStats || $[18] !== dateRange || $[19] !== displayStats || $[20] !== isLoadingFiltered) { - t7 = ; - $[17] = allTimeStats; - $[18] = dateRange; - $[19] = displayStats; - $[20] = isLoadingFiltered; - $[21] = t7; - } else { - t7 = $[21]; - } - let t8; - if ($[22] !== dateRange || $[23] !== displayStats || $[24] !== isLoadingFiltered) { - t8 = ; - $[22] = dateRange; - $[23] = displayStats; - $[24] = isLoadingFiltered; - $[25] = t8; - } else { - t8 = $[25]; - } - let t9; - if ($[26] !== t7 || $[27] !== t8) { - t9 = {t7}{t8}; - $[26] = t7; - $[27] = t8; - $[28] = t9; - } else { - t9 = $[28]; + // r to cycle date range + if (input === 'r' && !key.ctrl && !key.meta) { + setDateRange(getNextDateRange(dateRange)) + } + // Ctrl+S to copy screenshot to clipboard + if (key.ctrl && input === 's' && displayStats) { + void handleScreenshot(displayStats, activeTab, setCopyStatus) + } + }) + + if (allTimeResult.type === 'error') { + return ( + + Failed to load stats: {allTimeResult.message} + + ) } - const t10 = copyStatus ? ` · ${copyStatus}` : ""; - let t11; - if ($[29] !== t10) { - t11 = Esc to cancel · r to cycle dates · ctrl+s to copy{t10}; - $[29] = t10; - $[30] = t11; - } else { - t11 = $[30]; + + if (allTimeResult.type === 'empty') { + return ( + + + No stats available yet. Start using Claude Code! + + + ) } - let t12; - if ($[31] !== t11 || $[32] !== t9) { - t12 = {t9}{t11}; - $[31] = t11; - $[32] = t9; - $[33] = t12; - } else { - t12 = $[33]; + + if (!displayStats || !allTimeStats) { + return ( + + + Loading stats… + + ) } - return t12; -} -function _temp(prev_0) { - return prev_0 === "Overview" ? "Models" : "Overview"; + + return ( + + + + + + + + + + + + + + Esc to cancel · r to cycle dates · ctrl+s to copy + {copyStatus ? ` · ${copyStatus}` : ''} + + + + ) } -function DateRangeSelector(t0) { - const $ = _c(9); - const { - dateRange, - isLoading - } = t0; - let t1; - if ($[0] !== dateRange) { - t1 = DATE_RANGE_ORDER.map((range, i) => {i > 0 && · }{range === dateRange ? {DATE_RANGE_LABELS[range]} : {DATE_RANGE_LABELS[range]}}); - $[0] = dateRange; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = {t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== isLoading) { - t3 = isLoading && ; - $[4] = isLoading; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== t2 || $[7] !== t3) { - t4 = {t2}{t3}; - $[6] = t2; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + +function DateRangeSelector({ + dateRange, + isLoading, +}: { + dateRange: StatsDateRange + isLoading: boolean +}): React.ReactNode { + return ( + + + {DATE_RANGE_ORDER.map((range, i) => ( + + {i > 0 && · } + {range === dateRange ? ( + + {DATE_RANGE_LABELS[range]} + + ) : ( + {DATE_RANGE_LABELS[range]} + )} + + ))} + + {isLoading && } + + ) } + function OverviewTab({ stats, allTimeStats, dateRange, - isLoading + isLoading, }: { - stats: ClaudeCodeStats; - allTimeStats: ClaudeCodeStats; - dateRange: StatsDateRange; - isLoading: boolean; + stats: ClaudeCodeStats + allTimeStats: ClaudeCodeStats + dateRange: StatsDateRange + isLoading: boolean }): React.ReactNode { - const { - columns: terminalWidth - } = useTerminalSize(); + const { columns: terminalWidth } = useTerminalSize() // Calculate favorite model and total tokens - const modelEntries = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens)); - const favoriteModel = modelEntries[0]; - const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); + const modelEntries = Object.entries(stats.modelUsage).sort( + ([, a], [, b]) => + b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ) + const favoriteModel = modelEntries[0] + const totalTokens = modelEntries.reduce( + (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, + 0, + ) // Memoize the factoid so it doesn't change when switching tabs - const factoid = useMemo(() => generateFunFactoid(stats, totalTokens), [stats, totalTokens]); + const factoid = useMemo( + () => generateFunFactoid(stats, totalTokens), + [stats, totalTokens], + ) // Calculate range days based on selected date range - const rangeDays = dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : stats.totalDays; + const rangeDays = + dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : stats.totalDays // Compute shot stats data (ant-only, gated by feature flag) let shotStatsData: { - avgShots: string; - buckets: { - label: string; - count: number; - pct: number; - }[]; - } | null = null; + avgShots: string + buckets: { label: string; count: number; pct: number }[] + } | null = null if (feature('SHOT_STATS') && stats.shotDistribution) { - const dist = stats.shotDistribution; - const total = Object.values(dist).reduce((s, n) => s + n, 0); + const dist = stats.shotDistribution + const total = Object.values(dist).reduce((s, n) => s + n, 0) if (total > 0) { - const totalShots = Object.entries(dist).reduce((s_0, [count, sessions]) => s_0 + parseInt(count, 10) * sessions, 0); - const bucket = (min: number, max?: number) => Object.entries(dist).filter(([k]) => { - const n_0 = parseInt(k, 10); - return n_0 >= min && (max === undefined || n_0 <= max); - }).reduce((s_1, [, v]) => s_1 + v, 0); - const pct = (n_1: number) => Math.round(n_1 / total * 100); - const b1 = bucket(1, 1); - const b2_5 = bucket(2, 5); - const b6_10 = bucket(6, 10); - const b11 = bucket(11); + const totalShots = Object.entries(dist).reduce( + (s, [count, sessions]) => s + parseInt(count, 10) * sessions, + 0, + ) + const bucket = (min: number, max?: number) => + Object.entries(dist) + .filter(([k]) => { + const n = parseInt(k, 10) + return n >= min && (max === undefined || n <= max) + }) + .reduce((s, [, v]) => s + v, 0) + const pct = (n: number) => Math.round((n / total) * 100) + const b1 = bucket(1, 1) + const b2_5 = bucket(2, 5) + const b6_10 = bucket(6, 10) + const b11 = bucket(11) shotStatsData = { avgShots: (totalShots / total).toFixed(1), - buckets: [{ - label: '1-shot', - count: b1, - pct: pct(b1) - }, { - label: '2\u20135 shot', - count: b2_5, - pct: pct(b2_5) - }, { - label: '6\u201310 shot', - count: b6_10, - pct: pct(b6_10) - }, { - label: '11+ shot', - count: b11, - pct: pct(b11) - }] - }; + buckets: [ + { label: '1-shot', count: b1, pct: pct(b1) }, + { label: '2\u20135 shot', count: b2_5, pct: pct(b2_5) }, + { label: '6\u201310 shot', count: b6_10, pct: pct(b6_10) }, + { label: '11+ shot', count: b11, pct: pct(b11) }, + ], + } } } - return + + return ( + {/* Activity Heatmap - always shows all-time data */} - {allTimeStats.dailyActivity.length > 0 && + {allTimeStats.dailyActivity.length > 0 && ( + - {generateHeatmap(allTimeStats.dailyActivity, { - terminalWidth - })} + {generateHeatmap(allTimeStats.dailyActivity, { terminalWidth })} - } + + )} {/* Date range selector */} @@ -440,12 +372,14 @@ function OverviewTab({ {/* Section 1: Usage */} - {favoriteModel && + {favoriteModel && ( + Favorite model:{' '} {renderModelName(favoriteModel[0])} - } + + )} @@ -464,12 +398,14 @@ function OverviewTab({ - {stats.longestSession && + {stats.longestSession && ( + Longest session:{' '} {formatDuration(stats.longestSession.duration)} - } + + )} @@ -495,10 +431,12 @@ function OverviewTab({ {/* Row 3: Most active day | Current streak */} - {stats.peakActivityDay && + {stats.peakActivityDay && ( + Most active day:{' '} {formatPeakDay(stats.peakActivityDay)} - } + + )} @@ -512,7 +450,9 @@ function OverviewTab({ {/* Speculation time saved (ant-only) */} - {(process.env.USER_TYPE) === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && + {process.env.USER_TYPE === 'ant' && + stats.totalSpeculationTimeSavedMs > 0 && ( + Speculation saved:{' '} @@ -521,10 +461,12 @@ function OverviewTab({ - } + + )} {/* Shot stats (ant-only) */} - {shotStatsData && <> + {shotStatsData && ( + <> Shot distribution @@ -568,660 +510,675 @@ function OverviewTab({ - } + + )} {/* Fun factoid */} - {factoid && + {factoid && ( + {factoid} - } - ; + + )} + + ) } // Famous books and their approximate token counts (words * ~1.3) // Sorted by tokens ascending for comparison logic -const BOOK_COMPARISONS = [{ - name: 'The Little Prince', - tokens: 22000 -}, { - name: 'The Old Man and the Sea', - tokens: 35000 -}, { - name: 'A Christmas Carol', - tokens: 37000 -}, { - name: 'Animal Farm', - tokens: 39000 -}, { - name: 'Fahrenheit 451', - tokens: 60000 -}, { - name: 'The Great Gatsby', - tokens: 62000 -}, { - name: 'Slaughterhouse-Five', - tokens: 64000 -}, { - name: 'Brave New World', - tokens: 83000 -}, { - name: 'The Catcher in the Rye', - tokens: 95000 -}, { - name: "Harry Potter and the Philosopher's Stone", - tokens: 103000 -}, { - name: 'The Hobbit', - tokens: 123000 -}, { - name: '1984', - tokens: 123000 -}, { - name: 'To Kill a Mockingbird', - tokens: 130000 -}, { - name: 'Pride and Prejudice', - tokens: 156000 -}, { - name: 'Dune', - tokens: 244000 -}, { - name: 'Moby-Dick', - tokens: 268000 -}, { - name: 'Crime and Punishment', - tokens: 274000 -}, { - name: 'A Game of Thrones', - tokens: 381000 -}, { - name: 'Anna Karenina', - tokens: 468000 -}, { - name: 'Don Quixote', - tokens: 520000 -}, { - name: 'The Lord of the Rings', - tokens: 576000 -}, { - name: 'The Count of Monte Cristo', - tokens: 603000 -}, { - name: 'Les Misérables', - tokens: 689000 -}, { - name: 'War and Peace', - tokens: 730000 -}]; +const BOOK_COMPARISONS = [ + { name: 'The Little Prince', tokens: 22000 }, + { name: 'The Old Man and the Sea', tokens: 35000 }, + { name: 'A Christmas Carol', tokens: 37000 }, + { name: 'Animal Farm', tokens: 39000 }, + { name: 'Fahrenheit 451', tokens: 60000 }, + { name: 'The Great Gatsby', tokens: 62000 }, + { name: 'Slaughterhouse-Five', tokens: 64000 }, + { name: 'Brave New World', tokens: 83000 }, + { name: 'The Catcher in the Rye', tokens: 95000 }, + { name: "Harry Potter and the Philosopher's Stone", tokens: 103000 }, + { name: 'The Hobbit', tokens: 123000 }, + { name: '1984', tokens: 123000 }, + { name: 'To Kill a Mockingbird', tokens: 130000 }, + { name: 'Pride and Prejudice', tokens: 156000 }, + { name: 'Dune', tokens: 244000 }, + { name: 'Moby-Dick', tokens: 268000 }, + { name: 'Crime and Punishment', tokens: 274000 }, + { name: 'A Game of Thrones', tokens: 381000 }, + { name: 'Anna Karenina', tokens: 468000 }, + { name: 'Don Quixote', tokens: 520000 }, + { name: 'The Lord of the Rings', tokens: 576000 }, + { name: 'The Count of Monte Cristo', tokens: 603000 }, + { name: 'Les Misérables', tokens: 689000 }, + { name: 'War and Peace', tokens: 730000 }, +] // Time equivalents for session durations -const TIME_COMPARISONS = [{ - name: 'a TED talk', - minutes: 18 -}, { - name: 'an episode of The Office', - minutes: 22 -}, { - name: 'listening to Abbey Road', - minutes: 47 -}, { - name: 'a yoga class', - minutes: 60 -}, { - name: 'a World Cup soccer match', - minutes: 90 -}, { - name: 'a half marathon (average time)', - minutes: 120 -}, { - name: 'the movie Inception', - minutes: 148 -}, { - name: 'watching Titanic', - minutes: 195 -}, { - name: 'a transatlantic flight', - minutes: 420 -}, { - name: 'a full night of sleep', - minutes: 480 -}]; -function generateFunFactoid(stats: ClaudeCodeStats, totalTokens: number): string { - const factoids: string[] = []; +const TIME_COMPARISONS = [ + { name: 'a TED talk', minutes: 18 }, + { name: 'an episode of The Office', minutes: 22 }, + { name: 'listening to Abbey Road', minutes: 47 }, + { name: 'a yoga class', minutes: 60 }, + { name: 'a World Cup soccer match', minutes: 90 }, + { name: 'a half marathon (average time)', minutes: 120 }, + { name: 'the movie Inception', minutes: 148 }, + { name: 'watching Titanic', minutes: 195 }, + { name: 'a transatlantic flight', minutes: 420 }, + { name: 'a full night of sleep', minutes: 480 }, +] + +function generateFunFactoid( + stats: ClaudeCodeStats, + totalTokens: number, +): string { + const factoids: string[] = [] + if (totalTokens > 0) { - const matchingBooks = BOOK_COMPARISONS.filter(book => totalTokens >= book.tokens); + const matchingBooks = BOOK_COMPARISONS.filter( + book => totalTokens >= book.tokens, + ) + for (const book of matchingBooks) { - const times = totalTokens / book.tokens; + const times = totalTokens / book.tokens if (times >= 2) { - factoids.push(`You've used ~${Math.floor(times)}x more tokens than ${book.name}`); + factoids.push( + `You've used ~${Math.floor(times)}x more tokens than ${book.name}`, + ) } else { - factoids.push(`You've used the same number of tokens as ${book.name}`); + factoids.push(`You've used the same number of tokens as ${book.name}`) } } } + if (stats.longestSession) { - const sessionMinutes = stats.longestSession.duration / (1000 * 60); + const sessionMinutes = stats.longestSession.duration / (1000 * 60) for (const comparison of TIME_COMPARISONS) { - const ratio = sessionMinutes / comparison.minutes; + const ratio = sessionMinutes / comparison.minutes if (ratio >= 2) { - factoids.push(`Your longest session is ~${Math.floor(ratio)}x longer than ${comparison.name}`); + factoids.push( + `Your longest session is ~${Math.floor(ratio)}x longer than ${comparison.name}`, + ) } } } + if (factoids.length === 0) { - return ''; + return '' } - const randomIndex = Math.floor(Math.random() * factoids.length); - return factoids[randomIndex]!; + const randomIndex = Math.floor(Math.random() * factoids.length) + return factoids[randomIndex]! } -function ModelsTab(t0) { - const $ = _c(15); - const { - stats, - dateRange, - isLoading - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - const [scrollOffset, setScrollOffset] = useState(0); - const { - columns: terminalWidth - } = useTerminalSize(); - const modelEntries = Object.entries(stats.modelUsage).sort(_temp7); - const t1 = !headerFocused; - let t2; - if ($[0] !== t1) { - t2 = { - isActive: t1 - }; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - useInput((_input, key) => { - if (key.downArrow && scrollOffset < modelEntries.length - 4) { - setScrollOffset(prev => Math.min(prev + 2, modelEntries.length - 4)); - } - if (key.upArrow) { - if (scrollOffset > 0) { - setScrollOffset(_temp8); - } else { - focusHeader(); + +function ModelsTab({ + stats, + dateRange, + isLoading, +}: { + stats: ClaudeCodeStats + dateRange: StatsDateRange + isLoading: boolean +}): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + const [scrollOffset, setScrollOffset] = useState(0) + const { columns: terminalWidth } = useTerminalSize() + const VISIBLE_MODELS = 4 // Show 4 models at a time (2 per column) + + const modelEntries = Object.entries(stats.modelUsage).sort( + ([, a], [, b]) => + b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ) + + // Handle scrolling with arrow keys + useInput( + (_input, key) => { + if ( + key.downArrow && + scrollOffset < modelEntries.length - VISIBLE_MODELS + ) { + setScrollOffset(prev => + Math.min(prev + 2, modelEntries.length - VISIBLE_MODELS), + ) } - } - }, t2); + if (key.upArrow) { + if (scrollOffset > 0) { + setScrollOffset(prev => Math.max(prev - 2, 0)) + } else { + focusHeader() + } + } + }, + { isActive: !headerFocused }, + ) + if (modelEntries.length === 0) { - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = No model usage data available; - $[2] = t3; - } else { - t3 = $[2]; - } - return t3; - } - const totalTokens = modelEntries.reduce(_temp9, 0); - const chartOutput = generateTokenChart(stats.dailyModelTokens, modelEntries.map(_temp0), terminalWidth); - const visibleModels = modelEntries.slice(scrollOffset, scrollOffset + 4); - const midpoint = Math.ceil(visibleModels.length / 2); - const leftModels = visibleModels.slice(0, midpoint); - const rightModels = visibleModels.slice(midpoint); - const canScrollUp = scrollOffset > 0; - const canScrollDown = scrollOffset < modelEntries.length - 4; - const showScrollHint = modelEntries.length > 4; - let t3; - if ($[3] !== dateRange || $[4] !== isLoading) { - t3 = ; - $[3] = dateRange; - $[4] = isLoading; - $[5] = t3; - } else { - t3 = $[5]; - } - const T0 = Box; - const t5 = "column"; - const t6 = 36; - const t8 = rightModels.map(t7 => { - const [model_1, usage_1] = t7; - return ; - }); - let t9; - if ($[6] !== T0 || $[7] !== t8) { - t9 = {t8}; - $[6] = T0; - $[7] = t8; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] !== canScrollDown || $[10] !== canScrollUp || $[11] !== modelEntries || $[12] !== scrollOffset || $[13] !== showScrollHint) { - t10 = showScrollHint && {canScrollUp ? figures.arrowUp : " "}{" "}{canScrollDown ? figures.arrowDown : " "} {scrollOffset + 1}-{Math.min(scrollOffset + 4, modelEntries.length)} of{" "}{modelEntries.length} models (↑↓ to scroll); - $[9] = canScrollDown; - $[10] = canScrollUp; - $[11] = modelEntries; - $[12] = scrollOffset; - $[13] = showScrollHint; - $[14] = t10; - } else { - t10 = $[14]; + return ( + + No model usage data available + + ) } - return {chartOutput && Tokens per Day{chartOutput.chart}{chartOutput.xAxisLabels}{chartOutput.legend.map(_temp1)}}{t3}{leftModels.map(t4 => { - const [model_0, usage_0] = t4; - return ; - })}{t9}{t10}; -} -function _temp1(item, i) { - return {i > 0 ? " \xB7 " : ""}{item.coloredBullet} {item.model}; -} -function _temp0(t0) { - const [model] = t0; - return model; -} -function _temp9(sum, t0) { - const [, usage] = t0; - return sum + usage.inputTokens + usage.outputTokens; -} -function _temp8(prev_0) { - return Math.max(prev_0 - 2, 0); -} -function _temp7(t0, t1) { - const [, a] = t0; - const [, b] = t1; - return b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens); + + const totalTokens = modelEntries.reduce( + (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, + 0, + ) + + // Generate token usage chart - use terminal width for responsive sizing + const chartOutput = generateTokenChart( + stats.dailyModelTokens, + modelEntries.map(([model]) => model), + terminalWidth, + ) + + // Get visible models and split into two columns + const visibleModels = modelEntries.slice( + scrollOffset, + scrollOffset + VISIBLE_MODELS, + ) + const midpoint = Math.ceil(visibleModels.length / 2) + const leftModels = visibleModels.slice(0, midpoint) + const rightModels = visibleModels.slice(midpoint) + + const canScrollUp = scrollOffset > 0 + const canScrollDown = scrollOffset < modelEntries.length - VISIBLE_MODELS + const showScrollHint = modelEntries.length > VISIBLE_MODELS + + return ( + + {/* Token usage chart */} + {chartOutput && ( + + Tokens per Day + {chartOutput.chart} + {chartOutput.xAxisLabels} + + {chartOutput.legend.map((item, i) => ( + + {i > 0 ? ' · ' : ''} + {item.coloredBullet} {item.model} + + ))} + + + )} + + {/* Date range selector */} + + + {/* Model breakdown - two columns with fixed width */} + + + {leftModels.map(([model, usage]) => ( + + ))} + + + {rightModels.map(([model, usage]) => ( + + ))} + + + + {/* Scroll hint */} + {showScrollHint && ( + + + {canScrollUp ? figures.arrowUp : ' '}{' '} + {canScrollDown ? figures.arrowDown : ' '} {scrollOffset + 1}- + {Math.min(scrollOffset + VISIBLE_MODELS, modelEntries.length)} of{' '} + {modelEntries.length} models (↑↓ to scroll) + + + )} + + ) } + type ModelEntryProps = { - model: string; + model: string usage: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - }; - totalTokens: number; -}; -function ModelEntry(t0) { - const $ = _c(21); - const { - model, - usage, - totalTokens - } = t0; - const modelTokens = usage.inputTokens + usage.outputTokens; - const t1 = modelTokens / totalTokens * 100; - let t2; - if ($[0] !== t1) { - t2 = t1.toFixed(1); - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; + inputTokens: number + outputTokens: number + cacheReadInputTokens: number } - const percentage = t2; - let t3; - if ($[2] !== model) { - t3 = renderModelName(model); - $[2] = model; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== t3) { - t4 = {t3}; - $[4] = t3; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== percentage) { - t5 = ({percentage}%); - $[6] = percentage; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== t4 || $[9] !== t5) { - t6 = {figures.bullet} {t4}{" "}{t5}; - $[8] = t4; - $[9] = t5; - $[10] = t6; - } else { - t6 = $[10]; - } - let t7; - if ($[11] !== usage.inputTokens) { - t7 = formatNumber(usage.inputTokens); - $[11] = usage.inputTokens; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== usage.outputTokens) { - t8 = formatNumber(usage.outputTokens); - $[13] = usage.outputTokens; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== t7 || $[16] !== t8) { - t9 = {" "}In: {t7} · Out:{" "}{t8}; - $[15] = t7; - $[16] = t8; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] !== t6 || $[19] !== t9) { - t10 = {t6}{t9}; - $[18] = t6; - $[19] = t9; - $[20] = t10; - } else { - t10 = $[20]; - } - return t10; + totalTokens: number +} + +function ModelEntry({ + model, + usage, + totalTokens, +}: ModelEntryProps): React.ReactNode { + const modelTokens = usage.inputTokens + usage.outputTokens + const percentage = ((modelTokens / totalTokens) * 100).toFixed(1) + + return ( + + + {figures.bullet} {renderModelName(model)}{' '} + ({percentage}%) + + + {' '}In: {formatNumber(usage.inputTokens)} · Out:{' '} + {formatNumber(usage.outputTokens)} + + + ) } + type ChartLegend = { - model: string; - coloredBullet: string; // Pre-colored bullet using chalk -}; + model: string + coloredBullet: string // Pre-colored bullet using chalk +} + type ChartOutput = { - chart: string; - legend: ChartLegend[]; - xAxisLabels: string; -}; -function generateTokenChart(dailyTokens: DailyModelTokens[], models: string[], terminalWidth: number): ChartOutput | null { + chart: string + legend: ChartLegend[] + xAxisLabels: string +} + +function generateTokenChart( + dailyTokens: DailyModelTokens[], + models: string[], + terminalWidth: number, +): ChartOutput | null { if (dailyTokens.length < 2 || models.length === 0) { - return null; + return null } // Y-axis labels take about 6 characters, plus some padding // Cap at ~52 to align with heatmap width (1 year of data) - const yAxisWidth = 7; - const availableWidth = terminalWidth - yAxisWidth; - const chartWidth = Math.min(52, Math.max(20, availableWidth)); + const yAxisWidth = 7 + const availableWidth = terminalWidth - yAxisWidth + const chartWidth = Math.min(52, Math.max(20, availableWidth)) // Distribute data across the available chart width - let recentData: DailyModelTokens[]; + let recentData: DailyModelTokens[] if (dailyTokens.length >= chartWidth) { // More data than space: take most recent N days - recentData = dailyTokens.slice(-chartWidth); + recentData = dailyTokens.slice(-chartWidth) } else { // Less data than space: expand by repeating each point - const repeatCount = Math.floor(chartWidth / dailyTokens.length); - recentData = []; + const repeatCount = Math.floor(chartWidth / dailyTokens.length) + recentData = [] for (const day of dailyTokens) { for (let i = 0; i < repeatCount; i++) { - recentData.push(day); + recentData.push(day) } } } // Color palette for different models - use theme colors - const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)); - const colors = [themeColorToAnsi(theme.suggestion), themeColorToAnsi(theme.success), themeColorToAnsi(theme.warning)]; + const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)) + const colors = [ + themeColorToAnsi(theme.suggestion), + themeColorToAnsi(theme.success), + themeColorToAnsi(theme.warning), + ] // Prepare series data for each model - const series: number[][] = []; - const legend: ChartLegend[] = []; + const series: number[][] = [] + const legend: ChartLegend[] = [] // Only show top 3 models to keep chart readable - const topModels = models.slice(0, 3); + const topModels = models.slice(0, 3) + for (let i = 0; i < topModels.length; i++) { - const model = topModels[i]!; - const data = recentData.map(day => day.tokensByModel[model] || 0); + const model = topModels[i]! + const data = recentData.map(day => day.tokensByModel[model] || 0) // Only include if there's actual data if (data.some(v => v > 0)) { - series.push(data); + series.push(data) // Use theme colors that match the chart - const bulletColors = [theme.suggestion, theme.success, theme.warning]; + const bulletColors = [theme.suggestion, theme.success, theme.warning] legend.push({ model: renderModelName(model), - coloredBullet: applyColor(figures.bullet, bulletColors[i % bulletColors.length] as Color) - }); + coloredBullet: applyColor( + figures.bullet, + bulletColors[i % bulletColors.length] as Color, + ), + }) } } + if (series.length === 0) { - return null; + return null } + const chart = asciichart(series, { height: 8, colors: colors.slice(0, series.length), format: (x: number) => { - let label: string; + let label: string if (x >= 1_000_000) { - label = (x / 1_000_000).toFixed(1) + 'M'; + label = (x / 1_000_000).toFixed(1) + 'M' } else if (x >= 1_000) { - label = (x / 1_000).toFixed(0) + 'k'; + label = (x / 1_000).toFixed(0) + 'k' } else { - label = x.toFixed(0); + label = x.toFixed(0) } - return label.padStart(6); - } - }); + return label.padStart(6) + }, + }) // Generate x-axis labels with dates - const xAxisLabels = generateXAxisLabels(recentData, recentData.length, yAxisWidth); - return { - chart, - legend, - xAxisLabels - }; + const xAxisLabels = generateXAxisLabels( + recentData, + recentData.length, + yAxisWidth, + ) + + return { chart, legend, xAxisLabels } } -function generateXAxisLabels(data: DailyModelTokens[], _chartWidth: number, yAxisOffset: number): string { - if (data.length === 0) return ''; + +function generateXAxisLabels( + data: DailyModelTokens[], + _chartWidth: number, + yAxisOffset: number, +): string { + if (data.length === 0) return '' // Show 3-4 date labels evenly spaced, but leave room for last label - const numLabels = Math.min(4, Math.max(2, Math.floor(data.length / 8))); + const numLabels = Math.min(4, Math.max(2, Math.floor(data.length / 8))) // Don't use the very last position - leave room for the label text - const usableLength = data.length - 6; // Reserve ~6 chars for last label (e.g., "Dec 7") - const step = Math.floor(usableLength / (numLabels - 1)) || 1; - const labelPositions: { - pos: number; - label: string; - }[] = []; + const usableLength = data.length - 6 // Reserve ~6 chars for last label (e.g., "Dec 7") + const step = Math.floor(usableLength / (numLabels - 1)) || 1 + + const labelPositions: { pos: number; label: string }[] = [] + for (let i = 0; i < numLabels; i++) { - const idx = Math.min(i * step, data.length - 1); - const date = new Date(data[idx]!.date); + const idx = Math.min(i * step, data.length - 1) + const date = new Date(data[idx]!.date) const label = date.toLocaleDateString('en-US', { month: 'short', - day: 'numeric' - }); - labelPositions.push({ - pos: idx, - label - }); + day: 'numeric', + }) + labelPositions.push({ pos: idx, label }) } // Build the label string with proper spacing - let result = ' '.repeat(yAxisOffset); - let currentPos = 0; - for (const { - pos, - label - } of labelPositions) { - const spaces = Math.max(1, pos - currentPos); - result += ' '.repeat(spaces) + label; - currentPos = pos + label.length; + let result = ' '.repeat(yAxisOffset) + let currentPos = 0 + + for (const { pos, label } of labelPositions) { + const spaces = Math.max(1, pos - currentPos) + result += ' '.repeat(spaces) + label + currentPos = pos + label.length } - return result; + + return result } // Screenshot functionality -async function handleScreenshot(stats: ClaudeCodeStats, activeTab: 'Overview' | 'Models', setStatus: (status: string | null) => void): Promise { - setStatus('copying…'); - const ansiText = renderStatsToAnsi(stats, activeTab); - const result = await copyAnsiToClipboard(ansiText); - setStatus(result.success ? 'copied!' : 'copy failed'); +async function handleScreenshot( + stats: ClaudeCodeStats, + activeTab: 'Overview' | 'Models', + setStatus: (status: string | null) => void, +): Promise { + setStatus('copying…') + + const ansiText = renderStatsToAnsi(stats, activeTab) + const result = await copyAnsiToClipboard(ansiText) + + setStatus(result.success ? 'copied!' : 'copy failed') // Clear status after 2 seconds - setTimeout(setStatus, 2000, null); + setTimeout(setStatus, 2000, null) } -function renderStatsToAnsi(stats: ClaudeCodeStats, activeTab: 'Overview' | 'Models'): string { - const lines: string[] = []; + +function renderStatsToAnsi( + stats: ClaudeCodeStats, + activeTab: 'Overview' | 'Models', +): string { + const lines: string[] = [] + if (activeTab === 'Overview') { - lines.push(...renderOverviewToAnsi(stats)); + lines.push(...renderOverviewToAnsi(stats)) } else { - lines.push(...renderModelsToAnsi(stats)); + lines.push(...renderModelsToAnsi(stats)) } // Trim trailing empty lines - while (lines.length > 0 && stripAnsi(lines[lines.length - 1]!).trim() === '') { - lines.pop(); + while ( + lines.length > 0 && + stripAnsi(lines[lines.length - 1]!).trim() === '' + ) { + lines.pop() } // Add "/stats" right-aligned on the last line if (lines.length > 0) { - const lastLine = lines[lines.length - 1]!; - const lastLineLen = getStringWidth(lastLine); + const lastLine = lines[lines.length - 1]! + const lastLineLen = getStringWidth(lastLine) // Use known content widths based on layout: // Overview: two-column stats = COL2_START(40) + COL2_LABEL_WIDTH(18) + max_value(~12) = 70 // Models: chart width = 80 - const contentWidth = activeTab === 'Overview' ? 70 : 80; - const statsLabel = '/stats'; - const padding = Math.max(2, contentWidth - lastLineLen - statsLabel.length); - lines[lines.length - 1] = lastLine + ' '.repeat(padding) + chalk.gray(statsLabel); + const contentWidth = activeTab === 'Overview' ? 70 : 80 + const statsLabel = '/stats' + const padding = Math.max(2, contentWidth - lastLineLen - statsLabel.length) + lines[lines.length - 1] = + lastLine + ' '.repeat(padding) + chalk.gray(statsLabel) } - return lines.join('\n'); + + return lines.join('\n') } + function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] { - const lines: string[] = []; - const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)); - const h = (text: string) => applyColor(text, theme.claude as Color); + const lines: string[] = [] + const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)) + const h = (text: string) => applyColor(text, theme.claude as Color) // Two-column helper with fixed spacing // Column 1: label (18 chars) + value + padding to reach col 2 // Column 2 starts at character position 40 - const COL1_LABEL_WIDTH = 18; - const COL2_START = 40; - const COL2_LABEL_WIDTH = 18; + const COL1_LABEL_WIDTH = 18 + const COL2_START = 40 + const COL2_LABEL_WIDTH = 18 + const row = (l1: string, v1: string, l2: string, v2: string): string => { // Build column 1: label + value - const label1 = (l1 + ':').padEnd(COL1_LABEL_WIDTH); - const col1PlainLen = label1.length + v1.length; + const label1 = (l1 + ':').padEnd(COL1_LABEL_WIDTH) + const col1PlainLen = label1.length + v1.length // Calculate spaces needed between col1 value and col2 label - const spaceBetween = Math.max(2, COL2_START - col1PlainLen); + const spaceBetween = Math.max(2, COL2_START - col1PlainLen) // Build column 2: label + value - const label2 = (l2 + ':').padEnd(COL2_LABEL_WIDTH); + const label2 = (l2 + ':').padEnd(COL2_LABEL_WIDTH) // Assemble with colors applied to values only - return label1 + h(v1) + ' '.repeat(spaceBetween) + label2 + h(v2); - }; + return label1 + h(v1) + ' '.repeat(spaceBetween) + label2 + h(v2) + } // Heatmap - use fixed width for screenshot (56 = 52 weeks + 4 for day labels) if (stats.dailyActivity.length > 0) { - lines.push(generateHeatmap(stats.dailyActivity, { - terminalWidth: 56 - })); - lines.push(''); + lines.push(generateHeatmap(stats.dailyActivity, { terminalWidth: 56 })) + lines.push('') } // Calculate values - const modelEntries = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens)); - const favoriteModel = modelEntries[0]; - const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); + const modelEntries = Object.entries(stats.modelUsage).sort( + ([, a], [, b]) => + b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ) + const favoriteModel = modelEntries[0] + const totalTokens = modelEntries.reduce( + (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, + 0, + ) // Row 1: Favorite model | Total tokens if (favoriteModel) { - lines.push(row('Favorite model', renderModelName(favoriteModel[0]), 'Total tokens', formatNumber(totalTokens))); + lines.push( + row( + 'Favorite model', + renderModelName(favoriteModel[0]), + 'Total tokens', + formatNumber(totalTokens), + ), + ) } - lines.push(''); + lines.push('') // Row 2: Sessions | Longest session - lines.push(row('Sessions', formatNumber(stats.totalSessions), 'Longest session', stats.longestSession ? formatDuration(stats.longestSession.duration) : 'N/A')); + lines.push( + row( + 'Sessions', + formatNumber(stats.totalSessions), + 'Longest session', + stats.longestSession + ? formatDuration(stats.longestSession.duration) + : 'N/A', + ), + ) // Row 3: Current streak | Longest streak - const currentStreakVal = `${stats.streaks.currentStreak} ${stats.streaks.currentStreak === 1 ? 'day' : 'days'}`; - const longestStreakVal = `${stats.streaks.longestStreak} ${stats.streaks.longestStreak === 1 ? 'day' : 'days'}`; - lines.push(row('Current streak', currentStreakVal, 'Longest streak', longestStreakVal)); + const currentStreakVal = `${stats.streaks.currentStreak} ${stats.streaks.currentStreak === 1 ? 'day' : 'days'}` + const longestStreakVal = `${stats.streaks.longestStreak} ${stats.streaks.longestStreak === 1 ? 'day' : 'days'}` + lines.push( + row('Current streak', currentStreakVal, 'Longest streak', longestStreakVal), + ) // Row 4: Active days | Peak hour - const activeDaysVal = `${stats.activeDays}/${stats.totalDays}`; - const peakHourVal = stats.peakActivityHour !== null ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` : 'N/A'; - lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal)); + const activeDaysVal = `${stats.activeDays}/${stats.totalDays}` + const peakHourVal = + stats.peakActivityHour !== null + ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` + : 'N/A' + lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal)) // Speculation time saved (ant-only) - if ((process.env.USER_TYPE) === 'ant' && stats.totalSpeculationTimeSavedMs > 0) { - const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH); - lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs))); + if ( + process.env.USER_TYPE === 'ant' && + stats.totalSpeculationTimeSavedMs > 0 + ) { + const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH) + lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs))) } // Shot stats (ant-only) if (feature('SHOT_STATS') && stats.shotDistribution) { - const dist = stats.shotDistribution; - const totalWithShots = Object.values(dist).reduce((s, n) => s + n, 0); + const dist = stats.shotDistribution + const totalWithShots = Object.values(dist).reduce((s, n) => s + n, 0) if (totalWithShots > 0) { - const totalShots = Object.entries(dist).reduce((s, [count, sessions]) => s + parseInt(count, 10) * sessions, 0); - const avgShots = (totalShots / totalWithShots).toFixed(1); - const bucket = (min: number, max?: number) => Object.entries(dist).filter(([k]) => { - const n = parseInt(k, 10); - return n >= min && (max === undefined || n <= max); - }).reduce((s, [, v]) => s + v, 0); - const pct = (n: number) => Math.round(n / totalWithShots * 100); - const fmtBucket = (count: number, p: number) => `${count} (${p}%)`; - const b1 = bucket(1, 1); - const b2_5 = bucket(2, 5); - const b6_10 = bucket(6, 10); - const b11 = bucket(11); - lines.push(''); - lines.push('Shot distribution'); - lines.push(row('1-shot', fmtBucket(b1, pct(b1)), '2\u20135 shot', fmtBucket(b2_5, pct(b2_5)))); - lines.push(row('6\u201310 shot', fmtBucket(b6_10, pct(b6_10)), '11+ shot', fmtBucket(b11, pct(b11)))); - lines.push(`${'Avg/session:'.padEnd(COL1_LABEL_WIDTH)}${h(avgShots)}`); + const totalShots = Object.entries(dist).reduce( + (s, [count, sessions]) => s + parseInt(count, 10) * sessions, + 0, + ) + const avgShots = (totalShots / totalWithShots).toFixed(1) + const bucket = (min: number, max?: number) => + Object.entries(dist) + .filter(([k]) => { + const n = parseInt(k, 10) + return n >= min && (max === undefined || n <= max) + }) + .reduce((s, [, v]) => s + v, 0) + const pct = (n: number) => Math.round((n / totalWithShots) * 100) + const fmtBucket = (count: number, p: number) => `${count} (${p}%)` + const b1 = bucket(1, 1) + const b2_5 = bucket(2, 5) + const b6_10 = bucket(6, 10) + const b11 = bucket(11) + lines.push('') + lines.push('Shot distribution') + lines.push( + row( + '1-shot', + fmtBucket(b1, pct(b1)), + '2\u20135 shot', + fmtBucket(b2_5, pct(b2_5)), + ), + ) + lines.push( + row( + '6\u201310 shot', + fmtBucket(b6_10, pct(b6_10)), + '11+ shot', + fmtBucket(b11, pct(b11)), + ), + ) + lines.push(`${'Avg/session:'.padEnd(COL1_LABEL_WIDTH)}${h(avgShots)}`) } } - lines.push(''); + + lines.push('') // Fun factoid - const factoid = generateFunFactoid(stats, totalTokens); - lines.push(h(factoid)); - lines.push(chalk.gray(`Stats from the last ${stats.totalDays} days`)); - return lines; + const factoid = generateFunFactoid(stats, totalTokens) + lines.push(h(factoid)) + lines.push(chalk.gray(`Stats from the last ${stats.totalDays} days`)) + + return lines } + function renderModelsToAnsi(stats: ClaudeCodeStats): string[] { - const lines: string[] = []; - const modelEntries = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens)); + const lines: string[] = [] + + const modelEntries = Object.entries(stats.modelUsage).sort( + ([, a], [, b]) => + b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ) + if (modelEntries.length === 0) { - lines.push(chalk.gray('No model usage data available')); - return lines; + lines.push(chalk.gray('No model usage data available')) + return lines } - const favoriteModel = modelEntries[0]; - const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); + + const favoriteModel = modelEntries[0] + const totalTokens = modelEntries.reduce( + (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, + 0, + ) // Generate chart if we have data - use fixed width for screenshot - const chartOutput = generateTokenChart(stats.dailyModelTokens, modelEntries.map(([model]) => model), 80 // Fixed width for screenshot - ); + const chartOutput = generateTokenChart( + stats.dailyModelTokens, + modelEntries.map(([model]) => model), + 80, // Fixed width for screenshot + ) + if (chartOutput) { - lines.push(chalk.bold('Tokens per Day')); - lines.push(chartOutput.chart); - lines.push(chalk.gray(chartOutput.xAxisLabels)); + lines.push(chalk.bold('Tokens per Day')) + lines.push(chartOutput.chart) + lines.push(chalk.gray(chartOutput.xAxisLabels)) // Legend - use pre-colored bullets from chart output - const legendLine = chartOutput.legend.map(item => `${item.coloredBullet} ${item.model}`).join(' · '); - lines.push(legendLine); - lines.push(''); + const legendLine = chartOutput.legend + .map(item => `${item.coloredBullet} ${item.model}`) + .join(' · ') + lines.push(legendLine) + lines.push('') } // Summary - lines.push(`${figures.star} Favorite: ${chalk.magenta.bold(renderModelName(favoriteModel?.[0] || ''))} · ${figures.circle} Total: ${chalk.magenta(formatNumber(totalTokens))} tokens`); - lines.push(''); + lines.push( + `${figures.star} Favorite: ${chalk.magenta.bold(renderModelName(favoriteModel?.[0] || ''))} · ${figures.circle} Total: ${chalk.magenta(formatNumber(totalTokens))} tokens`, + ) + lines.push('') // Model breakdown - only show top 3 for screenshot - const topModels = modelEntries.slice(0, 3); + const topModels = modelEntries.slice(0, 3) for (const [model, usage] of topModels) { - const modelTokens = usage.inputTokens + usage.outputTokens; - const percentage = (modelTokens / totalTokens * 100).toFixed(1); - lines.push(`${figures.bullet} ${chalk.bold(renderModelName(model))} ${chalk.gray(`(${percentage}%)`)}`); - lines.push(chalk.dim(` In: ${formatNumber(usage.inputTokens)} · Out: ${formatNumber(usage.outputTokens)}`)); + const modelTokens = usage.inputTokens + usage.outputTokens + const percentage = ((modelTokens / totalTokens) * 100).toFixed(1) + lines.push( + `${figures.bullet} ${chalk.bold(renderModelName(model))} ${chalk.gray(`(${percentage}%)`)}`, + ) + lines.push( + chalk.dim( + ` In: ${formatNumber(usage.inputTokens)} · Out: ${formatNumber(usage.outputTokens)}`, + ), + ) } - return lines; + + return lines } diff --git a/src/components/StatusLine.tsx b/src/components/StatusLine.tsx index 95f3cebf5..ad90655f4 100644 --- a/src/components/StatusLine.tsx +++ b/src/components/StatusLine.tsx @@ -1,61 +1,413 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { memo } from 'react'; -import { useAppState } from 'src/state/AppState.js'; -import { getSdkBetas, getKairosActive } from '../bootstrap/state.js'; -import { getTotalCost, getTotalInputTokens, getTotalOutputTokens } from '../cost-tracker.js'; -import { useMainLoopModel } from '../hooks/useMainLoopModel.js'; -import { type ReadonlySettings } from '../hooks/useSettings.js'; -import { getRawUtilization } from '../services/claudeAiLimits.js'; -import type { Message } from '../types/message.js'; -import { calculateContextPercentages, getContextWindowForModel } from '../utils/context.js'; -import { getLastAssistantMessage } from '../utils/messages.js'; -import { getRuntimeMainLoopModel, renderModelName } from '../utils/model/model.js'; -import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js'; -import { BuiltinStatusLine } from './BuiltinStatusLine.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { memo, useCallback, useEffect, useRef } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js' +import { + getIsRemoteMode, + getKairosActive, + getMainThreadAgentType, + getOriginalCwd, + getSdkBetas, + getSessionId, +} from '../bootstrap/state.js' +import { DEFAULT_OUTPUT_STYLE_NAME } from '../constants/outputStyles.js' +import { useNotifications } from '../context/notifications.js' +import { + getTotalAPIDuration, + getTotalCost, + getTotalDuration, + getTotalInputTokens, + getTotalLinesAdded, + getTotalLinesRemoved, + getTotalOutputTokens, +} from '../cost-tracker.js' +import { useMainLoopModel } from '../hooks/useMainLoopModel.js' +import { type ReadonlySettings, useSettings } from '../hooks/useSettings.js' +import { Ansi, Box, Text } from '../ink.js' +import { getRawUtilization } from '../services/claudeAiLimits.js' +import type { Message } from '../types/message.js' +import type { StatusLineCommandInput } from '../types/statusLine.js' +import type { VimMode } from '../types/textInputTypes.js' +import { checkHasTrustDialogAccepted } from '../utils/config.js' +import { + calculateContextPercentages, + getContextWindowForModel, +} from '../utils/context.js' +import { getCwd } from '../utils/cwd.js' +import { logForDebugging } from '../utils/debug.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import { + createBaseHookInput, + executeStatusLineCommand, +} from '../utils/hooks.js' +import { getLastAssistantMessage } from '../utils/messages.js' +import { + getRuntimeMainLoopModel, + type ModelName, + renderModelName, +} from '../utils/model/model.js' +import { getCurrentSessionTitle } from '../utils/sessionStorage.js' +import { + doesMostRecentAssistantMessageExceed200k, + getCurrentUsage, +} from '../utils/tokens.js' +import { getCurrentWorktreeSession } from '../utils/worktree.js' +import { isVimModeEnabled } from './PromptInput/utils.js' export function statusLineShouldDisplay(settings: ReadonlySettings): boolean { - if (feature('KAIROS') && getKairosActive()) return false; - return true; + // Assistant mode: statusline fields (model, permission mode, cwd) reflect the + // REPL/daemon process, not what the agent child is actually running. Hide it. + if (feature('KAIROS') && getKairosActive()) return false + return settings?.statusLine !== undefined +} + +function buildStatusLineCommandInput( + permissionMode: PermissionMode, + exceeds200kTokens: boolean, + settings: ReadonlySettings, + messages: Message[], + addedDirs: string[], + mainLoopModel: ModelName, + vimMode?: VimMode, +): StatusLineCommandInput { + const agentType = getMainThreadAgentType() + const worktreeSession = getCurrentWorktreeSession() + const runtimeModel = getRuntimeMainLoopModel({ + permissionMode, + mainLoopModel, + exceeds200kTokens, + }) + const outputStyleName = settings?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME + + const currentUsage = getCurrentUsage(messages) + const contextWindowSize = getContextWindowForModel( + runtimeModel, + getSdkBetas(), + ) + const contextPercentages = calculateContextPercentages( + currentUsage, + contextWindowSize, + ) + + const sessionId = getSessionId() + const sessionName = getCurrentSessionTitle(sessionId) + const rawUtil = getRawUtilization() + const rateLimits: StatusLineCommandInput['rate_limits'] = { + ...(rawUtil.five_hour && { + five_hour: { + used_percentage: rawUtil.five_hour.utilization * 100, + resets_at: rawUtil.five_hour.resets_at, + }, + }), + ...(rawUtil.seven_day && { + seven_day: { + used_percentage: rawUtil.seven_day.utilization * 100, + resets_at: rawUtil.seven_day.resets_at, + }, + }), + } + return { + ...createBaseHookInput(), + ...(sessionName && { session_name: sessionName }), + model: { + id: runtimeModel, + display_name: renderModelName(runtimeModel), + }, + workspace: { + current_dir: getCwd(), + project_dir: getOriginalCwd(), + added_dirs: addedDirs, + }, + version: MACRO.VERSION, + output_style: { + name: outputStyleName, + }, + cost: { + total_cost_usd: getTotalCost(), + total_duration_ms: getTotalDuration(), + total_api_duration_ms: getTotalAPIDuration(), + total_lines_added: getTotalLinesAdded(), + total_lines_removed: getTotalLinesRemoved(), + }, + context_window: { + total_input_tokens: getTotalInputTokens(), + total_output_tokens: getTotalOutputTokens(), + context_window_size: contextWindowSize, + current_usage: currentUsage, + used_percentage: contextPercentages.used, + remaining_percentage: contextPercentages.remaining, + }, + exceeds_200k_tokens: exceeds200kTokens, + ...((rateLimits.five_hour || rateLimits.seven_day) && { + rate_limits: rateLimits, + }), + ...(isVimModeEnabled() && { + vim: { + mode: vimMode ?? 'INSERT', + }, + }), + ...(agentType && { + agent: { + name: agentType, + }, + }), + ...(getIsRemoteMode() && { + remote: { + session_id: getSessionId(), + }, + }), + ...(worktreeSession && { + worktree: { + name: worktreeSession.worktreeName, + path: worktreeSession.worktreePath, + branch: worktreeSession.worktreeBranch, + original_cwd: worktreeSession.originalCwd, + original_branch: worktreeSession.originalBranch, + }, + }), + } } type Props = { - messagesRef: React.RefObject; - lastAssistantMessageId: string | null; - vimMode?: unknown; -}; + // messages stays behind a ref (read only in the debounced callback); + // lastAssistantMessageId is the actual re-render trigger. + messagesRef: React.RefObject + lastAssistantMessageId: string | null + vimMode?: VimMode +} export function getLastAssistantMessageId(messages: Message[]): string | null { - return getLastAssistantMessage(messages)?.uuid ?? null; + return getLastAssistantMessage(messages)?.uuid ?? null } -function StatusLineInner({ messagesRef, lastAssistantMessageId }: Props): React.ReactNode { - const mainLoopModel = useMainLoopModel(); - const permissionMode = useAppState(s => s.toolPermissionContext.mode); +function StatusLineInner({ + messagesRef, + lastAssistantMessageId, + vimMode, +}: Props): React.ReactNode { + const abortControllerRef = useRef(undefined) + const permissionMode = useAppState(s => s.toolPermissionContext.mode) + const additionalWorkingDirectories = useAppState( + s => s.toolPermissionContext.additionalWorkingDirectories, + ) + const statusLineText = useAppState(s => s.statusLineText) + const setAppState = useSetAppState() + const settings = useSettings() + const { addNotification } = useNotifications() + // AppState-sourced model — same source as API requests. getMainLoopModel() + // re-reads settings.json on every call, so another session's /model write + // would leak into this session's statusline (anthropics/claude-code#37596). + const mainLoopModel = useMainLoopModel() + + // Keep latest values in refs for stable callback access + const settingsRef = useRef(settings) + settingsRef.current = settings + const vimModeRef = useRef(vimMode) + vimModeRef.current = vimMode + const permissionModeRef = useRef(permissionMode) + permissionModeRef.current = permissionMode + const addedDirsRef = useRef(additionalWorkingDirectories) + addedDirsRef.current = additionalWorkingDirectories + const mainLoopModelRef = useRef(mainLoopModel) + mainLoopModelRef.current = mainLoopModel + + // Track previous state to detect changes and cache expensive calculations + const previousStateRef = useRef<{ + messageId: string | null + exceeds200kTokens: boolean + permissionMode: PermissionMode + vimMode: VimMode | undefined + mainLoopModel: ModelName + }>({ + messageId: null, + exceeds200kTokens: false, + permissionMode, + vimMode, + mainLoopModel, + }) + + // Debounce timer ref + const debounceTimerRef = useRef | undefined>( + undefined, + ) + + // True when the next invocation should log its result (first run or after settings reload) + const logNextResultRef = useRef(true) + + // Stable update function — reads latest values from refs + const doUpdate = useCallback(async () => { + // Cancel any in-flight requests + abortControllerRef.current?.abort() + + const controller = new AbortController() + abortControllerRef.current = controller + + const msgs = messagesRef.current + + const logResult = logNextResultRef.current + logNextResultRef.current = false + + try { + let exceeds200kTokens = previousStateRef.current.exceeds200kTokens + + // Only recalculate 200k check if messages changed + const currentMessageId = getLastAssistantMessageId(msgs) + if (currentMessageId !== previousStateRef.current.messageId) { + exceeds200kTokens = doesMostRecentAssistantMessageExceed200k(msgs) + previousStateRef.current.messageId = currentMessageId + previousStateRef.current.exceeds200kTokens = exceeds200kTokens + } + + const statusInput = buildStatusLineCommandInput( + permissionModeRef.current, + exceeds200kTokens, + settingsRef.current, + msgs, + Array.from(addedDirsRef.current.keys()), + mainLoopModelRef.current, + vimModeRef.current, + ) + + const text = await executeStatusLineCommand( + statusInput, + controller.signal, + undefined, + logResult, + ) + if (!controller.signal.aborted) { + setAppState(prev => { + if (prev.statusLineText === text) return prev + return { ...prev, statusLineText: text } + }) + } + } catch { + // Silently ignore errors in status line updates + } + }, [messagesRef, setAppState]) + + // Stable debounced schedule function — no deps, uses refs + const scheduleUpdate = useCallback(() => { + if (debounceTimerRef.current !== undefined) { + clearTimeout(debounceTimerRef.current) + } + debounceTimerRef.current = setTimeout( + (ref, doUpdate) => { + ref.current = undefined + void doUpdate() + }, + 300, + debounceTimerRef, + doUpdate, + ) + }, [doUpdate]) + + // Only trigger update when assistant message, permission mode, vim mode, or model actually changes + useEffect(() => { + if ( + lastAssistantMessageId !== previousStateRef.current.messageId || + permissionMode !== previousStateRef.current.permissionMode || + vimMode !== previousStateRef.current.vimMode || + mainLoopModel !== previousStateRef.current.mainLoopModel + ) { + // Don't update messageId here — let doUpdate handle it so + // exceeds200kTokens is recalculated with the latest messages + previousStateRef.current.permissionMode = permissionMode + previousStateRef.current.vimMode = vimMode + previousStateRef.current.mainLoopModel = mainLoopModel + scheduleUpdate() + } + }, [ + lastAssistantMessageId, + permissionMode, + vimMode, + mainLoopModel, + scheduleUpdate, + ]) + + // When the statusLine command changes (hot reload), log the next result + const statusLineCommand = settings?.statusLine?.command + const isFirstSettingsRender = useRef(true) + useEffect(() => { + if (isFirstSettingsRender.current) { + isFirstSettingsRender.current = false + return + } + logNextResultRef.current = true + void doUpdate() + }, [statusLineCommand, doUpdate]) + + // Separate effect for logging on mount + useEffect(() => { + const statusLine = settings?.statusLine + if (statusLine) { + logEvent('tengu_status_line_mount', { + command_length: statusLine.command.length, + padding: statusLine.padding, + }) + // Log if status line is configured but disabled by disableAllHooks + if (settings.disableAllHooks === true) { + logForDebugging( + 'Status line is configured but disableAllHooks is true', + { level: 'warn' }, + ) + } + // executeStatusLineCommand (hooks.ts) returns undefined when trust is + // blocked — statusLineText stays undefined forever, user sees nothing, + // and tengu_status_line_mount above fires anyway so telemetry looks fine. + if (!checkHasTrustDialogAccepted()) { + addNotification({ + key: 'statusline-trust-blocked', + text: 'statusline skipped · restart to fix', + color: 'warning', + priority: 'low', + }) + logForDebugging( + 'Status line command skipped: workspace trust not accepted', + { level: 'warn' }, + ) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional + }, []) // Only run once on mount - settings stable for initial logging - const messages = messagesRef.current ?? []; + // Initial update on mount + cleanup on unmount + useEffect(() => { + void doUpdate() - const exceeds200kTokens = lastAssistantMessageId ? doesMostRecentAssistantMessageExceed200k(messages) : false; + return () => { + abortControllerRef.current?.abort() + if (debounceTimerRef.current !== undefined) { + clearTimeout(debounceTimerRef.current) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional + }, []) // Only run once on mount, not when doUpdate changes - const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel, exceeds200kTokens }); - const modelDisplay = renderModelName(runtimeModel); - const currentUsage = getCurrentUsage(messages); - const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas()); - const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize); - const rawUtil = getRawUtilization(); - const totalCost = getTotalCost(); - const usedTokens = getTotalInputTokens() + getTotalOutputTokens(); + // Get padding from settings or default to 0 + const paddingX = settings?.statusLine?.padding ?? 0 + // StatusLine must have stable height in fullscreen — the footer is + // flexShrink:0 so a 0→1 row change when the command finishes steals + // a row from ScrollBox and shifts content. Reserve the row while loading + // (same trick as PromptInputFooterLeftSide). return ( - - ); + + {statusLineText ? ( + + {statusLineText} + + ) : isFullscreenEnvEnabled() ? ( + + ) : null} + + ) } -export const StatusLine = memo(StatusLineInner); +// Parent (PromptInputFooter) re-renders on every setMessages, but StatusLine's +// own props now only change when lastAssistantMessageId flips — memo keeps it +// from being dragged along (previously ~18 no-prop-change renders per session). +export const StatusLine = memo(StatusLineInner) diff --git a/src/components/StatusNotices.tsx b/src/components/StatusNotices.tsx index 9bd9ef599..a62df498e 100644 --- a/src/components/StatusNotices.tsx +++ b/src/components/StatusNotices.tsx @@ -1,54 +1,43 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { use } from 'react'; -import { Box } from '../ink.js'; -import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'; -import { getMemoryFiles } from '../utils/claudemd.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { getActiveNotices, type StatusNoticeContext } from '../utils/statusNoticeDefinitions.js'; +import * as React from 'react' +import { use } from 'react' +import { Box } from '../ink.js' +import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js' +import { getMemoryFiles } from '../utils/claudemd.js' +import { getGlobalConfig } from '../utils/config.js' +import { + getActiveNotices, + type StatusNoticeContext, +} from '../utils/statusNoticeDefinitions.js' + type Props = { - agentDefinitions?: AgentDefinitionsResult; -}; + agentDefinitions?: AgentDefinitionsResult +} /** * StatusNotices contains the information displayed to users at startup. We have * moved neutral or positive status to src/components/Status.tsx instead, which * users can access through /status. */ -export function StatusNotices(t0) { - const $ = _c(4); - const { - agentDefinitions - } = t0 === undefined ? {} : t0; - const t1 = getGlobalConfig(); - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getMemoryFiles(); - $[0] = t2; - } else { - t2 = $[0]; - } - const context = { - config: t1, +export function StatusNotices({ + agentDefinitions, +}: Props = {}): React.ReactNode { + const context: StatusNoticeContext = { + config: getGlobalConfig(), agentDefinitions, - memoryFiles: use(t2) as any - }; - const activeNotices = getActiveNotices(context); - if (activeNotices.length === 0) { - return null; + memoryFiles: use(getMemoryFiles()), } - const T0 = Box; - const t3 = "column"; - const t4 = 1; - const t5 = activeNotices.map(notice => {notice.render(context)}); - let t6; - if ($[1] !== T0 || $[2] !== t5) { - t6 = {t5}; - $[1] = T0; - $[2] = t5; - $[3] = t6; - } else { - t6 = $[3]; + const activeNotices = getActiveNotices(context) + if (activeNotices.length === 0) { + return null } - return t6; + + return ( + + {activeNotices.map(notice => ( + + {notice.render(context)} + + ))} + + ) } diff --git a/src/components/StructuredDiff.tsx b/src/components/StructuredDiff.tsx index fffff72c8..237dfea2b 100644 --- a/src/components/StructuredDiff.tsx +++ b/src/components/StructuredDiff.tsx @@ -1,22 +1,22 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import * as React from 'react'; -import { memo } from 'react'; -import { useSettings } from '../hooks/useSettings.js'; -import { Box, NoSelect, RawAnsi, useTheme } from '../ink.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import sliceAnsi from '../utils/sliceAnsi.js'; -import { expectColorDiff } from './StructuredDiff/colorDiff.js'; -import { StructuredDiffFallback } from './StructuredDiff/Fallback.js'; +import type { StructuredPatchHunk } from 'diff' +import * as React from 'react' +import { memo } from 'react' +import { useSettings } from '../hooks/useSettings.js' +import { Box, NoSelect, RawAnsi, useTheme } from '../ink.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import sliceAnsi from '../utils/sliceAnsi.js' +import { expectColorDiff } from './StructuredDiff/colorDiff.js' +import { StructuredDiffFallback } from './StructuredDiff/Fallback.js' + type Props = { - patch: StructuredPatchHunk; - dim: boolean; - filePath: string; // File path for language detection - firstLine: string | null; // First line of file for shebang detection - fileContent?: string; // Full file content for syntax context (multiline strings, etc.) - width: number; - skipHighlighting?: boolean; // Skip syntax highlighting -}; + patch: StructuredPatchHunk + dim: boolean + filePath: string // File path for language detection + firstLine: string | null // First line of file for shebang detection + fileContent?: string // Full file content for syntax context (multiline strings, etc.) + width: number + skipHighlighting?: boolean // Skip syntax highlighting +} // REPL.tsx renders at two disjoint tree positions (transcript // early-return vs prompt-mode nested in FullscreenLayout), so ctrl+o @@ -30,160 +30,157 @@ type Props = { // reactivating the per-line branch that PR #20378 had bypassed. // Caching the split here restores the O(1)-leaves-per-diff invariant. type CachedRender = { - lines: string[]; + lines: string[] // Two RawAnsi columns replace what was N DiffLine rows. sliceAnsi work // moves from per-remount to cold-cache-only; parseToSpans is eliminated // entirely (RawAnsi bypasses Ansi parsing). - gutterWidth: number; - gutters: string[] | null; - contents: string[] | null; -}; -const RENDER_CACHE = new WeakMap>(); + gutterWidth: number + gutters: string[] | null + contents: string[] | null +} +const RENDER_CACHE = new WeakMap< + StructuredPatchHunk, + Map +>() // Gutter width matches the Rust module's layout: marker (1) + space + // right-aligned line number (max_digits) + space. Depends only on patch // identity (the WeakMap key), so it's cacheable alongside the NAPI output. function computeGutterWidth(patch: StructuredPatchHunk): number { - const maxLineNumber = Math.max(patch.oldStart + patch.oldLines - 1, patch.newStart + patch.newLines - 1, 1); - return maxLineNumber.toString().length + 3; // marker + 2 padding spaces + const maxLineNumber = Math.max( + patch.oldStart + patch.oldLines - 1, + patch.newStart + patch.newLines - 1, + 1, + ) + return maxLineNumber.toString().length + 3 // marker + 2 padding spaces } -function renderColorDiff(patch: StructuredPatchHunk, firstLine: string | null, filePath: string, fileContent: string | null, theme: string, width: number, dim: boolean, splitGutter: boolean): CachedRender | null { - const ColorDiff = expectColorDiff(); - if (!ColorDiff) return null; + +function renderColorDiff( + patch: StructuredPatchHunk, + firstLine: string | null, + filePath: string, + fileContent: string | null, + theme: string, + width: number, + dim: boolean, + splitGutter: boolean, +): CachedRender | null { + const ColorDiff = expectColorDiff() + if (!ColorDiff) return null // Defensive: if the gutter would eat the whole render width (narrow // terminal), skip the split. Rust already wraps to `width` so the // single-column output stays correct; we just lose noSelect. Without // this, sliceAnsi(line, gutterWidth) would return empty content and // RawAnsi(width<=0) is untested. - const rawGutterWidth = splitGutter ? computeGutterWidth(patch) : 0; - const gutterWidth = rawGutterWidth > 0 && rawGutterWidth < width ? rawGutterWidth : 0; - const key = `${theme}|${width}|${dim ? 1 : 0}|${gutterWidth}|${firstLine ?? ''}|${filePath}`; - let perHunk = RENDER_CACHE.get(patch); - const hit = perHunk?.get(key); - if (hit) return hit; - const lines = new ColorDiff(patch, firstLine, filePath, fileContent).render(theme, width, dim); - if (lines === null) return null; + const rawGutterWidth = splitGutter ? computeGutterWidth(patch) : 0 + const gutterWidth = + rawGutterWidth > 0 && rawGutterWidth < width ? rawGutterWidth : 0 + + const key = `${theme}|${width}|${dim ? 1 : 0}|${gutterWidth}|${firstLine ?? ''}|${filePath}` + + let perHunk = RENDER_CACHE.get(patch) + const hit = perHunk?.get(key) + if (hit) return hit + + const lines = new ColorDiff(patch, firstLine, filePath, fileContent).render( + theme, + width, + dim, + ) + if (lines === null) return null // Pre-split the gutter column once (cold-cache). sliceAnsi preserves // styles across the cut; the Rust module already pads the gutter to // gutterWidth so the narrow RawAnsi column's width matches its cells. - let gutters: string[] | null = null; - let contents: string[] | null = null; + let gutters: string[] | null = null + let contents: string[] | null = null if (gutterWidth > 0) { - gutters = lines.map(l => sliceAnsi(l, 0, gutterWidth)); - contents = lines.map(l => sliceAnsi(l, gutterWidth)); + gutters = lines.map(l => sliceAnsi(l, 0, gutterWidth)) + contents = lines.map(l => sliceAnsi(l, gutterWidth)) } - const entry: CachedRender = { - lines, - gutterWidth, - gutters, - contents - }; + + const entry: CachedRender = { lines, gutterWidth, gutters, contents } + if (!perHunk) { - perHunk = new Map(); - RENDER_CACHE.set(patch, perHunk); + perHunk = new Map() + RENDER_CACHE.set(patch, perHunk) } // Cap the inner map: width is part of the key, so terminal resize while a // diff is visible accumulates a full render copy per distinct width. Four // variants (two widths × dim on/off) covers the steady state; beyond that // the user is actively resizing and old widths are stale. - if (perHunk.size >= 4) perHunk.clear(); - perHunk.set(key, entry); - return entry; + if (perHunk.size >= 4) perHunk.clear() + perHunk.set(key, entry) + return entry } -export const StructuredDiff = memo(function StructuredDiff(t0: Props) { - const $ = _c(26); - const { - patch, - dim, - filePath, - firstLine, - fileContent, - width, - skipHighlighting: t1 - } = t0; - const skipHighlighting = t1 === undefined ? false : t1; - const [theme] = useTheme(); - const settings = useSettings(); - const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false; - const safeWidth = Math.max(1, Math.floor(width)); - let t2; - if ($[0] !== dim || $[1] !== fileContent || $[2] !== filePath || $[3] !== firstLine || $[4] !== patch || $[5] !== safeWidth || $[6] !== skipHighlighting || $[7] !== syntaxHighlightingDisabled || $[8] !== theme) { - const splitGutter = isFullscreenEnvEnabled(); - t2 = skipHighlighting || syntaxHighlightingDisabled ? null : renderColorDiff(patch, firstLine, filePath, fileContent ?? null, theme, safeWidth, dim, splitGutter); - $[0] = dim; - $[1] = fileContent; - $[2] = filePath; - $[3] = firstLine; - $[4] = patch; - $[5] = safeWidth; - $[6] = skipHighlighting; - $[7] = syntaxHighlightingDisabled; - $[8] = theme; - $[9] = t2; - } else { - t2 = $[9]; - } - const cached = t2; + +export const StructuredDiff = memo(function StructuredDiff({ + patch, + dim, + filePath, + firstLine, + fileContent, + width, + skipHighlighting = false, +}: Props): React.ReactNode { + const [theme] = useTheme() + const settings = useSettings() + const syntaxHighlightingDisabled = + settings.syntaxHighlightingDisabled ?? false + + // Ensure width is at least 1 to prevent crashes in the Rust NAPI module + // which expects u32 (can't handle negative numbers) + const safeWidth = Math.max(1, Math.floor(width)) + + // Only split out a noSelect gutter in fullscreen mode — terminal native + // selection is used otherwise and noSelect is meaningless. Both branches + // are now O(1) Yoga leaves per diff on remount (2 vs 1), so this gate + // only saves cold-cache sliceAnsi work when fullscreen is off. + const splitGutter = isFullscreenEnvEnabled() + + const cached = + skipHighlighting || syntaxHighlightingDisabled + ? null + : renderColorDiff( + patch, + firstLine, + filePath, + fileContent ?? null, + theme, + safeWidth, + dim, + splitGutter, + ) + if (!cached) { - let t3; - if ($[10] !== dim || $[11] !== patch || $[12] !== width) { - t3 = ; - $[10] = dim; - $[11] = patch; - $[12] = width; - $[13] = t3; - } else { - t3 = $[13]; - } - return t3; + return ( + + + + ) } - const { - lines, - gutterWidth, - gutters, - contents - } = cached; + + const { lines, gutterWidth, gutters, contents } = cached + + // Two-column layout: gutter (noSelect) + content. NoSelect marks the + // Box's computed bounds non-selectable; RawAnsi's measure func sets + // rawHeight=lines.length, so one tall leaf gets the same noSelect + // coverage N per-row Boxes would — without the per-row Yoga cost. if (gutterWidth > 0 && gutters && contents) { - let t3; - if ($[14] !== gutterWidth || $[15] !== gutters) { - t3 = ; - $[14] = gutterWidth; - $[15] = gutters; - $[16] = t3; - } else { - t3 = $[16]; - } - const t4 = safeWidth - gutterWidth; - let t5; - if ($[17] !== contents || $[18] !== t4) { - t5 = ; - $[17] = contents; - $[18] = t4; - $[19] = t5; - } else { - t5 = $[19]; - } - let t6; - if ($[20] !== t3 || $[21] !== t5) { - t6 = {t3}{t5}; - $[20] = t3; - $[21] = t5; - $[22] = t6; - } else { - t6 = $[22]; - } - return t6; + return ( + + + + + + + ) } - let t3; - if ($[23] !== lines || $[24] !== safeWidth) { - t3 = ; - $[23] = lines; - $[24] = safeWidth; - $[25] = t3; - } else { - t3 = $[25]; - } - return t3; -}); + + return ( + + + + ) +}) diff --git a/src/components/StructuredDiff/Fallback.tsx b/src/components/StructuredDiff/Fallback.tsx index 945911dcf..335391e0a 100644 --- a/src/components/StructuredDiff/Fallback.tsx +++ b/src/components/StructuredDiff/Fallback.tsx @@ -1,10 +1,9 @@ -import { c as _c } from "react/compiler-runtime"; -import { diffWordsWithSpace, type StructuredPatchHunk } from 'diff'; -import * as React from 'react'; -import { useMemo } from 'react'; -import type { ThemeName } from 'src/utils/theme.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, NoSelect, Text, useTheme, wrapText } from '../../ink.js'; +import { diffWordsWithSpace, type StructuredPatchHunk } from 'diff' +import * as React from 'react' +import { useMemo } from 'react' +import type { ThemeName } from 'src/utils/theme.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, NoSelect, Text, useTheme, wrapText } from '../../ink.js' /* * StructuredDiffFallback Component: Word-Level Diff Highlighting Example @@ -46,82 +45,61 @@ import { Box, NoSelect, Text, useTheme, wrapText } from '../../ink.js'; // Define DiffLine interface to be used throughout the file interface DiffLine { - code: string; - type: 'add' | 'remove' | 'nochange'; - i: number; - originalCode: string; - wordDiff?: boolean; // Flag for word-level diffing - matchedLine?: DiffLine; + code: string + type: 'add' | 'remove' | 'nochange' + i: number + originalCode: string + wordDiff?: boolean // Flag for word-level diffing + matchedLine?: DiffLine } // Line object type for internal functions export interface LineObject { - code: string; - i: number; - type: 'add' | 'remove' | 'nochange'; - originalCode: string; - wordDiff?: boolean; - matchedLine?: LineObject; + code: string + i: number + type: 'add' | 'remove' | 'nochange' + originalCode: string + wordDiff?: boolean + matchedLine?: LineObject } // Type for word-level diff parts interface DiffPart { - added?: boolean; - removed?: boolean; - value: string; + added?: boolean + removed?: boolean + value: string } + type Props = { - patch: StructuredPatchHunk; - dim: boolean; - width: number; -}; + patch: StructuredPatchHunk + dim: boolean + width: number +} // Threshold for when we show a full-line diff instead of word-level diffing -const CHANGE_THRESHOLD = 0.4; -export function StructuredDiffFallback(t0) { - const $ = _c(10); - const { - patch, - dim, - width - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] !== dim || $[1] !== patch.lines || $[2] !== patch.oldStart || $[3] !== theme || $[4] !== width) { - t1 = formatDiff(patch.lines, patch.oldStart, width, dim, theme); - $[0] = dim; - $[1] = patch.lines; - $[2] = patch.oldStart; - $[3] = theme; - $[4] = width; - $[5] = t1; - } else { - t1 = $[5]; - } - const diff = t1; - let t2; - if ($[6] !== diff) { - t2 = diff.map(_temp); - $[6] = diff; - $[7] = t2; - } else { - t2 = $[7]; - } - let t3; - if ($[8] !== t2) { - t3 = {t2}; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - return t3; +const CHANGE_THRESHOLD = 0.4 + +export function StructuredDiffFallback({ + patch, + dim, + width, +}: Props): React.ReactNode { + const [theme] = useTheme() + const diff = useMemo( + () => formatDiff(patch.lines, patch.oldStart, width, dim, theme), + [patch.lines, patch.oldStart, width, dim, theme], + ) + + return ( + + {diff.map((node, i) => ( + {node} + ))} + + ) } // Transform lines to line objects with type information -function _temp(node, i) { - return {node}; -} export function transformLinesToObjects(lines: string[]): LineObject[] { return lines.map(code => { if (code.startsWith('+')) { @@ -129,358 +107,428 @@ export function transformLinesToObjects(lines: string[]): LineObject[] { code: code.slice(1), i: 0, type: 'add', - originalCode: code.slice(1) - }; + originalCode: code.slice(1), + } } if (code.startsWith('-')) { return { code: code.slice(1), i: 0, type: 'remove', - originalCode: code.slice(1) - }; + originalCode: code.slice(1), + } } return { code: code.slice(1), i: 0, type: 'nochange', - originalCode: code.slice(1) - }; - }); + originalCode: code.slice(1), + } + }) } // Group adjacent add/remove lines for word-level diffing export function processAdjacentLines(lineObjects: LineObject[]): LineObject[] { - const processedLines: LineObject[] = []; - let i = 0; + const processedLines: LineObject[] = [] + let i = 0 + while (i < lineObjects.length) { - const current = lineObjects[i]; + const current = lineObjects[i] if (!current) { - i++; - continue; + i++ + continue } // Find a sequence of remove followed by add (possible word-level diff candidates) if (current.type === 'remove') { - const removeLines: LineObject[] = [current]; - let j = i + 1; + const removeLines: LineObject[] = [current] + let j = i + 1 // Collect consecutive remove lines while (j < lineObjects.length && lineObjects[j]?.type === 'remove') { - const line = lineObjects[j]; + const line = lineObjects[j] if (line) { - removeLines.push(line); + removeLines.push(line) } - j++; + j++ } // Check if there are add lines following the remove lines - const addLines: LineObject[] = []; + const addLines: LineObject[] = [] while (j < lineObjects.length && lineObjects[j]?.type === 'add') { - const line = lineObjects[j]; + const line = lineObjects[j] if (line) { - addLines.push(line); + addLines.push(line) } - j++; + j++ } // If we have both remove and add lines, perform word-level diffing if (removeLines.length > 0 && addLines.length > 0) { // For word diffing, we'll compare each pair of lines or the closest available match - const pairCount = Math.min(removeLines.length, addLines.length); + const pairCount = Math.min(removeLines.length, addLines.length) // Add paired lines with word diff info for (let k = 0; k < pairCount; k++) { - const removeLine = removeLines[k]; - const addLine = addLines[k]; + const removeLine = removeLines[k] + const addLine = addLines[k] + if (removeLine && addLine) { - removeLine.wordDiff = true; - addLine.wordDiff = true; + removeLine.wordDiff = true + addLine.wordDiff = true // Store the matched pair for later word diffing - removeLine.matchedLine = addLine; - addLine.matchedLine = removeLine; + removeLine.matchedLine = addLine + addLine.matchedLine = removeLine } } // Add all remove lines (both paired and unpaired) - processedLines.push(...removeLines.filter(Boolean)); + processedLines.push(...removeLines.filter(Boolean)) // Then add all add lines (both paired and unpaired) - processedLines.push(...addLines.filter(Boolean)); - i = j; // Skip all the lines we've processed + processedLines.push(...addLines.filter(Boolean)) + + i = j // Skip all the lines we've processed } else { // No matching add lines, just add the current remove line - processedLines.push(current); - i++; + processedLines.push(current) + i++ } } else { // Not a remove line, just add it - processedLines.push(current); - i++; + processedLines.push(current) + i++ } } - return processedLines; + + return processedLines } // Calculate word-level diffs between two text strings -export function calculateWordDiffs(oldText: string, newText: string): DiffPart[] { +export function calculateWordDiffs( + oldText: string, + newText: string, +): DiffPart[] { // Use diffWordsWithSpace instead of diffWords to preserve whitespace // This ensures spaces between tokens like > and { are preserved - const result = diffWordsWithSpace(oldText, newText, { - ignoreCase: false - }); - return result; + const result = diffWordsWithSpace(oldText, newText, { ignoreCase: false }) + + return result } // Process word-level diffs with manual wrapping support -function generateWordDiffElements(item: DiffLine, width: number, maxWidth: number, dim: boolean, overrideTheme?: ThemeName): React.ReactNode[] | null { - const { - type, - i, - wordDiff, - matchedLine, - originalCode - } = item; +function generateWordDiffElements( + item: DiffLine, + width: number, + maxWidth: number, + dim: boolean, + overrideTheme?: ThemeName, +): React.ReactNode[] | null { + const { type, i, wordDiff, matchedLine, originalCode } = item + if (!wordDiff || !matchedLine) { - return null; // This function only handles word-level diff rendering + return null // This function only handles word-level diff rendering } - const removedLineText = type === 'remove' ? originalCode : matchedLine.originalCode; - const addedLineText = type === 'remove' ? matchedLine.originalCode : originalCode; - const wordDiffs = calculateWordDiffs(removedLineText, addedLineText); + + const removedLineText = + type === 'remove' ? originalCode : matchedLine.originalCode + const addedLineText = + type === 'remove' ? matchedLine.originalCode : originalCode + + const wordDiffs = calculateWordDiffs(removedLineText, addedLineText) // Check if we should use word-level diffing - const totalLength = removedLineText.length + addedLineText.length; - const changedLength = wordDiffs.filter(part => part.added || part.removed).reduce((sum, part) => sum + part.value.length, 0); - const changeRatio = changedLength / totalLength; + const totalLength = removedLineText.length + addedLineText.length + const changedLength = wordDiffs + .filter(part => part.added || part.removed) + .reduce((sum, part) => sum + part.value.length, 0) + const changeRatio = changedLength / totalLength + if (changeRatio > CHANGE_THRESHOLD || dim) { - return null; // Fall back to standard rendering for major changes + return null // Fall back to standard rendering for major changes } // Calculate available width for content - const diffPrefix = type === 'add' ? '+' : '-'; - const diffPrefixWidth = diffPrefix.length; - const availableContentWidth = Math.max(1, width - maxWidth - 1 - diffPrefixWidth); + const diffPrefix = type === 'add' ? '+' : '-' + const diffPrefixWidth = diffPrefix.length + const availableContentWidth = Math.max( + 1, + width - maxWidth - 1 - diffPrefixWidth, + ) // Manually wrap the word diff parts with better space efficiency - const wrappedLines: { - content: React.ReactNode[]; - contentWidth: number; - }[] = []; - let currentLine: React.ReactNode[] = []; - let currentLineWidth = 0; + const wrappedLines: { content: React.ReactNode[]; contentWidth: number }[] = + [] + let currentLine: React.ReactNode[] = [] + let currentLineWidth = 0 + wordDiffs.forEach((part, partIndex) => { // Determine if this part should be shown for this line type - let shouldShow = false; - let partBgColor: 'diffAddedWord' | 'diffRemovedWord' | undefined; + let shouldShow = false + let partBgColor: 'diffAddedWord' | 'diffRemovedWord' | undefined + if (type === 'add') { if (part.added) { - shouldShow = true; - partBgColor = 'diffAddedWord'; + shouldShow = true + partBgColor = 'diffAddedWord' } else if (!part.removed) { - shouldShow = true; + shouldShow = true } } else if (type === 'remove') { if (part.removed) { - shouldShow = true; - partBgColor = 'diffRemovedWord'; + shouldShow = true + partBgColor = 'diffRemovedWord' } else if (!part.added) { - shouldShow = true; + shouldShow = true } } - if (!shouldShow) return; + + if (!shouldShow) return // Use wrapText to wrap this individual part if it's long - const partWrapped = wrapText(part.value, availableContentWidth, 'wrap'); - const partLines = partWrapped.split('\n'); + const partWrapped = wrapText(part.value, availableContentWidth, 'wrap') + const partLines = partWrapped.split('\n') + partLines.forEach((partLine, lineIdx) => { - if (!partLine) return; + if (!partLine) return // Check if we need to start a new line - if (lineIdx > 0 || currentLineWidth + stringWidth(partLine) > availableContentWidth) { + if ( + lineIdx > 0 || + currentLineWidth + stringWidth(partLine) > availableContentWidth + ) { if (currentLine.length > 0) { wrappedLines.push({ content: [...currentLine], - contentWidth: currentLineWidth - }); - currentLine = []; - currentLineWidth = 0; + contentWidth: currentLineWidth, + }) + currentLine = [] + currentLineWidth = 0 } } - currentLine.push( + + currentLine.push( + {partLine} - ); - currentLineWidth += stringWidth(partLine); - }); - }); + , + ) + + currentLineWidth += stringWidth(partLine) + }) + }) + if (currentLine.length > 0) { - wrappedLines.push({ - content: currentLine, - contentWidth: currentLineWidth - }); + wrappedLines.push({ content: currentLine, contentWidth: currentLineWidth }) } // Render each wrapped line as a separate Text element - return wrappedLines.map(({ - content, - contentWidth - }, lineIndex) => { - const key = `${type}-${i}-${lineIndex}`; - const lineBgColor = type === 'add' ? dim ? 'diffAddedDimmed' : 'diffAdded' : dim ? 'diffRemovedDimmed' : 'diffRemoved'; - const lineNum = lineIndex === 0 ? i : undefined; - const lineNumStr = (lineNum !== undefined ? lineNum.toString().padStart(maxWidth) : ' '.repeat(maxWidth)) + ' '; + return wrappedLines.map(({ content, contentWidth }, lineIndex) => { + const key = `${type}-${i}-${lineIndex}` + const lineBgColor = + type === 'add' + ? dim + ? 'diffAddedDimmed' + : 'diffAdded' + : dim + ? 'diffRemovedDimmed' + : 'diffRemoved' + const lineNum = lineIndex === 0 ? i : undefined + const lineNumStr = + (lineNum !== undefined + ? lineNum.toString().padStart(maxWidth) + : ' '.repeat(maxWidth)) + ' ' // Calculate padding to fill the entire terminal width - const usedWidth = lineNumStr.length + diffPrefixWidth + contentWidth; - const padding = Math.max(0, width - usedWidth); - return + const usedWidth = lineNumStr.length + diffPrefixWidth + contentWidth + const padding = Math.max(0, width - usedWidth) + + return ( + - + {lineNumStr} {diffPrefix} - + {content} {' '.repeat(padding)} - ; - }); + + ) + }) } -function formatDiff(lines: string[], startingLineNumber: number, width: number, dim: boolean, overrideTheme?: ThemeName): React.ReactNode[] { + +function formatDiff( + lines: string[], + startingLineNumber: number, + width: number, + dim: boolean, + overrideTheme?: ThemeName, +): React.ReactNode[] { // Ensure width is at least 1 to prevent rendering issues with very narrow terminals - const safeWidth = Math.max(1, Math.floor(width)); + const safeWidth = Math.max(1, Math.floor(width)) // Step 1: Transform lines to line objects with type information - const lineObjects = transformLinesToObjects(lines); + const lineObjects = transformLinesToObjects(lines) // Step 2: Group adjacent add/remove lines for word-level diffing - const processedLines = processAdjacentLines(lineObjects); + const processedLines = processAdjacentLines(lineObjects) // Step 3: Number the diff lines - const ls = numberDiffLines(processedLines, startingLineNumber); + const ls = numberDiffLines(processedLines, startingLineNumber) // Find max line number width for alignment - const maxLineNumber = Math.max(...ls.map(({ - i - }) => i), 0); - const maxWidth = Math.max(maxLineNumber.toString().length + 1, 0); + const maxLineNumber = Math.max(...ls.map(({ i }) => i), 0) + const maxWidth = Math.max(maxLineNumber.toString().length + 1, 0) // Step 4: Render formatting return ls.flatMap((item): React.ReactNode[] => { - const { - type, - code, - i, - wordDiff, - matchedLine - } = item; + const { type, code, i, wordDiff, matchedLine } = item // Handle word-level diffing for add/remove pairs if (wordDiff && matchedLine) { - const wordDiffElements = generateWordDiffElements(item, safeWidth, maxWidth, dim, overrideTheme); + const wordDiffElements = generateWordDiffElements( + item, + safeWidth, + maxWidth, + dim, + overrideTheme, + ) // word-diff might refuse (e.g. due to lines being substantially different) in which // case we'll fall through to normal renderin gbelow if (wordDiffElements !== null) { - return wordDiffElements; + return wordDiffElements } } // Standard rendering for lines without word diffing or as fallback // Calculate available width accounting for line number + space + diff prefix - const diffPrefixWidth = 2; // " " for unchanged, "+ " or "- " for changes - const availableContentWidth = Math.max(1, safeWidth - maxWidth - 1 - diffPrefixWidth); // -1 for space after line number - const wrappedText = wrapText(code, availableContentWidth, 'wrap'); - const wrappedLines = wrappedText.split('\n'); + const diffPrefixWidth = 2 // " " for unchanged, "+ " or "- " for changes + const availableContentWidth = Math.max( + 1, + safeWidth - maxWidth - 1 - diffPrefixWidth, + ) // -1 for space after line number + const wrappedText = wrapText(code, availableContentWidth, 'wrap') + const wrappedLines = wrappedText.split('\n') + return wrappedLines.map((line, lineIndex) => { - const key = `${type}-${i}-${lineIndex}`; - const lineNum = lineIndex === 0 ? i : undefined; - const lineNumStr = (lineNum !== undefined ? lineNum.toString().padStart(maxWidth) : ' '.repeat(maxWidth)) + ' '; - const sigil = type === 'add' ? '+' : type === 'remove' ? '-' : ' '; + const key = `${type}-${i}-${lineIndex}` + const lineNum = lineIndex === 0 ? i : undefined + const lineNumStr = + (lineNum !== undefined + ? lineNum.toString().padStart(maxWidth) + : ' '.repeat(maxWidth)) + ' ' + const sigil = type === 'add' ? '+' : type === 'remove' ? '-' : ' ' // Calculate padding to fill the entire terminal width - const contentWidth = lineNumStr.length + 1 + stringWidth(line); // lineNum + sigil + code - const padding = Math.max(0, safeWidth - contentWidth); - const bgColor = type === 'add' ? dim ? 'diffAddedDimmed' : 'diffAdded' : type === 'remove' ? dim ? 'diffRemovedDimmed' : 'diffRemoved' : undefined; + const contentWidth = lineNumStr.length + 1 + stringWidth(line) // lineNum + sigil + code + const padding = Math.max(0, safeWidth - contentWidth) + + const bgColor = + type === 'add' + ? dim + ? 'diffAddedDimmed' + : 'diffAdded' + : type === 'remove' + ? dim + ? 'diffRemovedDimmed' + : 'diffRemoved' + : undefined // Gutter (line number + sigil) is wrapped in so fullscreen // text selection yields clean code. bgColor carries across both boxes // so the visual continuity (solid red/green bar) is unchanged. - return + return ( + - + {lineNumStr} {sigil} - + {line} {' '.repeat(padding)} - ; - }); - }); + + ) + }) + }) } -export function numberDiffLines(diff: LineObject[], startLine: number): DiffLine[] { - let i = startLine; - const result: DiffLine[] = []; - const queue = [...diff]; + +export function numberDiffLines( + diff: LineObject[], + startLine: number, +): DiffLine[] { + let i = startLine + const result: DiffLine[] = [] + const queue = [...diff] + while (queue.length > 0) { - const current = queue.shift()!; - const { - code, - type, - originalCode, - wordDiff, - matchedLine - } = current; + const current = queue.shift()! + const { code, type, originalCode, wordDiff, matchedLine } = current const line = { code, type, i, originalCode, wordDiff, - matchedLine - }; + matchedLine, + } // Update counters based on change type switch (type) { case 'nochange': - i++; - result.push(line); - break; + i++ + result.push(line) + break case 'add': - i++; - result.push(line); - break; - case 'remove': - { - result.push(line); - let numRemoved = 0; - while (queue[0]?.type === 'remove') { - i++; - const current = queue.shift()!; - const { - code, - type, - originalCode, - wordDiff, - matchedLine - } = current; - const line = { - code, - type, - i, - originalCode, - wordDiff, - matchedLine - }; - result.push(line); - numRemoved++; + i++ + result.push(line) + break + case 'remove': { + result.push(line) + let numRemoved = 0 + while (queue[0]?.type === 'remove') { + i++ + const current = queue.shift()! + const { code, type, originalCode, wordDiff, matchedLine } = current + const line = { + code, + type, + i, + originalCode, + wordDiff, + matchedLine, } - i -= numRemoved; - break; + result.push(line) + numRemoved++ } + i -= numRemoved + break + } } } - return result; + + return result } diff --git a/src/components/StructuredDiffList.tsx b/src/components/StructuredDiffList.tsx index c31117dc6..af0eeeb02 100644 --- a/src/components/StructuredDiffList.tsx +++ b/src/components/StructuredDiffList.tsx @@ -1,16 +1,17 @@ -import type { StructuredPatchHunk } from 'diff'; -import * as React from 'react'; -import { Box, NoSelect, Text } from '../ink.js'; -import { intersperse } from '../utils/array.js'; -import { StructuredDiff } from './StructuredDiff.js'; +import type { StructuredPatchHunk } from 'diff' +import * as React from 'react' +import { Box, NoSelect, Text } from '../ink.js' +import { intersperse } from '../utils/array.js' +import { StructuredDiff } from './StructuredDiff.js' + type Props = { - hunks: StructuredPatchHunk[]; - dim: boolean; - width: number; - filePath: string; - firstLine: string | null; - fileContent?: string; -}; + hunks: StructuredPatchHunk[] + dim: boolean + width: number + filePath: string + firstLine: string | null + fileContent?: string +} /** Renders a list of diff hunks with ellipsis separators between them. */ export function StructuredDiffList({ @@ -19,11 +20,25 @@ export function StructuredDiffList({ width, filePath, firstLine, - fileContent + fileContent, }: Props): React.ReactNode { - return intersperse(hunks.map(hunk => - - ), i => + return intersperse( + hunks.map(hunk => ( + + + + )), + i => ( + ... - ); + + ), + ) } diff --git a/src/components/TagTabs.tsx b/src/components/TagTabs.tsx index 49217a512..786a1f81d 100644 --- a/src/components/TagTabs.tsx +++ b/src/components/TagTabs.tsx @@ -1,40 +1,46 @@ -import React from 'react'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import { truncateToWidth } from '../utils/format.js'; +import React from 'react' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import { truncateToWidth } from '../utils/format.js' // Constants for width calculations - derived from actual rendered strings -const ALL_TAB_LABEL = 'All'; -const TAB_PADDING = 2; // Space before and after tab text: " {tab} " -const HASH_PREFIX_LENGTH = 1; // "#" prefix for non-All tabs -const LEFT_ARROW_PREFIX = '← '; -const RIGHT_HINT_WITH_COUNT_PREFIX = '→'; -const RIGHT_HINT_SUFFIX = ' (tab to cycle)'; -const RIGHT_HINT_NO_COUNT = '(tab to cycle)'; -const MAX_OVERFLOW_DIGITS = 2; // Assume max 99 hidden tabs for width calculation +const ALL_TAB_LABEL = 'All' +const TAB_PADDING = 2 // Space before and after tab text: " {tab} " +const HASH_PREFIX_LENGTH = 1 // "#" prefix for non-All tabs +const LEFT_ARROW_PREFIX = '← ' +const RIGHT_HINT_WITH_COUNT_PREFIX = '→' +const RIGHT_HINT_SUFFIX = ' (tab to cycle)' +const RIGHT_HINT_NO_COUNT = '(tab to cycle)' +const MAX_OVERFLOW_DIGITS = 2 // Assume max 99 hidden tabs for width calculation // Computed widths -const LEFT_ARROW_WIDTH = LEFT_ARROW_PREFIX.length + MAX_OVERFLOW_DIGITS + 1; // "← NN " with gap -const RIGHT_HINT_WIDTH_WITH_COUNT = RIGHT_HINT_WITH_COUNT_PREFIX.length + MAX_OVERFLOW_DIGITS + RIGHT_HINT_SUFFIX.length; // "→NN (tab to cycle)" -const RIGHT_HINT_WIDTH_NO_COUNT = RIGHT_HINT_NO_COUNT.length; +const LEFT_ARROW_WIDTH = LEFT_ARROW_PREFIX.length + MAX_OVERFLOW_DIGITS + 1 // "← NN " with gap +const RIGHT_HINT_WIDTH_WITH_COUNT = + RIGHT_HINT_WITH_COUNT_PREFIX.length + + MAX_OVERFLOW_DIGITS + + RIGHT_HINT_SUFFIX.length // "→NN (tab to cycle)" +const RIGHT_HINT_WIDTH_NO_COUNT = RIGHT_HINT_NO_COUNT.length + type Props = { - tabs: string[]; - selectedIndex: number; - availableWidth: number; - showAllProjects?: boolean; -}; + tabs: string[] + selectedIndex: number + availableWidth: number + showAllProjects?: boolean +} /** * Calculate the display width of a tab */ function getTabWidth(tab: string, maxWidth?: number): number { if (tab === ALL_TAB_LABEL) { - return ALL_TAB_LABEL.length + TAB_PADDING; + return ALL_TAB_LABEL.length + TAB_PADDING } // For non-All tabs: " #{tag} " but truncate tag if needed - const tagWidth = stringWidth(tab); - const effectiveTagWidth = maxWidth ? Math.min(tagWidth, maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH) : tagWidth; - return Math.max(0, effectiveTagWidth) + TAB_PADDING + HASH_PREFIX_LENGTH; + const tagWidth = stringWidth(tab) + const effectiveTagWidth = maxWidth + ? Math.min(tagWidth, maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH) + : tagWidth + return Math.max(0, effectiveTagWidth) + TAB_PADDING + HASH_PREFIX_LENGTH } /** @@ -42,97 +48,130 @@ function getTabWidth(tab: string, maxWidth?: number): number { */ function truncateTag(tag: string, maxWidth: number): string { // Available space for the tag text itself: maxWidth - " #" - " " - const availableForTag = maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH; + const availableForTag = maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH if (stringWidth(tag) <= availableForTag) { - return tag; + return tag } if (availableForTag <= 1) { - return tag.charAt(0); + return tag.charAt(0) } - return truncateToWidth(tag, availableForTag); + return truncateToWidth(tag, availableForTag) } + export function TagTabs({ tabs, selectedIndex, availableWidth, - showAllProjects = false + showAllProjects = false, }: Props): React.ReactNode { - const resumeLabel = showAllProjects ? 'Resume (All Projects)' : 'Resume'; - const resumeLabelWidth = resumeLabel.length + 1; // +1 for gap + const resumeLabel = showAllProjects ? 'Resume (All Projects)' : 'Resume' + const resumeLabelWidth = resumeLabel.length + 1 // +1 for gap // Calculate how much space we have for tabs (use worst-case hint width) - const rightHintWidth = Math.max(RIGHT_HINT_WIDTH_WITH_COUNT, RIGHT_HINT_WIDTH_NO_COUNT); - const maxTabsWidth = availableWidth - resumeLabelWidth - rightHintWidth - 2; // 2 for gaps + const rightHintWidth = Math.max( + RIGHT_HINT_WIDTH_WITH_COUNT, + RIGHT_HINT_WIDTH_NO_COUNT, + ) + const maxTabsWidth = availableWidth - resumeLabelWidth - rightHintWidth - 2 // 2 for gaps // Clamp selectedIndex to valid range - const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, tabs.length - 1)); + const safeSelectedIndex = Math.max( + 0, + Math.min(selectedIndex, tabs.length - 1), + ) // Calculate width of each tab, with truncation for very long tags - const maxSingleTabWidth = Math.max(20, Math.floor(maxTabsWidth / 2)); // At least show half the space for one tab - const tabWidths = tabs.map(tab => getTabWidth(tab, maxSingleTabWidth)); + const maxSingleTabWidth = Math.max(20, Math.floor(maxTabsWidth / 2)) // At least show half the space for one tab + const tabWidths = tabs.map(tab => getTabWidth(tab, maxSingleTabWidth)) // Find a window of tabs that fits, centered around selectedIndex - let startIndex = 0; - let endIndex = tabs.length; + let startIndex = 0 + let endIndex = tabs.length // Calculate total width of all tabs - const totalTabsWidth = tabWidths.reduce((sum, w, i) => sum + w + (i < tabWidths.length - 1 ? 1 : 0), 0); // +1 for gaps between tabs + const totalTabsWidth = tabWidths.reduce( + (sum, w, i) => sum + w + (i < tabWidths.length - 1 ? 1 : 0), + 0, + ) // +1 for gaps between tabs if (totalTabsWidth > maxTabsWidth) { // Need to show a subset - account for left arrow when not at start - const effectiveMaxWidth = maxTabsWidth - LEFT_ARROW_WIDTH; + const effectiveMaxWidth = maxTabsWidth - LEFT_ARROW_WIDTH // Start with the selected tab - let windowWidth = tabWidths[safeSelectedIndex] ?? 0; - startIndex = safeSelectedIndex; - endIndex = safeSelectedIndex + 1; + let windowWidth = tabWidths[safeSelectedIndex] ?? 0 + startIndex = safeSelectedIndex + endIndex = safeSelectedIndex + 1 // Expand window to include more tabs while (startIndex > 0 || endIndex < tabs.length) { - const canExpandLeft = startIndex > 0; - const canExpandRight = endIndex < tabs.length; + const canExpandLeft = startIndex > 0 + const canExpandRight = endIndex < tabs.length + if (canExpandLeft) { - const leftWidth = (tabWidths[startIndex - 1] ?? 0) + 1; // +1 for gap + const leftWidth = (tabWidths[startIndex - 1] ?? 0) + 1 // +1 for gap if (windowWidth + leftWidth <= effectiveMaxWidth) { - startIndex--; - windowWidth += leftWidth; - continue; + startIndex-- + windowWidth += leftWidth + continue } } + if (canExpandRight) { - const rightWidth = (tabWidths[endIndex] ?? 0) + 1; // +1 for gap + const rightWidth = (tabWidths[endIndex] ?? 0) + 1 // +1 for gap if (windowWidth + rightWidth <= effectiveMaxWidth) { - endIndex++; - windowWidth += rightWidth; - continue; + endIndex++ + windowWidth += rightWidth + continue } } - break; + + break } } - const hiddenLeft = startIndex; - const hiddenRight = tabs.length - endIndex; - const visibleTabs = tabs.slice(startIndex, endIndex); - const visibleIndices = visibleTabs.map((_, i_0) => startIndex + i_0); - return + + const hiddenLeft = startIndex + const hiddenRight = tabs.length - endIndex + const visibleTabs = tabs.slice(startIndex, endIndex) + const visibleIndices = visibleTabs.map((_, i) => startIndex + i) + + return ( + {resumeLabel} - {hiddenLeft > 0 && + {hiddenLeft > 0 && ( + {LEFT_ARROW_PREFIX} {hiddenLeft} - } - {visibleTabs.map((tab_0, i_1) => { - const actualIndex = visibleIndices[i_1]!; - const isSelected = actualIndex === safeSelectedIndex; - const displayText = tab_0 === ALL_TAB_LABEL ? tab_0 : `#${truncateTag(tab_0, maxSingleTabWidth - TAB_PADDING)}`; - return + + )} + {visibleTabs.map((tab, i) => { + const actualIndex = visibleIndices[i]! + const isSelected = actualIndex === safeSelectedIndex + const displayText = + tab === ALL_TAB_LABEL + ? tab + : `#${truncateTag(tab, maxSingleTabWidth - TAB_PADDING)}` + return ( + {' '} {displayText}{' '} - ; - })} - {hiddenRight > 0 ? + + ) + })} + {hiddenRight > 0 ? ( + {RIGHT_HINT_WITH_COUNT_PREFIX} {hiddenRight} {RIGHT_HINT_SUFFIX} - : {RIGHT_HINT_NO_COUNT}} - ; + + ) : ( + {RIGHT_HINT_NO_COUNT} + )} + + ) } diff --git a/src/components/TaskListV2.tsx b/src/components/TaskListV2.tsx index 1fab71045..d9d4dfa04 100644 --- a/src/components/TaskListV2.tsx +++ b/src/components/TaskListV2.tsx @@ -1,103 +1,117 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import { useAppState } from '../state/AppState.js'; -import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; -import { AGENT_COLOR_TO_THEME_COLOR, type AgentColorName } from '../tools/AgentTool/agentColorManager.js'; -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; -import { count } from '../utils/array.js'; -import { summarizeRecentActivities } from '../utils/collapseReadSearch.js'; -import { truncateToWidth } from '../utils/format.js'; -import { isTodoV2Enabled, type Task } from '../utils/tasks.js'; -import type { Theme } from '../utils/theme.js'; -import ThemedText from './design-system/ThemedText.js'; +import figures from 'figures' +import * as React from 'react' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import { useAppState } from '../state/AppState.js' +import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + type AgentColorName, +} from '../tools/AgentTool/agentColorManager.js' +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' +import { count } from '../utils/array.js' +import { summarizeRecentActivities } from '../utils/collapseReadSearch.js' +import { truncateToWidth } from '../utils/format.js' +import { isTodoV2Enabled, type Task } from '../utils/tasks.js' +import type { Theme } from '../utils/theme.js' +import ThemedText from './design-system/ThemedText.js' + type Props = { - tasks: Task[]; - isStandalone?: boolean; -}; -const RECENT_COMPLETED_TTL_MS = 30_000; + tasks: Task[] + isStandalone?: boolean +} + +const RECENT_COMPLETED_TTL_MS = 30_000 + function byIdAsc(a: Task, b: Task): number { - const aNum = parseInt(a.id, 10); - const bNum = parseInt(b.id, 10); + const aNum = parseInt(a.id, 10) + const bNum = parseInt(b.id, 10) if (!isNaN(aNum) && !isNaN(bNum)) { - return aNum - bNum; + return aNum - bNum } - return a.id.localeCompare(b.id); + return a.id.localeCompare(b.id) } + export function TaskListV2({ tasks, - isStandalone = false + isStandalone = false, }: Props): React.ReactNode { - const teamContext = useAppState(s => s.teamContext); - const appStateTasks = useAppState(s_0 => s_0.tasks); - const [, forceUpdate] = React.useState(0); - const { - rows, - columns - } = useTerminalSize(); + const teamContext = useAppState(s => s.teamContext) + const appStateTasks = useAppState(s => s.tasks) + const [, forceUpdate] = React.useState(0) + const { rows, columns } = useTerminalSize() // Track when each task was last observed transitioning to completed - const completionTimestampsRef = React.useRef(new Map()); - const previousCompletedIdsRef = React.useRef | null>(null); + const completionTimestampsRef = React.useRef(new Map()) + const previousCompletedIdsRef = React.useRef | null>(null) if (previousCompletedIdsRef.current === null) { - previousCompletedIdsRef.current = new Set(tasks.filter(t => t.status === 'completed').map(t_0 => t_0.id)); + previousCompletedIdsRef.current = new Set( + tasks.filter(t => t.status === 'completed').map(t => t.id), + ) } - const maxDisplay = rows <= 10 ? 0 : Math.min(10, Math.max(3, rows - 14)); + const maxDisplay = rows <= 10 ? 0 : Math.min(10, Math.max(3, rows - 14)) // Update completion timestamps: reset when a task transitions to completed - const currentCompletedIds = new Set(tasks.filter(t_1 => t_1.status === 'completed').map(t_2 => t_2.id)); - const now = Date.now(); + const currentCompletedIds = new Set( + tasks.filter(t => t.status === 'completed').map(t => t.id), + ) + const now = Date.now() for (const id of currentCompletedIds) { if (!previousCompletedIdsRef.current.has(id)) { - completionTimestampsRef.current.set(id, now); + completionTimestampsRef.current.set(id, now) } } - for (const id_0 of completionTimestampsRef.current.keys()) { - if (!currentCompletedIds.has(id_0)) { - completionTimestampsRef.current.delete(id_0); + for (const id of completionTimestampsRef.current.keys()) { + if (!currentCompletedIds.has(id)) { + completionTimestampsRef.current.delete(id) } } - previousCompletedIdsRef.current = currentCompletedIds; + previousCompletedIdsRef.current = currentCompletedIds // Schedule re-render when the next recent completion expires. // Depend on `tasks` so the timer is only reset when the task list changes, // not on every render (which was causing unnecessary work). React.useEffect(() => { if (completionTimestampsRef.current.size === 0) { - return; + return } - const currentNow = Date.now(); - let earliestExpiry = Infinity; + const currentNow = Date.now() + let earliestExpiry = Infinity for (const ts of completionTimestampsRef.current.values()) { - const expiry = ts + RECENT_COMPLETED_TTL_MS; + const expiry = ts + RECENT_COMPLETED_TTL_MS if (expiry > currentNow && expiry < earliestExpiry) { - earliestExpiry = expiry; + earliestExpiry = expiry } } if (earliestExpiry === Infinity) { - return; + return } - const timer = setTimeout(forceUpdate_0 => forceUpdate_0((n: number) => n + 1), earliestExpiry - currentNow, forceUpdate); - return () => clearTimeout(timer); - }, [tasks]); + const timer = setTimeout( + forceUpdate => forceUpdate((n: number) => n + 1), + earliestExpiry - currentNow, + forceUpdate, + ) + return () => clearTimeout(timer) + }, [tasks]) + if (!isTodoV2Enabled()) { - return null; + return null } + if (tasks.length === 0) { - return null; + return null } // Build a map of teammate name -> theme color - const teammateColors: Record = {}; + const teammateColors: Record = {} if (isAgentSwarmsEnabled() && teamContext?.teammates) { for (const teammate of Object.values(teamContext.teammates)) { if (teammate.color) { - const themeColor = AGENT_COLOR_TO_THEME_COLOR[teammate.color as AgentColorName]; + const themeColor = + AGENT_COLOR_TO_THEME_COLOR[teammate.color as AgentColorName] if (themeColor) { - teammateColors[teammate.name] = themeColor; + teammateColors[teammate.name] = themeColor } } } @@ -108,270 +122,240 @@ export function TaskListV2({ // task owners match regardless of which format the model used. // Rolls up consecutive search/read tool uses into a compact summary. // Also track which teammates are still running (not shut down). - const teammateActivity: Record = {}; - const activeTeammates = new Set(); + const teammateActivity: Record = {} + const activeTeammates = new Set() if (isAgentSwarmsEnabled()) { for (const bgTask of Object.values(appStateTasks)) { if (isInProcessTeammateTask(bgTask) && bgTask.status === 'running') { - activeTeammates.add(bgTask.identity.agentName); - activeTeammates.add(bgTask.identity.agentId); - const activities = bgTask.progress?.recentActivities; - const desc = (activities && summarizeRecentActivities(activities)) ?? bgTask.progress?.lastActivity?.activityDescription; + activeTeammates.add(bgTask.identity.agentName) + activeTeammates.add(bgTask.identity.agentId) + const activities = bgTask.progress?.recentActivities + const desc = + (activities && summarizeRecentActivities(activities)) ?? + bgTask.progress?.lastActivity?.activityDescription if (desc) { - teammateActivity[bgTask.identity.agentName] = desc; - teammateActivity[bgTask.identity.agentId] = desc; + teammateActivity[bgTask.identity.agentName] = desc + teammateActivity[bgTask.identity.agentId] = desc } } } } // Get task counts for display - const completedCount = count(tasks, t_3 => t_3.status === 'completed'); - const pendingCount = count(tasks, t_4 => t_4.status === 'pending'); - const inProgressCount = tasks.length - completedCount - pendingCount; + const completedCount = count(tasks, t => t.status === 'completed') + const pendingCount = count(tasks, t => t.status === 'pending') + const inProgressCount = tasks.length - completedCount - pendingCount // Unresolved tasks (open or in_progress) block dependent tasks - const unresolvedTaskIds = new Set(tasks.filter(t_5 => t_5.status !== 'completed').map(t_6 => t_6.id)); + const unresolvedTaskIds = new Set( + tasks.filter(t => t.status !== 'completed').map(t => t.id), + ) // Check if we need to truncate - const needsTruncation = tasks.length > maxDisplay; - let visibleTasks: Task[]; - let hiddenTasks: Task[]; + const needsTruncation = tasks.length > maxDisplay + + let visibleTasks: Task[] + let hiddenTasks: Task[] + if (needsTruncation) { // Prioritize: recently completed (within 30s), in-progress, pending, older completed - const recentCompleted: Task[] = []; - const olderCompleted: Task[] = []; - for (const task of tasks.filter(t_7 => t_7.status === 'completed')) { - const ts_0 = completionTimestampsRef.current.get(task.id); - if (ts_0 && now - ts_0 < RECENT_COMPLETED_TTL_MS) { - recentCompleted.push(task); + const recentCompleted: Task[] = [] + const olderCompleted: Task[] = [] + for (const task of tasks.filter(t => t.status === 'completed')) { + const ts = completionTimestampsRef.current.get(task.id) + if (ts && now - ts < RECENT_COMPLETED_TTL_MS) { + recentCompleted.push(task) } else { - olderCompleted.push(task); + olderCompleted.push(task) } } - recentCompleted.sort(byIdAsc); - olderCompleted.sort(byIdAsc); - const inProgress = tasks.filter(t_8 => t_8.status === 'in_progress').sort(byIdAsc); - const pending = tasks.filter(t_9 => t_9.status === 'pending').sort((a, b) => { - const aBlocked = a.blockedBy.some(id_1 => unresolvedTaskIds.has(id_1)); - const bBlocked = b.blockedBy.some(id_2 => unresolvedTaskIds.has(id_2)); - if (aBlocked !== bBlocked) { - return aBlocked ? 1 : -1; - } - return byIdAsc(a, b); - }); - const prioritized = [...recentCompleted, ...inProgress, ...pending, ...olderCompleted]; - visibleTasks = prioritized.slice(0, maxDisplay); - hiddenTasks = prioritized.slice(maxDisplay); + recentCompleted.sort(byIdAsc) + olderCompleted.sort(byIdAsc) + const inProgress = tasks + .filter(t => t.status === 'in_progress') + .sort(byIdAsc) + const pending = tasks + .filter(t => t.status === 'pending') + .sort((a, b) => { + const aBlocked = a.blockedBy.some(id => unresolvedTaskIds.has(id)) + const bBlocked = b.blockedBy.some(id => unresolvedTaskIds.has(id)) + if (aBlocked !== bBlocked) { + return aBlocked ? 1 : -1 + } + return byIdAsc(a, b) + }) + + const prioritized = [ + ...recentCompleted, + ...inProgress, + ...pending, + ...olderCompleted, + ] + visibleTasks = prioritized.slice(0, maxDisplay) + hiddenTasks = prioritized.slice(maxDisplay) } else { // No truncation needed — sort by ID for stable ordering - visibleTasks = [...tasks].sort(byIdAsc); - hiddenTasks = []; + visibleTasks = [...tasks].sort(byIdAsc) + hiddenTasks = [] } - let hiddenSummary = ''; + + let hiddenSummary = '' if (hiddenTasks.length > 0) { - const parts: string[] = []; - const hiddenPending = count(hiddenTasks, t_10 => t_10.status === 'pending'); - const hiddenInProgress = count(hiddenTasks, t_11 => t_11.status === 'in_progress'); - const hiddenCompleted = count(hiddenTasks, t_12 => t_12.status === 'completed'); + const parts: string[] = [] + const hiddenPending = count(hiddenTasks, t => t.status === 'pending') + const hiddenInProgress = count(hiddenTasks, t => t.status === 'in_progress') + const hiddenCompleted = count(hiddenTasks, t => t.status === 'completed') if (hiddenInProgress > 0) { - parts.push(`${hiddenInProgress} in progress`); + parts.push(`${hiddenInProgress} in progress`) } if (hiddenPending > 0) { - parts.push(`${hiddenPending} pending`); + parts.push(`${hiddenPending} pending`) } if (hiddenCompleted > 0) { - parts.push(`${hiddenCompleted} completed`); + parts.push(`${hiddenCompleted} completed`) } - hiddenSummary = ` … +${parts.join(', ')}`; + hiddenSummary = ` … +${parts.join(', ')}` } - const content = <> - {visibleTasks.map(task_0 => unresolvedTaskIds.has(id_3))} activity={task_0.owner ? teammateActivity[task_0.owner] : undefined} ownerActive={task_0.owner ? activeTeammates.has(task_0.owner) : false} columns={columns} />)} + + const content = ( + <> + {visibleTasks.map(task => ( + unresolvedTaskIds.has(id))} + activity={task.owner ? teammateActivity[task.owner] : undefined} + ownerActive={task.owner ? activeTeammates.has(task.owner) : false} + columns={columns} + /> + ))} {maxDisplay > 0 && hiddenSummary && {hiddenSummary}} - ; + + ) + if (isStandalone) { - return + return ( + {tasks.length} {' tasks ('} {completedCount} {' done, '} - {inProgressCount > 0 && <> + {inProgressCount > 0 && ( + <> {inProgressCount} {' in progress, '} - } + + )} {pendingCount} {' open)'} {content} - ; + + ) } - return {content}; + + return {content} } + type TaskItemProps = { - task: Task; - ownerColor?: keyof Theme; - openBlockers: string[]; - activity?: string; - ownerActive: boolean; - columns: number; -}; + task: Task + ownerColor?: keyof Theme + openBlockers: string[] + activity?: string + ownerActive: boolean + columns: number +} + function getTaskIcon(status: Task['status']): { - icon: string; - color: keyof Theme | undefined; + icon: string + color: keyof Theme | undefined } { switch (status) { case 'completed': - return { - icon: figures.tick, - color: 'success' - }; + return { icon: figures.tick, color: 'success' } case 'in_progress': - return { - icon: figures.squareSmallFilled, - color: 'claude' - }; + return { icon: figures.squareSmallFilled, color: 'claude' } case 'pending': - return { - icon: figures.squareSmall, - color: undefined - }; + return { icon: figures.squareSmall, color: undefined } } } -function TaskItem(t0) { - const $ = _c(37); - const { - task, - ownerColor, - openBlockers, - activity, - ownerActive, - columns - } = t0; - const isCompleted = task.status === "completed"; - const isInProgress = task.status === "in_progress"; - const isBlocked = openBlockers.length > 0; - let t1; - if ($[0] !== task.status) { - t1 = getTaskIcon(task.status); - $[0] = task.status; - $[1] = t1; - } else { - t1 = $[1]; - } - const { - icon, - color - } = t1; - const showActivity = isInProgress && !isBlocked && activity; - const showOwner = columns >= 60 && task.owner && ownerActive; - let t2; - if ($[2] !== showOwner || $[3] !== task.owner) { - t2 = showOwner ? stringWidth(` (@${task.owner})`) : 0; - $[2] = showOwner; - $[3] = task.owner; - $[4] = t2; - } else { - t2 = $[4]; - } - const ownerWidth = t2; - const maxSubjectWidth = Math.max(15, columns - 15 - ownerWidth); - let t3; - if ($[5] !== maxSubjectWidth || $[6] !== task.subject) { - t3 = truncateToWidth(task.subject, maxSubjectWidth); - $[5] = maxSubjectWidth; - $[6] = task.subject; - $[7] = t3; - } else { - t3 = $[7]; - } - const displaySubject = t3; - const maxActivityWidth = Math.max(15, columns - 15); - let t4; - if ($[8] !== activity || $[9] !== maxActivityWidth) { - t4 = activity ? truncateToWidth(activity, maxActivityWidth) : undefined; - $[8] = activity; - $[9] = maxActivityWidth; - $[10] = t4; - } else { - t4 = $[10]; - } - const displayActivity = t4; - let t5; - if ($[11] !== color || $[12] !== icon) { - t5 = {icon} ; - $[11] = color; - $[12] = icon; - $[13] = t5; - } else { - t5 = $[13]; - } - const t6 = isCompleted || isBlocked; - let t7; - if ($[14] !== displaySubject || $[15] !== isCompleted || $[16] !== isInProgress || $[17] !== t6) { - t7 = {displaySubject}; - $[14] = displaySubject; - $[15] = isCompleted; - $[16] = isInProgress; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - let t8; - if ($[19] !== ownerColor || $[20] !== showOwner || $[21] !== task.owner) { - t8 = showOwner && {" ("}{ownerColor ? @{task.owner} : `@${task.owner}`}{")"}; - $[19] = ownerColor; - $[20] = showOwner; - $[21] = task.owner; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== isBlocked || $[24] !== openBlockers) { - t9 = isBlocked && {" "}{figures.pointerSmall} blocked by{" "}{[...openBlockers].sort(_temp).map(_temp2).join(", ")}; - $[23] = isBlocked; - $[24] = openBlockers; - $[25] = t9; - } else { - t9 = $[25]; - } - let t10; - if ($[26] !== t5 || $[27] !== t7 || $[28] !== t8 || $[29] !== t9) { - t10 = {t5}{t7}{t8}{t9}; - $[26] = t5; - $[27] = t7; - $[28] = t8; - $[29] = t9; - $[30] = t10; - } else { - t10 = $[30]; - } - let t11; - if ($[31] !== displayActivity || $[32] !== showActivity) { - t11 = showActivity && displayActivity && {" "}{displayActivity}{figures.ellipsis}; - $[31] = displayActivity; - $[32] = showActivity; - $[33] = t11; - } else { - t11 = $[33]; - } - let t12; - if ($[34] !== t10 || $[35] !== t11) { - t12 = {t10}{t11}; - $[34] = t10; - $[35] = t11; - $[36] = t12; - } else { - t12 = $[36]; - } - return t12; -} -function _temp2(id) { - return `#${id}`; -} -function _temp(a, b) { - return parseInt(a, 10) - parseInt(b, 10); + +function TaskItem({ + task, + ownerColor, + openBlockers, + activity, + ownerActive, + columns, +}: TaskItemProps): React.ReactNode { + const isCompleted = task.status === 'completed' + const isInProgress = task.status === 'in_progress' + const isBlocked = openBlockers.length > 0 + + const { icon, color } = getTaskIcon(task.status) + + const showActivity = isInProgress && !isBlocked && activity + + // Responsive layout: hide owner on narrow screens (<60 cols) + // Truncate subject based on available space + const showOwner = columns >= 60 && task.owner && ownerActive + const ownerWidth = showOwner ? stringWidth(` (@${task.owner})`) : 0 + // Account for: icon(2) + indentation(~8 when nested under spinner) + owner + safety + // Use columns - 15 as a conservative estimate for nested layouts + const maxSubjectWidth = Math.max(15, columns - 15 - ownerWidth) + const displaySubject = truncateToWidth(task.subject, maxSubjectWidth) + + // Truncate activity for narrow screens + const maxActivityWidth = Math.max(15, columns - 15) + const displayActivity = activity + ? truncateToWidth(activity, maxActivityWidth) + : undefined + + return ( + + + {icon} + + {displaySubject} + + {showOwner && ( + + {' ('} + {ownerColor ? ( + @{task.owner} + ) : ( + `@${task.owner}` + )} + {')'} + + )} + {isBlocked && ( + + {' '} + {figures.pointerSmall} blocked by{' '} + {[...openBlockers] + .sort((a, b) => parseInt(a, 10) - parseInt(b, 10)) + .map(id => `#${id}`) + .join(', ')} + + )} + + {showActivity && displayActivity && ( + + + {' '} + {displayActivity} + {figures.ellipsis} + + + )} + + ) } diff --git a/src/components/TeammateViewHeader.tsx b/src/components/TeammateViewHeader.tsx index 5a6b371f7..01e64571d 100644 --- a/src/components/TeammateViewHeader.tsx +++ b/src/components/TeammateViewHeader.tsx @@ -1,81 +1,39 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../ink.js'; -import { useAppState } from '../state/AppState.js'; -import { getViewedTeammateTask } from '../state/selectors.js'; -import { toInkColor } from '../utils/ink.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { OffscreenFreeze } from './OffscreenFreeze.js'; +import * as React from 'react' +import { Box, Text } from '../ink.js' +import { useAppState } from '../state/AppState.js' +import { getViewedTeammateTask } from '../state/selectors.js' +import { toInkColor } from '../utils/ink.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { OffscreenFreeze } from './OffscreenFreeze.js' /** * Header shown when viewing a teammate's transcript. * Displays teammate name (colored), task description, and exit hint. */ -export function TeammateViewHeader() { - const $ = _c(14); - const viewedTeammate = useAppState(_temp); +export function TeammateViewHeader(): React.ReactNode { + const viewedTeammate = useAppState(s => getViewedTeammateTask(s)) + if (!viewedTeammate) { - return null; - } - let t0; - if ($[0] !== viewedTeammate.identity.color) { - t0 = toInkColor(viewedTeammate.identity.color); - $[0] = viewedTeammate.identity.color; - $[1] = t0; - } else { - t0 = $[1]; - } - const nameColor = t0; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Viewing ; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== nameColor || $[4] !== viewedTeammate.identity.agentName) { - t2 = @{viewedTeammate.identity.agentName}; - $[3] = nameColor; - $[4] = viewedTeammate.identity.agentName; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {" \xB7 "}; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t2) { - t4 = {t1}{t2}{t3}; - $[7] = t2; - $[8] = t4; - } else { - t4 = $[8]; + return null } - let t5; - if ($[9] !== viewedTeammate.prompt) { - t5 = {viewedTeammate.prompt}; - $[9] = viewedTeammate.prompt; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t4 || $[12] !== t5) { - t6 = {t4}{t5}; - $[11] = t4; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; -} -function _temp(s) { - return getViewedTeammateTask(s); + + const nameColor = toInkColor(viewedTeammate.identity.color) + + return ( + + + + Viewing + + @{viewedTeammate.identity.agentName} + + + {' · '} + + + + {viewedTeammate.prompt} + + + ) } diff --git a/src/components/TeleportError.tsx b/src/components/TeleportError.tsx index 0bf687f12..343ef7427 100644 --- a/src/components/TeleportError.tsx +++ b/src/components/TeleportError.tsx @@ -1,170 +1,136 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useState } from 'react'; -import { checkIsGitClean, checkNeedsClaudeAiLogin } from 'src/utils/background/remote/preconditions.js'; -import { gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; -import { Box, Text } from '../ink.js'; -import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -import { TeleportStash } from './TeleportStash.js'; -export type TeleportLocalErrorType = 'needsLogin' | 'needsGitStash'; +import React, { useCallback, useEffect, useState } from 'react' +import { + checkIsGitClean, + checkNeedsClaudeAiLogin, +} from 'src/utils/background/remote/preconditions.js' +import { gracefulShutdownSync } from 'src/utils/gracefulShutdown.js' +import { Box, Text } from '../ink.js' +import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { TeleportStash } from './TeleportStash.js' + +export type TeleportLocalErrorType = 'needsLogin' | 'needsGitStash' + type TeleportErrorProps = { - onComplete: () => void; - errorsToIgnore?: ReadonlySet; -}; + onComplete: () => void + errorsToIgnore?: ReadonlySet +} // Module-level sentinel so the default parameter has stable identity. // Previously `= new Set()` created a fresh Set every render, which put // a new object in checkErrors' deps and caused the mount effect to // re-fire on every render. -const EMPTY_ERRORS_TO_IGNORE: ReadonlySet = new Set(); -export function TeleportError(t0) { - const $ = _c(18); - const { - onComplete, - errorsToIgnore: t1 - } = t0; - const errorsToIgnore = t1 === undefined ? EMPTY_ERRORS_TO_IGNORE : t1; - const [currentError, setCurrentError] = useState(null); - const [isLoggingIn, setIsLoggingIn] = useState(false); - let t2; - if ($[0] !== errorsToIgnore || $[1] !== onComplete) { - t2 = async () => { - const currentErrors = await getTeleportErrors(); - const filteredErrors = new Set(Array.from(currentErrors).filter(error => !errorsToIgnore.has(error))); - if (filteredErrors.size === 0) { - onComplete(); - return; - } - if (filteredErrors.has("needsLogin")) { - setCurrentError("needsLogin"); - } else { - if (filteredErrors.has("needsGitStash")) { - setCurrentError("needsGitStash"); - } - } - }; - $[0] = errorsToIgnore; - $[1] = onComplete; - $[2] = t2; - } else { - t2 = $[2]; - } - const checkErrors = t2; - let t3; - let t4; - if ($[3] !== checkErrors) { - t3 = () => { - checkErrors(); - }; - t4 = [checkErrors]; - $[3] = checkErrors; - $[4] = t3; - $[5] = t4; - } else { - t3 = $[4]; - t4 = $[5]; - } - useEffect(t3, t4); - const onCancel = _temp; - let t5; - if ($[6] !== checkErrors) { - t5 = () => { - setIsLoggingIn(false); - checkErrors(); - }; - $[6] = checkErrors; - $[7] = t5; - } else { - t5 = $[7]; - } - const handleLoginComplete = t5; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => { - setIsLoggingIn(true); - }; - $[8] = t6; - } else { - t6 = $[8]; - } - const handleLoginWithClaudeAI = t6; - let t7; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t7 = value => { - if (value === "login") { - handleLoginWithClaudeAI(); +const EMPTY_ERRORS_TO_IGNORE: ReadonlySet = new Set() + +export function TeleportError({ + onComplete, + errorsToIgnore = EMPTY_ERRORS_TO_IGNORE, +}: TeleportErrorProps): React.ReactNode { + const [currentError, setCurrentError] = + useState(null) + const [isLoggingIn, setIsLoggingIn] = useState(false) + + // Check for errors on mount and when error resolution occurs + const checkErrors = useCallback(async () => { + const currentErrors = await getTeleportErrors() + const filteredErrors = new Set( + Array.from(currentErrors).filter( + (error: TeleportLocalErrorType) => !errorsToIgnore.has(error), + ), + ) + + // If no errors remain, call onComplete + if (filteredErrors.size === 0) { + onComplete() + return + } + + // Set current error to handle (prioritize login over git) + if (filteredErrors.has('needsLogin')) { + setCurrentError('needsLogin') + } else if (filteredErrors.has('needsGitStash')) { + setCurrentError('needsGitStash') + } + }, [onComplete, errorsToIgnore]) + + // Check errors on mount + useEffect(() => { + void checkErrors() + }, [checkErrors]) + + const onCancel = useCallback(() => { + gracefulShutdownSync(0) + }, []) + + const handleLoginComplete = useCallback(() => { + setIsLoggingIn(false) + void checkErrors() + }, [checkErrors]) + + const handleLoginWithClaudeAI = useCallback(() => { + setIsLoggingIn(true) + }, [setIsLoggingIn]) + + const handleLoginDialogSelect = useCallback( + (value: string) => { + if (value === 'login') { + handleLoginWithClaudeAI() } else { - onCancel(); + // User selected exit + onCancel() } - }; - $[9] = t7; - } else { - t7 = $[9]; - } - const handleLoginDialogSelect = t7; - let t8; - if ($[10] !== checkErrors) { - t8 = () => { - checkErrors(); - }; - $[10] = checkErrors; - $[11] = t8; - } else { - t8 = $[11]; - } - const handleStashComplete = t8; + }, + [handleLoginWithClaudeAI, onCancel], + ) + + const handleStashComplete = useCallback(() => { + void checkErrors() + }, [checkErrors]) + + // Don't render anything if no current error (onComplete will be called) if (!currentError) { - return null; + return null } + switch (currentError) { - case "needsGitStash": - { - let t9; - if ($[12] !== handleStashComplete) { - t9 = ; - $[12] = handleStashComplete; - $[13] = t9; - } else { - t9 = $[13]; - } - return t9; - } - case "needsLogin": - { - if (isLoggingIn) { - let t9; - if ($[14] !== handleLoginComplete) { - t9 = ; - $[14] = handleLoginComplete; - $[15] = t9; - } else { - t9 = $[15]; - } - return t9; - } - let t9; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t9 = Teleport requires a Claude.ai account.Your Claude Pro/Max subscription will be used by Claude Code.; - $[16] = t9; - } else { - t9 = $[16]; - } - let t10; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {t9} + + ) + } } } @@ -172,17 +138,22 @@ export function TeleportError(t0) { * Gets current teleport errors that need to be resolved * @returns Set of teleport error types that need to be handled */ -function _temp() { - gracefulShutdownSync(0); -} -export async function getTeleportErrors(): Promise> { - const errors = new Set(); - const [needsLogin, isGitClean] = await Promise.all([checkNeedsClaudeAiLogin(), checkIsGitClean()]); +export async function getTeleportErrors(): Promise< + Set +> { + const errors = new Set() + + const [needsLogin, isGitClean] = await Promise.all([ + checkNeedsClaudeAiLogin(), + checkIsGitClean(), + ]) + if (needsLogin) { - errors.add('needsLogin'); + errors.add('needsLogin') } if (!isGitClean) { - errors.add('needsGitStash'); + errors.add('needsGitStash') } - return errors; + + return errors } diff --git a/src/components/TeleportProgress.tsx b/src/components/TeleportProgress.tsx index 0cfb18273..f8ef62110 100644 --- a/src/components/TeleportProgress.tsx +++ b/src/components/TeleportProgress.tsx @@ -1,139 +1,122 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useState } from 'react'; -import type { Root } from '../ink.js'; -import { Box, Text, useAnimationFrame } from '../ink.js'; -import { AppStateProvider } from '../state/AppState.js'; -import { checkOutTeleportedSessionBranch, processMessagesForTeleportResume, type TeleportProgressStep, type TeleportResult, teleportResumeCodeSession } from '../utils/teleport.js'; +import figures from 'figures' +import * as React from 'react' +import { useState } from 'react' +import type { Root } from '../ink.js' +import { Box, Text, useAnimationFrame } from '../ink.js' +import { AppStateProvider } from '../state/AppState.js' +import { + checkOutTeleportedSessionBranch, + processMessagesForTeleportResume, + type TeleportProgressStep, + type TeleportResult, + teleportResumeCodeSession, +} from '../utils/teleport.js' + type Props = { - currentStep: TeleportProgressStep; - sessionId?: string; -}; -const SPINNER_FRAMES = ['◐', '◓', '◑', '◒']; -const STEPS: { - key: TeleportProgressStep; - label: string; -}[] = [{ - key: 'validating', - label: 'Validating session' -}, { - key: 'fetching_logs', - label: 'Fetching session logs' -}, { - key: 'fetching_branch', - label: 'Getting branch info' -}, { - key: 'checking_out', - label: 'Checking out branch' -}]; -export function TeleportProgress(t0) { - const $ = _c(16); - const { - currentStep, - sessionId - } = t0; - const [ref, time] = useAnimationFrame(100); - const frame = Math.floor(time / 100) % SPINNER_FRAMES.length; - let t1; - if ($[0] !== currentStep) { - t1 = s => s.key === currentStep; - $[0] = currentStep; - $[1] = t1; - } else { - t1 = $[1]; - } - const currentStepIndex = STEPS.findIndex(t1); - const t2 = SPINNER_FRAMES[frame]; - let t3; - if ($[2] !== t2) { - t3 = {t2} Teleporting session…; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== sessionId) { - t4 = sessionId && {sessionId}; - $[4] = sessionId; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== currentStepIndex || $[7] !== frame) { - t5 = STEPS.map((step, index) => { - const isComplete = index < currentStepIndex; - const isCurrent = index === currentStepIndex; - const isPending = index > currentStepIndex; - let icon; - let color; - if (isComplete) { - icon = figures.tick; - color = "green"; - } else { - if (isCurrent) { - icon = SPINNER_FRAMES[frame]; - color = "claude"; - } else { - icon = figures.circle; - color = undefined; - } - } - return {icon}{step.label}; - }); - $[6] = currentStepIndex; - $[7] = frame; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== t5) { - t6 = {t5}; - $[9] = t5; - $[10] = t6; - } else { - t6 = $[10]; - } - let t7; - if ($[11] !== ref || $[12] !== t3 || $[13] !== t4 || $[14] !== t6) { - t7 = {t3}{t4}{t6}; - $[11] = ref; - $[12] = t3; - $[13] = t4; - $[14] = t6; - $[15] = t7; - } else { - t7 = $[15]; - } - return t7; + currentStep: TeleportProgressStep + sessionId?: string +} + +const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] + +const STEPS: { key: TeleportProgressStep; label: string }[] = [ + { key: 'validating', label: 'Validating session' }, + { key: 'fetching_logs', label: 'Fetching session logs' }, + { key: 'fetching_branch', label: 'Getting branch info' }, + { key: 'checking_out', label: 'Checking out branch' }, +] + +export function TeleportProgress({ + currentStep, + sessionId, +}: Props): React.ReactNode { + const [ref, time] = useAnimationFrame(100) + const frame = Math.floor(time / 100) % SPINNER_FRAMES.length + + const currentStepIndex = STEPS.findIndex(s => s.key === currentStep) + + return ( + + + + {SPINNER_FRAMES[frame]} Teleporting session… + + + + {sessionId && ( + + {sessionId} + + )} + + + {STEPS.map((step, index) => { + const isComplete = index < currentStepIndex + const isCurrent = index === currentStepIndex + const isPending = index > currentStepIndex + + let icon: string + let color: string | undefined + + if (isComplete) { + icon = figures.tick + color = 'green' + } else if (isCurrent) { + icon = SPINNER_FRAMES[frame]! + color = 'claude' + } else { + icon = figures.circle + color = undefined + } + + return ( + + + + {icon} + + + + {step.label} + + + ) + })} + + + ) } /** * Teleports to a remote session with progress UI rendered into the existing root. * Fetches the session, checks out the branch, and returns the result. */ -export async function teleportWithProgress(root: Root, sessionId: string): Promise { +export async function teleportWithProgress( + root: Root, + sessionId: string, +): Promise { // Capture the setState function from the rendered component - let setStep: (step: TeleportProgressStep) => void = () => {}; + let setStep: (step: TeleportProgressStep) => void = () => {} + function TeleportProgressWrapper(): React.ReactNode { - const [step, _setStep] = useState('validating'); - setStep = _setStep; - return ; + const [step, _setStep] = useState('validating') + setStep = _setStep + return } - root.render( + + root.render( + - ); - const result = await teleportResumeCodeSession(sessionId, setStep); - setStep('checking_out'); - const { - branchName, - branchError - } = await checkOutTeleportedSessionBranch(result.branch); + , + ) + + const result = await teleportResumeCodeSession(sessionId, setStep) + setStep('checking_out') + const { branchName, branchError } = await checkOutTeleportedSessionBranch( + result.branch, + ) return { messages: processMessagesForTeleportResume(result.log, branchError), - branchName - }; + branchName, + } } diff --git a/src/components/TeleportRepoMismatchDialog.tsx b/src/components/TeleportRepoMismatchDialog.tsx index c5182c96a..126a9c432 100644 --- a/src/components/TeleportRepoMismatchDialog.tsx +++ b/src/components/TeleportRepoMismatchDialog.tsx @@ -1,103 +1,104 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useState } from 'react'; -import { Box, Text } from '../ink.js'; -import { getDisplayPath } from '../utils/file.js'; -import { removePathFromRepo, validateRepoAtPath } from '../utils/githubRepoPathMapping.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -import { Spinner } from './Spinner.js'; +import React, { useCallback, useState } from 'react' +import { Box, Text } from '../ink.js' +import { getDisplayPath } from '../utils/file.js' +import { + removePathFromRepo, + validateRepoAtPath, +} from '../utils/githubRepoPathMapping.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { Spinner } from './Spinner.js' + type Props = { - targetRepo: string; - initialPaths: string[]; - onSelectPath: (path: string) => void; - onCancel: () => void; -}; -export function TeleportRepoMismatchDialog(t0) { - const $ = _c(18); - const { - targetRepo, - initialPaths, - onSelectPath, - onCancel - } = t0; - const [availablePaths, setAvailablePaths] = useState(initialPaths); - const [errorMessage, setErrorMessage] = useState(null); - const [validating, setValidating] = useState(false); - let t1; - if ($[0] !== availablePaths || $[1] !== onCancel || $[2] !== onSelectPath || $[3] !== targetRepo) { - t1 = async value => { - if (value === "cancel") { - onCancel(); - return; + targetRepo: string + initialPaths: string[] + onSelectPath: (path: string) => void + onCancel: () => void +} + +export function TeleportRepoMismatchDialog({ + targetRepo, + initialPaths, + onSelectPath, + onCancel, +}: Props): React.ReactNode { + const [availablePaths, setAvailablePaths] = useState(initialPaths) + const [errorMessage, setErrorMessage] = useState(null) + const [validating, setValidating] = useState(false) + + const handleChange = useCallback( + async (value: string): Promise => { + if (value === 'cancel') { + onCancel() + return } - setValidating(true); - setErrorMessage(null); - const isValid = await validateRepoAtPath(value, targetRepo); + + setValidating(true) + setErrorMessage(null) + + const isValid = await validateRepoAtPath(value, targetRepo) + if (isValid) { - onSelectPath(value); - return; + onSelectPath(value) + return } - removePathFromRepo(targetRepo, value); - const updatedPaths = availablePaths.filter(p => p !== value); - setAvailablePaths(updatedPaths); - setValidating(false); - setErrorMessage(`${getDisplayPath(value)} no longer contains the correct repository. Select another path.`); - }; - $[0] = availablePaths; - $[1] = onCancel; - $[2] = onSelectPath; - $[3] = targetRepo; - $[4] = t1; - } else { - t1 = $[4]; - } - const handleChange = t1; - let t2; - if ($[5] !== availablePaths) { - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - label: "Cancel", - value: "cancel" - }; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = [...availablePaths.map(_temp), t3]; - $[5] = availablePaths; - $[6] = t2; - } else { - t2 = $[6]; - } - const options = t2; - let t3; - if ($[8] !== availablePaths.length || $[9] !== errorMessage || $[10] !== handleChange || $[11] !== options || $[12] !== targetRepo || $[13] !== validating) { - t3 = availablePaths.length > 0 ? <>{errorMessage && {errorMessage}}Open Claude Code in {targetRepo}:{validating ? Validating repository… : void handleChange(value)} + /> + )} + + ) : ( + + {errorMessage && {errorMessage}} + + Run claude --teleport from a checkout of {targetRepo} + + + )} + + ) } diff --git a/src/components/TeleportResumeWrapper.tsx b/src/components/TeleportResumeWrapper.tsx index ef6809c17..60e6c7806 100644 --- a/src/components/TeleportResumeWrapper.tsx +++ b/src/components/TeleportResumeWrapper.tsx @@ -1,166 +1,108 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js'; -import type { CodeSession } from 'src/utils/teleport/api.js'; -import { type TeleportSource, useTeleportResume } from '../hooks/useTeleportResume.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { ResumeTask } from './ResumeTask.js'; -import { Spinner } from './Spinner.js'; +import React, { useEffect } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js' +import type { CodeSession } from 'src/utils/teleport/api.js' +import { + type TeleportSource, + useTeleportResume, +} from '../hooks/useTeleportResume.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { ResumeTask } from './ResumeTask.js' +import { Spinner } from './Spinner.js' + interface TeleportResumeWrapperProps { - onComplete: (result: TeleportRemoteResponse) => void; - onCancel: () => void; - onError?: (error: string, formattedMessage?: string) => void; - isEmbedded?: boolean; - source: TeleportSource; + onComplete: (result: TeleportRemoteResponse) => void + onCancel: () => void + onError?: (error: string, formattedMessage?: string) => void + isEmbedded?: boolean + source: TeleportSource } /** * Wrapper component that manages the full teleport resume flow, * including session selection, loading state, and error handling */ -export function TeleportResumeWrapper(t0) { - const $ = _c(25); - const { - onComplete, - onCancel, - onError, - isEmbedded: t1, - source - } = t0; - const isEmbedded = t1 === undefined ? false : t1; - const { - resumeSession, - isResuming, - error, - selectedSession - } = useTeleportResume(source); - let t2; - let t3; - if ($[0] !== source) { - t2 = () => { - logEvent("tengu_teleport_started", { - source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }; - t3 = [source]; - $[0] = source; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - let t4; - if ($[3] !== error || $[4] !== onComplete || $[5] !== onError || $[6] !== resumeSession) { - t4 = async session => { - const result = await resumeSession(session); - if (result) { - onComplete(result); - } else { - if (error) { - if (onError) { - onError(error.message, error.formattedMessage); - } - } +export function TeleportResumeWrapper({ + onComplete, + onCancel, + onError, + isEmbedded = false, + source, +}: TeleportResumeWrapperProps): React.ReactNode { + const { resumeSession, isResuming, error, selectedSession } = + useTeleportResume(source) + + // Log when teleport flow starts (for funnel tracking) + useEffect(() => { + logEvent('tengu_teleport_started', { + source: + source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, [source]) + + const handleSelect = async (session: CodeSession) => { + const result = await resumeSession(session) + if (result) { + onComplete(result) + } else if (error) { + // If there's an error handler provided, use it + if (onError) { + onError(error.message, error.formattedMessage) } - }; - $[3] = error; - $[4] = onComplete; - $[5] = onError; - $[6] = resumeSession; - $[7] = t4; - } else { - t4 = $[7]; - } - const handleSelect = t4; - let t5; - if ($[8] !== onCancel) { - t5 = () => { - logEvent("tengu_teleport_cancelled", {}); - onCancel(); - }; - $[8] = onCancel; - $[9] = t5; - } else { - t5 = $[9]; + // Otherwise the error will be displayed in the UI + } } - const handleCancel = t5; - const t6 = !!error && !onError; - let t7; - if ($[10] !== t6) { - t7 = { - context: "Global", - isActive: t6 - }; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; + + const handleCancel = () => { + logEvent('tengu_teleport_cancelled', {}) + onCancel() } - useKeybinding("app:interrupt", handleCancel, t7); + + // Allow Esc to dismiss the error state + useKeybinding('app:interrupt', handleCancel, { + context: 'Global', + isActive: !!error && !onError, + }) + + // Show loading spinner when resuming if (isResuming && selectedSession) { - let t8; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Resuming session…; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] !== selectedSession.title) { - t9 = {t8}Loading "{selectedSession.title}"…; - $[13] = selectedSession.title; - $[14] = t9; - } else { - t9 = $[14]; - } - return t9; + return ( + + + + Resuming session… + + Loading "{selectedSession.title}"… + + ) } + + // Show error if there was a problem resuming if (error && !onError) { - let t8; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Failed to resume session; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] !== error.message) { - t9 = {error.message}; - $[16] = error.message; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Press Esc to cancel; - $[18] = t10; - } else { - t10 = $[18]; - } - let t11; - if ($[19] !== t9) { - t11 = {t8}{t9}{t10}; - $[19] = t9; - $[20] = t11; - } else { - t11 = $[20]; - } - return t11; + return ( + + + Failed to resume session + + {error.message} + + + Press Esc to cancel + + + + ) } - let t8; - if ($[21] !== handleCancel || $[22] !== handleSelect || $[23] !== isEmbedded) { - t8 = ; - $[21] = handleCancel; - $[22] = handleSelect; - $[23] = isEmbedded; - $[24] = t8; - } else { - t8 = $[24]; - } - return t8; + + return ( + + ) } diff --git a/src/components/TeleportStash.tsx b/src/components/TeleportStash.tsx index 9c7b63d86..8baa30580 100644 --- a/src/components/TeleportStash.tsx +++ b/src/components/TeleportStash.tsx @@ -1,82 +1,96 @@ -import figures from 'figures'; -import React, { useEffect, useState } from 'react'; -import { Box, Text } from '../ink.js'; -import { logForDebugging } from '../utils/debug.js'; -import type { GitFileStatus } from '../utils/git.js'; -import { getFileStatus, stashToCleanState } from '../utils/git.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -import { Spinner } from './Spinner.js'; +import figures from 'figures' +import React, { useEffect, useState } from 'react' +import { Box, Text } from '../ink.js' +import { logForDebugging } from '../utils/debug.js' +import type { GitFileStatus } from '../utils/git.js' +import { getFileStatus, stashToCleanState } from '../utils/git.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { Spinner } from './Spinner.js' + type TeleportStashProps = { - onStashAndContinue: () => void; - onCancel: () => void; -}; + onStashAndContinue: () => void + onCancel: () => void +} + export function TeleportStash({ onStashAndContinue, - onCancel + onCancel, }: TeleportStashProps): React.ReactNode { - const [gitFileStatus, setGitFileStatus] = useState(null); - const changedFiles = gitFileStatus !== null ? [...gitFileStatus.tracked, ...gitFileStatus.untracked] : []; - const [loading, setLoading] = useState(true); - const [stashing, setStashing] = useState(false); - const [error, setError] = useState(null); + const [gitFileStatus, setGitFileStatus] = useState(null) + const changedFiles = + gitFileStatus !== null + ? [...gitFileStatus.tracked, ...gitFileStatus.untracked] + : [] + const [loading, setLoading] = useState(true) + const [stashing, setStashing] = useState(false) + const [error, setError] = useState(null) // Load changed files on mount useEffect(() => { const loadChangedFiles = async () => { try { - const fileStatus = await getFileStatus(); - setGitFileStatus(fileStatus); + const fileStatus = await getFileStatus() + setGitFileStatus(fileStatus) } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); + const errorMessage = err instanceof Error ? err.message : String(err) logForDebugging(`Error getting changed files: ${errorMessage}`, { - level: 'error' - }); - setError('Failed to get changed files'); + level: 'error', + }) + setError('Failed to get changed files') } finally { - setLoading(false); + setLoading(false) } - }; - void loadChangedFiles(); - }, []); + } + + void loadChangedFiles() + }, []) + const handleStash = async () => { - setStashing(true); + setStashing(true) try { - logForDebugging('Stashing changes before teleport...'); - const success = await stashToCleanState('Teleport auto-stash'); + logForDebugging('Stashing changes before teleport...') + const success = await stashToCleanState('Teleport auto-stash') + if (success) { - logForDebugging('Successfully stashed changes'); - onStashAndContinue(); + logForDebugging('Successfully stashed changes') + onStashAndContinue() } else { - setError('Failed to stash changes'); + setError('Failed to stash changes') } - } catch (err_0) { - const errorMessage_0 = err_0 instanceof Error ? err_0.message : String(err_0); - logForDebugging(`Error stashing changes: ${errorMessage_0}`, { - level: 'error' - }); - setError('Failed to stash changes'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + logForDebugging(`Error stashing changes: ${errorMessage}`, { + level: 'error', + }) + setError('Failed to stash changes') } finally { - setStashing(false); + setStashing(false) } - }; + } + const handleSelectChange = (value: string) => { if (value === 'stash') { - void handleStash(); + void handleStash() } else { - onCancel(); + onCancel() } - }; + } + if (loading) { - return + return ( + Checking git status{figures.ellipsis} - ; + + ) } + if (error) { - return + return ( + Error: {error} @@ -85,31 +99,50 @@ export function TeleportStash({ Escape to cancel - ; + + ) } - const showFileCount = changedFiles.length > 8; - return + + const showFileCount = changedFiles.length > 8 + + return ( + Teleport will switch git branches. The following changes were found: - {changedFiles.length > 0 ? showFileCount ? {changedFiles.length} files changed : changedFiles.map((file: string, index: number) => {file}) : No changes detected} + {changedFiles.length > 0 ? ( + showFileCount ? ( + {changedFiles.length} files changed + ) : ( + changedFiles.map((file: string, index: number) => ( + {file} + )) + ) + ) : ( + No changes detected + )} Would you like to stash these changes and continue with teleport? - {stashing ? + {stashing ? ( + Stashing changes... - : + )} + + ) } diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx index a705256e3..486c73ef2 100644 --- a/src/components/TextInput.tsx +++ b/src/components/TextInput.tsx @@ -1,94 +1,108 @@ -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import React, { useMemo, useRef } from 'react'; -import { useVoiceState } from '../context/voice.js'; -import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js'; -import { useSettings } from '../hooks/useSettings.js'; -import { useTextInput } from '../hooks/useTextInput.js'; -import { Box, color, useAnimationFrame, useTerminalFocus, useTheme } from '../ink.js'; -import type { BaseTextInputProps } from '../types/textInputTypes.js'; -import { isEnvTruthy } from '../utils/envUtils.js'; -import type { TextHighlight } from '../utils/textHighlighting.js'; -import { BaseTextInput } from './BaseTextInput.js'; -import { hueToRgb } from './Spinner/utils.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import React, { useMemo, useRef } from 'react' +import { useVoiceState } from '../context/voice.js' +import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js' +import { useSettings } from '../hooks/useSettings.js' +import { useTextInput } from '../hooks/useTextInput.js' +import { + Box, + color, + useAnimationFrame, + useTerminalFocus, + useTheme, +} from '../ink.js' +import type { BaseTextInputProps } from '../types/textInputTypes.js' +import { isEnvTruthy } from '../utils/envUtils.js' +import type { TextHighlight } from '../utils/textHighlighting.js' +import { BaseTextInput } from './BaseTextInput.js' +import { hueToRgb } from './Spinner/utils.js' // Block characters for waveform bars: space (silent) + 8 rising block elements. -const BARS = ' \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588'; +const BARS = ' \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588' // Mini waveform cursor width -const CURSOR_WAVEFORM_WIDTH = 1; +const CURSOR_WAVEFORM_WIDTH = 1 // Smoothing factor (0 = instant, 1 = frozen). Applied as EMA to // smooth both rises and falls for a steady, non-jittery bar. -const SMOOTH = 0.7; +const SMOOTH = 0.7 // Boost factor for audio levels — computeLevel normalizes with a // conservative divisor (rms/2000), so normal speech sits around // 0.3-0.5. This multiplier lets the bar use the full range. -const LEVEL_BOOST = 1.8; +const LEVEL_BOOST = 1.8 // Raw audio level threshold (pre-boost) below which the cursor is // grey. computeLevel returns sqrt(rms/2000), so ambient mic noise // typically sits at 0.05-0.15. Speech starts around 0.2+. -const SILENCE_THRESHOLD = 0.15; +const SILENCE_THRESHOLD = 0.15 + export type Props = BaseTextInputProps & { - highlights?: TextHighlight[]; -}; + highlights?: TextHighlight[] +} + export default function TextInput(props: Props): React.ReactNode { - const [theme] = useTheme(); - const isTerminalFocused = useTerminalFocus(); + const [theme] = useTheme() + const isTerminalFocused = useTerminalFocus() // Hoisted to mount-time — this component re-renders on every keystroke. - const accessibilityEnabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY), []); - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle' as const; - const isVoiceRecording = voiceState === 'recording'; - const audioLevels = (feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_0 => s_0.voiceAudioLevels) : []) as number[]; - const smoothedRef = useRef(new Array(CURSOR_WAVEFORM_WIDTH).fill(0)); - const needsAnimation = isVoiceRecording && !reducedMotion; - const [animRef, animTime] = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAnimationFrame(needsAnimation ? 50 : null) : [() => {}, 0]; + const accessibilityEnabled = useMemo( + () => isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY), + [], + ) + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) + const isVoiceRecording = voiceState === 'recording' + + const audioLevels = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceAudioLevels) + : [] + const smoothedRef = useRef(new Array(CURSOR_WAVEFORM_WIDTH).fill(0)) + + const needsAnimation = isVoiceRecording && !reducedMotion + const [animRef, animTime] = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAnimationFrame(needsAnimation ? 50 : null) + : [() => {}, 0] // Show hint when terminal regains focus and clipboard has an image - useClipboardImageHint(isTerminalFocused, !!props.onImagePaste); + useClipboardImageHint(isTerminalFocused, !!props.onImagePaste) // Cursor invert function: mini waveform during voice recording, // standard chalk.inverse otherwise. No warmup pulse — the ~120ms // warmup window is too short for a 1s-period pulse to register, and // driving TextInput re-renders at 50ms during warmup (while spaces // are simultaneously arriving every 30-80ms) causes visible stutter. - const canShowCursor = isTerminalFocused && !accessibilityEnabled; - let invert: (text: string) => string; + const canShowCursor = isTerminalFocused && !accessibilityEnabled + let invert: (text: string) => string if (!canShowCursor) { - invert = (text: string) => text; + invert = (text: string) => text } else if (isVoiceRecording && !reducedMotion) { // Single-bar waveform from the latest audio level - const smoothed = smoothedRef.current; - const raw = audioLevels.length > 0 ? audioLevels[audioLevels.length - 1] ?? 0 : 0; - const target = Math.min(raw * LEVEL_BOOST, 1); - smoothed[0] = (smoothed[0] ?? 0) * SMOOTH + target * (1 - SMOOTH); - const displayLevel = smoothed[0] ?? 0; - const barIndex = Math.max(1, Math.min(Math.round(displayLevel * (BARS.length - 1)), BARS.length - 1)); - const isSilent = raw < SILENCE_THRESHOLD; - const hue = animTime / 1000 * 90 % 360; - const { - r, - g, - b - } = isSilent ? { - r: 128, - g: 128, - b: 128 - } : hueToRgb(hue); - invert = () => chalk.rgb(r, g, b)(BARS[barIndex]!); + const smoothed = smoothedRef.current + const raw = + audioLevels.length > 0 ? (audioLevels[audioLevels.length - 1] ?? 0) : 0 + const target = Math.min(raw * LEVEL_BOOST, 1) + smoothed[0] = (smoothed[0] ?? 0) * SMOOTH + target * (1 - SMOOTH) + const displayLevel = smoothed[0] ?? 0 + const barIndex = Math.max( + 1, + Math.min(Math.round(displayLevel * (BARS.length - 1)), BARS.length - 1), + ) + const isSilent = raw < SILENCE_THRESHOLD + const hue = ((animTime / 1000) * 90) % 360 + const { r, g, b } = isSilent ? { r: 128, g: 128, b: 128 } : hueToRgb(hue) + invert = () => chalk.rgb(r, g, b)(BARS[barIndex]!) } else { - invert = chalk.inverse; + invert = chalk.inverse } + const textInputState = useTextInput({ value: props.value, onChange: props.onChange, @@ -109,15 +123,26 @@ export default function TextInput(props: Props): React.ReactNode { columns: props.columns, maxVisibleLines: props.maxVisibleLines, onImagePaste: props.onImagePaste, - disableCursorMovementForUpDownKeys: props.disableCursorMovementForUpDownKeys, + disableCursorMovementForUpDownKeys: + props.disableCursorMovementForUpDownKeys, disableEscapeDoublePress: props.disableEscapeDoublePress, externalOffset: props.cursorOffset, onOffsetChange: props.onChangeCursorOffset, inputFilter: props.inputFilter, inlineGhostText: props.inlineGhostText, - dim: chalk.dim - }); - return - - ; + dim: chalk.dim, + }) + + return ( + + + + ) } diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index 338ce236d..b14bcfd2c 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -1,332 +1,229 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box, Text, usePreviewTheme, useTheme, useThemeSetting } from '../ink.js'; -import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { gracefulShutdown } from '../utils/gracefulShutdown.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import type { ThemeSetting } from '../utils/theme.js'; -import { Select } from './CustomSelect/index.js'; -import { Byline } from './design-system/Byline.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { getColorModuleUnavailableReason, getSyntaxTheme } from './StructuredDiff/colorDiff.js'; -import { StructuredDiff } from './StructuredDiff.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { + Box, + Text, + usePreviewTheme, + useTheme, + useThemeSetting, +} from '../ink.js' +import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { gracefulShutdown } from '../utils/gracefulShutdown.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import type { ThemeSetting } from '../utils/theme.js' +import { Select } from './CustomSelect/index.js' +import { Byline } from './design-system/Byline.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { + getColorModuleUnavailableReason, + getSyntaxTheme, +} from './StructuredDiff/colorDiff.js' +import { StructuredDiff } from './StructuredDiff.js' + export type ThemePickerProps = { - onThemeSelect: (setting: ThemeSetting) => void; - showIntroText?: boolean; - helpText?: string; - showHelpTextBelow?: boolean; - hideEscToCancel?: boolean; + onThemeSelect: (setting: ThemeSetting) => void + showIntroText?: boolean + helpText?: string + showHelpTextBelow?: boolean + hideEscToCancel?: boolean /** Skip exit handling when running in a context that already has it (e.g., onboarding) */ - skipExitHandling?: boolean; + skipExitHandling?: boolean /** Called when the user cancels (presses Escape). If skipExitHandling is true and this is provided, it will be called instead of just saving the preview. */ - onCancel?: () => void; -}; -export function ThemePicker(t0) { - const $ = _c(59); - const { - onThemeSelect, - showIntroText: t1, - helpText: t2, - showHelpTextBelow: t3, - hideEscToCancel: t4, - skipExitHandling: t5, - onCancel: onCancelProp - } = t0; - const showIntroText = t1 === undefined ? false : t1; - const helpText = t2 === undefined ? "" : t2; - const showHelpTextBelow = t3 === undefined ? false : t3; - const hideEscToCancel = t4 === undefined ? false : t4; - const skipExitHandling = t5 === undefined ? false : t5; - const [theme] = useTheme(); - const themeSetting = useThemeSetting(); - const { - columns - } = useTerminalSize(); - let t6; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t6 = getColorModuleUnavailableReason(); - $[0] = t6; - } else { - t6 = $[0]; - } - const colorModuleUnavailableReason = t6; - let t7; - if ($[1] !== theme) { - t7 = colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null; - $[1] = theme; - $[2] = t7; - } else { - t7 = $[2]; - } - const syntaxTheme = t7; - const { - setPreviewTheme, - savePreview, - cancelPreview - } = usePreviewTheme(); - const syntaxHighlightingDisabled = useAppState(_temp) ?? false; - const setAppState = useSetAppState(); - useRegisterKeybindingContext("ThemePicker", undefined); - const syntaxToggleShortcut = useShortcutDisplay("theme:toggleSyntaxHighlighting", "ThemePicker", "ctrl+t"); - let t8; - if ($[3] !== setAppState || $[4] !== syntaxHighlightingDisabled) { - t8 = () => { + onCancel?: () => void +} + +export function ThemePicker({ + onThemeSelect, + showIntroText = false, + helpText = '', + showHelpTextBelow = false, + hideEscToCancel = false, + skipExitHandling = false, + onCancel: onCancelProp, +}: ThemePickerProps): React.ReactNode { + const [theme] = useTheme() + const themeSetting = useThemeSetting() + const { columns } = useTerminalSize() + const colorModuleUnavailableReason = getColorModuleUnavailableReason() + const syntaxTheme = + colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null + const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme() + const syntaxHighlightingDisabled = + useAppState(s => s.settings.syntaxHighlightingDisabled) ?? false + const setAppState = useSetAppState() + + // Register ThemePicker context so its keybindings take precedence over Global + useRegisterKeybindingContext('ThemePicker') + + const syntaxToggleShortcut = useShortcutDisplay( + 'theme:toggleSyntaxHighlighting', + 'ThemePicker', + 'ctrl+t', + ) + + useKeybinding( + 'theme:toggleSyntaxHighlighting', + () => { if (colorModuleUnavailableReason === null) { - const newValue = !syntaxHighlightingDisabled; - updateSettingsForSource("userSettings", { - syntaxHighlightingDisabled: newValue - }); + const newValue = !syntaxHighlightingDisabled + updateSettingsForSource('userSettings', { + syntaxHighlightingDisabled: newValue, + }) setAppState(prev => ({ ...prev, - settings: { - ...prev.settings, - syntaxHighlightingDisabled: newValue - } - })); + settings: { ...prev.settings, syntaxHighlightingDisabled: newValue }, + })) } - }; - $[3] = setAppState; - $[4] = syntaxHighlightingDisabled; - $[5] = t8; - } else { - t8 = $[5]; - } - let t9; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t9 = { - context: "ThemePicker" - }; - $[6] = t9; - } else { - t9 = $[6]; - } - useKeybinding("theme:toggleSyntaxHighlighting", t8, t9); - const exitState = useExitOnCtrlCDWithKeybindings(skipExitHandling ? _temp2 : undefined); - let t10; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t10 = [...(feature("AUTO_THEME") ? [{ - label: "Auto (match terminal)", - value: "auto" as const - }] : []), { - label: "Dark mode", - value: "dark" - }, { - label: "Light mode", - value: "light" - }, { - label: "Dark mode (colorblind-friendly)", - value: "dark-daltonized" - }, { - label: "Light mode (colorblind-friendly)", - value: "light-daltonized" - }, { - label: "Dark mode (ANSI colors only)", - value: "dark-ansi" - }, { - label: "Light mode (ANSI colors only)", - value: "light-ansi" - }]; - $[7] = t10; - } else { - t10 = $[7]; - } - const themeOptions = t10; - let t11; - if ($[8] !== showIntroText) { - t11 = showIntroText ? Let's get started. : Theme; - $[8] = showIntroText; - $[9] = t11; - } else { - t11 = $[9]; - } - let t12; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Choose the text style that looks best with your terminal; - $[10] = t12; - } else { - t12 = $[10]; - } - let t13; - if ($[11] !== helpText || $[12] !== showHelpTextBelow) { - t13 = helpText && !showHelpTextBelow && {helpText}; - $[11] = helpText; - $[12] = showHelpTextBelow; - $[13] = t13; - } else { - t13 = $[13]; - } - let t14; - if ($[14] !== t13) { - t14 = {t12}{t13}; - $[14] = t13; - $[15] = t14; - } else { - t14 = $[15]; - } - let t15; - if ($[16] !== setPreviewTheme) { - t15 = setting => { - setPreviewTheme(setting as ThemeSetting); - }; - $[16] = setPreviewTheme; - $[17] = t15; - } else { - t15 = $[17]; - } - let t16; - if ($[18] !== onThemeSelect || $[19] !== savePreview) { - t16 = setting_0 => { - savePreview(); - onThemeSelect(setting_0 as ThemeSetting); - }; - $[18] = onThemeSelect; - $[19] = savePreview; - $[20] = t16; - } else { - t16 = $[20]; - } - let t17; - if ($[21] !== cancelPreview || $[22] !== onCancelProp || $[23] !== skipExitHandling) { - t17 = skipExitHandling ? () => { - cancelPreview(); - onCancelProp?.(); - } : async () => { - cancelPreview(); - await gracefulShutdown(0); - }; - $[21] = cancelPreview; - $[22] = onCancelProp; - $[23] = skipExitHandling; - $[24] = t17; - } else { - t17 = $[24]; - } - let t18; - if ($[25] !== t15 || $[26] !== t16 || $[27] !== t17 || $[28] !== themeSetting) { - t18 = { + setPreviewTheme(setting as ThemeSetting) + }} + onChange={(setting: string) => { + savePreview() + onThemeSelect(setting as ThemeSetting) + }} + onCancel={ + skipExitHandling + ? () => { + cancelPreview() + onCancelProp?.() + } + : async () => { + cancelPreview() + await gracefulShutdown(0) + } + } + visibleOptionCount={themeOptions.length} + defaultValue={themeSetting} + defaultFocusValue={themeSetting} + /> + + + + + + + {' '} + {colorModuleUnavailableReason === 'env' + ? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` + : syntaxHighlightingDisabled + ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` + : syntaxTheme + ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ''} (${syntaxToggleShortcut} to disable)` + : `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`} + + + + ) + + // Only wrap in a box when not in onboarding if (!showIntroText) { - let t26; - if ($[45] !== content) { - t26 = {content}; - $[45] = content; - $[46] = t26; - } else { - t26 = $[46]; - } - let t27; - if ($[47] !== helpText || $[48] !== showHelpTextBelow) { - t27 = showHelpTextBelow && helpText && {helpText}; - $[47] = helpText; - $[48] = showHelpTextBelow; - $[49] = t27; - } else { - t27 = $[49]; - } - let t28; - if ($[50] !== exitState || $[51] !== hideEscToCancel) { - t28 = !hideEscToCancel && {exitState.pending ? <>Press {exitState.keyName} again to exit : }; - $[50] = exitState; - $[51] = hideEscToCancel; - $[52] = t28; - } else { - t28 = $[52]; - } - let t29; - if ($[53] !== t27 || $[54] !== t28) { - t29 = {t27}{t28}; - $[53] = t27; - $[54] = t28; - $[55] = t29; - } else { - t29 = $[55]; - } - let t30; - if ($[56] !== t26 || $[57] !== t29) { - t30 = <>{t26}{t29}; - $[56] = t26; - $[57] = t29; - $[58] = t30; - } else { - t30 = $[58]; - } - return t30; - } - return content; -} -function _temp2() {} -function _temp(s) { - return s.settings.syntaxHighlightingDisabled; + return ( + <> + {content} + + {showHelpTextBelow && helpText && ( + + {helpText} + + )} + {!hideEscToCancel && ( + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + + + + + )} + + + )} + + + ) + } + + return content } diff --git a/src/components/ThinkingToggle.tsx b/src/components/ThinkingToggle.tsx index d8f4eb6b0..f17636cde 100644 --- a/src/components/ThinkingToggle.tsx +++ b/src/components/ThinkingToggle.tsx @@ -1,152 +1,135 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/index.js'; -import { Byline } from './design-system/Byline.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Pane } from './design-system/Pane.js'; +import * as React from 'react' +import { useState } from 'react' +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/index.js' +import { Byline } from './design-system/Byline.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Pane } from './design-system/Pane.js' + export type Props = { - currentValue: boolean; - onSelect: (enabled: boolean) => void; - onCancel?: () => void; - isMidConversation?: boolean; -}; -export function ThinkingToggle(t0) { - const $ = _c(27); - const { - currentValue, - onSelect, - onCancel, - isMidConversation - } = t0; - const exitState = useExitOnCtrlCDWithKeybindings(); - const [confirmationPending, setConfirmationPending] = useState(null); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = [{ - value: "true", - label: "Enabled", - description: "Claude will think before responding" - }, { - value: "false", - label: "Disabled", - description: "Claude will respond without extended thinking" - }]; - $[0] = t1; - } else { - t1 = $[0]; - } - const options = t1; - let t2; - if ($[1] !== confirmationPending || $[2] !== onCancel) { - t2 = () => { + currentValue: boolean + onSelect: (enabled: boolean) => void + onCancel?: () => void + isMidConversation?: boolean +} + +export function ThinkingToggle({ + currentValue, + onSelect, + onCancel, + isMidConversation, +}: Props): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings() + const [confirmationPending, setConfirmationPending] = useState< + boolean | null + >(null) + + const options = [ + { + value: 'true', + label: 'Enabled', + description: 'Claude will think before responding', + }, + { + value: 'false', + label: 'Disabled', + description: 'Claude will respond without extended thinking', + }, + ] + + // Use configurable keybinding for ESC to cancel/go back + useKeybinding( + 'confirm:no', + () => { if (confirmationPending !== null) { - setConfirmationPending(null); + setConfirmationPending(null) } else { - onCancel?.(); + onCancel?.() } - }; - $[1] = confirmationPending; - $[2] = onCancel; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[4] = t3; - } else { - t3 = $[4]; - } - useKeybinding("confirm:no", t2, t3); - let t4; - if ($[5] !== confirmationPending || $[6] !== onSelect) { - t4 = () => { + }, + { context: 'Confirmation' }, + ) + + // Use configurable keybinding for Enter to confirm in confirmation mode + useKeybinding( + 'confirm:yes', + () => { if (confirmationPending !== null) { - onSelect(confirmationPending); - } - }; - $[5] = confirmationPending; - $[6] = onSelect; - $[7] = t4; - } else { - t4 = $[7]; - } - const t5 = confirmationPending !== null; - let t6; - if ($[8] !== t5) { - t6 = { - context: "Confirmation", - isActive: t5 - }; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - useKeybinding("confirm:yes", t4, t6); - let t7; - if ($[10] !== currentValue || $[11] !== isMidConversation || $[12] !== onSelect) { - t7 = function handleSelectChange(value) { - const selected = value === "true"; - if (isMidConversation && selected !== currentValue) { - setConfirmationPending(selected); - } else { - onSelect(selected); + onSelect(confirmationPending) } - }; - $[10] = currentValue; - $[11] = isMidConversation; - $[12] = onSelect; - $[13] = t7; - } else { - t7 = $[13]; - } - const handleSelectChange = t7; - let t8; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Toggle thinking modeEnable or disable thinking for this session.; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== confirmationPending || $[16] !== currentValue || $[17] !== handleSelectChange || $[18] !== onCancel) { - t9 = {t8}{confirmationPending !== null ? Changing thinking mode mid-conversation will increase latency and may reduce quality. For best results, set this at the start of a session.Do you want to proceed? : {})} + visibleOptionCount={2} + /> + + )} + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : confirmationPending !== null ? ( + + + + + ) : ( + + + + + )} + + + ) } -function _temp() {} diff --git a/src/components/TokenWarning.tsx b/src/components/TokenWarning.tsx index fe42c7ca1..3ccb7235f 100644 --- a/src/components/TokenWarning.tsx +++ b/src/components/TokenWarning.tsx @@ -1,16 +1,20 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useSyncExternalStore } from 'react'; -import { Box, Text } from '../ink.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { calculateTokenWarningState, getEffectiveContextWindowSize, isAutoCompactEnabled } from '../services/compact/autoCompact.js'; -import { useCompactWarningSuppression } from '../services/compact/compactWarningHook.js'; -import { getUpgradeMessage } from '../utils/model/contextWindowUpgradeCheck.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useSyncExternalStore } from 'react' +import { Box, Text } from '../ink.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { + calculateTokenWarningState, + getEffectiveContextWindowSize, + isAutoCompactEnabled, +} from '../services/compact/autoCompact.js' +import { useCompactWarningSuppression } from '../services/compact/compactWarningHook.js' +import { getUpgradeMessage } from '../utils/model/contextWindowUpgradeCheck.js' + type Props = { - tokenUsage: number; - model: string; -}; + tokenUsage: number + model: string +} /** * Live collapse progress: "x / y summarized". Sub-component so @@ -18,161 +22,134 @@ type Props = { * (hooks-in-conditionals would violate React rules). The parent only * renders this when feature('CONTEXT_COLLAPSE') + isContextCollapseEnabled(). */ -function CollapseLabel(t0) { - const $ = _c(8); - const { - upgradeMessage - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = require("../services/contextCollapse/index.js"); - $[0] = t1; - } else { - t1 = $[0]; - } - const { - getStats, - subscribe - } = t1 as typeof import('../services/contextCollapse/index.js'); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - const s = getStats(); - const idleWarn = s.health.emptySpawnWarningEmitted ? 1 : 0; - return `${s.collapsedSpans}|${s.stagedSpans}|${s.health.totalErrors}|${s.health.totalEmptySpawns}|${idleWarn}`; - }; - $[1] = t2; - } else { - t2 = $[1]; - } - const snapshot = useSyncExternalStore(subscribe, t2); - let t3; - if ($[2] !== snapshot) { - t3 = (snapshot as string).split("|").map(Number); - $[2] = snapshot; - $[3] = t3; - } else { - t3 = $[3]; - } - const [collapsed, staged, errors, emptySpawns, idleWarn_0] = t3 as [number, number, number, number, number]; - const total = collapsed + staged; - if (errors > 0 || idleWarn_0) { - const problem = errors > 0 ? `collapse errors: ${errors}` : `collapse idle (${emptySpawns} empty runs)`; - const t4 = total > 0 ? `${collapsed} / ${total} summarized \u00b7 ${problem}` : problem; - let t5; - if ($[4] !== t4) { - t5 = {t4}; - $[4] = t4; - $[5] = t5; - } else { - t5 = $[5]; - } - return t5; - } - if (total === 0) { - return null; - } - const label = `${collapsed} / ${total} summarized`; - const t4 = upgradeMessage ? `${label} \u00b7 ${upgradeMessage}` : label; - let t5; - if ($[6] !== t4) { - t5 = {t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; +function CollapseLabel({ + upgradeMessage, +}: { + upgradeMessage: string | null +}): React.ReactNode { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { getStats, subscribe } = + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + + // Snapshot must be referentially stable across calls when the + // underlying counts haven't changed — returning a fresh object every + // time would infinite-loop useSyncExternalStore. Encode as a string. + const snapshot = useSyncExternalStore(subscribe, () => { + const s = getStats() + const idleWarn = s.health.emptySpawnWarningEmitted ? 1 : 0 + return `${s.collapsedSpans}|${s.stagedSpans}|${s.health.totalErrors}|${s.health.totalEmptySpawns}|${idleWarn}` + }) + + const [collapsed, staged, errors, emptySpawns, idleWarn] = snapshot + .split('|') + .map(Number) as [number, number, number, number, number] + const total = collapsed + staged + + // Show error indicator when ctx-agent is failing silently + if (errors > 0 || idleWarn) { + const problem = + errors > 0 + ? `collapse errors: ${errors}` + : `collapse idle (${emptySpawns} empty runs)` + return ( + + {total > 0 + ? `${collapsed} / ${total} summarized \u00b7 ${problem}` + : problem} + + ) } - return t5; + + if (total === 0) return null + + const label = `${collapsed} / ${total} summarized` + return ( + + {upgradeMessage ? `${label} \u00b7 ${upgradeMessage}` : label} + + ) } -export function TokenWarning(t0) { - const $ = _c(13); - const { - tokenUsage, - model - } = t0; - let t1; - if ($[0] !== model || $[1] !== tokenUsage) { - t1 = calculateTokenWarningState(tokenUsage, model); - $[0] = model; - $[1] = tokenUsage; - $[2] = t1; - } else { - t1 = $[2]; - } - const { - percentLeft, - isAboveWarningThreshold, - isAboveErrorThreshold - } = t1; - const suppressWarning = useCompactWarningSuppression(); + +export function TokenWarning({ tokenUsage, model }: Props): React.ReactNode { + const { percentLeft, isAboveWarningThreshold, isAboveErrorThreshold } = + calculateTokenWarningState(tokenUsage, model) + + // Use reactive hook to check if warning should be suppressed + const suppressWarning = useCompactWarningSuppression() + if (!isAboveWarningThreshold || suppressWarning) { - return null; - } - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = isAutoCompactEnabled(); - $[3] = t2; - } else { - t2 = $[3]; + return null } - const showAutoCompactWarning = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = getUpgradeMessage("warning"); - $[4] = t3; - } else { - t3 = $[4]; - } - const upgradeMessage = t3; - let displayPercentLeft = percentLeft; - let reactiveOnlyMode = false; - let collapseMode = false; - if (feature("REACTIVE_COMPACT")) { - if (getFeatureValue_CACHED_MAY_BE_STALE("tengu_cobalt_raccoon", false)) { - reactiveOnlyMode = true; + + const showAutoCompactWarning = isAutoCompactEnabled() + const upgradeMessage = getUpgradeMessage('warning') + + // Reactive-only or context-collapse mode: proactive autocompact never + // fires, so percentLeft's normal calculation (against the autocompact + // threshold) counts down to an event that won't happen. Recompute + // against the effective window so the percentage is honest. + // + // Each feature() block stands alone so the flag strings DCE from + // external builds independently. + let displayPercentLeft = percentLeft + let reactiveOnlyMode = false + let collapseMode = false + if (feature('REACTIVE_COMPACT')) { + if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) { + reactiveOnlyMode = true } } - if (feature("CONTEXT_COLLAPSE")) { - const { - isContextCollapseEnabled - } = require("../services/contextCollapse/index.js") as typeof import('../services/contextCollapse/index.js'); + if (feature('CONTEXT_COLLAPSE')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isContextCollapseEnabled } = + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + /* eslint-enable @typescript-eslint/no-require-imports */ if (isContextCollapseEnabled()) { - collapseMode = true; + collapseMode = true } } if (reactiveOnlyMode || collapseMode) { - const effectiveWindow = getEffectiveContextWindowSize(model); - let t4; - if ($[5] !== effectiveWindow || $[6] !== tokenUsage) { - t4 = Math.round((effectiveWindow - tokenUsage) / effectiveWindow * 100); - $[5] = effectiveWindow; - $[6] = tokenUsage; - $[7] = t4; - } else { - t4 = $[7]; - } - displayPercentLeft = Math.max(0, t4); - } - if (collapseMode && feature("CONTEXT_COLLAPSE")) { - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + const effectiveWindow = getEffectiveContextWindowSize(model) + displayPercentLeft = Math.max( + 0, + Math.round(((effectiveWindow - tokenUsage) / effectiveWindow) * 100), + ) } - const autocompactLabel = reactiveOnlyMode ? `${100 - displayPercentLeft}% context used` : `${displayPercentLeft}% until auto-compact`; - let t4; - if ($[9] !== autocompactLabel || $[10] !== isAboveErrorThreshold || $[11] !== percentLeft) { - t4 = {showAutoCompactWarning ? {upgradeMessage ? `${autocompactLabel} \u00b7 ${upgradeMessage}` : autocompactLabel} : {upgradeMessage ? `Context low (${percentLeft}% remaining) \u00b7 ${upgradeMessage}` : `Context low (${percentLeft}% remaining) \u00b7 Run /compact to compact & continue`}}; - $[9] = autocompactLabel; - $[10] = isAboveErrorThreshold; - $[11] = percentLeft; - $[12] = t4; - } else { - t4 = $[12]; + + // Collapse mode: delegate to the subscribing sub-component so the + // indicator updates live as the ctx-agent stages and commits fire, not + // just when the next API response re-renders TokenWarning. + if (collapseMode && feature('CONTEXT_COLLAPSE')) { + return ( + + + + ) } - return t4; + + const autocompactLabel = reactiveOnlyMode + ? `${100 - displayPercentLeft}% context used` + : `${displayPercentLeft}% until auto-compact` + + return ( + + {showAutoCompactWarning ? ( + + {upgradeMessage + ? `${autocompactLabel} \u00b7 ${upgradeMessage}` + : autocompactLabel} + + ) : ( + + {upgradeMessage + ? `Context low (${percentLeft}% remaining) \u00b7 ${upgradeMessage}` + : `Context low (${percentLeft}% remaining) \u00b7 Run /compact to compact & continue`} + + )} + + ) } diff --git a/src/components/ToolUseLoader.tsx b/src/components/ToolUseLoader.tsx index b02549159..52652be99 100644 --- a/src/components/ToolUseLoader.tsx +++ b/src/components/ToolUseLoader.tsx @@ -1,41 +1,38 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { BLACK_CIRCLE } from '../constants/figures.js'; -import { useBlink } from '../hooks/useBlink.js'; -import { Box, Text } from '../ink.js'; +import React from 'react' +import { BLACK_CIRCLE } from '../constants/figures.js' +import { useBlink } from '../hooks/useBlink.js' +import { Box, Text } from '../ink.js' + type Props = { - isError: boolean; - isUnresolved: boolean; - shouldAnimate: boolean; -}; -export function ToolUseLoader(t0) { - const $ = _c(7); - const { - isError, - isUnresolved, - shouldAnimate - } = t0; - const [ref, isBlinking] = useBlink(shouldAnimate); - const color = isUnresolved ? undefined : isError ? "error" : "success"; - const t1 = !shouldAnimate || isBlinking || isError || !isUnresolved ? BLACK_CIRCLE : " "; - let t2; - if ($[0] !== color || $[1] !== isUnresolved || $[2] !== t1) { - t2 = {t1}; - $[0] = color; - $[1] = isUnresolved; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== ref || $[5] !== t2) { - t3 = {t2}; - $[4] = ref; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; + isError: boolean + isUnresolved: boolean + shouldAnimate: boolean +} + +export function ToolUseLoader({ + isError, + isUnresolved, + shouldAnimate, +}: Props): React.ReactNode { + const [ref, isBlinking] = useBlink(shouldAnimate) + + const color = isUnresolved ? undefined : isError ? 'error' : 'success' + + // WARNING: The code here and in AssistantToolUseMessage is particularly + // sensitive to what *should* just be trivial refactorings. A `x` + // followed *immediately* by `y` tag incorrectly renders `y` as + // dim! This is because `` and `` are both reset by \x1b[22m + // due to historical reasons, and chalk can't distinguish between them. + // The symptom you'll see if we get this wrong is the tool name blinks along + // with this loading indicator, which looks quite bad. + // https://github.com/chalk/chalk/issues/290 + return ( + + + {!shouldAnimate || isBlinking || isError || !isUnresolved + ? BLACK_CIRCLE + : ' '} + + + ) } diff --git a/src/components/TrustDialog/TrustDialog.tsx b/src/components/TrustDialog/TrustDialog.tsx index fcca334b3..a57408392 100644 --- a/src/components/TrustDialog/TrustDialog.tsx +++ b/src/components/TrustDialog/TrustDialog.tsx @@ -1,289 +1,230 @@ -import { c as _c } from "react/compiler-runtime"; -import { homedir } from 'os'; -import React from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { setSessionTrustAccepted } from '../../bootstrap/state.js'; -import type { Command } from '../../commands.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Link, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { getMcpConfigsByScope } from '../../services/mcp/config.js'; -import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'; -import { checkHasTrustDialogAccepted, saveCurrentProjectConfig } from '../../utils/config.js'; -import { getCwd } from '../../utils/cwd.js'; -import { getFsImplementation } from '../../utils/fsOperations.js'; -import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; -import { Select } from '../CustomSelect/index.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; -import { getApiKeyHelperSources, getAwsCommandsSources, getBashPermissionSources, getDangerousEnvVarsSources, getGcpCommandsSources, getHooksSources, getOtelHeadersHelperSources } from './utils.js'; +import { homedir } from 'os' +import React from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { setSessionTrustAccepted } from '../../bootstrap/state.js' +import type { Command } from '../../commands.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Link, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { getMcpConfigsByScope } from '../../services/mcp/config.js' +import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' +import { + checkHasTrustDialogAccepted, + saveCurrentProjectConfig, +} from '../../utils/config.js' +import { getCwd } from '../../utils/cwd.js' +import { getFsImplementation } from '../../utils/fsOperations.js' +import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js' +import { Select } from '../CustomSelect/index.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' +import { + getApiKeyHelperSources, + getAwsCommandsSources, + getBashPermissionSources, + getDangerousEnvVarsSources, + getGcpCommandsSources, + getHooksSources, + getOtelHeadersHelperSources, +} from './utils.js' + type Props = { - onDone(): void; - commands?: Command[]; -}; -export function TrustDialog(t0) { - const $ = _c(33); - const { - onDone, - commands - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getMcpConfigsByScope("project"); - $[0] = t1; - } else { - t1 = $[0]; - } - const { - servers: projectServers - } = t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Object.keys(projectServers); - $[1] = t2; - } else { - t2 = $[1]; - } - const hasMcpServers = t2.length > 0; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = getHooksSources(); - $[2] = t3; - } else { - t3 = $[2]; - } - const hooksSettingSources = t3; - const hasHooks = hooksSettingSources.length > 0; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = getBashPermissionSources(); - $[3] = t4; - } else { - t4 = $[3]; - } - const bashSettingSources = t4; - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = getApiKeyHelperSources(); - $[4] = t5; - } else { - t5 = $[4]; - } - const apiKeyHelperSources = t5; - const hasApiKeyHelper = apiKeyHelperSources.length > 0; - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = getAwsCommandsSources(); - $[5] = t6; - } else { - t6 = $[5]; - } - const awsCommandsSources = t6; - const hasAwsCommands = awsCommandsSources.length > 0; - let t7; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t7 = getGcpCommandsSources(); - $[6] = t7; - } else { - t7 = $[6]; - } - const gcpCommandsSources = t7; - const hasGcpCommands = gcpCommandsSources.length > 0; - let t8; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t8 = getOtelHeadersHelperSources(); - $[7] = t8; - } else { - t8 = $[7]; - } - const otelHeadersHelperSources = t8; - const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0; - let t9; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t9 = getDangerousEnvVarsSources(); - $[8] = t9; - } else { - t9 = $[8]; - } - const dangerousEnvVarsSources = t9; - const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0; - let t10; - if ($[9] !== commands) { - t10 = commands?.some(_temp2) ?? false; - $[9] = commands; - $[10] = t10; - } else { - t10 = $[10]; - } - const hasSlashCommandBash = t10; - let t11; - if ($[11] !== commands) { - t11 = commands?.some(_temp4) ?? false; - $[11] = commands; - $[12] = t11; - } else { - t11 = $[12]; - } - const hasSkillsBash = t11; - const hasAnyBashExecution = bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash; - const hasTrustDialogAccepted = checkHasTrustDialogAccepted(); - let t12; - let t13; - if ($[13] !== hasAnyBashExecution) { - t12 = () => { - const isHomeDir = homedir() === getCwd(); - logEvent("tengu_trust_dialog_shown", { - isHomeDir, - hasMcpServers, - hasHooks, - hasBashExecution: hasAnyBashExecution, - hasApiKeyHelper, - hasAwsCommands, - hasGcpCommands, - hasOtelHeadersHelper, - hasDangerousEnvVars - }); - }; - t13 = [hasMcpServers, hasHooks, hasAnyBashExecution, hasApiKeyHelper, hasAwsCommands, hasGcpCommands, hasOtelHeadersHelper, hasDangerousEnvVars]; - $[13] = hasAnyBashExecution; - $[14] = t12; - $[15] = t13; - } else { - t12 = $[14]; - t13 = $[15]; - } - React.useEffect(t12, t13); - let t14; - if ($[16] !== hasAnyBashExecution || $[17] !== onDone) { - t14 = function onChange(value) { - if (value === "exit") { - gracefulShutdownSync(1); - return; - } - const isHomeDir_0 = homedir() === getCwd(); - logEvent("tengu_trust_dialog_accept", { - isHomeDir: isHomeDir_0, - hasMcpServers, - hasHooks, - hasBashExecution: hasAnyBashExecution, - hasApiKeyHelper, - hasAwsCommands, - hasGcpCommands, - hasOtelHeadersHelper, - hasDangerousEnvVars - }); - if (isHomeDir_0) { - setSessionTrustAccepted(true); - } else { - saveCurrentProjectConfig(_temp5); - } - onDone(); - }; - $[16] = hasAnyBashExecution; - $[17] = onDone; - $[18] = t14; - } else { - t14 = $[18]; - } - const onChange = t14; - const exitState = useExitOnCtrlCDWithKeybindings(_temp6); - let t15; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t15 = { - context: "Confirmation" - }; - $[19] = t15; - } else { - t15 = $[19]; - } - useKeybinding("confirm:no", _temp7, t15); - if (hasTrustDialogAccepted) { - setTimeout(onDone); - return null; - } - let t16; - let t17; - let t18; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t16 = {getFsImplementation().cwd()}; - t17 = Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what{"'"}s in this folder first.; - t18 = Claude Code{"'"}ll be able to read, edit, and execute files here.; - $[20] = t16; - $[21] = t17; - $[22] = t18; - } else { - t16 = $[20]; - t17 = $[21]; - t18 = $[22]; - } - let t19; - if ($[23] === Symbol.for("react.memo_cache_sentinel")) { - t19 = Security guide; - $[23] = t19; - } else { - t19 = $[23]; - } - let t20; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t20 = [{ - label: "Yes, I trust this folder", - value: "enable_all" - }, { - label: "No, exit", - value: "exit" - }]; - $[24] = t20; - } else { - t20 = $[24]; - } - let t21; - if ($[25] !== onChange) { - t21 = onChange(value as 'enable_all' | 'exit')} + onCancel={() => onChange('exit')} + /> + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Enter to confirm · Esc to cancel + )} + + + + ) } diff --git a/src/components/ValidationErrorsList.tsx b/src/components/ValidationErrorsList.tsx index bb6be2e9e..a7eea5400 100644 --- a/src/components/ValidationErrorsList.tsx +++ b/src/components/ValidationErrorsList.tsx @@ -1,147 +1,178 @@ -import { c as _c } from "react/compiler-runtime"; -import setWith from 'lodash-es/setWith.js'; -import * as React from 'react'; -import { Box, Text, useTheme } from '../ink.js'; -import type { ValidationError } from '../utils/settings/validation.js'; -import { type TreeNode, treeify } from '../utils/treeify.js'; +import setWith from 'lodash-es/setWith.js' +import * as React from 'react' +import { Box, Text, useTheme } from '../ink.js' +import type { ValidationError } from '../utils/settings/validation.js' +import { type TreeNode, treeify } from '../utils/treeify.js' /** * Builds a nested tree structure from dot-notation paths * Uses lodash setWith to avoid automatic array creation */ function buildNestedTree(errors: ValidationError[]): TreeNode { - const tree: TreeNode = {}; + const tree: TreeNode = {} + errors.forEach(error => { if (!error.path) { // Root level error - use empty string as key - tree[''] = error.message; - return; + tree[''] = error.message + return } // Try to enhance the path with meaningful values - const pathParts = error.path.split('.'); - let modifiedPath = error.path; + const pathParts = error.path.split('.') + let modifiedPath = error.path // If we have an invalid value, try to make the path more readable - if (error.invalidValue !== null && error.invalidValue !== undefined && pathParts.length > 0) { - const newPathParts: string[] = []; + if ( + error.invalidValue !== null && + error.invalidValue !== undefined && + pathParts.length > 0 + ) { + const newPathParts: string[] = [] + for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i]; - if (!part) continue; - const numericPart = parseInt(part, 10); + const part = pathParts[i] + if (!part) continue + + const numericPart = parseInt(part, 10) // If this is a numeric index and it's the last part where we have the invalid value if (!isNaN(numericPart) && i === pathParts.length - 1) { // Format the value for display - let displayValue: string; + let displayValue: string if (typeof error.invalidValue === 'string') { - displayValue = `"${error.invalidValue}"`; + displayValue = `"${error.invalidValue}"` } else if (error.invalidValue === null) { - displayValue = 'null'; + displayValue = 'null' } else if (error.invalidValue === undefined) { - displayValue = 'undefined'; + displayValue = 'undefined' } else { - displayValue = String(error.invalidValue); + displayValue = String(error.invalidValue) } - newPathParts.push(displayValue); + + newPathParts.push(displayValue) } else { // Keep other parts as-is - newPathParts.push(part); + newPathParts.push(part) } } - modifiedPath = newPathParts.join('.'); + + modifiedPath = newPathParts.join('.') } - setWith(tree, modifiedPath, error.message, Object); - }); - return tree; + + setWith(tree, modifiedPath, error.message, Object) + }) + + return tree } /** * Groups and displays validation errors using treeify with deduplication */ -export function ValidationErrorsList(t0) { - const $ = _c(9); - const { - errors - } = t0; - const [themeName] = useTheme(); +export function ValidationErrorsList({ + errors, +}: { + errors: ValidationError[] +}): React.ReactNode { + const [themeName] = useTheme() + if (errors.length === 0) { - return null; + return null } - let T0; - let t1; - let t2; - if ($[0] !== errors || $[1] !== themeName) { - const errorsByFile = errors.reduce(_temp, {}); - const sortedFiles = Object.keys(errorsByFile).sort(); - T0 = Box; - t1 = "column"; - t2 = sortedFiles.map(file_0 => { - const fileErrors = errorsByFile[file_0] || []; - fileErrors.sort(_temp2); - const errorTree = buildNestedTree(fileErrors); - const suggestionPairs = new Map(); - fileErrors.forEach(error_0 => { - if (error_0.suggestion || error_0.docLink) { - const key = `${error_0.suggestion || ""}|${error_0.docLink || ""}`; - if (!suggestionPairs.has(key)) { - suggestionPairs.set(key, { - suggestion: error_0.suggestion, - docLink: error_0.docLink - }); + + // Group errors by file + const errorsByFile = errors.reduce>( + (acc, error) => { + const file = error.file || '(file not specified)' + if (!acc[file]) { + acc[file] = [] + } + acc[file]!.push(error) + return acc + }, + {}, + ) + + // Sort files alphabetically + const sortedFiles = Object.keys(errorsByFile).sort() + + return ( + + {sortedFiles.map(file => { + const fileErrors = errorsByFile[file] || [] + + // Sort errors by path + fileErrors.sort((a, b) => { + if (!a.path && b.path) return -1 + if (a.path && !b.path) return 1 + return (a.path || '').localeCompare(b.path || '') + }) + + // Build nested tree structure from error paths + const errorTree = buildNestedTree(fileErrors) + + // Collect unique suggestion+docLink pairs + const suggestionPairs = new Map< + string, + { suggestion?: string; docLink?: string } + >() + + fileErrors.forEach(error => { + if (error.suggestion || error.docLink) { + // Create a key from suggestion+docLink combination + const key = `${error.suggestion || ''}|${error.docLink || ''}` + if (!suggestionPairs.has(key)) { + suggestionPairs.set(key, { + suggestion: error.suggestion, + docLink: error.docLink, + }) + } } - } - }); - const treeOutput = treeify(errorTree, { - showValues: true, - themeName, - treeCharColors: { - treeChar: "inactive", - key: "text", - value: "inactive" - } - }); - return {file_0}{treeOutput}{suggestionPairs.size > 0 && {Array.from(suggestionPairs.values()).map(_temp3)}}; - }); - $[0] = errors; - $[1] = themeName; - $[2] = T0; - $[3] = t1; - $[4] = t2; - } else { - T0 = $[2]; - t1 = $[3]; - t2 = $[4]; - } - let t3; - if ($[5] !== T0 || $[6] !== t1 || $[7] !== t2) { - t3 = {t2}; - $[5] = T0; - $[6] = t1; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; -} -function _temp3(pair, index) { - return {pair.suggestion && {pair.suggestion}}{pair.docLink && Learn more: {pair.docLink}}; -} -function _temp2(a, b) { - if (!a.path && b.path) { - return -1; - } - if (a.path && !b.path) { - return 1; - } - return (a.path || "").localeCompare(b.path || ""); -} -function _temp(acc, error) { - const file = error.file || "(file not specified)"; - if (!acc[file]) { - acc[file] = []; - } - acc[file].push(error); - return acc; + }) + + // Render the tree + const treeOutput = treeify(errorTree, { + showValues: true, + themeName, + treeCharColors: { + treeChar: 'inactive', + key: 'text', + value: 'inactive', + }, + }) + + return ( + + {file} + + {treeOutput} + + {/* Display unique suggestion+docLink pairs */} + {suggestionPairs.size > 0 && ( + + {Array.from(suggestionPairs.values()).map((pair, index) => ( + + {pair.suggestion && ( + + {pair.suggestion} + + )} + {pair.docLink && ( + + Learn more: {pair.docLink} + + )} + + ))} + + )} + + ) + })} + + ) } diff --git a/src/components/VimTextInput.tsx b/src/components/VimTextInput.tsx index 59fed4972..bc8e8211f 100644 --- a/src/components/VimTextInput.tsx +++ b/src/components/VimTextInput.tsx @@ -1,139 +1,69 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import React from 'react'; -import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js'; -import { useVimInput } from '../hooks/useVimInput.js'; -import { Box, color, useTerminalFocus, useTheme } from '../ink.js'; -import type { VimTextInputProps } from '../types/textInputTypes.js'; -import type { TextHighlight } from '../utils/textHighlighting.js'; -import { BaseTextInput } from './BaseTextInput.js'; +import chalk from 'chalk' +import React from 'react' +import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js' +import { useVimInput } from '../hooks/useVimInput.js' +import { Box, color, useTerminalFocus, useTheme } from '../ink.js' +import type { VimTextInputProps } from '../types/textInputTypes.js' +import type { TextHighlight } from '../utils/textHighlighting.js' +import { BaseTextInput } from './BaseTextInput.js' + export type Props = VimTextInputProps & { - highlights?: TextHighlight[]; -}; -export default function VimTextInput(props) { - const $ = _c(38); - const [theme] = useTheme(); - const isTerminalFocused = useTerminalFocus(); - useClipboardImageHint(isTerminalFocused, !!props.onImagePaste); - const t0 = props.value; - const t1 = props.onChange; - const t2 = props.onSubmit; - const t3 = props.onExit; - const t4 = props.onExitMessage; - const t5 = props.onHistoryReset; - const t6 = props.onHistoryUp; - const t7 = props.onHistoryDown; - const t8 = props.onClearInput; - const t9 = props.focus; - const t10 = props.mask; - const t11 = props.multiline; - const t12 = props.showCursor ? " " : ""; - const t13 = props.highlightPastedText; - const t14 = isTerminalFocused ? chalk.inverse : _temp; - let t15; - if ($[0] !== theme) { - t15 = color("text", theme); - $[0] = theme; - $[1] = t15; - } else { - t15 = $[1]; - } - let t16; - if ($[2] !== props.columns || $[3] !== props.cursorOffset || $[4] !== props.disableCursorMovementForUpDownKeys || $[5] !== props.disableEscapeDoublePress || $[6] !== props.focus || $[7] !== props.highlightPastedText || $[8] !== props.inputFilter || $[9] !== props.mask || $[10] !== props.maxVisibleLines || $[11] !== props.multiline || $[12] !== props.onChange || $[13] !== props.onChangeCursorOffset || $[14] !== props.onClearInput || $[15] !== props.onExit || $[16] !== props.onExitMessage || $[17] !== props.onHistoryDown || $[18] !== props.onHistoryReset || $[19] !== props.onHistoryUp || $[20] !== props.onImagePaste || $[21] !== props.onModeChange || $[22] !== props.onSubmit || $[23] !== props.onUndo || $[24] !== props.value || $[25] !== t12 || $[26] !== t14 || $[27] !== t15) { - t16 = { - value: t0, - onChange: t1, - onSubmit: t2, - onExit: t3, - onExitMessage: t4, - onHistoryReset: t5, - onHistoryUp: t6, - onHistoryDown: t7, - onClearInput: t8, - focus: t9, - mask: t10, - multiline: t11, - cursorChar: t12, - highlightPastedText: t13, - invert: t14, - themeText: t15, - columns: props.columns, - maxVisibleLines: props.maxVisibleLines, - onImagePaste: props.onImagePaste, - disableCursorMovementForUpDownKeys: props.disableCursorMovementForUpDownKeys, - disableEscapeDoublePress: props.disableEscapeDoublePress, - externalOffset: props.cursorOffset, - onOffsetChange: props.onChangeCursorOffset, - inputFilter: props.inputFilter, - onModeChange: props.onModeChange, - onUndo: props.onUndo - }; - $[2] = props.columns; - $[3] = props.cursorOffset; - $[4] = props.disableCursorMovementForUpDownKeys; - $[5] = props.disableEscapeDoublePress; - $[6] = props.focus; - $[7] = props.highlightPastedText; - $[8] = props.inputFilter; - $[9] = props.mask; - $[10] = props.maxVisibleLines; - $[11] = props.multiline; - $[12] = props.onChange; - $[13] = props.onChangeCursorOffset; - $[14] = props.onClearInput; - $[15] = props.onExit; - $[16] = props.onExitMessage; - $[17] = props.onHistoryDown; - $[18] = props.onHistoryReset; - $[19] = props.onHistoryUp; - $[20] = props.onImagePaste; - $[21] = props.onModeChange; - $[22] = props.onSubmit; - $[23] = props.onUndo; - $[24] = props.value; - $[25] = t12; - $[26] = t14; - $[27] = t15; - $[28] = t16; - } else { - t16 = $[28]; - } - const vimInputState = useVimInput(t16); - const { - mode, - setMode - } = vimInputState; - let t17; - let t18; - if ($[29] !== mode || $[30] !== props.initialMode || $[31] !== setMode) { - t17 = () => { - if (props.initialMode && props.initialMode !== mode) { - setMode(props.initialMode); - } - }; - t18 = [props.initialMode, mode, setMode]; - $[29] = mode; - $[30] = props.initialMode; - $[31] = setMode; - $[32] = t17; - $[33] = t18; - } else { - t17 = $[32]; - t18 = $[33]; - } - React.useEffect(t17, t18); - let t19; - if ($[34] !== isTerminalFocused || $[35] !== props || $[36] !== vimInputState) { - t19 = ; - $[34] = isTerminalFocused; - $[35] = props; - $[36] = vimInputState; - $[37] = t19; - } else { - t19 = $[37]; - } - return t19; + highlights?: TextHighlight[] } -function _temp(text) { - return text; + +export default function VimTextInput(props: Props): React.ReactNode { + const [theme] = useTheme() + const isTerminalFocused = useTerminalFocus() + + // Show hint when terminal regains focus and clipboard has an image + useClipboardImageHint(isTerminalFocused, !!props.onImagePaste) + + const vimInputState = useVimInput({ + value: props.value, + onChange: props.onChange, + onSubmit: props.onSubmit, + onExit: props.onExit, + onExitMessage: props.onExitMessage, + onHistoryReset: props.onHistoryReset, + onHistoryUp: props.onHistoryUp, + onHistoryDown: props.onHistoryDown, + onClearInput: props.onClearInput, + focus: props.focus, + mask: props.mask, + multiline: props.multiline, + cursorChar: props.showCursor ? ' ' : '', + highlightPastedText: props.highlightPastedText, + invert: isTerminalFocused ? chalk.inverse : (text: string) => text, + themeText: color('text', theme), + columns: props.columns, + maxVisibleLines: props.maxVisibleLines, + onImagePaste: props.onImagePaste, + disableCursorMovementForUpDownKeys: + props.disableCursorMovementForUpDownKeys, + disableEscapeDoublePress: props.disableEscapeDoublePress, + externalOffset: props.cursorOffset, + onOffsetChange: props.onChangeCursorOffset, + inputFilter: props.inputFilter, + onModeChange: props.onModeChange, + onUndo: props.onUndo, + }) + + const { mode, setMode } = vimInputState + + React.useEffect(() => { + if (props.initialMode && props.initialMode !== mode) { + setMode(props.initialMode) + } + }, [props.initialMode, mode, setMode]) + + return ( + + + + ) } diff --git a/src/components/VirtualMessageList.tsx b/src/components/VirtualMessageList.tsx index 8c7e2f3b2..bbe4a196c 100644 --- a/src/components/VirtualMessageList.tsx +++ b/src/components/VirtualMessageList.tsx @@ -1,116 +1,133 @@ -import { c as _c } from "react/compiler-runtime"; -import type { RefObject } from 'react'; -import * as React from 'react'; -import { useCallback, useContext, useEffect, useImperativeHandle, useRef, useState, useSyncExternalStore } from 'react'; -import { useVirtualScroll } from '../hooks/useVirtualScroll.js'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import type { DOMElement } from '../ink/dom.js'; -import type { MatchPosition } from '../ink/render-to-screen.js'; -import { Box } from '../ink.js'; -import type { RenderableMessage } from '../types/message.js'; -import { TextHoverColorContext } from './design-system/ThemedText.js'; -import { ScrollChromeContext } from './FullscreenLayout.js'; +import type { RefObject } from 'react' +import * as React from 'react' +import { + useCallback, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { useVirtualScroll } from '../hooks/useVirtualScroll.js' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import type { DOMElement } from '../ink/dom.js' +import type { MatchPosition } from '../ink/render-to-screen.js' +import { Box } from '../ink.js' +import type { RenderableMessage } from '../types/message.js' +import { TextHoverColorContext } from './design-system/ThemedText.js' +import { ScrollChromeContext } from './FullscreenLayout.js' // Rows of breathing room above the target when we scrollTo. -const HEADROOM = 3; -import { logForDebugging } from '../utils/debug.js'; -import { sleep } from '../utils/sleep.js'; -import { renderableSearchText } from '../utils/transcriptSearch.js'; -import { isNavigableMessage, type MessageActionsNav, type MessageActionsState, type NavigableMessage, type NavigableType, stripSystemReminders, toolCallOf } from './messageActions.js'; +const HEADROOM = 3 + +import { logForDebugging } from '../utils/debug.js' +import { sleep } from '../utils/sleep.js' +import { renderableSearchText } from '../utils/transcriptSearch.js' +import { + isNavigableMessage, + type MessageActionsNav, + type MessageActionsState, + type NavigableMessage, + stripSystemReminders, + toolCallOf, +} from './messageActions.js' // Fallback extractor: lower + cache here for callers without the // Messages.tsx tool-lookup path (tests, static contexts). Messages.tsx // provides its own lowering cache that also handles tool extractSearchText. -const fallbackLowerCache = new WeakMap(); +const fallbackLowerCache = new WeakMap() function defaultExtractSearchText(msg: RenderableMessage): string { - const cached = fallbackLowerCache.get(msg); - if (cached !== undefined) return cached; - const lowered = renderableSearchText(msg); - fallbackLowerCache.set(msg, lowered); - return lowered; -} -export type StickyPrompt = { - text: string; - scrollTo: () => void; + const cached = fallbackLowerCache.get(msg) + if (cached !== undefined) return cached + const lowered = renderableSearchText(msg) + fallbackLowerCache.set(msg, lowered) + return lowered } -// Click sets this — header HIDES but padding stays collapsed (0) so -// the content ❯ lands at screen row 0 instead of row 1. Cleared on -// the next sticky-prompt compute (user scrolls again). -| 'clicked'; + +export type StickyPrompt = + | { text: string; scrollTo: () => void } + // Click sets this — header HIDES but padding stays collapsed (0) so + // the content ❯ lands at screen row 0 instead of row 1. Cleared on + // the next sticky-prompt compute (user scrolls again). + | 'clicked' /** Huge pasted prompts (cat file | claude) can be MBs. Header wraps into * 2 rows via overflow:hidden — this just bounds the React prop size. */ -const STICKY_TEXT_CAP = 500; +const STICKY_TEXT_CAP = 500 /** Imperative handle for transcript navigation. Methods compute matches * HERE (renderableMessages indices are only valid inside this component — * Messages.tsx filters and reorders, REPL can't compute externally). */ export type JumpHandle = { - jumpToIndex: (i: number) => void; - setSearchQuery: (q: string) => void; - nextMatch: () => void; - prevMatch: () => void; + jumpToIndex: (i: number) => void + setSearchQuery: (q: string) => void + nextMatch: () => void + prevMatch: () => void /** Capture current scrollTop as the incsearch anchor. Typing jumps * around as preview; 0-matches snaps back here. Enter/n/N never * restore (they don't call setSearchQuery with empty). Next / call * overwrites. */ - setAnchor: () => void; + setAnchor: () => void /** Warm the search-text cache by extracting every message's text. * Returns elapsed ms, or 0 if already warm (subsequent / in same * transcript session). Yields before work so the caller can paint * "indexing…" first. Caller shows "indexed in Xms" on resolve. */ - warmSearchIndex: () => Promise; + warmSearchIndex: () => Promise /** Manual scroll (j/k/PgUp/wheel) exited the search context. Clear * positions (yellow goes away, inverse highlights stay). Next n/N * re-establishes via step()→jump(). Wired from ScrollKeybindingHandler's * onScroll — only fires for keyboard/wheel, not programmatic scrollTo. */ - disarmSearch: () => void; -}; + disarmSearch: () => void +} + type Props = { - messages: RenderableMessage[]; - scrollRef: RefObject; + messages: RenderableMessage[] + scrollRef: RefObject /** Invalidates heightCache on change — cached heights from a different * width are wrong (text rewrap → black screen on scroll-up after widen). */ - columns: number; - itemKey: (msg: RenderableMessage) => string; - renderItem: (msg: RenderableMessage, index: number) => React.ReactNode; + columns: number + itemKey: (msg: RenderableMessage) => string + renderItem: (msg: RenderableMessage, index: number) => React.ReactNode /** Fires when a message Box is clicked (toggle per-message verbose). */ - onItemClick?: (msg: RenderableMessage) => void; + onItemClick?: (msg: RenderableMessage) => void /** Per-item filter — suppress hover/click for messages where the verbose * toggle does nothing (text, file edits, etc). Defaults to all-clickable. */ - isItemClickable?: (msg: RenderableMessage) => boolean; + isItemClickable?: (msg: RenderableMessage) => boolean /** Expanded items get a persistent grey bg (not just on hover). */ - isItemExpanded?: (msg: RenderableMessage) => boolean; + isItemExpanded?: (msg: RenderableMessage) => boolean /** PRE-LOWERED search text. Messages.tsx caches the lowered result * once at warm time so setSearchQuery's per-keystroke loop does * only indexOf (zero toLowerCase alloc). Falls back to a lowering * wrapper on renderableSearchText for callers without the cache. */ - extractSearchText?: (msg: RenderableMessage) => string; + extractSearchText?: (msg: RenderableMessage) => string /** Enable the sticky-prompt tracker. StickyTracker writes via * ScrollChromeContext (not a callback prop) so state lives in * FullscreenLayout instead of REPL. */ - trackStickyPrompt?: boolean; - selectedIndex?: number; + trackStickyPrompt?: boolean + selectedIndex?: number /** Nav handle lives here because height measurement lives here. */ - cursorNavRef?: React.Ref; - setCursor?: (c: MessageActionsState | null) => void; - jumpRef?: RefObject; + cursorNavRef?: React.Ref + setCursor?: (c: MessageActionsState | null) => void + jumpRef?: RefObject /** Fires when search matches change (query edit, n/N). current is * 1-based for "3/47" display; 0 means no matches. */ - onSearchMatchesChange?: (count: number, current: number) => void; + onSearchMatchesChange?: (count: number, current: number) => void /** Paint existing DOM subtree to fresh Screen, scan. Element from the * main tree (all providers). Message-relative positions (row 0 = el * top). Works for any height — closes the tall-message gap. */ - scanElement?: (el: DOMElement) => MatchPosition[]; + scanElement?: (el: DOMElement) => MatchPosition[] /** Position-based CURRENT highlight. Positions known upfront (from * scanElement), navigation = index arithmetic + scrollTo. rowOffset * = message's current screen-top; positions stay stable. */ - setPositions?: (state: { - positions: MatchPosition[]; - rowOffset: number; - currentIdx: number; - } | null) => void; -}; + setPositions?: ( + state: { + positions: MatchPosition[] + rowOffset: number + currentIdx: number + } | null, + ) => void +} /** * Returns the text of a real user prompt, or null for anything else. @@ -129,34 +146,45 @@ type Props = { * prompt that happened to get a reminder is rejected by the startsWith('<') * check. Shows up on `cc -c` resumes where memory-update reminders are dense. */ -const promptTextCache = new WeakMap(); +const promptTextCache = new WeakMap() + function stickyPromptText(msg: RenderableMessage): string | null { // Cache keyed on message object — messages are append-only and don't // mutate, so a WeakMap hit is always valid. The walk (StickyTracker, // per-scroll-tick) calls this 5-50+ times with the SAME messages every // tick; the system-reminder strip allocates a fresh string on each // parse. WeakMap self-GCs on compaction/clear (messages[] replaced). - const cached = promptTextCache.get(msg); - if (cached !== undefined) return cached; - const result = computeStickyPromptText(msg); - promptTextCache.set(msg, result); - return result; + const cached = promptTextCache.get(msg) + if (cached !== undefined) return cached + const result = computeStickyPromptText(msg) + promptTextCache.set(msg, result) + return result } + function computeStickyPromptText(msg: RenderableMessage): string | null { - let raw: string | null = null; + let raw: string | null = null if (msg.type === 'user') { - if (msg.isMeta || msg.isVisibleInTranscriptOnly) return null; - const block = Array.isArray(msg.message.content) ? msg.message.content[0] : undefined; - if (!block || typeof block === 'string' || block?.type !== 'text') return null; - raw = block.text; - } else if (msg.type === 'attachment' && msg.attachment.type === 'queued_command' && msg.attachment.commandMode !== 'task-notification' && !msg.attachment.isMeta) { - const p = msg.attachment.prompt; - raw = typeof p === 'string' ? p : (p as any[]).flatMap(b => b.type === 'text' ? [b.text] : []).join('\n'); + if (msg.isMeta || msg.isVisibleInTranscriptOnly) return null + const block = msg.message.content[0] + if (block?.type !== 'text') return null + raw = block.text + } else if ( + msg.type === 'attachment' && + msg.attachment.type === 'queued_command' && + msg.attachment.commandMode !== 'task-notification' && + !msg.attachment.isMeta + ) { + const p = msg.attachment.prompt + raw = + typeof p === 'string' + ? p + : p.flatMap(b => (b.type === 'text' ? [b.text] : [])).join('\n') } - if (raw === null) return null; - const t = stripSystemReminders(raw); - if (t.startsWith('<') || t === '') return null; - return t; + if (raw === null) return null + + const t = stripSystemReminders(raw) + if (t.startsWith('<') || t === '') return null + return t } /** @@ -168,18 +196,18 @@ function computeStickyPromptText(msg: RenderableMessage): string | null { * a ref. Single-child column Box passes Yoga height through unchanged. */ type VirtualItemProps = { - itemKey: string; - msg: RenderableMessage; - idx: number; - measureRef: (key: string) => (el: DOMElement | null) => void; - expanded: boolean | undefined; - hovered: boolean; - clickable: boolean; - onClickK: (msg: RenderableMessage, cellIsBlank: boolean) => void; - onEnterK: (k: string) => void; - onLeaveK: (k: string) => void; - renderItem: (msg: RenderableMessage, idx: number) => React.ReactNode; -}; + itemKey: string + msg: RenderableMessage + idx: number + measureRef: (key: string) => (el: DOMElement | null) => void + expanded: boolean | undefined + hovered: boolean + clickable: boolean + onClickK: (msg: RenderableMessage, cellIsBlank: boolean) => void + onEnterK: (k: string) => void + onLeaveK: (k: string) => void + renderItem: (msg: RenderableMessage, idx: number) => React.ReactNode +} // Item wrapper with stable click handlers. The per-item closures were the // `operationNewArrowFunction` leafs → `FunctionExecutable::finalizeUnconditionally` @@ -194,98 +222,41 @@ type VirtualItemProps = { // verbose). Memoing with a comparator that ignores renderItem would use a // STALE closure on bail (wrong selection highlight, stale verbose). Including // renderItem in the comparator defeats memo since it's fresh each render. -function VirtualItem(t0) { - const $ = _c(30); - const { - itemKey: k, - msg, - idx, - measureRef, - expanded, - hovered, - clickable, - onClickK, - onEnterK, - onLeaveK, - renderItem - } = t0; - let t1; - if ($[0] !== k || $[1] !== measureRef) { - t1 = measureRef(k); - $[0] = k; - $[1] = measureRef; - $[2] = t1; - } else { - t1 = $[2]; - } - const t2 = expanded ? "userMessageBackgroundHover" : undefined; - const t3 = expanded ? 1 : undefined; - let t4; - if ($[3] !== clickable || $[4] !== msg || $[5] !== onClickK) { - t4 = clickable ? e => onClickK(msg, e.cellIsBlank) : undefined; - $[3] = clickable; - $[4] = msg; - $[5] = onClickK; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== clickable || $[8] !== k || $[9] !== onEnterK) { - t5 = clickable ? () => onEnterK(k) : undefined; - $[7] = clickable; - $[8] = k; - $[9] = onEnterK; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== clickable || $[12] !== k || $[13] !== onLeaveK) { - t6 = clickable ? () => onLeaveK(k) : undefined; - $[11] = clickable; - $[12] = k; - $[13] = onLeaveK; - $[14] = t6; - } else { - t6 = $[14]; - } - const t7 = hovered && !expanded ? "text" : undefined; - let t8; - if ($[15] !== idx || $[16] !== msg || $[17] !== renderItem) { - t8 = renderItem(msg, idx); - $[15] = idx; - $[16] = msg; - $[17] = renderItem; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== t7 || $[20] !== t8) { - t9 = {t8}; - $[19] = t7; - $[20] = t8; - $[21] = t9; - } else { - t9 = $[21]; - } - let t10; - if ($[22] !== t1 || $[23] !== t2 || $[24] !== t3 || $[25] !== t4 || $[26] !== t5 || $[27] !== t6 || $[28] !== t9) { - t10 = {t9}; - $[22] = t1; - $[23] = t2; - $[24] = t3; - $[25] = t4; - $[26] = t5; - $[27] = t6; - $[28] = t9; - $[29] = t10; - } else { - t10 = $[29]; - } - return t10; +function VirtualItem({ + itemKey: k, + msg, + idx, + measureRef, + expanded, + hovered, + clickable, + onClickK, + onEnterK, + onLeaveK, + renderItem, +}: VirtualItemProps): React.ReactNode { + return ( + onClickK(msg, e.cellIsBlank) : undefined} + onMouseEnter={clickable ? () => onEnterK(k) : undefined} + onMouseLeave={clickable ? () => onLeaveK(k) : undefined} + > + + {renderItem(msg, idx)} + + + ) } + export function VirtualMessageList({ messages, scrollRef, @@ -303,25 +274,29 @@ export function VirtualMessageList({ jumpRef, onSearchMatchesChange, scanElement, - setPositions + setPositions, }: Props): React.ReactNode { // Incremental key array. Streaming appends one message at a time; rebuilding // the full string array on every commit allocates O(n) per message (~1MB // churn at 27k messages). Append-only delta push when the prefix matches; // fall back to full rebuild on compaction, /clear, or itemKey change. - const keysRef = useRef([]); - const prevMessagesRef = useRef(messages); - const prevItemKeyRef = useRef(itemKey); - if (prevItemKeyRef.current !== itemKey || messages.length < keysRef.current.length || messages[0] !== prevMessagesRef.current[0]) { - keysRef.current = messages.map(m => itemKey(m)); + const keysRef = useRef([]) + const prevMessagesRef = useRef(messages) + const prevItemKeyRef = useRef(itemKey) + if ( + prevItemKeyRef.current !== itemKey || + messages.length < keysRef.current.length || + messages[0] !== prevMessagesRef.current[0] + ) { + keysRef.current = messages.map(m => itemKey(m)) } else { for (let i = keysRef.current.length; i < messages.length; i++) { - keysRef.current.push(itemKey(messages[i]!)); + keysRef.current.push(itemKey(messages[i]!)) } } - prevMessagesRef.current = messages; - prevItemKeyRef.current = itemKey; - const keys = keysRef.current; + prevMessagesRef.current = messages + prevItemKeyRef.current = itemKey + const keys = keysRef.current const { range, topSpacer, @@ -332,53 +307,61 @@ export function VirtualMessageList({ getItemTop, getItemElement, getItemHeight, - scrollToIndex - } = useVirtualScroll(scrollRef, keys, columns); - const [start, end] = range; + scrollToIndex, + } = useVirtualScroll(scrollRef, keys, columns) + const [start, end] = range // Unmeasured (undefined height) falls through — assume visible. - const isVisible = useCallback((i: number) => { - const h = getItemHeight(i); - if (h === 0) return false; - return isNavigableMessage(messages[i]!); - }, [getItemHeight, messages]); + const isVisible = useCallback( + (i: number) => { + const h = getItemHeight(i) + if (h === 0) return false + return isNavigableMessage(messages[i]!) + }, + [getItemHeight, messages], + ) useImperativeHandle(cursorNavRef, (): MessageActionsNav => { - const select = (m: NavigableMessage) => setCursor?.({ - uuid: m.uuid, - msgType: m.type as NavigableType, - expanded: false, - toolName: toolCallOf(m)?.name - }); - const selIdx = selectedIndex ?? -1; - const scan = (from: number, dir: 1 | -1, pred: (i: number) => boolean = isVisible) => { + const select = (m: NavigableMessage) => + setCursor?.({ + uuid: m.uuid, + msgType: m.type, + expanded: false, + toolName: toolCallOf(m)?.name, + }) + const selIdx = selectedIndex ?? -1 + const scan = ( + from: number, + dir: 1 | -1, + pred: (i: number) => boolean = isVisible, + ) => { for (let i = from; i >= 0 && i < messages.length; i += dir) { if (pred(i)) { - select(messages[i]!); - return true; + select(messages[i]!) + return true } } - return false; - }; - const isUser = (i: number) => isVisible(i) && messages[i]!.type === 'user'; + return false + } + const isUser = (i: number) => isVisible(i) && messages[i]!.type === 'user' return { // Entry via shift+↑ = same semantic as in-cursor shift+↑ (prevUser). enterCursor: () => scan(messages.length - 1, -1, isUser), navigatePrev: () => scan(selIdx - 1, -1), navigateNext: () => { - if (scan(selIdx + 1, 1)) return; + if (scan(selIdx + 1, 1)) return // Past last visible → exit + repin. Last message's TOP is at viewport // top (selection-scroll effect); its BOTTOM may be below the fold. - scrollRef.current?.scrollToBottom(); - setCursor?.(null); + scrollRef.current?.scrollToBottom() + setCursor?.(null) }, // type:'user' only — queued_command attachments look like prompts but have no raw UserMessage to rewind to. navigatePrevUser: () => scan(selIdx - 1, -1, isUser), navigateNextUser: () => scan(selIdx + 1, 1, isUser), navigateTop: () => scan(0, 1), navigateBottom: () => scan(messages.length - 1, -1), - getSelected: () => selIdx >= 0 ? messages[selIdx] ?? null : null - }; - }, [messages, selectedIndex, setCursor, isVisible]); + getSelected: () => (selIdx >= 0 ? (messages[selIdx] ?? null) : null), + } + }, [messages, selectedIndex, setCursor, isVisible]) // Two-phase jump + search engine. Read-through-ref so the handle stays // stable across renders — offsets/messages identity changes every render, // can't go in useImperativeHandle deps without recreating the handle. @@ -388,67 +371,63 @@ export function VirtualMessageList({ getItemElement, getItemTop, messages, - scrollToIndex - }); + scrollToIndex, + }) jumpState.current = { offsets, start, getItemElement, getItemTop, messages, - scrollToIndex - }; + scrollToIndex, + } // Keep cursor-selected message visible. offsets rebuilds every render // — as a bare dep this re-pinned on every mousewheel tick. Read through // jumpState instead; past-overscan jumps land via scrollToIndex, next // nav is precise. useEffect(() => { - if (selectedIndex === undefined) return; - const s = jumpState.current; - const el = s.getItemElement(selectedIndex); + if (selectedIndex === undefined) return + const s = jumpState.current + const el = s.getItemElement(selectedIndex) if (el) { - scrollRef.current?.scrollToElement(el, 1); + scrollRef.current?.scrollToElement(el, 1) } else { - s.scrollToIndex(selectedIndex); + s.scrollToIndex(selectedIndex) } - }, [selectedIndex, scrollRef]); + }, [selectedIndex, scrollRef]) // Pending seek request. jump() sets this + bumps seekGen. The seek // effect fires post-paint (passive effect — after resetAfterCommit), // checks if target is mounted. Yes → scan+highlight. No → re-estimate // with a fresher anchor (start moved toward idx) and scrollTo again. const scanRequestRef = useRef<{ - idx: number; - wantLast: boolean; - tries: number; - } | null>(null); + idx: number + wantLast: boolean + tries: number + } | null>(null) // Message-relative positions from scanElement. Row 0 = message top. // Stable across scroll — highlight computes rowOffset fresh. msgIdx // for computing rowOffset = getItemTop(msgIdx) - scrollTop. const elementPositions = useRef<{ - msgIdx: number; - positions: MatchPosition[]; - }>({ - msgIdx: -1, - positions: [] - }); + msgIdx: number + positions: MatchPosition[] + }>({ msgIdx: -1, positions: [] }) // Wraparound guard. Auto-advance stops if ptr wraps back to here. - const startPtrRef = useRef(-1); + const startPtrRef = useRef(-1) // Phantom-burst cap. Resets on scan success. - const phantomBurstRef = useRef(0); + const phantomBurstRef = useRef(0) // One-deep queue: n/N arriving mid-seek gets stored (not dropped) and // fires after the seek completes. Holding n stays smooth without // queueing 30 jumps. Latest press overwrites — we want the direction // the user is going NOW, not where they were 10 keypresses ago. - const pendingStepRef = useRef<1 | -1 | 0>(0); + const pendingStepRef = useRef<1 | -1 | 0>(0) // step + highlight via ref so the seek effect reads latest without // closure-capture or deps churn. - const stepRef = useRef<(d: 1 | -1) => void>(() => {}); - const highlightRef = useRef<(ord: number) => void>(() => {}); + const stepRef = useRef<(d: 1 | -1) => void>(() => {}) + const highlightRef = useRef<(ord: number) => void>(() => {}) const searchState = useRef({ - matches: [] as number[], - // deduplicated msg indices + matches: [] as number[], // deduplicated msg indices ptr: 0, screenOrd: 0, // Cumulative engine-occurrence count before each matches[k]. Lets us @@ -456,12 +435,12 @@ export function VirtualMessageList({ // Engine-counted (indexOf on extractSearchText), not render-counted — // close enough for the badge; exact counts would need scanElement on // every matched message (~1-3ms × N). total = prefixSum[matches.length]. - prefixSum: [] as number[] - }); + prefixSum: [] as number[], + }) // scrollTop at the moment / was pressed. Incsearch preview-jumps snap // back here when matches drop to 0. -1 = no anchor (before first /). - const searchAnchor = useRef(-1); - const indexWarmed = useRef(false); + const searchAnchor = useRef(-1) + const indexWarmed = useRef(false) // Scroll target for message i: land at MESSAGE TOP. est = top - HEADROOM // so lo = top - est = HEADROOM ≥ 0 (or lo = top if est clamped to 0). @@ -470,8 +449,8 @@ export function VirtualMessageList({ // (was a safety net for frac garbage — without frac, est IS the next // message's top, spam-n/N converges because message tops are ordered). function targetFor(i: number): number { - const top = jumpState.current.getItemTop(i); - return Math.max(0, top - HEADROOM); + const top = jumpState.current.getItemTop(i) + return Math.max(0, top - HEADROOM) } // Highlight positions[ord]. Positions are MESSAGE-RELATIVE (row 0 = @@ -479,51 +458,48 @@ export function VirtualMessageList({ // scrollTop fresh. If ord's position is off-viewport, scroll to bring // it in, recompute rowOffset. setPositions triggers overlay write. function highlight(ord: number): void { - const s = scrollRef.current; - const { - msgIdx, - positions - } = elementPositions.current; + const s = scrollRef.current + const { msgIdx, positions } = elementPositions.current if (!s || positions.length === 0 || msgIdx < 0) { - setPositions?.(null); - return; + setPositions?.(null) + return } - const idx = Math.max(0, Math.min(ord, positions.length - 1)); - const p = positions[idx]!; - const top = jumpState.current.getItemTop(msgIdx); + const idx = Math.max(0, Math.min(ord, positions.length - 1)) + const p = positions[idx]! + const top = jumpState.current.getItemTop(msgIdx) // lo = item's position within scroll content (wrapper-relative). // viewportTop = where the scroll content starts on SCREEN (after // ScrollBox padding/border + any chrome above). Highlight writes to // screen-absolute, so rowOffset = viewportTop + lo. Observed: off-by- // 1+ without viewportTop (FullscreenLayout has paddingTop=1 on the // ScrollBox, plus any header above). - const vpTop = s.getViewportTop(); - let lo = top - s.getScrollTop(); - const vp = s.getViewportHeight(); - let screenRow = vpTop + lo + p.row; + const vpTop = s.getViewportTop() + let lo = top - s.getScrollTop() + const vp = s.getViewportHeight() + let screenRow = vpTop + lo + p.row // Off viewport → scroll to bring it in (HEADROOM from top). // scrollTo commits sync; read-back after gives fresh lo. if (screenRow < vpTop || screenRow >= vpTop + vp) { - s.scrollTo(Math.max(0, top + p.row - HEADROOM)); - lo = top - s.getScrollTop(); - screenRow = vpTop + lo + p.row; + s.scrollTo(Math.max(0, top + p.row - HEADROOM)) + lo = top - s.getScrollTop() + screenRow = vpTop + lo + p.row } - setPositions?.({ - positions, - rowOffset: vpTop + lo, - currentIdx: idx - }); + setPositions?.({ positions, rowOffset: vpTop + lo, currentIdx: idx }) // Badge: global current = sum of occurrences before this msg + ord+1. // prefixSum[ptr] is engine-counted (indexOf on extractSearchText); // may drift from render-count for ghost messages but close enough — // badge is a rough location hint, not a proof. - const st = searchState.current; - const total = st.prefixSum.at(-1) ?? 0; - const current = (st.prefixSum[st.ptr] ?? 0) + idx + 1; - onSearchMatchesChange?.(total, current); - logForDebugging(`highlight(i=${msgIdx}, ord=${idx}/${positions.length}): ` + `pos={row:${p.row},col:${p.col}} lo=${lo} screenRow=${screenRow} ` + `badge=${current}/${total}`); + const st = searchState.current + const total = st.prefixSum.at(-1) ?? 0 + const current = (st.prefixSum[st.ptr] ?? 0) + idx + 1 + onSearchMatchesChange?.(total, current) + logForDebugging( + `highlight(i=${msgIdx}, ord=${idx}/${positions.length}): ` + + `pos={row:${p.row},col:${p.col}} lo=${lo} screenRow=${screenRow} ` + + `badge=${current}/${total}`, + ) } - highlightRef.current = highlight; + highlightRef.current = highlight // Seek effect. jump() sets scanRequestRef + scrollToIndex + bump. // bump → re-render → useVirtualScroll mounts the target (scrollToIndex @@ -533,114 +509,92 @@ export function VirtualMessageList({ // // Dep is ONLY seekGen — effect doesn't re-run on random renders // (onSearchMatchesChange churn during incsearch). - const [seekGen, setSeekGen] = useState(0); - const bumpSeek = useCallback(() => setSeekGen(g => g + 1), []); + const [seekGen, setSeekGen] = useState(0) + const bumpSeek = useCallback(() => setSeekGen(g => g + 1), []) + useEffect(() => { - const req = scanRequestRef.current; - if (!req) return; - const { - idx, - wantLast, - tries - } = req; - const s = scrollRef.current; - if (!s) return; - const { - getItemElement, - getItemTop, - scrollToIndex - } = jumpState.current; - const el = getItemElement(idx); - const h = el?.yogaNode?.getComputedHeight() ?? 0; + const req = scanRequestRef.current + if (!req) return + const { idx, wantLast, tries } = req + const s = scrollRef.current + if (!s) return + const { getItemElement, getItemTop, scrollToIndex } = jumpState.current + const el = getItemElement(idx) + const h = el?.yogaNode?.getComputedHeight() ?? 0 + if (!el || h === 0) { // Not mounted after scrollToIndex. Shouldn't happen — scrollToIndex // guarantees mount by construction (scrollTop and topSpacer agree // via the same offsets value). Sanity: retry once, then skip. if (tries > 1) { - scanRequestRef.current = null; - logForDebugging(`seek(i=${idx}): no mount after scrollToIndex, skip`); - stepRef.current(wantLast ? -1 : 1); - return; + scanRequestRef.current = null + logForDebugging(`seek(i=${idx}): no mount after scrollToIndex, skip`) + stepRef.current(wantLast ? -1 : 1) + return } - scanRequestRef.current = { - idx, - wantLast, - tries: tries + 1 - }; - scrollToIndex(idx); - bumpSeek(); - return; + scanRequestRef.current = { idx, wantLast, tries: tries + 1 } + scrollToIndex(idx) + bumpSeek() + return } - scanRequestRef.current = null; + + scanRequestRef.current = null // Precise scrollTo — scrollToIndex got us in the neighborhood // (item is mounted, maybe a few-dozen rows off due to overscan // estimate drift). Now land it at top-HEADROOM. - s.scrollTo(Math.max(0, getItemTop(idx) - HEADROOM)); - const positions = scanElement?.(el) ?? []; - elementPositions.current = { - msgIdx: idx, - positions - }; - logForDebugging(`seek(i=${idx} t=${tries}): ${positions.length} positions`); + s.scrollTo(Math.max(0, getItemTop(idx) - HEADROOM)) + const positions = scanElement?.(el) ?? [] + elementPositions.current = { msgIdx: idx, positions } + logForDebugging(`seek(i=${idx} t=${tries}): ${positions.length} positions`) if (positions.length === 0) { // Phantom — engine matched, render didn't. Auto-advance. if (++phantomBurstRef.current > 20) { - phantomBurstRef.current = 0; - return; + phantomBurstRef.current = 0 + return } - stepRef.current(wantLast ? -1 : 1); - return; + stepRef.current(wantLast ? -1 : 1) + return } - phantomBurstRef.current = 0; - const ord = wantLast ? positions.length - 1 : 0; - searchState.current.screenOrd = ord; - startPtrRef.current = -1; - highlightRef.current(ord); - const pending = pendingStepRef.current; + phantomBurstRef.current = 0 + const ord = wantLast ? positions.length - 1 : 0 + searchState.current.screenOrd = ord + startPtrRef.current = -1 + highlightRef.current(ord) + const pending = pendingStepRef.current if (pending) { - pendingStepRef.current = 0; - stepRef.current(pending); + pendingStepRef.current = 0 + stepRef.current(pending) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [seekGen]); + }, [seekGen]) // Scroll to message i's top, arm scanPending. scan-effect reads fresh // screen next tick. wantLast: N-into-message — screenOrd = length-1. function jump(i: number, wantLast: boolean): void { - const s = scrollRef.current; - if (!s) return; - const js = jumpState.current; - const { - getItemElement, - scrollToIndex - } = js; + const s = scrollRef.current + if (!s) return + const js = jumpState.current + const { getItemElement, scrollToIndex } = js // offsets is a Float64Array whose .length is the allocated buffer (only // grows) — messages.length is the logical item count. - if (i < 0 || i >= js.messages.length) return; + if (i < 0 || i >= js.messages.length) return // Clear stale highlight before scroll. Between now and the seek // effect's highlight, inverse-only from scan-highlight shows. - setPositions?.(null); - elementPositions.current = { - msgIdx: -1, - positions: [] - }; - scanRequestRef.current = { - idx: i, - wantLast, - tries: 0 - }; - const el = getItemElement(i); - const h = el?.yogaNode?.getComputedHeight() ?? 0; + setPositions?.(null) + elementPositions.current = { msgIdx: -1, positions: [] } + scanRequestRef.current = { idx: i, wantLast, tries: 0 } + const el = getItemElement(i) + const h = el?.yogaNode?.getComputedHeight() ?? 0 // Mounted → precise scrollTo. Unmounted → scrollToIndex mounts it // (scrollTop and topSpacer agree via the same offsets value — exact // by construction, no estimation). Seek effect does the precise // scrollTo after paint either way. if (el && h > 0) { - s.scrollTo(targetFor(i)); + s.scrollTo(targetFor(i)) } else { - scrollToIndex(i); + scrollToIndex(i) } - bumpSeek(); + bumpSeek() } // Advance screenOrd within elementPositions. Exhausted → ptr advances, @@ -648,177 +602,175 @@ export function VirtualMessageList({ // jump) triggers auto-advance from scan-effect. Wraparound guard stops // if every message is a phantom. function step(delta: 1 | -1): void { - const st = searchState.current; - const { - matches, - prefixSum - } = st; - const total = prefixSum.at(-1) ?? 0; - if (matches.length === 0) return; + const st = searchState.current + const { matches, prefixSum } = st + const total = prefixSum.at(-1) ?? 0 + if (matches.length === 0) return // Seek in-flight — queue this press (one-deep, latest overwrites). // The seek effect fires it after highlight. if (scanRequestRef.current) { - pendingStepRef.current = delta; - return; + pendingStepRef.current = delta + return } - if (startPtrRef.current < 0) startPtrRef.current = st.ptr; - const { - positions - } = elementPositions.current; - const newOrd = st.screenOrd + delta; + + if (startPtrRef.current < 0) startPtrRef.current = st.ptr + + const { positions } = elementPositions.current + const newOrd = st.screenOrd + delta if (newOrd >= 0 && newOrd < positions.length) { - st.screenOrd = newOrd; - highlight(newOrd); // updates badge internally - startPtrRef.current = -1; - return; + st.screenOrd = newOrd + highlight(newOrd) // updates badge internally + startPtrRef.current = -1 + return } // Exhausted visible. Advance ptr → jump → re-scan. - const ptr = (st.ptr + delta + matches.length) % matches.length; + const ptr = (st.ptr + delta + matches.length) % matches.length if (ptr === startPtrRef.current) { - setPositions?.(null); - startPtrRef.current = -1; - logForDebugging(`step: wraparound at ptr=${ptr}, all ${matches.length} msgs phantoms`); - return; + setPositions?.(null) + startPtrRef.current = -1 + logForDebugging( + `step: wraparound at ptr=${ptr}, all ${matches.length} msgs phantoms`, + ) + return } - st.ptr = ptr; - st.screenOrd = 0; // resolved after scan (wantLast → length-1) - jump(matches[ptr]!, delta < 0); + st.ptr = ptr + st.screenOrd = 0 // resolved after scan (wantLast → length-1) + jump(matches[ptr]!, delta < 0) // screenOrd will resolve after scan. Best-effort: prefixSum[ptr] + 0 // for n (first pos), prefixSum[ptr+1] for N (last pos = count-1). // The scan-effect's highlight will be the real value; this is a // pre-scan placeholder so the badge updates immediately. - const placeholder = delta < 0 ? prefixSum[ptr + 1] ?? total : prefixSum[ptr]! + 1; - onSearchMatchesChange?.(total, placeholder); + const placeholder = + delta < 0 ? (prefixSum[ptr + 1] ?? total) : prefixSum[ptr]! + 1 + onSearchMatchesChange?.(total, placeholder) } - stepRef.current = step; - useImperativeHandle(jumpRef, () => ({ - // Non-search jump (sticky header click, etc). No scan, no positions. - jumpToIndex: (i: number) => { - const s = scrollRef.current; - if (s) s.scrollTo(targetFor(i)); - }, - setSearchQuery: (q: string) => { - // New search invalidates everything. - scanRequestRef.current = null; - elementPositions.current = { - msgIdx: -1, - positions: [] - }; - startPtrRef.current = -1; - setPositions?.(null); - const lq = q.toLowerCase(); - // One entry per MESSAGE (deduplicated). Boolean "does this msg - // contain the query". ~10ms for 9k messages with cached lowered. - const matches: number[] = []; - // Per-message occurrence count → prefixSum for global current - // index. Engine-counted (cheap indexOf loop); may differ from - // render-count (scanElement) for ghost/phantom messages but close - // enough for the badge. The badge is a rough location hint. - const prefixSum: number[] = [0]; - if (lq) { - const msgs = jumpState.current.messages; - for (let i = 0; i < msgs.length; i++) { - const text = extractSearchText(msgs[i]!); - let pos = text.indexOf(lq); - let cnt = 0; - while (pos >= 0) { - cnt++; - pos = text.indexOf(lq, pos + lq.length); - } - if (cnt > 0) { - matches.push(i); - prefixSum.push(prefixSum.at(-1)! + cnt); + stepRef.current = step + + useImperativeHandle( + jumpRef, + () => ({ + // Non-search jump (sticky header click, etc). No scan, no positions. + jumpToIndex: (i: number) => { + const s = scrollRef.current + if (s) s.scrollTo(targetFor(i)) + }, + setSearchQuery: (q: string) => { + // New search invalidates everything. + scanRequestRef.current = null + elementPositions.current = { msgIdx: -1, positions: [] } + startPtrRef.current = -1 + setPositions?.(null) + const lq = q.toLowerCase() + // One entry per MESSAGE (deduplicated). Boolean "does this msg + // contain the query". ~10ms for 9k messages with cached lowered. + const matches: number[] = [] + // Per-message occurrence count → prefixSum for global current + // index. Engine-counted (cheap indexOf loop); may differ from + // render-count (scanElement) for ghost/phantom messages but close + // enough for the badge. The badge is a rough location hint. + const prefixSum: number[] = [0] + if (lq) { + const msgs = jumpState.current.messages + for (let i = 0; i < msgs.length; i++) { + const text = extractSearchText(msgs[i]!) + let pos = text.indexOf(lq) + let cnt = 0 + while (pos >= 0) { + cnt++ + pos = text.indexOf(lq, pos + lq.length) + } + if (cnt > 0) { + matches.push(i) + prefixSum.push(prefixSum.at(-1)! + cnt) + } } } - } - const total = prefixSum.at(-1)!; - // Nearest MESSAGE to the anchor. <= so ties go to later. - let ptr = 0; - const s = scrollRef.current; - const { - offsets, - start, - getItemTop - } = jumpState.current; - const firstTop = getItemTop(start); - const origin = firstTop >= 0 ? firstTop - offsets[start]! : 0; - if (matches.length > 0 && s) { - const curTop = searchAnchor.current >= 0 ? searchAnchor.current : s.getScrollTop(); - let best = Infinity; - for (let k = 0; k < matches.length; k++) { - const d = Math.abs(origin + offsets[matches[k]!]! - curTop); - if (d <= best) { - best = d; - ptr = k; + const total = prefixSum.at(-1)! + // Nearest MESSAGE to the anchor. <= so ties go to later. + let ptr = 0 + const s = scrollRef.current + const { offsets, start, getItemTop } = jumpState.current + const firstTop = getItemTop(start) + const origin = firstTop >= 0 ? firstTop - offsets[start]! : 0 + if (matches.length > 0 && s) { + const curTop = + searchAnchor.current >= 0 ? searchAnchor.current : s.getScrollTop() + let best = Infinity + for (let k = 0; k < matches.length; k++) { + const d = Math.abs(origin + offsets[matches[k]!]! - curTop) + if (d <= best) { + best = d + ptr = k + } } + logForDebugging( + `setSearchQuery('${q}'): ${matches.length} msgs · ptr=${ptr} ` + + `msgIdx=${matches[ptr]} curTop=${curTop} origin=${origin}`, + ) } - logForDebugging(`setSearchQuery('${q}'): ${matches.length} msgs · ptr=${ptr} ` + `msgIdx=${matches[ptr]} curTop=${curTop} origin=${origin}`); - } - searchState.current = { - matches, - ptr, - screenOrd: 0, - prefixSum - }; - if (matches.length > 0) { - // wantLast=true: preview the LAST occurrence in the nearest - // message. At sticky-bottom (common / entry), nearest is the - // last msg; its last occurrence is closest to where the user - // was — minimal view movement. n advances forward from there. - jump(matches[ptr]!, true); - } else if (searchAnchor.current >= 0 && s) { - // /foob → 0 matches → snap back to anchor. less/vim incsearch. - s.scrollTo(searchAnchor.current); - } - // Global occurrence count + 1-based current. wantLast=true so the - // scan will land on the last occurrence in matches[ptr]. Placeholder - // = prefixSum[ptr+1] (count through this msg). highlight() updates - // to the exact value after scan completes. - onSearchMatchesChange?.(total, matches.length > 0 ? prefixSum[ptr + 1] ?? total : 0); - }, - nextMatch: () => step(1), - prevMatch: () => step(-1), - setAnchor: () => { - const s = scrollRef.current; - if (s) searchAnchor.current = s.getScrollTop(); - }, - disarmSearch: () => { - // Manual scroll invalidates screen-absolute positions. - setPositions?.(null); - scanRequestRef.current = null; - elementPositions.current = { - msgIdx: -1, - positions: [] - }; - startPtrRef.current = -1; - }, - warmSearchIndex: async () => { - if (indexWarmed.current) return 0; - const msgs = jumpState.current.messages; - const CHUNK = 500; - let workMs = 0; - const wallStart = performance.now(); - for (let i = 0; i < msgs.length; i += CHUNK) { - await sleep(0); - const t0 = performance.now(); - const end = Math.min(i + CHUNK, msgs.length); - for (let j = i; j < end; j++) { - extractSearchText(msgs[j]!); + searchState.current = { matches, ptr, screenOrd: 0, prefixSum } + if (matches.length > 0) { + // wantLast=true: preview the LAST occurrence in the nearest + // message. At sticky-bottom (common / entry), nearest is the + // last msg; its last occurrence is closest to where the user + // was — minimal view movement. n advances forward from there. + jump(matches[ptr]!, true) + } else if (searchAnchor.current >= 0 && s) { + // /foob → 0 matches → snap back to anchor. less/vim incsearch. + s.scrollTo(searchAnchor.current) } - workMs += performance.now() - t0; - } - const wallMs = Math.round(performance.now() - wallStart); - logForDebugging(`warmSearchIndex: ${msgs.length} msgs · work=${Math.round(workMs)}ms wall=${wallMs}ms chunks=${Math.ceil(msgs.length / CHUNK)}`); - indexWarmed.current = true; - return Math.round(workMs); - } - }), - // Closures over refs + callbacks. scrollRef stable; others are - // useCallback([]) or prop-drilled from REPL (stable). - // eslint-disable-next-line react-hooks/exhaustive-deps - [scrollRef]); + // Global occurrence count + 1-based current. wantLast=true so the + // scan will land on the last occurrence in matches[ptr]. Placeholder + // = prefixSum[ptr+1] (count through this msg). highlight() updates + // to the exact value after scan completes. + onSearchMatchesChange?.( + total, + matches.length > 0 ? (prefixSum[ptr + 1] ?? total) : 0, + ) + }, + nextMatch: () => step(1), + prevMatch: () => step(-1), + setAnchor: () => { + const s = scrollRef.current + if (s) searchAnchor.current = s.getScrollTop() + }, + disarmSearch: () => { + // Manual scroll invalidates screen-absolute positions. + setPositions?.(null) + scanRequestRef.current = null + elementPositions.current = { msgIdx: -1, positions: [] } + startPtrRef.current = -1 + }, + warmSearchIndex: async () => { + if (indexWarmed.current) return 0 + const msgs = jumpState.current.messages + const CHUNK = 500 + let workMs = 0 + const wallStart = performance.now() + for (let i = 0; i < msgs.length; i += CHUNK) { + await sleep(0) + const t0 = performance.now() + const end = Math.min(i + CHUNK, msgs.length) + for (let j = i; j < end; j++) { + extractSearchText(msgs[j]!) + } + workMs += performance.now() - t0 + } + const wallMs = Math.round(performance.now() - wallStart) + logForDebugging( + `warmSearchIndex: ${msgs.length} msgs · work=${Math.round(workMs)}ms wall=${wallMs}ms chunks=${Math.ceil(msgs.length / CHUNK)}`, + ) + indexWarmed.current = true + return Math.round(workMs) + }, + }), + // Closures over refs + callbacks. scrollRef stable; others are + // useCallback([]) or prop-drilled from REPL (stable). + // eslint-disable-next-line react-hooks/exhaustive-deps + [scrollRef], + ) // StickyTracker goes AFTER the list content. It returns null (no DOM node) // so order shouldn't matter for layout — but putting it first means every @@ -826,7 +778,7 @@ export function VirtualMessageList({ // the sibling items (React walks children in order). After the items, it's // a leaf reconcile. Defensive: also avoids any Yoga child-index quirks if // the Ink reconciler ever materializes a placeholder for null returns. - const [hoveredKey, setHoveredKey] = useState(null); + const [hoveredKey, setHoveredKey] = useState(null) // Stable click/hover handlers — called with k, dispatch from a ref so // closure identity doesn't change per render. The per-item handler // closures (`e => ...`, `() => setHoveredKey(k)`) were the @@ -836,39 +788,65 @@ export function VirtualMessageList({ // scroll = 1800 short-lived closures/sec. With stable refs the item // wrapper props don't change → VirtualItem.memo bails for the ~35 // unchanged items, only ~25 fresh items pay createElement cost. - const handlersRef = useRef({ - onItemClick, - setHoveredKey - }); - handlersRef.current = { - onItemClick, - setHoveredKey - }; - const onClickK = useCallback((msg: RenderableMessage, cellIsBlank: boolean) => { - const h = handlersRef.current; - if (!cellIsBlank && h.onItemClick) h.onItemClick(msg); - }, []); + const handlersRef = useRef({ onItemClick, setHoveredKey }) + handlersRef.current = { onItemClick, setHoveredKey } + const onClickK = useCallback( + (msg: RenderableMessage, cellIsBlank: boolean) => { + const h = handlersRef.current + if (!cellIsBlank && h.onItemClick) h.onItemClick(msg) + }, + [], + ) const onEnterK = useCallback((k: string) => { - handlersRef.current.setHoveredKey(k); - }, []); + handlersRef.current.setHoveredKey(k) + }, []) const onLeaveK = useCallback((k: string) => { - handlersRef.current.setHoveredKey(prev => prev === k ? null : prev); - }, []); - return <> + handlersRef.current.setHoveredKey(prev => (prev === k ? null : prev)) + }, []) + + return ( + <> {messages.slice(start, end).map((msg, i) => { - const idx = start + i; - const k = keys[idx]!; - const clickable = !!onItemClick && (isItemClickable?.(msg) ?? true); - const hovered = clickable && hoveredKey === k; - const expanded = isItemExpanded?.(msg); - return ; - })} + const idx = start + i + const k = keys[idx]! + const clickable = !!onItemClick && (isItemClickable?.(msg) ?? true) + const hovered = clickable && hoveredKey === k + const expanded = isItemExpanded?.(msg) + return ( + + ) + })} {bottomSpacer > 0 && } - {trackStickyPrompt && } - ; + {trackStickyPrompt && ( + + )} + + ) } -const NOOP_UNSUB = () => {}; + +const NOOP_UNSUB = () => {} /** * Effect-only child that tracks the last user-prompt scrolled above the @@ -896,34 +874,40 @@ function StickyTracker({ offsets, getItemTop, getItemElement, - scrollRef + scrollRef, }: { - messages: RenderableMessage[]; - start: number; - end: number; - offsets: ArrayLike; - getItemTop: (index: number) => number; - getItemElement: (index: number) => DOMElement | null; - scrollRef: RefObject; + messages: RenderableMessage[] + start: number + end: number + offsets: ArrayLike + getItemTop: (index: number) => number + getItemElement: (index: number) => DOMElement | null + scrollRef: RefObject }): null { - const { - setStickyPrompt - } = useContext(ScrollChromeContext); + const { setStickyPrompt } = useContext(ScrollChromeContext) // Fine-grained subscription — snapshot is unquantized scrollTop+delta so // every scroll action (wheel tick, PgUp, drag) triggers a re-render of // THIS component only. Sticky bit folded into the sign so sticky→broken // also triggers (scrollToBottom sets sticky without moving scrollTop). - const subscribe = useCallback((listener: () => void) => scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, [scrollRef]); + const subscribe = useCallback( + (listener: () => void) => + scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, + [scrollRef], + ) useSyncExternalStore(subscribe, () => { - const s = scrollRef.current; - if (!s) return NaN; - const t = s.getScrollTop() + s.getPendingDelta(); - return s.isSticky() ? -1 - t : t; - }); + const s = scrollRef.current + if (!s) return NaN + const t = s.getScrollTop() + s.getPendingDelta() + return s.isSticky() ? -1 - t : t + }) // Read live scroll state on every render. - const isSticky = scrollRef.current?.isSticky() ?? true; - const target = Math.max(0, (scrollRef.current?.getScrollTop() ?? 0) + (scrollRef.current?.getPendingDelta() ?? 0)); + const isSticky = scrollRef.current?.isSticky() ?? true + const target = Math.max( + 0, + (scrollRef.current?.getScrollTop() ?? 0) + + (scrollRef.current?.getPendingDelta() ?? 0), + ) // Walk the mounted range to find the first item at-or-below the viewport // top. `range` is from the parent's coarse-quantum render (may be slightly @@ -931,37 +915,40 @@ function StickyTracker({ // directions. Items without a Yoga layout yet (newly mounted this frame) // are treated as at-or-below — they're somewhere in view, and assuming // otherwise would show a sticky for a prompt that's actually on screen. - let firstVisible = start; - let firstVisibleTop = -1; + let firstVisible = start + let firstVisibleTop = -1 for (let i = end - 1; i >= start; i--) { - const top = getItemTop(i); + const top = getItemTop(i) if (top >= 0) { - if (top < target) break; - firstVisibleTop = top; + if (top < target) break + firstVisibleTop = top } - firstVisible = i; + firstVisible = i } - let idx = -1; - let text: string | null = null; + + let idx = -1 + let text: string | null = null if (firstVisible > 0 && !isSticky) { for (let i = firstVisible - 1; i >= 0; i--) { - const t = stickyPromptText(messages[i]!); - if (t === null) continue; + const t = stickyPromptText(messages[i]!) + if (t === null) continue // The prompt's wrapping Box top is above target (that's why it's in // the [0, firstVisible) range), but its ❯ is at top+1 (marginTop=1). // If the ❯ is at-or-below target, it's VISIBLE at viewport top — // showing the same text in the header would duplicate it. Happens // in the 1-row gap between Box top scrolling past and ❯ scrolling // past. Skip to the next-older prompt (its ❯ is definitely above). - const top = getItemTop(i); - if (top >= 0 && top + 1 >= target) continue; - idx = i; - text = t; - break; + const top = getItemTop(i) + if (top >= 0 && top + 1 >= target) continue + idx = i + text = t + break } } - const baseOffset = firstVisibleTop >= 0 ? firstVisibleTop - offsets[firstVisible]! : 0; - const estimate = idx >= 0 ? Math.max(0, baseOffset + offsets[idx]!) : -1; + + const baseOffset = + firstVisibleTop >= 0 ? firstVisibleTop - offsets[firstVisible]! : 0 + const estimate = idx >= 0 ? Math.max(0, baseOffset + offsets[idx]!) : -1 // For click-jumps to items not yet mounted (user scrolled far past, // prompt is in the topSpacer). Click handler scrolls to the estimate @@ -970,10 +957,7 @@ function StickyTracker({ // reads el.yogaNode.getComputedTop() in the SAME calculateLayout pass // that produces scrollHeight) — no throttle race. Cap retries: a /clear // race could unmount the item mid-sequence. - const pending = useRef({ - idx: -1, - tries: 0 - }); + const pending = useRef({ idx: -1, tries: 0 }) // Suppression state machine. The click handler arms; the onChange effect // consumes (armed→force) then fires-and-clears on the render AFTER that // (force→none). The force step poisons the dedup: after click, idx often @@ -981,14 +965,14 @@ function StickyTracker({ // without force the last.idx===idx guard would hold 'clicked' until the // user crossed a prompt boundary. Previously encoded in last.idx as // -1/-2/-3 which overlapped with real indices — too clever. - type Suppress = 'none' | 'armed' | 'force'; - const suppress = useRef('none'); + type Suppress = 'none' | 'armed' | 'force' + const suppress = useRef('none') // Dedup on idx only — estimate derives from firstVisibleTop which shifts // every scroll tick, so including it in the key made the guard dead // (setStickyPrompt fired a fresh {text,scrollTo} per-frame). The scrollTo // closure still captures the current estimate; it just doesn't need to // re-fire when only estimate moved. - const lastIdx = useRef(-1); + const lastIdx = useRef(-1) // setStickyPrompt effect FIRST — must see pending.idx before the // correction effect below clears it. On the estimate-fallback path, the @@ -998,84 +982,79 @@ function StickyTracker({ // header over 'clicked'. useEffect(() => { // Hold while two-phase correction is in flight. - if (pending.current.idx >= 0) return; + if (pending.current.idx >= 0) return if (suppress.current === 'armed') { - suppress.current = 'force'; - return; + suppress.current = 'force' + return } - const force = suppress.current === 'force'; - suppress.current = 'none'; - if (!force && lastIdx.current === idx) return; - lastIdx.current = idx; + const force = suppress.current === 'force' + suppress.current = 'none' + if (!force && lastIdx.current === idx) return + lastIdx.current = idx if (text === null) { - setStickyPrompt(null); - return; + setStickyPrompt(null) + return } // First paragraph only (split on blank line) — a prompt like // "still seeing bugs:\n\n1. foo\n2. bar" previews as just the // lead-in. trimStart so a leading blank line (queued_command mid- // turn messages sometimes have one) doesn't find paraEnd at 0. - const trimmed = text.trimStart(); - const paraEnd = trimmed.search(/\n\s*\n/); - const collapsed = (paraEnd >= 0 ? trimmed.slice(0, paraEnd) : trimmed).slice(0, STICKY_TEXT_CAP).replace(/\s+/g, ' ').trim(); + const trimmed = text.trimStart() + const paraEnd = trimmed.search(/\n\s*\n/) + const collapsed = (paraEnd >= 0 ? trimmed.slice(0, paraEnd) : trimmed) + .slice(0, STICKY_TEXT_CAP) + .replace(/\s+/g, ' ') + .trim() if (collapsed === '') { - setStickyPrompt(null); - return; + setStickyPrompt(null) + return } - const capturedIdx = idx; - const capturedEstimate = estimate; + const capturedIdx = idx + const capturedEstimate = estimate setStickyPrompt({ text: collapsed, scrollTo: () => { // Hide header, keep padding collapsed — FullscreenLayout's // 'clicked' sentinel → scrollBox_y=0 + pad=0 → viewportTop=0. - setStickyPrompt('clicked'); - suppress.current = 'armed'; + setStickyPrompt('clicked') + suppress.current = 'armed' // scrollToElement anchors by DOMElement ref, not a number: // render-node-to-output reads el.yogaNode.getComputedTop() at // paint time (same Yoga pass as scrollHeight). No staleness from // the throttled render — the ref is stable, the position read is // deferred. offset=1 = UserPromptMessage marginTop. - const el = getItemElement(capturedIdx); + const el = getItemElement(capturedIdx) if (el) { - scrollRef.current?.scrollToElement(el, 1); + scrollRef.current?.scrollToElement(el, 1) } else { // Not mounted (scrolled far past — in topSpacer). Jump to // estimate to mount it; correction effect re-anchors once it // appears. Estimate is DEFAULT_ESTIMATE-based — lands short. - scrollRef.current?.scrollTo(capturedEstimate); - pending.current = { - idx: capturedIdx, - tries: 0 - }; + scrollRef.current?.scrollTo(capturedEstimate) + pending.current = { idx: capturedIdx, tries: 0 } } - } - }); + }, + }) // No deps — must run every render. Suppression state lives in a ref // (not idx/estimate), so a deps-gated effect would never see it tick. // Body's own guards short-circuit when nothing changed. // eslint-disable-next-line react-hooks/exhaustive-deps - }); + }) // Correction: for click-jumps to unmounted items. Click handler scrolled // to the estimate; this re-anchors by element once the item appears. // scrollToElement defers the Yoga read to paint time — deterministic. // SECOND so it clears pending AFTER the onChange gate above has seen it. useEffect(() => { - if (pending.current.idx < 0) return; - const el = getItemElement(pending.current.idx); + if (pending.current.idx < 0) return + const el = getItemElement(pending.current.idx) if (el) { - scrollRef.current?.scrollToElement(el, 1); - pending.current = { - idx: -1, - tries: 0 - }; + scrollRef.current?.scrollToElement(el, 1) + pending.current = { idx: -1, tries: 0 } } else if (++pending.current.tries > 5) { - pending.current = { - idx: -1, - tries: 0 - }; + pending.current = { idx: -1, tries: 0 } } - }); - return null; + }) + + return null } diff --git a/src/components/WorkflowMultiselectDialog.tsx b/src/components/WorkflowMultiselectDialog.tsx index d9195dbd9..e06737514 100644 --- a/src/components/WorkflowMultiselectDialog.tsx +++ b/src/components/WorkflowMultiselectDialog.tsx @@ -1,127 +1,115 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useState } from 'react'; -import type { Workflow } from '../commands/install-github-app/types.js'; -import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Link, Text } from '../ink.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { SelectMulti } from './CustomSelect/SelectMulti.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import React, { useCallback, useState } from 'react' +import type { Workflow } from '../commands/install-github-app/types.js' +import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Link, Text } from '../ink.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { SelectMulti } from './CustomSelect/SelectMulti.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' + type WorkflowOption = { - value: Workflow; - label: string; -}; + value: Workflow + label: string +} + type Props = { - onSubmit: (selectedWorkflows: Workflow[]) => void; - defaultSelections: Workflow[]; -}; -const WORKFLOWS: WorkflowOption[] = [{ - value: 'claude' as const, - label: '@Claude Code - Tag @claude in issues and PR comments' -}, { - value: 'claude-review' as const, - label: 'Claude Code Review - Automated code review on new PRs' -}]; + onSubmit: (selectedWorkflows: Workflow[]) => void + defaultSelections: Workflow[] +} + +const WORKFLOWS: WorkflowOption[] = [ + { + value: 'claude' as const, + label: '@Claude Code - Tag @claude in issues and PR comments', + }, + { + value: 'claude-review' as const, + label: 'Claude Code Review - Automated code review on new PRs', + }, +] + function renderInputGuide(exitState: ExitState): React.ReactNode { if (exitState.pending) { - return Press {exitState.keyName} again to exit; + return Press {exitState.keyName} again to exit } - return + return ( + - - ; + + + ) } -export function WorkflowMultiselectDialog(t0) { - const $ = _c(14); - const { - onSubmit, - defaultSelections - } = t0; - const [showError, setShowError] = useState(false); - let t1; - if ($[0] !== onSubmit) { - t1 = selectedValues => { + +export function WorkflowMultiselectDialog({ + onSubmit, + defaultSelections, +}: Props): React.ReactNode { + const [showError, setShowError] = useState(false) + + const handleSubmit = useCallback( + (selectedValues: Workflow[]) => { if (selectedValues.length === 0) { - setShowError(true); - return; + setShowError(true) + return } - setShowError(false); - onSubmit(selectedValues); - }; - $[0] = onSubmit; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSubmit = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - setShowError(false); - }; - $[2] = t2; - } else { - t2 = $[2]; - } - const handleChange = t2; - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => { - setShowError(true); - }; - $[3] = t3; - } else { - t3 = $[3]; - } - const handleCancel = t3; - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = More workflow examples (issue triage, CI fixes, etc.) at:{" "}https://github.com/anthropics/claude-code-action/blob/main/examples/; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = WORKFLOWS.map(_temp); - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== defaultSelections || $[7] !== handleSubmit) { - t6 = ; - $[6] = defaultSelections; - $[7] = handleSubmit; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== showError) { - t7 = showError && You must select at least one workflow to continue; - $[9] = showError; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t6 || $[12] !== t7) { - t8 = {t4}{t6}{t7}; - $[11] = t6; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; -} -function _temp(workflow) { - return { - label: workflow.label, - value: workflow.value - }; + setShowError(false) + onSubmit(selectedValues) + }, + [onSubmit], + ) + + const handleChange = useCallback(() => { + setShowError(false) + }, []) + + // Cancel just shows the error - user must select at least one workflow + const handleCancel = useCallback(() => { + setShowError(true) + }, []) + + return ( + + + + More workflow examples (issue triage, CI fixes, etc.) at:{' '} + + https://github.com/anthropics/claude-code-action/blob/main/examples/ + + + + + ({ + label: workflow.label, + value: workflow.value, + }))} + defaultValue={defaultSelections} + onSubmit={handleSubmit} + onChange={handleChange} + onCancel={handleCancel} + hideIndexes + /> + + {showError && ( + + + You must select at least one workflow to continue + + + )} + + ) } diff --git a/src/components/WorktreeExitDialog.tsx b/src/components/WorktreeExitDialog.tsx index 933a90fc4..ba5ab0f83 100644 --- a/src/components/WorktreeExitDialog.tsx +++ b/src/components/WorktreeExitDialog.tsx @@ -1,230 +1,300 @@ -import React, { useEffect, useState } from 'react'; -import type { CommandResultDisplay } from 'src/commands.js'; -import { logEvent } from 'src/services/analytics/index.js'; -import { logForDebugging } from 'src/utils/debug.js'; -import { Box, Text } from '../ink.js'; -import { execFileNoThrow } from '../utils/execFileNoThrow.js'; -import { getPlansDirectory } from '../utils/plans.js'; -import { setCwd } from '../utils/Shell.js'; -import { cleanupWorktree, getCurrentWorktreeSession, keepWorktree, killTmuxSession } from '../utils/worktree.js'; -import { Select } from './CustomSelect/select.js'; -import { Dialog } from './design-system/Dialog.js'; -import { Spinner } from './Spinner.js'; +import React, { useEffect, useState } from 'react' +import type { CommandResultDisplay } from 'src/commands.js' +import { logEvent } from 'src/services/analytics/index.js' +import { logForDebugging } from 'src/utils/debug.js' +import { Box, Text } from '../ink.js' +import { execFileNoThrow } from '../utils/execFileNoThrow.js' +import { getPlansDirectory } from '../utils/plans.js' +import { setCwd } from '../utils/Shell.js' +import { + cleanupWorktree, + getCurrentWorktreeSession, + keepWorktree, + killTmuxSession, +} from '../utils/worktree.js' +import { Select } from './CustomSelect/select.js' +import { Dialog } from './design-system/Dialog.js' +import { Spinner } from './Spinner.js' // Inline require breaks the cycle this file would otherwise close: // sessionStorage → commands → exit → ExitFlow → here. All call sites // are inside callbacks, so the lazy require never sees an undefined import. function recordWorktreeExit(): void { /* eslint-disable @typescript-eslint/no-require-imports */ - ; - (require('../utils/sessionStorage.js') as typeof import('../utils/sessionStorage.js')).saveWorktreeState(null); + ;( + require('../utils/sessionStorage.js') as typeof import('../utils/sessionStorage.js') + ).saveWorktreeState(null) /* eslint-enable @typescript-eslint/no-require-imports */ } + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onCancel?: () => void; -}; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onCancel?: () => void +} + export function WorktreeExitDialog({ onDone, - onCancel + onCancel, }: Props): React.ReactNode { - const [status, setStatus] = useState<'loading' | 'asking' | 'keeping' | 'removing' | 'done'>('loading'); - const [changes, setChanges] = useState([]); - const [commitCount, setCommitCount] = useState(0); - const [resultMessage, setResultMessage] = useState(); - const worktreeSession = getCurrentWorktreeSession(); + const [status, setStatus] = useState< + 'loading' | 'asking' | 'keeping' | 'removing' | 'done' + >('loading') + const [changes, setChanges] = useState([]) + const [commitCount, setCommitCount] = useState(0) + const [resultMessage, setResultMessage] = useState() + const worktreeSession = getCurrentWorktreeSession() + useEffect(() => { async function loadChanges() { - let changeLines: string[] = []; - const gitStatus = await execFileNoThrow('git', ['status', '--porcelain']); + let changeLines: string[] = [] + const gitStatus = await execFileNoThrow('git', ['status', '--porcelain']) if (gitStatus.stdout) { - changeLines = gitStatus.stdout.split('\n').filter(_ => _.trim() !== ''); - setChanges(changeLines); + changeLines = gitStatus.stdout.split('\n').filter(_ => _.trim() !== '') + setChanges(changeLines) } // Check for commits to eject if (worktreeSession) { // Get commits in worktree that are not in original branch - const { - stdout: commitsStr - } = await execFileNoThrow('git', ['rev-list', '--count', `${worktreeSession.originalHeadCommit}..HEAD`]); - const count = parseInt(commitsStr.trim()) || 0; - setCommitCount(count); + const { stdout: commitsStr } = await execFileNoThrow('git', [ + 'rev-list', + '--count', + `${worktreeSession.originalHeadCommit}..HEAD`, + ]) + const count = parseInt(commitsStr.trim()) || 0 + setCommitCount(count) // If no changes and no commits, clean up silently if (changeLines.length === 0 && count === 0) { - setStatus('removing'); - void cleanupWorktree().then(() => { - process.chdir(worktreeSession.originalCwd); - setCwd(worktreeSession.originalCwd); - recordWorktreeExit(); - getPlansDirectory.cache.clear?.(); - setResultMessage('Worktree removed (no changes)'); - }).catch(error => { - logForDebugging(`Failed to clean up worktree: ${error}`, { - level: 'error' - }); - setResultMessage('Worktree cleanup failed, exiting anyway'); - }).then(() => { - setStatus('done'); - }); - return; + setStatus('removing') + void cleanupWorktree() + .then(() => { + process.chdir(worktreeSession.originalCwd) + setCwd(worktreeSession.originalCwd) + recordWorktreeExit() + getPlansDirectory.cache.clear?.() + setResultMessage('Worktree removed (no changes)') + }) + .catch(error => { + logForDebugging(`Failed to clean up worktree: ${error}`, { + level: 'error', + }) + setResultMessage('Worktree cleanup failed, exiting anyway') + }) + .then(() => { + setStatus('done') + }) + return } else { - setStatus('asking'); + setStatus('asking') } } } - void loadChanges(); + void loadChanges() // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, [worktreeSession]); + }, [worktreeSession]) + useEffect(() => { if (status === 'done') { - onDone(resultMessage); + onDone(resultMessage) } - }, [status, onDone, resultMessage]); + }, [status, onDone, resultMessage]) + if (!worktreeSession) { - onDone('No active worktree session found', { - display: 'system' - }); - return null; + onDone('No active worktree session found', { display: 'system' }) + return null } + if (status === 'loading' || status === 'done') { - return null; + return null } + async function handleSelect(value: string) { - if (!worktreeSession) return; - const hasTmux = Boolean(worktreeSession.tmuxSessionName); + if (!worktreeSession) return + + const hasTmux = Boolean(worktreeSession.tmuxSessionName) + if (value === 'keep' || value === 'keep-with-tmux') { - setStatus('keeping'); + setStatus('keeping') logEvent('tengu_worktree_kept', { commits: commitCount, - changed_files: changes.length - }); - await keepWorktree(); - process.chdir(worktreeSession.originalCwd); - setCwd(worktreeSession.originalCwd); - recordWorktreeExit(); - getPlansDirectory.cache.clear?.(); + changed_files: changes.length, + }) + await keepWorktree() + process.chdir(worktreeSession.originalCwd) + setCwd(worktreeSession.originalCwd) + recordWorktreeExit() + getPlansDirectory.cache.clear?.() if (hasTmux) { - setResultMessage(`Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Reattach to tmux session with: tmux attach -t ${worktreeSession.tmuxSessionName}`); + setResultMessage( + `Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Reattach to tmux session with: tmux attach -t ${worktreeSession.tmuxSessionName}`, + ) } else { - setResultMessage(`Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}`); + setResultMessage( + `Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}`, + ) } - setStatus('done'); + setStatus('done') } else if (value === 'keep-kill-tmux') { - setStatus('keeping'); + setStatus('keeping') logEvent('tengu_worktree_kept', { commits: commitCount, - changed_files: changes.length - }); + changed_files: changes.length, + }) if (worktreeSession.tmuxSessionName) { - await killTmuxSession(worktreeSession.tmuxSessionName); + await killTmuxSession(worktreeSession.tmuxSessionName) } - await keepWorktree(); - process.chdir(worktreeSession.originalCwd); - setCwd(worktreeSession.originalCwd); - recordWorktreeExit(); - getPlansDirectory.cache.clear?.(); - setResultMessage(`Worktree kept at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Tmux session terminated.`); - setStatus('done'); + await keepWorktree() + process.chdir(worktreeSession.originalCwd) + setCwd(worktreeSession.originalCwd) + recordWorktreeExit() + getPlansDirectory.cache.clear?.() + setResultMessage( + `Worktree kept at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Tmux session terminated.`, + ) + setStatus('done') } else if (value === 'remove' || value === 'remove-with-tmux') { - setStatus('removing'); + setStatus('removing') logEvent('tengu_worktree_removed', { commits: commitCount, - changed_files: changes.length - }); + changed_files: changes.length, + }) if (worktreeSession.tmuxSessionName) { - await killTmuxSession(worktreeSession.tmuxSessionName); + await killTmuxSession(worktreeSession.tmuxSessionName) } try { - await cleanupWorktree(); - process.chdir(worktreeSession.originalCwd); - setCwd(worktreeSession.originalCwd); - recordWorktreeExit(); - getPlansDirectory.cache.clear?.(); + await cleanupWorktree() + process.chdir(worktreeSession.originalCwd) + setCwd(worktreeSession.originalCwd) + recordWorktreeExit() + getPlansDirectory.cache.clear?.() } catch (error) { logForDebugging(`Failed to clean up worktree: ${error}`, { - level: 'error' - }); - setResultMessage('Worktree cleanup failed, exiting anyway'); - setStatus('done'); - return; + level: 'error', + }) + setResultMessage('Worktree cleanup failed, exiting anyway') + setStatus('done') + return } - const tmuxNote = hasTmux ? ' Tmux session terminated.' : ''; + const tmuxNote = hasTmux ? ' Tmux session terminated.' : '' if (commitCount > 0 && changes.length > 0) { - setResultMessage(`Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} and uncommitted changes were discarded.${tmuxNote}`); + setResultMessage( + `Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} and uncommitted changes were discarded.${tmuxNote}`, + ) } else if (commitCount > 0) { - setResultMessage(`Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${worktreeSession.worktreeBranch} ${commitCount === 1 ? 'was' : 'were'} discarded.${tmuxNote}`); + setResultMessage( + `Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${worktreeSession.worktreeBranch} ${commitCount === 1 ? 'was' : 'were'} discarded.${tmuxNote}`, + ) } else if (changes.length > 0) { - setResultMessage(`Worktree removed. Uncommitted changes were discarded.${tmuxNote}`); + setResultMessage( + `Worktree removed. Uncommitted changes were discarded.${tmuxNote}`, + ) } else { - setResultMessage(`Worktree removed.${tmuxNote}`); + setResultMessage(`Worktree removed.${tmuxNote}`) } - setStatus('done'); + setStatus('done') } } + if (status === 'keeping') { - return + return ( + Keeping worktree… - ; + + ) } + if (status === 'removing') { - return + return ( + Removing worktree… - ; + + ) } - const branchName = worktreeSession.worktreeBranch; - const hasUncommitted = changes.length > 0; - const hasCommits = commitCount > 0; - let subtitle = ''; + + const branchName = worktreeSession.worktreeBranch + const hasUncommitted = changes.length > 0 + const hasCommits = commitCount > 0 + + let subtitle = '' if (hasUncommitted && hasCommits) { - subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'} and ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. All will be lost if you remove.`; + subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'} and ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. All will be lost if you remove.` } else if (hasUncommitted) { - subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'}. These will be lost if you remove the worktree.`; + subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'}. These will be lost if you remove the worktree.` } else if (hasCommits) { - subtitle = `You have ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. The branch will be deleted if you remove the worktree.`; + subtitle = `You have ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. The branch will be deleted if you remove the worktree.` } else { - subtitle = 'You are working in a worktree. Keep it to continue working there, or remove it to clean up.'; + subtitle = + 'You are working in a worktree. Keep it to continue working there, or remove it to clean up.' } + function handleCancel() { if (onCancel) { // Abort exit and return to the session - onCancel(); - return; + onCancel() + return } // Fallback: treat Escape as "keep" if no onCancel provided - void handleSelect('keep'); + void handleSelect('keep') } - const removeDescription = hasUncommitted || hasCommits ? 'All changes and commits will be lost.' : 'Clean up the worktree directory.'; - const hasTmuxSession = Boolean(worktreeSession.tmuxSessionName); - const options = hasTmuxSession ? [{ - label: 'Keep worktree and tmux session', - value: 'keep-with-tmux', - description: `Stays at ${worktreeSession.worktreePath}. Reattach with: tmux attach -t ${worktreeSession.tmuxSessionName}` - }, { - label: 'Keep worktree, kill tmux session', - value: 'keep-kill-tmux', - description: `Keeps worktree at ${worktreeSession.worktreePath}, terminates tmux session.` - }, { - label: 'Remove worktree and tmux session', - value: 'remove-with-tmux', - description: removeDescription - }] : [{ - label: 'Keep worktree', - value: 'keep', - description: `Stays at ${worktreeSession.worktreePath}` - }, { - label: 'Remove worktree', - value: 'remove', - description: removeDescription - }]; - const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep'; - return - + + ) } diff --git a/src/components/agents/AgentDetail.tsx b/src/components/agents/AgentDetail.tsx index 4c0f50e56..4c817b134 100644 --- a/src/components/agents/AgentDetail.tsx +++ b/src/components/agents/AgentDetail.tsx @@ -1,219 +1,148 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Tools } from '../../Tool.js'; -import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js'; -import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js'; -import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js'; -import { type AgentDefinition, isBuiltInAgent } from '../../tools/AgentTool/loadAgentsDir.js'; -import { getAgentModelDisplay } from '../../utils/model/agent.js'; -import { Markdown } from '../Markdown.js'; -import { getActualRelativeAgentFilePath } from './agentFileUtils.js'; +import figures from 'figures' +import * as React from 'react' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Tools } from '../../Tool.js' +import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js' +import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js' +import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js' +import { + type AgentDefinition, + isBuiltInAgent, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { getAgentModelDisplay } from '../../utils/model/agent.js' +import { Markdown } from '../Markdown.js' +import { getActualRelativeAgentFilePath } from './agentFileUtils.js' + type Props = { - agent: AgentDefinition; - tools: Tools; - allAgents?: AgentDefinition[]; - onBack: () => void; -}; -export function AgentDetail(t0) { - const $ = _c(48); - const { - agent, - tools, - onBack - } = t0; - const resolvedTools = resolveAgentTools(agent, tools, false); - let t1; - if ($[0] !== agent) { - t1 = getActualRelativeAgentFilePath(agent); - $[0] = agent; - $[1] = t1; - } else { - t1 = $[1]; - } - const filePath = t1; - let t2; - if ($[2] !== agent.agentType) { - t2 = getAgentColor(agent.agentType); - $[2] = agent.agentType; - $[3] = t2; - } else { - t2 = $[3]; - } - const backgroundColor = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[4] = t3; - } else { - t3 = $[4]; - } - useKeybinding("confirm:no", onBack, t3); - let t4; - if ($[5] !== onBack) { - t4 = e => { - if (e.key === "return") { - e.preventDefault(); - onBack(); - } - }; - $[5] = onBack; - $[6] = t4; - } else { - t4 = $[6]; + agent: AgentDefinition + tools: Tools + allAgents?: AgentDefinition[] + onBack: () => void +} + +export function AgentDetail({ agent, tools, onBack }: Props): React.ReactNode { + const resolvedTools = resolveAgentTools(agent, tools, false) + const filePath = getActualRelativeAgentFilePath(agent) + const backgroundColor = getAgentColor(agent.agentType) + + // Handle Esc to go back + useKeybinding('confirm:no', onBack, { context: 'Confirmation' }) + + // Handle Enter to go back + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + onBack() + } } - const handleKeyDown = t4; - const renderToolsList = function renderToolsList() { + + function renderToolsList(): React.ReactNode { if (resolvedTools.hasWildcard) { - return All tools; + return All tools } + if (!agent.tools || agent.tools.length === 0) { - return None; + return None } - return <>{resolvedTools.validTools.length > 0 && {resolvedTools.validTools.join(", ")}}{resolvedTools.invalidTools.length > 0 && {figures.warning} Unrecognized:{" "}{resolvedTools.invalidTools.join(", ")}}; - }; - const T0 = Box; - const t5 = "column"; - const t6 = 1; - const t7 = 0; - const t8 = true; - let t9; - if ($[7] !== filePath) { - t9 = {filePath}; - $[7] = filePath; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Description (tells Claude when to use this agent):; - $[9] = t10; - } else { - t10 = $[9]; - } - let t11; - if ($[10] !== agent.whenToUse) { - t11 = {t10}{agent.whenToUse}; - $[10] = agent.whenToUse; - $[11] = t11; - } else { - t11 = $[11]; - } - const T1 = Box; - let t12; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Tools:{" "}; - $[12] = t12; - } else { - t12 = $[12]; - } - const t13 = renderToolsList(); - let t14; - if ($[13] !== T1 || $[14] !== t12 || $[15] !== t13) { - t14 = {t12}{t13}; - $[13] = T1; - $[14] = t12; - $[15] = t13; - $[16] = t14; - } else { - t14 = $[16]; - } - let t15; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t15 = Model; - $[17] = t15; - } else { - t15 = $[17]; - } - let t16; - if ($[18] !== agent.model) { - t16 = getAgentModelDisplay(agent.model); - $[18] = agent.model; - $[19] = t16; - } else { - t16 = $[19]; - } - let t17; - if ($[20] !== t16) { - t17 = {t15}: {t16}; - $[20] = t16; - $[21] = t17; - } else { - t17 = $[21]; - } - let t18; - if ($[22] !== agent.permissionMode) { - t18 = agent.permissionMode && Permission mode: {agent.permissionMode}; - $[22] = agent.permissionMode; - $[23] = t18; - } else { - t18 = $[23]; - } - let t19; - if ($[24] !== agent.memory) { - t19 = agent.memory && Memory: {getMemoryScopeDisplay(agent.memory)}; - $[24] = agent.memory; - $[25] = t19; - } else { - t19 = $[25]; - } - let t20; - if ($[26] !== agent.hooks) { - t20 = agent.hooks && Object.keys(agent.hooks).length > 0 && Hooks: {Object.keys(agent.hooks).join(", ")}; - $[26] = agent.hooks; - $[27] = t20; - } else { - t20 = $[27]; - } - let t21; - if ($[28] !== agent.skills) { - t21 = agent.skills && agent.skills.length > 0 && Skills:{" "}{agent.skills.length > 10 ? `${agent.skills.length} skills` : agent.skills.join(", ")}; - $[28] = agent.skills; - $[29] = t21; - } else { - t21 = $[29]; - } - let t22; - if ($[30] !== agent.agentType || $[31] !== backgroundColor) { - t22 = backgroundColor && Color:{" "}{" "}{agent.agentType}{" "}; - $[30] = agent.agentType; - $[31] = backgroundColor; - $[32] = t22; - } else { - t22 = $[32]; - } - let t23; - if ($[33] !== agent) { - t23 = !isBuiltInAgent(agent) && <>System prompt:{agent.getSystemPrompt()}; - $[33] = agent; - $[34] = t23; - } else { - t23 = $[34]; - } - let t24; - if ($[35] !== T0 || $[36] !== handleKeyDown || $[37] !== t11 || $[38] !== t14 || $[39] !== t17 || $[40] !== t18 || $[41] !== t19 || $[42] !== t20 || $[43] !== t21 || $[44] !== t22 || $[45] !== t23 || $[46] !== t9) { - t24 = {t9}{t11}{t14}{t17}{t18}{t19}{t20}{t21}{t22}{t23}; - $[35] = T0; - $[36] = handleKeyDown; - $[37] = t11; - $[38] = t14; - $[39] = t17; - $[40] = t18; - $[41] = t19; - $[42] = t20; - $[43] = t21; - $[44] = t22; - $[45] = t23; - $[46] = t9; - $[47] = t24; - } else { - t24 = $[47]; - } - return t24; + + return ( + <> + {resolvedTools.validTools.length > 0 && ( + {resolvedTools.validTools.join(', ')} + )} + {resolvedTools.invalidTools.length > 0 && ( + + {figures.warning} Unrecognized:{' '} + {resolvedTools.invalidTools.join(', ')} + + )} + + ) + } + + return ( + + {filePath} + + + + Description (tells Claude when to use this agent): + + + {agent.whenToUse} + + + + + + Tools:{' '} + + {renderToolsList()} + + + + Model: {getAgentModelDisplay(agent.model)} + + + {agent.permissionMode && ( + + Permission mode: {agent.permissionMode} + + )} + + {agent.memory && ( + + Memory: {getMemoryScopeDisplay(agent.memory)} + + )} + + {agent.hooks && Object.keys(agent.hooks).length > 0 && ( + + Hooks: {Object.keys(agent.hooks).join(', ')} + + )} + + {agent.skills && agent.skills.length > 0 && ( + + Skills:{' '} + {agent.skills.length > 10 + ? `${agent.skills.length} skills` + : agent.skills.join(', ')} + + )} + + {backgroundColor && ( + + + Color:{' '} + + {' '} + {agent.agentType}{' '} + + + + )} + + {!isBuiltInAgent(agent) && ( + <> + + + System prompt: + + + + {agent.getSystemPrompt()} + + + )} + + ) } diff --git a/src/components/agents/AgentEditor.tsx b/src/components/agents/AgentEditor.tsx index e406cf5b2..e5c7b1847 100644 --- a/src/components/agents/AgentEditor.tsx +++ b/src/components/agents/AgentEditor.tsx @@ -1,177 +1,246 @@ -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import { useSetAppState } from 'src/state/AppState.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Tools } from '../../Tool.js'; -import { type AgentColorName, setAgentColor } from '../../tools/AgentTool/agentColorManager.js'; -import { type AgentDefinition, getActiveAgentsFromList, isCustomAgent, isPluginAgent } from '../../tools/AgentTool/loadAgentsDir.js'; -import { editFileInEditor } from '../../utils/promptEditor.js'; -import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js'; -import { ColorPicker } from './ColorPicker.js'; -import { ModelSelector } from './ModelSelector.js'; -import { ToolSelector } from './ToolSelector.js'; -import { getAgentSourceDisplayName } from './utils.js'; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useSetAppState } from 'src/state/AppState.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Tools } from '../../Tool.js' +import { + type AgentColorName, + setAgentColor, +} from '../../tools/AgentTool/agentColorManager.js' +import { + type AgentDefinition, + getActiveAgentsFromList, + isCustomAgent, + isPluginAgent, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { editFileInEditor } from '../../utils/promptEditor.js' +import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js' +import { ColorPicker } from './ColorPicker.js' +import { ModelSelector } from './ModelSelector.js' +import { ToolSelector } from './ToolSelector.js' +import { getAgentSourceDisplayName } from './utils.js' + type Props = { - agent: AgentDefinition; - tools: Tools; - onSaved: (message: string) => void; - onBack: () => void; -}; -type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model'; + agent: AgentDefinition + tools: Tools + onSaved: (message: string) => void + onBack: () => void +} + +type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model' + type SaveChanges = { - tools?: string[]; - color?: AgentColorName; - model?: string; -}; + tools?: string[] + color?: AgentColorName + model?: string +} + export function AgentEditor({ agent, tools, onSaved, - onBack + onBack, }: Props): React.ReactNode { - const setAppState = useSetAppState(); - const [editMode, setEditMode] = useState('menu'); - const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); - const [error, setError] = useState(null); - const [selectedColor, setSelectedColor] = useState(agent.color as AgentColorName | undefined); + const setAppState = useSetAppState() + const [editMode, setEditMode] = useState('menu') + const [selectedMenuIndex, setSelectedMenuIndex] = useState(0) + const [error, setError] = useState(null) + const [selectedColor, setSelectedColor] = useState< + AgentColorName | undefined + >(agent.color as AgentColorName | undefined) + const handleOpenInEditor = useCallback(async () => { - const filePath = getActualAgentFilePath(agent); - const result = await editFileInEditor(filePath); + const filePath = getActualAgentFilePath(agent) + const result = await editFileInEditor(filePath) + if (result.error) { - setError(result.error); + setError(result.error) } else { - onSaved(`Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`); + onSaved( + `Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`, + ) } - }, [agent, onSaved]); - const handleSave = useCallback(async (changes: SaveChanges = {}) => { - const { - tools: newTools, - color: newColor, - model: newModel - } = changes; - const finalColor = newColor ?? selectedColor; - const hasToolsChanged = newTools !== undefined; - const hasModelChanged = newModel !== undefined; - const hasColorChanged = finalColor !== agent.color; - if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { - return false; - } - try { - // Only custom/plugin agents can be edited - // this is for type safety; the UI shouldn't allow editing otherwise - if (!isCustomAgent(agent) && !isPluginAgent(agent)) { - return false; - } - await updateAgentFile(agent, agent.whenToUse, newTools ?? agent.tools, agent.getSystemPrompt(), finalColor, newModel ?? agent.model); - if (hasColorChanged && finalColor) { - setAgentColor(agent.agentType, finalColor); + }, [agent, onSaved]) + + const handleSave = useCallback( + async (changes: SaveChanges = {}) => { + const { tools: newTools, color: newColor, model: newModel } = changes + const finalColor = newColor ?? selectedColor + const hasToolsChanged = newTools !== undefined + const hasModelChanged = newModel !== undefined + const hasColorChanged = finalColor !== agent.color + + if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { + return false } - setAppState(state => { - const allAgents = state.agentDefinitions.allAgents.map(a => a.agentType === agent.agentType ? { - ...a, - tools: newTools ?? a.tools, - color: finalColor, - model: newModel ?? a.model - } : a); - return { - ...state, - agentDefinitions: { - ...state.agentDefinitions, - activeAgents: getActiveAgentsFromList(allAgents), - allAgents + + try { + // Only custom/plugin agents can be edited + // this is for type safety; the UI shouldn't allow editing otherwise + if (!isCustomAgent(agent) && !isPluginAgent(agent)) { + return false + } + + await updateAgentFile( + agent, + agent.whenToUse, + newTools ?? agent.tools, + agent.getSystemPrompt(), + finalColor, + newModel ?? agent.model, + ) + + if (hasColorChanged && finalColor) { + setAgentColor(agent.agentType, finalColor) + } + + setAppState(state => { + const allAgents = state.agentDefinitions.allAgents.map(a => + a.agentType === agent.agentType + ? { + ...a, + tools: newTools ?? a.tools, + color: finalColor, + model: newModel ?? a.model, + } + : a, + ) + return { + ...state, + agentDefinitions: { + ...state.agentDefinitions, + activeAgents: getActiveAgentsFromList(allAgents), + allAgents, + }, } - }; - }); - onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`); - return true; - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save agent'); - return false; - } - }, [agent, selectedColor, onSaved, setAppState]); - const menuItems = useMemo(() => [{ - label: 'Open in editor', - action: handleOpenInEditor - }, { - label: 'Edit tools', - action: () => setEditMode('edit-tools') - }, { - label: 'Edit model', - action: () => setEditMode('edit-model') - }, { - label: 'Edit color', - action: () => setEditMode('edit-color') - }], [handleOpenInEditor]); + }) + + onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`) + return true + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save agent') + return false + } + }, + [agent, selectedColor, onSaved, setAppState], + ) + + const menuItems = useMemo( + () => [ + { label: 'Open in editor', action: handleOpenInEditor }, + { label: 'Edit tools', action: () => setEditMode('edit-tools') }, + { label: 'Edit model', action: () => setEditMode('edit-model') }, + { label: 'Edit color', action: () => setEditMode('edit-color') }, + ], + [handleOpenInEditor], + ) + const handleEscape = useCallback(() => { - setError(null); + setError(null) if (editMode === 'menu') { - onBack(); + onBack() } else { - setEditMode('menu'); + setEditMode('menu') } - }, [editMode, onBack]); - const handleMenuKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'up') { - e.preventDefault(); - setSelectedMenuIndex(index => Math.max(0, index - 1)); - } else if (e.key === 'down') { - e.preventDefault(); - setSelectedMenuIndex(index_0 => Math.min(menuItems.length - 1, index_0 + 1)); - } else if (e.key === 'return') { - e.preventDefault(); - const selectedItem = menuItems[selectedMenuIndex]; - if (selectedItem) { - void selectedItem.action(); + }, [editMode, onBack]) + + const handleMenuKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'up') { + e.preventDefault() + setSelectedMenuIndex(index => Math.max(0, index - 1)) + } else if (e.key === 'down') { + e.preventDefault() + setSelectedMenuIndex(index => Math.min(menuItems.length - 1, index + 1)) + } else if (e.key === 'return') { + e.preventDefault() + const selectedItem = menuItems[selectedMenuIndex] + if (selectedItem) { + void selectedItem.action() + } } - } - }, [menuItems, selectedMenuIndex]); - useKeybinding('confirm:no', handleEscape, { - context: 'Confirmation' - }); - const renderMenu = (): React.ReactNode => + }, + [menuItems, selectedMenuIndex], + ) + + useKeybinding('confirm:no', handleEscape, { context: 'Confirmation' }) + + const renderMenu = (): React.ReactNode => ( + Source: {getAgentSourceDisplayName(agent.source)} - {menuItems.map((item, index_1) => - {index_1 === selectedMenuIndex ? `${figures.pointer} ` : ' '} + {menuItems.map((item, index) => ( + + {index === selectedMenuIndex ? `${figures.pointer} ` : ' '} {item.label} - )} + + ))} - {error && + {error && ( + {error} - } - ; + + )} + + ) + switch (editMode) { case 'menu': - return renderMenu(); + return renderMenu() + case 'edit-tools': - return { - setEditMode('menu'); - await handleSave({ - tools: finalTools - }); - }} />; + return ( + { + setEditMode('menu') + await handleSave({ tools: finalTools }) + }} + /> + ) + case 'edit-color': - return { - setSelectedColor(color); - setEditMode('menu'); - await handleSave({ - color - }); - }} />; + return ( + { + setSelectedColor(color) + setEditMode('menu') + await handleSave({ color }) + }} + /> + ) + case 'edit-model': - return { - setEditMode('menu'); - await handleSave({ - model - }); - }} />; + return ( + { + setEditMode('menu') + await handleSave({ model }) + }} + /> + ) + default: - return null; + return null } } diff --git a/src/components/agents/AgentNavigationFooter.tsx b/src/components/agents/AgentNavigationFooter.tsx index e20f7301d..9c4fa9f76 100644 --- a/src/components/agents/AgentNavigationFooter.tsx +++ b/src/components/agents/AgentNavigationFooter.tsx @@ -1,25 +1,23 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; +import * as React from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' + type Props = { - instructions?: string; -}; -export function AgentNavigationFooter(t0) { - const $ = _c(2); - const { - instructions: t1 - } = t0; - const instructions = t1 === undefined ? "Press \u2191\u2193 to navigate \xB7 Enter to select \xB7 Esc to go back" : t1; - const exitState = useExitOnCtrlCDWithKeybindings(); - const t2 = exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions; - let t3; - if ($[0] !== t2) { - t3 = {t2}; - $[0] = t2; - $[1] = t3; - } else { - t3 = $[1]; - } - return t3; + instructions?: string +} + +export function AgentNavigationFooter({ + instructions = 'Press ↑↓ to navigate · Enter to select · Esc to go back', +}: Props): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings() + + return ( + + + {exitState.pending + ? `Press ${exitState.keyName} again to exit` + : instructions} + + + ) } diff --git a/src/components/agents/AgentsList.tsx b/src/components/agents/AgentsList.tsx index 2e394fa1d..6eadf1ef7 100644 --- a/src/components/agents/AgentsList.tsx +++ b/src/components/agents/AgentsList.tsx @@ -1,439 +1,342 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js'; -import { AGENT_SOURCE_GROUPS, compareAgentsByName, getOverrideSourceLabel, resolveAgentModelDisplay } from '../../tools/AgentTool/agentDisplay.js'; -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; -import { count } from '../../utils/array.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { Divider } from '../design-system/Divider.js'; -import { getAgentSourceDisplayName } from './utils.js'; +import figures from 'figures' +import * as React from 'react' +import type { SettingSource } from 'src/utils/settings/constants.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js' +import { + AGENT_SOURCE_GROUPS, + compareAgentsByName, + getOverrideSourceLabel, + resolveAgentModelDisplay, +} from '../../tools/AgentTool/agentDisplay.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { count } from '../../utils/array.js' +import { Dialog } from '../design-system/Dialog.js' +import { Divider } from '../design-system/Divider.js' +import { getAgentSourceDisplayName } from './utils.js' + type Props = { - source: SettingSource | 'all' | 'built-in' | 'plugin'; - agents: ResolvedAgent[]; - onBack: () => void; - onSelect: (agent: AgentDefinition) => void; - onCreateNew?: () => void; - changes?: string[]; -}; -export function AgentsList(t0) { - const $ = _c(96); - const { - source, - agents, - onBack, - onSelect, - onCreateNew, - changes - } = t0; - const [selectedAgent, setSelectedAgent] = React.useState(null); - const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true); - let t1; - if ($[0] !== agents) { - t1 = [...agents].sort(compareAgentsByName); - $[0] = agents; - $[1] = t1; - } else { - t1 = $[1]; + source: SettingSource | 'all' | 'built-in' | 'plugin' + agents: ResolvedAgent[] + onBack: () => void + onSelect: (agent: AgentDefinition) => void + onCreateNew?: () => void + changes?: string[] +} + +export function AgentsList({ + source, + agents, + onBack, + onSelect, + onCreateNew, + changes, +}: Props): React.ReactNode { + const [selectedAgent, setSelectedAgent] = + React.useState(null) + const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true) + + // Sort agents alphabetically by name within each source group + const sortedAgents = React.useMemo( + () => [...agents].sort(compareAgentsByName), + [agents], + ) + + const getOverrideInfo = (agent: ResolvedAgent) => { + return { + isOverridden: !!agent.overriddenBy, + overriddenBy: agent.overriddenBy || null, + } } - const sortedAgents = t1; - const getOverrideInfo = _temp; - let t2; - if ($[2] !== isCreateNewSelected) { - t2 = () => {isCreateNewSelected ? `${figures.pointer} ` : " "}Create new agent; - $[2] = isCreateNewSelected; - $[3] = t2; - } else { - t2 = $[3]; + + const renderCreateNewOption = () => { + return ( + + + {isCreateNewSelected ? `${figures.pointer} ` : ' '} + + + Create new agent + + + ) } - const renderCreateNewOption = t2; - let t3; - if ($[4] !== isCreateNewSelected || $[5] !== selectedAgent?.agentType || $[6] !== selectedAgent?.source) { - t3 = agent_0 => { - const isBuiltIn = agent_0.source === "built-in"; - const isSelected = !isBuiltIn && !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source; - const { - isOverridden, - overriddenBy - } = getOverrideInfo(agent_0); - const dimmed = isBuiltIn || isOverridden; - const textColor = !isBuiltIn && isSelected ? "suggestion" : undefined; - const resolvedModel = resolveAgentModelDisplay(agent_0); - return {isBuiltIn ? "" : isSelected ? `${figures.pointer} ` : " "}{agent_0.agentType}{resolvedModel && {" \xB7 "}{resolvedModel}}{agent_0.memory && {" \xB7 "}{agent_0.memory} memory}{overriddenBy && {" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}}; - }; - $[4] = isCreateNewSelected; - $[5] = selectedAgent?.agentType; - $[6] = selectedAgent?.source; - $[7] = t3; - } else { - t3 = $[7]; + + const renderAgent = (agent: ResolvedAgent) => { + const isBuiltIn = agent.source === 'built-in' + const isSelected = + !isBuiltIn && + !isCreateNewSelected && + selectedAgent?.agentType === agent.agentType && + selectedAgent?.source === agent.source + + const { isOverridden, overriddenBy } = getOverrideInfo(agent) + const dimmed = isBuiltIn || isOverridden + const textColor = !isBuiltIn && isSelected ? 'suggestion' : undefined + + const resolvedModel = resolveAgentModelDisplay(agent) + + return ( + + + {isBuiltIn ? '' : isSelected ? `${figures.pointer} ` : ' '} + + + {agent.agentType} + + {resolvedModel && ( + + {' · '} + {resolvedModel} + + )} + {agent.memory && ( + + {' · '} + {agent.memory} memory + + )} + {overriddenBy && ( + + {' '} + {figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)} + + )} + + ) } - const renderAgent = t3; - let t4; - if ($[8] !== sortedAgents || $[9] !== source) { - bb0: { - const nonBuiltIn = sortedAgents.filter(_temp2); - if (source === "all") { - t4 = AGENT_SOURCE_GROUPS.filter(_temp3).flatMap(t5 => { - const { - source: groupSource - } = t5; - return nonBuiltIn.filter(a_0 => a_0.source === groupSource); - }); - break bb0; - } - t4 = nonBuiltIn; + + const selectableAgentsInOrder = React.useMemo(() => { + const nonBuiltIn = sortedAgents.filter(a => a.source !== 'built-in') + if (source === 'all') { + return AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').flatMap( + ({ source: groupSource }) => + nonBuiltIn.filter(a => a.source === groupSource), + ) } - $[8] = sortedAgents; - $[9] = source; - $[10] = t4; - } else { - t4 = $[10]; - } - const selectableAgentsInOrder = t4; - let t5; - let t6; - if ($[11] !== isCreateNewSelected || $[12] !== onCreateNew || $[13] !== selectableAgentsInOrder || $[14] !== selectedAgent) { - t5 = () => { - if (!selectedAgent && !isCreateNewSelected && selectableAgentsInOrder.length > 0) { - if (onCreateNew) { - setIsCreateNewSelected(true); - } else { - setSelectedAgent(selectableAgentsInOrder[0] || null); - } - } - }; - t6 = [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]; - $[11] = isCreateNewSelected; - $[12] = onCreateNew; - $[13] = selectableAgentsInOrder; - $[14] = selectedAgent; - $[15] = t5; - $[16] = t6; - } else { - t5 = $[15]; - t6 = $[16]; - } - React.useEffect(t5, t6); - let t7; - if ($[17] !== isCreateNewSelected || $[18] !== onCreateNew || $[19] !== onSelect || $[20] !== selectableAgentsInOrder || $[21] !== selectedAgent) { - t7 = e => { - if (e.key === "return") { - e.preventDefault(); - if (isCreateNewSelected && onCreateNew) { - onCreateNew(); - } else { - if (selectedAgent) { - onSelect(selectedAgent); - } - } - return; - } - if (e.key !== "up" && e.key !== "down") { - return; - } - e.preventDefault(); - const hasCreateOption = !!onCreateNew; - const totalItems = selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0); - if (totalItems === 0) { - return; - } - let currentPosition = 0; - if (!isCreateNewSelected && selectedAgent) { - const agentIndex = selectableAgentsInOrder.findIndex(a_1 => a_1.agentType === selectedAgent.agentType && a_1.source === selectedAgent.source); - if (agentIndex >= 0) { - currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex; - } - } - const newPosition = e.key === "up" ? currentPosition === 0 ? totalItems - 1 : currentPosition - 1 : currentPosition === totalItems - 1 ? 0 : currentPosition + 1; - if (hasCreateOption && newPosition === 0) { - setIsCreateNewSelected(true); - setSelectedAgent(null); + return nonBuiltIn + }, [sortedAgents, source]) + + // Set initial selection + React.useEffect(() => { + if ( + !selectedAgent && + !isCreateNewSelected && + selectableAgentsInOrder.length > 0 + ) { + if (onCreateNew) { + setIsCreateNewSelected(true) } else { - const agentIndex_0 = hasCreateOption ? newPosition - 1 : newPosition; - const newAgent = selectableAgentsInOrder[agentIndex_0]; - if (newAgent) { - setIsCreateNewSelected(false); - setSelectedAgent(newAgent); - } - } - }; - $[17] = isCreateNewSelected; - $[18] = onCreateNew; - $[19] = onSelect; - $[20] = selectableAgentsInOrder; - $[21] = selectedAgent; - $[22] = t7; - } else { - t7 = $[22]; - } - const handleKeyDown = t7; - let t8; - if ($[23] !== renderAgent || $[24] !== sortedAgents) { - t8 = t9 => { - const title = t9 === undefined ? "Built-in (always available):" : t9; - const builtInAgents = sortedAgents.filter(_temp4); - return {title}{builtInAgents.map(renderAgent)}; - }; - $[23] = renderAgent; - $[24] = sortedAgents; - $[25] = t8; - } else { - t8 = $[25]; - } - const renderBuiltInAgentsSection = t8; - let t9; - if ($[26] !== renderAgent) { - t9 = (title_0, groupAgents) => { - if (!groupAgents.length) { - return null; + setSelectedAgent(selectableAgentsInOrder[0] || null) } - const folderPath = groupAgents[0]?.baseDir; - return {title_0}{folderPath && ({folderPath})}{groupAgents.map(agent_1 => renderAgent(agent_1))}; - }; - $[26] = renderAgent; - $[27] = t9; - } else { - t9 = $[27]; - } - const renderAgentGroup = t9; - let t10; - if ($[28] !== source) { - t10 = getAgentSourceDisplayName(source); - $[28] = source; - $[29] = t10; - } else { - t10 = $[29]; - } - const sourceTitle = t10; - let T0; - let T1; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - let t18; - let t19; - let t20; - let t21; - let t22; - if ($[30] !== changes || $[31] !== handleKeyDown || $[32] !== onBack || $[33] !== onCreateNew || $[34] !== renderAgent || $[35] !== renderAgentGroup || $[36] !== renderBuiltInAgentsSection || $[37] !== renderCreateNewOption || $[38] !== sortedAgents || $[39] !== source || $[40] !== sourceTitle) { - t22 = Symbol.for("react.early_return_sentinel"); - bb1: { - const builtInAgents_0 = sortedAgents.filter(_temp5); - const hasNoAgents = !sortedAgents.length || source !== "built-in" && !sortedAgents.some(_temp6); - if (hasNoAgents) { - let t23; - if ($[55] !== onCreateNew || $[56] !== renderCreateNewOption) { - t23 = onCreateNew && {renderCreateNewOption()}; - $[55] = onCreateNew; - $[56] = renderCreateNewOption; - $[57] = t23; - } else { - t23 = $[57]; - } - let t24; - let t25; - let t26; - if ($[58] === Symbol.for("react.memo_cache_sentinel")) { - t24 = No agents found. Create specialized subagents that Claude can delegate to.; - t25 = Each subagent has its own context window, custom system prompt, and specific tools.; - t26 = Try creating: Code Reviewer, Code Simplifier, Security Reviewer, Tech Lead, or UX Reviewer.; - $[58] = t24; - $[59] = t25; - $[60] = t26; - } else { - t24 = $[58]; - t25 = $[59]; - t26 = $[60]; - } - let t27; - if ($[61] !== renderBuiltInAgentsSection || $[62] !== sortedAgents || $[63] !== source) { - t27 = source !== "built-in" && sortedAgents.some(_temp7) && <>{renderBuiltInAgentsSection()}; - $[61] = renderBuiltInAgentsSection; - $[62] = sortedAgents; - $[63] = source; - $[64] = t27; - } else { - t27 = $[64]; - } - let t28; - if ($[65] !== handleKeyDown || $[66] !== t23 || $[67] !== t27) { - t28 = {t23}{t24}{t25}{t26}{t27}; - $[65] = handleKeyDown; - $[66] = t23; - $[67] = t27; - $[68] = t28; - } else { - t28 = $[68]; - } - let t29; - if ($[69] !== onBack || $[70] !== sourceTitle || $[71] !== t28) { - t29 = {t28}; - $[69] = onBack; - $[70] = sourceTitle; - $[71] = t28; - $[72] = t29; - } else { - t29 = $[72]; - } - t22 = t29; - break bb1; - } - T1 = Dialog; - t17 = sourceTitle; - let t23; - if ($[73] !== sortedAgents) { - t23 = count(sortedAgents, _temp8); - $[73] = sortedAgents; - $[74] = t23; - } else { - t23 = $[74]; + } + }, [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + if (isCreateNewSelected && onCreateNew) { + onCreateNew() + } else if (selectedAgent) { + onSelect(selectedAgent) } - t18 = `${t23} agents`; - t19 = onBack; - t20 = true; - if ($[75] !== changes) { - t21 = changes && changes.length > 0 && {changes[changes.length - 1]}; - $[75] = changes; - $[76] = t21; - } else { - t21 = $[76]; + return + } + + if (e.key !== 'up' && e.key !== 'down') return + e.preventDefault() + + // Handle navigation with "Create New Agent" option + const hasCreateOption = !!onCreateNew + const totalItems = + selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0) + + if (totalItems === 0) return + + // Calculate current position in list (0 = create new, 1+ = agents) + let currentPosition = 0 + if (!isCreateNewSelected && selectedAgent) { + const agentIndex = selectableAgentsInOrder.findIndex( + a => + a.agentType === selectedAgent.agentType && + a.source === selectedAgent.source, + ) + if (agentIndex >= 0) { + currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex } - T0 = Box; - t11 = "column"; - t12 = 0; - t13 = true; - t14 = handleKeyDown; - if ($[77] !== onCreateNew || $[78] !== renderCreateNewOption) { - t15 = onCreateNew && {renderCreateNewOption()}; - $[77] = onCreateNew; - $[78] = renderCreateNewOption; - $[79] = t15; - } else { - t15 = $[79]; + } + + // Calculate new position with wrap-around + const newPosition = + e.key === 'up' + ? currentPosition === 0 + ? totalItems - 1 + : currentPosition - 1 + : currentPosition === totalItems - 1 + ? 0 + : currentPosition + 1 + + // Update selection based on new position + if (hasCreateOption && newPosition === 0) { + setIsCreateNewSelected(true) + setSelectedAgent(null) + } else { + const agentIndex = hasCreateOption ? newPosition - 1 : newPosition + const newAgent = selectableAgentsInOrder[agentIndex] + if (newAgent) { + setIsCreateNewSelected(false) + setSelectedAgent(newAgent) } - t16 = source === "all" ? <>{AGENT_SOURCE_GROUPS.filter(_temp9).map(t24 => { - const { - label, - source: groupSource_0 - } = t24; - return {renderAgentGroup(label, sortedAgents.filter(a_7 => a_7.source === groupSource_0))}; - })}{builtInAgents_0.length > 0 && Built-in agents (always available){builtInAgents_0.map(renderAgent)}} : source === "built-in" ? <>Built-in agents are provided by default and cannot be modified.{sortedAgents.map(agent_2 => renderAgent(agent_2))} : <>{sortedAgents.filter(_temp0).map(agent_3 => renderAgent(agent_3))}{sortedAgents.some(_temp1) && <>{renderBuiltInAgentsSection()}}; } - $[30] = changes; - $[31] = handleKeyDown; - $[32] = onBack; - $[33] = onCreateNew; - $[34] = renderAgent; - $[35] = renderAgentGroup; - $[36] = renderBuiltInAgentsSection; - $[37] = renderCreateNewOption; - $[38] = sortedAgents; - $[39] = source; - $[40] = sourceTitle; - $[41] = T0; - $[42] = T1; - $[43] = t11; - $[44] = t12; - $[45] = t13; - $[46] = t14; - $[47] = t15; - $[48] = t16; - $[49] = t17; - $[50] = t18; - $[51] = t19; - $[52] = t20; - $[53] = t21; - $[54] = t22; - } else { - T0 = $[41]; - T1 = $[42]; - t11 = $[43]; - t12 = $[44]; - t13 = $[45]; - t14 = $[46]; - t15 = $[47]; - t16 = $[48]; - t17 = $[49]; - t18 = $[50]; - t19 = $[51]; - t20 = $[52]; - t21 = $[53]; - t22 = $[54]; } - if (t22 !== Symbol.for("react.early_return_sentinel")) { - return t22; + + const renderBuiltInAgentsSection = ( + title = 'Built-in (always available):', + ) => { + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + return ( + + + {title} + + {builtInAgents.map(renderAgent)} + + ) } - let t23; - if ($[80] !== T0 || $[81] !== t11 || $[82] !== t12 || $[83] !== t13 || $[84] !== t14 || $[85] !== t15 || $[86] !== t16) { - t23 = {t15}{t16}; - $[80] = T0; - $[81] = t11; - $[82] = t12; - $[83] = t13; - $[84] = t14; - $[85] = t15; - $[86] = t16; - $[87] = t23; - } else { - t23 = $[87]; + + const renderAgentGroup = (title: string, groupAgents: ResolvedAgent[]) => { + if (!groupAgents.length) return null + + const folderPath = groupAgents[0]?.baseDir + + return ( + + + + {title} + + {folderPath && ({folderPath})} + + {groupAgents.map(agent => renderAgent(agent))} + + ) } - let t24; - if ($[88] !== T1 || $[89] !== t17 || $[90] !== t18 || $[91] !== t19 || $[92] !== t20 || $[93] !== t21 || $[94] !== t23) { - t24 = {t21}{t23}; - $[88] = T1; - $[89] = t17; - $[90] = t18; - $[91] = t19; - $[92] = t20; - $[93] = t21; - $[94] = t23; - $[95] = t24; - } else { - t24 = $[95]; + + const sourceTitle = getAgentSourceDisplayName(source) + + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + + const hasNoAgents = + !sortedAgents.length || + (source !== 'built-in' && !sortedAgents.some(a => a.source !== 'built-in')) + + if (hasNoAgents) { + return ( + + + {onCreateNew && {renderCreateNewOption()}} + + No agents found. Create specialized subagents that Claude can + delegate to. + + + Each subagent has its own context window, custom system prompt, and + specific tools. + + + Try creating: Code Reviewer, Code Simplifier, Security Reviewer, + Tech Lead, or UX Reviewer. + + {source !== 'built-in' && + sortedAgents.some(a => a.source === 'built-in') && ( + <> + + {renderBuiltInAgentsSection()} + + )} + + + ) } - return t24; -} -function _temp1(a_9) { - return a_9.source === "built-in"; -} -function _temp0(a_8) { - return a_8.source !== "built-in"; -} -function _temp9(g_0) { - return g_0.source !== "built-in"; -} -function _temp8(a_6) { - return !a_6.overriddenBy; -} -function _temp7(a_5) { - return a_5.source === "built-in"; -} -function _temp6(a_4) { - return a_4.source !== "built-in"; -} -function _temp5(a_3) { - return a_3.source === "built-in"; -} -function _temp4(a_2) { - return a_2.source === "built-in"; -} -function _temp3(g) { - return g.source !== "built-in"; -} -function _temp2(a) { - return a.source !== "built-in"; -} -function _temp(agent) { - return { - isOverridden: !!agent.overriddenBy, - overriddenBy: agent.overriddenBy || null - }; + + return ( + !a.overriddenBy)} agents`} + onCancel={onBack} + hideInputGuide + > + {changes && changes.length > 0 && ( + + {changes[changes.length - 1]} + + )} + + {onCreateNew && {renderCreateNewOption()}} + {source === 'all' ? ( + <> + {AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').map( + ({ label, source: groupSource }) => ( + + {renderAgentGroup( + label, + sortedAgents.filter(a => a.source === groupSource), + )} + + ), + )} + {builtInAgents.length > 0 && ( + + + Built-in agents (always available) + + {builtInAgents.map(renderAgent)} + + )} + + ) : source === 'built-in' ? ( + <> + + Built-in agents are provided by default and cannot be modified. + + + {sortedAgents.map(agent => renderAgent(agent))} + + + ) : ( + <> + {sortedAgents + .filter(a => a.source !== 'built-in') + .map(agent => renderAgent(agent))} + {sortedAgents.some(a => a.source === 'built-in') && ( + <> + + {renderBuiltInAgentsSection()} + + )} + + )} + + + ) } diff --git a/src/components/agents/AgentsMenu.tsx b/src/components/agents/AgentsMenu.tsx index 5a3f56eed..91de932b4 100644 --- a/src/components/agents/AgentsMenu.tsx +++ b/src/components/agents/AgentsMenu.tsx @@ -1,799 +1,369 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useMergedTools } from '../../hooks/useMergedTools.js'; -import { Box, Text } from '../../ink.js'; -import { useAppState, useSetAppState } from '../../state/AppState.js'; -import type { Tools } from '../../Tool.js'; -import { type ResolvedAgent, resolveAgentOverrides } from '../../tools/AgentTool/agentDisplay.js'; -import { type AgentDefinition, getActiveAgentsFromList } from '../../tools/AgentTool/loadAgentsDir.js'; -import { toError } from '../../utils/errors.js'; -import { logError } from '../../utils/log.js'; -import { Select } from '../CustomSelect/select.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { AgentDetail } from './AgentDetail.js'; -import { AgentEditor } from './AgentEditor.js'; -import { AgentNavigationFooter } from './AgentNavigationFooter.js'; -import { AgentsList } from './AgentsList.js'; -import { deleteAgentFromFile } from './agentFileUtils.js'; -import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js'; -import type { ModeState } from './types.js'; +import chalk from 'chalk' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import type { SettingSource } from 'src/utils/settings/constants.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useMergedTools } from '../../hooks/useMergedTools.js' +import { Box, Text } from '../../ink.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' +import type { Tools } from '../../Tool.js' +import { + type ResolvedAgent, + resolveAgentOverrides, +} from '../../tools/AgentTool/agentDisplay.js' +import { + type AgentDefinition, + getActiveAgentsFromList, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { toError } from '../../utils/errors.js' +import { logError } from '../../utils/log.js' +import { Select } from '../CustomSelect/select.js' +import { Dialog } from '../design-system/Dialog.js' +import { AgentDetail } from './AgentDetail.js' +import { AgentEditor } from './AgentEditor.js' +import { AgentNavigationFooter } from './AgentNavigationFooter.js' +import { AgentsList } from './AgentsList.js' +import { deleteAgentFromFile } from './agentFileUtils.js' +import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js' +import type { ModeState } from './types.js' + type Props = { - tools: Tools; - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function AgentsMenu(t0) { - const $ = _c(157); - const { - tools, - onExit - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - mode: "list-agents", - source: "all" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const [modeState, setModeState] = useState(t1); - const agentDefinitions = useAppState(_temp); - const mcpTools = useAppState(_temp2); - const toolPermissionContext = useAppState(_temp3); - const setAppState = useSetAppState(); - const { - allAgents, - activeAgents: agents - } = agentDefinitions; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [changes, setChanges] = useState(t2); - const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext); - useExitOnCtrlCDWithKeybindings(); - let t3; - if ($[2] !== allAgents) { - t3 = allAgents.filter(_temp4); - $[2] = allAgents; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== allAgents) { - t4 = allAgents.filter(_temp5); - $[4] = allAgents; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== allAgents) { - t5 = allAgents.filter(_temp6); - $[6] = allAgents; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== allAgents) { - t6 = allAgents.filter(_temp7); - $[8] = allAgents; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== allAgents) { - t7 = allAgents.filter(_temp8); - $[10] = allAgents; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== allAgents) { - t8 = allAgents.filter(_temp9); - $[12] = allAgents; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== allAgents) { - t9 = allAgents.filter(_temp0); - $[14] = allAgents; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== allAgents || $[17] !== t3 || $[18] !== t4 || $[19] !== t5 || $[20] !== t6 || $[21] !== t7 || $[22] !== t8 || $[23] !== t9) { - t10 = { - "built-in": t3, - userSettings: t4, - projectSettings: t5, - policySettings: t6, - localSettings: t7, - flagSettings: t8, - plugin: t9, - all: allAgents - }; - $[16] = allAgents; - $[17] = t3; - $[18] = t4; - $[19] = t5; - $[20] = t6; - $[21] = t7; - $[22] = t8; - $[23] = t9; - $[24] = t10; - } else { - t10 = $[24]; - } - const agentsBySource = t10; - let t11; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t11 = message => { - setChanges(prev => [...prev, message]); - setModeState({ - mode: "list-agents", - source: "all" - }); - }; - $[25] = t11; - } else { - t11 = $[25]; - } - const handleAgentCreated = t11; - let t12; - if ($[26] !== setAppState) { - t12 = async agent => { - ; + tools: Tools + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { + const [modeState, setModeState] = useState({ + mode: 'list-agents', + source: 'all', + }) + const agentDefinitions = useAppState(s => s.agentDefinitions) + const mcpTools = useAppState(s => s.mcp.tools) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const setAppState = useSetAppState() + const { allAgents, activeAgents: agents } = agentDefinitions + const [changes, setChanges] = useState([]) + + // Get MCP tools from app state and merge with local tools + const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext) + + useExitOnCtrlCDWithKeybindings() + + const agentsBySource: Record< + SettingSource | 'all' | 'built-in' | 'plugin', + AgentDefinition[] + > = useMemo( + () => ({ + 'built-in': allAgents.filter(a => a.source === 'built-in'), + userSettings: allAgents.filter(a => a.source === 'userSettings'), + projectSettings: allAgents.filter(a => a.source === 'projectSettings'), + policySettings: allAgents.filter(a => a.source === 'policySettings'), + localSettings: allAgents.filter(a => a.source === 'localSettings'), + flagSettings: allAgents.filter(a => a.source === 'flagSettings'), + plugin: allAgents.filter(a => a.source === 'plugin'), + all: allAgents, + }), + [allAgents], + ) + + const handleAgentCreated = useCallback((message: string) => { + setChanges(prev => [...prev, message]) + setModeState({ mode: 'list-agents', source: 'all' }) + }, []) + + const handleAgentDeleted = useCallback( + async (agent: AgentDefinition) => { try { - await deleteAgentFromFile(agent); + await deleteAgentFromFile(agent) setAppState(state => { - const allAgents_0 = state.agentDefinitions.allAgents.filter(a_6 => !(a_6.agentType === agent.agentType && a_6.source === agent.source)); + const allAgents = state.agentDefinitions.allAgents.filter( + a => + !(a.agentType === agent.agentType && a.source === agent.source), + ) return { ...state, agentDefinitions: { ...state.agentDefinitions, - allAgents: allAgents_0, - activeAgents: getActiveAgentsFromList(allAgents_0) - } - }; - }); - setChanges(prev_0 => [...prev_0, `Deleted agent: ${chalk.bold(agent.agentType)}`]); - setModeState({ - mode: "list-agents", - source: "all" - }); - } catch (t13) { - const error = t13; - logError(toError(error)); + allAgents, + activeAgents: getActiveAgentsFromList(allAgents), + }, + } + }) + + setChanges(prev => [ + ...prev, + `Deleted agent: ${chalk.bold(agent.agentType)}`, + ]) + // Go back to the agents list after deletion + setModeState({ mode: 'list-agents', source: 'all' }) + } catch (error) { + logError(toError(error)) } - }; - $[26] = setAppState; - $[27] = t12; - } else { - t12 = $[27]; - } - const handleAgentDeleted = t12; + }, + [setAppState], + ) + + // Render based on mode switch (modeState.mode) { - case "list-agents": - { - let t13; - if ($[28] !== agentsBySource || $[29] !== modeState.source) { - t13 = modeState.source === "all" ? [...agentsBySource["built-in"], ...agentsBySource.userSettings, ...agentsBySource.projectSettings, ...agentsBySource.localSettings, ...agentsBySource.policySettings, ...agentsBySource.flagSettings, ...agentsBySource.plugin] : agentsBySource[modeState.source]; - $[28] = agentsBySource; - $[29] = modeState.source; - $[30] = t13; - } else { - t13 = $[30]; - } - const agentsToShow = t13; - let t14; - if ($[31] !== agents || $[32] !== agentsToShow) { - t14 = resolveAgentOverrides(agentsToShow, agents); - $[31] = agents; - $[32] = agentsToShow; - $[33] = t14; - } else { - t14 = $[33]; - } - const allResolved = t14; - const resolvedAgents = allResolved; - let t15; - if ($[34] !== changes || $[35] !== onExit) { - t15 = () => { - const exitMessage = changes.length > 0 ? `Agent changes:\n${changes.join("\n")}` : undefined; - onExit(exitMessage ?? "Agents dialog dismissed", { - display: changes.length === 0 ? "system" : undefined - }); - }; - $[34] = changes; - $[35] = onExit; - $[36] = t15; - } else { - t15 = $[36]; - } - let t16; - if ($[37] !== modeState) { - t16 = agent_0 => setModeState({ - mode: "agent-menu", - agent: agent_0, - previousMode: modeState - }); - $[37] = modeState; - $[38] = t16; - } else { - t16 = $[38]; - } - let t17; - if ($[39] === Symbol.for("react.memo_cache_sentinel")) { - t17 = () => setModeState({ - mode: "create-agent" - }); - $[39] = t17; - } else { - t17 = $[39]; - } - let t18; - if ($[40] !== changes || $[41] !== modeState.source || $[42] !== resolvedAgents || $[43] !== t15 || $[44] !== t16) { - t18 = ; - $[40] = changes; - $[41] = modeState.source; - $[42] = resolvedAgents; - $[43] = t15; - $[44] = t16; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[46] = t19; - } else { - t19 = $[46]; - } - let t20; - if ($[47] !== t18) { - t20 = <>{t18}{t19}; - $[47] = t18; - $[48] = t20; - } else { - t20 = $[48]; - } - return t20; - } - case "create-agent": - { - let t13; - if ($[49] === Symbol.for("react.memo_cache_sentinel")) { - t13 = () => setModeState({ - mode: "list-agents", - source: "all" - }); - $[49] = t13; - } else { - t13 = $[49]; - } - let t14; - if ($[50] !== agents || $[51] !== mergedTools) { - t14 = ; - $[50] = agents; - $[51] = mergedTools; - $[52] = t14; - } else { - t14 = $[52]; - } - return t14; - } - case "agent-menu": - { - let t13; - if ($[53] !== allAgents || $[54] !== modeState.agent.agentType || $[55] !== modeState.agent.source) { - let t14; - if ($[57] !== modeState.agent.agentType || $[58] !== modeState.agent.source) { - t14 = a_9 => a_9.agentType === modeState.agent.agentType && a_9.source === modeState.agent.source; - $[57] = modeState.agent.agentType; - $[58] = modeState.agent.source; - $[59] = t14; - } else { - t14 = $[59]; - } - t13 = allAgents.find(t14); - $[53] = allAgents; - $[54] = modeState.agent.agentType; - $[55] = modeState.agent.source; - $[56] = t13; - } else { - t13 = $[56]; - } - const freshAgent_1 = t13; - const agentToUse = freshAgent_1 || modeState.agent; - const isEditable = agentToUse.source !== "built-in" && agentToUse.source !== "plugin" && agentToUse.source !== "flagSettings"; - let t14; - if ($[60] === Symbol.for("react.memo_cache_sentinel")) { - t14 = { - label: "View agent", - value: "view" - }; - $[60] = t14; - } else { - t14 = $[60]; - } - let t15; - if ($[61] !== isEditable) { - t15 = isEditable ? [{ - label: "Edit agent", - value: "edit" - }, { - label: "Delete agent", - value: "delete" - }] : []; - $[61] = isEditable; - $[62] = t15; - } else { - t15 = $[62]; - } - let t16; - if ($[63] === Symbol.for("react.memo_cache_sentinel")) { - t16 = { - label: "Back", - value: "back" - }; - $[63] = t16; - } else { - t16 = $[63]; - } - let t17; - if ($[64] !== t15) { - t17 = [t14, ...t15, t16]; - $[64] = t15; - $[65] = t17; - } else { - t17 = $[65]; - } - const menuItems = t17; - let t18; - if ($[66] !== agentToUse || $[67] !== modeState) { - t18 = value_0 => { - bb129: switch (value_0) { - case "view": - { - setModeState({ - mode: "view-agent", - agent: agentToUse, - previousMode: modeState.previousMode - }); - break bb129; - } - case "edit": - { - setModeState({ - mode: "edit-agent", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "delete": - { - setModeState({ - mode: "delete-confirm", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "back": - { - setModeState(modeState.previousMode); - } + case 'list-agents': { + const agentsToShow = + modeState.source === 'all' + ? [ + ...agentsBySource['built-in'], + ...agentsBySource['userSettings'], + ...agentsBySource['projectSettings'], + ...agentsBySource['localSettings'], + ...agentsBySource['policySettings'], + ...agentsBySource['flagSettings'], + ...agentsBySource['plugin'], + ] + : agentsBySource[modeState.source] + + // Resolve overrides and filter to the agents we want to show + const allResolved = resolveAgentOverrides(agentsToShow, agents) + const resolvedAgents: ResolvedAgent[] = allResolved + + return ( + <> + { + const exitMessage = + changes.length > 0 + ? `Agent changes:\n${changes.join('\n')}` + : undefined + onExit(exitMessage ?? 'Agents dialog dismissed', { + display: changes.length === 0 ? 'system' : undefined, + }) + }} + onSelect={agent => + setModeState({ + mode: 'agent-menu', + agent, + previousMode: modeState, + }) } - }; - $[66] = agentToUse; - $[67] = modeState; - $[68] = t18; - } else { - t18 = $[68]; - } - const handleMenuSelect = t18; - let t19; - if ($[69] !== modeState.previousMode) { - t19 = () => setModeState(modeState.previousMode); - $[69] = modeState.previousMode; - $[70] = t19; - } else { - t19 = $[70]; - } - let t20; - if ($[71] !== modeState.previousMode) { - t20 = () => setModeState(modeState.previousMode); - $[71] = modeState.previousMode; - $[72] = t20; - } else { - t20 = $[72]; - } - let t21; - if ($[73] !== handleMenuSelect || $[74] !== menuItems || $[75] !== t20) { - t21 = setModeState(modeState.previousMode)} + /> + {changes.length > 0 && ( + + {changes[changes.length - 1]} + + )} + + + + + ) + } + + case 'view-agent': { + // Always use fresh agent data from allAgents + const freshAgent = allAgents.find( + a => + a.agentType === modeState.agent.agentType && + a.source === modeState.agent.source, + ) + const agentToDisplay = freshAgent || modeState.agent + + return ( + <> + + setModeState({ + mode: 'agent-menu', + agent: agentToDisplay, + previousMode: modeState.previousMode, + }) } - }; - $[113] = modeState; - $[114] = t14; - } else { - t14 = $[114]; - } - let t15; - if ($[115] !== modeState.agent.agentType) { - t15 = Are you sure you want to delete the agent{" "}{modeState.agent.agentType}?; - $[115] = modeState.agent.agentType; - $[116] = t15; - } else { - t15 = $[116]; - } - let t16; - if ($[117] !== modeState.agent.source) { - t16 = Source: {modeState.agent.source}; - $[117] = modeState.agent.source; - $[118] = t16; - } else { - t16 = $[118]; - } - let t17; - if ($[119] !== handleAgentDeleted || $[120] !== modeState) { - t17 = value => { - if (value === "yes") { - handleAgentDeleted(modeState.agent); - } else { - if ("previousMode" in modeState) { - setModeState(modeState.previousMode); + hideInputGuide + > + + setModeState({ + mode: 'agent-menu', + agent: agentToDisplay, + previousMode: modeState.previousMode, + }) } - } - }; - $[119] = handleAgentDeleted; - $[120] = modeState; - $[121] = t17; - } else { - t17 = $[121]; - } - let t18; - if ($[122] !== modeState) { - t18 = () => { - if ("previousMode" in modeState) { - setModeState(modeState.previousMode); - } - }; - $[122] = modeState; - $[123] = t18; - } else { - t18 = $[123]; - } - let t19; - if ($[124] !== t17 || $[125] !== t18) { - t19 = { + if (value === 'yes') { + void handleAgentDeleted(modeState.agent) + } else { + if ('previousMode' in modeState) { + setModeState(modeState.previousMode) + } + } + }} + onCancel={() => { + if ('previousMode' in modeState) { + setModeState(modeState.previousMode) + } + }} + /> + + + + + ) + } + + case 'edit-agent': { + // Always use fresh agent data + const freshAgent = allAgents.find( + a => + a.agentType === modeState.agent.agentType && + a.source === modeState.agent.source, + ) + const agentToEdit = freshAgent || modeState.agent + + return ( + <> + setModeState(modeState.previousMode)} + hideInputGuide + > + { + handleAgentCreated(message) + setModeState(modeState.previousMode) + }} + onBack={() => setModeState(modeState.previousMode)} + /> + + + + ) + } + default: - { - return null; - } + return null } } -function _temp0(a_5) { - return a_5.source === "plugin"; -} -function _temp9(a_4) { - return a_4.source === "flagSettings"; -} -function _temp8(a_3) { - return a_3.source === "localSettings"; -} -function _temp7(a_2) { - return a_2.source === "policySettings"; -} -function _temp6(a_1) { - return a_1.source === "projectSettings"; -} -function _temp5(a_0) { - return a_0.source === "userSettings"; -} -function _temp4(a) { - return a.source === "built-in"; -} -function _temp3(s_1) { - return s_1.toolPermissionContext; -} -function _temp2(s_0) { - return s_0.mcp.tools; -} -function _temp(s) { - return s.agentDefinitions; -} diff --git a/src/components/agents/ColorPicker.tsx b/src/components/agents/ColorPicker.tsx index 2f372e3c0..8549424cd 100644 --- a/src/components/agents/ColorPicker.tsx +++ b/src/components/agents/ColorPicker.tsx @@ -1,111 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useState } from 'react'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import { capitalize } from '../../utils/stringUtils.js'; -type ColorOption = AgentColorName | 'automatic'; -const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS]; +import figures from 'figures' +import React, { useState } from 'react' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import { capitalize } from '../../utils/stringUtils.js' + +type ColorOption = AgentColorName | 'automatic' + +const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS] + type Props = { - agentName: string; - currentColor?: AgentColorName | 'automatic'; - onConfirm: (color: AgentColorName | undefined) => void; -}; -export function ColorPicker(t0) { - const $ = _c(17); - const { - agentName, - currentColor: t1, - onConfirm - } = t0; - const currentColor = t1 === undefined ? "automatic" : t1; - let t2; - if ($[0] !== currentColor) { - t2 = COLOR_OPTIONS.findIndex(opt => opt === currentColor); - $[0] = currentColor; - $[1] = t2; - } else { - t2 = $[1]; - } - const [selectedIndex, setSelectedIndex] = useState(Math.max(0, t2)); - let t3; - if ($[2] !== onConfirm || $[3] !== selectedIndex) { - t3 = e => { - if (e.key === "up") { - e.preventDefault(); - setSelectedIndex(_temp); - } else { - if (e.key === "down") { - e.preventDefault(); - setSelectedIndex(_temp2); - } else { - if (e.key === "return") { - e.preventDefault(); - const selected = COLOR_OPTIONS[selectedIndex]; - onConfirm(selected === "automatic" ? undefined : selected); - } - } - } - }; - $[2] = onConfirm; - $[3] = selectedIndex; - $[4] = t3; - } else { - t3 = $[4]; - } - const handleKeyDown = t3; - const selectedValue = COLOR_OPTIONS[selectedIndex]; - let t4; - if ($[5] !== selectedIndex) { - t4 = COLOR_OPTIONS.map((option, index) => { - const isSelected = index === selectedIndex; - return {isSelected ? figures.pointer : " "}{option === "automatic" ? Automatic color : {" "}{capitalize(option)}}; - }); - $[5] = selectedIndex; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t4) { - t5 = {t4}; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Preview: ; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== agentName || $[11] !== selectedValue) { - t7 = {t6}{selectedValue === undefined || selectedValue === "automatic" ? {" "}@{agentName}{" "} : {" "}@{agentName}{" "}}; - $[10] = agentName; - $[11] = selectedValue; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== handleKeyDown || $[14] !== t5 || $[15] !== t7) { - t8 = {t5}{t7}; - $[13] = handleKeyDown; - $[14] = t5; - $[15] = t7; - $[16] = t8; - } else { - t8 = $[16]; - } - return t8; + agentName: string + currentColor?: AgentColorName | 'automatic' + onConfirm: (color: AgentColorName | undefined) => void } -function _temp2(prev_0) { - return prev_0 < COLOR_OPTIONS.length - 1 ? prev_0 + 1 : 0; -} -function _temp(prev) { - return prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1; + +export function ColorPicker({ + agentName, + currentColor = 'automatic', + onConfirm, +}: Props): React.ReactNode { + const [selectedIndex, setSelectedIndex] = useState( + Math.max( + 0, + COLOR_OPTIONS.findIndex(opt => opt === currentColor), + ), + ) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'up') { + e.preventDefault() + setSelectedIndex(prev => (prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1)) + } else if (e.key === 'down') { + e.preventDefault() + setSelectedIndex(prev => (prev < COLOR_OPTIONS.length - 1 ? prev + 1 : 0)) + } else if (e.key === 'return') { + e.preventDefault() + const selected = COLOR_OPTIONS[selectedIndex] + onConfirm(selected === 'automatic' ? undefined : selected) + } + } + + const selectedValue = COLOR_OPTIONS[selectedIndex] + + return ( + + + {COLOR_OPTIONS.map((option, index) => { + const isSelected = index === selectedIndex + + return ( + + + {isSelected ? figures.pointer : ' '} + + + {option === 'automatic' ? ( + Automatic color + ) : ( + + + {' '} + + {capitalize(option)} + + )} + + ) + })} + + + + Preview: + {selectedValue === undefined || selectedValue === 'automatic' ? ( + + {' '} + @{agentName}{' '} + + ) : ( + + {' '} + @{agentName}{' '} + + )} + + + ) } diff --git a/src/components/agents/ModelSelector.tsx b/src/components/agents/ModelSelector.tsx index 9e186c7d2..4f1b2e8af 100644 --- a/src/components/agents/ModelSelector.tsx +++ b/src/components/agents/ModelSelector.tsx @@ -1,67 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getAgentModelOptions } from '../../utils/model/agent.js'; -import { Select } from '../CustomSelect/select.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { getAgentModelOptions } from '../../utils/model/agent.js' +import { Select } from '../CustomSelect/select.js' + interface ModelSelectorProps { - initialModel?: string; - onComplete: (model?: string) => void; - onCancel?: () => void; + initialModel?: string + onComplete: (model?: string) => void + onCancel?: () => void } -export function ModelSelector(t0) { - const $ = _c(11); - const { - initialModel, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== initialModel) { - bb0: { - const base = getAgentModelOptions(); - if (initialModel && !base.some(o => o.value === initialModel)) { - t1 = [{ + +export function ModelSelector({ + initialModel, + onComplete, + onCancel, +}: ModelSelectorProps): React.ReactNode { + const modelOptions = React.useMemo(() => { + const base = getAgentModelOptions() + // If the agent's current model is a full ID (e.g. 'claude-opus-4-5') not + // in the alias list, inject it as an option so it can round-trip through + // confirm without being overwritten. + if (initialModel && !base.some(o => o.value === initialModel)) { + return [ + { value: initialModel, label: initialModel, - description: "Current model (custom ID)" - }, ...base]; - break bb0; - } - t1 = base; + description: 'Current model (custom ID)', + }, + ...base, + ] } - $[0] = initialModel; - $[1] = t1; - } else { - t1 = $[1]; - } - const modelOptions = t1; - const defaultModel = initialModel ?? "sonnet"; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Model determines the agent's reasoning capabilities and speed.; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onCancel || $[4] !== onComplete) { - t3 = () => onCancel ? onCancel() : onComplete(undefined); - $[3] = onCancel; - $[4] = onComplete; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== defaultModel || $[7] !== modelOptions || $[8] !== onComplete || $[9] !== t3) { - t4 = {t2} (onCancel ? onCancel() : onComplete(undefined))} + /> + + ) } diff --git a/src/components/agents/ToolSelector.tsx b/src/components/agents/ToolSelector.tsx index 27766abae..9bc20b7d8 100644 --- a/src/components/agents/ToolSelector.tsx +++ b/src/components/agents/ToolSelector.tsx @@ -1,561 +1,478 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useCallback, useMemo, useState } from 'react'; -import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js'; -import { isMcpTool } from 'src/services/mcp/utils.js'; -import type { Tool, Tools } from 'src/Tool.js'; -import { filterToolsForAgent } from 'src/tools/AgentTool/agentToolUtils.js'; -import { AGENT_TOOL_NAME } from 'src/tools/AgentTool/constants.js'; -import { BashTool } from 'src/tools/BashTool/BashTool.js'; -import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; -import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js'; -import { FileReadTool } from 'src/tools/FileReadTool/FileReadTool.js'; -import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js'; -import { GlobTool } from 'src/tools/GlobTool/GlobTool.js'; -import { GrepTool } from 'src/tools/GrepTool/GrepTool.js'; -import { ListMcpResourcesTool } from 'src/tools/ListMcpResourcesTool/ListMcpResourcesTool.js'; -import { NotebookEditTool } from 'src/tools/NotebookEditTool/NotebookEditTool.js'; -import { ReadMcpResourceTool } from 'src/tools/ReadMcpResourceTool/ReadMcpResourceTool.js'; -import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js'; -import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js'; -import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js'; -import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.js'; -import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js'; -import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { count } from '../../utils/array.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Divider } from '../design-system/Divider.js'; +import figures from 'figures' +import React, { useCallback, useMemo, useState } from 'react' +import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js' +import { isMcpTool } from 'src/services/mcp/utils.js' +import type { Tool, Tools } from 'src/Tool.js' +import { filterToolsForAgent } from 'src/tools/AgentTool/agentToolUtils.js' +import { AGENT_TOOL_NAME } from 'src/tools/AgentTool/constants.js' +import { BashTool } from 'src/tools/BashTool/BashTool.js' +import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' +import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js' +import { FileReadTool } from 'src/tools/FileReadTool/FileReadTool.js' +import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js' +import { GlobTool } from 'src/tools/GlobTool/GlobTool.js' +import { GrepTool } from 'src/tools/GrepTool/GrepTool.js' +import { ListMcpResourcesTool } from 'src/tools/ListMcpResourcesTool/ListMcpResourcesTool.js' +import { NotebookEditTool } from 'src/tools/NotebookEditTool/NotebookEditTool.js' +import { ReadMcpResourceTool } from 'src/tools/ReadMcpResourceTool/ReadMcpResourceTool.js' +import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js' +import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js' +import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js' +import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.js' +import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js' +import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { count } from '../../utils/array.js' +import { plural } from '../../utils/stringUtils.js' +import { Divider } from '../design-system/Divider.js' + type Props = { - tools: Tools; - initialTools: string[] | undefined; - onComplete: (selectedTools: string[] | undefined) => void; - onCancel?: () => void; -}; + tools: Tools + initialTools: string[] | undefined + onComplete: (selectedTools: string[] | undefined) => void + onCancel?: () => void +} + type ToolBucket = { - name: string; - toolNames: Set; - isMcp?: boolean; -}; + name: string + toolNames: Set + isMcp?: boolean +} + type ToolBuckets = { - READ_ONLY: ToolBucket; - EDIT: ToolBucket; - EXECUTION: ToolBucket; - MCP: ToolBucket; - OTHER: ToolBucket; -}; + READ_ONLY: ToolBucket + EDIT: ToolBucket + EXECUTION: ToolBucket + MCP: ToolBucket + OTHER: ToolBucket +} + function getToolBuckets(): ToolBuckets { return { READ_ONLY: { name: 'Read-only tools', - toolNames: new Set([GlobTool.name, GrepTool.name, ExitPlanModeV2Tool.name, FileReadTool.name, WebFetchTool.name, TodoWriteTool.name, WebSearchTool.name, TaskStopTool.name, TaskOutputTool.name, ListMcpResourcesTool.name, ReadMcpResourceTool.name]) + toolNames: new Set([ + GlobTool.name, + GrepTool.name, + ExitPlanModeV2Tool.name, + FileReadTool.name, + WebFetchTool.name, + TodoWriteTool.name, + WebSearchTool.name, + TaskStopTool.name, + TaskOutputTool.name, + ListMcpResourcesTool.name, + ReadMcpResourceTool.name, + ]), }, EDIT: { name: 'Edit tools', - toolNames: new Set([FileEditTool.name, FileWriteTool.name, NotebookEditTool.name]) + toolNames: new Set([ + FileEditTool.name, + FileWriteTool.name, + NotebookEditTool.name, + ]), }, EXECUTION: { name: 'Execution tools', - toolNames: new Set([BashTool.name, (process.env.USER_TYPE) === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined)) + toolNames: new Set( + [ + BashTool.name, + process.env.USER_TYPE === 'ant' ? TungstenTool.name : undefined, + ].filter(n => n !== undefined), + ), }, MCP: { name: 'MCP tools', - toolNames: new Set(), - // Dynamic - no static list - isMcp: true + toolNames: new Set(), // Dynamic - no static list + isMcp: true, }, OTHER: { name: 'Other tools', - toolNames: new Set() // Dynamic - catch-all for uncategorized tools - } - }; + toolNames: new Set(), // Dynamic - catch-all for uncategorized tools + }, + } } // Helper to get MCP server buckets dynamically function getMcpServerBuckets(tools: Tools): Array<{ - serverName: string; - tools: Tools; + serverName: string + tools: Tools }> { - const serverMap = new Map(); + const serverMap = new Map() + tools.forEach(tool => { if (isMcpTool(tool)) { - const mcpInfo = mcpInfoFromString(tool.name); + const mcpInfo = mcpInfoFromString(tool.name) if (mcpInfo?.serverName) { - const existing = serverMap.get(mcpInfo.serverName) || []; - existing.push(tool); - serverMap.set(mcpInfo.serverName, existing); + const existing = serverMap.get(mcpInfo.serverName) || [] + existing.push(tool) + serverMap.set(mcpInfo.serverName, existing) } } - }); - return Array.from(serverMap.entries()).map(([serverName, tools]) => ({ - serverName, - tools - })).sort((a, b) => a.serverName.localeCompare(b.serverName)); + }) + + return Array.from(serverMap.entries()) + .map(([serverName, tools]) => ({ serverName, tools })) + .sort((a, b) => a.serverName.localeCompare(b.serverName)) } -export function ToolSelector(t0) { - const $ = _c(69); - const { - tools, - initialTools, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== tools) { - t1 = filterToolsForAgent({ - tools, - isBuiltIn: false, - isAsync: false - }); - $[0] = tools; - $[1] = t1; - } else { - t1 = $[1]; - } - const customAgentTools = t1; - let t2; - if ($[2] !== customAgentTools || $[3] !== initialTools) { - t2 = !initialTools || initialTools.includes("*") ? customAgentTools.map(_temp) : initialTools; - $[2] = customAgentTools; - $[3] = initialTools; - $[4] = t2; - } else { - t2 = $[4]; - } - const expandedInitialTools = t2; - const [selectedTools, setSelectedTools] = useState(expandedInitialTools); - const [focusIndex, setFocusIndex] = useState(0); - const [showIndividualTools, setShowIndividualTools] = useState(false); - let t3; - if ($[5] !== customAgentTools) { - t3 = new Set(customAgentTools.map(_temp2)); - $[5] = customAgentTools; - $[6] = t3; - } else { - t3 = $[6]; - } - const toolNames = t3; - let t4; - if ($[7] !== selectedTools || $[8] !== toolNames) { - let t5; - if ($[10] !== toolNames) { - t5 = name => toolNames.has(name); - $[10] = toolNames; - $[11] = t5; - } else { - t5 = $[11]; - } - t4 = selectedTools.filter(t5); - $[7] = selectedTools; - $[8] = toolNames; - $[9] = t4; - } else { - t4 = $[9]; - } - const validSelectedTools = t4; - let t5; - if ($[12] !== validSelectedTools) { - t5 = new Set(validSelectedTools); - $[12] = validSelectedTools; - $[13] = t5; - } else { - t5 = $[13]; + +export function ToolSelector({ + tools, + initialTools, + onComplete, + onCancel, +}: Props): React.ReactNode { + // Filter tools for custom agents + const customAgentTools = useMemo( + () => filterToolsForAgent({ tools, isBuiltIn: false, isAsync: false }), + [tools], + ) + + // Expand wildcard or undefined to explicit tool list for internal state + const expandedInitialTools = + !initialTools || initialTools.includes('*') + ? customAgentTools.map(t => t.name) + : initialTools + + const [selectedTools, setSelectedTools] = + useState(expandedInitialTools) + const [focusIndex, setFocusIndex] = useState(0) + const [showIndividualTools, setShowIndividualTools] = useState(false) + + // Filter selectedTools to only include tools that currently exist + // This handles MCP tools that disconnect while selected + const validSelectedTools = useMemo(() => { + const toolNames = new Set(customAgentTools.map(t => t.name)) + return selectedTools.filter(name => toolNames.has(name)) + }, [selectedTools, customAgentTools]) + + const selectedSet = new Set(validSelectedTools) + const isAllSelected = + validSelectedTools.length === customAgentTools.length && + customAgentTools.length > 0 + + const handleToggleTool = (toolName: string) => { + if (!toolName) return + + setSelectedTools(current => + current.includes(toolName) + ? current.filter(t => t !== toolName) + : [...current, toolName], + ) } - const selectedSet = t5; - const isAllSelected = validSelectedTools.length === customAgentTools.length && customAgentTools.length > 0; - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = toolName => { - if (!toolName) { - return; + + const handleToggleTools = (toolNames: string[], select: boolean) => { + setSelectedTools(current => { + if (select) { + const toolsToAdd = toolNames.filter(t => !current.includes(t)) + return [...current, ...toolsToAdd] + } else { + return current.filter(t => !toolNames.includes(t)) } - setSelectedTools(current => current.includes(toolName) ? current.filter(t_1 => t_1 !== toolName) : [...current, toolName]); - }; - $[14] = t6; - } else { - t6 = $[14]; - } - const handleToggleTool = t6; - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = (toolNames_0, select) => { - setSelectedTools(current_0 => { - if (select) { - const toolsToAdd = toolNames_0.filter(t_2 => !current_0.includes(t_2)); - return [...current_0, ...toolsToAdd]; - } else { - return current_0.filter(t_3 => !toolNames_0.includes(t_3)); - } - }); - }; - $[15] = t7; - } else { - t7 = $[15]; + }) } - const handleToggleTools = t7; - let t8; - if ($[16] !== customAgentTools || $[17] !== onComplete || $[18] !== validSelectedTools) { - t8 = () => { - const allToolNames = customAgentTools.map(_temp3); - const areAllToolsSelected = validSelectedTools.length === allToolNames.length && allToolNames.every(name_0 => validSelectedTools.includes(name_0)); - const finalTools = areAllToolsSelected ? undefined : validSelectedTools; - onComplete(finalTools); - }; - $[16] = customAgentTools; - $[17] = onComplete; - $[18] = validSelectedTools; - $[19] = t8; - } else { - t8 = $[19]; + + const handleConfirm = () => { + // Convert to undefined if all tools are selected (for cleaner file format) + const allToolNames = customAgentTools.map(t => t.name) + const areAllToolsSelected = + validSelectedTools.length === allToolNames.length && + allToolNames.every(name => validSelectedTools.includes(name)) + const finalTools = areAllToolsSelected ? undefined : validSelectedTools + + onComplete(finalTools) } - const handleConfirm = t8; - let buckets; - if ($[20] !== customAgentTools) { - const toolBuckets = getToolBuckets(); - buckets = { + + // Group tools by bucket + const toolsByBucket = useMemo(() => { + const toolBuckets = getToolBuckets() + const buckets = { readOnly: [] as Tool[], edit: [] as Tool[], execution: [] as Tool[], mcp: [] as Tool[], - other: [] as Tool[] - }; + other: [] as Tool[], + } + customAgentTools.forEach(tool => { + // Check if it's an MCP tool first if (isMcpTool(tool)) { - buckets.mcp.push(tool); - } else { - if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { - buckets.readOnly.push(tool); - } else { - if (toolBuckets.EDIT.toolNames.has(tool.name)) { - buckets.edit.push(tool); - } else { - if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { - buckets.execution.push(tool); - } else { - if (tool.name !== AGENT_TOOL_NAME) { - buckets.other.push(tool); - } - } - } - } + buckets.mcp.push(tool) + } else if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { + buckets.readOnly.push(tool) + } else if (toolBuckets.EDIT.toolNames.has(tool.name)) { + buckets.edit.push(tool) + } else if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { + buckets.execution.push(tool) + } else if (tool.name !== AGENT_TOOL_NAME) { + // Catch-all for uncategorized tools (except Task) + buckets.other.push(tool) } - }); - $[20] = customAgentTools; - $[21] = buckets; - } else { - buckets = $[21]; - } - const toolsByBucket = buckets; - let t9; - if ($[22] !== selectedSet) { - t9 = bucketTools => { - const selected = count(bucketTools, (t_5: Tool) => selectedSet.has(t_5.name)); - const needsSelection = selected < bucketTools.length; - return () => { - const toolNames_1 = bucketTools.map(_temp4); - handleToggleTools(toolNames_1, needsSelection); - }; - }; - $[22] = selectedSet; - $[23] = t9; - } else { - t9 = $[23]; + }) + + return buckets + }, [customAgentTools]) + + const createBucketToggleAction = (bucketTools: Tool[]) => { + const selected = count(bucketTools, t => selectedSet.has(t.name)) + const needsSelection = selected < bucketTools.length + + return () => { + const toolNames = bucketTools.map(t => t.name) + handleToggleTools(toolNames, needsSelection) + } } - const createBucketToggleAction = t9; - let navigableItems; - if ($[24] !== createBucketToggleAction || $[25] !== customAgentTools || $[26] !== focusIndex || $[27] !== handleConfirm || $[28] !== isAllSelected || $[29] !== selectedSet || $[30] !== showIndividualTools || $[31] !== toolsByBucket.edit || $[32] !== toolsByBucket.execution || $[33] !== toolsByBucket.mcp || $[34] !== toolsByBucket.other || $[35] !== toolsByBucket.readOnly) { - navigableItems = []; + + // Build navigable items (no separators) + const navigableItems: Array<{ + id: string + label: string + action: () => void + isContinue?: boolean + isToggle?: boolean + isHeader?: boolean + }> = [] + + // Continue button + navigableItems.push({ + id: 'continue', + label: 'Continue', + action: handleConfirm, + isContinue: true, + }) + + // All tools + navigableItems.push({ + id: 'bucket-all', + label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, + action: () => { + const allToolNames = customAgentTools.map(t => t.name) + handleToggleTools(allToolNames, !isAllSelected) + }, + }) + + // Create bucket menu items + const toolBuckets = getToolBuckets() + const bucketConfigs = [ + { + id: 'bucket-readonly', + name: toolBuckets.READ_ONLY.name, + tools: toolsByBucket.readOnly, + }, + { + id: 'bucket-edit', + name: toolBuckets.EDIT.name, + tools: toolsByBucket.edit, + }, + { + id: 'bucket-execution', + name: toolBuckets.EXECUTION.name, + tools: toolsByBucket.execution, + }, + { + id: 'bucket-mcp', + name: toolBuckets.MCP.name, + tools: toolsByBucket.mcp, + }, + { + id: 'bucket-other', + name: toolBuckets.OTHER.name, + tools: toolsByBucket.other, + }, + ] + + bucketConfigs.forEach(({ id, name, tools: bucketTools }) => { + if (bucketTools.length === 0) return + + const selected = count(bucketTools, t => selectedSet.has(t.name)) + const isFullySelected = selected === bucketTools.length + navigableItems.push({ - id: "continue", - label: "Continue", - action: handleConfirm, - isContinue: true - }); - let t10; - if ($[37] !== customAgentTools || $[38] !== isAllSelected) { - t10 = () => { - const allToolNames_0 = customAgentTools.map(_temp5); - handleToggleTools(allToolNames_0, !isAllSelected); - }; - $[37] = customAgentTools; - $[38] = isAllSelected; - $[39] = t10; - } else { - t10 = $[39]; + id, + label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name}`, + action: createBucketToggleAction(bucketTools), + }) + }) + + // Toggle button for individual tools + const toggleButtonIndex = navigableItems.length + navigableItems.push({ + id: 'toggle-individual', + label: showIndividualTools + ? 'Hide advanced options' + : 'Show advanced options', + action: () => { + setShowIndividualTools(!showIndividualTools) + // If hiding tools and focus is on an individual tool, move focus to toggle button + if (showIndividualTools && focusIndex > toggleButtonIndex) { + setFocusIndex(toggleButtonIndex) + } + }, + isToggle: true, + }) + + // Memoize MCP server buckets (must be outside conditional for hooks rules) + const mcpServerBuckets = useMemo( + () => getMcpServerBuckets(customAgentTools), + [customAgentTools], + ) + + // Individual tools (only if expanded) + if (showIndividualTools) { + // Add MCP server buckets if any exist + if (mcpServerBuckets.length > 0) { + navigableItems.push({ + id: 'mcp-servers-header', + label: 'MCP Servers:', + action: () => {}, // No action - just a header + isHeader: true, + }) + + mcpServerBuckets.forEach(({ serverName, tools: serverTools }) => { + const selected = count(serverTools, t => selectedSet.has(t.name)) + const isFullySelected = selected === serverTools.length + + navigableItems.push({ + id: `mcp-server-${serverName}`, + label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${serverName} (${serverTools.length} ${plural(serverTools.length, 'tool')})`, + action: () => { + const toolNames = serverTools.map(t => t.name) + handleToggleTools(toolNames, !isFullySelected) + }, + }) + }) + + // Add separator header before individual tools + navigableItems.push({ + id: 'tools-header', + label: 'Individual Tools:', + action: () => {}, + isHeader: true, + }) } - navigableItems.push({ - id: "bucket-all", - label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, - action: t10 - }); - const toolBuckets_0 = getToolBuckets(); - const bucketConfigs = [{ - id: "bucket-readonly", - name: toolBuckets_0.READ_ONLY.name, - tools: toolsByBucket.readOnly - }, { - id: "bucket-edit", - name: toolBuckets_0.EDIT.name, - tools: toolsByBucket.edit - }, { - id: "bucket-execution", - name: toolBuckets_0.EXECUTION.name, - tools: toolsByBucket.execution - }, { - id: "bucket-mcp", - name: toolBuckets_0.MCP.name, - tools: toolsByBucket.mcp - }, { - id: "bucket-other", - name: toolBuckets_0.OTHER.name, - tools: toolsByBucket.other - }]; - bucketConfigs.forEach(t11 => { - const { - id, - name: name_1, - tools: bucketTools_0 - } = t11; - if (bucketTools_0.length === 0) { - return; + + // Add individual tools + customAgentTools.forEach(tool => { + let displayName = tool.name + if (tool.name.startsWith('mcp__')) { + const mcpInfo = mcpInfoFromString(tool.name) + displayName = mcpInfo + ? `${mcpInfo.toolName} (${mcpInfo.serverName})` + : tool.name } - const selected_0 = count(bucketTools_0, (t_8: Tool) => selectedSet.has(t_8.name)); - const isFullySelected = selected_0 === bucketTools_0.length; + navigableItems.push({ - id, - label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name_1}`, - action: createBucketToggleAction(bucketTools_0) - }); - }); - const toggleButtonIndex = navigableItems.length; - let t12; - if ($[40] !== focusIndex || $[41] !== showIndividualTools || $[42] !== toggleButtonIndex) { - t12 = () => { - setShowIndividualTools(!showIndividualTools); - if (showIndividualTools && focusIndex > toggleButtonIndex) { - setFocusIndex(toggleButtonIndex); - } - }; - $[40] = focusIndex; - $[41] = showIndividualTools; - $[42] = toggleButtonIndex; - $[43] = t12; + id: `tool-${tool.name}`, + label: `${selectedSet.has(tool.name) ? figures.checkboxOn : figures.checkboxOff} ${displayName}`, + action: () => handleToggleTool(tool.name), + }) + }) + } + + const handleCancel = useCallback(() => { + if (onCancel) { + onCancel() } else { - t12 = $[43]; + onComplete(initialTools) } - navigableItems.push({ - id: "toggle-individual", - label: showIndividualTools ? "Hide advanced options" : "Show advanced options", - action: t12, - isToggle: true - }); - const mcpServerBuckets = getMcpServerBuckets(customAgentTools); - if (showIndividualTools) { - if (mcpServerBuckets.length > 0) { - navigableItems.push({ - id: "mcp-servers-header", - label: "MCP Servers:", - action: _temp6, - isHeader: true - }); - mcpServerBuckets.forEach(t13 => { - const { - serverName, - tools: serverTools - } = t13; - const selected_1 = count(serverTools, t_9 => selectedSet.has(t_9.name)); - const isFullySelected_0 = selected_1 === serverTools.length; - navigableItems.push({ - id: `mcp-server-${serverName}`, - label: `${isFullySelected_0 ? figures.checkboxOn : figures.checkboxOff} ${serverName} (${serverTools.length} ${plural(serverTools.length, "tool")})`, - action: () => { - const toolNames_2 = serverTools.map(_temp7); - handleToggleTools(toolNames_2, !isFullySelected_0); - } - }); - }); - navigableItems.push({ - id: "tools-header", - label: "Individual Tools:", - action: _temp8, - isHeader: true - }); + }, [onCancel, onComplete, initialTools]) + + useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + const item = navigableItems[focusIndex] + if (item && !item.isHeader) { + item.action() } - customAgentTools.forEach(tool_0 => { - let displayName = tool_0.name; - if (tool_0.name.startsWith("mcp__")) { - const mcpInfo = mcpInfoFromString(tool_0.name); - displayName = mcpInfo ? `${mcpInfo.toolName} (${mcpInfo.serverName})` : tool_0.name; - } - navigableItems.push({ - id: `tool-${tool_0.name}`, - label: `${selectedSet.has(tool_0.name) ? figures.checkboxOn : figures.checkboxOff} ${displayName}`, - action: () => handleToggleTool(tool_0.name) - }); - }); - } - $[24] = createBucketToggleAction; - $[25] = customAgentTools; - $[26] = focusIndex; - $[27] = handleConfirm; - $[28] = isAllSelected; - $[29] = selectedSet; - $[30] = showIndividualTools; - $[31] = toolsByBucket.edit; - $[32] = toolsByBucket.execution; - $[33] = toolsByBucket.mcp; - $[34] = toolsByBucket.other; - $[35] = toolsByBucket.readOnly; - $[36] = navigableItems; - } else { - navigableItems = $[36]; - } - let t10; - if ($[44] !== initialTools || $[45] !== onCancel || $[46] !== onComplete) { - t10 = () => { - if (onCancel) { - onCancel(); - } else { - onComplete(initialTools); + } else if (e.key === 'up') { + e.preventDefault() + let newIndex = focusIndex - 1 + // Skip headers when navigating up + while (newIndex > 0 && navigableItems[newIndex]?.isHeader) { + newIndex-- } - }; - $[44] = initialTools; - $[45] = onCancel; - $[46] = onComplete; - $[47] = t10; - } else { - t10 = $[47]; - } - const handleCancel = t10; - let t11; - if ($[48] === Symbol.for("react.memo_cache_sentinel")) { - t11 = { - context: "Confirmation" - }; - $[48] = t11; - } else { - t11 = $[48]; - } - useKeybinding("confirm:no", handleCancel, t11); - let t12; - if ($[49] !== focusIndex || $[50] !== navigableItems) { - t12 = e => { - if (e.key === "return") { - e.preventDefault(); - const item = navigableItems[focusIndex]; - if (item && !item.isHeader) { - item.action(); - } - } else { - if (e.key === "up") { - e.preventDefault(); - let newIndex = focusIndex - 1; - while (newIndex > 0 && navigableItems[newIndex]?.isHeader) { - newIndex--; - } - setFocusIndex(Math.max(0, newIndex)); - } else { - if (e.key === "down") { - e.preventDefault(); - let newIndex_0 = focusIndex + 1; - while (newIndex_0 < navigableItems.length - 1 && navigableItems[newIndex_0]?.isHeader) { - newIndex_0++; - } - setFocusIndex(Math.min(navigableItems.length - 1, newIndex_0)); - } - } + setFocusIndex(Math.max(0, newIndex)) + } else if (e.key === 'down') { + e.preventDefault() + let newIndex = focusIndex + 1 + // Skip headers when navigating down + while ( + newIndex < navigableItems.length - 1 && + navigableItems[newIndex]?.isHeader + ) { + newIndex++ } - }; - $[49] = focusIndex; - $[50] = navigableItems; - $[51] = t12; - } else { - t12 = $[51]; - } - const handleKeyDown = t12; - const t13 = focusIndex === 0 ? "suggestion" : undefined; - const t14 = focusIndex === 0; - const t15 = focusIndex === 0 ? `${figures.pointer} ` : " "; - let t16; - if ($[52] !== t13 || $[53] !== t14 || $[54] !== t15) { - t16 = {t15}[ Continue ]; - $[52] = t13; - $[53] = t14; - $[54] = t15; - $[55] = t16; - } else { - t16 = $[55]; - } - let t17; - if ($[56] === Symbol.for("react.memo_cache_sentinel")) { - t17 = ; - $[56] = t17; - } else { - t17 = $[56]; - } - let t18; - if ($[57] !== navigableItems) { - t18 = navigableItems.slice(1); - $[57] = navigableItems; - $[58] = t18; - } else { - t18 = $[58]; - } - let t19; - if ($[59] !== focusIndex || $[60] !== t18) { - t19 = t18.map((item_0, index) => { - const isCurrentlyFocused = index + 1 === focusIndex; - const isToggleButton = item_0.isToggle; - const isHeader = item_0.isHeader; - return {isToggleButton && }{isHeader && index > 0 && }{isHeader ? "" : isCurrentlyFocused ? `${figures.pointer} ` : " "}{isToggleButton ? `[ ${item_0.label} ]` : item_0.label}; - }); - $[59] = focusIndex; - $[60] = t18; - $[61] = t19; - } else { - t19 = $[61]; - } - const t20 = isAllSelected ? "All tools selected" : `${selectedSet.size} of ${customAgentTools.length} tools selected`; - let t21; - if ($[62] !== t20) { - t21 = {t20}; - $[62] = t20; - $[63] = t21; - } else { - t21 = $[63]; - } - let t22; - if ($[64] !== handleKeyDown || $[65] !== t16 || $[66] !== t19 || $[67] !== t21) { - t22 = {t16}{t17}{t19}{t21}; - $[64] = handleKeyDown; - $[65] = t16; - $[66] = t19; - $[67] = t21; - $[68] = t22; - } else { - t22 = $[68]; + setFocusIndex(Math.min(navigableItems.length - 1, newIndex)) + } } - return t22; -} -function _temp8() {} -function _temp7(t_10) { - return t_10.name; -} -function _temp6() {} -function _temp5(t_7) { - return t_7.name; -} -function _temp4(t_6) { - return t_6.name; -} -function _temp3(t_4) { - return t_4.name; -} -function _temp2(t_0) { - return t_0.name; -} -function _temp(t) { - return t.name; + + return ( + + {/* Render Continue button */} + + {focusIndex === 0 ? `${figures.pointer} ` : ' '}[ Continue ] + + + {/* Separator */} + + + {/* Render all navigable items except Continue (which is at index 0) */} + {navigableItems.slice(1).map((item, index) => { + const isCurrentlyFocused = index + 1 === focusIndex + const isToggleButton = item.isToggle + const isHeader = item.isHeader + + return ( + + {/* Add separator before toggle button */} + {isToggleButton && } + + {/* Add margin before headers */} + {isHeader && index > 0 && } + + + {isHeader + ? '' + : isCurrentlyFocused + ? `${figures.pointer} ` + : ' '} + {isToggleButton ? `[ ${item.label} ]` : item.label} + + + ) + })} + + + + {isAllSelected + ? 'All tools selected' + : `${selectedSet.size} of ${customAgentTools.length} tools selected`} + + + + ) } diff --git a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx index bad4005a4..b9959d91d 100644 --- a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx +++ b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx @@ -1,96 +1,68 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { isAutoMemoryEnabled } from '../../../memdir/paths.js'; -import type { Tools } from '../../../Tool.js'; -import type { AgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js'; -import { WizardProvider } from '../../wizard/index.js'; -import type { WizardStepComponent } from '../../wizard/types.js'; -import type { AgentWizardData } from './types.js'; -import { ColorStep } from './wizard-steps/ColorStep.js'; -import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js'; -import { DescriptionStep } from './wizard-steps/DescriptionStep.js'; -import { GenerateStep } from './wizard-steps/GenerateStep.js'; -import { LocationStep } from './wizard-steps/LocationStep.js'; -import { MemoryStep } from './wizard-steps/MemoryStep.js'; -import { MethodStep } from './wizard-steps/MethodStep.js'; -import { ModelStep } from './wizard-steps/ModelStep.js'; -import { PromptStep } from './wizard-steps/PromptStep.js'; -import { ToolsStep } from './wizard-steps/ToolsStep.js'; -import { TypeStep } from './wizard-steps/TypeStep.js'; +import React, { type ReactNode } from 'react' +import { isAutoMemoryEnabled } from '../../../memdir/paths.js' +import type { Tools } from '../../../Tool.js' +import type { AgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js' +import { WizardProvider } from '../../wizard/index.js' +import type { WizardStepComponent } from '../../wizard/types.js' +import type { AgentWizardData } from './types.js' +import { ColorStep } from './wizard-steps/ColorStep.js' +import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js' +import { DescriptionStep } from './wizard-steps/DescriptionStep.js' +import { GenerateStep } from './wizard-steps/GenerateStep.js' +import { LocationStep } from './wizard-steps/LocationStep.js' +import { MemoryStep } from './wizard-steps/MemoryStep.js' +import { MethodStep } from './wizard-steps/MethodStep.js' +import { ModelStep } from './wizard-steps/ModelStep.js' +import { PromptStep } from './wizard-steps/PromptStep.js' +import { ToolsStep } from './wizard-steps/ToolsStep.js' +import { TypeStep } from './wizard-steps/TypeStep.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onComplete: (message: string) => void; - onCancel: () => void; -}; -export function CreateAgentWizard(t0) { - const $ = _c(17); - const { - tools, - existingAgents, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== existingAgents) { - t1 = () => ; - $[0] = existingAgents; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== tools) { - t2 = () => ; - $[2] = tools; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = isAutoMemoryEnabled() ? [MemoryStep] : []; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== existingAgents || $[6] !== onComplete || $[7] !== tools) { - t4 = () => ; - $[5] = existingAgents; - $[6] = onComplete; - $[7] = tools; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t1 || $[10] !== t2 || $[11] !== t4) { - t5 = [LocationStep, MethodStep, GenerateStep, t1, PromptStep, DescriptionStep, t2, ModelStep, ColorStep, ...t3, t4]; - $[9] = t1; - $[10] = t2; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - const steps = t5; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {}; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== onCancel || $[15] !== steps) { - t7 = ; - $[14] = onCancel; - $[15] = steps; - $[16] = t7; - } else { - t7 = $[16]; - } - return t7; + tools: Tools + existingAgents: AgentDefinition[] + onComplete: (message: string) => void + onCancel: () => void +} + +export function CreateAgentWizard({ + tools, + existingAgents, + onComplete, + onCancel, +}: Props): ReactNode { + // Create step components with props + const steps: WizardStepComponent[] = [ + LocationStep, // 0 + MethodStep, // 1 + GenerateStep, // 2 + () => , // 3 + PromptStep, // 4 + DescriptionStep, // 5 + () => , // 6 + ModelStep, // 7 + ColorStep, // 8 + // MemoryStep is conditionally included based on GrowthBook gate + ...(isAutoMemoryEnabled() ? [MemoryStep] : []), + () => ( + + ), + ] + + return ( + + steps={steps} + initialData={{}} + onComplete={() => { + // Wizard completion is handled by ConfirmStepWrapper + // which calls onComplete with the appropriate message + }} + onCancel={onCancel} + title="Create new agent" + showStepCounter={false} + /> + ) } -function _temp() {} diff --git a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx index 9ec059371..adc35e27c 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx @@ -1,83 +1,64 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import type { AgentColorName } from '../../../../tools/AgentTool/agentColorManager.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ColorPicker } from '../../ColorPicker.js'; -import type { AgentWizardData } from '../types.js'; -export function ColorStep() { - const $ = _c(14); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Confirmation" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import type { AgentColorName } from '../../../../tools/AgentTool/agentColorManager.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ColorPicker } from '../../ColorPicker.js' +import type { AgentWizardData } from '../types.js' + +export function ColorStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + // Handle escape key - ColorPicker handles its own escape internally + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + + const handleConfirm = (color?: string): void => { + updateWizardData({ + selectedColor: color, + // Prepare final agent for confirmation + finalAgent: { + agentType: wizardData.agentType!, + whenToUse: wizardData.whenToUse!, + getSystemPrompt: () => wizardData.systemPrompt!, + tools: wizardData.selectedTools, + ...(wizardData.selectedModel + ? { model: wizardData.selectedModel } + : {}), + ...(color ? { color: color as AgentColorName } : {}), + source: wizardData.location!, + }, + }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== goNext || $[2] !== updateWizardData || $[3] !== wizardData.agentType || $[4] !== wizardData.location || $[5] !== wizardData.selectedModel || $[6] !== wizardData.selectedTools || $[7] !== wizardData.systemPrompt || $[8] !== wizardData.whenToUse) { - t1 = color => { - updateWizardData({ - selectedColor: color, - finalAgent: { - agentType: wizardData.agentType, - whenToUse: wizardData.whenToUse, - getSystemPrompt: () => wizardData.systemPrompt, - tools: wizardData.selectedTools, - ...(wizardData.selectedModel ? { - model: wizardData.selectedModel - } : {}), - ...(color ? { - color: color as AgentColorName - } : {}), - source: wizardData.location - } - }); - goNext(); - }; - $[1] = goNext; - $[2] = updateWizardData; - $[3] = wizardData.agentType; - $[4] = wizardData.location; - $[5] = wizardData.selectedModel; - $[6] = wizardData.selectedTools; - $[7] = wizardData.systemPrompt; - $[8] = wizardData.whenToUse; - $[9] = t1; - } else { - t1 = $[9]; - } - const handleConfirm = t1; - let t2; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[10] = t2; - } else { - t2 = $[10]; - } - const t3 = wizardData.agentType || "agent"; - let t4; - if ($[11] !== handleConfirm || $[12] !== t3) { - t4 = ; - $[11] = handleConfirm; - $[12] = t3; - $[13] = t4; - } else { - t4 = $[13]; - } - return t4; + + return ( + + + + + + } + > + + + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx index b696d861b..bfa035eb5 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx @@ -1,377 +1,168 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import type { KeyboardEvent } from '../../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; -import type { Tools } from '../../../../Tool.js'; -import { getMemoryScopeDisplay } from '../../../../tools/AgentTool/agentMemory.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { truncateToWidth } from '../../../../utils/format.js'; -import { getAgentModelDisplay } from '../../../../utils/model/agent.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js'; -import { validateAgent } from '../../validateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import type { KeyboardEvent } from '../../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' +import type { Tools } from '../../../../Tool.js' +import { getMemoryScopeDisplay } from '../../../../tools/AgentTool/agentMemory.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { truncateToWidth } from '../../../../utils/format.js' +import { getAgentModelDisplay } from '../../../../utils/model/agent.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js' +import { validateAgent } from '../../validateAgent.js' +import type { AgentWizardData } from '../types.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onSave: () => void; - onSaveAndEdit: () => void; - error?: string | null; -}; -export function ConfirmStep(t0) { - const $ = _c(88); - const { - tools, - existingAgents, - onSave, - onSaveAndEdit, - error - } = t0; - const { - goBack, - wizardData - } = useWizard(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", goBack, t1); - let t2; - if ($[1] !== onSave || $[2] !== onSaveAndEdit) { - t2 = e => { - if (e.key === "s" || e.key === "return") { - e.preventDefault(); - onSave(); - } else { - if (e.key === "e") { - e.preventDefault(); - onSaveAndEdit(); - } - } - }; - $[1] = onSave; - $[2] = onSaveAndEdit; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleKeyDown = t2; - const agent = wizardData.finalAgent; - let T0; - let T1; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - let t18; - let t19; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[4] !== agent || $[5] !== existingAgents || $[6] !== handleKeyDown || $[7] !== tools || $[8] !== wizardData.location) { - const validation = validateAgent(agent, tools, existingAgents); - let t20; - if ($[28] !== agent) { - t20 = truncateToWidth(agent.getSystemPrompt(), 240); - $[28] = agent; - $[29] = t20; - } else { - t20 = $[29]; - } - const systemPromptPreview = t20; - let t21; - if ($[30] !== agent.whenToUse) { - t21 = truncateToWidth(agent.whenToUse, 240); - $[30] = agent.whenToUse; - $[31] = t21; - } else { - t21 = $[31]; - } - const whenToUsePreview = t21; - const getToolsDisplay = _temp; - let t22; - if ($[32] !== agent.memory) { - t22 = isAutoMemoryEnabled() ? Memory: {getMemoryScopeDisplay(agent.memory)} : null; - $[32] = agent.memory; - $[33] = t22; - } else { - t22 = $[33]; - } - const memoryDisplayElement = t22; - T1 = WizardDialogLayout; - t18 = "Confirm and save"; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[34] = t19; - } else { - t19 = $[34]; - } - T0 = Box; - t3 = "column"; - t4 = 0; - t5 = true; - t6 = handleKeyDown; - let t23; - if ($[35] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Name; - $[35] = t23; - } else { - t23 = $[35]; - } - if ($[36] !== agent.agentType) { - t7 = {t23}: {agent.agentType}; - $[36] = agent.agentType; - $[37] = t7; - } else { - t7 = $[37]; - } - let t24; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t24 = Location; - $[38] = t24; - } else { - t24 = $[38]; - } - let t25; - if ($[39] !== agent.agentType || $[40] !== wizardData.location) { - t25 = getNewRelativeAgentFilePath({ - source: wizardData.location, - agentType: agent.agentType - }); - $[39] = agent.agentType; - $[40] = wizardData.location; - $[41] = t25; - } else { - t25 = $[41]; - } - if ($[42] !== t25) { - t8 = {t24}:{" "}{t25}; - $[42] = t25; - $[43] = t8; - } else { - t8 = $[43]; - } - let t26; - if ($[44] === Symbol.for("react.memo_cache_sentinel")) { - t26 = Tools; - $[44] = t26; - } else { - t26 = $[44]; - } - let t27; - if ($[45] !== agent.tools) { - t27 = getToolsDisplay(agent.tools); - $[45] = agent.tools; - $[46] = t27; - } else { - t27 = $[46]; - } - if ($[47] !== t27) { - t9 = {t26}: {t27}; - $[47] = t27; - $[48] = t9; - } else { - t9 = $[48]; - } - let t28; - if ($[49] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Model; - $[49] = t28; - } else { - t28 = $[49]; - } - let t29; - if ($[50] !== agent.model) { - t29 = getAgentModelDisplay(agent.model); - $[50] = agent.model; - $[51] = t29; - } else { - t29 = $[51]; - } - if ($[52] !== t29) { - t10 = {t28}: {t29}; - $[52] = t29; - $[53] = t10; - } else { - t10 = $[53]; - } - t11 = memoryDisplayElement; - if ($[54] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Description (tells Claude when to use this agent):; - $[54] = t12; - } else { - t12 = $[54]; - } - if ($[55] !== whenToUsePreview) { - t13 = {whenToUsePreview}; - $[55] = whenToUsePreview; - $[56] = t13; - } else { - t13 = $[56]; - } - if ($[57] === Symbol.for("react.memo_cache_sentinel")) { - t14 = System prompt:; - $[57] = t14; - } else { - t14 = $[57]; - } - if ($[58] !== systemPromptPreview) { - t15 = {systemPromptPreview}; - $[58] = systemPromptPreview; - $[59] = t15; - } else { - t15 = $[59]; - } - t16 = validation.warnings.length > 0 && Warnings:{validation.warnings.map(_temp2)}; - t17 = validation.errors.length > 0 && Errors:{validation.errors.map(_temp3)}; - $[4] = agent; - $[5] = existingAgents; - $[6] = handleKeyDown; - $[7] = tools; - $[8] = wizardData.location; - $[9] = T0; - $[10] = T1; - $[11] = t10; - $[12] = t11; - $[13] = t12; - $[14] = t13; - $[15] = t14; - $[16] = t15; - $[17] = t16; - $[18] = t17; - $[19] = t18; - $[20] = t19; - $[21] = t3; - $[22] = t4; - $[23] = t5; - $[24] = t6; - $[25] = t7; - $[26] = t8; - $[27] = t9; - } else { - T0 = $[9]; - T1 = $[10]; - t10 = $[11]; - t11 = $[12]; - t12 = $[13]; - t13 = $[14]; - t14 = $[15]; - t15 = $[16]; - t16 = $[17]; - t17 = $[18]; - t18 = $[19]; - t19 = $[20]; - t3 = $[21]; - t4 = $[22]; - t5 = $[23]; - t6 = $[24]; - t7 = $[25]; - t8 = $[26]; - t9 = $[27]; - } - let t20; - if ($[60] !== error) { - t20 = error && {error}; - $[60] = error; - $[61] = t20; - } else { - t20 = $[61]; - } - let t21; - if ($[62] === Symbol.for("react.memo_cache_sentinel")) { - t21 = s; - $[62] = t21; - } else { - t21 = $[62]; - } - let t22; - if ($[63] === Symbol.for("react.memo_cache_sentinel")) { - t22 = Enter; - $[63] = t22; - } else { - t22 = $[63]; - } - let t23; - if ($[64] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Press {t21} or {t22} to save,{" "}e to save and edit; - $[64] = t23; - } else { - t23 = $[64]; - } - let t24; - if ($[65] !== T0 || $[66] !== t10 || $[67] !== t11 || $[68] !== t12 || $[69] !== t13 || $[70] !== t14 || $[71] !== t15 || $[72] !== t16 || $[73] !== t17 || $[74] !== t20 || $[75] !== t3 || $[76] !== t4 || $[77] !== t5 || $[78] !== t6 || $[79] !== t7 || $[80] !== t8 || $[81] !== t9) { - t24 = {t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t20}{t23}; - $[65] = T0; - $[66] = t10; - $[67] = t11; - $[68] = t12; - $[69] = t13; - $[70] = t14; - $[71] = t15; - $[72] = t16; - $[73] = t17; - $[74] = t20; - $[75] = t3; - $[76] = t4; - $[77] = t5; - $[78] = t6; - $[79] = t7; - $[80] = t8; - $[81] = t9; - $[82] = t24; - } else { - t24 = $[82]; - } - let t25; - if ($[83] !== T1 || $[84] !== t18 || $[85] !== t19 || $[86] !== t24) { - t25 = {t24}; - $[83] = T1; - $[84] = t18; - $[85] = t19; - $[86] = t24; - $[87] = t25; - } else { - t25 = $[87]; - } - return t25; -} -function _temp3(err, i_0) { - return {" "}• {err}; -} -function _temp2(warning, i) { - return {" "}• {warning}; + tools: Tools + existingAgents: AgentDefinition[] + onSave: () => void + onSaveAndEdit: () => void + error?: string | null } -function _temp(toolNames) { - if (toolNames === undefined) { - return "All tools"; - } - if (toolNames.length === 0) { - return "None"; - } - if (toolNames.length === 1) { - return toolNames[0] || "None"; + +export function ConfirmStep({ + tools, + existingAgents, + onSave, + onSaveAndEdit, + error, +}: Props): ReactNode { + const { goBack, wizardData } = useWizard() + + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 's' || e.key === 'return') { + e.preventDefault() + onSave() + } else if (e.key === 'e') { + e.preventDefault() + onSaveAndEdit() + } } - if (toolNames.length === 2) { - return toolNames.join(" and "); + + const agent = wizardData.finalAgent! + const validation = validateAgent(agent, tools, existingAgents) + + const systemPromptPreview = truncateToWidth(agent.getSystemPrompt(), 240) + const whenToUsePreview = truncateToWidth(agent.whenToUse, 240) + + const getToolsDisplay = (toolNames: string[] | undefined): string => { + // undefined means "all tools" per PR semantic + if (toolNames === undefined) return 'All tools' + if (toolNames.length === 0) return 'None' + if (toolNames.length === 1) return toolNames[0] || 'None' + if (toolNames.length === 2) return toolNames.join(' and ') + return `${toolNames.slice(0, -1).join(', ')}, and ${toolNames[toolNames.length - 1]}` } - return `${toolNames.slice(0, -1).join(", ")}, and ${toolNames[toolNames.length - 1]}`; + + // Compute memory display outside JSX + const memoryDisplayElement = isAutoMemoryEnabled() ? ( + + Memory: {getMemoryScopeDisplay(agent.memory)} + + ) : null + + return ( + + + + + + } + > + + + Name: {agent.agentType} + + + Location:{' '} + {getNewRelativeAgentFilePath({ + source: wizardData.location!, + agentType: agent.agentType, + })} + + + Tools: {getToolsDisplay(agent.tools)} + + + Model: {getAgentModelDisplay(agent.model)} + + {memoryDisplayElement} + + + + Description (tells Claude when to use this agent): + + + + {whenToUsePreview} + + + + + System prompt: + + + + {systemPromptPreview} + + + {validation.warnings.length > 0 && ( + + Warnings: + {validation.warnings.map((warning, i) => ( + + {' '} + • {warning} + + ))} + + )} + + {validation.errors.length > 0 && ( + + Errors: + {validation.errors.map((err, i) => ( + + {' '} + • {err} + + ))} + + )} + + {error && ( + + {error} + + )} + + + + Press s or Enter to save,{' '} + e to save and edit + + + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx index 0def7267b..013de633a 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx @@ -1,73 +1,112 @@ -import chalk from 'chalk'; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useSetAppState } from 'src/state/AppState.js'; -import type { Tools } from '../../../../Tool.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { getActiveAgentsFromList } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { editFileInEditor } from '../../../../utils/promptEditor.js'; -import { useWizard } from '../../../wizard/index.js'; -import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js'; -import type { AgentWizardData } from '../types.js'; -import { ConfirmStep } from './ConfirmStep.js'; +import chalk from 'chalk' +import React, { type ReactNode, useCallback, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { useSetAppState } from 'src/state/AppState.js' +import type { Tools } from '../../../../Tool.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { getActiveAgentsFromList } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { editFileInEditor } from '../../../../utils/promptEditor.js' +import { useWizard } from '../../../wizard/index.js' +import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js' +import type { AgentWizardData } from '../types.js' +import { ConfirmStep } from './ConfirmStep.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onComplete: (message: string) => void; -}; + tools: Tools + existingAgents: AgentDefinition[] + onComplete: (message: string) => void +} + export function ConfirmStepWrapper({ tools, existingAgents, - onComplete + onComplete, }: Props): ReactNode { - const { - wizardData - } = useWizard(); - const [saveError, setSaveError] = useState(null); - const setAppState = useSetAppState(); - const saveAgent = useCallback(async (openInEditor: boolean): Promise => { - if (!wizardData?.finalAgent) return; - try { - await saveAgentToFile(wizardData.location!, wizardData.finalAgent.agentType, wizardData.finalAgent.whenToUse, wizardData.finalAgent.tools, wizardData.finalAgent.getSystemPrompt(), true, wizardData.finalAgent.color, wizardData.finalAgent.model, wizardData.finalAgent.memory); - setAppState(state => { - if (!wizardData.finalAgent) return state; - const allAgents = state.agentDefinitions.allAgents.concat(wizardData.finalAgent); - return { - ...state, - agentDefinitions: { - ...state.agentDefinitions, - activeAgents: getActiveAgentsFromList(allAgents), - allAgents + const { wizardData } = useWizard() + const [saveError, setSaveError] = useState(null) + const setAppState = useSetAppState() + + const saveAgent = useCallback( + async (openInEditor: boolean): Promise => { + if (!wizardData?.finalAgent) return + + try { + await saveAgentToFile( + wizardData.location!, + wizardData.finalAgent.agentType, + wizardData.finalAgent.whenToUse, + wizardData.finalAgent.tools, + wizardData.finalAgent.getSystemPrompt(), + true, + wizardData.finalAgent.color, + wizardData.finalAgent.model, + wizardData.finalAgent.memory, + ) + + setAppState(state => { + if (!wizardData.finalAgent) return state + + const allAgents = state.agentDefinitions.allAgents.concat( + wizardData.finalAgent, + ) + return { + ...state, + agentDefinitions: { + ...state.agentDefinitions, + activeAgents: getActiveAgentsFromList(allAgents), + allAgents, + }, } - }; - }); - if (openInEditor) { - const filePath = getNewAgentFilePath({ + }) + + if (openInEditor) { + const filePath = getNewAgentFilePath({ + source: wizardData.location!, + agentType: wizardData.finalAgent.agentType, + }) + await editFileInEditor(filePath) + } + + logEvent('tengu_agent_created', { + agent_type: wizardData.finalAgent.agentType, + generation_method: wizardData.wasGenerated ? 'generated' : 'manual', source: wizardData.location!, - agentType: wizardData.finalAgent.agentType - }); - await editFileInEditor(filePath); + tool_count: wizardData.finalAgent.tools?.length ?? 'all', + has_custom_model: !!wizardData.finalAgent.model, + has_custom_color: !!wizardData.finalAgent.color, + has_memory: !!wizardData.finalAgent.memory, + memory_scope: wizardData.finalAgent.memory ?? 'none', + ...(openInEditor ? { opened_in_editor: true } : {}), + } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + + const message = openInEditor + ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + + `If you made edits, restart to load the latest version.` + : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}` + onComplete(message) + } catch (err) { + setSaveError( + err instanceof Error ? err.message : 'Failed to save agent', + ) } - logEvent('tengu_agent_created', { - agent_type: wizardData.finalAgent.agentType, - generation_method: wizardData.wasGenerated ? 'generated' : 'manual', - source: wizardData.location!, - tool_count: wizardData.finalAgent.tools?.length ?? 'all', - has_custom_model: !!wizardData.finalAgent.model, - has_custom_color: !!wizardData.finalAgent.color, - has_memory: !!wizardData.finalAgent.memory, - memory_scope: wizardData.finalAgent.memory ?? 'none', - ...(openInEditor ? { - opened_in_editor: true - } : {}) - } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS); - const message = openInEditor ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + `If you made edits, restart to load the latest version.` : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}`; - onComplete(message); - } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save agent'); - } - }, [wizardData, onComplete, setAppState]); - const handleSave = useCallback(() => saveAgent(false), [saveAgent]); - const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]); - return ; + }, + [wizardData, onComplete, setAppState], + ) + + const handleSave = useCallback(() => saveAgent(false), [saveAgent]) + + const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]) + + return ( + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx index 504ff0fd1..1138cc3d3 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx @@ -1,122 +1,94 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function DescriptionStep() { - const $ = _c(18); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || ""); - const [cursorOffset, setCursorOffset] = useState(whenToUse.length); - const [error, setError] = useState(null); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode, useCallback, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function DescriptionStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || '') + const [cursorOffset, setCursorOffset] = useState(whenToUse.length) + const [error, setError] = useState(null) + + // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleExternalEditor = useCallback(async () => { + const result = await editPromptInEditor(whenToUse) + if (result.content !== null) { + setWhenToUse(result.content) + setCursorOffset(result.content.length) + } + }, [whenToUse]) + + useKeybinding('chat:externalEditor', handleExternalEditor, { + context: 'Chat', + }) + + const handleSubmit = (value: string): void => { + const trimmedValue = value.trim() + if (!trimmedValue) { + setError('Description is required') + return + } + + setError(null) + updateWizardData({ whenToUse: trimmedValue }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== whenToUse) { - t1 = async () => { - const result = await editPromptInEditor(whenToUse); - if (result.content !== null) { - setWhenToUse(result.content); - setCursorOffset(result.content.length); + + return ( + + + + + + } - }; - $[1] = whenToUse; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleExternalEditor = t1; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Chat" - }; - $[3] = t2; - } else { - t2 = $[3]; - } - useKeybinding("chat:externalEditor", handleExternalEditor, t2); - let t3; - if ($[4] !== goNext || $[5] !== updateWizardData) { - t3 = value => { - const trimmedValue = value.trim(); - if (!trimmedValue) { - setError("Description is required"); - return; - } - setError(null); - updateWizardData({ - whenToUse: trimmedValue - }); - goNext(); - }; - $[4] = goNext; - $[5] = updateWizardData; - $[6] = t3; - } else { - t3 = $[6]; - } - const handleSubmit = t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = When should Claude use this agent?; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== cursorOffset || $[10] !== handleSubmit || $[11] !== whenToUse) { - t6 = ; - $[9] = cursorOffset; - $[10] = handleSubmit; - $[11] = whenToUse; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== error) { - t7 = error && {error}; - $[13] = error; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== t6 || $[16] !== t7) { - t8 = {t5}{t6}{t7}; - $[15] = t6; - $[16] = t7; - $[17] = t8; - } else { - t8 = $[17]; - } - return t8; + > + + When should Claude use this agent? + + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx index 892833bc3..1cb7ae69d 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx @@ -1,58 +1,57 @@ -import { APIUserAbortError } from '@anthropic-ai/sdk'; -import React, { type ReactNode, useCallback, useRef, useState } from 'react'; -import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { createAbortController } from '../../../../utils/abortController.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { Spinner } from '../../../Spinner.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { generateAgent } from '../../generateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import { APIUserAbortError } from '@anthropic-ai/sdk' +import React, { type ReactNode, useCallback, useRef, useState } from 'react' +import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { createAbortController } from '../../../../utils/abortController.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { Spinner } from '../../../Spinner.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { generateAgent } from '../../generateAgent.js' +import type { AgentWizardData } from '../types.js' + export function GenerateStep(): ReactNode { - const { - updateWizardData, - goBack, - goToStep, - wizardData - } = useWizard(); - const [prompt, setPrompt] = useState(wizardData.generationPrompt || ''); - const [isGenerating, setIsGenerating] = useState(false); - const [error, setError] = useState(null); - const [cursorOffset, setCursorOffset] = useState(prompt.length); - const model = useMainLoopModel(); - const abortControllerRef = useRef(null); + const { updateWizardData, goBack, goToStep, wizardData } = + useWizard() + const [prompt, setPrompt] = useState(wizardData.generationPrompt || '') + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState(null) + const [cursorOffset, setCursorOffset] = useState(prompt.length) + const model = useMainLoopModel() + const abortControllerRef = useRef(null) // Cancel generation when escape pressed during generation const handleCancelGeneration = useCallback(() => { if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - setIsGenerating(false); - setError('Generation cancelled'); + abortControllerRef.current.abort() + abortControllerRef.current = null + setIsGenerating(false) + setError('Generation cancelled') } - }, []); + }, []) // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleCancelGeneration, { context: 'Settings', - isActive: isGenerating - }); + isActive: isGenerating, + }) + const handleExternalEditor = useCallback(async () => { - const result = await editPromptInEditor(prompt); + const result = await editPromptInEditor(prompt) if (result.content !== null) { - setPrompt(result.content); - setCursorOffset(result.content.length); + setPrompt(result.content) + setCursorOffset(result.content.length) } - }, [prompt]); + }, [prompt]) + useKeybinding('chat:externalEditor', handleExternalEditor, { context: 'Chat', - isActive: !isGenerating - }); + isActive: !isGenerating, + }) // Go back when escape pressed while not generating const handleGoBack = useCallback(() => { @@ -62,81 +61,141 @@ export function GenerateStep(): ReactNode { systemPrompt: '', whenToUse: '', generatedAgent: undefined, - wasGenerated: false - }); - setPrompt(''); - setError(null); - goBack(); - }, [updateWizardData, goBack]); + wasGenerated: false, + }) + setPrompt('') + setError(null) + goBack() + }, [updateWizardData, goBack]) // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleGoBack, { context: 'Settings', - isActive: !isGenerating - }); + isActive: !isGenerating, + }) + const handleGenerate = async (): Promise => { - const trimmedPrompt = prompt.trim(); + const trimmedPrompt = prompt.trim() if (!trimmedPrompt) { - setError('Please describe what the agent should do'); - return; + setError('Please describe what the agent should do') + return } - setError(null); - setIsGenerating(true); + + setError(null) + setIsGenerating(true) updateWizardData({ generationPrompt: trimmedPrompt, - isGenerating: true - }); + isGenerating: true, + }) // Create abort controller for this generation - const controller = createAbortController(); - abortControllerRef.current = controller; + const controller = createAbortController() + abortControllerRef.current = controller + try { - const generated = await generateAgent(trimmedPrompt, model, [], controller.signal); + const generated = await generateAgent( + trimmedPrompt, + model, + [], + controller.signal, + ) + updateWizardData({ agentType: generated.identifier, whenToUse: generated.whenToUse, systemPrompt: generated.systemPrompt, generatedAgent: generated, isGenerating: false, - wasGenerated: true - }); + wasGenerated: true, + }) // Skip directly to ToolsStep (index 6) - matching original flow - goToStep(6); + goToStep(6) } catch (err) { // Don't show error if it was cancelled (already set in escape handler) if (err instanceof APIUserAbortError) { // User cancelled - no error to show - } else if (err instanceof Error && !err.message.includes('No assistant message found')) { - setError(err.message || 'Failed to generate agent'); + } else if ( + err instanceof Error && + !err.message.includes('No assistant message found') + ) { + setError(err.message || 'Failed to generate agent') } - updateWizardData({ - isGenerating: false - }); + updateWizardData({ isGenerating: false }) } finally { - setIsGenerating(false); - abortControllerRef.current = null; + setIsGenerating(false) + abortControllerRef.current = null } - }; - const subtitle = 'Describe what this agent should do and when it should be used (be comprehensive for best results)'; + } + + const subtitle = + 'Describe what this agent should do and when it should be used (be comprehensive for best results)' + if (isGenerating) { - return }> + return ( + + } + > Generating agent from description... - ; + + ) } - return - - - - }> + + return ( + + + + + + } + > - {error && + {error && ( + {error} - } - + + )} + - ; + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx index cf0a544d5..a7fd0a2bc 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx @@ -1,79 +1,55 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import type { SettingSource } from '../../../../utils/settings/constants.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function LocationStep() { - const $ = _c(11); - const { - goNext, - updateWizardData, - cancel - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - label: "Project (.claude/agents/)", - value: "projectSettings" as SettingSource - }; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = [t0, { - label: "Personal (~/.claude/agents/)", - value: "userSettings" as SettingSource - }]; - $[1] = t1; - } else { - t1 = $[1]; - } - const locationOptions = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== goNext || $[4] !== updateWizardData) { - t3 = value => { - updateWizardData({ - location: value as SettingSource - }); - goNext(); - }; - $[3] = goNext; - $[4] = updateWizardData; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== cancel) { - t4 = () => cancel(); - $[6] = cancel; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = { + updateWizardData({ location: value as SettingSource }) + goNext() + }} + onCancel={() => cancel()} + /> + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx index fc5cad0f3..3c987cf77 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx @@ -1,112 +1,102 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; -import { type AgentMemoryScope, loadAgentMemoryPrompt } from '../../../../tools/AgentTool/agentMemory.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' +import { + type AgentMemoryScope, + loadAgentMemoryPrompt, +} from '../../../../tools/AgentTool/agentMemory.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Select } from '../../../CustomSelect/select.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + type MemoryOption = { - label: string; - value: AgentMemoryScope | 'none'; -}; -export function MemoryStep() { - const $ = _c(13); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Confirmation" - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useKeybinding("confirm:no", goBack, t0); - const isUserScope = wizardData.location === "userSettings"; - let t1; - if ($[1] !== isUserScope) { - t1 = isUserScope ? [{ - label: "User scope (~/.claude/agent-memory/) (Recommended)", - value: "user" - }, { - label: "None (no persistent memory)", - value: "none" - }, { - label: "Project scope (.claude/agent-memory/)", - value: "project" - }, { - label: "Local scope (.claude/agent-memory-local/)", - value: "local" - }] : [{ - label: "Project scope (.claude/agent-memory/) (Recommended)", - value: "project" - }, { - label: "None (no persistent memory)", - value: "none" - }, { - label: "User scope (~/.claude/agent-memory/)", - value: "user" - }, { - label: "Local scope (.claude/agent-memory-local/)", - value: "local" - }]; - $[1] = isUserScope; - $[2] = t1; - } else { - t1 = $[2]; - } - const memoryOptions = t1; - let t2; - if ($[3] !== goNext || $[4] !== updateWizardData || $[5] !== wizardData.finalAgent || $[6] !== wizardData.systemPrompt) { - t2 = value => { - const memory = value === "none" ? undefined : value as AgentMemoryScope; - const agentType = wizardData.finalAgent?.agentType; - updateWizardData({ - selectedMemory: memory, - finalAgent: wizardData.finalAgent ? { - ...wizardData.finalAgent, - memory, - getSystemPrompt: isAutoMemoryEnabled() && memory && agentType ? () => wizardData.systemPrompt + "\n\n" + loadAgentMemoryPrompt(agentType, memory) : () => wizardData.systemPrompt - } : undefined - }); - goNext(); - }; - $[3] = goNext; - $[4] = updateWizardData; - $[5] = wizardData.finalAgent; - $[6] = wizardData.systemPrompt; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleSelect = t2; - let t3; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[8] = t3; - } else { - t3 = $[8]; - } - let t4; - if ($[9] !== goBack || $[10] !== handleSelect || $[11] !== memoryOptions) { - t4 = + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx index 5e9f40418..8f8252e12 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx @@ -1,79 +1,65 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function MethodStep() { - const $ = _c(11); - const { - goNext, - goBack, - updateWizardData, - goToStep - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = [{ - label: "Generate with Claude (recommended)", - value: "generate" - }, { - label: "Manual configuration", - value: "manual" - }]; - $[0] = t0; - } else { - t0 = $[0]; - } - const methodOptions = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== goNext || $[3] !== goToStep || $[4] !== updateWizardData) { - t2 = value => { - const method = value as 'generate' | 'manual'; - updateWizardData({ - method, - wasGenerated: method === "generate" - }); - if (method === "generate") { - goNext(); - } else { - goToStep(3); +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Select } from '../../../CustomSelect/select.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function MethodStep(): ReactNode { + const { goNext, goBack, updateWizardData, goToStep } = + useWizard() + + const methodOptions = [ + { + label: 'Generate with Claude (recommended)', + value: 'generate', + }, + { + label: 'Manual configuration', + value: 'manual', + }, + ] + + return ( + + + + + } - }; - $[2] = goNext; - $[3] = goToStep; - $[4] = updateWizardData; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== goBack) { - t3 = () => goBack(); - $[6] = goBack; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== t2 || $[9] !== t3) { - t4 = { + const method = value as 'generate' | 'manual' + updateWizardData({ + method, + wasGenerated: method === 'generate', + }) + + // Dynamic navigation based on method + if (method === 'generate') { + goNext() // Go to GenerateStep (index 2) + } else { + goToStep(3) // Skip to TypeStep (index 3) + } + }} + onCancel={() => goBack()} + /> + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx index b53ffd683..586cc6cc8 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx @@ -1,51 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ModelSelector } from '../../ModelSelector.js'; -import type { AgentWizardData } from '../types.js'; -export function ModelStep() { - const $ = _c(8); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] !== goNext || $[1] !== updateWizardData) { - t0 = model => { - updateWizardData({ - selectedModel: model - }); - goNext(); - }; - $[0] = goNext; - $[1] = updateWizardData; - $[2] = t0; - } else { - t0 = $[2]; +import React, { type ReactNode } from 'react' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ModelSelector } from '../../ModelSelector.js' +import type { AgentWizardData } from '../types.js' + +export function ModelStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + const handleComplete = (model?: string): void => { + updateWizardData({ selectedModel: model }) + goNext() } - const handleComplete = t0; - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== wizardData.selectedModel) { - t2 = ; - $[4] = goBack; - $[5] = handleComplete; - $[6] = wizardData.selectedModel; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; + + return ( + + + + + + } + > + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx index 1b8224c28..4d6747520 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx @@ -1,127 +1,97 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function PromptStep() { - const $ = _c(20); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [systemPrompt, setSystemPrompt] = useState(wizardData.systemPrompt || ""); - const [cursorOffset, setCursorOffset] = useState(systemPrompt.length); - const [error, setError] = useState(null); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode, useCallback, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function PromptStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [systemPrompt, setSystemPrompt] = useState( + wizardData.systemPrompt || '', + ) + const [cursorOffset, setCursorOffset] = useState(systemPrompt.length) + const [error, setError] = useState(null) + + // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleExternalEditor = useCallback(async () => { + const result = await editPromptInEditor(systemPrompt) + if (result.content !== null) { + setSystemPrompt(result.content) + setCursorOffset(result.content.length) + } + }, [systemPrompt]) + + useKeybinding('chat:externalEditor', handleExternalEditor, { + context: 'Chat', + }) + + const handleSubmit = (): void => { + const trimmedPrompt = systemPrompt.trim() + if (!trimmedPrompt) { + setError('System prompt is required') + return + } + + setError(null) + updateWizardData({ systemPrompt: trimmedPrompt }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== systemPrompt) { - t1 = async () => { - const result = await editPromptInEditor(systemPrompt); - if (result.content !== null) { - setSystemPrompt(result.content); - setCursorOffset(result.content.length); + + return ( + + + + + + } - }; - $[1] = systemPrompt; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleExternalEditor = t1; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Chat" - }; - $[3] = t2; - } else { - t2 = $[3]; - } - useKeybinding("chat:externalEditor", handleExternalEditor, t2); - let t3; - if ($[4] !== goNext || $[5] !== systemPrompt || $[6] !== updateWizardData) { - t3 = () => { - const trimmedPrompt = systemPrompt.trim(); - if (!trimmedPrompt) { - setError("System prompt is required"); - return; - } - setError(null); - updateWizardData({ - systemPrompt: trimmedPrompt - }); - goNext(); - }; - $[4] = goNext; - $[5] = systemPrompt; - $[6] = updateWizardData; - $[7] = t3; - } else { - t3 = $[7]; - } - const handleSubmit = t3; - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Enter the system prompt for your agent:; - t6 = Be comprehensive for best results; - $[9] = t5; - $[10] = t6; - } else { - t5 = $[9]; - t6 = $[10]; - } - let t7; - if ($[11] !== cursorOffset || $[12] !== handleSubmit || $[13] !== systemPrompt) { - t7 = ; - $[11] = cursorOffset; - $[12] = handleSubmit; - $[13] = systemPrompt; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== error) { - t8 = error && {error}; - $[15] = error; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== t7 || $[18] !== t8) { - t9 = {t5}{t6}{t7}{t8}; - $[17] = t7; - $[18] = t8; - $[19] = t9; - } else { - t9 = $[19]; - } - return t9; + > + + Enter the system prompt for your agent: + Be comprehensive for best results + + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx index 0c982da6a..501509ff5 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx @@ -1,60 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import type { Tools } from '../../../../Tool.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ToolSelector } from '../../ToolSelector.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import type { Tools } from '../../../../Tool.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ToolSelector } from '../../ToolSelector.js' +import type { AgentWizardData } from '../types.js' + type Props = { - tools: Tools; -}; -export function ToolsStep(t0) { - const $ = _c(9); - const { - tools - } = t0; - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t1; - if ($[0] !== goNext || $[1] !== updateWizardData) { - t1 = selectedTools => { - updateWizardData({ - selectedTools - }); - goNext(); - }; - $[0] = goNext; - $[1] = updateWizardData; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleComplete = t1; - const initialTools = wizardData.selectedTools; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== initialTools || $[7] !== tools) { - t3 = ; - $[4] = goBack; - $[5] = handleComplete; - $[6] = initialTools; - $[7] = tools; - $[8] = t3; - } else { - t3 = $[8]; + tools: Tools +} + +export function ToolsStep({ tools }: Props): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + const handleComplete = (selectedTools: string[] | undefined): void => { + updateWizardData({ selectedTools }) + goNext() } - return t3; + + // Pass through undefined to preserve "all tools" semantic + // ToolSelector will expand it internally for display purposes + const initialTools = wizardData.selectedTools + + return ( + + + + + + } + > + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx index 70c085cc5..6ff025492 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx @@ -1,102 +1,83 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { validateAgentType } from '../../validateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { validateAgentType } from '../../validateAgent.js' +import type { AgentWizardData } from '../types.js' + type Props = { - existingAgents: AgentDefinition[]; -}; -export function TypeStep(_props) { - const $ = _c(15); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [agentType, setAgentType] = useState(wizardData.agentType || ""); - const [error, setError] = useState(null); - const [cursorOffset, setCursorOffset] = useState(agentType.length); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; + existingAgents: AgentDefinition[] +} + +export function TypeStep(_props: Props): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [agentType, setAgentType] = useState(wizardData.agentType || '') + const [error, setError] = useState(null) + const [cursorOffset, setCursorOffset] = useState(agentType.length) + + // Handle escape key - Go back to MethodStep + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleSubmit = (value: string): void => { + const trimmedValue = value.trim() + const validationError = validateAgentType(trimmedValue) + + if (validationError) { + setError(validationError) + return + } + + setError(null) + updateWizardData({ agentType: trimmedValue }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== goNext || $[2] !== updateWizardData) { - t1 = value => { - const trimmedValue = value.trim(); - const validationError = validateAgentType(trimmedValue); - if (validationError) { - setError(validationError); - return; + + return ( + + + + + } - setError(null); - updateWizardData({ - agentType: trimmedValue - }); - goNext(); - }; - $[1] = goNext; - $[2] = updateWizardData; - $[3] = t1; - } else { - t1 = $[3]; - } - const handleSubmit = t1; - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Enter a unique identifier for your agent:; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== agentType || $[7] !== cursorOffset || $[8] !== handleSubmit) { - t4 = ; - $[6] = agentType; - $[7] = cursorOffset; - $[8] = handleSubmit; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== error) { - t5 = error && {error}; - $[10] = error; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t4 || $[13] !== t5) { - t6 = {t3}{t4}{t5}; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + > + + Enter a unique identifier for your agent: + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/design-system/Byline.tsx b/src/components/design-system/Byline.tsx index be41b584c..b0ddc97f3 100644 --- a/src/components/design-system/Byline.tsx +++ b/src/components/design-system/Byline.tsx @@ -1,10 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Children, isValidElement } from 'react'; -import { Text } from '../../ink.js'; +import React, { Children, isValidElement } from 'react' +import { Text } from '../../ink.js' + type Props = { /** The items to join with a middot separator */ - children: React.ReactNode; -}; + children: React.ReactNode +} /** * Joins children with a middot separator (" · ") for inline metadata display. @@ -34,43 +34,24 @@ type Props = { * * */ -export function Byline(t0) { - const $ = _c(5); - const { - children - } = t0; - let t1; - let t2; - if ($[0] !== children) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const validChildren = Children.toArray(children); - if (validChildren.length === 0) { - t2 = null; - break bb0; - } - t1 = validChildren.map(_temp); - } - $[0] = children; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - let t3; - if ($[3] !== t1) { - t3 = <>{t1}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; +export function Byline({ children }: Props): React.ReactNode { + // Children.toArray already filters out null, undefined, and booleans + const validChildren = Children.toArray(children) + + if (validChildren.length === 0) { + return null } - return t3; -} -function _temp(child, index) { - return {index > 0 && · }{child}; + + return ( + <> + {validChildren.map((child, index) => ( + + {index > 0 && · } + {child} + + ))} + + ) } diff --git a/src/components/design-system/Dialog.tsx b/src/components/design-system/Dialog.tsx index 5461c6c74..4472bd0d0 100644 --- a/src/components/design-system/Dialog.tsx +++ b/src/components/design-system/Dialog.tsx @@ -1,23 +1,26 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Theme } from '../../utils/theme.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from './Byline.js'; -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'; -import { Pane } from './Pane.js'; +import React from 'react' +import { + type ExitState, + useExitOnCtrlCDWithKeybindings, +} from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Theme } from '../../utils/theme.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from './Byline.js' +import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' +import { Pane } from './Pane.js' + type DialogProps = { - title: React.ReactNode; - subtitle?: React.ReactNode; - children: React.ReactNode; - onCancel: () => void; - color?: keyof Theme; - hideInputGuide?: boolean; - hideBorder?: boolean; + title: React.ReactNode + subtitle?: React.ReactNode + children: React.ReactNode + onCancel: () => void + color?: keyof Theme + hideInputGuide?: boolean + hideBorder?: boolean /** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */ - inputGuide?: (exitState: ExitState) => React.ReactNode; + inputGuide?: (exitState: ExitState) => React.ReactNode /** * Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt * (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text @@ -25,113 +28,73 @@ type DialogProps = { * consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on * press, delete-forward on ctrl+d with text). Defaults to `true`. */ - isCancelActive?: boolean; -}; -export function Dialog(t0) { - const $ = _c(27); - const { - title, - subtitle, - children, - onCancel, - color: t1, - hideInputGuide, - hideBorder, - inputGuide, - isCancelActive: t2 - } = t0; - const color = t1 === undefined ? "permission" : t1; - const isCancelActive = t2 === undefined ? true : t2; - const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive); - let t3; - if ($[0] !== isCancelActive) { - t3 = { - context: "Confirmation", - isActive: isCancelActive - }; - $[0] = isCancelActive; - $[1] = t3; - } else { - t3 = $[1]; - } - useKeybinding("confirm:no", onCancel, t3); - let t4; - if ($[2] !== exitState.keyName || $[3] !== exitState.pending) { - t4 = exitState.pending ? Press {exitState.keyName} again to exit : ; - $[2] = exitState.keyName; - $[3] = exitState.pending; - $[4] = t4; - } else { - t4 = $[4]; - } - const defaultInputGuide = t4; - let t5; - if ($[5] !== color || $[6] !== title) { - t5 = {title}; - $[5] = color; - $[6] = title; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== subtitle) { - t6 = subtitle && {subtitle}; - $[8] = subtitle; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== t5 || $[11] !== t6) { - t7 = {t5}{t6}; - $[10] = t5; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== children || $[14] !== t7) { - t8 = {t7}{children}; - $[13] = children; - $[14] = t7; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] !== defaultInputGuide || $[17] !== exitState || $[18] !== hideInputGuide || $[19] !== inputGuide) { - t9 = !hideInputGuide && {inputGuide ? inputGuide(exitState) : defaultInputGuide}; - $[16] = defaultInputGuide; - $[17] = exitState; - $[18] = hideInputGuide; - $[19] = inputGuide; - $[20] = t9; - } else { - t9 = $[20]; - } - let t10; - if ($[21] !== t8 || $[22] !== t9) { - t10 = <>{t8}{t9}; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const content = t10; + isCancelActive?: boolean +} + +export function Dialog({ + title, + subtitle, + children, + onCancel, + color = 'permission', + hideInputGuide, + hideBorder, + inputGuide, + isCancelActive = true, +}: DialogProps): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings( + undefined, + undefined, + isCancelActive, + ) + + // Use configurable keybinding for ESC to cancel. + // isCancelActive lets consumers (e.g. ElicitationDialog) disable this while + // an embedded TextInput is focused, so that keys like 'n' reach the field + // instead of being consumed here. + useKeybinding('confirm:no', onCancel, { + context: 'Confirmation', + isActive: isCancelActive, + }) + + const defaultInputGuide = exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + + + + ) + + const content = ( + <> + + + + {title} + + {subtitle && {subtitle}} + + {children} + + {!hideInputGuide && ( + + + {inputGuide ? inputGuide(exitState) : defaultInputGuide} + + + )} + + ) + if (hideBorder) { - return content; - } - let t11; - if ($[24] !== color || $[25] !== content) { - t11 = {content}; - $[24] = color; - $[25] = content; - $[26] = t11; - } else { - t11 = $[26]; + return content } - return t11; + + return {content} } diff --git a/src/components/design-system/Divider.tsx b/src/components/design-system/Divider.tsx index 362f4c283..a88982be5 100644 --- a/src/components/design-system/Divider.tsx +++ b/src/components/design-system/Divider.tsx @@ -1,33 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Ansi, Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; +import React from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Ansi, Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' + type DividerProps = { /** * Width of the divider in characters. * Defaults to terminal width. */ - width?: number; + width?: number /** * Theme color for the divider. * If not provided, dimColor is used. */ - color?: keyof Theme; + color?: keyof Theme /** * Character to use for the divider line. * @default '─' */ - char?: string; + char?: string /** * Padding to subtract from the width (e.g., for indentation). * @default 0 */ - padding?: number; + padding?: number /** * Title shown in the middle of the divider. @@ -37,8 +37,8 @@ type DividerProps = { * // ─────────── Title ─────────── * */ - title?: string; -}; + title?: string +} /** * A horizontal divider line. @@ -63,86 +63,35 @@ type DividerProps = { * // With centered title * */ -export function Divider(t0) { - const $ = _c(21); - const { - width, - color, - char: t1, - padding: t2, - title - } = t0; - const char = t1 === undefined ? "\u2500" : t1; - const padding = t2 === undefined ? 0 : t2; - const { - columns: terminalWidth - } = useTerminalSize(); - const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding); +export function Divider({ + width, + color, + char = '─', + padding = 0, + title, +}: DividerProps): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding) + if (title) { - const titleWidth = stringWidth(title) + 2; - const sideWidth = Math.max(0, effectiveWidth - titleWidth); - const leftWidth = Math.floor(sideWidth / 2); - const rightWidth = sideWidth - leftWidth; - const t3 = !color; - let t4; - if ($[0] !== char || $[1] !== leftWidth) { - t4 = char.repeat(leftWidth); - $[0] = char; - $[1] = leftWidth; - $[2] = t4; - } else { - t4 = $[2]; - } - let t5; - if ($[3] !== title) { - t5 = {title}; - $[3] = title; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] !== char || $[6] !== rightWidth) { - t6 = char.repeat(rightWidth); - $[5] = char; - $[6] = rightWidth; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== color || $[9] !== t3 || $[10] !== t4 || $[11] !== t5 || $[12] !== t6) { - t7 = {t4}{" "}{t5}{" "}{t6}; - $[8] = color; - $[9] = t3; - $[10] = t4; - $[11] = t5; - $[12] = t6; - $[13] = t7; - } else { - t7 = $[13]; - } - return t7; + const titleWidth = stringWidth(title) + 2 // +2 for spaces around title + const sideWidth = Math.max(0, effectiveWidth - titleWidth) + const leftWidth = Math.floor(sideWidth / 2) + const rightWidth = sideWidth - leftWidth + return ( + + {char.repeat(leftWidth)}{' '} + + {title} + {' '} + {char.repeat(rightWidth)} + + ) } - const t3 = !color; - let t4; - if ($[14] !== char || $[15] !== effectiveWidth) { - t4 = char.repeat(effectiveWidth); - $[14] = char; - $[15] = effectiveWidth; - $[16] = t4; - } else { - t4 = $[16]; - } - let t5; - if ($[17] !== color || $[18] !== t3 || $[19] !== t4) { - t5 = {t4}; - $[17] = color; - $[18] = t3; - $[19] = t4; - $[20] = t5; - } else { - t5 = $[20]; - } - return t5; + + return ( + + {char.repeat(effectiveWidth)} + + ) } diff --git a/src/components/design-system/FuzzyPicker.tsx b/src/components/design-system/FuzzyPicker.tsx index e84f1aafd..fc1b9fe9e 100644 --- a/src/components/design-system/FuzzyPicker.tsx +++ b/src/components/design-system/FuzzyPicker.tsx @@ -1,70 +1,73 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { useSearchInput } from '../../hooks/useSearchInput.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { clamp } from '../../ink/layout/geometry.js'; -import { Box, Text, useTerminalFocus } from '../../ink.js'; -import { SearchBox } from '../SearchBox.js'; -import { Byline } from './Byline.js'; -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'; -import { ListItem } from './ListItem.js'; -import { Pane } from './Pane.js'; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { useSearchInput } from '../../hooks/useSearchInput.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { clamp } from '../../ink/layout/geometry.js' +import { Box, Text, useTerminalFocus } from '../../ink.js' +import { SearchBox } from '../SearchBox.js' +import { Byline } from './Byline.js' +import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' +import { ListItem } from './ListItem.js' +import { Pane } from './Pane.js' + type PickerAction = { /** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */ - action: string; - handler: (item: T) => void; -}; + action: string + handler: (item: T) => void +} + type Props = { - title: string; - placeholder?: string; - initialQuery?: string; - items: readonly T[]; - getKey: (item: T) => string; + title: string + placeholder?: string + initialQuery?: string + items: readonly T[] + getKey: (item: T) => string /** Keep to one line — preview handles overflow. */ - renderItem: (item: T, isFocused: boolean) => React.ReactNode; - renderPreview?: (item: T) => React.ReactNode; + renderItem: (item: T, isFocused: boolean) => React.ReactNode + renderPreview?: (item: T) => React.ReactNode /** 'right' keeps hints stable (no bounce), but needs width. */ - previewPosition?: 'bottom' | 'right'; - visibleCount?: number; + previewPosition?: 'bottom' | 'right' + visibleCount?: number /** * 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows * always match screen direction — ↑ walks visually up regardless. */ - direction?: 'down' | 'up'; + direction?: 'down' | 'up' /** Caller owns filtering: re-filter on each call and pass new items. */ - onQueryChange: (query: string) => void; + onQueryChange: (query: string) => void /** Enter key. Primary action. */ - onSelect: (item: T) => void; + onSelect: (item: T) => void /** * Tab key. If provided, Tab no longer aliases Enter — it gets its own * handler and hint. Shift+Tab falls through to this if onShiftTab is unset. */ - onTab?: PickerAction; + onTab?: PickerAction /** Shift+Tab key. Gets its own hint. */ - onShiftTab?: PickerAction; + onShiftTab?: PickerAction /** * Fires when the focused item changes (via arrows or when items reset). * Useful for async preview loading — keeps I/O out of renderPreview. */ - onFocus?: (item: T | undefined) => void; - onCancel: () => void; + onFocus?: (item: T | undefined) => void + onCancel: () => void /** Shown when items is empty. Caller bakes loading/searching state into this. */ - emptyMessage?: string | ((query: string) => string); + emptyMessage?: string | ((query: string) => string) /** * Status line below the list, e.g. "500+ matches" or "42 matches…". * Caller decides when to show it — pass undefined to hide. */ - matchLabel?: string; - selectAction?: string; - extraHints?: React.ReactNode; -}; -const DEFAULT_VISIBLE = 8; + matchLabel?: string + selectAction?: string + extraHints?: React.ReactNode +} + +const DEFAULT_VISIBLE = 8 // Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3 // rows) + hints. matchLabel adds +1 when present, accounted for separately. -const CHROME_ROWS = 10; -const MIN_VISIBLE = 2; +const CHROME_ROWS = 10 +const MIN_VISIBLE = 2 + export function FuzzyPicker({ title, placeholder = 'Type to search…', @@ -85,117 +88,168 @@ export function FuzzyPicker({ emptyMessage = 'No results', matchLabel, selectAction = 'select', - extraHints + extraHints, }: Props): React.ReactNode { - const isTerminalFocused = useTerminalFocus(); - const { - rows, - columns - } = useTerminalSize(); - const [focusedIndex, setFocusedIndex] = useState(0); + const isTerminalFocused = useTerminalFocus() + const { rows, columns } = useTerminalSize() + const [focusedIndex, setFocusedIndex] = useState(0) // Cap visibleCount so the picker never exceeds the terminal height. When it // overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up // by the overflow amount and a previously-drawn line flashes blank. - const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0))); + const visibleCount = Math.max( + MIN_VISIBLE, + Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)), + ) // Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently // below that. Compact mode drops shift+tab and shortens labels. - const compact = columns < 120; + const compact = columns < 120 + const step = (delta: 1 | -1) => { - setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)); - }; + setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)) + } // onKeyDown fires after useSearchInput's useInput, so onExit must be a // no-op — return/downArrow are handled by handleKeyDown below. onCancel // still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so // a held backspace doesn't eject the user from the dialog. - const { - query, - cursorOffset - } = useSearchInput({ + const { query, cursorOffset } = useSearchInput({ isActive: true, onExit: () => {}, onCancel, initialQuery, - backspaceExitsOnEmpty: false - }); + backspaceExitsOnEmpty: false, + }) + const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'up' || e.ctrl && e.key === 'p') { - e.preventDefault(); - e.stopImmediatePropagation(); - step(direction === 'up' ? 1 : -1); - return; + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + e.stopImmediatePropagation() + step(direction === 'up' ? 1 : -1) + return } - if (e.key === 'down' || e.ctrl && e.key === 'n') { - e.preventDefault(); - e.stopImmediatePropagation(); - step(direction === 'up' ? -1 : 1); - return; + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + e.stopImmediatePropagation() + step(direction === 'up' ? -1 : 1) + return } if (e.key === 'return') { - e.preventDefault(); - e.stopImmediatePropagation(); - const selected = items[focusedIndex]; - if (selected) onSelect(selected); - return; + e.preventDefault() + e.stopImmediatePropagation() + const selected = items[focusedIndex] + if (selected) onSelect(selected) + return } if (e.key === 'tab') { - e.preventDefault(); - e.stopImmediatePropagation(); - const selected = items[focusedIndex]; - if (!selected) return; - const tabAction = e.shift ? onShiftTab ?? onTab : onTab; + e.preventDefault() + e.stopImmediatePropagation() + const selected = items[focusedIndex] + if (!selected) return + const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab if (tabAction) { - tabAction.handler(selected); + tabAction.handler(selected) } else { - onSelect(selected); + onSelect(selected) } } - }; + } + useEffect(() => { - onQueryChange(query); - setFocusedIndex(0); + onQueryChange(query) + setFocusedIndex(0) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]); + }, [query]) + useEffect(() => { - setFocusedIndex(i => clamp(i, 0, items.length - 1)); - }, [items.length]); - const focused = items[focusedIndex]; + setFocusedIndex(i => clamp(i, 0, items.length - 1)) + }, [items.length]) + + const focused = items[focusedIndex] useEffect(() => { - onFocus?.(focused); + onFocus?.(focused) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focused]); - const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount); - const visible = items.slice(windowStart, windowStart + visibleCount); - const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage; - const searchBox = ; - const listBlock = ; - const preview = renderPreview && focused ? + }, [focused]) + + const windowStart = clamp( + focusedIndex - visibleCount + 1, + 0, + items.length - visibleCount, + ) + const visible = items.slice(windowStart, windowStart + visibleCount) + + const emptyText = + typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage + + const searchBox = ( + + ) + + const listBlock = ( + + ) + + const preview = + renderPreview && focused ? ( + {renderPreview(focused)} - : null; + + ) : null // Structure must not depend on preview truthiness — when focused goes // undefined (e.g. delete clears matches), switching row→fragment would // change both layout AND gap count, bouncing the searchBox below. - const listGroup = renderPreview && previewPosition === 'right' ? + const listGroup = + renderPreview && previewPosition === 'right' ? ( + {listBlock} {matchLabel && {matchLabel}} {preview ?? } - : - // Box (not fragment) so the outer gap={1} doesn't insert a blank line - // between list/matchLabel/preview — that read as extra space above the - // prompt in direction='up'. - + + ) : ( + // Box (not fragment) so the outer gap={1} doesn't insert a blank line + // between list/matchLabel/preview — that read as extra space above the + // prompt in direction='up'. + {listBlock} {matchLabel && {matchLabel}} {preview} - ; - const inputAbove = direction !== 'up'; - return - + + ) + + const inputAbove = direction !== 'up' + return ( + + {title} @@ -204,108 +258,93 @@ export function FuzzyPicker({ {!inputAbove && searchBox} - - - {onTab && } - {onShiftTab && !compact && } + + + {onTab && ( + + )} + {onShiftTab && !compact && ( + + )} {extraHints} - ; + + ) } -type ListProps = Pick, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & { - visible: readonly T[]; - windowStart: number; - total: number; - focusedIndex: number; - emptyText: string; -}; -function List(t0) { - const $ = _c(27); - const { - visible, - windowStart, - visibleCount, - total, - focusedIndex, - direction, - getKey, - renderItem, - emptyText - } = t0; + +type ListProps = Pick< + Props, + 'visibleCount' | 'direction' | 'getKey' | 'renderItem' +> & { + visible: readonly T[] + windowStart: number + total: number + focusedIndex: number + emptyText: string +} + +function List({ + visible, + windowStart, + visibleCount, + total, + focusedIndex, + direction, + getKey, + renderItem, + emptyText, +}: ListProps): React.ReactNode { if (visible.length === 0) { - let t1; - if ($[0] !== emptyText) { - t1 = {emptyText}; - $[0] = emptyText; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1 || $[3] !== visibleCount) { - t2 = {t1}; - $[2] = t1; - $[3] = visibleCount; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; - } - let t1; - if ($[5] !== direction || $[6] !== focusedIndex || $[7] !== getKey || $[8] !== renderItem || $[9] !== total || $[10] !== visible || $[11] !== visibleCount || $[12] !== windowStart) { - let t2; - if ($[14] !== direction || $[15] !== focusedIndex || $[16] !== getKey || $[17] !== renderItem || $[18] !== total || $[19] !== visible.length || $[20] !== visibleCount || $[21] !== windowStart) { - t2 = (item, i) => { - const actualIndex = windowStart + i; - const isFocused = actualIndex === focusedIndex; - const atLowEdge = i === 0 && windowStart > 0; - const atHighEdge = i === visible.length - 1 && windowStart + visibleCount < total; - return {renderItem(item, isFocused)}; - }; - $[14] = direction; - $[15] = focusedIndex; - $[16] = getKey; - $[17] = renderItem; - $[18] = total; - $[19] = visible.length; - $[20] = visibleCount; - $[21] = windowStart; - $[22] = t2; - } else { - t2 = $[22]; - } - t1 = visible.map(t2); - $[5] = direction; - $[6] = focusedIndex; - $[7] = getKey; - $[8] = renderItem; - $[9] = total; - $[10] = visible; - $[11] = visibleCount; - $[12] = windowStart; - $[13] = t1; - } else { - t1 = $[13]; - } - const rows = t1; - const t2 = direction === "up" ? "column-reverse" : "column"; - let t3; - if ($[23] !== rows || $[24] !== t2 || $[25] !== visibleCount) { - t3 = {rows}; - $[23] = rows; - $[24] = t2; - $[25] = visibleCount; - $[26] = t3; - } else { - t3 = $[26]; + return ( + + {emptyText} + + ) } - return t3; + + const rows = visible.map((item, i) => { + const actualIndex = windowStart + i + const isFocused = actualIndex === focusedIndex + const atLowEdge = i === 0 && windowStart > 0 + const atHighEdge = + i === visible.length - 1 && windowStart + visibleCount! < total + return ( + + {renderItem(item, isFocused)} + + ) + }) + + return ( + + {rows} + + ) } + function firstWord(s: string): string { - const i = s.indexOf(' '); - return i === -1 ? s : s.slice(0, i); + const i = s.indexOf(' ') + return i === -1 ? s : s.slice(0, i) } diff --git a/src/components/design-system/KeyboardShortcutHint.tsx b/src/components/design-system/KeyboardShortcutHint.tsx index 19b51d05b..7d3c136d1 100644 --- a/src/components/design-system/KeyboardShortcutHint.tsx +++ b/src/components/design-system/KeyboardShortcutHint.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import Text from '../../ink/components/Text.js'; +import React from 'react' +import Text from '../../ink/components/Text.js' + type Props = { /** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */ - shortcut: string; + shortcut: string /** The action the key performs (e.g., "expand", "select", "navigate") */ - action: string; + action: string /** Whether to wrap the hint in parentheses. Default: false */ - parens?: boolean; + parens?: boolean /** Whether to render the shortcut in bold. Default: false */ - bold?: boolean; -}; + bold?: boolean +} /** * Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)" @@ -35,46 +35,24 @@ type Props = { * * */ -export function KeyboardShortcutHint(t0) { - const $ = _c(9); - const { - shortcut, - action, - parens: t1, - bold: t2 - } = t0; - const parens = t1 === undefined ? false : t1; - const bold = t2 === undefined ? false : t2; - let t3; - if ($[0] !== bold || $[1] !== shortcut) { - t3 = bold ? {shortcut} : shortcut; - $[0] = bold; - $[1] = shortcut; - $[2] = t3; - } else { - t3 = $[2]; - } - const shortcutText = t3; +export function KeyboardShortcutHint({ + shortcut, + action, + parens = false, + bold = false, +}: Props): React.ReactNode { + const shortcutText = bold ? {shortcut} : shortcut + if (parens) { - let t4; - if ($[3] !== action || $[4] !== shortcutText) { - t4 = ({shortcutText} to {action}); - $[3] = action; - $[4] = shortcutText; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; - } - let t4; - if ($[6] !== action || $[7] !== shortcutText) { - t4 = {shortcutText} to {action}; - $[6] = action; - $[7] = shortcutText; - $[8] = t4; - } else { - t4 = $[8]; + return ( + + ({shortcutText} to {action}) + + ) } - return t4; + return ( + + {shortcutText} to {action} + + ) } diff --git a/src/components/design-system/ListItem.tsx b/src/components/design-system/ListItem.tsx index 0ee8068cc..2d142be03 100644 --- a/src/components/design-system/ListItem.tsx +++ b/src/components/design-system/ListItem.tsx @@ -1,44 +1,44 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import type { ReactNode } from 'react'; -import React from 'react'; -import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js'; -import { Box, Text } from '../../ink.js'; +import figures from 'figures' +import type { ReactNode } from 'react' +import React from 'react' +import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js' +import { Box, Text } from '../../ink.js' + type ListItemProps = { /** * Whether this item is currently focused (keyboard selection). * Shows the pointer indicator (❯) when true. */ - isFocused: boolean; + isFocused: boolean /** * Whether this item is selected (chosen/checked). * Shows the checkmark indicator (✓) when true. * @default false */ - isSelected?: boolean; + isSelected?: boolean /** * The content to display for this item. */ - children: ReactNode; + children: ReactNode /** * Optional description text displayed below the main content. */ - description?: string; + description?: string /** * Show a down arrow indicator instead of pointer (for scroll hints). * Only applies when not focused. */ - showScrollDown?: boolean; + showScrollDown?: boolean /** * Show an up arrow indicator instead of pointer (for scroll hints). * Only applies when not focused. */ - showScrollUp?: boolean; + showScrollUp?: boolean /** * Whether to apply automatic styling to the children based on focus/selection state. @@ -46,21 +46,21 @@ type ListItemProps = { * - When false: children are rendered as-is, allowing custom styling * @default true */ - styled?: boolean; + styled?: boolean /** * Whether this item is disabled. Disabled items show dimmed text and no indicators. * @default false */ - disabled?: boolean; + disabled?: boolean /** * Whether this ListItem should declare the terminal cursor position. * Set false when a child (e.g. BaseTextInput) declares its own cursor. * @default true */ - declareCursor?: boolean; -}; + declareCursor?: boolean +} /** * A list item component for selection UIs (dropdowns, multi-selects, menus). @@ -101,143 +101,88 @@ type ListItemProps = { * Custom styled content * */ -export function ListItem(t0) { - const $ = _c(32); - const { - isFocused, - isSelected: t1, - children, - description, - showScrollDown, - showScrollUp, - styled: t2, - disabled: t3, - declareCursor - } = t0; - const isSelected = t1 === undefined ? false : t1; - const styled = t2 === undefined ? true : t2; - const disabled = t3 === undefined ? false : t3; - let t4; - if ($[0] !== disabled || $[1] !== isFocused || $[2] !== showScrollDown || $[3] !== showScrollUp) { - t4 = function renderIndicator() { - if (disabled) { - return ; - } - if (isFocused) { - return {figures.pointer}; - } - if (showScrollDown) { - return {figures.arrowDown}; - } - if (showScrollUp) { - return {figures.arrowUp}; - } - return ; - }; - $[0] = disabled; - $[1] = isFocused; - $[2] = showScrollDown; - $[3] = showScrollUp; - $[4] = t4; - } else { - t4 = $[4]; - } - const renderIndicator = t4; - let t5; - if ($[5] !== disabled || $[6] !== isFocused || $[7] !== isSelected || $[8] !== styled) { - const getTextColor = function getTextColor() { - if (disabled) { - return "inactive"; - } - if (!styled) { - return; - } - if (isSelected) { - return "success"; - } - if (isFocused) { - return "suggestion"; - } - }; - t5 = getTextColor(); - $[5] = disabled; - $[6] = isFocused; - $[7] = isSelected; - $[8] = styled; - $[9] = t5; - } else { - t5 = $[9]; - } - const textColor = t5; - const t6 = isFocused && !disabled && declareCursor !== false; - let t7; - if ($[10] !== t6) { - t7 = { - line: 0, - column: 0, - active: t6 - }; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - const cursorRef = useDeclaredCursor(t7); - let t8; - if ($[12] !== renderIndicator) { - t8 = renderIndicator(); - $[12] = renderIndicator; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== children || $[15] !== disabled || $[16] !== styled || $[17] !== textColor) { - t9 = styled ? {children} : children; - $[14] = children; - $[15] = disabled; - $[16] = styled; - $[17] = textColor; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== disabled || $[20] !== isSelected) { - t10 = isSelected && !disabled && {figures.tick}; - $[19] = disabled; - $[20] = isSelected; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== t10 || $[23] !== t8 || $[24] !== t9) { - t11 = {t8}{t9}{t10}; - $[22] = t10; - $[23] = t8; - $[24] = t9; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== description) { - t12 = description && {description}; - $[26] = description; - $[27] = t12; - } else { - t12 = $[27]; +export function ListItem({ + isFocused, + isSelected = false, + children, + description, + showScrollDown, + showScrollUp, + styled = true, + disabled = false, + declareCursor, +}: ListItemProps): React.ReactNode { + // Determine which indicator to show + function renderIndicator(): ReactNode { + if (disabled) { + return + } + + if (isFocused) { + return {figures.pointer} + } + + if (showScrollDown) { + return {figures.arrowDown} + } + + if (showScrollUp) { + return {figures.arrowUp} + } + + return } - let t13; - if ($[28] !== cursorRef || $[29] !== t11 || $[30] !== t12) { - t13 = {t11}{t12}; - $[28] = cursorRef; - $[29] = t11; - $[30] = t12; - $[31] = t13; - } else { - t13 = $[31]; + + // Determine text color based on state + function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined { + if (disabled) { + return 'inactive' + } + + if (!styled) { + return undefined + } + + if (isSelected) { + return 'success' + } + + if (isFocused) { + return 'suggestion' + } + + return undefined } - return t13; + + const textColor = getTextColor() + + // Park the native terminal cursor on the pointer indicator so screen + // readers / magnifiers track the focused item. (0,0) is the top-left of + // this Box, where the pointer renders. + const cursorRef = useDeclaredCursor({ + line: 0, + column: 0, + active: isFocused && !disabled && declareCursor !== false, + }) + + return ( + + + {renderIndicator()} + {styled ? ( + + {children} + + ) : ( + children + )} + {isSelected && !disabled && {figures.tick}} + + {description && ( + + {description} + + )} + + ) } diff --git a/src/components/design-system/LoadingState.tsx b/src/components/design-system/LoadingState.tsx index aa05dd941..046f726fa 100644 --- a/src/components/design-system/LoadingState.tsx +++ b/src/components/design-system/LoadingState.tsx @@ -1,30 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Spinner } from '../Spinner.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { Spinner } from '../Spinner.js' + type LoadingStateProps = { /** * The loading message to display next to the spinner. */ - message: string; + message: string /** * Display the message in bold. * @default false */ - bold?: boolean; + bold?: boolean /** * Display the message in dimmed color. * @default false */ - dimColor?: boolean; + dimColor?: boolean /** * Optional subtitle displayed below the main message. */ - subtitle?: string; -}; + subtitle?: string +} /** * A spinner with loading message for async operations. @@ -45,49 +45,22 @@ type LoadingStateProps = { * subtitle="Fetching your Claude Code sessions..." * /> */ -export function LoadingState(t0) { - const $ = _c(10); - const { - message, - bold: t1, - dimColor: t2, - subtitle - } = t0; - const bold = t1 === undefined ? false : t1; - const dimColor = t2 === undefined ? false : t2; - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[0] = t3; - } else { - t3 = $[0]; - } - let t4; - if ($[1] !== bold || $[2] !== dimColor || $[3] !== message) { - t4 = {t3}{" "}{message}; - $[1] = bold; - $[2] = dimColor; - $[3] = message; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== subtitle) { - t5 = subtitle && {subtitle}; - $[5] = subtitle; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== t4 || $[8] !== t5) { - t6 = {t4}{t5}; - $[7] = t4; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - return t6; +export function LoadingState({ + message, + bold = false, + dimColor = false, + subtitle, +}: LoadingStateProps): React.ReactNode { + return ( + + + + + {' '} + {message} + + + {subtitle && {subtitle}} + + ) } diff --git a/src/components/design-system/Pane.tsx b/src/components/design-system/Pane.tsx index 4f1264bea..9c10907d3 100644 --- a/src/components/design-system/Pane.tsx +++ b/src/components/design-system/Pane.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { Box } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; -import { Divider } from './Divider.js'; +import React from 'react' +import { useIsInsideModal } from '../../context/modalContext.js' +import { Box } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' +import { Divider } from './Divider.js' + type PaneProps = { - children: React.ReactNode; + children: React.ReactNode /** * Theme color for the top border line. */ - color?: keyof Theme; -}; + color?: keyof Theme +} /** * A pane — a region of the terminal that appears below the REPL prompt, @@ -30,47 +30,28 @@ type PaneProps = { * ... * */ -export function Pane(t0) { - const $ = _c(9); - const { - children, - color - } = t0; +export function Pane({ children, color }: PaneProps): React.ReactNode { + // When rendered inside FullscreenLayout's modal slot, its ▔ divider IS + // the frame. Skip our own Divider (would double-frame) and the extra top + // padding. This lets slash-command screens that wrap in Pane (e.g. + // /model → ModelPicker) route through the modal slot unchanged. if (useIsInsideModal()) { - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - let t1; - if ($[2] !== color) { - t1 = ; - $[2] = color; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== children) { - t2 = {children}; - $[4] = children; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== t1 || $[7] !== t2) { - t3 = {t1}{t2}; - $[6] = t1; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; + // flexShrink=0: the modal slot's absolute Box has no explicit height + // (grows to fit, maxHeight cap). With flexGrow=1, re-renders cause + // yoga to resolve this Box's height to 0 against the undetermined + // parent — /permissions body blanks on Down arrow. See #23592. + return ( + + {children} + + ) } - return t3; + return ( + + + + {children} + + + ) } diff --git a/src/components/design-system/ProgressBar.tsx b/src/components/design-system/ProgressBar.tsx index 0d27c514b..590fcd265 100644 --- a/src/components/design-system/ProgressBar.tsx +++ b/src/components/design-system/ProgressBar.tsx @@ -1,85 +1,54 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; +import React from 'react' +import { Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' + type Props = { /** * How much progress to display, between 0 and 1 inclusive */ - ratio: number; // [0, 1] + ratio: number // [0, 1] /** * How many characters wide to draw the progress bar */ - width: number; // how many characters wide + width: number // how many characters wide /** * Optional color for the filled portion of the bar */ - fillColor?: keyof Theme; + fillColor?: keyof Theme /** * Optional color for the empty portion of the bar */ - emptyColor?: keyof Theme; -}; -const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; -export function ProgressBar(t0) { - const $ = _c(13); - const { - ratio: inputRatio, - width, - fillColor, - emptyColor - } = t0; - const ratio = Math.min(1, Math.max(0, inputRatio)); - const whole = Math.floor(ratio * width); - let t1; - if ($[0] !== whole) { - t1 = BLOCKS[BLOCKS.length - 1].repeat(whole); - $[0] = whole; - $[1] = t1; - } else { - t1 = $[1]; - } - let segments; - if ($[2] !== ratio || $[3] !== t1 || $[4] !== whole || $[5] !== width) { - segments = [t1]; - if (whole < width) { - const remainder = ratio * width - whole; - const middle = Math.floor(remainder * BLOCKS.length); - segments.push(BLOCKS[middle]); - const empty = width - whole - 1; - if (empty > 0) { - let t2; - if ($[7] !== empty) { - t2 = BLOCKS[0].repeat(empty); - $[7] = empty; - $[8] = t2; - } else { - t2 = $[8]; - } - segments.push(t2); - } + emptyColor?: keyof Theme +} + +const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] + +export function ProgressBar({ + ratio: inputRatio, + width, + fillColor, + emptyColor, +}: Props): React.ReactNode { + const ratio = Math.min(1, Math.max(0, inputRatio)) + const whole = Math.floor(ratio * width) + const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)] + if (whole < width) { + const remainder = ratio * width - whole + const middle = Math.floor(remainder * BLOCKS.length) + segments.push(BLOCKS[middle]!) + + const empty = width - whole - 1 + if (empty > 0) { + segments.push(BLOCKS[0]!.repeat(empty)) } - $[2] = ratio; - $[3] = t1; - $[4] = whole; - $[5] = width; - $[6] = segments; - } else { - segments = $[6]; } - const t2 = segments.join(""); - let t3; - if ($[9] !== emptyColor || $[10] !== fillColor || $[11] !== t2) { - t3 = {t2}; - $[9] = emptyColor; - $[10] = fillColor; - $[11] = t2; - $[12] = t3; - } else { - t3 = $[12]; - } - return t3; + + return ( + + {segments.join('')} + + ) } diff --git a/src/components/design-system/Ratchet.tsx b/src/components/design-system/Ratchet.tsx index a63cffb33..91580ff05 100644 --- a/src/components/design-system/Ratchet.tsx +++ b/src/components/design-system/Ratchet.tsx @@ -1,79 +1,45 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js'; -import { Box, type DOMElement, measureElement } from '../../ink.js'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js' +import { Box, type DOMElement, measureElement } from '../../ink.js' + type Props = { - children: React.ReactNode; - lock?: 'always' | 'offscreen'; -}; -export function Ratchet(t0) { - const $ = _c(10); - const { - children, - lock: t1 - } = t0; - const lock = t1 === undefined ? "always" : t1; - const [viewportRef, t2] = useTerminalViewport(); - const { - isVisible - } = t2; - const { - rows - } = useTerminalSize(); - const innerRef = useRef(null); - const maxHeight = useRef(0); - const [minHeight, setMinHeight] = useState(0); - let t3; - if ($[0] !== viewportRef) { - t3 = el => { - viewportRef(el); - }; - $[0] = viewportRef; - $[1] = t3; - } else { - t3 = $[1]; - } - const outerRef = t3; - const engaged = lock === "always" || !isVisible; - let t4; - if ($[2] !== rows) { - t4 = () => { - if (!innerRef.current) { - return; - } - const { - height - } = measureElement(innerRef.current); - if (height > maxHeight.current) { - maxHeight.current = Math.min(height, rows); - setMinHeight(maxHeight.current); - } - }; - $[2] = rows; - $[3] = t4; - } else { - t4 = $[3]; - } - useLayoutEffect(t4); - const t5 = engaged ? minHeight : undefined; - let t6; - if ($[4] !== children) { - t6 = {children}; - $[4] = children; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== outerRef || $[7] !== t5 || $[8] !== t6) { - t7 = {t6}; - $[6] = outerRef; - $[7] = t5; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - return t7; + children: React.ReactNode + lock?: 'always' | 'offscreen' +} + +export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode { + const [viewportRef, { isVisible }] = useTerminalViewport() + const { rows } = useTerminalSize() + const innerRef = useRef(null) + const maxHeight = useRef(0) + const [minHeight, setMinHeight] = useState(0) + + const outerRef = useCallback( + (el: DOMElement | null) => { + viewportRef(el) + }, + [viewportRef], + ) + + const engaged = lock === 'always' || !isVisible + + useLayoutEffect(() => { + if (!innerRef.current) { + return + } + const { height } = measureElement(innerRef.current) + if (height > maxHeight.current) { + maxHeight.current = Math.min(height, rows) + setMinHeight(maxHeight.current) + } + }) + + return ( + + + {children} + + + ) } diff --git a/src/components/design-system/StatusIcon.tsx b/src/components/design-system/StatusIcon.tsx index d50693edd..832c83a9e 100644 --- a/src/components/design-system/StatusIcon.tsx +++ b/src/components/design-system/StatusIcon.tsx @@ -1,8 +1,9 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Text } from '../../ink.js'; -type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading'; +import figures from 'figures' +import React from 'react' +import { Text } from '../../ink.js' + +type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading' + type Props = { /** * The status to display. Determines both the icon and color. @@ -14,42 +15,28 @@ type Props = { * - `pending`: Dimmed circle (○) * - `loading`: Dimmed ellipsis (…) */ - status: Status; + status: Status /** * Include a trailing space after the icon. Useful when followed by text. * @default false */ - withSpace?: boolean; -}; -const STATUS_CONFIG: Record = { - success: { - icon: figures.tick, - color: 'success' - }, - error: { - icon: figures.cross, - color: 'error' - }, - warning: { - icon: figures.warning, - color: 'warning' - }, - info: { - icon: figures.info, - color: 'suggestion' - }, - pending: { - icon: figures.circle, - color: undefined - }, - loading: { - icon: '…', - color: undefined + withSpace?: boolean +} + +const STATUS_CONFIG: Record< + Status, + { + icon: string + color: 'success' | 'error' | 'warning' | 'suggestion' | undefined } -}; +> = { + success: { icon: figures.tick, color: 'success' }, + error: { icon: figures.cross, color: 'error' }, + warning: { icon: figures.warning, color: 'warning' }, + info: { icon: figures.info, color: 'suggestion' }, + pending: { icon: figures.circle, color: undefined }, + loading: { icon: '…', color: undefined }, +} /** * Renders a status indicator icon with appropriate color. @@ -69,26 +56,16 @@ const STATUS_CONFIG: Record */ -export function StatusIcon(t0) { - const $ = _c(5); - const { - status, - withSpace: t1 - } = t0; - const withSpace = t1 === undefined ? false : t1; - const config = STATUS_CONFIG[status]; - const t2 = !config.color; - const t3 = withSpace && " "; - let t4; - if ($[0] !== config.color || $[1] !== config.icon || $[2] !== t2 || $[3] !== t3) { - t4 = {config.icon}{t3}; - $[0] = config.color; - $[1] = config.icon; - $[2] = t2; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - return t4; +export function StatusIcon({ + status, + withSpace = false, +}: Props): React.ReactNode { + const config = STATUS_CONFIG[status] + + return ( + + {config.icon} + {withSpace && ' '} + + ) } diff --git a/src/components/design-system/Tabs.tsx b/src/components/design-system/Tabs.tsx index db8d0d59e..40bae7baa 100644 --- a/src/components/design-system/Tabs.tsx +++ b/src/components/design-system/Tabs.tsx @@ -1,28 +1,37 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { useIsInsideModal, useModalScrollRef } from '../../context/modalContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import ScrollBox from '../../ink/components/ScrollBox.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { Theme } from '../../utils/theme.js'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { + useIsInsideModal, + useModalScrollRef, +} from '../../context/modalContext.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import ScrollBox from '../../ink/components/ScrollBox.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { Theme } from '../../utils/theme.js' + type TabsProps = { - children: Array>; - title?: string; - color?: keyof Theme; - defaultTab?: string; - hidden?: boolean; - useFullWidth?: boolean; + children: Array> + title?: string + color?: keyof Theme + defaultTab?: string + hidden?: boolean + useFullWidth?: boolean /** Controlled mode: current selected tab id/title */ - selectedTab?: string; + selectedTab?: string /** Controlled mode: callback when tab changes */ - onTabChange?: (tabId: string) => void; + onTabChange?: (tabId: string) => void /** Optional banner to display below tabs header */ - banner?: React.ReactNode; + banner?: React.ReactNode /** Disable keyboard navigation (e.g. when a child component handles arrow keys) */ - disableNavigation?: boolean; + disableNavigation?: boolean /** * Initial focus state for the tab header row. Defaults to true (header * focused, nav always works). Keep the default for Select/list content — @@ -31,28 +40,30 @@ type TabsProps = { * content actually binds left/right/tab (e.g. enum cycling), and show a * "↑ tabs" footer hint — without it tabs look broken. */ - initialHeaderFocused?: boolean; + initialHeaderFocused?: boolean /** * Fixed height for the content area. When set, all tabs render within the * same height (overflow hidden) so switching tabs doesn't cause layout * shifts. Shorter tabs get whitespace; taller tabs are clipped. */ - contentHeight?: number; + contentHeight?: number /** * Let Tab/←/→ switch tabs from focused content. Opt-in since some * content uses those keys; pass a reactive boolean to cede them when * needed. Switching from content focuses the header. */ - navFromContent?: boolean; -}; + navFromContent?: boolean +} + type TabsContextValue = { - selectedTab: string | undefined; - width: number | undefined; - headerFocused: boolean; - focusHeader: () => void; - blurHeader: () => void; - registerOptIn: () => () => void; -}; + selectedTab: string | undefined + width: number | undefined + headerFocused: boolean + focusHeader: () => void + blurHeader: () => void + registerOptIn: () => () => void +} + const TabsContext = createContext({ selectedTab: undefined, width: undefined, @@ -61,236 +72,248 @@ const TabsContext = createContext({ headerFocused: false, focusHeader: () => {}, blurHeader: () => {}, - registerOptIn: () => () => {} -}); -export function Tabs(t0) { - const $ = _c(25); - const { - title, - color, - defaultTab, - children, - hidden, - useFullWidth, - selectedTab: controlledSelectedTab, - onTabChange, - banner, - disableNavigation, - initialHeaderFocused: t1, - contentHeight, - navFromContent: t2 - } = t0; - const initialHeaderFocused = t1 === undefined ? true : t1; - const navFromContent = t2 === undefined ? false : t2; - const { - columns: terminalWidth - } = useTerminalSize(); - const tabs = children.map(_temp); - const defaultTabIndex = defaultTab ? tabs.findIndex(tab => defaultTab === tab[0]) : 0; - const isControlled = controlledSelectedTab !== undefined; - const [internalSelectedTab, setInternalSelectedTab] = useState(defaultTabIndex !== -1 ? defaultTabIndex : 0); - const controlledTabIndex = isControlled ? tabs.findIndex(tab_0 => tab_0[0] === controlledSelectedTab) : -1; - const selectedTabIndex = isControlled ? controlledTabIndex !== -1 ? controlledTabIndex : 0 : internalSelectedTab; - const modalScrollRef = useModalScrollRef(); - const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused); - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setHeaderFocused(true); - $[0] = t3; - } else { - t3 = $[0]; - } - const focusHeader = t3; - let t4; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t4 = () => setHeaderFocused(false); - $[1] = t4; - } else { - t4 = $[1]; - } - const blurHeader = t4; - const [optInCount, setOptInCount] = useState(0); - let t5; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t5 = () => { - setOptInCount(_temp2); - return () => setOptInCount(_temp3); - }; - $[2] = t5; - } else { - t5 = $[2]; - } - const registerOptIn = t5; - const optedIn = optInCount > 0; - const handleTabChange = offset => { - const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length; - const newTabId = tabs[newIndex]?.[0]; + registerOptIn: () => () => {}, +}) + +export function Tabs({ + title, + color, + defaultTab, + children, + hidden, + useFullWidth, + selectedTab: controlledSelectedTab, + onTabChange, + banner, + disableNavigation, + initialHeaderFocused = true, + contentHeight, + navFromContent = false, +}: TabsProps): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const tabs = children.map(child => [ + child.props.id ?? child.props.title, + child.props.title, + ]) + const defaultTabIndex = defaultTab + ? tabs.findIndex(tab => defaultTab === tab[0]) + : 0 + + // Support both controlled and uncontrolled modes + const isControlled = controlledSelectedTab !== undefined + const [internalSelectedTab, setInternalSelectedTab] = useState( + defaultTabIndex !== -1 ? defaultTabIndex : 0, + ) + + // In controlled mode, find the index of the controlled tab + const controlledTabIndex = isControlled + ? tabs.findIndex(tab => tab[0] === controlledSelectedTab) + : -1 + const selectedTabIndex = isControlled + ? controlledTabIndex !== -1 + ? controlledTabIndex + : 0 + : internalSelectedTab + + const modalScrollRef = useModalScrollRef() + + // Header focus: left/right/tab only switch tabs when the header row is + // focused. Children with interactive content call focusHeader() (via + // useTabHeaderFocus) on up-arrow to hand focus back here; down-arrow + // returns it. Tabs that never call the hook see no behavior change — + // initialHeaderFocused defaults to true so nav always works. + const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused) + const focusHeader = useCallback(() => setHeaderFocused(true), []) + const blurHeader = useCallback(() => setHeaderFocused(false), []) + // Count of mounted children using useTabHeaderFocus(). Down-arrow blur and + // the ↓ hint only engage when at least one child has opted in — otherwise + // pressing down on a legacy tab would strand the user with nav disabled. + const [optInCount, setOptInCount] = useState(0) + const registerOptIn = useCallback(() => { + setOptInCount(n => n + 1) + return () => setOptInCount(n => n - 1) + }, []) + const optedIn = optInCount > 0 + + const handleTabChange = (offset: number) => { + const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length + const newTabId = tabs[newIndex]?.[0] + if (isControlled && onTabChange && newTabId) { - onTabChange(newTabId); + onTabChange(newTabId) } else { - setInternalSelectedTab(newIndex); + setInternalSelectedTab(newIndex) } - setHeaderFocused(true); - }; - const t6 = !hidden && !disableNavigation && headerFocused; - let t7; - if ($[3] !== t6) { - t7 = { - context: "Tabs", - isActive: t6 - }; - $[3] = t6; - $[4] = t7; - } else { - t7 = $[4]; + // Tab switching is a header action — stay focused so the user can keep + // cycling. The newly mounted tab can blur via its own interaction. + setHeaderFocused(true) } - useKeybindings({ - "tabs:next": () => handleTabChange(1), - "tabs:previous": () => handleTabChange(-1) - }, t7); - let t8; - if ($[5] !== headerFocused || $[6] !== hidden || $[7] !== optedIn) { - t8 = e => { - if (!headerFocused || !optedIn || hidden) { - return; - } - if (e.key === "down") { - e.preventDefault(); - setHeaderFocused(false); - } - }; - $[5] = headerFocused; - $[6] = hidden; - $[7] = optedIn; - $[8] = t8; - } else { - t8 = $[8]; - } - const handleKeyDown = t8; - const t9 = navFromContent && !headerFocused && optedIn && !hidden && !disableNavigation; - let t10; - if ($[9] !== t9) { - t10 = { - context: "Tabs", - isActive: t9 - }; - $[9] = t9; - $[10] = t10; - } else { - t10 = $[10]; - } - useKeybindings({ - "tabs:next": () => { - handleTabChange(1); - setHeaderFocused(true); + + useKeybindings( + { + 'tabs:next': () => handleTabChange(1), + 'tabs:previous': () => handleTabChange(-1), }, - "tabs:previous": () => { - handleTabChange(-1); - setHeaderFocused(true); + { + context: 'Tabs', + isActive: !hidden && !disableNavigation && headerFocused, + }, + ) + + // When the header is focused, down-arrow returns focus to content. Only + // active when the selected tab has opted in via useTabHeaderFocus() — + // legacy tabs have nowhere to return focus to. + const handleKeyDown = (e: KeyboardEvent) => { + if (!headerFocused || !optedIn || hidden) return + if (e.key === 'down') { + e.preventDefault() + setHeaderFocused(false) } - }, t10); - const titleWidth = title ? stringWidth(title) + 1 : 0; - const tabsWidth = tabs.reduce(_temp4, 0); - const usedWidth = titleWidth + tabsWidth; - const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0; - const contentWidth = useFullWidth ? terminalWidth : undefined; - const T0 = Box; - const t11 = "column"; - const t12 = 0; - const t13 = true; - const t14 = modalScrollRef ? 0 : undefined; - const t15 = !hidden && {title !== undefined && {title}}{tabs.map((t16, i) => { - const [id, title_0] = t16; - const isCurrent = selectedTabIndex === i; - const hasColorCursor = color && isCurrent && headerFocused; - return {" "}{title_0}{" "}; - })}{spacerWidth > 0 && {" ".repeat(spacerWidth)}}; - let t17; - if ($[11] !== children || $[12] !== contentHeight || $[13] !== contentWidth || $[14] !== hidden || $[15] !== modalScrollRef || $[16] !== selectedTabIndex) { - t17 = modalScrollRef ? {children} : {children}; - $[11] = children; - $[12] = contentHeight; - $[13] = contentWidth; - $[14] = hidden; - $[15] = modalScrollRef; - $[16] = selectedTabIndex; - $[17] = t17; - } else { - t17 = $[17]; } - let t18; - if ($[18] !== T0 || $[19] !== banner || $[20] !== handleKeyDown || $[21] !== t14 || $[22] !== t15 || $[23] !== t17) { - t18 = {t15}{banner}{t17}; - $[18] = T0; - $[19] = banner; - $[20] = handleKeyDown; - $[21] = t14; - $[22] = t15; - $[23] = t17; - $[24] = t18; - } else { - t18 = $[24]; - } - return {t18}; -} -function _temp4(sum, t0) { - const [, tabTitle] = t0; - return sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1; -} -function _temp3(n_0) { - return n_0 - 1; -} -function _temp2(n) { - return n + 1; -} -function _temp(child) { - return [child.props.id ?? child.props.title, child.props.title]; + + // Opt-in: same tabs:next/previous actions, active from content. Focuses + // the header so subsequent presses cycle via the handler above. + useKeybindings( + { + 'tabs:next': () => { + handleTabChange(1) + setHeaderFocused(true) + }, + 'tabs:previous': () => { + handleTabChange(-1) + setHeaderFocused(true) + }, + }, + { + context: 'Tabs', + isActive: + navFromContent && + !headerFocused && + optedIn && + !hidden && + !disableNavigation, + }, + ) + + // Calculate spacing to fill the available width. No keyboard hint in the + // header row — content footers own hints (see useTabHeaderFocus docs). + const titleWidth = title ? stringWidth(title) + 1 : 0 // +1 for gap + const tabsWidth = tabs.reduce( + (sum, [, tabTitle]) => sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1, // +2 for padding, +1 for gap + 0, + ) + const usedWidth = titleWidth + tabsWidth + const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0 + + const contentWidth = useFullWidth ? terminalWidth : undefined + + return ( + + + {!hidden && ( + + {title !== undefined && ( + + {title} + + )} + {tabs.map(([id, title], i) => { + const isCurrent = selectedTabIndex === i + const hasColorCursor = color && isCurrent && headerFocused + return ( + + {' '} + {title}{' '} + + ) + })} + {spacerWidth > 0 && {' '.repeat(spacerWidth)}} + + )} + {banner} + {modalScrollRef ? ( + // Inside the modal slot: own the ScrollBox here so the tabs + // header row above sits OUTSIDE the scroll area — it can never + // scroll off. The ref reaches REPL's ScrollKeybindingHandler via + // ModalContext. Keyed by selectedTabIndex → remounts on tab + // switch, resetting scrollTop to 0 without scrollTo() timing games. + + + {children} + + + ) : ( + + {children} + + )} + + + ) } + type TabProps = { - title: string; - id?: string; - children: React.ReactNode; -}; -export function Tab(t0) { - const $ = _c(4); - const { - title, - id, - children - } = t0; - const { - selectedTab, - width - } = useContext(TabsContext); - const insideModal = useIsInsideModal(); + title: string + id?: string + children: React.ReactNode +} + +export function Tab({ title, id, children }: TabProps): React.ReactNode { + const { selectedTab, width } = useContext(TabsContext) + const insideModal = useIsInsideModal() if (selectedTab !== (id ?? title)) { - return null; - } - const t1 = insideModal ? 0 : undefined; - let t2; - if ($[0] !== children || $[1] !== t1 || $[2] !== width) { - t2 = {children}; - $[0] = children; - $[1] = t1; - $[2] = width; - $[3] = t2; - } else { - t2 = $[3]; + return null } - return t2; + + return ( + + {children} + + ) } -export function useTabsWidth() { - const { - width - } = useContext(TabsContext); - return width; + +export function useTabsWidth(): number | undefined { + const { width } = useContext(TabsContext) + return width } /** @@ -304,36 +327,13 @@ export function useTabsWidth() { * no onUpFromFirstItem to recover. Split the component so the hook only runs * when the Select renders. */ -export function useTabHeaderFocus() { - const $ = _c(6); - const { - headerFocused, - focusHeader, - blurHeader, - registerOptIn - } = useContext(TabsContext); - let t0; - if ($[0] !== registerOptIn) { - t0 = [registerOptIn]; - $[0] = registerOptIn; - $[1] = t0; - } else { - t0 = $[1]; - } - useEffect(registerOptIn, t0); - let t1; - if ($[2] !== blurHeader || $[3] !== focusHeader || $[4] !== headerFocused) { - t1 = { - headerFocused, - focusHeader, - blurHeader - }; - $[2] = blurHeader; - $[3] = focusHeader; - $[4] = headerFocused; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; +export function useTabHeaderFocus(): { + headerFocused: boolean + focusHeader: () => void + blurHeader: () => void +} { + const { headerFocused, focusHeader, blurHeader, registerOptIn } = + useContext(TabsContext) + useEffect(registerOptIn, [registerOptIn]) + return { headerFocused, focusHeader, blurHeader } } diff --git a/src/components/design-system/ThemeProvider.tsx b/src/components/design-system/ThemeProvider.tsx index 373f73072..ef60d23a1 100644 --- a/src/components/design-system/ThemeProvider.tsx +++ b/src/components/design-system/ThemeProvider.tsx @@ -1,169 +1,160 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import useStdin from '../../ink/hooks/use-stdin.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { getSystemThemeName, type SystemTheme } from '../../utils/systemTheme.js'; -import type { ThemeName, ThemeSetting } from '../../utils/theme.js'; +import { feature } from 'bun:bundle' +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import useStdin from '../../ink/hooks/use-stdin.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { + getSystemThemeName, + type SystemTheme, +} from '../../utils/systemTheme.js' +import type { ThemeName, ThemeSetting } from '../../utils/theme.js' + type ThemeContextValue = { /** The saved user preference. May be 'auto'. */ - themeSetting: ThemeSetting; - setThemeSetting: (setting: ThemeSetting) => void; - setPreviewTheme: (setting: ThemeSetting) => void; - savePreview: () => void; - cancelPreview: () => void; + themeSetting: ThemeSetting + setThemeSetting: (setting: ThemeSetting) => void + setPreviewTheme: (setting: ThemeSetting) => void + savePreview: () => void + cancelPreview: () => void /** The resolved theme to render with. Never 'auto'. */ - currentTheme: ThemeName; -}; + currentTheme: ThemeName +} // Non-'auto' default so useTheme() works without a provider (tests, tooling). -const DEFAULT_THEME: ThemeName = 'dark'; +const DEFAULT_THEME: ThemeName = 'dark' + const ThemeContext = createContext({ themeSetting: DEFAULT_THEME, setThemeSetting: () => {}, setPreviewTheme: () => {}, savePreview: () => {}, cancelPreview: () => {}, - currentTheme: DEFAULT_THEME -}); + currentTheme: DEFAULT_THEME, +}) + type Props = { - children: React.ReactNode; - initialState?: ThemeSetting; - onThemeSave?: (setting: ThemeSetting) => void; -}; + children: React.ReactNode + initialState?: ThemeSetting + onThemeSave?: (setting: ThemeSetting) => void +} + function defaultInitialTheme(): ThemeSetting { - return getGlobalConfig().theme; + return getGlobalConfig().theme } + function defaultSaveTheme(setting: ThemeSetting): void { - saveGlobalConfig(current => ({ - ...current, - theme: setting - })); + saveGlobalConfig(current => ({ ...current, theme: setting })) } + export function ThemeProvider({ children, initialState, - onThemeSave = defaultSaveTheme + onThemeSave = defaultSaveTheme, }: Props) { - const [themeSetting, setThemeSetting] = useState(initialState ?? defaultInitialTheme); - const [previewTheme, setPreviewTheme] = useState(null); + const [themeSetting, setThemeSetting] = useState( + initialState ?? defaultInitialTheme, + ) + const [previewTheme, setPreviewTheme] = useState(null) // Track terminal theme for 'auto' resolution. Seeds from $COLORFGBG (or // 'dark' if unset); the OSC 11 watcher corrects it on first poll. - const [systemTheme, setSystemTheme] = useState(() => (initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark'); + const [systemTheme, setSystemTheme] = useState(() => + (initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark', + ) // The setting currently in effect (preview wins while picker is open) - const activeSetting = previewTheme ?? themeSetting; - const { - internal_querier - } = useStdin(); + const activeSetting = previewTheme ?? themeSetting + + const { internal_querier } = useStdin() // Watch for live terminal theme changes while 'auto' is active. // Positive feature() pattern so the watcher import is dead-code-eliminated // in external builds. useEffect(() => { if (feature('AUTO_THEME')) { - if (activeSetting !== 'auto' || !internal_querier) return; - let cleanup: (() => void) | undefined; - let cancelled = false; - void import('../../utils/systemThemeWatcher.js').then(({ - watchSystemTheme - }) => { - if (cancelled) return; - cleanup = watchSystemTheme(internal_querier, setSystemTheme); - }); + if (activeSetting !== 'auto' || !internal_querier) return + let cleanup: (() => void) | undefined + let cancelled = false + void import('../../utils/systemThemeWatcher.js').then( + ({ watchSystemTheme }) => { + if (cancelled) return + cleanup = watchSystemTheme(internal_querier, setSystemTheme) + }, + ) return () => { - cancelled = true; - cleanup?.(); - }; - } - }, [activeSetting, internal_querier]); - const currentTheme: ThemeName = activeSetting === 'auto' ? systemTheme : activeSetting; - const value = useMemo(() => ({ - themeSetting, - setThemeSetting: (newSetting: ThemeSetting) => { - setThemeSetting(newSetting); - setPreviewTheme(null); - // Switching to 'auto' restarts the watcher (activeSetting dep), whose - // first poll fires immediately. Seed from the cache so the OSC - // round-trip doesn't flash the wrong palette. - if (newSetting === 'auto') { - setSystemTheme(getSystemThemeName()); - } - onThemeSave?.(newSetting); - }, - setPreviewTheme: (newSetting_0: ThemeSetting) => { - setPreviewTheme(newSetting_0); - if (newSetting_0 === 'auto') { - setSystemTheme(getSystemThemeName()); - } - }, - savePreview: () => { - if (previewTheme !== null) { - setThemeSetting(previewTheme); - setPreviewTheme(null); - onThemeSave?.(previewTheme); + cancelled = true + cleanup?.() } - }, - cancelPreview: () => { - if (previewTheme !== null) { - setPreviewTheme(null); - } - }, - currentTheme - }), [themeSetting, previewTheme, currentTheme, onThemeSave]); - return {children}; + } + }, [activeSetting, internal_querier]) + + const currentTheme: ThemeName = + activeSetting === 'auto' ? systemTheme : activeSetting + + const value = useMemo( + () => ({ + themeSetting, + setThemeSetting: (newSetting: ThemeSetting) => { + setThemeSetting(newSetting) + setPreviewTheme(null) + // Switching to 'auto' restarts the watcher (activeSetting dep), whose + // first poll fires immediately. Seed from the cache so the OSC + // round-trip doesn't flash the wrong palette. + if (newSetting === 'auto') { + setSystemTheme(getSystemThemeName()) + } + onThemeSave?.(newSetting) + }, + setPreviewTheme: (newSetting: ThemeSetting) => { + setPreviewTheme(newSetting) + if (newSetting === 'auto') { + setSystemTheme(getSystemThemeName()) + } + }, + savePreview: () => { + if (previewTheme !== null) { + setThemeSetting(previewTheme) + setPreviewTheme(null) + onThemeSave?.(previewTheme) + } + }, + cancelPreview: () => { + if (previewTheme !== null) { + setPreviewTheme(null) + } + }, + currentTheme, + }), + [themeSetting, previewTheme, currentTheme, onThemeSave], + ) + + return {children} } /** * Returns the resolved theme for rendering (never 'auto') and a setter that * accepts any ThemeSetting (including 'auto'). */ -export function useTheme() { - const $ = _c(3); - const { - currentTheme, - setThemeSetting - } = useContext(ThemeContext); - let t0; - if ($[0] !== currentTheme || $[1] !== setThemeSetting) { - t0 = [currentTheme, setThemeSetting]; - $[0] = currentTheme; - $[1] = setThemeSetting; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; +export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] { + const { currentTheme, setThemeSetting } = useContext(ThemeContext) + return [currentTheme, setThemeSetting] } /** * Returns the raw theme setting as stored in config. Use this in UI that * needs to show 'auto' as a distinct choice (e.g., ThemePicker). */ -export function useThemeSetting() { - return useContext(ThemeContext).themeSetting; +export function useThemeSetting(): ThemeSetting { + return useContext(ThemeContext).themeSetting } + export function usePreviewTheme() { - const $ = _c(4); - const { - setPreviewTheme, - savePreview, - cancelPreview - } = useContext(ThemeContext); - let t0; - if ($[0] !== cancelPreview || $[1] !== savePreview || $[2] !== setPreviewTheme) { - t0 = { - setPreviewTheme, - savePreview, - cancelPreview - }; - $[0] = cancelPreview; - $[1] = savePreview; - $[2] = setPreviewTheme; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + const { setPreviewTheme, savePreview, cancelPreview } = + useContext(ThemeContext) + return { setPreviewTheme, savePreview, cancelPreview } } diff --git a/src/components/design-system/ThemedBox.tsx b/src/components/design-system/ThemedBox.tsx index 0b56f18a6..10fbe9137 100644 --- a/src/components/design-system/ThemedBox.tsx +++ b/src/components/design-system/ThemedBox.tsx @@ -1,155 +1,112 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren, type Ref } from 'react'; -import Box from '../../ink/components/Box.js'; -import type { DOMElement } from '../../ink/dom.js'; -import type { ClickEvent } from '../../ink/events/click-event.js'; -import type { FocusEvent } from '../../ink/events/focus-event.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import type { Color, Styles } from '../../ink/styles.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { useTheme } from './ThemeProvider.js'; +import React, { type PropsWithChildren, type Ref } from 'react' +import Box from '../../ink/components/Box.js' +import type { DOMElement } from '../../ink/dom.js' +import type { ClickEvent } from '../../ink/events/click-event.js' +import type { FocusEvent } from '../../ink/events/focus-event.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import type { Color, Styles } from '../../ink/styles.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { useTheme } from './ThemeProvider.js' // Color props that accept theme keys type ThemedColorProps = { - readonly borderColor?: keyof Theme | Color; - readonly borderTopColor?: keyof Theme | Color; - readonly borderBottomColor?: keyof Theme | Color; - readonly borderLeftColor?: keyof Theme | Color; - readonly borderRightColor?: keyof Theme | Color; - readonly backgroundColor?: keyof Theme | Color; -}; + readonly borderColor?: keyof Theme | Color + readonly borderTopColor?: keyof Theme | Color + readonly borderBottomColor?: keyof Theme | Color + readonly borderLeftColor?: keyof Theme | Color + readonly borderRightColor?: keyof Theme | Color + readonly backgroundColor?: keyof Theme | Color +} // Base Styles without color props (they'll be overridden) -type BaseStylesWithoutColors = Omit; -export type Props = BaseStylesWithoutColors & ThemedColorProps & { - ref?: Ref; - tabIndex?: number; - autoFocus?: boolean; - onClick?: (event: ClickEvent) => void; - onFocus?: (event: FocusEvent) => void; - onFocusCapture?: (event: FocusEvent) => void; - onBlur?: (event: FocusEvent) => void; - onBlurCapture?: (event: FocusEvent) => void; - onKeyDown?: (event: KeyboardEvent) => void; - onKeyDownCapture?: (event: KeyboardEvent) => void; - onMouseEnter?: () => void; - onMouseLeave?: () => void; -}; +type BaseStylesWithoutColors = Omit< + Styles, + | 'textWrap' + | 'borderColor' + | 'borderTopColor' + | 'borderBottomColor' + | 'borderLeftColor' + | 'borderRightColor' + | 'backgroundColor' +> + +export type Props = BaseStylesWithoutColors & + ThemedColorProps & { + ref?: Ref + tabIndex?: number + autoFocus?: boolean + onClick?: (event: ClickEvent) => void + onFocus?: (event: FocusEvent) => void + onFocusCapture?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void + onKeyDown?: (event: KeyboardEvent) => void + onKeyDownCapture?: (event: KeyboardEvent) => void + onMouseEnter?: () => void + onMouseLeave?: () => void + } /** * Resolves a color value that may be a theme key to a raw Color. */ -function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined { - if (!color) return undefined; +function resolveColor( + color: keyof Theme | Color | undefined, + theme: Theme, +): Color | undefined { + if (!color) return undefined // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) { - return color as Color; + if ( + color.startsWith('rgb(') || + color.startsWith('#') || + color.startsWith('ansi256(') || + color.startsWith('ansi:') + ) { + return color as Color } // It's a theme key - resolve it - return theme[color as keyof Theme] as Color; + return theme[color as keyof Theme] as Color } /** * Theme-aware Box component that resolves theme color keys to raw colors. * This wraps the base Box component with theme resolution for border colors. */ -function ThemedBox(t0) { - const $ = _c(33); - let backgroundColor; - let borderBottomColor; - let borderColor; - let borderLeftColor; - let borderRightColor; - let borderTopColor; - let children; - let ref; - let rest; - if ($[0] !== t0) { - ({ - borderColor, - borderTopColor, - borderBottomColor, - borderLeftColor, - borderRightColor, - backgroundColor, - children, - ref, - ...rest - } = t0); - $[0] = t0; - $[1] = backgroundColor; - $[2] = borderBottomColor; - $[3] = borderColor; - $[4] = borderLeftColor; - $[5] = borderRightColor; - $[6] = borderTopColor; - $[7] = children; - $[8] = ref; - $[9] = rest; - } else { - backgroundColor = $[1]; - borderBottomColor = $[2]; - borderColor = $[3]; - borderLeftColor = $[4]; - borderRightColor = $[5]; - borderTopColor = $[6]; - children = $[7]; - ref = $[8]; - rest = $[9]; - } - const [themeName] = useTheme(); - let resolvedBorderBottomColor; - let resolvedBorderColor; - let resolvedBorderLeftColor; - let resolvedBorderRightColor; - let resolvedBorderTopColor; - let t1; - if ($[10] !== backgroundColor || $[11] !== borderBottomColor || $[12] !== borderColor || $[13] !== borderLeftColor || $[14] !== borderRightColor || $[15] !== borderTopColor || $[16] !== themeName) { - const theme = getTheme(themeName); - resolvedBorderColor = resolveColor(borderColor, theme); - resolvedBorderTopColor = resolveColor(borderTopColor, theme); - resolvedBorderBottomColor = resolveColor(borderBottomColor, theme); - resolvedBorderLeftColor = resolveColor(borderLeftColor, theme); - resolvedBorderRightColor = resolveColor(borderRightColor, theme); - t1 = resolveColor(backgroundColor, theme); - $[10] = backgroundColor; - $[11] = borderBottomColor; - $[12] = borderColor; - $[13] = borderLeftColor; - $[14] = borderRightColor; - $[15] = borderTopColor; - $[16] = themeName; - $[17] = resolvedBorderBottomColor; - $[18] = resolvedBorderColor; - $[19] = resolvedBorderLeftColor; - $[20] = resolvedBorderRightColor; - $[21] = resolvedBorderTopColor; - $[22] = t1; - } else { - resolvedBorderBottomColor = $[17]; - resolvedBorderColor = $[18]; - resolvedBorderLeftColor = $[19]; - resolvedBorderRightColor = $[20]; - resolvedBorderTopColor = $[21]; - t1 = $[22]; - } - const resolvedBackgroundColor = t1; - let t2; - if ($[23] !== children || $[24] !== ref || $[25] !== resolvedBackgroundColor || $[26] !== resolvedBorderBottomColor || $[27] !== resolvedBorderColor || $[28] !== resolvedBorderLeftColor || $[29] !== resolvedBorderRightColor || $[30] !== resolvedBorderTopColor || $[31] !== rest) { - t2 = {children}; - $[23] = children; - $[24] = ref; - $[25] = resolvedBackgroundColor; - $[26] = resolvedBorderBottomColor; - $[27] = resolvedBorderColor; - $[28] = resolvedBorderLeftColor; - $[29] = resolvedBorderRightColor; - $[30] = resolvedBorderTopColor; - $[31] = rest; - $[32] = t2; - } else { - t2 = $[32]; - } - return t2; +function ThemedBox({ + borderColor, + borderTopColor, + borderBottomColor, + borderLeftColor, + borderRightColor, + backgroundColor, + children, + ref, + ...rest +}: PropsWithChildren): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + + // Resolve theme keys to raw colors + const resolvedBorderColor = resolveColor(borderColor, theme) + const resolvedBorderTopColor = resolveColor(borderTopColor, theme) + const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme) + const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme) + const resolvedBorderRightColor = resolveColor(borderRightColor, theme) + const resolvedBackgroundColor = resolveColor(backgroundColor, theme) + + return ( + + {children} + + ) } -export default ThemedBox; + +export default ThemedBox diff --git a/src/components/design-system/ThemedText.tsx b/src/components/design-system/ThemedText.tsx index abaa68f23..3c32b8bc3 100644 --- a/src/components/design-system/ThemedText.tsx +++ b/src/components/design-system/ThemedText.tsx @@ -1,123 +1,132 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React, { useContext } from 'react'; -import Text from '../../ink/components/Text.js'; -import type { Color, Styles } from '../../ink/styles.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { useTheme } from './ThemeProvider.js'; +import type { ReactNode } from 'react' +import React, { useContext } from 'react' +import Text from '../../ink/components/Text.js' +import type { Color, Styles } from '../../ink/styles.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { useTheme } from './ThemeProvider.js' /** Colors uncolored ThemedText in the subtree. Precedence: explicit `color` > * this > dimColor. Crosses Box boundaries (Ink's style cascade doesn't). */ -export const TextHoverColorContext = React.createContext(undefined); +export const TextHoverColorContext = React.createContext< + keyof Theme | undefined +>(undefined) + export type Props = { /** * Change text color. Accepts a theme key or raw color value. */ - readonly color?: keyof Theme | Color; + readonly color?: keyof Theme | Color /** * Same as `color`, but for background. Must be a theme key. */ - readonly backgroundColor?: keyof Theme; + readonly backgroundColor?: keyof Theme /** * Dim the color using the theme's inactive color. * This is compatible with bold (unlike ANSI dim). */ - readonly dimColor?: boolean; + readonly dimColor?: boolean /** * Make the text bold. */ - readonly bold?: boolean; + readonly bold?: boolean /** * Make the text italic. */ - readonly italic?: boolean; + readonly italic?: boolean /** * Make the text underlined. */ - readonly underline?: boolean; + readonly underline?: boolean /** * Make the text crossed with a line. */ - readonly strikethrough?: boolean; + readonly strikethrough?: boolean /** * Inverse background and foreground colors. */ - readonly inverse?: boolean; + readonly inverse?: boolean /** * This property tells Ink to wrap or truncate text if its width is larger than container. * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines. * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off. */ - readonly wrap?: Styles['textWrap']; - readonly children?: ReactNode; -}; + readonly wrap?: Styles['textWrap'] + + readonly children?: ReactNode +} /** * Resolves a color value that may be a theme key to a raw Color. */ -function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined { - if (!color) return undefined; +function resolveColor( + color: keyof Theme | Color | undefined, + theme: Theme, +): Color | undefined { + if (!color) return undefined // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) { - return color as Color; + if ( + color.startsWith('rgb(') || + color.startsWith('#') || + color.startsWith('ansi256(') || + color.startsWith('ansi:') + ) { + return color as Color } // It's a theme key - resolve it - return theme[color as keyof Theme] as Color; + return theme[color as keyof Theme] as Color } /** * Theme-aware Text component that resolves theme color keys to raw colors. * This wraps the base Text component with theme resolution. */ -export default function ThemedText(t0) { - const $ = _c(10); - const { - color, - backgroundColor, - dimColor: t1, - bold: t2, - italic: t3, - underline: t4, - strikethrough: t5, - inverse: t6, - wrap: t7, - children - } = t0; - const dimColor = t1 === undefined ? false : t1; - const bold = t2 === undefined ? false : t2; - const italic = t3 === undefined ? false : t3; - const underline = t4 === undefined ? false : t4; - const strikethrough = t5 === undefined ? false : t5; - const inverse = t6 === undefined ? false : t6; - const wrap = t7 === undefined ? "wrap" : t7; - const [themeName] = useTheme(); - const theme = getTheme(themeName); - const hoverColor = useContext(TextHoverColorContext); - const resolvedColor = !color && hoverColor ? resolveColor(hoverColor, theme) : dimColor ? theme.inactive as Color : resolveColor(color, theme); - const resolvedBackgroundColor = backgroundColor ? theme[backgroundColor] as Color : undefined; - let t8; - if ($[0] !== bold || $[1] !== children || $[2] !== inverse || $[3] !== italic || $[4] !== resolvedBackgroundColor || $[5] !== resolvedColor || $[6] !== strikethrough || $[7] !== underline || $[8] !== wrap) { - t8 = {children}; - $[0] = bold; - $[1] = children; - $[2] = inverse; - $[3] = italic; - $[4] = resolvedBackgroundColor; - $[5] = resolvedColor; - $[6] = strikethrough; - $[7] = underline; - $[8] = wrap; - $[9] = t8; - } else { - t8 = $[9]; - } - return t8; +export default function ThemedText({ + color, + backgroundColor, + dimColor = false, + bold = false, + italic = false, + underline = false, + strikethrough = false, + inverse = false, + wrap = 'wrap', + children, +}: Props): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + const hoverColor = useContext(TextHoverColorContext) + + // Resolve theme keys to raw colors + const resolvedColor = + !color && hoverColor + ? resolveColor(hoverColor, theme) + : dimColor + ? (theme.inactive as Color) + : resolveColor(color, theme) + const resolvedBackgroundColor = backgroundColor + ? (theme[backgroundColor] as Color) + : undefined + + return ( + + {children} + + ) } diff --git a/src/components/diff/DiffDetailView.tsx b/src/components/diff/DiffDetailView.tsx index 8ff1c0a76..af79d9365 100644 --- a/src/components/diff/DiffDetailView.tsx +++ b/src/components/diff/DiffDetailView.tsx @@ -1,280 +1,140 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import { resolve } from 'path'; -import React, { useMemo } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Box, Text } from '../../ink.js'; -import { getCwd } from '../../utils/cwd.js'; -import { readFileSafe } from '../../utils/file.js'; -import { Divider } from '../design-system/Divider.js'; -import { StructuredDiff } from '../StructuredDiff.js'; +import type { StructuredPatchHunk } from 'diff' +import { resolve } from 'path' +import React, { useMemo } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Text } from '../../ink.js' +import { getCwd } from '../../utils/cwd.js' +import { readFileSafe } from '../../utils/file.js' +import { Divider } from '../design-system/Divider.js' +import { StructuredDiff } from '../StructuredDiff.js' + type Props = { - filePath: string; - hunks: StructuredPatchHunk[]; - isLargeFile?: boolean; - isBinary?: boolean; - isTruncated?: boolean; - isUntracked?: boolean; -}; + filePath: string + hunks: StructuredPatchHunk[] + isLargeFile?: boolean + isBinary?: boolean + isTruncated?: boolean + isUntracked?: boolean +} /** * Displays the diff content for a single file. * Uses StructuredDiff for word-level diffing and syntax highlighting. * No scrolling - renders all lines (max 400 due to parsing limits). */ -export function DiffDetailView(t0) { - const $ = _c(53); - const { - filePath, - hunks, - isLargeFile, - isBinary, - isTruncated, - isUntracked - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - bb0: { +export function DiffDetailView({ + filePath, + hunks, + isLargeFile, + isBinary, + isTruncated, + isUntracked, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + + // Read file content for syntax detection and multiline construct handling. + // Only computed when this component is rendered (detail view mode). + const { firstLine, fileContent } = useMemo(() => { if (!filePath) { - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - firstLine: null, - fileContent: undefined - }; - $[0] = t2; - } else { - t2 = $[0]; - } - t1 = t2; - break bb0; + return { firstLine: null, fileContent: undefined } } - let content; - let t2; - if ($[1] !== filePath) { - const fullPath = resolve(getCwd(), filePath); - content = readFileSafe(fullPath); - t2 = content?.split("\n")[0] ?? null; - $[1] = filePath; - $[2] = content; - $[3] = t2; - } else { - content = $[2]; - t2 = $[3]; + const fullPath = resolve(getCwd(), filePath) + const content = readFileSafe(fullPath) + return { + firstLine: content?.split('\n')[0] ?? null, + fileContent: content ?? undefined, } - const t3 = content ?? undefined; - let t4; - if ($[4] !== t2 || $[5] !== t3) { - t4 = { - firstLine: t2, - fileContent: t3 - }; - $[4] = t2; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - t1 = t4; - } - const { - firstLine, - fileContent - } = t1; + }, [filePath]) + + // Handle untracked files if (isUntracked) { - let t2; - if ($[7] !== filePath) { - t2 = {filePath}; - $[7] = filePath; - $[8] = t2; - } else { - t2 = $[8]; - } - let t3; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t3 = (untracked); - $[9] = t3; - } else { - t3 = $[9]; - } - let t4; - if ($[10] !== t2) { - t4 = {t2}{t3}; - $[10] = t2; - $[11] = t4; - } else { - t4 = $[11]; - } - let t5; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = New file not yet staged.; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== filePath) { - t7 = {t6}Run `git add {filePath}` to see line counts.; - $[14] = filePath; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t4 || $[17] !== t7) { - t8 = {t4}{t5}{t7}; - $[16] = t4; - $[17] = t7; - $[18] = t8; - } else { - t8 = $[18]; - } - return t8; + return ( + + + {filePath} + (untracked) + + + + + New file not yet staged. + + + Run `git add {filePath}` to see line counts. + + + + ) } + + // Handle binary files if (isBinary) { - let t2; - if ($[19] !== filePath) { - t2 = {filePath}; - $[19] = filePath; - $[20] = t2; - } else { - t2 = $[20]; - } - let t3; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[21] = t3; - } else { - t3 = $[21]; - } - let t4; - if ($[22] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Binary file - cannot display diff; - $[22] = t4; - } else { - t4 = $[22]; - } - let t5; - if ($[23] !== t2) { - t5 = {t2}{t3}{t4}; - $[23] = t2; - $[24] = t5; - } else { - t5 = $[24]; - } - return t5; + return ( + + + {filePath} + + + + + Binary file - cannot display diff + + + + ) } + + // Handle large files if (isLargeFile) { - let t2; - if ($[25] !== filePath) { - t2 = {filePath}; - $[25] = filePath; - $[26] = t2; - } else { - t2 = $[26]; - } - let t3; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[27] = t3; - } else { - t3 = $[27]; - } - let t4; - if ($[28] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Large file - diff exceeds 1 MB limit; - $[28] = t4; - } else { - t4 = $[28]; - } - let t5; - if ($[29] !== t2) { - t5 = {t2}{t3}{t4}; - $[29] = t2; - $[30] = t5; - } else { - t5 = $[30]; - } - return t5; - } - let t2; - if ($[31] !== filePath) { - t2 = {filePath}; - $[31] = filePath; - $[32] = t2; - } else { - t2 = $[32]; - } - let t3; - if ($[33] !== isTruncated) { - t3 = isTruncated && (truncated); - $[33] = isTruncated; - $[34] = t3; - } else { - t3 = $[34]; - } - let t4; - if ($[35] !== t2 || $[36] !== t3) { - t4 = {t2}{t3}; - $[35] = t2; - $[36] = t3; - $[37] = t4; - } else { - t4 = $[37]; - } - let t5; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[38] = t5; - } else { - t5 = $[38]; + return ( + + + {filePath} + + + + + Large file - diff exceeds 1 MB limit + + + + ) } - let t6; - if ($[39] !== columns || $[40] !== fileContent || $[41] !== filePath || $[42] !== firstLine || $[43] !== hunks) { - t6 = hunks.length === 0 ? No diff content : hunks.map((hunk, index) => ); - $[39] = columns; - $[40] = fileContent; - $[41] = filePath; - $[42] = firstLine; - $[43] = hunks; - $[44] = t6; - } else { - t6 = $[44]; - } - let t7; - if ($[45] !== t6) { - t7 = {t6}; - $[45] = t6; - $[46] = t7; - } else { - t7 = $[46]; - } - let t8; - if ($[47] !== isTruncated) { - t8 = isTruncated && … diff truncated (exceeded 400 line limit); - $[47] = isTruncated; - $[48] = t8; - } else { - t8 = $[48]; - } - let t9; - if ($[49] !== t4 || $[50] !== t7 || $[51] !== t8) { - t9 = {t4}{t5}{t7}{t8}; - $[49] = t4; - $[50] = t7; - $[51] = t8; - $[52] = t9; - } else { - t9 = $[52]; - } - return t9; + + const outerPaddingX = 1 + const outerBorderWidth = 1 + + return ( + + + {filePath} + {isTruncated && (truncated)} + + + + + {hunks.length === 0 ? ( + No diff content + ) : ( + hunks.map((hunk, index) => ( + + )) + )} + + + {isTruncated && ( + + … diff truncated (exceeded 400 line limit) + + )} + + ) } diff --git a/src/components/diff/DiffDialog.tsx b/src/components/diff/DiffDialog.tsx index f8adf42fc..8847a25fc 100644 --- a/src/components/diff/DiffDialog.tsx +++ b/src/components/diff/DiffDialog.tsx @@ -1,382 +1,289 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import { type DiffData, useDiffData } from '../../hooks/useDiffData.js'; -import { type TurnDiff, useTurnDiffs } from '../../hooks/useTurnDiffs.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import type { Message } from '../../types/message.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { DiffDetailView } from './DiffDetailView.js'; -import { DiffFileList } from './DiffFileList.js'; +import type { StructuredPatchHunk } from 'diff' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import { type DiffData, useDiffData } from '../../hooks/useDiffData.js' +import { type TurnDiff, useTurnDiffs } from '../../hooks/useTurnDiffs.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import type { Message } from '../../types/message.js' +import { plural } from '../../utils/stringUtils.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { DiffDetailView } from './DiffDetailView.js' +import { DiffFileList } from './DiffFileList.js' + type Props = { - messages: Message[]; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -type ViewMode = 'list' | 'detail'; -type DiffSource = { - type: 'current'; -} | { - type: 'turn'; - turn: TurnDiff; -}; + messages: Message[] + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type ViewMode = 'list' | 'detail' + +type DiffSource = { type: 'current' } | { type: 'turn'; turn: TurnDiff } + function turnDiffToDiffData(turn: TurnDiff): DiffData { - const files = Array.from(turn.files.values()).map(f => ({ - path: f.filePath, - linesAdded: f.linesAdded, - linesRemoved: f.linesRemoved, - isBinary: false, - isLargeFile: false, - isTruncated: false, - isNewFile: f.isNewFile - })).sort((a, b) => a.path.localeCompare(b.path)); - const hunks = new Map(); + const files = Array.from(turn.files.values()) + .map(f => ({ + path: f.filePath, + linesAdded: f.linesAdded, + linesRemoved: f.linesRemoved, + isBinary: false, + isLargeFile: false, + isTruncated: false, + isNewFile: f.isNewFile, + })) + .sort((a, b) => a.path.localeCompare(b.path)) + + const hunks = new Map() for (const f of turn.files.values()) { - hunks.set(f.filePath, f.hunks); + hunks.set(f.filePath, f.hunks) } + return { stats: { filesCount: turn.stats.filesChanged, linesAdded: turn.stats.linesAdded, - linesRemoved: turn.stats.linesRemoved + linesRemoved: turn.stats.linesRemoved, }, files, hunks, - loading: false - }; -} -export function DiffDialog(t0) { - const $ = _c(73); - const { - messages, - onDone - } = t0; - const gitDiffData = useDiffData(); - const turnDiffs = useTurnDiffs(messages); - const [viewMode, setViewMode] = useState("list"); - const [selectedIndex, setSelectedIndex] = useState(0); - const [sourceIndex, setSourceIndex] = useState(0); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - type: "current" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== turnDiffs) { - t2 = [t1, ...turnDiffs.map(_temp)]; - $[1] = turnDiffs; - $[2] = t2; - } else { - t2 = $[2]; + loading: false, } - const sources = t2; - const currentSource = sources[sourceIndex]; - const currentTurn = currentSource?.type === "turn" ? currentSource.turn : null; - let t3; - if ($[3] !== currentTurn || $[4] !== gitDiffData) { - t3 = currentTurn ? turnDiffToDiffData(currentTurn) : gitDiffData; - $[3] = currentTurn; - $[4] = gitDiffData; - $[5] = t3; - } else { - t3 = $[5]; - } - const diffData = t3; - const selectedFile = diffData.files[selectedIndex]; - let t4; - if ($[6] !== diffData.hunks || $[7] !== selectedFile) { - t4 = selectedFile ? diffData.hunks.get(selectedFile.path) || [] : []; - $[6] = diffData.hunks; - $[7] = selectedFile; - $[8] = t4; - } else { - t4 = $[8]; - } - const selectedHunks = t4; - let t5; - let t6; - if ($[9] !== sourceIndex || $[10] !== sources.length) { - t5 = () => { - if (sourceIndex >= sources.length) { - setSourceIndex(Math.max(0, sources.length - 1)); - } - }; - t6 = [sources.length, sourceIndex]; - $[9] = sourceIndex; - $[10] = sources.length; - $[11] = t5; - $[12] = t6; - } else { - t5 = $[11]; - t6 = $[12]; - } - useEffect(t5, t6); - const prevSourceIndex = useRef(sourceIndex); - let t7; - let t8; - if ($[13] !== sourceIndex) { - t7 = () => { - if (prevSourceIndex.current !== sourceIndex) { - setSelectedIndex(0); - prevSourceIndex.current = sourceIndex; - } - }; - t8 = [sourceIndex]; - $[13] = sourceIndex; - $[14] = t7; - $[15] = t8; - } else { - t7 = $[14]; - t8 = $[15]; - } - useEffect(t7, t8); - useRegisterOverlay("diff-dialog", undefined); - let t10; - let t9; - if ($[16] !== sources.length || $[17] !== viewMode) { - t9 = () => { - if (viewMode === "detail") { - setViewMode("list"); - } else { - if (viewMode === "list" && sources.length > 1) { - setSourceIndex(_temp2); +} + +export function DiffDialog({ messages, onDone }: Props): React.ReactNode { + const gitDiffData = useDiffData() + const turnDiffs = useTurnDiffs(messages) + + const [viewMode, setViewMode] = useState('list') + const [selectedIndex, setSelectedIndex] = useState(0) + const [sourceIndex, setSourceIndex] = useState(0) + + const sources: DiffSource[] = useMemo( + () => [ + { type: 'current' }, + ...turnDiffs.map((turn): DiffSource => ({ type: 'turn', turn })), + ], + [turnDiffs], + ) + + const currentSource = sources[sourceIndex] + const currentTurn = currentSource?.type === 'turn' ? currentSource.turn : null + + const diffData = useMemo((): DiffData => { + return currentTurn ? turnDiffToDiffData(currentTurn) : gitDiffData + }, [currentTurn, gitDiffData]) + + const selectedFile = diffData.files[selectedIndex] + const selectedHunks = useMemo(() => { + return selectedFile ? diffData.hunks.get(selectedFile.path) || [] : [] + }, [selectedFile, diffData.hunks]) + + // Clamp sourceIndex when sources shrink (e.g., conversation rewind) + useEffect(() => { + if (sourceIndex >= sources.length) { + setSourceIndex(Math.max(0, sources.length - 1)) + } + }, [sources.length, sourceIndex]) + + // Reset file selection when source changes + const prevSourceIndex = useRef(sourceIndex) + useEffect(() => { + if (prevSourceIndex.current !== sourceIndex) { + setSelectedIndex(0) + prevSourceIndex.current = sourceIndex + } + }, [sourceIndex]) + + // Register as modal overlay so Chat keybindings and CancelRequestHandler + // are disabled while DiffDialog is showing + useRegisterOverlay('diff-dialog') + + // Diff dialog navigation keybindings + // View-mode dependent: left/right arrows have different behavior based on mode + // (source tab switching vs back navigation), and up/down/enter are + // context-sensitive to viewMode + // + // Note: Escape handling (diff:dismiss) is NOT registered here because Dialog's + // built-in useKeybinding('confirm:no', handleCancel) already handles it. + // Having both would be dead code since Dialog's child effect registers first + // and calls stopImmediatePropagation(). The diff:dismiss binding in + // defaultBindings.ts is kept for useShortcutDisplay to show the "esc close" hint. + useKeybindings( + { + // Left arrow: in detail mode goes back, in list mode switches source + 'diff:previousSource': () => { + if (viewMode === 'detail') { + setViewMode('list') + } else if (viewMode === 'list' && sources.length > 1) { + setSourceIndex(prev => Math.max(0, prev - 1)) } - } - }; - t10 = () => { - if (viewMode === "list" && sources.length > 1) { - setSourceIndex(prev_0 => Math.min(sources.length - 1, prev_0 + 1)); - } - }; - $[16] = sources.length; - $[17] = viewMode; - $[18] = t10; - $[19] = t9; - } else { - t10 = $[18]; - t9 = $[19]; - } - let t11; - if ($[20] !== viewMode) { - t11 = () => { - if (viewMode === "detail") { - setViewMode("list"); - } - }; - $[20] = viewMode; - $[21] = t11; - } else { - t11 = $[21]; - } - let t12; - if ($[22] !== selectedFile || $[23] !== viewMode) { - t12 = () => { - if (viewMode === "list" && selectedFile) { - setViewMode("detail"); - } - }; - $[22] = selectedFile; - $[23] = viewMode; - $[24] = t12; - } else { - t12 = $[24]; - } - let t13; - if ($[25] !== viewMode) { - t13 = () => { - if (viewMode === "list") { - setSelectedIndex(_temp3); - } - }; - $[25] = viewMode; - $[26] = t13; - } else { - t13 = $[26]; - } - let t14; - if ($[27] !== diffData.files.length || $[28] !== viewMode) { - t14 = () => { - if (viewMode === "list") { - setSelectedIndex(prev_2 => Math.min(diffData.files.length - 1, prev_2 + 1)); - } - }; - $[27] = diffData.files.length; - $[28] = viewMode; - $[29] = t14; - } else { - t14 = $[29]; - } - let t15; - if ($[30] !== t10 || $[31] !== t11 || $[32] !== t12 || $[33] !== t13 || $[34] !== t14 || $[35] !== t9) { - t15 = { - "diff:previousSource": t9, - "diff:nextSource": t10, - "diff:back": t11, - "diff:viewDetails": t12, - "diff:previousFile": t13, - "diff:nextFile": t14 - }; - $[30] = t10; - $[31] = t11; - $[32] = t12; - $[33] = t13; - $[34] = t14; - $[35] = t9; - $[36] = t15; - } else { - t15 = $[36]; - } - let t16; - if ($[37] === Symbol.for("react.memo_cache_sentinel")) { - t16 = { - context: "DiffDialog" - }; - $[37] = t16; - } else { - t16 = $[37]; - } - useKeybindings(t15, t16); - let t17; - if ($[38] !== diffData.stats) { - t17 = diffData.stats ? {diffData.stats.filesCount} {plural(diffData.stats.filesCount, "file")}{" "}changed{diffData.stats.linesAdded > 0 && +{diffData.stats.linesAdded}}{diffData.stats.linesRemoved > 0 && -{diffData.stats.linesRemoved}} : null; - $[38] = diffData.stats; - $[39] = t17; - } else { - t17 = $[39]; - } - const subtitle = t17; - const headerTitle = currentTurn ? `Turn ${currentTurn.turnIndex}` : "Uncommitted changes"; - const headerSubtitle = currentTurn ? currentTurn.userPromptPreview ? `"${currentTurn.userPromptPreview}"` : "" : "(git diff HEAD)"; - let t18; - if ($[40] !== sourceIndex || $[41] !== sources) { - t18 = sources.length > 1 ? {sourceIndex > 0 && }{sources.map((source, i) => { - const isSelected = i === sourceIndex; - const label = source.type === "current" ? "Current" : `T${source.turn.turnIndex}`; - return {i > 0 ? " \xB7 " : ""}{label}; - })}{sourceIndex < sources.length - 1 && } : null; - $[40] = sourceIndex; - $[41] = sources; - $[42] = t18; - } else { - t18 = $[42]; - } - const sourceSelector = t18; - const dismissShortcut = useShortcutDisplay("diff:dismiss", "DiffDialog", "esc"); - let t19; - bb0: { + }, + 'diff:nextSource': () => { + if (viewMode === 'list' && sources.length > 1) { + setSourceIndex(prev => Math.min(sources.length - 1, prev + 1)) + } + }, + 'diff:back': () => { + if (viewMode === 'detail') { + setViewMode('list') + } + }, + 'diff:viewDetails': () => { + if (viewMode === 'list' && selectedFile) { + setViewMode('detail') + } + }, + 'diff:previousFile': () => { + if (viewMode === 'list') { + setSelectedIndex(prev => Math.max(0, prev - 1)) + } + }, + 'diff:nextFile': () => { + if (viewMode === 'list') { + setSelectedIndex(prev => + Math.min(diffData.files.length - 1, prev + 1), + ) + } + }, + }, + { context: 'DiffDialog' }, + ) + + const subtitle = diffData.stats ? ( + + {diffData.stats.filesCount} {plural(diffData.stats.filesCount, 'file')}{' '} + changed + {diffData.stats.linesAdded > 0 && ( + +{diffData.stats.linesAdded} + )} + {diffData.stats.linesRemoved > 0 && ( + -{diffData.stats.linesRemoved} + )} + + ) : null + + // Build header based on current source + const headerTitle = currentTurn + ? `Turn ${currentTurn.turnIndex}` + : 'Uncommitted changes' + const headerSubtitle = currentTurn + ? currentTurn.userPromptPreview + ? `"${currentTurn.userPromptPreview}"` + : '' + : '(git diff HEAD)' + + // Source selector pills + const sourceSelector = + sources.length > 1 ? ( + + {sourceIndex > 0 && } + {sources.map((source, i) => { + const isSelected = i === sourceIndex + const label = + source.type === 'current' ? 'Current' : `T${source.turn.turnIndex}` + return ( + + {i > 0 ? ' · ' : ''} + {label} + + ) + })} + {sourceIndex < sources.length - 1 && } + + ) : null + + const dismissShortcut = useShortcutDisplay( + 'diff:dismiss', + 'DiffDialog', + 'esc', + ) + // Determine the appropriate message when no files are shown + const emptyMessage = (() => { if (diffData.loading) { - t19 = "Loading diff\u2026"; - break bb0; + return 'Loading diff…' } if (currentTurn) { - t19 = "No file changes in this turn"; - break bb0; + return 'No file changes in this turn' } - if (diffData.stats && diffData.stats.filesCount > 0 && diffData.files.length === 0) { - t19 = "Too many files to display details"; - break bb0; + // Check if we have stats but no files (too many files case) + if ( + diffData.stats && + diffData.stats.filesCount > 0 && + diffData.files.length === 0 + ) { + return 'Too many files to display details' + } + return 'Working tree is clean' + })() + + // Build title with header subtitle inline + const title = ( + + {headerTitle} + {headerSubtitle && {headerSubtitle}} + + ) + + // Handle cancel/dismiss - in detail mode goes back, in list mode dismisses + function handleCancel(): void { + if (viewMode === 'detail') { + setViewMode('list') + } else { + onDone('Diff dialog dismissed', { display: 'system' }) } - t19 = "Working tree is clean"; - } - const emptyMessage = t19; - let t20; - if ($[43] !== headerSubtitle) { - t20 = headerSubtitle && {headerSubtitle}; - $[43] = headerSubtitle; - $[44] = t20; - } else { - t20 = $[44]; - } - let t21; - if ($[45] !== headerTitle || $[46] !== t20) { - t21 = {headerTitle}{t20}; - $[45] = headerTitle; - $[46] = t20; - $[47] = t21; - } else { - t21 = $[47]; } - const title = t21; - let t22; - if ($[48] !== onDone || $[49] !== viewMode) { - t22 = function handleCancel() { - if (viewMode === "detail") { - setViewMode("list"); - } else { - onDone("Diff dialog dismissed", { - display: "system" - }); + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : viewMode === 'list' ? ( + + {sources.length > 1 && ←/→ source} + ↑/↓ select + Enter view + {dismissShortcut} close + + ) : ( + + ← back + {dismissShortcut} close + + ) } - }; - $[48] = onDone; - $[49] = viewMode; - $[50] = t22; - } else { - t22 = $[50]; - } - const handleCancel = t22; - let t23; - if ($[51] !== dismissShortcut || $[52] !== sources.length || $[53] !== viewMode) { - t23 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : viewMode === "list" ? {sources.length > 1 && ←/→ source}↑/↓ selectEnter view{dismissShortcut} close : ← back{dismissShortcut} close; - $[51] = dismissShortcut; - $[52] = sources.length; - $[53] = viewMode; - $[54] = t23; - } else { - t23 = $[54]; - } - let t24; - if ($[55] !== diffData.files || $[56] !== emptyMessage || $[57] !== selectedFile?.isBinary || $[58] !== selectedFile?.isLargeFile || $[59] !== selectedFile?.isTruncated || $[60] !== selectedFile?.isUntracked || $[61] !== selectedFile?.path || $[62] !== selectedHunks || $[63] !== selectedIndex || $[64] !== viewMode) { - t24 = diffData.files.length === 0 ? {emptyMessage} : viewMode === "list" ? : ; - $[55] = diffData.files; - $[56] = emptyMessage; - $[57] = selectedFile?.isBinary; - $[58] = selectedFile?.isLargeFile; - $[59] = selectedFile?.isTruncated; - $[60] = selectedFile?.isUntracked; - $[61] = selectedFile?.path; - $[62] = selectedHunks; - $[63] = selectedIndex; - $[64] = viewMode; - $[65] = t24; - } else { - t24 = $[65]; - } - let t25; - if ($[66] !== handleCancel || $[67] !== sourceSelector || $[68] !== subtitle || $[69] !== t23 || $[70] !== t24 || $[71] !== title) { - t25 = {sourceSelector}{subtitle}{t24}; - $[66] = handleCancel; - $[67] = sourceSelector; - $[68] = subtitle; - $[69] = t23; - $[70] = t24; - $[71] = title; - $[72] = t25; - } else { - t25 = $[72]; - } - return t25; -} -function _temp3(prev_1) { - return Math.max(0, prev_1 - 1); -} -function _temp2(prev) { - return Math.max(0, prev - 1); -} -function _temp(turn) { - return { - type: "turn", - turn - }; + > + {sourceSelector} + {subtitle} + {diffData.files.length === 0 ? ( + + {emptyMessage} + + ) : viewMode === 'list' ? ( + + + + ) : ( + + + + )} + + ) } diff --git a/src/components/diff/DiffFileList.tsx b/src/components/diff/DiffFileList.tsx index f3564f763..76c3040aa 100644 --- a/src/components/diff/DiffFileList.tsx +++ b/src/components/diff/DiffFileList.tsx @@ -1,291 +1,153 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useMemo } from 'react'; -import type { DiffFile } from '../../hooks/useDiffData.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Box, Text } from '../../ink.js'; -import { truncateStartToWidth } from '../../utils/format.js'; -import { plural } from '../../utils/stringUtils.js'; -const MAX_VISIBLE_FILES = 5; +import figures from 'figures' +import React, { useMemo } from 'react' +import type { DiffFile } from '../../hooks/useDiffData.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Text } from '../../ink.js' +import { truncateStartToWidth } from '../../utils/format.js' +import { plural } from '../../utils/stringUtils.js' + +const MAX_VISIBLE_FILES = 5 + type Props = { - files: DiffFile[]; - selectedIndex: number; -}; -export function DiffFileList(t0) { - const $ = _c(36); - const { - files, - selectedIndex - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - bb0: { + files: DiffFile[] + selectedIndex: number +} + +export function DiffFileList({ files, selectedIndex }: Props): React.ReactNode { + const { columns } = useTerminalSize() + + // Calculate scroll window - must be before early return for hooks rules + const { startIndex, endIndex } = useMemo(() => { if (files.length === 0 || files.length <= MAX_VISIBLE_FILES) { - let t2; - if ($[0] !== files.length) { - t2 = { - startIndex: 0, - endIndex: files.length - }; - $[0] = files.length; - $[1] = t2; - } else { - t2 = $[1]; - } - t1 = t2; - break bb0; + return { startIndex: 0, endIndex: files.length } } - let start = Math.max(0, selectedIndex - Math.floor(MAX_VISIBLE_FILES / 2)); - let end = start + MAX_VISIBLE_FILES; + + // Keep selected item roughly in the middle + let start = Math.max(0, selectedIndex - Math.floor(MAX_VISIBLE_FILES / 2)) + let end = start + MAX_VISIBLE_FILES + + // Adjust if we're at the end if (end > files.length) { - end = files.length; - start = Math.max(0, end - MAX_VISIBLE_FILES); - } - let t2; - if ($[2] !== end || $[3] !== start) { - t2 = { - startIndex: start, - endIndex: end - }; - $[2] = end; - $[3] = start; - $[4] = t2; - } else { - t2 = $[4]; + end = files.length + start = Math.max(0, end - MAX_VISIBLE_FILES) } - t1 = t2; - } - const { - startIndex, - endIndex - } = t1; + + return { startIndex: start, endIndex: end } + }, [files.length, selectedIndex]) + if (files.length === 0) { - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = No changed files; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; - } - let T0; - let hasMoreBelow; - let needsPagination; - let t2; - let t3; - let t4; - if ($[6] !== columns || $[7] !== endIndex || $[8] !== files || $[9] !== selectedIndex || $[10] !== startIndex) { - const visibleFiles = files.slice(startIndex, endIndex); - const hasMoreAbove = startIndex > 0; - hasMoreBelow = endIndex < files.length; - needsPagination = files.length > MAX_VISIBLE_FILES; - const maxPathWidth = Math.max(20, columns - 16 - 3 - 4); - T0 = Box; - t2 = "column"; - if ($[17] !== hasMoreAbove || $[18] !== needsPagination || $[19] !== startIndex) { - t3 = needsPagination && {hasMoreAbove ? ` ↑ ${startIndex} more ${plural(startIndex, "file")}` : " "}; - $[17] = hasMoreAbove; - $[18] = needsPagination; - $[19] = startIndex; - $[20] = t3; - } else { - t3 = $[20]; - } - let t5; - if ($[21] !== maxPathWidth || $[22] !== selectedIndex || $[23] !== startIndex) { - t5 = (file, index) => ; - $[21] = maxPathWidth; - $[22] = selectedIndex; - $[23] = startIndex; - $[24] = t5; - } else { - t5 = $[24]; - } - t4 = visibleFiles.map(t5); - $[6] = columns; - $[7] = endIndex; - $[8] = files; - $[9] = selectedIndex; - $[10] = startIndex; - $[11] = T0; - $[12] = hasMoreBelow; - $[13] = needsPagination; - $[14] = t2; - $[15] = t3; - $[16] = t4; - } else { - T0 = $[11]; - hasMoreBelow = $[12]; - needsPagination = $[13]; - t2 = $[14]; - t3 = $[15]; - t4 = $[16]; - } - let t5; - if ($[25] !== endIndex || $[26] !== files.length || $[27] !== hasMoreBelow || $[28] !== needsPagination) { - t5 = needsPagination && {hasMoreBelow ? ` ↓ ${files.length - endIndex} more ${plural(files.length - endIndex, "file")}` : " "}; - $[25] = endIndex; - $[26] = files.length; - $[27] = hasMoreBelow; - $[28] = needsPagination; - $[29] = t5; - } else { - t5 = $[29]; - } - let t6; - if ($[30] !== T0 || $[31] !== t2 || $[32] !== t3 || $[33] !== t4 || $[34] !== t5) { - t6 = {t3}{t4}{t5}; - $[30] = T0; - $[31] = t2; - $[32] = t3; - $[33] = t4; - $[34] = t5; - $[35] = t6; - } else { - t6 = $[35]; - } - return t6; + return No changed files + } + + const visibleFiles = files.slice(startIndex, endIndex) + const hasMoreAbove = startIndex > 0 + const hasMoreBelow = endIndex < files.length + const needsPagination = files.length > MAX_VISIBLE_FILES + + const statsWidth = 16 + const pointerWidth = 3 + const maxPathWidth = Math.max(20, columns - statsWidth - pointerWidth - 4) + + return ( + + {needsPagination && ( + + {hasMoreAbove + ? ` ↑ ${startIndex} more ${plural(startIndex, 'file')}` + : ' '} + + )} + {visibleFiles.map((file, index) => ( + + ))} + {needsPagination && ( + + {hasMoreBelow + ? ` ↓ ${files.length - endIndex} more ${plural(files.length - endIndex, 'file')}` + : ' '} + + )} + + ) } -function FileItem(t0) { - const $ = _c(14); - const { - file, - isSelected, - maxPathWidth - } = t0; - let t1; - if ($[0] !== file.path || $[1] !== maxPathWidth) { - t1 = truncateStartToWidth(file.path, maxPathWidth); - $[0] = file.path; - $[1] = maxPathWidth; - $[2] = t1; - } else { - t1 = $[2]; - } - const displayPath = t1; - const pointer = isSelected ? figures.pointer + " " : " "; - const line = `${pointer}${displayPath}`; - const t2 = isSelected ? "background" : undefined; - let t3; - if ($[3] !== isSelected || $[4] !== line || $[5] !== t2) { - t3 = {line}; - $[3] = isSelected; - $[4] = line; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== file || $[9] !== isSelected) { - t5 = ; - $[8] = file; - $[9] = isSelected; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t3 || $[12] !== t5) { - t6 = {t3}{t4}{t5}; - $[11] = t3; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; + +function FileItem({ + file, + isSelected, + maxPathWidth, +}: { + file: DiffFile + isSelected: boolean + maxPathWidth: number +}): React.ReactNode { + const displayPath = truncateStartToWidth(file.path, maxPathWidth) + + const pointer = isSelected ? figures.pointer + ' ' : ' ' + const line = `${pointer}${displayPath}` + + return ( + + + {line} + + + + + ) } -function FileStats(t0) { - const $ = _c(20); - const { - file, - isSelected - } = t0; + +function FileStats({ + file, + isSelected, +}: { + file: DiffFile + isSelected: boolean +}): React.ReactNode { if (file.isUntracked) { - const t1 = !isSelected; - let t2; - if ($[0] !== t1) { - t2 = untracked; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - return t2; + return ( + + untracked + + ) } if (file.isBinary) { - const t1 = !isSelected; - let t2; - if ($[2] !== t1) { - t2 = Binary file; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; + return ( + + Binary file + + ) } if (file.isLargeFile) { - const t1 = !isSelected; - let t2; - if ($[4] !== t1) { - t2 = Large file modified; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; - } - let t1; - if ($[6] !== file.linesAdded || $[7] !== isSelected) { - t1 = file.linesAdded > 0 && +{file.linesAdded}; - $[6] = file.linesAdded; - $[7] = isSelected; - $[8] = t1; - } else { - t1 = $[8]; - } - const t2 = file.linesAdded > 0 && file.linesRemoved > 0 && " "; - let t3; - if ($[9] !== file.linesRemoved || $[10] !== isSelected) { - t3 = file.linesRemoved > 0 && -{file.linesRemoved}; - $[9] = file.linesRemoved; - $[10] = isSelected; - $[11] = t3; - } else { - t3 = $[11]; - } - let t4; - if ($[12] !== file.isTruncated || $[13] !== isSelected) { - t4 = file.isTruncated && (truncated); - $[12] = file.isTruncated; - $[13] = isSelected; - $[14] = t4; - } else { - t4 = $[14]; - } - let t5; - if ($[15] !== t1 || $[16] !== t2 || $[17] !== t3 || $[18] !== t4) { - t5 = {t1}{t2}{t3}{t4}; - $[15] = t1; - $[16] = t2; - $[17] = t3; - $[18] = t4; - $[19] = t5; - } else { - t5 = $[19]; - } - return t5; + return ( + + Large file modified + + ) + } + // Normal or truncated file - show line counts + return ( + + {file.linesAdded > 0 && ( + + +{file.linesAdded} + + )} + {file.linesAdded > 0 && file.linesRemoved > 0 && ' '} + {file.linesRemoved > 0 && ( + + -{file.linesRemoved} + + )} + {file.isTruncated && (truncated)} + + ) } diff --git a/src/components/grove/Grove.tsx b/src/components/grove/Grove.tsx index 02993428e..0998fcf05 100644 --- a/src/components/grove/Grove.tsx +++ b/src/components/grove/Grove.tsx @@ -1,18 +1,36 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { Box, Link, Text, useInput } from '../../ink.js'; -import { type AccountSettings, calculateShouldShowGrove, type GroveConfig, getGroveNoticeConfig, getGroveSettings, markGroveNoticeViewed, updateGroveSettings } from '../../services/api/grove.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -export type GroveDecision = 'accept_opt_in' | 'accept_opt_out' | 'defer' | 'escape' | 'skip_rendering'; +import React, { useEffect, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { Box, Link, Text, useInput } from '../../ink.js' +import { + type AccountSettings, + calculateShouldShowGrove, + type GroveConfig, + getGroveNoticeConfig, + getGroveSettings, + markGroveNoticeViewed, + updateGroveSettings, +} from '../../services/api/grove.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + +export type GroveDecision = + | 'accept_opt_in' + | 'accept_opt_out' + | 'defer' + | 'escape' + | 'skip_rendering' + type Props = { - showIfAlreadyViewed: boolean; - location: 'settings' | 'policy_update_modal' | 'onboarding'; - onDone(decision: GroveDecision): void; -}; + showIfAlreadyViewed: boolean + location: 'settings' | 'policy_update_modal' | 'onboarding' + onDone(decision: GroveDecision): void +} + const NEW_TERMS_ASCII = ` _____________ | \\ \\ | NEW TERMS \\__\\ @@ -23,440 +41,335 @@ const NEW_TERMS_ASCII = ` _____________ | ---------- | | ---------- | | | - |______________|`; -function GracePeriodContentBody() { - const $ = _c(9); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = An update to our Consumer Terms and Privacy Policy will take effect on{" "}October 8, 2025. You can accept the updated terms today.; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = What's changing?; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = · ; - t3 = You can help improve Claude ; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {t2}{t3}— Allow the use of your chats and coding sessions to train and improve Anthropic AI models. Change anytime in your Privacy Settings ().; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {t1}{t4}· Updates to data retention — To help us improve our AI models and safety protections, we're extending data retention to 5 years.; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = ; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t7 = ; - $[7] = t7; - } else { - t7 = $[7]; - } - let t8; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t8 = <>{t0}{t5}Learn more ({t6}) or read the updated Consumer Terms ({t7}) and Privacy Policy (); - $[8] = t8; - } else { - t8 = $[8]; - } - return t8; + |______________|` + +function GracePeriodContentBody(): React.ReactNode { + return ( + <> + + An update to our Consumer Terms and Privacy Policy will take effect on{' '} + October 8, 2025. You can accept the updated terms + today. + + + + What's changing? + + + + · + You can help improve Claude + + — Allow the use of your chats and coding sessions to train and + improve Anthropic AI models. Change anytime in your Privacy + Settings ( + + ). + + + + + + · + Updates to data retention + + — To help us improve our AI models and safety protections, + we're extending data retention to 5 years. + + + + + + + Learn more ( + + ) or read the updated Consumer Terms ( + ) and Privacy + Policy () + + + ) } -function PostGracePeriodContentBody() { - const $ = _c(7); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = We've updated our Consumer Terms and Privacy Policy.; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = What's changing?; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Help improve ClaudeAllow the use of your chats and coding sessions to train and improve Anthropic AI models. You can change this anytime in Privacy Settings; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {t1}{t2}How this affects data retentionTurning ON the improve Claude setting extends data retention from 30 days to 5 years. Turning it OFF keeps the default 30-day data retention. Delete data anytime.; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = <>{t0}{t3}Learn more ({t4}) or read the updated Consumer Terms ({t5}) and Privacy Policy (); - $[6] = t6; - } else { - t6 = $[6]; - } - return t6; + +function PostGracePeriodContentBody(): React.ReactNode { + return ( + <> + We've updated our Consumer Terms and Privacy Policy. + + + What's changing? + + + Help improve Claude + + Allow the use of your chats and coding sessions to train and improve + Anthropic AI models. You can change this anytime in Privacy Settings + + + + + + How this affects data retention + + Turning ON the improve Claude setting extends data retention from 30 + days to 5 years. Turning it OFF keeps the default 30-day data + retention. Delete data anytime. + + + + + + Learn more ( + + ) or read the updated Consumer Terms ( + ) and Privacy + Policy () + + + ) } -export function GroveDialog(t0) { - const $ = _c(34); - const { - showIfAlreadyViewed, - location, - onDone - } = t0; - const [shouldShowDialog, setShouldShowDialog] = useState(null); - const [groveConfig, setGroveConfig] = useState(null); - let t1; - let t2; - if ($[0] !== location || $[1] !== onDone || $[2] !== showIfAlreadyViewed) { - t1 = () => { - const checkGroveSettings = async function checkGroveSettings() { - const [settingsResult, configResult] = await Promise.all([getGroveSettings(), getGroveNoticeConfig()]); - const config = configResult.success ? configResult.data : null; - setGroveConfig(config); - const shouldShow = calculateShouldShowGrove(settingsResult, configResult, showIfAlreadyViewed); - setShouldShowDialog(shouldShow); - if (!shouldShow) { - onDone("skip_rendering"); - return; - } - markGroveNoticeViewed(); - logEvent("tengu_grove_policy_viewed", { - location: location as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - dismissable: config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }; - checkGroveSettings(); - }; - t2 = [showIfAlreadyViewed, location, onDone]; - $[0] = location; - $[1] = onDone; - $[2] = showIfAlreadyViewed; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - useEffect(t1, t2); + +export function GroveDialog({ + showIfAlreadyViewed, + location, + onDone, +}: Props): React.ReactNode { + const [shouldShowDialog, setShouldShowDialog] = useState(null) + const [groveConfig, setGroveConfig] = useState(null) + + useEffect(() => { + async function checkGroveSettings() { + const [settingsResult, configResult] = await Promise.all([ + getGroveSettings(), + getGroveNoticeConfig(), + ]) + + // Extract config data if successful, otherwise null + const config = configResult.success ? configResult.data : null + setGroveConfig(config) + + // Determine if we should show the dialog (returns false on API failure) + const shouldShow = calculateShouldShowGrove( + settingsResult, + configResult, + showIfAlreadyViewed, + ) + + setShouldShowDialog(shouldShow) + // If we shouldn't show the dialog, immediately call onDone + if (!shouldShow) { + onDone('skip_rendering') + return + } + // Mark as viewed every time we show the dialog (for reminder frequency tracking) + void markGroveNoticeViewed() + // Log that the Grove policy dialog was shown + logEvent('tengu_grove_policy_viewed', { + location: + location as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + dismissable: + config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + } + + void checkGroveSettings() + }, [showIfAlreadyViewed, location, onDone]) + + // Loading state if (shouldShowDialog === null) { - return null; + return null } + + // User has already set preferences, don't show dialog if (!shouldShowDialog) { - return null; - } - let t3; - if ($[5] !== groveConfig?.notice_is_grace_period || $[6] !== onDone) { - t3 = async function onChange(value) { - bb21: switch (value) { - case "accept_opt_in": - { - await updateGroveSettings(true); - logEvent("tengu_grove_policy_submitted", { - state: true, - dismissable: groveConfig?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - break bb21; - } - case "accept_opt_out": - { - await updateGroveSettings(false); - logEvent("tengu_grove_policy_submitted", { - state: false, - dismissable: groveConfig?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - break bb21; - } - case "defer": - { - logEvent("tengu_grove_policy_dismissed", { - state: true - }); - break bb21; - } - case "escape": - { - logEvent("tengu_grove_policy_escaped", {}); - } + return null + } + + async function onChange( + value: 'accept_opt_in' | 'accept_opt_out' | 'defer' | 'escape', + ) { + switch (value) { + case 'accept_opt_in': { + await updateGroveSettings(true) + logEvent('tengu_grove_policy_submitted', { + state: true, + dismissable: + groveConfig?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + break } - onDone(value); - }; - $[5] = groveConfig?.notice_is_grace_period; - $[6] = onDone; - $[7] = t3; - } else { - t3 = $[7]; - } - const onChange = t3; - let t4; - if ($[8] !== groveConfig?.domain_excluded) { - t4 = groveConfig?.domain_excluded ? [{ - label: "Accept terms \xB7 Help improve Claude: OFF (for emails with your domain)", - value: "accept_opt_out" - }] : [{ - label: "Accept terms \xB7 Help improve Claude: ON", - value: "accept_opt_in" - }, { - label: "Accept terms \xB7 Help improve Claude: OFF", - value: "accept_opt_out" - }]; - $[8] = groveConfig?.domain_excluded; - $[9] = t4; - } else { - t4 = $[9]; - } - const acceptOptions = t4; - let t5; - if ($[10] !== groveConfig?.notice_is_grace_period || $[11] !== onChange) { - t5 = function handleCancel() { - if (groveConfig?.notice_is_grace_period) { - onChange("defer"); - return; + case 'accept_opt_out': { + await updateGroveSettings(false) + logEvent('tengu_grove_policy_submitted', { + state: false, + dismissable: + groveConfig?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + break } - onChange("escape"); - }; - $[10] = groveConfig?.notice_is_grace_period; - $[11] = onChange; - $[12] = t5; - } else { - t5 = $[12]; - } - const handleCancel = t5; - let t6; - if ($[13] !== groveConfig?.notice_is_grace_period) { - t6 = {groveConfig?.notice_is_grace_period ? : }; - $[13] = groveConfig?.notice_is_grace_period; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = {NEW_TERMS_ASCII}; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t6) { - t8 = {t6}{t7}; - $[16] = t6; - $[17] = t8; - } else { - t8 = $[17]; - } - let t9; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t9 = Please select how you'd like to continueYour choice takes effect immediately upon confirmation.; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== groveConfig?.notice_is_grace_period) { - t10 = groveConfig?.notice_is_grace_period ? [{ - label: "Not now", - value: "defer" - }] : []; - $[19] = groveConfig?.notice_is_grace_period; - $[20] = t10; - } else { - t10 = $[20]; - } - let t11; - if ($[21] !== acceptOptions || $[22] !== t10) { - t11 = [...acceptOptions, ...t10]; - $[21] = acceptOptions; - $[22] = t10; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== onChange) { - t12 = value_0 => onChange(value_0 as 'accept_opt_in' | 'accept_opt_out' | 'defer'); - $[24] = onChange; - $[25] = t12; - } else { - t12 = $[25]; - } - let t13; - if ($[26] !== handleCancel || $[27] !== t11 || $[28] !== t12) { - t13 = {t9} + onChange(value as 'accept_opt_in' | 'accept_opt_out' | 'defer') + } + onCancel={handleCancel} + /> + + + ) } + type PrivacySettingsDialogProps = { - settings: AccountSettings; - domainExcluded?: boolean; - onDone(): void; -}; -export function PrivacySettingsDialog(t0) { - const $ = _c(17); - const { - settings, - domainExcluded, - onDone - } = t0; - const [groveEnabled, setGroveEnabled] = useState(settings.grove_enabled); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp2, t1); - let t2; - if ($[1] !== domainExcluded || $[2] !== groveEnabled) { - t2 = async (input, key) => { - if (!domainExcluded && (key.tab || key.return || input === " ")) { - const newValue = !groveEnabled; - setGroveEnabled(newValue); - await updateGroveSettings(newValue); - } - }; - $[1] = domainExcluded; - $[2] = groveEnabled; - $[3] = t2; - } else { - t2 = $[3]; - } - useInput(t2); - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = false; - $[4] = t3; - } else { - t3 = $[4]; - } - let valueComponent = t3; - if (domainExcluded) { - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = false (for emails with your domain); - $[5] = t4; - } else { - t4 = $[5]; + settings: AccountSettings + domainExcluded?: boolean + onDone(): void +} + +export function PrivacySettingsDialog({ + settings, + domainExcluded, + onDone, +}: PrivacySettingsDialogProps): React.ReactNode { + const [groveEnabled, setGroveEnabled] = useState(settings.grove_enabled) + + React.useEffect(() => { + logEvent('tengu_grove_privacy_settings_viewed', {}) + }, []) + + useInput(async (input, key) => { + // Toggle the setting when enter/tab/space is pressed + if (!domainExcluded && (key.tab || key.return || input === ' ')) { + const newValue = !groveEnabled + setGroveEnabled(newValue) + await updateGroveSettings(newValue) } - valueComponent = t4; - } else { - if (groveEnabled) { - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = true; - $[6] = t4; - } else { - t4 = $[6]; + }) + + let valueComponent = false + if (domainExcluded) { + valueComponent = ( + false (for emails with your domain) + ) + } else if (groveEnabled) { + valueComponent = true + } + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : domainExcluded ? ( + + ) : ( + + + + + ) } - valueComponent = t4; - } - } - let t4; - if ($[7] !== domainExcluded) { - t4 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : domainExcluded ? : ; - $[7] = domainExcluded; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Review and manage your privacy settings at{" "}; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Help improve Claude; - $[10] = t6; - } else { - t6 = $[10]; - } - let t7; - if ($[11] !== valueComponent) { - t7 = {t6}{valueComponent}; - $[11] = valueComponent; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== onDone || $[14] !== t4 || $[15] !== t7) { - t8 = {t5}{t7}; - $[13] = onDone; - $[14] = t4; - $[15] = t7; - $[16] = t8; - } else { - t8 = $[16]; - } - return t8; -} -function _temp2() { - logEvent("tengu_grove_privacy_settings_viewed", {}); + > + + Review and manage your privacy settings at{' '} + + + + + + Help improve Claude + + {valueComponent} + + + ) } diff --git a/src/components/hooks/HooksConfigMenu.tsx b/src/components/hooks/HooksConfigMenu.tsx index c94678526..425af2798 100644 --- a/src/components/hooks/HooksConfigMenu.tsx +++ b/src/components/hooks/HooksConfigMenu.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * HooksConfigMenu is a read-only browser for configured hooks. * @@ -11,567 +10,324 @@ import { c as _c } from "react/compiler-runtime"; * command-type hooks and duplicating the settings.json editing surface * in-menu for all four types would be a maintenance burden. */ -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; -import { useAppState, useAppStateStore } from 'src/state/AppState.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useSettingsChange } from '../../hooks/useSettingsChange.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { getHookEventMetadata, getHooksForMatcher, getMatcherMetadata, getSortedMatchersForEvent, groupHooksByEventAndMatcher } from '../../utils/hooks/hooksConfigManager.js'; -import type { IndividualHookConfig } from '../../utils/hooks/hooksSettings.js'; -import { getSettings_DEPRECATED, getSettingsForSource } from '../../utils/settings/settings.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { SelectEventMode } from './SelectEventMode.js'; -import { SelectHookMode } from './SelectHookMode.js'; -import { SelectMatcherMode } from './SelectMatcherMode.js'; -import { ViewHookMode } from './ViewHookMode.js'; +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { useAppState, useAppStateStore } from 'src/state/AppState.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useSettingsChange } from '../../hooks/useSettingsChange.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { + getHookEventMetadata, + getHooksForMatcher, + getMatcherMetadata, + getSortedMatchersForEvent, + groupHooksByEventAndMatcher, +} from '../../utils/hooks/hooksConfigManager.js' +import type { IndividualHookConfig } from '../../utils/hooks/hooksSettings.js' +import { + getSettings_DEPRECATED, + getSettingsForSource, +} from '../../utils/settings/settings.js' +import { plural } from '../../utils/stringUtils.js' +import { Dialog } from '../design-system/Dialog.js' +import { SelectEventMode } from './SelectEventMode.js' +import { SelectHookMode } from './SelectHookMode.js' +import { SelectMatcherMode } from './SelectMatcherMode.js' +import { ViewHookMode } from './ViewHookMode.js' + type Props = { - toolNames: string[]; - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -type ModeState = { - mode: 'select-event'; -} | { - mode: 'select-matcher'; - event: HookEvent; -} | { - mode: 'select-hook'; - event: HookEvent; - matcher: string; -} | { - mode: 'view-hook'; - event: HookEvent; - hook: IndividualHookConfig; -}; -export function HooksConfigMenu(t0) { - const $ = _c(100); - const { - toolNames, - onExit - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - mode: "select-event" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const [modeState, setModeState] = useState(t1); - const [disabledByPolicy, setDisabledByPolicy] = useState(_temp); - const [restrictedByPolicy, setRestrictedByPolicy] = useState(_temp2); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = source => { - if (source === "policySettings") { - const settings_0 = getSettings_DEPRECATED(); - const hooksDisabled_0 = settings_0?.disableAllHooks === true; - setDisabledByPolicy(hooksDisabled_0 && getSettingsForSource("policySettings")?.disableAllHooks === true); - setRestrictedByPolicy(getSettingsForSource("policySettings")?.allowManagedHooksOnly === true); - } - }; - $[1] = t2; - } else { - t2 = $[1]; - } - useSettingsChange(t2); - const mode = modeState.mode; - const selectedEvent = "event" in modeState ? modeState.event : "PreToolUse"; - const selectedMatcher = "matcher" in modeState ? modeState.matcher : null; - const mcp = useAppState(_temp3); - const appStateStore = useAppStateStore(); - let t3; - if ($[2] !== mcp.tools || $[3] !== toolNames) { - t3 = [...toolNames, ...mcp.tools.map(_temp4)]; - $[2] = mcp.tools; - $[3] = toolNames; - $[4] = t3; - } else { - t3 = $[4]; - } - const combinedToolNames = t3; - let t4; - if ($[5] !== appStateStore || $[6] !== combinedToolNames) { - t4 = groupHooksByEventAndMatcher(appStateStore.getState(), combinedToolNames); - $[5] = appStateStore; - $[6] = combinedToolNames; - $[7] = t4; - } else { - t4 = $[7]; - } - const hooksByEventAndMatcher = t4; - let t5; - if ($[8] !== hooksByEventAndMatcher || $[9] !== selectedEvent) { - t5 = getSortedMatchersForEvent(hooksByEventAndMatcher, selectedEvent); - $[8] = hooksByEventAndMatcher; - $[9] = selectedEvent; - $[10] = t5; - } else { - t5 = $[10]; - } - const sortedMatchersForSelectedEvent = t5; - let t6; - if ($[11] !== hooksByEventAndMatcher || $[12] !== selectedEvent || $[13] !== selectedMatcher) { - t6 = getHooksForMatcher(hooksByEventAndMatcher, selectedEvent, selectedMatcher); - $[11] = hooksByEventAndMatcher; - $[12] = selectedEvent; - $[13] = selectedMatcher; - $[14] = t6; - } else { - t6 = $[14]; - } - const hooksForSelectedMatcher = t6; - let t7; - if ($[15] !== onExit) { - t7 = () => { - onExit("Hooks dialog dismissed", { - display: "system" - }); - }; - $[15] = onExit; - $[16] = t7; - } else { - t7 = $[16]; - } - const handleExit = t7; - const t8 = mode === "select-event"; - let t9; - if ($[17] !== t8) { - t9 = { - context: "Confirmation", - isActive: t8 - }; - $[17] = t8; - $[18] = t9; - } else { - t9 = $[18]; - } - useKeybinding("confirm:no", handleExit, t9); - let t10; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t10 = () => { - setModeState({ - mode: "select-event" - }); - }; - $[19] = t10; - } else { - t10 = $[19]; - } - const t11 = mode === "select-matcher"; - let t12; - if ($[20] !== t11) { - t12 = { - context: "Confirmation", - isActive: t11 - }; - $[20] = t11; - $[21] = t12; - } else { - t12 = $[21]; - } - useKeybinding("confirm:no", t10, t12); - let t13; - if ($[22] !== combinedToolNames || $[23] !== modeState) { - t13 = () => { - if ("event" in modeState) { - if (getMatcherMetadata(modeState.event, combinedToolNames) !== undefined) { - setModeState({ - mode: "select-matcher", - event: modeState.event - }); + toolNames: string[] + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type ModeState = + | { mode: 'select-event' } + | { mode: 'select-matcher'; event: HookEvent } + | { mode: 'select-hook'; event: HookEvent; matcher: string } + | { mode: 'view-hook'; event: HookEvent; hook: IndividualHookConfig } + +export function HooksConfigMenu({ toolNames, onExit }: Props): React.ReactNode { + const [modeState, setModeState] = useState({ + mode: 'select-event', + }) + // Cache whether hooks are disabled by policy settings. + // getSettingsForSource() is expensive (file read + JSON parse + validation), + // so we compute it once on mount and only re-compute when policy settings change. + // Short-circuit evaluation ensures we skip the expensive check when hooks aren't disabled. + const [disabledByPolicy, setDisabledByPolicy] = useState(() => { + const settings = getSettings_DEPRECATED() + const hooksDisabled = settings?.disableAllHooks === true + return ( + hooksDisabled && + getSettingsForSource('policySettings')?.disableAllHooks === true + ) + }) + + // Check if hooks are restricted to managed-only by policy + const [restrictedByPolicy, setRestrictedByPolicy] = useState(() => { + return ( + getSettingsForSource('policySettings')?.allowManagedHooksOnly === true + ) + }) + + // Update cached values when policy settings change + useSettingsChange(source => { + if (source === 'policySettings') { + const settings = getSettings_DEPRECATED() + const hooksDisabled = settings?.disableAllHooks === true + setDisabledByPolicy( + hooksDisabled && + getSettingsForSource('policySettings')?.disableAllHooks === true, + ) + setRestrictedByPolicy( + getSettingsForSource('policySettings')?.allowManagedHooksOnly === true, + ) + } + }) + + // Extract commonly used values from modeState for convenience + const mode = modeState.mode + const selectedEvent = 'event' in modeState ? modeState.event : 'PreToolUse' + const selectedMatcher = 'matcher' in modeState ? modeState.matcher : null + + const mcp = useAppState(s => s.mcp) + const appStateStore = useAppStateStore() + const combinedToolNames = useMemo( + () => [...toolNames, ...mcp.tools.map(tool => tool.name)], + [toolNames, mcp.tools], + ) + + const hooksByEventAndMatcher = useMemo( + () => + groupHooksByEventAndMatcher(appStateStore.getState(), combinedToolNames), + [combinedToolNames, appStateStore], + ) + + const sortedMatchersForSelectedEvent = useMemo( + () => getSortedMatchersForEvent(hooksByEventAndMatcher, selectedEvent), + [hooksByEventAndMatcher, selectedEvent], + ) + + const hooksForSelectedMatcher = useMemo( + () => + getHooksForMatcher( + hooksByEventAndMatcher, + selectedEvent, + selectedMatcher, + ), + [hooksByEventAndMatcher, selectedEvent, selectedMatcher], + ) + + // Handler for exiting the dialog + const handleExit = useCallback(() => { + onExit('Hooks dialog dismissed', { display: 'system' }) + }, [onExit]) + + // Escape handling for select-event mode - exit the menu + useKeybinding('confirm:no', handleExit, { + context: 'Confirmation', + isActive: mode === 'select-event', + }) + + // Escape handling for select-matcher mode - go to select-event + useKeybinding( + 'confirm:no', + () => { + setModeState({ mode: 'select-event' }) + }, + { + context: 'Confirmation', + isActive: mode === 'select-matcher', + }, + ) + + // Escape handling for select-hook mode - go to select-matcher or select-event + useKeybinding( + 'confirm:no', + () => { + if ('event' in modeState) { + if ( + getMatcherMetadata(modeState.event, combinedToolNames) !== undefined + ) { + setModeState({ mode: 'select-matcher', event: modeState.event }) } else { - setModeState({ - mode: "select-event" - }); + setModeState({ mode: 'select-event' }) } } - }; - $[22] = combinedToolNames; - $[23] = modeState; - $[24] = t13; - } else { - t13 = $[24]; - } - const t14 = mode === "select-hook"; - let t15; - if ($[25] !== t14) { - t15 = { - context: "Confirmation", - isActive: t14 - }; - $[25] = t14; - $[26] = t15; - } else { - t15 = $[26]; - } - useKeybinding("confirm:no", t13, t15); - let t16; - if ($[27] !== modeState) { - t16 = () => { - if (modeState.mode === "view-hook") { - const { - event, - hook - } = modeState; + }, + { + context: 'Confirmation', + isActive: mode === 'select-hook', + }, + ) + + // Escape handling for view-hook mode - go to select-hook + useKeybinding( + 'confirm:no', + () => { + if (modeState.mode === 'view-hook') { + const { event, hook } = modeState setModeState({ - mode: "select-hook", + mode: 'select-hook', event, - matcher: hook.matcher || "" - }); + matcher: hook.matcher || '', + }) } - }; - $[27] = modeState; - $[28] = t16; - } else { - t16 = $[28]; - } - const t17 = mode === "view-hook"; - let t18; - if ($[29] !== t17) { - t18 = { - context: "Confirmation", - isActive: t17 - }; - $[29] = t17; - $[30] = t18; - } else { - t18 = $[30]; - } - useKeybinding("confirm:no", t16, t18); - let t19; - if ($[31] !== combinedToolNames) { - t19 = getHookEventMetadata(combinedToolNames); - $[31] = combinedToolNames; - $[32] = t19; - } else { - t19 = $[32]; - } - const hookEventMetadata = t19; - const settings_1 = getSettings_DEPRECATED(); - const hooksDisabled_1 = settings_1?.disableAllHooks === true; - let t20; - if ($[33] !== hooksByEventAndMatcher) { - const byEvent = {}; - let total = 0; - for (const [event_0, matchers] of Object.entries(hooksByEventAndMatcher)) { - const eventCount = Object.values(matchers).reduce(_temp5, 0); - byEvent[event_0 as HookEvent] = eventCount; - total = total + eventCount; - } - t20 = { - hooksByEvent: byEvent, - totalHooksCount: total - }; - $[33] = hooksByEventAndMatcher; - $[34] = t20; - } else { - t20 = $[34]; - } - const { - hooksByEvent, - totalHooksCount - } = t20; - if (hooksDisabled_1) { - let t21; - if ($[35] === Symbol.for("react.memo_cache_sentinel")) { - t21 = disabled; - $[35] = t21; - } else { - t21 = $[35]; - } - const t22 = disabledByPolicy && " by a managed settings file"; - let t23; - if ($[36] !== totalHooksCount) { - t23 = {totalHooksCount}; - $[36] = totalHooksCount; - $[37] = t23; - } else { - t23 = $[37]; - } - let t24; - if ($[38] !== totalHooksCount) { - t24 = plural(totalHooksCount, "hook"); - $[38] = totalHooksCount; - $[39] = t24; - } else { - t24 = $[39]; - } - let t25; - if ($[40] !== totalHooksCount) { - t25 = plural(totalHooksCount, "is", "are"); - $[40] = totalHooksCount; - $[41] = t25; - } else { - t25 = $[41]; - } - let t26; - if ($[42] !== t22 || $[43] !== t23 || $[44] !== t24 || $[45] !== t25) { - t26 = All hooks are currently {t21}{t22}. You have{" "}{t23} configured{" "}{t24} that{" "}{t25} not running.; - $[42] = t22; - $[43] = t23; - $[44] = t24; - $[45] = t25; - $[46] = t26; - } else { - t26 = $[46]; + }, + { + context: 'Confirmation', + isActive: mode === 'view-hook', + }, + ) + + const hookEventMetadata = getHookEventMetadata(combinedToolNames) + + // Check if hooks are disabled + const settings = getSettings_DEPRECATED() + const hooksDisabled = settings?.disableAllHooks === true + + // Count hooks per event for the event-selection view, and the total. + const { hooksByEvent, totalHooksCount } = useMemo(() => { + const byEvent: Partial> = {} + let total = 0 + for (const [event, matchers] of Object.entries(hooksByEventAndMatcher)) { + const eventCount = Object.values(matchers).reduce( + (sum, hooks) => sum + hooks.length, + 0, + ) + byEvent[event as HookEvent] = eventCount + total += eventCount } - let t27; - let t28; - let t29; - let t30; - if ($[47] === Symbol.for("react.memo_cache_sentinel")) { - t27 = When hooks are disabled:; - t28 = · No hook commands will execute; - t29 = · StatusLine will not be displayed; - t30 = · Tool operations will proceed without hook validation; - $[47] = t27; - $[48] = t28; - $[49] = t29; - $[50] = t30; - } else { - t27 = $[47]; - t28 = $[48]; - t29 = $[49]; - t30 = $[50]; - } - let t31; - if ($[51] !== t26) { - t31 = {t26}{t27}{t28}{t29}{t30}; - $[51] = t26; - $[52] = t31; - } else { - t31 = $[52]; - } - let t32; - if ($[53] !== disabledByPolicy) { - t32 = !disabledByPolicy && To re-enable hooks, remove "disableAllHooks" from settings.json or ask Claude.; - $[53] = disabledByPolicy; - $[54] = t32; - } else { - t32 = $[54]; - } - let t33; - if ($[55] !== t31 || $[56] !== t32) { - t33 = {t31}{t32}; - $[55] = t31; - $[56] = t32; - $[57] = t33; - } else { - t33 = $[57]; - } - let t34; - if ($[58] !== handleExit || $[59] !== t33) { - t34 = {t33}; - $[58] = handleExit; - $[59] = t33; - $[60] = t34; - } else { - t34 = $[60]; - } - return t34; + return { hooksByEvent: byEvent, totalHooksCount: total } + }, [hooksByEventAndMatcher]) + + // If hooks are disabled, show an informational screen. + // The menu is read-only, so we don't offer a re-enable button — + // users can edit settings.json or ask Claude instead. + if (hooksDisabled) { + return ( + Esc to close} + > + + + + All hooks are currently disabled + {disabledByPolicy && ' by a managed settings file'}. You have{' '} + {totalHooksCount} configured{' '} + {plural(totalHooksCount, 'hook')} that{' '} + {plural(totalHooksCount, 'is', 'are')} not running. + + + When hooks are disabled: + + · No hook commands will execute + · StatusLine will not be displayed + + · Tool operations will proceed without hook validation + + + {!disabledByPolicy && ( + + To re-enable hooks, remove "disableAllHooks" from + settings.json or ask Claude. + + )} + + + ) } + switch (modeState.mode) { - case "select-event": - { - let t21; - if ($[61] !== combinedToolNames) { - t21 = event_2 => { - if (getMatcherMetadata(event_2, combinedToolNames) !== undefined) { - setModeState({ - mode: "select-matcher", - event: event_2 - }); + case 'select-event': + return ( + { + if (getMatcherMetadata(event, combinedToolNames) !== undefined) { + setModeState({ mode: 'select-matcher', event }) } else { - setModeState({ - mode: "select-hook", - event: event_2, - matcher: "" - }); + setModeState({ mode: 'select-hook', event, matcher: '' }) } - }; - $[61] = combinedToolNames; - $[62] = t21; - } else { - t21 = $[62]; - } - let t22; - if ($[63] !== handleExit || $[64] !== hookEventMetadata || $[65] !== hooksByEvent || $[66] !== restrictedByPolicy || $[67] !== t21 || $[68] !== totalHooksCount) { - t22 = ; - $[63] = handleExit; - $[64] = hookEventMetadata; - $[65] = hooksByEvent; - $[66] = restrictedByPolicy; - $[67] = t21; - $[68] = totalHooksCount; - $[69] = t22; - } else { - t22 = $[69]; - } - return t22; - } - case "select-matcher": - { - const t21 = hookEventMetadata[modeState.event]; - let t22; - if ($[70] !== modeState.event) { - t22 = matcher => { + }} + onCancel={handleExit} + /> + ) + case 'select-matcher': + return ( + { setModeState({ - mode: "select-hook", + mode: 'select-hook', event: modeState.event, - matcher - }); - }; - $[70] = modeState.event; - $[71] = t22; - } else { - t22 = $[71]; - } - let t23; - if ($[72] === Symbol.for("react.memo_cache_sentinel")) { - t23 = () => { + matcher, + }) + }} + onCancel={() => { + setModeState({ mode: 'select-event' }) + }} + /> + ) + case 'select-hook': + return ( + { setModeState({ - mode: "select-event" - }); - }; - $[72] = t23; - } else { - t23 = $[72]; - } - let t24; - if ($[73] !== hooksByEventAndMatcher || $[74] !== modeState.event || $[75] !== sortedMatchersForSelectedEvent || $[76] !== t21.description || $[77] !== t22) { - t24 = ; - $[73] = hooksByEventAndMatcher; - $[74] = modeState.event; - $[75] = sortedMatchersForSelectedEvent; - $[76] = t21.description; - $[77] = t22; - $[78] = t24; - } else { - t24 = $[78]; - } - return t24; - } - case "select-hook": - { - const t21 = hookEventMetadata[modeState.event]; - let t22; - if ($[79] !== modeState.event) { - t22 = hook_1 => { - setModeState({ - mode: "view-hook", + mode: 'view-hook', event: modeState.event, - hook: hook_1 - }); - }; - $[79] = modeState.event; - $[80] = t22; - } else { - t22 = $[80]; - } - let t23; - if ($[81] !== combinedToolNames || $[82] !== modeState.event) { - t23 = () => { - if (getMatcherMetadata(modeState.event, combinedToolNames) !== undefined) { + hook, + }) + }} + onCancel={() => { + // Go back to matcher selection or event selection + if ( + getMatcherMetadata(modeState.event, combinedToolNames) !== + undefined + ) { setModeState({ - mode: "select-matcher", - event: modeState.event - }); + mode: 'select-matcher', + event: modeState.event, + }) } else { - setModeState({ - mode: "select-event" - }); + setModeState({ mode: 'select-event' }) } - }; - $[81] = combinedToolNames; - $[82] = modeState.event; - $[83] = t23; - } else { - t23 = $[83]; - } - let t24; - if ($[84] !== hooksForSelectedMatcher || $[85] !== modeState.event || $[86] !== modeState.matcher || $[87] !== t21 || $[88] !== t22 || $[89] !== t23) { - t24 = ; - $[84] = hooksForSelectedMatcher; - $[85] = modeState.event; - $[86] = modeState.matcher; - $[87] = t21; - $[88] = t22; - $[89] = t23; - $[90] = t24; - } else { - t24 = $[90]; - } - return t24; - } - case "view-hook": - { - const t21 = modeState.hook; - let t22; - if ($[91] !== combinedToolNames || $[92] !== modeState.event) { - t22 = getMatcherMetadata(modeState.event, combinedToolNames); - $[91] = combinedToolNames; - $[92] = modeState.event; - $[93] = t22; - } else { - t22 = $[93]; - } - const t23 = t22 !== undefined; - let t24; - if ($[94] !== modeState) { - t24 = () => { - const { - event: event_1, - hook: hook_0 - } = modeState; + }} + /> + ) + case 'view-hook': + return ( + { + const { event, hook } = modeState setModeState({ - mode: "select-hook", - event: event_1, - matcher: hook_0.matcher || "" - }); - }; - $[94] = modeState; - $[95] = t24; - } else { - t24 = $[95]; - } - let t25; - if ($[96] !== modeState.hook || $[97] !== t23 || $[98] !== t24) { - t25 = ; - $[96] = modeState.hook; - $[97] = t23; - $[98] = t24; - $[99] = t25; - } else { - t25 = $[99]; - } - return t25; - } + mode: 'select-hook', + event, + matcher: hook.matcher || '', + }) + }} + /> + ) } } -function _temp6() { - return Esc to close; -} -function _temp5(sum, hooks) { - return sum + hooks.length; -} -function _temp4(tool) { - return tool.name; -} -function _temp3(s) { - return s.mcp; -} -function _temp2() { - return getSettingsForSource("policySettings")?.allowManagedHooksOnly === true; -} -function _temp() { - const settings = getSettings_DEPRECATED(); - const hooksDisabled = settings?.disableAllHooks === true; - return hooksDisabled && getSettingsForSource("policySettings")?.disableAllHooks === true; -} diff --git a/src/components/hooks/PromptDialog.tsx b/src/components/hooks/PromptDialog.tsx index 7b82be353..f5566ec46 100644 --- a/src/components/hooks/PromptDialog.tsx +++ b/src/components/hooks/PromptDialog.tsx @@ -1,89 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { PromptRequest } from '../../types/hooks.js'; -import { Select } from '../CustomSelect/select.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { PromptRequest } from '../../types/hooks.js' +import { Select } from '../CustomSelect/select.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' + type Props = { - title: string; - toolInputSummary?: string | null; - request: PromptRequest; - onRespond: (key: string) => void; - onAbort: () => void; -}; -export function PromptDialog(t0) { - const $ = _c(15); - const { - title, - toolInputSummary, - request, - onRespond, - onAbort - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - isActive: true - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("app:interrupt", onAbort, t1); - let t2; - if ($[1] !== request.options) { - t2 = request.options.map(_temp); - $[1] = request.options; - $[2] = t2; - } else { - t2 = $[2]; - } - const options = t2; - let t3; - if ($[3] !== toolInputSummary) { - t3 = toolInputSummary ? {toolInputSummary} : undefined; - $[3] = toolInputSummary; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== onRespond) { - t4 = value => { - onRespond(value); - }; - $[5] = onRespond; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== options || $[8] !== t4) { - t5 = { + onRespond(value) + }} + /> + + + ) } diff --git a/src/components/hooks/SelectEventMode.tsx b/src/components/hooks/SelectEventMode.tsx index 1cba0c253..a18d01952 100644 --- a/src/components/hooks/SelectEventMode.tsx +++ b/src/components/hooks/SelectEventMode.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * SelectEventMode is the entrypoint of the Hooks config menu, where the user * sees the list of available hook events. @@ -8,119 +7,84 @@ import { c as _c } from "react/compiler-runtime"; * edit settings.json directly or ask Claude. */ -import figures from 'figures'; -import * as React from 'react'; -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; -import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js'; -import { Box, Link, Text } from '../../ink.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Select } from '../CustomSelect/select.js'; -import { Dialog } from '../design-system/Dialog.js'; +import figures from 'figures' +import * as React from 'react' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js' +import { Box, Link, Text } from '../../ink.js' +import { plural } from '../../utils/stringUtils.js' +import { Select } from '../CustomSelect/select.js' +import { Dialog } from '../design-system/Dialog.js' + type Props = { - hookEventMetadata: Record; - hooksByEvent: Partial>; - totalHooksCount: number; - restrictedByPolicy: boolean; - onSelectEvent: (event: HookEvent) => void; - onCancel: () => void; -}; -export function SelectEventMode(t0) { - const $ = _c(23); - const { - hookEventMetadata, - hooksByEvent, - totalHooksCount, - restrictedByPolicy, - onSelectEvent, - onCancel - } = t0; - let t1; - if ($[0] !== totalHooksCount) { - t1 = plural(totalHooksCount, "hook"); - $[0] = totalHooksCount; - $[1] = t1; - } else { - t1 = $[1]; - } - const subtitle = `${totalHooksCount} ${t1} configured`; - let t2; - if ($[2] !== restrictedByPolicy) { - t2 = restrictedByPolicy && {figures.info} Hooks Restricted by PolicyOnly hooks from managed settings can run. User-defined hooks from ~/.claude/settings.json, .claude/settings.json, and .claude/settings.local.json are blocked.; - $[2] = restrictedByPolicy; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {figures.info} This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude.{" "}Learn more; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== onSelectEvent) { - t4 = value => { - onSelectEvent(value as HookEvent); - }; - $[5] = onSelectEvent; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== hookEventMetadata) { - t5 = Object.entries(hookEventMetadata); - $[7] = hookEventMetadata; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== hooksByEvent || $[10] !== t5) { - t6 = t5.map(t7 => { - const [name, metadata] = t7; - const count = hooksByEvent[name as HookEvent] || 0; - return { - label: count > 0 ? {name} ({count}) : name, - value: name, - description: metadata.summary - }; - }); - $[9] = hooksByEvent; - $[10] = t5; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] !== onCancel || $[13] !== t4 || $[14] !== t6) { - t7 = { + onSelectEvent(value as HookEvent) + }} + onCancel={onCancel} + options={Object.entries(hookEventMetadata).map( + ([name, metadata]) => { + const count = hooksByEvent[name as HookEvent] || 0 + return { + label: + count > 0 ? ( + + {name} ({count}) + + ) : ( + name + ), + value: name, + description: metadata.summary, + } + }, + )} + /> + + + + ) } diff --git a/src/components/hooks/SelectHookMode.tsx b/src/components/hooks/SelectHookMode.tsx index 92c484993..5764ae9d5 100644 --- a/src/components/hooks/SelectHookMode.tsx +++ b/src/components/hooks/SelectHookMode.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * SelectHookMode shows all hooks configured for a given event+matcher pair. * @@ -6,106 +5,84 @@ import { c as _c } from "react/compiler-runtime"; * and selecting a hook shows its read-only details instead of a delete * confirmation. */ -import * as React from 'react'; -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; -import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js'; -import { Box, Text } from '../../ink.js'; -import { getHookDisplayText, hookSourceHeaderDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js'; -import { Select } from '../CustomSelect/select.js'; -import { Dialog } from '../design-system/Dialog.js'; +import * as React from 'react' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js' +import { Box, Text } from '../../ink.js' +import { + getHookDisplayText, + hookSourceHeaderDisplayString, + type IndividualHookConfig, +} from '../../utils/hooks/hooksSettings.js' +import { Select } from '../CustomSelect/select.js' +import { Dialog } from '../design-system/Dialog.js' + type Props = { - selectedEvent: HookEvent; - selectedMatcher: string | null; - hooksForSelectedMatcher: IndividualHookConfig[]; - hookEventMetadata: HookEventMetadata; - onSelect: (hook: IndividualHookConfig) => void; - onCancel: () => void; -}; -export function SelectHookMode(t0) { - const $ = _c(19); - const { - selectedEvent, - selectedMatcher, - hooksForSelectedMatcher, - hookEventMetadata, - onSelect, - onCancel - } = t0; - const title = hookEventMetadata.matcherMetadata !== undefined ? `${selectedEvent} - Matcher: ${selectedMatcher || "(all)"}` : selectedEvent; + selectedEvent: HookEvent + selectedMatcher: string | null + hooksForSelectedMatcher: IndividualHookConfig[] + hookEventMetadata: HookEventMetadata + onSelect: (hook: IndividualHookConfig) => void + onCancel: () => void +} + +export function SelectHookMode({ + selectedEvent, + selectedMatcher, + hooksForSelectedMatcher, + hookEventMetadata, + onSelect, + onCancel, +}: Props): React.ReactNode { + const title = + hookEventMetadata.matcherMetadata !== undefined + ? `${selectedEvent} - Matcher: ${selectedMatcher || '(all)'}` + : selectedEvent + if (hooksForSelectedMatcher.length === 0) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = No hooks configured for this event.To add hooks, edit settings.json directly or ask Claude.; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== hookEventMetadata.description || $[2] !== onCancel || $[3] !== title) { - t2 = {t1}; - $[1] = hookEventMetadata.description; - $[2] = onCancel; - $[3] = title; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; - } - const t1 = hookEventMetadata.description; - let t2; - if ($[5] !== hooksForSelectedMatcher) { - t2 = hooksForSelectedMatcher.map(_temp2); - $[5] = hooksForSelectedMatcher; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== hooksForSelectedMatcher || $[8] !== onSelect) { - t3 = value => { - const index_0 = parseInt(value, 10); - const hook_0 = hooksForSelectedMatcher[index_0]; - if (hook_0) { - onSelect(hook_0); - } - }; - $[7] = hooksForSelectedMatcher; - $[8] = onSelect; - $[9] = t3; - } else { - t3 = $[9]; - } - let t4; - if ($[10] !== onCancel || $[11] !== t2 || $[12] !== t3) { - t4 = ({ + label: `[${hook.config.type}] ${getHookDisplayText(hook.config)}`, + value: index.toString(), + description: + hook.source === 'pluginHook' && hook.pluginName + ? `${hookSourceHeaderDisplayString(hook.source)} (${hook.pluginName})` + : hookSourceHeaderDisplayString(hook.source), + }))} + onChange={value => { + const index = parseInt(value, 10) + const hook = hooksForSelectedMatcher[index] + if (hook) { + onSelect(hook) + } + }} + onCancel={onCancel} + /> + + + ) } diff --git a/src/components/hooks/SelectMatcherMode.tsx b/src/components/hooks/SelectMatcherMode.tsx index 0b5593cfc..6792a47b1 100644 --- a/src/components/hooks/SelectMatcherMode.tsx +++ b/src/components/hooks/SelectMatcherMode.tsx @@ -1,143 +1,103 @@ -import { c as _c } from "react/compiler-runtime"; /** * SelectMatcherMode shows the configured matchers for a selected hook event. * * The /hooks menu is read-only: this view no longer offers "add new matcher" * and simply lets the user drill into each matcher to see its hooks. */ -import * as React from 'react'; -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; -import { Box, Text } from '../../ink.js'; -import { type HookSource, hookSourceInlineDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Select } from '../CustomSelect/select.js'; -import { Dialog } from '../design-system/Dialog.js'; +import * as React from 'react' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { Box, Text } from '../../ink.js' +import { + type HookSource, + hookSourceInlineDisplayString, + type IndividualHookConfig, +} from '../../utils/hooks/hooksSettings.js' +import { plural } from '../../utils/stringUtils.js' +import { Select } from '../CustomSelect/select.js' +import { Dialog } from '../design-system/Dialog.js' + type MatcherWithSource = { - matcher: string; - sources: HookSource[]; - hookCount: number; -}; + matcher: string + sources: HookSource[] + hookCount: number +} + type Props = { - selectedEvent: HookEvent; - matchersForSelectedEvent: string[]; - hooksByEventAndMatcher: Record>; - eventDescription: string; - onSelect: (matcher: string) => void; - onCancel: () => void; -}; -export function SelectMatcherMode(t0) { - const $ = _c(25); - const { - selectedEvent, - matchersForSelectedEvent, - hooksByEventAndMatcher, - eventDescription, - onSelect, - onCancel - } = t0; - let t1; - if ($[0] !== hooksByEventAndMatcher || $[1] !== matchersForSelectedEvent || $[2] !== selectedEvent) { - let t2; - if ($[4] !== hooksByEventAndMatcher || $[5] !== selectedEvent) { - t2 = matcher => { - const hooks = hooksByEventAndMatcher[selectedEvent]?.[matcher] || []; - const sources = Array.from(new Set(hooks.map(_temp))); - return { - matcher, - sources, - hookCount: hooks.length - }; - }; - $[4] = hooksByEventAndMatcher; - $[5] = selectedEvent; - $[6] = t2; - } else { - t2 = $[6]; - } - t1 = matchersForSelectedEvent.map(t2); - $[0] = hooksByEventAndMatcher; - $[1] = matchersForSelectedEvent; - $[2] = selectedEvent; - $[3] = t1; - } else { - t1 = $[3]; - } - const matchersWithSources = t1; + selectedEvent: HookEvent + matchersForSelectedEvent: string[] + hooksByEventAndMatcher: Record< + HookEvent, + Record + > + eventDescription: string + onSelect: (matcher: string) => void + onCancel: () => void +} + +export function SelectMatcherMode({ + selectedEvent, + matchersForSelectedEvent, + hooksByEventAndMatcher, + eventDescription, + onSelect, + onCancel, +}: Props): React.ReactNode { + // Group matchers with their sources (already sorted by priority in parent) + const matchersWithSources: MatcherWithSource[] = React.useMemo(() => { + return matchersForSelectedEvent.map(matcher => { + const hooks = hooksByEventAndMatcher[selectedEvent]?.[matcher] || [] + const sources = Array.from(new Set(hooks.map(h => h.source))) + return { + matcher, + sources, + hookCount: hooks.length, + } + }) + }, [matchersForSelectedEvent, hooksByEventAndMatcher, selectedEvent]) + if (matchersForSelectedEvent.length === 0) { - const t2 = `${selectedEvent} - Matchers`; - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = No hooks configured for this event.To add hooks, edit settings.json directly or ask Claude.; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== eventDescription || $[9] !== onCancel || $[10] !== t2) { - t4 = {t3}; - $[8] = eventDescription; - $[9] = onCancel; - $[10] = t2; - $[11] = t4; - } else { - t4 = $[11]; - } - return t4; - } - const t2 = `${selectedEvent} - Matchers`; - let t3; - if ($[12] !== matchersWithSources) { - t3 = matchersWithSources.map(_temp3); - $[12] = matchersWithSources; - $[13] = t3; - } else { - t3 = $[13]; - } - let t4; - if ($[14] !== onSelect) { - t4 = value => { - onSelect(value); - }; - $[14] = onSelect; - $[15] = t4; - } else { - t4 = $[15]; + return ( + Esc to go back} + > + + No hooks configured for this event. + + To add hooks, edit settings.json directly or ask Claude. + + + + ) } - let t5; - if ($[16] !== onCancel || $[17] !== t3 || $[18] !== t4) { - t5 = { + const sourceText = item.sources + .map(hookSourceInlineDisplayString) + .join(', ') + const matcherLabel = item.matcher || '(all)' + return { + label: `[${sourceText}] ${matcherLabel}`, + value: item.matcher, + description: `${item.hookCount} ${plural(item.hookCount, 'hook')}`, + } + })} + onChange={value => { + onSelect(value) + }} + onCancel={onCancel} + /> + + + ) } diff --git a/src/components/hooks/ViewHookMode.tsx b/src/components/hooks/ViewHookMode.tsx index f398b1047..5766ead25 100644 --- a/src/components/hooks/ViewHookMode.tsx +++ b/src/components/hooks/ViewHookMode.tsx @@ -1,182 +1,100 @@ -import { c as _c } from "react/compiler-runtime"; /** * ViewHookMode shows read-only details for a single configured hook. * * The /hooks menu is read-only; this view replaces the former delete-hook * confirmation screen and directs users to settings.json or Claude for edits. */ -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { hookSourceDescriptionDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js'; -import { Dialog } from '../design-system/Dialog.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + hookSourceDescriptionDisplayString, + type IndividualHookConfig, +} from '../../utils/hooks/hooksSettings.js' +import { Dialog } from '../design-system/Dialog.js' + type Props = { - selectedHook: IndividualHookConfig; - eventSupportsMatcher: boolean; - onCancel: () => void; -}; -export function ViewHookMode(t0) { - const $ = _c(40); - const { - selectedHook, - eventSupportsMatcher, - onCancel - } = t0; - let t1; - if ($[0] !== selectedHook.event) { - t1 = Event: {selectedHook.event}; - $[0] = selectedHook.event; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== eventSupportsMatcher || $[3] !== selectedHook.matcher) { - t2 = eventSupportsMatcher && Matcher: {selectedHook.matcher || "(all)"}; - $[2] = eventSupportsMatcher; - $[3] = selectedHook.matcher; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== selectedHook.config.type) { - t3 = Type: {selectedHook.config.type}; - $[5] = selectedHook.config.type; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== selectedHook.source) { - t4 = hookSourceDescriptionDisplayString(selectedHook.source); - $[7] = selectedHook.source; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t4) { - t5 = Source:{" "}{t4}; - $[9] = t4; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== selectedHook.pluginName) { - t6 = selectedHook.pluginName && Plugin: {selectedHook.pluginName}; - $[11] = selectedHook.pluginName; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== t1 || $[14] !== t2 || $[15] !== t3 || $[16] !== t5 || $[17] !== t6) { - t7 = {t1}{t2}{t3}{t5}{t6}; - $[13] = t1; - $[14] = t2; - $[15] = t3; - $[16] = t5; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - let t8; - if ($[19] !== selectedHook.config) { - t8 = getContentFieldLabel(selectedHook.config); - $[19] = selectedHook.config; - $[20] = t8; - } else { - t8 = $[20]; - } - let t9; - if ($[21] !== t8) { - t9 = {t8}:; - $[21] = t8; - $[22] = t9; - } else { - t9 = $[22]; - } - let t10; - if ($[23] !== selectedHook.config) { - t10 = getContentFieldValue(selectedHook.config); - $[23] = selectedHook.config; - $[24] = t10; - } else { - t10 = $[24]; - } - let t11; - if ($[25] !== t10) { - t11 = {t10}; - $[25] = t10; - $[26] = t11; - } else { - t11 = $[26]; - } - let t12; - if ($[27] !== t11 || $[28] !== t9) { - t12 = {t9}{t11}; - $[27] = t11; - $[28] = t9; - $[29] = t12; - } else { - t12 = $[29]; - } - let t13; - if ($[30] !== selectedHook.config) { - t13 = "statusMessage" in selectedHook.config && selectedHook.config.statusMessage && Status message:{" "}{selectedHook.config.statusMessage}; - $[30] = selectedHook.config; - $[31] = t13; - } else { - t13 = $[31]; - } - let t14; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t14 = To modify or remove this hook, edit settings.json directly or ask Claude to help.; - $[32] = t14; - } else { - t14 = $[32]; - } - let t15; - if ($[33] !== t12 || $[34] !== t13 || $[35] !== t7) { - t15 = {t7}{t12}{t13}{t14}; - $[33] = t12; - $[34] = t13; - $[35] = t7; - $[36] = t15; - } else { - t15 = $[36]; - } - let t16; - if ($[37] !== onCancel || $[38] !== t15) { - t16 = {t15}; - $[37] = onCancel; - $[38] = t15; - $[39] = t16; - } else { - t16 = $[39]; - } - return t16; + selectedHook: IndividualHookConfig + eventSupportsMatcher: boolean + onCancel: () => void +} + +export function ViewHookMode({ + selectedHook, + eventSupportsMatcher, + onCancel, +}: Props): React.ReactNode { + return ( + Esc to go back} + > + + + + Event: {selectedHook.event} + + {eventSupportsMatcher && ( + + Matcher: {selectedHook.matcher || '(all)'} + + )} + + Type: {selectedHook.config.type} + + + Source:{' '} + + {hookSourceDescriptionDisplayString(selectedHook.source)} + + + {selectedHook.pluginName && ( + + Plugin: {selectedHook.pluginName} + + )} + + + {getContentFieldLabel(selectedHook.config)}: + + {getContentFieldValue(selectedHook.config)} + + + {'statusMessage' in selectedHook.config && + selectedHook.config.statusMessage && ( + + Status message:{' '} + {selectedHook.config.statusMessage} + + )} + + To modify or remove this hook, edit settings.json directly or ask + Claude to help. + + + + ) } /** * Get a human-readable label for the primary content field of a hook * based on its type. */ -function _temp() { - return Esc to go back; -} function getContentFieldLabel(config: IndividualHookConfig['config']): string { switch (config.type) { case 'command': - return 'Command'; + return 'Command' case 'prompt': - return 'Prompt'; + return 'Prompt' case 'agent': - return 'Prompt'; + return 'Prompt' case 'http': - return 'URL'; + return 'URL' } } @@ -187,12 +105,12 @@ function getContentFieldLabel(config: IndividualHookConfig['config']): string { function getContentFieldValue(config: IndividualHookConfig['config']): string { switch (config.type) { case 'command': - return config.command; + return config.command case 'prompt': - return config.prompt; + return config.prompt case 'agent': - return config.prompt; + return config.prompt case 'http': - return config.url; + return config.url } } diff --git a/src/components/mcp/CapabilitiesSection.tsx b/src/components/mcp/CapabilitiesSection.tsx index 3c3044cf4..a5f98466a 100644 --- a/src/components/mcp/CapabilitiesSection.tsx +++ b/src/components/mcp/CapabilitiesSection.tsx @@ -1,60 +1,35 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Byline } from '../design-system/Byline.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { Byline } from '../design-system/Byline.js' + type Props = { - serverToolsCount: number; - serverPromptsCount: number; - serverResourcesCount: number; -}; -export function CapabilitiesSection(t0) { - const $ = _c(9); - const { - serverToolsCount, - serverPromptsCount, - serverResourcesCount - } = t0; - let capabilities; - if ($[0] !== serverPromptsCount || $[1] !== serverResourcesCount || $[2] !== serverToolsCount) { - capabilities = []; - if (serverToolsCount > 0) { - capabilities.push("tools"); - } - if (serverResourcesCount > 0) { - capabilities.push("resources"); - } - if (serverPromptsCount > 0) { - capabilities.push("prompts"); - } - $[0] = serverPromptsCount; - $[1] = serverResourcesCount; - $[2] = serverToolsCount; - $[3] = capabilities; - } else { - capabilities = $[3]; - } - let t1; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Capabilities: ; - $[4] = t1; - } else { - t1 = $[4]; + serverToolsCount: number + serverPromptsCount: number + serverResourcesCount: number +} + +export function CapabilitiesSection({ + serverToolsCount, + serverPromptsCount, + serverResourcesCount, +}: Props): React.ReactNode { + const capabilities = [] + if (serverToolsCount > 0) { + capabilities.push('tools') } - let t2; - if ($[5] !== capabilities) { - t2 = capabilities.length > 0 ? {capabilities} : "none"; - $[5] = capabilities; - $[6] = t2; - } else { - t2 = $[6]; + if (serverResourcesCount > 0) { + capabilities.push('resources') } - let t3; - if ($[7] !== t2) { - t3 = {t1}{t2}; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; + if (serverPromptsCount > 0) { + capabilities.push('prompts') } - return t3; + + return ( + + Capabilities: + + {capabilities.length > 0 ? {capabilities} : 'none'} + + + ) } diff --git a/src/components/mcp/ElicitationDialog.tsx b/src/components/mcp/ElicitationDialog.tsx index 0ae90a9bd..dbf8f22f9 100644 --- a/src/components/mcp/ElicitationDialog.tsx +++ b/src/components/mcp/ElicitationDialog.tsx @@ -1,39 +1,62 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, PrimitiveSchemaDefinition } from '@modelcontextprotocol/sdk/types.js'; -import figures from 'figures'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import type { + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + PrimitiveSchemaDefinition, +} from '@modelcontextprotocol/sdk/types.js' +import figures from 'figures' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for elicitation form -import { Box, Text, useInput } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { ElicitationRequestEvent } from '../../services/mcp/elicitationHandler.js'; -import { openBrowser } from '../../utils/browser.js'; -import { getEnumLabel, getEnumValues, getMultiSelectLabel, getMultiSelectValues, isDateTimeSchema, isEnumSchema, isMultiSelectEnumSchema, validateElicitationInput, validateElicitationInputAsync } from '../../utils/mcp/elicitationValidation.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import TextInput from '../TextInput.js'; +import { Box, Text, useInput } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { ElicitationRequestEvent } from '../../services/mcp/elicitationHandler.js' +import { openBrowser } from '../../utils/browser.js' +import { + getEnumLabel, + getEnumValues, + getMultiSelectLabel, + getMultiSelectValues, + isDateTimeSchema, + isEnumSchema, + isMultiSelectEnumSchema, + validateElicitationInput, + validateElicitationInputAsync, +} from '../../utils/mcp/elicitationValidation.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import TextInput from '../TextInput.js' + type Props = { - event: ElicitationRequestEvent; - onResponse: (action: ElicitResult['action'], content?: ElicitResult['content']) => void; + event: ElicitationRequestEvent + onResponse: ( + action: ElicitResult['action'], + content?: ElicitResult['content'], + ) => void /** Called when the phase 2 waiting state is dismissed (URL elicitations only). */ - onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void; -}; -const isTextField = (s: PrimitiveSchemaDefinition) => ['string', 'number', 'integer'].includes(s.type); -const RESOLVING_SPINNER_CHARS = '\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F'; -const advanceSpinnerFrame = (f: number) => (f + 1) % RESOLVING_SPINNER_CHARS.length; + onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void +} + +const isTextField = (s: PrimitiveSchemaDefinition) => + ['string', 'number', 'integer'].includes(s.type) + +const RESOLVING_SPINNER_CHARS = + '\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F' +const advanceSpinnerFrame = (f: number) => + (f + 1) % RESOLVING_SPINNER_CHARS.length /** Timer callback for enumTypeaheadRef — module-scope to avoid closure capture. */ function resetTypeahead(ta: { - buffer: string; - timer: ReturnType | undefined; + buffer: string + timer: ReturnType | undefined }): void { - ta.buffer = ''; - ta.timer = undefined; + ta.buffer = '' + ta.timer = undefined } /** @@ -46,42 +69,24 @@ function resetTypeahead(ta: { * with color="text", which would break the 1-col checkbox * column alignment here (other checkbox states are width-1 glyphs). */ -function ResolvingSpinner() { - const $ = _c(4); - const [frame, setFrame] = useState(0); - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - const timer = setInterval(setFrame, 80, advanceSpinnerFrame); - return () => clearInterval(timer); - }; - t1 = []; - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; - } - useEffect(t0, t1); - const t2 = RESOLVING_SPINNER_CHARS[frame]; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; +function ResolvingSpinner(): React.ReactNode { + const [frame, setFrame] = useState(0) + useEffect(() => { + const timer = setInterval(setFrame, 80, advanceSpinnerFrame) + return () => clearInterval(timer) + }, []) + return {RESOLVING_SPINNER_CHARS[frame]} } /** Format an ISO date/datetime for display, keeping the ISO value for submission. */ -function formatDateDisplay(isoValue: string, schema: PrimitiveSchemaDefinition): string { +function formatDateDisplay( + isoValue: string, + schema: PrimitiveSchemaDefinition, +): string { try { - const date = new Date(isoValue); - if (Number.isNaN(date.getTime())) return isoValue; - const format = 'format' in schema ? schema.format : undefined; + const date = new Date(isoValue) + if (Number.isNaN(date.getTime())) return isoValue + const format = 'format' in schema ? schema.format : undefined if (format === 'date-time') { return date.toLocaleDateString('en-US', { weekday: 'short', @@ -90,365 +95,477 @@ function formatDateDisplay(isoValue: string, schema: PrimitiveSchemaDefinition): day: 'numeric', hour: 'numeric', minute: '2-digit', - timeZoneName: 'short' - }); + timeZoneName: 'short', + }) } // date-only: parse as local date to avoid timezone shift - const parts = isoValue.split('-'); + const parts = isoValue.split('-') if (parts.length === 3) { - const local = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); + const local = new Date( + Number(parts[0]), + Number(parts[1]) - 1, + Number(parts[2]), + ) return local.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', - day: 'numeric' - }); + day: 'numeric', + }) } - return isoValue; + return isoValue } catch { - return isoValue; + return isoValue } } -export function ElicitationDialog(t0) { - const $ = _c(7); - const { - event, - onResponse, - onWaitingDismiss - } = t0; - if (event.params.mode === "url") { - let t1; - if ($[0] !== event || $[1] !== onResponse || $[2] !== onWaitingDismiss) { - t1 = ; - $[0] = event; - $[1] = onResponse; - $[2] = onWaitingDismiss; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; - } - let t1; - if ($[4] !== event || $[5] !== onResponse) { - t1 = ; - $[4] = event; - $[5] = onResponse; - $[6] = t1; - } else { - t1 = $[6]; + +export function ElicitationDialog({ + event, + onResponse, + onWaitingDismiss, +}: Props): React.ReactNode { + if (event.params.mode === 'url') { + return ( + + ) } - return t1; + + return } + function ElicitationFormDialog({ event, - onResponse + onResponse, }: { - event: ElicitationRequestEvent; - onResponse: Props['onResponse']; + event: ElicitationRequestEvent + onResponse: Props['onResponse'] }): React.ReactNode { - const { - serverName, - signal - } = event; - const request = event.params as ElicitRequestFormParams; - const { - message, - requestedSchema - } = request; - const hasFields = Object.keys(requestedSchema.properties).length > 0; - const [focusedButton, setFocusedButton] = useState<'accept' | 'decline' | null>(hasFields ? null : 'accept'); - const [formValues, setFormValues] = useState>(() => { - const initialValues: Record = {}; + const { serverName, signal } = event + const request = event.params as ElicitRequestFormParams + const { message, requestedSchema } = request + const hasFields = Object.keys(requestedSchema.properties).length > 0 + const [focusedButton, setFocusedButton] = useState< + 'accept' | 'decline' | null + >(hasFields ? null : 'accept') + const [formValues, setFormValues] = useState< + Record + >(() => { + const initialValues: Record = + {} if (requestedSchema.properties) { - for (const [propName, propSchema] of Object.entries(requestedSchema.properties)) { + for (const [propName, propSchema] of Object.entries( + requestedSchema.properties, + )) { if (typeof propSchema === 'object' && propSchema !== null) { if (propSchema.default !== undefined) { - initialValues[propName] = propSchema.default; + initialValues[propName] = propSchema.default } } } } - return initialValues; - }); - const [validationErrors, setValidationErrors] = useState>(() => { - const initialErrors: Record = {}; - for (const [propName_0, propSchema_0] of Object.entries(requestedSchema.properties)) { - if (isTextField(propSchema_0) && propSchema_0?.default !== undefined) { - const validation = validateElicitationInput(String(propSchema_0.default), propSchema_0); + return initialValues + }) + + const [validationErrors, setValidationErrors] = useState< + Record + >(() => { + const initialErrors: Record = {} + for (const [propName, propSchema] of Object.entries( + requestedSchema.properties, + )) { + if (isTextField(propSchema) && propSchema?.default !== undefined) { + const validation = validateElicitationInput( + String(propSchema.default), + propSchema, + ) if (!validation.isValid && validation.error) { - initialErrors[propName_0] = validation.error; + initialErrors[propName] = validation.error } } } - return initialErrors; - }); + return initialErrors + }) + useEffect(() => { - if (!signal) return; + if (!signal) return + const handleAbort = () => { - onResponse('cancel'); - }; + onResponse('cancel') + } + if (signal.aborted) { - handleAbort(); - return; + handleAbort() + return } - signal.addEventListener('abort', handleAbort); + + signal.addEventListener('abort', handleAbort) return () => { - signal.removeEventListener('abort', handleAbort); - }; - }, [signal, onResponse]); + signal.removeEventListener('abort', handleAbort) + } + }, [signal, onResponse]) + const schemaFields = useMemo(() => { - const requiredFields = requestedSchema.required ?? []; + const requiredFields = requestedSchema.required ?? [] return Object.entries(requestedSchema.properties).map(([name, schema]) => ({ name, schema, - isRequired: requiredFields.includes(name) - })); - }, [requestedSchema]); - const [currentFieldIndex, setCurrentFieldIndex] = useState(hasFields ? 0 : undefined); + isRequired: requiredFields.includes(name), + })) + }, [requestedSchema]) + + const [currentFieldIndex, setCurrentFieldIndex] = useState< + number | undefined + >(hasFields ? 0 : undefined) const [textInputValue, setTextInputValue] = useState(() => { // Initialize from the first field's value if it's a text field - const firstField = schemaFields[0]; + const firstField = schemaFields[0] if (firstField && isTextField(firstField.schema)) { - const val = formValues[firstField.name]; - if (val === undefined) return ''; - return String(val); + const val = formValues[firstField.name] + if (val === undefined) return '' + return String(val) } - return ''; - }); - const [textInputCursorOffset, setTextInputCursorOffset] = useState(textInputValue.length); - const [resolvingFields, setResolvingFields] = useState>(() => new Set()); + return '' + }) + const [textInputCursorOffset, setTextInputCursorOffset] = useState( + textInputValue.length, + ) + const [resolvingFields, setResolvingFields] = useState>( + () => new Set(), + ) // Accordion state (shared by multi-select and single-select enum) - const [expandedAccordion, setExpandedAccordion] = useState(); - const [accordionOptionIndex, setAccordionOptionIndex] = useState(0); - const dateDebounceRef = useRef | undefined>(undefined); - const resolveAbortRef = useRef>(new Map()); + const [expandedAccordion, setExpandedAccordion] = useState< + string | undefined + >() + const [accordionOptionIndex, setAccordionOptionIndex] = useState(0) + + const dateDebounceRef = useRef | undefined>( + undefined, + ) + const resolveAbortRef = useRef>(new Map()) const enumTypeaheadRef = useRef({ buffer: '', - timer: undefined as ReturnType | undefined - }); + timer: undefined as ReturnType | undefined, + }) // Clear pending debounce/typeahead timers and abort in-flight async // validations on unmount so they don't fire against an unmounted component // (e.g. dialog dismissed mid-debounce or mid-resolve). - useEffect(() => () => { - if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current); - } - const ta = enumTypeaheadRef.current; - if (ta.timer !== undefined) { - clearTimeout(ta.timer); - } - for (const controller of resolveAbortRef.current.values()) { - controller.abort(); - } - resolveAbortRef.current.clear(); - }, []); - const { - columns, - rows - } = useTerminalSize(); - const currentField = currentFieldIndex !== undefined ? schemaFields[currentFieldIndex] : undefined; - const currentFieldIsText = currentField !== undefined && isTextField(currentField.schema) && !isEnumSchema(currentField.schema); + useEffect( + () => () => { + if (dateDebounceRef.current !== undefined) { + clearTimeout(dateDebounceRef.current) + } + const ta = enumTypeaheadRef.current + if (ta.timer !== undefined) { + clearTimeout(ta.timer) + } + for (const controller of resolveAbortRef.current.values()) { + controller.abort() + } + resolveAbortRef.current.clear() + }, + [], + ) + + const { columns, rows } = useTerminalSize() + + const currentField = + currentFieldIndex !== undefined + ? schemaFields[currentFieldIndex] + : undefined + const currentFieldIsText = + currentField !== undefined && + isTextField(currentField.schema) && + !isEnumSchema(currentField.schema) // Text fields are always in edit mode when focused — no Enter-to-edit step. - const isEditingTextField = currentFieldIsText && !focusedButton; - useRegisterOverlay('elicitation', undefined); - useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_dialog'); + const isEditingTextField = currentFieldIsText && !focusedButton + + useRegisterOverlay('elicitation') + useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_dialog') // Sync textInputValue when the focused field changes - const syncTextInput = useCallback((fieldIndex: number | undefined) => { - if (fieldIndex === undefined) { - setTextInputValue(''); - setTextInputCursorOffset(0); - return; - } - const field = schemaFields[fieldIndex]; - if (field && isTextField(field.schema) && !isEnumSchema(field.schema)) { - const val_0 = formValues[field.name]; - const text = val_0 !== undefined ? String(val_0) : ''; - setTextInputValue(text); - setTextInputCursorOffset(text.length); - } - }, [schemaFields, formValues]); - function validateMultiSelect(fieldName: string, schema_0: PrimitiveSchemaDefinition) { - if (!isMultiSelectEnumSchema(schema_0)) return; - const selected = formValues[fieldName] as string[] | undefined ?? []; - const fieldRequired = schemaFields.find(f => f.name === fieldName)?.isRequired ?? false; - const min = schema_0.minItems; - const max = schema_0.maxItems; + const syncTextInput = useCallback( + (fieldIndex: number | undefined) => { + if (fieldIndex === undefined) { + setTextInputValue('') + setTextInputCursorOffset(0) + return + } + const field = schemaFields[fieldIndex] + if (field && isTextField(field.schema) && !isEnumSchema(field.schema)) { + const val = formValues[field.name] + const text = val !== undefined ? String(val) : '' + setTextInputValue(text) + setTextInputCursorOffset(text.length) + } + }, + [schemaFields, formValues], + ) + + function validateMultiSelect( + fieldName: string, + schema: PrimitiveSchemaDefinition, + ) { + if (!isMultiSelectEnumSchema(schema)) return + const selected = (formValues[fieldName] as string[] | undefined) ?? [] + const fieldRequired = + schemaFields.find(f => f.name === fieldName)?.isRequired ?? false + const min = schema.minItems + const max = schema.maxItems // Skip minItems check when field is optional and unset - if (min !== undefined && selected.length < min && (selected.length > 0 || fieldRequired)) { - updateValidationError(fieldName, `Select at least ${min} ${plural(min, 'item')}`); + if ( + min !== undefined && + selected.length < min && + (selected.length > 0 || fieldRequired) + ) { + updateValidationError( + fieldName, + `Select at least ${min} ${plural(min, 'item')}`, + ) } else if (max !== undefined && selected.length > max) { - updateValidationError(fieldName, `Select at most ${max} ${plural(max, 'item')}`); + updateValidationError( + fieldName, + `Select at most ${max} ${plural(max, 'item')}`, + ) } else { - updateValidationError(fieldName); + updateValidationError(fieldName) } } + function handleNavigation(direction: 'up' | 'down'): void { // Collapse accordion and validate on navigate away if (currentField && isMultiSelectEnumSchema(currentField.schema)) { - validateMultiSelect(currentField.name, currentField.schema); - setExpandedAccordion(undefined); + validateMultiSelect(currentField.name, currentField.schema) + setExpandedAccordion(undefined) } else if (currentField && isEnumSchema(currentField.schema)) { - setExpandedAccordion(undefined); + setExpandedAccordion(undefined) } // Commit current text field before navigating away if (isEditingTextField && currentField) { - commitTextField(currentField.name, currentField.schema, textInputValue); + commitTextField(currentField.name, currentField.schema, textInputValue) // Cancel any pending debounce — we're resolving now on navigate-away if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current); - dateDebounceRef.current = undefined; + clearTimeout(dateDebounceRef.current) + dateDebounceRef.current = undefined } // For date/datetime fields that failed sync validation, try async NL parsing - if (isDateTimeSchema(currentField.schema) && textInputValue.trim() !== '' && validationErrors[currentField.name]) { - resolveFieldAsync(currentField.name, currentField.schema, textInputValue); + if ( + isDateTimeSchema(currentField.schema) && + textInputValue.trim() !== '' && + validationErrors[currentField.name] + ) { + resolveFieldAsync( + currentField.name, + currentField.schema, + textInputValue, + ) } } // Fields + accept + decline - const itemCount = schemaFields.length + 2; - const index = currentFieldIndex ?? (focusedButton === 'accept' ? schemaFields.length : focusedButton === 'decline' ? schemaFields.length + 1 : undefined); - const nextIndex = index !== undefined ? (index + (direction === 'up' ? itemCount - 1 : 1)) % itemCount : 0; + const itemCount = schemaFields.length + 2 + const index = + currentFieldIndex ?? + (focusedButton === 'accept' + ? schemaFields.length + : focusedButton === 'decline' + ? schemaFields.length + 1 + : undefined) + const nextIndex = + index !== undefined + ? (index + (direction === 'up' ? itemCount - 1 : 1)) % itemCount + : 0 if (nextIndex < schemaFields.length) { - setCurrentFieldIndex(nextIndex); - setFocusedButton(null); - syncTextInput(nextIndex); + setCurrentFieldIndex(nextIndex) + setFocusedButton(null) + syncTextInput(nextIndex) } else { - setCurrentFieldIndex(undefined); - setFocusedButton(nextIndex === schemaFields.length ? 'accept' : 'decline'); - setTextInputValue(''); + setCurrentFieldIndex(undefined) + setFocusedButton(nextIndex === schemaFields.length ? 'accept' : 'decline') + setTextInputValue('') } } - function setField(fieldName_0: string, value: number | string | boolean | string[] | undefined) { + + function setField( + fieldName: string, + value: number | string | boolean | string[] | undefined, + ) { setFormValues(prev => { - const next = { - ...prev - }; + const next = { ...prev } if (value === undefined) { - delete next[fieldName_0]; + delete next[fieldName] } else { - next[fieldName_0] = value; + next[fieldName] = value } - return next; - }); + return next + }) // Clear "required" error when a value is provided - if (value !== undefined && validationErrors[fieldName_0] === 'This field is required') { - updateValidationError(fieldName_0); + if ( + value !== undefined && + validationErrors[fieldName] === 'This field is required' + ) { + updateValidationError(fieldName) } } - function updateValidationError(fieldName_1: string, error?: string) { - setValidationErrors(prev_0 => { - const next_0 = { - ...prev_0 - }; + + function updateValidationError(fieldName: string, error?: string) { + setValidationErrors(prev => { + const next = { ...prev } if (error) { - next_0[fieldName_1] = error; + next[fieldName] = error } else { - delete next_0[fieldName_1]; + delete next[fieldName] } - return next_0; - }); + return next + }) } - function unsetField(fieldName_2: string) { - if (!fieldName_2) return; - setField(fieldName_2, undefined); - updateValidationError(fieldName_2); - setTextInputValue(''); - setTextInputCursorOffset(0); + + function unsetField(fieldName: string) { + if (!fieldName) return + setField(fieldName, undefined) + updateValidationError(fieldName) + setTextInputValue('') + setTextInputCursorOffset(0) } - function commitTextField(fieldName_3: string, schema_1: PrimitiveSchemaDefinition, value_0: string) { - const trimmedValue = value_0.trim(); + + function commitTextField( + fieldName: string, + schema: PrimitiveSchemaDefinition, + value: string, + ) { + const trimmedValue = value.trim() // Empty input for non-plain-string types means unset - if (trimmedValue === '' && (schema_1.type !== 'string' || 'format' in schema_1 && schema_1.format !== undefined)) { - unsetField(fieldName_3); - return; + if ( + trimmedValue === '' && + (schema.type !== 'string' || + ('format' in schema && schema.format !== undefined)) + ) { + unsetField(fieldName) + return } + if (trimmedValue === '') { // Empty plain string — keep or unset depending on whether it was set - if (formValues[fieldName_3] !== undefined) { - setField(fieldName_3, ''); + if (formValues[fieldName] !== undefined) { + setField(fieldName, '') } - return; + return } - const validation_0 = validateElicitationInput(value_0, schema_1); - setField(fieldName_3, validation_0.isValid ? validation_0.value : value_0); - updateValidationError(fieldName_3, validation_0.isValid ? undefined : validation_0.error); + + const validation = validateElicitationInput(value, schema) + setField(fieldName, validation.isValid ? validation.value : value) + updateValidationError( + fieldName, + validation.isValid ? undefined : validation.error, + ) } - function resolveFieldAsync(fieldName_4: string, schema_2: PrimitiveSchemaDefinition, rawValue: string) { - if (!signal) return; + + function resolveFieldAsync( + fieldName: string, + schema: PrimitiveSchemaDefinition, + rawValue: string, + ) { + if (!signal) return // Abort any existing resolution for this field - const existing = resolveAbortRef.current.get(fieldName_4); + const existing = resolveAbortRef.current.get(fieldName) if (existing) { - existing.abort(); + existing.abort() } - const controller_0 = new AbortController(); - resolveAbortRef.current.set(fieldName_4, controller_0); - setResolvingFields(prev_1 => new Set(prev_1).add(fieldName_4)); - void validateElicitationInputAsync(rawValue, schema_2, controller_0.signal).then(result => { - resolveAbortRef.current.delete(fieldName_4); - setResolvingFields(prev_2 => { - const next_1 = new Set(prev_2); - next_1.delete(fieldName_4); - return next_1; - }); - if (controller_0.signal.aborted) return; - if (result.isValid) { - setField(fieldName_4, result.value); - updateValidationError(fieldName_4); - // Update the text input if we're still on this field - const isoText = String(result.value); - setTextInputValue(prev_3 => { - // Only replace if the field is still showing the raw input - if (prev_3 === rawValue) { - setTextInputCursorOffset(isoText.length); - return isoText; - } - return prev_3; - }); - } else { - // Keep raw text, show validation error - updateValidationError(fieldName_4, result.error); - } - }, () => { - resolveAbortRef.current.delete(fieldName_4); - setResolvingFields(prev_4 => { - const next_2 = new Set(prev_4); - next_2.delete(fieldName_4); - return next_2; - }); - }); + + const controller = new AbortController() + resolveAbortRef.current.set(fieldName, controller) + + setResolvingFields(prev => new Set(prev).add(fieldName)) + + void validateElicitationInputAsync( + rawValue, + schema, + controller.signal, + ).then( + result => { + resolveAbortRef.current.delete(fieldName) + setResolvingFields(prev => { + const next = new Set(prev) + next.delete(fieldName) + return next + }) + if (controller.signal.aborted) return + + if (result.isValid) { + setField(fieldName, result.value) + updateValidationError(fieldName) + // Update the text input if we're still on this field + const isoText = String(result.value) + setTextInputValue(prev => { + // Only replace if the field is still showing the raw input + if (prev === rawValue) { + setTextInputCursorOffset(isoText.length) + return isoText + } + return prev + }) + } else { + // Keep raw text, show validation error + updateValidationError(fieldName, result.error) + } + }, + () => { + resolveAbortRef.current.delete(fieldName) + setResolvingFields(prev => { + const next = new Set(prev) + next.delete(fieldName) + return next + }) + }, + ) } + function handleTextInputChange(newValue: string) { - setTextInputValue(newValue); + setTextInputValue(newValue) // Commit immediately on each keystroke (sync validation) if (currentField) { - commitTextField(currentField.name, currentField.schema, newValue); + commitTextField(currentField.name, currentField.schema, newValue) // For date/datetime fields, debounce async NL parsing after 2s of inactivity if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current); - dateDebounceRef.current = undefined; + clearTimeout(dateDebounceRef.current) + dateDebounceRef.current = undefined } - if (isDateTimeSchema(currentField.schema) && newValue.trim() !== '' && validationErrors[currentField.name]) { - const fieldName_5 = currentField.name; - const schema_3 = currentField.schema; - dateDebounceRef.current = setTimeout((dateDebounceRef_0, resolveFieldAsync_0, fieldName_6, schema_4, newValue_0) => { - dateDebounceRef_0.current = undefined; - resolveFieldAsync_0(fieldName_6, schema_4, newValue_0); - }, 2000, dateDebounceRef, resolveFieldAsync, fieldName_5, schema_3, newValue); + if ( + isDateTimeSchema(currentField.schema) && + newValue.trim() !== '' && + validationErrors[currentField.name] + ) { + const fieldName = currentField.name + const schema = currentField.schema + dateDebounceRef.current = setTimeout( + (dateDebounceRef, resolveFieldAsync, fieldName, schema, newValue) => { + dateDebounceRef.current = undefined + resolveFieldAsync(fieldName, schema, newValue) + }, + 2000, + dateDebounceRef, + resolveFieldAsync, + fieldName, + schema, + newValue, + ) } } } + function handleTextInputSubmit() { - handleNavigation('down'); + handleNavigation('down') } /** @@ -456,290 +573,337 @@ function ElicitationFormDialog({ * call `onMatch` with the index of the first label that prefix-matches. * Shared by boolean y/n, enum accordion, and multi-select accordion. */ - function runTypeahead(char: string, labels: string[], onMatch: (index: number) => void) { - const ta_0 = enumTypeaheadRef.current; - if (ta_0.timer !== undefined) clearTimeout(ta_0.timer); - ta_0.buffer += char.toLowerCase(); - ta_0.timer = setTimeout(resetTypeahead, 2000, ta_0); - const match = labels.findIndex(l => l.startsWith(ta_0.buffer)); - if (match !== -1) onMatch(match); + function runTypeahead( + char: string, + labels: string[], + onMatch: (index: number) => void, + ) { + const ta = enumTypeaheadRef.current + if (ta.timer !== undefined) clearTimeout(ta.timer) + ta.buffer += char.toLowerCase() + ta.timer = setTimeout(resetTypeahead, 2000, ta) + const match = labels.findIndex(l => l.startsWith(ta.buffer)) + if (match !== -1) onMatch(match) } // Esc while a field is focused: cancel the dialog. // Uses Settings context (escape-only, no 'n' key) since Dialog's // Confirmation-context cancel is suppressed when a field is focused. - useKeybinding('confirm:no', () => { - // For text fields, revert uncommitted changes first - if (isEditingTextField && currentField) { - const val_1 = formValues[currentField.name]; - setTextInputValue(val_1 !== undefined ? String(val_1) : ''); - setTextInputCursorOffset(0); - } - onResponse('cancel'); - }, { - context: 'Settings', - isActive: !!currentField && !focusedButton && !expandedAccordion - }); - useInput((_input, key) => { - // Text fields handle their own character input; we only intercept - // navigation keys and backspace-on-empty here. - if (isEditingTextField && !key.upArrow && !key.downArrow && !key.return && !key.backspace) { - return; - } + useKeybinding( + 'confirm:no', + () => { + // For text fields, revert uncommitted changes first + if (isEditingTextField && currentField) { + const val = formValues[currentField.name] + setTextInputValue(val !== undefined ? String(val) : '') + setTextInputCursorOffset(0) + } + onResponse('cancel') + }, + { + context: 'Settings', + isActive: !!currentField && !focusedButton && !expandedAccordion, + }, + ) - // Expanded multi-select accordion - if (expandedAccordion && currentField && isMultiSelectEnumSchema(currentField.schema)) { - const msSchema = currentField.schema; - const msValues = getMultiSelectValues(msSchema); - const selected_0 = formValues[currentField.name] as string[] ?? []; - if (key.leftArrow || key.escape) { - setExpandedAccordion(undefined); - validateMultiSelect(currentField.name, msSchema); - return; + useInput( + (_input, key) => { + // Text fields handle their own character input; we only intercept + // navigation keys and backspace-on-empty here. + if ( + isEditingTextField && + !key.upArrow && + !key.downArrow && + !key.return && + !key.backspace + ) { + return } - if (key.upArrow) { - if (accordionOptionIndex === 0) { - setExpandedAccordion(undefined); - validateMultiSelect(currentField.name, msSchema); - } else { - setAccordionOptionIndex(accordionOptionIndex - 1); + + // Expanded multi-select accordion + if ( + expandedAccordion && + currentField && + isMultiSelectEnumSchema(currentField.schema) + ) { + const msSchema = currentField.schema + const msValues = getMultiSelectValues(msSchema) + const selected = (formValues[currentField.name] as string[]) ?? [] + + if (key.leftArrow || key.escape) { + setExpandedAccordion(undefined) + validateMultiSelect(currentField.name, msSchema) + return } - return; - } - if (key.downArrow) { - if (accordionOptionIndex >= msValues.length - 1) { - setExpandedAccordion(undefined); - handleNavigation('down'); - } else { - setAccordionOptionIndex(accordionOptionIndex + 1); + if (key.upArrow) { + if (accordionOptionIndex === 0) { + setExpandedAccordion(undefined) + validateMultiSelect(currentField.name, msSchema) + } else { + setAccordionOptionIndex(accordionOptionIndex - 1) + } + return } - return; - } - if (_input === ' ') { - const optionValue = msValues[accordionOptionIndex]; - if (optionValue !== undefined) { - const newSelected = selected_0.includes(optionValue) ? selected_0.filter(v => v !== optionValue) : [...selected_0, optionValue]; - const newValue_1 = newSelected.length > 0 ? newSelected : undefined; - setField(currentField.name, newValue_1); - const min_0 = msSchema.minItems; - const max_0 = msSchema.maxItems; - if (min_0 !== undefined && newSelected.length < min_0 && (newSelected.length > 0 || currentField.isRequired)) { - updateValidationError(currentField.name, `Select at least ${min_0} ${plural(min_0, 'item')}`); - } else if (max_0 !== undefined && newSelected.length > max_0) { - updateValidationError(currentField.name, `Select at most ${max_0} ${plural(max_0, 'item')}`); + if (key.downArrow) { + if (accordionOptionIndex >= msValues.length - 1) { + setExpandedAccordion(undefined) + handleNavigation('down') } else { - updateValidationError(currentField.name); + setAccordionOptionIndex(accordionOptionIndex + 1) } + return } - return; - } - if (key.return) { - // Check (not toggle) the focused item, then collapse and advance - const optionValue_0 = msValues[accordionOptionIndex]; - if (optionValue_0 !== undefined && !selected_0.includes(optionValue_0)) { - setField(currentField.name, [...selected_0, optionValue_0]); + if (_input === ' ') { + const optionValue = msValues[accordionOptionIndex] + if (optionValue !== undefined) { + const newSelected = selected.includes(optionValue) + ? selected.filter(v => v !== optionValue) + : [...selected, optionValue] + const newValue = newSelected.length > 0 ? newSelected : undefined + setField(currentField.name, newValue) + const min = msSchema.minItems + const max = msSchema.maxItems + if ( + min !== undefined && + newSelected.length < min && + (newSelected.length > 0 || currentField.isRequired) + ) { + updateValidationError( + currentField.name, + `Select at least ${min} ${plural(min, 'item')}`, + ) + } else if (max !== undefined && newSelected.length > max) { + updateValidationError( + currentField.name, + `Select at most ${max} ${plural(max, 'item')}`, + ) + } else { + updateValidationError(currentField.name) + } + } + return } - setExpandedAccordion(undefined); - handleNavigation('down'); - return; - } - if (_input) { - const labels_0 = msValues.map(v_0 => getMultiSelectLabel(msSchema, v_0).toLowerCase()); - runTypeahead(_input, labels_0, setAccordionOptionIndex); - return; + if (key.return) { + // Check (not toggle) the focused item, then collapse and advance + const optionValue = msValues[accordionOptionIndex] + if (optionValue !== undefined && !selected.includes(optionValue)) { + setField(currentField.name, [...selected, optionValue]) + } + setExpandedAccordion(undefined) + handleNavigation('down') + return + } + if (_input) { + const labels = msValues.map(v => + getMultiSelectLabel(msSchema, v).toLowerCase(), + ) + runTypeahead(_input, labels, setAccordionOptionIndex) + return + } + return } - return; - } - // Expanded single-select enum accordion - if (expandedAccordion && currentField && isEnumSchema(currentField.schema)) { - const enumSchema = currentField.schema; - const enumValues = getEnumValues(enumSchema); - if (key.leftArrow || key.escape) { - setExpandedAccordion(undefined); - return; - } - if (key.upArrow) { - if (accordionOptionIndex === 0) { - setExpandedAccordion(undefined); - } else { - setAccordionOptionIndex(accordionOptionIndex - 1); + // Expanded single-select enum accordion + if ( + expandedAccordion && + currentField && + isEnumSchema(currentField.schema) + ) { + const enumSchema = currentField.schema + const enumValues = getEnumValues(enumSchema) + + if (key.leftArrow || key.escape) { + setExpandedAccordion(undefined) + return } - return; - } - if (key.downArrow) { - if (accordionOptionIndex >= enumValues.length - 1) { - setExpandedAccordion(undefined); - handleNavigation('down'); - } else { - setAccordionOptionIndex(accordionOptionIndex + 1); + if (key.upArrow) { + if (accordionOptionIndex === 0) { + setExpandedAccordion(undefined) + } else { + setAccordionOptionIndex(accordionOptionIndex - 1) + } + return } - return; - } - // Space: select and collapse - if (_input === ' ') { - const optionValue_1 = enumValues[accordionOptionIndex]; - if (optionValue_1 !== undefined) { - setField(currentField.name, optionValue_1); + if (key.downArrow) { + if (accordionOptionIndex >= enumValues.length - 1) { + setExpandedAccordion(undefined) + handleNavigation('down') + } else { + setAccordionOptionIndex(accordionOptionIndex + 1) + } + return } - setExpandedAccordion(undefined); - return; - } - // Enter: select, collapse, and move to next field - if (key.return) { - const optionValue_2 = enumValues[accordionOptionIndex]; - if (optionValue_2 !== undefined) { - setField(currentField.name, optionValue_2); + // Space: select and collapse + if (_input === ' ') { + const optionValue = enumValues[accordionOptionIndex] + if (optionValue !== undefined) { + setField(currentField.name, optionValue) + } + setExpandedAccordion(undefined) + return } - setExpandedAccordion(undefined); - handleNavigation('down'); - return; - } - if (_input) { - const labels_1 = enumValues.map(v_1 => getEnumLabel(enumSchema, v_1).toLowerCase()); - runTypeahead(_input, labels_1, setAccordionOptionIndex); - return; - } - return; - } - - // Accept / Decline buttons - if (key.return && focusedButton === 'accept') { - if (validateRequired() && Object.keys(validationErrors).length === 0) { - onResponse('accept', formValues); - } else { - // Show "required" validation errors on missing fields - const requiredFields_0 = requestedSchema.required || []; - for (const fieldName_7 of requiredFields_0) { - if (formValues[fieldName_7] === undefined) { - updateValidationError(fieldName_7, 'This field is required'); + // Enter: select, collapse, and move to next field + if (key.return) { + const optionValue = enumValues[accordionOptionIndex] + if (optionValue !== undefined) { + setField(currentField.name, optionValue) } + setExpandedAccordion(undefined) + handleNavigation('down') + return } - const firstBadIndex = schemaFields.findIndex(f_0 => requiredFields_0.includes(f_0.name) && formValues[f_0.name] === undefined || validationErrors[f_0.name] !== undefined); - if (firstBadIndex !== -1) { - setCurrentFieldIndex(firstBadIndex); - setFocusedButton(null); - syncTextInput(firstBadIndex); + if (_input) { + const labels = enumValues.map(v => + getEnumLabel(enumSchema, v).toLowerCase(), + ) + runTypeahead(_input, labels, setAccordionOptionIndex) + return } + return } - return; - } - if (key.return && focusedButton === 'decline') { - onResponse('decline'); - return; - } - // Up/Down navigation - if (key.upArrow || key.downArrow) { - // Reset enum typeahead when leaving a field - const ta_1 = enumTypeaheadRef.current; - ta_1.buffer = ''; - if (ta_1.timer !== undefined) { - clearTimeout(ta_1.timer); - ta_1.timer = undefined; + // Accept / Decline buttons + if (key.return && focusedButton === 'accept') { + if (validateRequired() && Object.keys(validationErrors).length === 0) { + onResponse('accept', formValues) + } else { + // Show "required" validation errors on missing fields + const requiredFields = requestedSchema.required || [] + for (const fieldName of requiredFields) { + if (formValues[fieldName] === undefined) { + updateValidationError(fieldName, 'This field is required') + } + } + const firstBadIndex = schemaFields.findIndex( + f => + (requiredFields.includes(f.name) && + formValues[f.name] === undefined) || + validationErrors[f.name] !== undefined, + ) + if (firstBadIndex !== -1) { + setCurrentFieldIndex(firstBadIndex) + setFocusedButton(null) + syncTextInput(firstBadIndex) + } + } + return } - handleNavigation(key.upArrow ? 'up' : 'down'); - return; - } - // Left/Right to switch between Accept and Decline buttons - if (focusedButton && (key.leftArrow || key.rightArrow)) { - setFocusedButton(focusedButton === 'accept' ? 'decline' : 'accept'); - return; - } - if (!currentField) return; - const { - schema: schema_5, - name: name_0 - } = currentField; - const value_1 = formValues[name_0]; - - // Boolean: Space to toggle, Enter to move on - if (schema_5.type === 'boolean') { - if (_input === ' ') { - setField(name_0, value_1 === undefined ? true : !value_1); - return; - } - if (key.return) { - handleNavigation('down'); - return; - } - if (key.backspace && value_1 !== undefined) { - unsetField(name_0); - return; - } - // y/n typeahead - if (_input && !key.return) { - runTypeahead(_input, ['yes', 'no'], i => setField(name_0, i === 0)); - return; + if (key.return && focusedButton === 'decline') { + onResponse('decline') + return } - return; - } - // Enum or multi-select (collapsed) — accordion style - if (isEnumSchema(schema_5) || isMultiSelectEnumSchema(schema_5)) { - if (key.return) { - handleNavigation('down'); - return; + // Up/Down navigation + if (key.upArrow || key.downArrow) { + // Reset enum typeahead when leaving a field + const ta = enumTypeaheadRef.current + ta.buffer = '' + if (ta.timer !== undefined) { + clearTimeout(ta.timer) + ta.timer = undefined + } + handleNavigation(key.upArrow ? 'up' : 'down') + return } - if (key.backspace && value_1 !== undefined) { - unsetField(name_0); - return; + + // Left/Right to switch between Accept and Decline buttons + if (focusedButton && (key.leftArrow || key.rightArrow)) { + setFocusedButton(focusedButton === 'accept' ? 'decline' : 'accept') + return } - // Compute option labels + initial focus index for rightArrow expand. - // Single-select focuses on the current value; multi-select starts at 0. - let labels_2: string[]; - let startIdx = 0; - if (isEnumSchema(schema_5)) { - const vals = getEnumValues(schema_5); - labels_2 = vals.map(v_2 => getEnumLabel(schema_5, v_2).toLowerCase()); - if (value_1 !== undefined) { - startIdx = Math.max(0, vals.indexOf(value_1 as string)); + + if (!currentField) return + const { schema, name } = currentField + const value = formValues[name] + + // Boolean: Space to toggle, Enter to move on + if (schema.type === 'boolean') { + if (_input === ' ') { + setField(name, value === undefined ? true : !value) + return } - } else { - const vals_0 = getMultiSelectValues(schema_5); - labels_2 = vals_0.map(v_3 => getMultiSelectLabel(schema_5, v_3).toLowerCase()); - } - if (key.rightArrow) { - setExpandedAccordion(name_0); - setAccordionOptionIndex(startIdx); - return; + if (key.return) { + handleNavigation('down') + return + } + if (key.backspace && value !== undefined) { + unsetField(name) + return + } + // y/n typeahead + if (_input && !key.return) { + runTypeahead(_input, ['yes', 'no'], i => setField(name, i === 0)) + return + } + return } - // Typeahead: expand and jump to matching option - if (_input && !key.leftArrow) { - runTypeahead(_input, labels_2, i_0 => { - setExpandedAccordion(name_0); - setAccordionOptionIndex(i_0); - }); - return; + + // Enum or multi-select (collapsed) — accordion style + if (isEnumSchema(schema) || isMultiSelectEnumSchema(schema)) { + if (key.return) { + handleNavigation('down') + return + } + if (key.backspace && value !== undefined) { + unsetField(name) + return + } + // Compute option labels + initial focus index for rightArrow expand. + // Single-select focuses on the current value; multi-select starts at 0. + let labels: string[] + let startIdx = 0 + if (isEnumSchema(schema)) { + const vals = getEnumValues(schema) + labels = vals.map(v => getEnumLabel(schema, v).toLowerCase()) + if (value !== undefined) { + startIdx = Math.max(0, vals.indexOf(value as string)) + } + } else { + const vals = getMultiSelectValues(schema) + labels = vals.map(v => getMultiSelectLabel(schema, v).toLowerCase()) + } + if (key.rightArrow) { + setExpandedAccordion(name) + setAccordionOptionIndex(startIdx) + return + } + // Typeahead: expand and jump to matching option + if (_input && !key.leftArrow) { + runTypeahead(_input, labels, i => { + setExpandedAccordion(name) + setAccordionOptionIndex(i) + }) + return + } + return } - return; - } - // Backspace: text fields when empty - if (key.backspace) { - if (isEditingTextField && textInputValue === '') { - unsetField(name_0); - return; + // Backspace: text fields when empty + if (key.backspace) { + if (isEditingTextField && textInputValue === '') { + unsetField(name) + return + } } - } - // Text field Enter is handled by TextInput's onSubmit - }, { - isActive: true - }); + // Text field Enter is handled by TextInput's onSubmit + }, + { isActive: true }, + ) + function validateRequired(): boolean { - const requiredFields_1 = requestedSchema.required || []; - for (const fieldName_8 of requiredFields_1) { - const value_2 = formValues[fieldName_8]; - if (value_2 === undefined || value_2 === null || value_2 === '') { - return false; + const requiredFields = requestedSchema.required || [] + for (const fieldName of requiredFields) { + const value = formValues[fieldName] + if (value === undefined || value === null || value === '') { + return false } - if (Array.isArray(value_2) && value_2.length === 0) { - return false; + if (Array.isArray(value) && value.length === 0) { + return false } } - return true; + return true } // Scroll windowing: compute visible field range @@ -751,179 +915,268 @@ function ElicitationFormDialog({ // To generalize: track per-field height (3 for collapsed, N+3 for // expanded multi-select) and compute a pixel-budget window instead // of a simple item-count window. - const LINES_PER_FIELD = 3; - const DIALOG_OVERHEAD = 14; - const maxVisibleFields = Math.max(2, Math.floor((rows - DIALOG_OVERHEAD) / LINES_PER_FIELD)); + const LINES_PER_FIELD = 3 + const DIALOG_OVERHEAD = 14 + const maxVisibleFields = Math.max( + 2, + Math.floor((rows - DIALOG_OVERHEAD) / LINES_PER_FIELD), + ) + const scrollWindow = useMemo(() => { - const total = schemaFields.length; + const total = schemaFields.length if (total <= maxVisibleFields) { - return { - start: 0, - end: total - }; + return { start: 0, end: total } } // When buttons are focused (currentFieldIndex undefined), pin to end - const focusIdx = currentFieldIndex ?? total - 1; - let start = Math.max(0, focusIdx - Math.floor(maxVisibleFields / 2)); - const end = Math.min(start + maxVisibleFields, total); + const focusIdx = currentFieldIndex ?? total - 1 + let start = Math.max(0, focusIdx - Math.floor(maxVisibleFields / 2)) + const end = Math.min(start + maxVisibleFields, total) // Adjust start if we hit the bottom - start = Math.max(0, end - maxVisibleFields); - return { - start, - end - }; - }, [schemaFields.length, maxVisibleFields, currentFieldIndex]); - const hasFieldsAbove = scrollWindow.start > 0; - const hasFieldsBelow = scrollWindow.end < schemaFields.length; + start = Math.max(0, end - maxVisibleFields) + return { start, end } + }, [schemaFields.length, maxVisibleFields, currentFieldIndex]) + + const hasFieldsAbove = scrollWindow.start > 0 + const hasFieldsBelow = scrollWindow.end < schemaFields.length + function renderFormFields(): React.ReactNode { - if (!schemaFields.length) return null; - return - {hasFieldsAbove && + if (!schemaFields.length) return null + + return ( + + {hasFieldsAbove && ( + {figures.arrowUp} {scrollWindow.start} more above - } - {schemaFields.slice(scrollWindow.start, scrollWindow.end).map((field_0, visibleIdx) => { - const index_0 = scrollWindow.start + visibleIdx; - const { - name: name_1, - schema: schema_6, - isRequired - } = field_0; - const isActive = index_0 === currentFieldIndex && !focusedButton; - const value_3 = formValues[name_1]; - const hasValue = value_3 !== undefined && (!Array.isArray(value_3) || value_3.length > 0); - const error_0 = validationErrors[name_1]; - - // Checkbox: spinner → ⚠ error → ✔ set → * required → space - const isResolving = resolvingFields.has(name_1); - const checkbox = isResolving ? : error_0 ? {figures.warning} : hasValue ? + + )} + {schemaFields + .slice(scrollWindow.start, scrollWindow.end) + .map((field, visibleIdx) => { + const index = scrollWindow.start + visibleIdx + const { name, schema, isRequired } = field + const isActive = index === currentFieldIndex && !focusedButton + const value = formValues[name] + const hasValue = + value !== undefined && (!Array.isArray(value) || value.length > 0) + const error = validationErrors[name] + + // Checkbox: spinner → ⚠ error → ✔ set → * required → space + const isResolving = resolvingFields.has(name) + const checkbox = isResolving ? ( + + ) : error ? ( + {figures.warning} + ) : hasValue ? ( + {figures.tick} - : isRequired ? * : ; - - // Selection color matches field status - const selectionColor = error_0 ? 'error' : hasValue ? 'success' : isRequired ? 'error' : 'suggestion'; - const activeColor = isActive ? selectionColor : undefined; - const label = - {schema_6.title || name_1} - ; - - // Render the value portion based on field type - let valueContent: React.ReactNode; - let accordionContent: React.ReactNode = null; - if (isMultiSelectEnumSchema(schema_6)) { - const msValues_0 = getMultiSelectValues(schema_6); - const selected_1 = value_3 as string[] | undefined ?? []; - const isExpanded = expandedAccordion === name_1 && isActive; - if (isExpanded) { - valueContent = {figures.triangleDownSmall}; - accordionContent = - {msValues_0.map((optVal, optIdx) => { - const optLabel = getMultiSelectLabel(schema_6, optVal); - const isChecked = selected_1.includes(optVal); - const isFocused = optIdx === accordionOptionIndex; - return + + ) : isRequired ? ( + * + ) : ( + + ) + + // Selection color matches field status + const selectionColor = error + ? 'error' + : hasValue + ? 'success' + : isRequired + ? 'error' + : 'suggestion' + + const activeColor = isActive ? selectionColor : undefined + + const label = ( + + {schema.title || name} + + ) + + // Render the value portion based on field type + let valueContent: React.ReactNode + let accordionContent: React.ReactNode = null + + if (isMultiSelectEnumSchema(schema)) { + const msValues = getMultiSelectValues(schema) + const selected = (value as string[] | undefined) ?? [] + const isExpanded = expandedAccordion === name && isActive + + if (isExpanded) { + valueContent = {figures.triangleDownSmall} + accordionContent = ( + + {msValues.map((optVal, optIdx) => { + const optLabel = getMultiSelectLabel(schema, optVal) + const isChecked = selected.includes(optVal) + const isFocused = optIdx === accordionOptionIndex + return ( + {isFocused ? figures.pointer : ' '} - {isChecked ? figures.checkboxOn : figures.checkboxOff} + {isChecked + ? figures.checkboxOn + : figures.checkboxOff} - + {optLabel} - ; - })} - ; - } else { - // Collapsed: ▸ arrow then comma-joined selected items - const arrow = isActive ? {figures.triangleRightSmall} : null; - if (selected_1.length > 0) { - const displayLabels = selected_1.map(v_4 => getMultiSelectLabel(schema_6, v_4)); - valueContent = + + ) + })} + + ) + } else { + // Collapsed: ▸ arrow then comma-joined selected items + const arrow = isActive ? ( + {figures.triangleRightSmall} + ) : null + if (selected.length > 0) { + const displayLabels = selected.map(v => + getMultiSelectLabel(schema, v), + ) + valueContent = ( + {arrow} {displayLabels.join(', ')} - ; - } else { - valueContent = + + ) + } else { + valueContent = ( + {arrow} not set - ; - } - } - } else if (isEnumSchema(schema_6)) { - const enumValues_0 = getEnumValues(schema_6); - const isExpanded_0 = expandedAccordion === name_1 && isActive; - if (isExpanded_0) { - valueContent = {figures.triangleDownSmall}; - accordionContent = - {enumValues_0.map((optVal_0, optIdx_0) => { - const optLabel_0 = getEnumLabel(schema_6, optVal_0); - const isSelected = value_3 === optVal_0; - const isFocused_0 = optIdx_0 === accordionOptionIndex; - return + + ) + } + } + } else if (isEnumSchema(schema)) { + const enumValues = getEnumValues(schema) + const isExpanded = expandedAccordion === name && isActive + + if (isExpanded) { + valueContent = {figures.triangleDownSmall} + accordionContent = ( + + {enumValues.map((optVal, optIdx) => { + const optLabel = getEnumLabel(schema, optVal) + const isSelected = value === optVal + const isFocused = optIdx === accordionOptionIndex + return ( + - {isFocused_0 ? figures.pointer : ' '} + {isFocused ? figures.pointer : ' '} {isSelected ? figures.radioOn : figures.radioOff} - - {optLabel_0} + + {optLabel} - ; - })} - ; - } else { - // Collapsed: ▸ arrow then current value - const arrow_0 = isActive ? {figures.triangleRightSmall} : null; - if (hasValue) { - valueContent = - {arrow_0} + + ) + })} + + ) + } else { + // Collapsed: ▸ arrow then current value + const arrow = isActive ? ( + {figures.triangleRightSmall} + ) : null + if (hasValue) { + valueContent = ( + + {arrow} - {getEnumLabel(schema_6, value_3 as string)} + {getEnumLabel(schema, value as string)} - ; - } else { - valueContent = - {arrow_0} + + ) + } else { + valueContent = ( + + {arrow} not set - ; - } - } - } else if (schema_6.type === 'boolean') { - if (isActive) { - valueContent = hasValue ? - {value_3 ? figures.checkboxOn : figures.checkboxOff} - : {figures.checkboxOff}; - } else { - valueContent = hasValue ? - {value_3 ? figures.checkboxOn : figures.checkboxOff} - : + + ) + } + } + } else if (schema.type === 'boolean') { + if (isActive) { + valueContent = hasValue ? ( + + {value ? figures.checkboxOn : figures.checkboxOff} + + ) : ( + {figures.checkboxOff} + ) + } else { + valueContent = hasValue ? ( + + {value ? figures.checkboxOn : figures.checkboxOff} + + ) : ( + not set - ; - } - } else if (isTextField(schema_6)) { - if (isActive) { - valueContent = ; - } else { - const displayValue = hasValue && isDateTimeSchema(schema_6) ? formatDateDisplay(String(value_3), schema_6) : String(value_3); - valueContent = hasValue ? {displayValue} : + + ) + } + } else if (isTextField(schema)) { + if (isActive) { + valueContent = ( + + ) + } else { + const displayValue = + hasValue && isDateTimeSchema(schema) + ? formatDateDisplay(String(value), schema) + : String(value) + valueContent = hasValue ? ( + {displayValue} + ) : ( + not set - ; - } - } else { - valueContent = hasValue ? {String(value_3)} : + + ) + } + } else { + valueContent = hasValue ? ( + {String(value)} + ) : ( + not set - ; - } - return + + ) + } + + return ( + {isActive ? figures.pointer : ' '} @@ -936,168 +1189,249 @@ function ElicitationFormDialog({ {accordionContent} - {schema_6.description && - {schema_6.description} - } + {schema.description && ( + + {schema.description} + + )} - {error_0 ? - {error_0} - : } + {error ? ( + + {error} + + ) : ( + + )} - ; - })} - {hasFieldsBelow && + + ) + })} + {hasFieldsBelow && ( + {figures.arrowDown} {schemaFields.length - scrollWindow.end} more below - } - ; + + )} + + ) } - return onResponse('cancel')} isCancelActive={(!currentField || !!focusedButton) && !expandedAccordion} inputGuide={exitState => exitState.pending ? Press {exitState.keyName} again to exit : - + + return ( + onResponse('cancel')} + isCancelActive={(!currentField || !!focusedButton) && !expandedAccordion} + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + - {currentField && } - {currentField && currentField.schema.type === 'boolean' && } - {currentField && isEnumSchema(currentField.schema) && (expandedAccordion ? : )} - {currentField && isMultiSelectEnumSchema(currentField.schema) && (expandedAccordion ? : )} - }> + {currentField && ( + + )} + {currentField && currentField.schema.type === 'boolean' && ( + + )} + {currentField && + isEnumSchema(currentField.schema) && + (expandedAccordion ? ( + + ) : ( + + ))} + {currentField && + isMultiSelectEnumSchema(currentField.schema) && + (expandedAccordion ? ( + + ) : ( + + ))} + + ) + } + > {renderFormFields()} {focusedButton === 'accept' ? figures.pointer : ' '} - + {' Accept '} {focusedButton === 'decline' ? figures.pointer : ' '} - + {' Decline'} - ; + + ) } + function ElicitationURLDialog({ event, onResponse, - onWaitingDismiss + onWaitingDismiss, }: { - event: ElicitationRequestEvent; - onResponse: Props['onResponse']; - onWaitingDismiss: Props['onWaitingDismiss']; + event: ElicitationRequestEvent + onResponse: Props['onResponse'] + onWaitingDismiss: Props['onWaitingDismiss'] }): React.ReactNode { - const { - serverName, - signal, - waitingState - } = event; - const urlParams = event.params as ElicitRequestURLParams; - const { - message, - url - } = urlParams; - const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt'); - const phaseRef = useRef<'prompt' | 'waiting'>('prompt'); - const [focusedButton, setFocusedButton] = useState<'accept' | 'decline' | 'open' | 'action' | 'cancel'>('accept'); - const showCancel = waitingState?.showCancel ?? false; - useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_url_dialog'); - useRegisterOverlay('elicitation-url', undefined); + const { serverName, signal, waitingState } = event + const urlParams = event.params as ElicitRequestURLParams + const { message, url } = urlParams + const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') + const phaseRef = useRef<'prompt' | 'waiting'>('prompt') + const [focusedButton, setFocusedButton] = useState< + 'accept' | 'decline' | 'open' | 'action' | 'cancel' + >('accept') + const showCancel = waitingState?.showCancel ?? false + + useNotifyAfterTimeout( + 'Claude Code needs your input', + 'elicitation_url_dialog', + ) + useRegisterOverlay('elicitation-url') // Keep refs in sync for use in abort handler (avoids re-registering listener) - phaseRef.current = phase; - const onWaitingDismissRef = useRef(onWaitingDismiss); - onWaitingDismissRef.current = onWaitingDismiss; + phaseRef.current = phase + const onWaitingDismissRef = useRef(onWaitingDismiss) + onWaitingDismissRef.current = onWaitingDismiss + useEffect(() => { const handleAbort = () => { if (phaseRef.current === 'waiting') { - onWaitingDismissRef.current?.('cancel'); + onWaitingDismissRef.current?.('cancel') } else { - onResponse('cancel'); + onResponse('cancel') } - }; + } if (signal.aborted) { - handleAbort(); - return; + handleAbort() + return } - signal.addEventListener('abort', handleAbort); - return () => signal.removeEventListener('abort', handleAbort); - }, [signal, onResponse]); + signal.addEventListener('abort', handleAbort) + return () => signal.removeEventListener('abort', handleAbort) + }, [signal, onResponse]) // Parse URL to highlight the domain - let domain = ''; - let urlBeforeDomain = ''; - let urlAfterDomain = ''; + let domain = '' + let urlBeforeDomain = '' + let urlAfterDomain = '' try { - const parsed = new URL(url); - domain = parsed.hostname; - const domainStart = url.indexOf(domain); - urlBeforeDomain = url.slice(0, domainStart); - urlAfterDomain = url.slice(domainStart + domain.length); + const parsed = new URL(url) + domain = parsed.hostname + const domainStart = url.indexOf(domain) + urlBeforeDomain = url.slice(0, domainStart) + urlAfterDomain = url.slice(domainStart + domain.length) } catch { - domain = url; + domain = url } // Auto-dismiss when the server sends a completion notification (sets completed flag) useEffect(() => { if (phase === 'waiting' && event.completed) { - onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss'); + onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') } - }, [phase, event.completed, onWaitingDismiss, showCancel]); + }, [phase, event.completed, onWaitingDismiss, showCancel]) + const handleAccept = useCallback(() => { - void openBrowser(url); - onResponse('accept'); - setPhase('waiting'); - phaseRef.current = 'waiting'; - setFocusedButton('open'); - }, [onResponse, url]); + void openBrowser(url) + onResponse('accept') + setPhase('waiting') + phaseRef.current = 'waiting' + setFocusedButton('open') + }, [onResponse, url]) // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for button navigation useInput((_input, key) => { if (phase === 'prompt') { if (key.leftArrow || key.rightArrow) { - setFocusedButton(prev => prev === 'accept' ? 'decline' : 'accept'); - return; + setFocusedButton(prev => (prev === 'accept' ? 'decline' : 'accept')) + return } if (key.return) { if (focusedButton === 'accept') { - handleAccept(); + handleAccept() } else { - onResponse('decline'); + onResponse('decline') } } } else { // waiting phase — cycle through buttons - type ButtonName = 'accept' | 'decline' | 'open' | 'action' | 'cancel'; - const waitingButtons: readonly ButtonName[] = showCancel ? ['open', 'action', 'cancel'] : ['open', 'action']; + type ButtonName = 'accept' | 'decline' | 'open' | 'action' | 'cancel' + const waitingButtons: readonly ButtonName[] = showCancel + ? ['open', 'action', 'cancel'] + : ['open', 'action'] if (key.leftArrow || key.rightArrow) { - setFocusedButton(prev_0 => { - const idx = waitingButtons.indexOf(prev_0); - const delta = key.rightArrow ? 1 : -1; - return waitingButtons[(idx + delta + waitingButtons.length) % waitingButtons.length]!; - }); - return; + setFocusedButton(prev => { + const idx = waitingButtons.indexOf(prev) + const delta = key.rightArrow ? 1 : -1 + return waitingButtons[ + (idx + delta + waitingButtons.length) % waitingButtons.length + ]! + }) + return } if (key.return) { if (focusedButton === 'open') { - void openBrowser(url); + void openBrowser(url) } else if (focusedButton === 'cancel') { - onWaitingDismiss?.('cancel'); + onWaitingDismiss?.('cancel') } else { - onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss'); + onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') } } } - }); + }) + if (phase === 'waiting') { - const actionLabel = waitingState?.actionLabel ?? 'Continue without waiting'; - return onWaitingDismiss?.('cancel')} isCancelActive inputGuide={exitState => exitState.pending ? Press {exitState.keyName} again to exit : - + const actionLabel = waitingState?.actionLabel ?? 'Continue without waiting' + return ( + onWaitingDismiss?.('cancel')} + isCancelActive + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + - }> + + ) + } + > @@ -1115,32 +1449,67 @@ function ElicitationURLDialog({ {focusedButton === 'open' ? figures.pointer : ' '} - + {' Reopen URL '} {focusedButton === 'action' ? figures.pointer : ' '} - + {` ${actionLabel}`} - {showCancel && <> + {showCancel && ( + <> {focusedButton === 'cancel' ? figures.pointer : ' '} - + {' Cancel'} - } + + )} - ; + + ) } - return onResponse('cancel')} isCancelActive inputGuide={exitState_0 => exitState_0.pending ? Press {exitState_0.keyName} again to exit : - + + return ( + onResponse('cancel')} + isCancelActive + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + - }> + + ) + } + > @@ -1153,16 +1522,25 @@ function ElicitationURLDialog({ {focusedButton === 'accept' ? figures.pointer : ' '} - + {' Accept '} {focusedButton === 'decline' ? figures.pointer : ' '} - + {' Decline'} - ; + + ) } diff --git a/src/components/mcp/MCPAgentServerMenu.tsx b/src/components/mcp/MCPAgentServerMenu.tsx index e56b99d1f..b02f79dac 100644 --- a/src/components/mcp/MCPAgentServerMenu.tsx +++ b/src/components/mcp/MCPAgentServerMenu.tsx @@ -1,24 +1,29 @@ -import figures from 'figures'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { AuthenticationCancelledError, performMCPOAuthFlow } from '../../services/mcp/auth.js'; -import { capitalize } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Spinner } from '../Spinner.js'; -import type { AgentMcpServerInfo } from './types.js'; +import figures from 'figures' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { + AuthenticationCancelledError, + performMCPOAuthFlow, +} from '../../services/mcp/auth.js' +import { capitalize } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Spinner } from '../Spinner.js' +import type { AgentMcpServerInfo } from './types.js' + type Props = { - agentServer: AgentMcpServerInfo; - onCancel: () => void; - onComplete?: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; + agentServer: AgentMcpServerInfo + onCancel: () => void + onComplete?: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} /** * Menu for agent-specific MCP servers. @@ -28,113 +33,165 @@ type Props = { export function MCPAgentServerMenu({ agentServer, onCancel, - onComplete + onComplete, }: Props): React.ReactNode { - const [theme] = useTheme(); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [error, setError] = useState(null); - const [authorizationUrl, setAuthorizationUrl] = useState(null); - const authAbortControllerRef = useRef(null); + const [theme] = useTheme() + const [isAuthenticating, setIsAuthenticating] = useState(false) + const [error, setError] = useState(null) + const [authorizationUrl, setAuthorizationUrl] = useState(null) + const authAbortControllerRef = useRef(null) // Abort OAuth flow on unmount so the callback server is closed even if a // parent component's Esc handler navigates away before ours fires. - useEffect(() => () => authAbortControllerRef.current?.abort(), []); + useEffect(() => () => authAbortControllerRef.current?.abort(), []) // Handle ESC to cancel authentication flow const handleEscCancel = useCallback(() => { if (isAuthenticating) { - authAbortControllerRef.current?.abort(); - authAbortControllerRef.current = null; - setIsAuthenticating(false); - setAuthorizationUrl(null); + authAbortControllerRef.current?.abort() + authAbortControllerRef.current = null + setIsAuthenticating(false) + setAuthorizationUrl(null) } - }, [isAuthenticating]); + }, [isAuthenticating]) + useKeybinding('confirm:no', handleEscCancel, { context: 'Confirmation', - isActive: isAuthenticating - }); + isActive: isAuthenticating, + }) + const handleAuthenticate = useCallback(async () => { if (!agentServer.needsAuth || !agentServer.url) { - return; + return } - setIsAuthenticating(true); - setError(null); - const controller = new AbortController(); - authAbortControllerRef.current = controller; + + setIsAuthenticating(true) + setError(null) + + const controller = new AbortController() + authAbortControllerRef.current = controller + try { // Create a temporary config for OAuth const tempConfig = { type: agentServer.transport as 'http' | 'sse', - url: agentServer.url - }; - await performMCPOAuthFlow(agentServer.name, tempConfig, setAuthorizationUrl, controller.signal); - onComplete?.(`Authentication successful for ${agentServer.name}. The server will connect when the agent runs.`); + url: agentServer.url, + } + + await performMCPOAuthFlow( + agentServer.name, + tempConfig, + setAuthorizationUrl, + controller.signal, + ) + + onComplete?.( + `Authentication successful for ${agentServer.name}. The server will connect when the agent runs.`, + ) } catch (err) { // Don't show error if it was a cancellation - if (err instanceof Error && !(err instanceof AuthenticationCancelledError)) { - setError(err.message); + if ( + err instanceof Error && + !(err instanceof AuthenticationCancelledError) + ) { + setError(err.message) } } finally { - setIsAuthenticating(false); - authAbortControllerRef.current = null; + setIsAuthenticating(false) + authAbortControllerRef.current = null } - }, [agentServer, onComplete]); - const capitalizedServerName = capitalize(String(agentServer.name)); + }, [agentServer, onComplete]) + + const capitalizedServerName = capitalize(String(agentServer.name)) + if (isAuthenticating) { - return + return ( + Authenticating with {agentServer.name}… A browser window will open for authentication - {authorizationUrl && + {authorizationUrl && ( + If your browser doesn't open automatically, copy this URL manually: - } + + )} Return here after authenticating in your browser.{' '} - + - ; + + ) } - const menuOptions = []; + + const menuOptions = [] // Only show authenticate option for HTTP/SSE servers if (agentServer.needsAuth) { menuOptions.push({ label: agentServer.isAuthenticated ? 'Re-authenticate' : 'Authenticate', - value: 'auth' - }); + value: 'auth', + }) } + menuOptions.push({ label: 'Back', - value: 'back' - }); - return exitState.pending ? Press {exitState.keyName} again to exit : + value: 'back', + }) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + - - }> + + + ) + } + > Type: {agentServer.transport} - {agentServer.url && + {agentServer.url && ( + URL: {agentServer.url} - } + + )} - {agentServer.command && + {agentServer.command && ( + Command: {agentServer.command} - } + + )} Used by: @@ -149,34 +206,47 @@ export function MCPAgentServerMenu({ - {agentServer.needsAuth && + {agentServer.needsAuth && ( + Auth: - {agentServer.isAuthenticated ? {color('success', theme)(figures.tick)} authenticated : + {agentServer.isAuthenticated ? ( + {color('success', theme)(figures.tick)} authenticated + ) : ( + {color('warning', theme)(figures.triangleUpOutline)} may need authentication - } - } + + )} + + )} This server connects only when running the agent. - {error && + {error && ( + Error: {error} - } + + )} - { + switch (value) { + case 'auth': + await handleAuthenticate() + break + case 'back': + onCancel() + break + } + }} + onCancel={onCancel} + /> - ; + + ) } diff --git a/src/components/mcp/MCPListPanel.tsx b/src/components/mcp/MCPListPanel.tsx index 075da7e95..af93c5538 100644 --- a/src/components/mcp/MCPListPanel.tsx +++ b/src/components/mcp/MCPListPanel.tsx @@ -1,503 +1,361 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useCallback, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { ConfigScope } from '../../services/mcp/types.js'; -import { describeMcpConfigFilePath } from '../../services/mcp/utils.js'; -import { isDebugMode } from '../../utils/debug.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { McpParsingWarnings } from './McpParsingWarnings.js'; -import type { AgentMcpServerInfo, ServerInfo } from './types.js'; +import figures from 'figures' +import React, { useCallback, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { ConfigScope } from '../../services/mcp/types.js' +import { describeMcpConfigFilePath } from '../../services/mcp/utils.js' +import { isDebugMode } from '../../utils/debug.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { McpParsingWarnings } from './McpParsingWarnings.js' +import type { AgentMcpServerInfo, ServerInfo } from './types.js' + type Props = { - servers: ServerInfo[]; - agentServers?: AgentMcpServerInfo[]; - onSelectServer: (server: ServerInfo) => void; - onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void; - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - defaultTab?: string; -}; -type SelectableItem = { - type: 'server'; - server: ServerInfo; -} | { - type: 'agent-server'; - agentServer: AgentMcpServerInfo; -}; + servers: ServerInfo[] + agentServers?: AgentMcpServerInfo[] + onSelectServer: (server: ServerInfo) => void + onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + defaultTab?: string +} + +type SelectableItem = + | { type: 'server'; server: ServerInfo } + | { type: 'agent-server'; agentServer: AgentMcpServerInfo } // Define scope order for display (constant, outside component) // 'dynamic' (built-in) is rendered separately at the end -const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise']; +const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise'] // Get scope heading parts (label is bold, path is grey) -function getScopeHeading(scope: ConfigScope): { - label: string; - path?: string; -} { +function getScopeHeading(scope: ConfigScope): { label: string; path?: string } { switch (scope) { case 'project': - return { - label: 'Project MCPs', - path: describeMcpConfigFilePath(scope) - }; + return { label: 'Project MCPs', path: describeMcpConfigFilePath(scope) } case 'user': - return { - label: 'User MCPs', - path: describeMcpConfigFilePath(scope) - }; + return { label: 'User MCPs', path: describeMcpConfigFilePath(scope) } case 'local': - return { - label: 'Local MCPs', - path: describeMcpConfigFilePath(scope) - }; + return { label: 'Local MCPs', path: describeMcpConfigFilePath(scope) } case 'enterprise': - return { - label: 'Enterprise MCPs' - }; + return { label: 'Enterprise MCPs' } case 'dynamic': - return { - label: 'Built-in MCPs', - path: 'always available' - }; + return { label: 'Built-in MCPs', path: 'always available' } default: - return { - label: scope - }; + return { label: scope } } } // Group servers by scope -function groupServersByScope(serverList: ServerInfo[]): Map { - const groups = new Map(); +function groupServersByScope( + serverList: ServerInfo[], +): Map { + const groups = new Map() for (const server of serverList) { - const scope = server.scope; + const scope = server.scope if (!groups.has(scope)) { - groups.set(scope, []); + groups.set(scope, []) } - groups.get(scope)!.push(server); + groups.get(scope)!.push(server) } // Sort servers within each group alphabetically for (const [, groupServers] of groups) { - groupServers.sort((a, b) => a.name.localeCompare(b.name)); + groupServers.sort((a, b) => a.name.localeCompare(b.name)) } - return groups; + return groups } -export function MCPListPanel(t0) { - const $ = _c(78); - const { - servers, - agentServers: t1, - onSelectServer, - onSelectAgentServer, - onComplete - } = t0; - let t2; - if ($[0] !== t1) { - t2 = t1 === undefined ? [] : t1; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - const agentServers = t2; - const [theme] = useTheme(); - const [selectedIndex, setSelectedIndex] = useState(0); - let t3; - if ($[2] !== servers) { - const regularServers = servers.filter(_temp); - t3 = groupServersByScope(regularServers); - $[2] = servers; - $[3] = t3; - } else { - t3 = $[3]; - } - const serversByScope = t3; - let t4; - if ($[4] !== servers) { - t4 = servers.filter(_temp2).sort(_temp3); - $[4] = servers; - $[5] = t4; - } else { - t4 = $[5]; - } - const claudeAiServers = t4; - let t5; - if ($[6] !== serversByScope) { - t5 = (serversByScope.get("dynamic") ?? []).sort(_temp4); - $[6] = serversByScope; - $[7] = t5; - } else { - t5 = $[7]; - } - const dynamicServers = t5; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = getScopeHeading("dynamic"); - $[8] = t6; - } else { - t6 = $[8]; - } - const dynamicHeading = t6; - let items; - if ($[9] !== agentServers || $[10] !== claudeAiServers || $[11] !== dynamicServers || $[12] !== serversByScope) { - items = []; + +export function MCPListPanel({ + servers, + agentServers = [], + onSelectServer, + onSelectAgentServer, + onComplete, +}: Props): React.ReactNode { + const [theme] = useTheme() + const [selectedIndex, setSelectedIndex] = useState(0) + + // Non-claudeai servers grouped by scope + const serversByScope = React.useMemo(() => { + const regularServers = servers.filter( + s => s.client.config.type !== 'claudeai-proxy', + ) + return groupServersByScope(regularServers) + }, [servers]) + + const claudeAiServers = React.useMemo( + () => + servers + .filter(s => s.client.config.type === 'claudeai-proxy') + .sort((a, b) => a.name.localeCompare(b.name)), + [servers], + ) + + // Built-in (dynamic) servers - rendered last + const dynamicServers = React.useMemo( + () => + (serversByScope.get('dynamic') ?? []).sort((a, b) => + a.name.localeCompare(b.name), + ), + [serversByScope], + ) + + // Pre-compute dynamic heading for render + const dynamicHeading = getScopeHeading('dynamic') + + // Build flat list of selectable items in display order + const selectableItems = React.useMemo(() => { + const items: SelectableItem[] = [] for (const scope of SCOPE_ORDER) { - const scopeServers = serversByScope.get(scope) ?? []; + const scopeServers = serversByScope.get(scope) ?? [] for (const server of scopeServers) { - items.push({ - type: "server", - server - }); + items.push({ type: 'server', server }) } } - for (const server_0 of claudeAiServers) { - items.push({ - type: "server", - server: server_0 - }); + for (const server of claudeAiServers) { + items.push({ type: 'server', server }) } for (const agentServer of agentServers) { - items.push({ - type: "agent-server", - agentServer - }); + items.push({ type: 'agent-server', agentServer }) } - for (const server_1 of dynamicServers) { - items.push({ - type: "server", - server: server_1 - }); + // Dynamic (built-in) servers come last + for (const server of dynamicServers) { + items.push({ type: 'server', server }) } - $[9] = agentServers; - $[10] = claudeAiServers; - $[11] = dynamicServers; - $[12] = serversByScope; - $[13] = items; - } else { - items = $[13]; - } - const selectableItems = items; - let t7; - if ($[14] !== onComplete) { - t7 = () => { - onComplete("MCP dialog dismissed", { - display: "system" - }); - }; - $[14] = onComplete; - $[15] = t7; - } else { - t7 = $[15]; - } - const handleCancel = t7; - let t8; - if ($[16] !== onSelectAgentServer || $[17] !== onSelectServer || $[18] !== selectableItems || $[19] !== selectedIndex) { - t8 = () => { - const item = selectableItems[selectedIndex]; - if (!item) { - return; - } - if (item.type === "server") { - onSelectServer(item.server); - } else { - if (item.type === "agent-server" && onSelectAgentServer) { - onSelectAgentServer(item.agentServer); - } - } - }; - $[16] = onSelectAgentServer; - $[17] = onSelectServer; - $[18] = selectableItems; - $[19] = selectedIndex; - $[20] = t8; - } else { - t8 = $[20]; - } - const handleSelect = t8; - let t10; - let t9; - if ($[21] !== selectableItems) { - t9 = () => setSelectedIndex(prev => prev === 0 ? selectableItems.length - 1 : prev - 1); - t10 = () => setSelectedIndex(prev_0 => prev_0 === selectableItems.length - 1 ? 0 : prev_0 + 1); - $[21] = selectableItems; - $[22] = t10; - $[23] = t9; - } else { - t10 = $[22]; - t9 = $[23]; - } - let t11; - if ($[24] !== handleCancel || $[25] !== handleSelect || $[26] !== t10 || $[27] !== t9) { - t11 = { - "confirm:previous": t9, - "confirm:next": t10, - "confirm:yes": handleSelect, - "confirm:no": handleCancel - }; - $[24] = handleCancel; - $[25] = handleSelect; - $[26] = t10; - $[27] = t9; - $[28] = t11; - } else { - t11 = $[28]; - } - let t12; - if ($[29] === Symbol.for("react.memo_cache_sentinel")) { - t12 = { - context: "Confirmation" - }; - $[29] = t12; - } else { - t12 = $[29]; - } - useKeybindings(t11, t12); - let t13; - if ($[30] !== selectableItems) { - t13 = server_2 => selectableItems.findIndex(item_0 => item_0.type === "server" && item_0.server === server_2); - $[30] = selectableItems; - $[31] = t13; - } else { - t13 = $[31]; - } - const getServerIndex = t13; - let t14; - if ($[32] !== selectableItems) { - t14 = agentServer_0 => selectableItems.findIndex(item_1 => item_1.type === "agent-server" && item_1.agentServer === agentServer_0); - $[32] = selectableItems; - $[33] = t14; - } else { - t14 = $[33]; - } - const getAgentServerIndex = t14; - let t15; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t15 = isDebugMode(); - $[34] = t15; - } else { - t15 = $[34]; + return items + }, [serversByScope, claudeAiServers, agentServers, dynamicServers]) + + const handleCancel = useCallback((): void => { + onComplete('MCP dialog dismissed', { + display: 'system', + }) + }, [onComplete]) + + const handleSelect = useCallback((): void => { + const item = selectableItems[selectedIndex] + if (!item) return + if (item.type === 'server') { + onSelectServer(item.server) + } else if (item.type === 'agent-server' && onSelectAgentServer) { + onSelectAgentServer(item.agentServer) + } + }, [selectableItems, selectedIndex, onSelectServer, onSelectAgentServer]) + + // Use configurable keybindings for navigation and selection + useKeybindings( + { + 'confirm:previous': () => + setSelectedIndex(prev => + prev === 0 ? selectableItems.length - 1 : prev - 1, + ), + 'confirm:next': () => + setSelectedIndex(prev => + prev === selectableItems.length - 1 ? 0 : prev + 1, + ), + 'confirm:yes': handleSelect, + 'confirm:no': handleCancel, + }, + { context: 'Confirmation' }, + ) + + // Build index lookup for each server + const getServerIndex = (server: ServerInfo): number => { + return selectableItems.findIndex( + item => item.type === 'server' && item.server === server, + ) } - const debugMode = t15; - let t16; - if ($[35] !== servers) { - t16 = servers.some(_temp5); - $[35] = servers; - $[36] = t16; - } else { - t16 = $[36]; + + const getAgentServerIndex = (agentServer: AgentMcpServerInfo): number => { + return selectableItems.findIndex( + item => item.type === 'agent-server' && item.agentServer === agentServer, + ) } - const hasFailedClients = t16; + + const debugMode = isDebugMode() + const hasFailedClients = servers.some(s => s.client.type === 'failed') + if (servers.length === 0 && agentServers.length === 0) { - return null; + return null } - let t17; - if ($[37] !== getServerIndex || $[38] !== selectedIndex || $[39] !== theme) { - t17 = server_3 => { - const index = getServerIndex(server_3); - const isSelected = selectedIndex === index; - let statusIcon; - let statusText; - if (server_3.client.type === "disabled") { - statusIcon = color("inactive", theme)(figures.radioOff); - statusText = "disabled"; + + const renderServerItem = (server: ServerInfo): React.ReactNode => { + const index = getServerIndex(server) + const isSelected = selectedIndex === index + let statusIcon = '' + let statusText = '' + + if (server.client.type === 'disabled') { + statusIcon = color('inactive', theme)(figures.radioOff) + statusText = 'disabled' + } else if (server.client.type === 'connected') { + statusIcon = color('success', theme)(figures.tick) + statusText = 'connected' + } else if (server.client.type === 'pending') { + statusIcon = color('inactive', theme)(figures.radioOff) + const { reconnectAttempt, maxReconnectAttempts } = server.client + if (reconnectAttempt && maxReconnectAttempts) { + statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…` } else { - if (server_3.client.type === "connected") { - statusIcon = color("success", theme)(figures.tick); - statusText = "connected"; - } else { - if (server_3.client.type === "pending") { - statusIcon = color("inactive", theme)(figures.radioOff); - const { - reconnectAttempt, - maxReconnectAttempts - } = server_3.client; - if (reconnectAttempt && maxReconnectAttempts) { - statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…`; - } else { - statusText = "connecting\u2026"; - } - } else { - if (server_3.client.type === "needs-auth") { - statusIcon = color("warning", theme)(figures.triangleUpOutline); - statusText = "needs authentication"; - } else { - statusIcon = color("error", theme)(figures.cross); - statusText = "failed"; - } - } - } + statusText = 'connecting…' } - return {isSelected ? `${figures.pointer} ` : " "}{server_3.name} · {statusIcon} {statusText}; - }; - $[37] = getServerIndex; - $[38] = selectedIndex; - $[39] = theme; - $[40] = t17; - } else { - t17 = $[40]; - } - const renderServerItem = t17; - let t18; - if ($[41] !== getAgentServerIndex || $[42] !== selectedIndex || $[43] !== theme) { - t18 = agentServer_1 => { - const index_0 = getAgentServerIndex(agentServer_1); - const isSelected_0 = selectedIndex === index_0; - const statusIcon_0 = agentServer_1.needsAuth ? color("warning", theme)(figures.triangleUpOutline) : color("inactive", theme)(figures.radioOff); - const statusText_0 = agentServer_1.needsAuth ? "may need auth" : "agent-only"; - return {isSelected_0 ? `${figures.pointer} ` : " "}{agentServer_1.name} · {statusIcon_0} {statusText_0}; - }; - $[41] = getAgentServerIndex; - $[42] = selectedIndex; - $[43] = theme; - $[44] = t18; - } else { - t18 = $[44]; - } - const renderAgentServerItem = t18; - const totalServers = servers.length + agentServers.length; - let t19; - if ($[45] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[45] = t19; - } else { - t19 = $[45]; - } - let t20; - if ($[46] !== totalServers) { - t20 = plural(totalServers, "server"); - $[46] = totalServers; - $[47] = t20; - } else { - t20 = $[47]; - } - const t21 = `${totalServers} ${t20}`; - let t22; - if ($[48] !== renderServerItem || $[49] !== serversByScope) { - t22 = SCOPE_ORDER.map(scope_0 => { - const scopeServers_0 = serversByScope.get(scope_0); - if (!scopeServers_0 || scopeServers_0.length === 0) { - return null; - } - const heading = getScopeHeading(scope_0); - return {heading.label}{heading.path && ({heading.path})}{scopeServers_0.map(server_4 => renderServerItem(server_4))}; - }); - $[48] = renderServerItem; - $[49] = serversByScope; - $[50] = t22; - } else { - t22 = $[50]; - } - let t23; - if ($[51] !== claudeAiServers || $[52] !== renderServerItem) { - t23 = claudeAiServers.length > 0 && claude.ai{claudeAiServers.map(server_5 => renderServerItem(server_5))}; - $[51] = claudeAiServers; - $[52] = renderServerItem; - $[53] = t23; - } else { - t23 = $[53]; - } - let t24; - if ($[54] !== agentServers || $[55] !== renderAgentServerItem) { - t24 = agentServers.length > 0 && Agent MCPs{[...new Set(agentServers.flatMap(_temp6))].map(agentName => @{agentName}{agentServers.filter(s_3 => s_3.sourceAgents.includes(agentName)).map(agentServer_2 => renderAgentServerItem(agentServer_2))})}; - $[54] = agentServers; - $[55] = renderAgentServerItem; - $[56] = t24; - } else { - t24 = $[56]; - } - let t25; - if ($[57] !== dynamicServers || $[58] !== renderServerItem) { - t25 = dynamicServers.length > 0 && {dynamicHeading.label}{dynamicHeading.path && ({dynamicHeading.path})}{dynamicServers.map(server_6 => renderServerItem(server_6))}; - $[57] = dynamicServers; - $[58] = renderServerItem; - $[59] = t25; - } else { - t25 = $[59]; - } - let t26; - if ($[60] !== hasFailedClients) { - t26 = hasFailedClients && {debugMode ? "\u203B Error logs shown inline with --debug" : "\u203B Run claude --debug to see error logs"}; - $[60] = hasFailedClients; - $[61] = t26; - } else { - t26 = $[61]; - } - let t27; - if ($[62] === Symbol.for("react.memo_cache_sentinel")) { - t27 = https://code.claude.com/docs/en/mcp{" "}for help; - $[62] = t27; - } else { - t27 = $[62]; - } - let t28; - if ($[63] !== t26) { - t28 = {t26}{t27}; - $[63] = t26; - $[64] = t28; - } else { - t28 = $[64]; - } - let t29; - if ($[65] !== t22 || $[66] !== t23 || $[67] !== t24 || $[68] !== t25 || $[69] !== t28) { - t29 = {t22}{t23}{t24}{t25}{t28}; - $[65] = t22; - $[66] = t23; - $[67] = t24; - $[68] = t25; - $[69] = t28; - $[70] = t29; - } else { - t29 = $[70]; - } - let t30; - if ($[71] !== handleCancel || $[72] !== t21 || $[73] !== t29) { - t30 = {t29}; - $[71] = handleCancel; - $[72] = t21; - $[73] = t29; - $[74] = t30; - } else { - t30 = $[74]; - } - let t31; - if ($[75] === Symbol.for("react.memo_cache_sentinel")) { - t31 = ; - $[75] = t31; - } else { - t31 = $[75]; + } else if (server.client.type === 'needs-auth') { + statusIcon = color('warning', theme)(figures.triangleUpOutline) + statusText = 'needs authentication' + } else { + statusIcon = color('error', theme)(figures.cross) + statusText = 'failed' + } + + return ( + + + {isSelected ? `${figures.pointer} ` : ' '} + + {server.name} + · {statusIcon} + {statusText} + + ) } - let t32; - if ($[76] !== t30) { - t32 = {t19}{t30}{t31}; - $[76] = t30; - $[77] = t32; - } else { - t32 = $[77]; + + const renderAgentServerItem = ( + agentServer: AgentMcpServerInfo, + ): React.ReactNode => { + const index = getAgentServerIndex(agentServer) + const isSelected = selectedIndex === index + const statusIcon = agentServer.needsAuth + ? color('warning', theme)(figures.triangleUpOutline) + : color('inactive', theme)(figures.radioOff) + const statusText = agentServer.needsAuth ? 'may need auth' : 'agent-only' + + return ( + + + {isSelected ? `${figures.pointer} ` : ' '} + + + {agentServer.name} + + · {statusIcon} + {statusText} + + ) } - return t32; -} -function _temp6(s_2) { - return s_2.sourceAgents; -} -function _temp5(s_1) { - return s_1.client.type === "failed"; -} -function _temp4(a_0, b_0) { - return a_0.name.localeCompare(b_0.name); -} -function _temp3(a, b) { - return a.name.localeCompare(b.name); -} -function _temp2(s_0) { - return s_0.client.config.type === "claudeai-proxy"; -} -function _temp(s) { - return s.client.config.type !== "claudeai-proxy"; + + const totalServers = servers.length + agentServers.length + + return ( + + + + + + {/* Regular servers grouped by scope */} + {SCOPE_ORDER.map(scope => { + const scopeServers = serversByScope.get(scope) + if (!scopeServers || scopeServers.length === 0) return null + const heading = getScopeHeading(scope) + return ( + + + {heading.label} + {heading.path && ({heading.path})} + + {scopeServers.map(server => renderServerItem(server))} + + ) + })} + + {/* Claude.ai servers section */} + {claudeAiServers.length > 0 && ( + + + claude.ai + + {claudeAiServers.map(server => renderServerItem(server))} + + )} + + {/* Agent servers section - grouped by source agent */} + {agentServers.length > 0 && ( + + + Agent MCPs + + {/* Group servers by source agent */} + {[...new Set(agentServers.flatMap(s => s.sourceAgents))].map( + agentName => ( + + + @{agentName} + + {agentServers + .filter(s => s.sourceAgents.includes(agentName)) + .map(agentServer => renderAgentServerItem(agentServer))} + + ), + )} + + )} + + {/* Built-in (dynamic) servers section - always last */} + {dynamicServers.length > 0 && ( + + + {dynamicHeading.label} + {dynamicHeading.path && ( + ({dynamicHeading.path}) + )} + + {dynamicServers.map(server => renderServerItem(server))} + + )} + + {/* Footer info */} + + {hasFailedClients && ( + + {debugMode + ? '※ Error logs shown inline with --debug' + : '※ Run claude --debug to see error logs'} + + )} + + + https://code.claude.com/docs/en/mcp + {' '} + for help + + + + + + {/* Custom footer with navigation hint */} + + + + + + + + + + + ) } diff --git a/src/components/mcp/MCPReconnect.tsx b/src/components/mcp/MCPReconnect.tsx index 8ca700239..209c80541 100644 --- a/src/components/mcp/MCPReconnect.tsx +++ b/src/components/mcp/MCPReconnect.tsx @@ -1,166 +1,105 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useEffect, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Box, color, Text, useTheme } from '../../ink.js'; -import { useMcpReconnect } from '../../services/mcp/MCPConnectionManager.js'; -import { useAppStateStore } from '../../state/AppState.js'; -import { Spinner } from '../Spinner.js'; +import figures from 'figures' +import React, { useEffect, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { Box, color, Text, useTheme } from '../../ink.js' +import { useMcpReconnect } from '../../services/mcp/MCPConnectionManager.js' +import { useAppStateStore } from '../../state/AppState.js' +import { Spinner } from '../Spinner.js' + type Props = { - serverName: string; - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function MCPReconnect(t0) { - const $ = _c(25); - const { - serverName, - onComplete - } = t0; - const [theme] = useTheme(); - const store = useAppStateStore(); - const reconnectMcpServer = useMcpReconnect(); - const [isReconnecting, setIsReconnecting] = useState(true); - const [error, setError] = useState(null); - let t1; - let t2; - if ($[0] !== onComplete || $[1] !== reconnectMcpServer || $[2] !== serverName || $[3] !== store) { - t1 = () => { - const attemptReconnect = async function attemptReconnect() { - ; - try { - const server = store.getState().mcp.clients.find(c => c.name === serverName); - if (!server) { - setError(`MCP server "${serverName}" not found`); - setIsReconnecting(false); - onComplete(`MCP server "${serverName}" not found`); - return; - } - const result = await reconnectMcpServer(serverName); - bb43: switch (result.client.type) { - case "connected": - { - setIsReconnecting(false); - onComplete(`Successfully reconnected to ${serverName}`); - break bb43; - } - case "needs-auth": - { - setError(`${serverName} requires authentication`); - setIsReconnecting(false); - onComplete(`${serverName} requires authentication. Use /mcp to authenticate.`); - break bb43; - } - case "pending": - case "failed": - case "disabled": - { - setError(`Failed to reconnect to ${serverName}`); - setIsReconnecting(false); - onComplete(`Failed to reconnect to ${serverName}`); - } - } - } catch (t3) { - const err = t3; - const errorMessage = err instanceof Error ? err.message : String(err); - setError(errorMessage); - setIsReconnecting(false); - onComplete(`Error: ${errorMessage}`); + serverName: string + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function MCPReconnect({ + serverName, + onComplete, +}: Props): React.ReactNode { + const [theme] = useTheme() + const store = useAppStateStore() + const reconnectMcpServer = useMcpReconnect() + const [isReconnecting, setIsReconnecting] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function attemptReconnect() { + try { + // Check if server exists. Read via store.getState() instead of a + // reactive selector so this effect does not re-fire when + // reconnectMcpServer updates mcp.clients via onConnectionAttempt. + const server = store + .getState() + .mcp.clients.find(c => c.name === serverName) + if (!server) { + setError(`MCP server "${serverName}" not found`) + setIsReconnecting(false) + onComplete(`MCP server "${serverName}" not found`) + return } - }; - attemptReconnect(); - }; - t2 = [serverName, reconnectMcpServer, store, onComplete]; - $[0] = onComplete; - $[1] = reconnectMcpServer; - $[2] = serverName; - $[3] = store; - $[4] = t1; - $[5] = t2; - } else { - t1 = $[4]; - t2 = $[5]; - } - useEffect(t1, t2); - if (isReconnecting) { - let t3; - if ($[6] !== serverName) { - t3 = Reconnecting to {serverName}; - $[6] = serverName; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Establishing connection to MCP server; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t3) { - t5 = {t3}{t4}; - $[9] = t3; - $[10] = t5; - } else { - t5 = $[10]; + + // Attempt reconnection + const result = await reconnectMcpServer(serverName) + + switch (result.client.type) { + case 'connected': + setIsReconnecting(false) + onComplete(`Successfully reconnected to ${serverName}`) + break + case 'needs-auth': + setError(`${serverName} requires authentication`) + setIsReconnecting(false) + onComplete( + `${serverName} requires authentication. Use /mcp to authenticate.`, + ) + break + case 'pending': + case 'failed': + case 'disabled': + setError(`Failed to reconnect to ${serverName}`) + setIsReconnecting(false) + onComplete(`Failed to reconnect to ${serverName}`) + break + } + } catch (err) { + // Only catch actual errors (like server not found) + const errorMessage = err instanceof Error ? err.message : String(err) + setError(errorMessage) + setIsReconnecting(false) + onComplete(`Error: ${errorMessage}`) + } } - return t5; + + void attemptReconnect() + }, [serverName, reconnectMcpServer, store, onComplete]) + + if (isReconnecting) { + return ( + + + Reconnecting to {serverName} + + + + Establishing connection to MCP server + + + ) } + if (error) { - let t3; - if ($[11] !== theme) { - t3 = color("error", theme)(figures.cross); - $[11] = theme; - $[12] = t3; - } else { - t3 = $[12]; - } - let t4; - if ($[13] !== t3) { - t4 = {t3} ; - $[13] = t3; - $[14] = t4; - } else { - t4 = $[14]; - } - let t5; - if ($[15] !== serverName) { - t5 = Failed to reconnect to {serverName}; - $[15] = serverName; - $[16] = t5; - } else { - t5 = $[16]; - } - let t6; - if ($[17] !== t4 || $[18] !== t5) { - t6 = {t4}{t5}; - $[17] = t4; - $[18] = t5; - $[19] = t6; - } else { - t6 = $[19]; - } - let t7; - if ($[20] !== error) { - t7 = Error: {error}; - $[20] = error; - $[21] = t7; - } else { - t7 = $[21]; - } - let t8; - if ($[22] !== t6 || $[23] !== t7) { - t8 = {t6}{t7}; - $[22] = t6; - $[23] = t7; - $[24] = t8; - } else { - t8 = $[24]; - } - return t8; + return ( + + + {color('error', theme)(figures.cross)} + Failed to reconnect to {serverName} + + Error: {error} + + ) } - return null; + + return null } diff --git a/src/components/mcp/MCPRemoteServerMenu.tsx b/src/components/mcp/MCPRemoteServerMenu.tsx index e8d8a6a3e..0a8efb76d 100644 --- a/src/components/mcp/MCPRemoteServerMenu.tsx +++ b/src/components/mcp/MCPRemoteServerMenu.tsx @@ -1,74 +1,108 @@ -import figures from 'figures'; -import React, { useEffect, useRef, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { getOauthConfig } from '../../constants/oauth.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { setClipboard } from '../../ink/termio/osc.js'; +import figures from 'figures' +import React, { useEffect, useRef, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import type { CommandResultDisplay } from '../../commands.js' +import { getOauthConfig } from '../../constants/oauth.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { setClipboard } from '../../ink/termio/osc.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow menu navigation -import { Box, color, Link, Text, useInput, useTheme } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { AuthenticationCancelledError, performMCPOAuthFlow, revokeServerTokens } from '../../services/mcp/auth.js'; -import { clearServerCache } from '../../services/mcp/client.js'; -import { useMcpReconnect, useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; -import { describeMcpConfigFilePath, excludeCommandsByServer, excludeResourcesByServer, excludeToolsByServer, filterMcpPromptsByServer } from '../../services/mcp/utils.js'; -import { useAppState, useSetAppState } from '../../state/AppState.js'; -import { getOauthAccountInfo } from '../../utils/auth.js'; -import { openBrowser } from '../../utils/browser.js'; -import { errorMessage } from '../../utils/errors.js'; -import { logMCPDebug } from '../../utils/log.js'; -import { capitalize } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Spinner } from '../Spinner.js'; -import TextInput from '../TextInput.js'; -import { CapabilitiesSection } from './CapabilitiesSection.js'; -import type { ClaudeAIServerInfo, HTTPServerInfo, SSEServerInfo } from './types.js'; -import { handleReconnectError, handleReconnectResult } from './utils/reconnectHelpers.js'; +import { Box, color, Link, Text, useInput, useTheme } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { + AuthenticationCancelledError, + performMCPOAuthFlow, + revokeServerTokens, +} from '../../services/mcp/auth.js' +import { clearServerCache } from '../../services/mcp/client.js' +import { + useMcpReconnect, + useMcpToggleEnabled, +} from '../../services/mcp/MCPConnectionManager.js' +import { + describeMcpConfigFilePath, + excludeCommandsByServer, + excludeResourcesByServer, + excludeToolsByServer, + filterMcpPromptsByServer, +} from '../../services/mcp/utils.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' +import { getOauthAccountInfo } from '../../utils/auth.js' +import { openBrowser } from '../../utils/browser.js' +import { errorMessage } from '../../utils/errors.js' +import { logMCPDebug } from '../../utils/log.js' +import { capitalize } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Spinner } from '../Spinner.js' +import TextInput from '../TextInput.js' +import { CapabilitiesSection } from './CapabilitiesSection.js' +import type { + ClaudeAIServerInfo, + HTTPServerInfo, + SSEServerInfo, +} from './types.js' +import { + handleReconnectError, + handleReconnectResult, +} from './utils/reconnectHelpers.js' + type Props = { - server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo; - serverToolsCount: number; - onViewTools: () => void; - onCancel: () => void; - onComplete?: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - borderless?: boolean; -}; + server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo + serverToolsCount: number + onViewTools: () => void + onCancel: () => void + onComplete?: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + borderless?: boolean +} + export function MCPRemoteServerMenu({ server, serverToolsCount, onViewTools, onCancel, onComplete, - borderless = false + borderless = false, }: Props): React.ReactNode { - const [theme] = useTheme(); - const exitState = useExitOnCtrlCDWithKeybindings(); - const { - columns: terminalColumns - } = useTerminalSize(); - const [isAuthenticating, setIsAuthenticating] = React.useState(false); - const [error, setError] = React.useState(null); - const mcp = useAppState(s => s.mcp); - const setAppState = useSetAppState(); - const [authorizationUrl, setAuthorizationUrl] = React.useState(null); - const [isReconnecting, setIsReconnecting] = useState(false); - const authAbortControllerRef = useRef(null); - const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = useState(false); - const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null); - const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false); - const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState(null); - const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); - const copyTimeoutRef = useRef | undefined>(undefined); - const unmountedRef = useRef(false); - const [callbackUrlInput, setCallbackUrlInput] = useState(''); - const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0); - const [manualCallbackSubmit, setManualCallbackSubmit] = useState<((url: string) => void) | null>(null); + const [theme] = useTheme() + const exitState = useExitOnCtrlCDWithKeybindings() + const { columns: terminalColumns } = useTerminalSize() + const [isAuthenticating, setIsAuthenticating] = React.useState(false) + const [error, setError] = React.useState(null) + const mcp = useAppState(s => s.mcp) + const setAppState = useSetAppState() + const [authorizationUrl, setAuthorizationUrl] = React.useState( + null, + ) + const [isReconnecting, setIsReconnecting] = useState(false) + const authAbortControllerRef = useRef(null) + const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = + useState(false) + const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null) + const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false) + const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState< + string | null + >(null) + const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = + useState(false) + const [urlCopied, setUrlCopied] = useState(false) + const copyTimeoutRef = useRef | undefined>( + undefined, + ) + const unmountedRef = useRef(false) + const [callbackUrlInput, setCallbackUrlInput] = useState('') + const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0) + const [manualCallbackSubmit, setManualCallbackSubmit] = useState< + ((url: string) => void) | null + >(null) // If the component unmounts mid-auth (e.g. a parent component's Esc handler // navigates away before ours fires), abort the OAuth flow so the callback @@ -76,58 +110,73 @@ export function MCPRemoteServerMenu({ // can outlive the terminal. Also clear the copy-feedback timer and mark // unmounted so the async setClipboard callback doesn't setUrlCopied / // schedule a new timer after unmount. - useEffect(() => () => { - unmountedRef.current = true; - authAbortControllerRef.current?.abort(); - if (copyTimeoutRef.current !== undefined) { - clearTimeout(copyTimeoutRef.current); - } - }, []); + useEffect( + () => () => { + unmountedRef.current = true + authAbortControllerRef.current?.abort() + if (copyTimeoutRef.current !== undefined) { + clearTimeout(copyTimeoutRef.current) + } + }, + [], + ) // A server is effectively authenticated if: // 1. It has OAuth tokens (server.isAuthenticated), OR // 2. It's connected and has tools (meaning it's working via some auth mechanism) - const isEffectivelyAuthenticated = server.isAuthenticated || server.client.type === 'connected' && serverToolsCount > 0; - const reconnectMcpServer = useMcpReconnect(); + const isEffectivelyAuthenticated = + server.isAuthenticated || + (server.client.type === 'connected' && serverToolsCount > 0) + + const reconnectMcpServer = useMcpReconnect() + const handleClaudeAIAuthComplete = React.useCallback(async () => { - setIsClaudeAIAuthenticating(false); - setClaudeAIAuthUrl(null); - setIsReconnecting(true); + setIsClaudeAIAuthenticating(false) + setClaudeAIAuthUrl(null) + setIsReconnecting(true) try { - const result = await reconnectMcpServer(server.name); - const success = result.client.type === 'connected'; - logEvent('tengu_claudeai_mcp_auth_completed', { - success - }); + const result = await reconnectMcpServer(server.name) + const success = result.client.type === 'connected' + logEvent('tengu_claudeai_mcp_auth_completed', { success }) if (success) { - onComplete?.(`Authentication successful. Connected to ${server.name}.`); + onComplete?.(`Authentication successful. Connected to ${server.name}.`) } else if (result.client.type === 'needs-auth') { - onComplete?.('Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.'); + onComplete?.( + 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', + ) } else { - onComplete?.('Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.'); + onComplete?.( + 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', + ) } } catch (err) { - logEvent('tengu_claudeai_mcp_auth_completed', { - success: false - }); - onComplete?.(handleReconnectError(err, server.name)); + logEvent('tengu_claudeai_mcp_auth_completed', { success: false }) + onComplete?.(handleReconnectError(err, server.name)) } finally { - setIsReconnecting(false); + setIsReconnecting(false) } - }, [reconnectMcpServer, server.name, onComplete]); + }, [reconnectMcpServer, server.name, onComplete]) + const handleClaudeAIClearAuthComplete = React.useCallback(async () => { await clearServerCache(server.name, { ...server.config, - scope: server.scope - }); + scope: server.scope, + }) + setAppState(prev => { - const newClients = prev.mcp.clients.map(c => c.name === server.name ? { - ...c, - type: 'needs-auth' as const - } : c); - const newTools = excludeToolsByServer(prev.mcp.tools, server.name); - const newCommands = excludeCommandsByServer(prev.mcp.commands, server.name); - const newResources = excludeResourcesByServer(prev.mcp.resources, server.name); + const newClients = prev.mcp.clients.map(c => + c.name === server.name ? { ...c, type: 'needs-auth' as const } : c, + ) + const newTools = excludeToolsByServer(prev.mcp.tools, server.name) + const newCommands = excludeCommandsByServer( + prev.mcp.commands, + server.name, + ) + const newResources = excludeResourcesByServer( + prev.mcp.resources, + server.name, + ) + return { ...prev, mcp: { @@ -135,311 +184,445 @@ export function MCPRemoteServerMenu({ clients: newClients, tools: newTools, commands: newCommands, - resources: newResources - } - }; - }); - logEvent('tengu_claudeai_mcp_clear_auth_completed', {}); - onComplete?.(`Disconnected from ${server.name}.`); - setIsClaudeAIClearingAuth(false); - setClaudeAIClearAuthUrl(null); - setClaudeAIClearAuthBrowserOpened(false); - }, [server.name, server.config, server.scope, setAppState, onComplete]); + resources: newResources, + }, + } + }) + + logEvent('tengu_claudeai_mcp_clear_auth_completed', {}) + onComplete?.(`Disconnected from ${server.name}.`) + setIsClaudeAIClearingAuth(false) + setClaudeAIClearAuthUrl(null) + setClaudeAIClearAuthBrowserOpened(false) + }, [server.name, server.config, server.scope, setAppState, onComplete]) // Escape to cancel authentication flow - useKeybinding('confirm:no', () => { - authAbortControllerRef.current?.abort(); - authAbortControllerRef.current = null; - setIsAuthenticating(false); - setAuthorizationUrl(null); - }, { - context: 'Confirmation', - isActive: isAuthenticating - }); + useKeybinding( + 'confirm:no', + () => { + authAbortControllerRef.current?.abort() + authAbortControllerRef.current = null + setIsAuthenticating(false) + setAuthorizationUrl(null) + }, + { + context: 'Confirmation', + isActive: isAuthenticating, + }, + ) // Escape to cancel Claude AI authentication - useKeybinding('confirm:no', () => { - setIsClaudeAIAuthenticating(false); - setClaudeAIAuthUrl(null); - }, { - context: 'Confirmation', - isActive: isClaudeAIAuthenticating - }); + useKeybinding( + 'confirm:no', + () => { + setIsClaudeAIAuthenticating(false) + setClaudeAIAuthUrl(null) + }, + { + context: 'Confirmation', + isActive: isClaudeAIAuthenticating, + }, + ) // Escape to cancel Claude AI clear auth - useKeybinding('confirm:no', () => { - setIsClaudeAIClearingAuth(false); - setClaudeAIClearAuthUrl(null); - setClaudeAIClearAuthBrowserOpened(false); - }, { - context: 'Confirmation', - isActive: isClaudeAIClearingAuth - }); + useKeybinding( + 'confirm:no', + () => { + setIsClaudeAIClearingAuth(false) + setClaudeAIClearAuthUrl(null) + setClaudeAIClearAuthBrowserOpened(false) + }, + { + context: 'Confirmation', + isActive: isClaudeAIClearingAuth, + }, + ) // Return key handling for authentication flows and 'c' to copy URL useInput((input, key) => { if (key.return && isClaudeAIAuthenticating) { - void handleClaudeAIAuthComplete(); + void handleClaudeAIAuthComplete() } if (key.return && isClaudeAIClearingAuth) { if (claudeAIClearAuthBrowserOpened) { - void handleClaudeAIClearAuthComplete(); + void handleClaudeAIClearAuthComplete() } else { // First Enter: open the browser - const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors`; - setClaudeAIClearAuthUrl(connectorsUrl); - setClaudeAIClearAuthBrowserOpened(true); - void openBrowser(connectorsUrl); + const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors` + setClaudeAIClearAuthUrl(connectorsUrl) + setClaudeAIClearAuthBrowserOpened(true) + void openBrowser(connectorsUrl) } } if (input === 'c' && !urlCopied) { - const urlToCopy = authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl; + const urlToCopy = + authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl if (urlToCopy) { void setClipboard(urlToCopy).then(raw => { - if (unmountedRef.current) return; - if (raw) process.stdout.write(raw); - setUrlCopied(true); + if (unmountedRef.current) return + if (raw) process.stdout.write(raw) + setUrlCopied(true) if (copyTimeoutRef.current !== undefined) { - clearTimeout(copyTimeoutRef.current); + clearTimeout(copyTimeoutRef.current) } - copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false); - }); + copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false) + }) } } - }); - const capitalizedServerName = capitalize(String(server.name)); + }) + + const capitalizedServerName = capitalize(String(server.name)) // Count MCP prompts for this server (skills are shown in /skills, not here) - const serverCommandsCount = filterMcpPromptsByServer(mcp.commands, server.name).length; - const toggleMcpServer = useMcpToggleEnabled(); + const serverCommandsCount = filterMcpPromptsByServer( + mcp.commands, + server.name, + ).length + + const toggleMcpServer = useMcpToggleEnabled() + const handleClaudeAIAuth = React.useCallback(async () => { - const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN; - const accountInfo = getOauthAccountInfo(); - const orgUuid = accountInfo?.organizationUuid; - let authUrl: string; - if (orgUuid && server.config.type === 'claudeai-proxy' && server.config.id) { + const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN + const accountInfo = getOauthAccountInfo() + const orgUuid = accountInfo?.organizationUuid + + let authUrl: string + if ( + orgUuid && + server.config.type === 'claudeai-proxy' && + server.config.id + ) { // Use the direct auth URL with org and server IDs // Replace 'mcprs' prefix with 'mcpsrv' if present - const serverId = server.config.id.startsWith('mcprs') ? 'mcpsrv' + server.config.id.slice(5) : server.config.id; - const productSurface = encodeURIComponent(process.env.CLAUDE_CODE_ENTRYPOINT || 'cli'); - authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}`; + const serverId = server.config.id.startsWith('mcprs') + ? 'mcpsrv' + server.config.id.slice(5) + : server.config.id + const productSurface = encodeURIComponent( + process.env.CLAUDE_CODE_ENTRYPOINT || 'cli', + ) + authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}` } else { // Fall back to settings/connectors if we don't have the required IDs - authUrl = `${claudeAiBaseUrl}/settings/connectors`; + authUrl = `${claudeAiBaseUrl}/settings/connectors` } - setClaudeAIAuthUrl(authUrl); - setIsClaudeAIAuthenticating(true); - logEvent('tengu_claudeai_mcp_auth_started', {}); - await openBrowser(authUrl); - }, [server.config]); + + setClaudeAIAuthUrl(authUrl) + setIsClaudeAIAuthenticating(true) + logEvent('tengu_claudeai_mcp_auth_started', {}) + await openBrowser(authUrl) + }, [server.config]) + const handleClaudeAIClearAuth = React.useCallback(() => { - setIsClaudeAIClearingAuth(true); - logEvent('tengu_claudeai_mcp_clear_auth_started', {}); - }, []); + setIsClaudeAIClearingAuth(true) + logEvent('tengu_claudeai_mcp_clear_auth_started', {}) + }, []) + const handleToggleEnabled = React.useCallback(async () => { - const wasEnabled = server.client.type !== 'disabled'; + const wasEnabled = server.client.type !== 'disabled' + try { - await toggleMcpServer(server.name); + await toggleMcpServer(server.name) + if (server.config.type === 'claudeai-proxy') { logEvent('tengu_claudeai_mcp_toggle', { - new_state: (wasEnabled ? 'disabled' : 'enabled') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + new_state: (wasEnabled + ? 'disabled' + : 'enabled') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } // Return to the server list so user can continue managing other servers - onCancel(); - } catch (err_0) { - const action = wasEnabled ? 'disable' : 'enable'; - onComplete?.(`Failed to ${action} MCP server '${server.name}': ${errorMessage(err_0)}`); + onCancel() + } catch (err) { + const action = wasEnabled ? 'disable' : 'enable' + onComplete?.( + `Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`, + ) } - }, [server.client.type, server.config.type, server.name, toggleMcpServer, onCancel, onComplete]); + }, [ + server.client.type, + server.config.type, + server.name, + toggleMcpServer, + onCancel, + onComplete, + ]) + const handleAuthenticate = React.useCallback(async () => { - if (server.config.type === 'claudeai-proxy') return; - setIsAuthenticating(true); - setError(null); - const controller = new AbortController(); - authAbortControllerRef.current = controller; + if (server.config.type === 'claudeai-proxy') return + + setIsAuthenticating(true) + setError(null) + + const controller = new AbortController() + authAbortControllerRef.current = controller + try { // Revoke existing tokens if re-authenticating, but preserve step-up // auth state so the next OAuth flow can reuse cached scope/discovery. if (server.isAuthenticated && server.config) { await revokeServerTokens(server.name, server.config, { - preserveStepUpState: true - }); + preserveStepUpState: true, + }) } + if (server.config) { - await performMCPOAuthFlow(server.name, server.config, setAuthorizationUrl, controller.signal, { - onWaitingForCallback: submit => { - setManualCallbackSubmit(() => submit); - } - }); + await performMCPOAuthFlow( + server.name, + server.config, + setAuthorizationUrl, + controller.signal, + { + onWaitingForCallback: submit => { + setManualCallbackSubmit(() => submit) + }, + }, + ) + logEvent('tengu_mcp_auth_config_authenticate', { - wasAuthenticated: server.isAuthenticated - }); - const result_0 = await reconnectMcpServer(server.name); - if (result_0.client.type === 'connected') { - const message = isEffectivelyAuthenticated ? `Authentication successful. Reconnected to ${server.name}.` : `Authentication successful. Connected to ${server.name}.`; - onComplete?.(message); - } else if (result_0.client.type === 'needs-auth') { - onComplete?.('Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.'); + wasAuthenticated: server.isAuthenticated, + }) + + const result = await reconnectMcpServer(server.name) + + if (result.client.type === 'connected') { + const message = isEffectivelyAuthenticated + ? `Authentication successful. Reconnected to ${server.name}.` + : `Authentication successful. Connected to ${server.name}.` + onComplete?.(message) + } else if (result.client.type === 'needs-auth') { + onComplete?.( + 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', + ) } else { // result.client.type === 'failed' - logMCPDebug(server.name, `Reconnection failed after authentication`); - onComplete?.('Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.'); + logMCPDebug(server.name, `Reconnection failed after authentication`) + onComplete?.( + 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', + ) } } - } catch (err_1) { + } catch (err) { // Don't show error if it was a cancellation - if (err_1 instanceof Error && !(err_1 instanceof AuthenticationCancelledError)) { - setError(err_1.message); + if ( + err instanceof Error && + !(err instanceof AuthenticationCancelledError) + ) { + setError(err.message) } } finally { - setIsAuthenticating(false); - authAbortControllerRef.current = null; - setManualCallbackSubmit(null); - setCallbackUrlInput(''); + setIsAuthenticating(false) + authAbortControllerRef.current = null + setManualCallbackSubmit(null) + setCallbackUrlInput('') } - }, [server.isAuthenticated, server.config, server.name, onComplete, reconnectMcpServer, isEffectivelyAuthenticated]); + }, [ + server.isAuthenticated, + server.config, + server.name, + onComplete, + reconnectMcpServer, + isEffectivelyAuthenticated, + ]) + const handleClearAuth = async () => { - if (server.config.type === 'claudeai-proxy') return; + if (server.config.type === 'claudeai-proxy') return + if (server.config) { // First revoke the authentication tokens and clear all auth state - await revokeServerTokens(server.name, server.config); - logEvent('tengu_mcp_auth_config_clear', {}); + await revokeServerTokens(server.name, server.config) + logEvent('tengu_mcp_auth_config_clear', {}) // Disconnect the client and clear the cache await clearServerCache(server.name, { ...server.config, - scope: server.scope - }); + scope: server.scope, + }) // Update app state to remove the disconnected server's tools, commands, and resources - setAppState(prev_0 => { - const newClients_0 = prev_0.mcp.clients.map(c_0 => - // 'failed' is a misnomer here, but we don't really differentiate between "not connected" and "failed" at the moment - c_0.name === server.name ? { - ...c_0, - type: 'failed' as const - } : c_0); - const newTools_0 = excludeToolsByServer(prev_0.mcp.tools, server.name); - const newCommands_0 = excludeCommandsByServer(prev_0.mcp.commands, server.name); - const newResources_0 = excludeResourcesByServer(prev_0.mcp.resources, server.name); + setAppState(prev => { + const newClients = prev.mcp.clients.map(c => + // 'failed' is a misnomer here, but we don't really differentiate between "not connected" and "failed" at the moment + c.name === server.name ? { ...c, type: 'failed' as const } : c, + ) + const newTools = excludeToolsByServer(prev.mcp.tools, server.name) + const newCommands = excludeCommandsByServer( + prev.mcp.commands, + server.name, + ) + const newResources = excludeResourcesByServer( + prev.mcp.resources, + server.name, + ) + return { - ...prev_0, + ...prev, mcp: { - ...prev_0.mcp, - clients: newClients_0, - tools: newTools_0, - commands: newCommands_0, - resources: newResources_0 - } - }; - }); - onComplete?.(`Authentication cleared for ${server.name}.`); + ...prev.mcp, + clients: newClients, + tools: newTools, + commands: newCommands, + resources: newResources, + }, + } + }) + + onComplete?.(`Authentication cleared for ${server.name}.`) } - }; + } + if (isAuthenticating) { // XAA: silent exchange (cached id_token → no browser), so don't claim // one will open. If IdP login IS needed, authorizationUrl populates and // the URL fallback block below still renders. - const authCopy = server.config.type !== 'claudeai-proxy' && server.config.oauth?.xaa ? ' Authenticating via your identity provider' : ' A browser window will open for authentication'; - return + const authCopy = + server.config.type !== 'claudeai-proxy' && server.config.oauth?.xaa + ? ' Authenticating via your identity provider' + : ' A browser window will open for authentication' + return ( + Authenticating with {server.name}… {authCopy} - {authorizationUrl && + {authorizationUrl && ( + If your browser doesn't open automatically, copy this URL manually{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} - } - {isAuthenticating && authorizationUrl && manualCallbackSubmit && + + )} + {isAuthenticating && authorizationUrl && manualCallbackSubmit && ( + If the redirect page shows a connection error, paste the URL from your browser's address bar: URL {'>'} - { - manualCallbackSubmit(value.trim()); - setCallbackUrlInput(''); - }} cursorOffset={callbackUrlCursorOffset} onChangeCursorOffset={setCallbackUrlCursorOffset} columns={terminalColumns - 8} /> + { + manualCallbackSubmit(value.trim()) + setCallbackUrlInput('') + }} + cursorOffset={callbackUrlCursorOffset} + onChangeCursorOffset={setCallbackUrlCursorOffset} + columns={terminalColumns - 8} + /> - } + + )} Return here after authenticating in your browser. Press Esc to go back. - ; + + ) } + if (isClaudeAIAuthenticating) { - return + return ( + Authenticating with {server.name}… A browser window will open for authentication - {claudeAIAuthUrl && + {claudeAIAuthUrl && ( + If your browser doesn't open automatically, copy this URL manually{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} - } + + )} Press Enter after authenticating in your browser. - + - ; + + ) } + if (isClaudeAIClearingAuth) { - return + return ( + Clear authentication for {server.name} - {claudeAIClearAuthBrowserOpened ? <> + {claudeAIClearAuthBrowserOpened ? ( + <> Find the MCP server in the browser and click "Disconnect". - {claudeAIClearAuthUrl && + {claudeAIClearAuthUrl && ( + If your browser didn't open automatically, copy this URL manually{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} - } + + )} Press Enter when done. - + - : <> + + ) : ( + <> This will open claude.ai in the browser. Find the MCP server in the list and click "Disconnect". @@ -449,14 +632,23 @@ export function MCPRemoteServerMenu({ Press Enter to open the browser. - + - } - ; + + )} + + ) } + if (isReconnecting) { - return + return ( + Connecting to {server.name} @@ -465,75 +657,87 @@ export function MCPRemoteServerMenu({ Establishing connection to MCP server This may take a few moments. - ; + + ) } - const menuOptions = []; + + const menuOptions = [] // If server is disabled, show Enable first as the primary action if (server.client.type === 'disabled') { menuOptions.push({ label: 'Enable', - value: 'toggle-enabled' - }); + value: 'toggle-enabled', + }) } + if (server.client.type === 'connected' && serverToolsCount > 0) { menuOptions.push({ label: 'View tools', - value: 'tools' - }); + value: 'tools', + }) } + if (server.config.type === 'claudeai-proxy') { if (server.client.type === 'connected') { menuOptions.push({ label: 'Clear authentication', - value: 'claudeai-clear-auth' - }); + value: 'claudeai-clear-auth', + }) } else if (server.client.type !== 'disabled') { menuOptions.push({ label: 'Authenticate', - value: 'claudeai-auth' - }); + value: 'claudeai-auth', + }) } } else { if (isEffectivelyAuthenticated) { menuOptions.push({ label: 'Re-authenticate', - value: 'reauth' - }); + value: 'reauth', + }) menuOptions.push({ label: 'Clear authentication', - value: 'clear-auth' - }); + value: 'clear-auth', + }) } + if (!isEffectivelyAuthenticated) { menuOptions.push({ label: 'Authenticate', - value: 'auth' - }); + value: 'auth', + }) } } + if (server.client.type !== 'disabled') { if (server.client.type !== 'needs-auth') { menuOptions.push({ label: 'Reconnect', - value: 'reconnectMcpServer' - }); + value: 'reconnectMcpServer', + }) } menuOptions.push({ label: 'Disable', - value: 'toggle-enabled' - }); + value: 'toggle-enabled', + }) } // If there are no other options, add a back option so Select handles escape if (menuOptions.length === 0) { menuOptions.push({ label: 'Back', - value: 'back' - }); + value: 'back', + }) } - return - + + return ( + + {capitalizedServerName} MCP Server @@ -541,23 +745,39 @@ export function MCPRemoteServerMenu({ Status: - {server.client.type === 'disabled' ? {color('inactive', theme)(figures.radioOff)} disabled : server.client.type === 'connected' ? {color('success', theme)(figures.tick)} connected : server.client.type === 'pending' ? <> + {server.client.type === 'disabled' ? ( + {color('inactive', theme)(figures.radioOff)} disabled + ) : server.client.type === 'connected' ? ( + {color('success', theme)(figures.tick)} connected + ) : server.client.type === 'pending' ? ( + <> {figures.radioOff} connecting… - : server.client.type === 'needs-auth' ? + + ) : server.client.type === 'needs-auth' ? ( + {color('warning', theme)(figures.triangleUpOutline)} needs authentication - : {color('error', theme)(figures.cross)} failed} + + ) : ( + {color('error', theme)(figures.cross)} failed + )} - {server.transport !== 'claudeai-proxy' && + {server.transport !== 'claudeai-proxy' && ( + Auth: - {isEffectivelyAuthenticated ? + {isEffectivelyAuthenticated ? ( + {color('success', theme)(figures.tick)} authenticated - : + + ) : ( + {color('error', theme)(figures.cross)} not authenticated - } - } + + )} + + )} URL: @@ -569,80 +789,107 @@ export function MCPRemoteServerMenu({ {describeMcpConfigFilePath(server.scope)} - {server.client.type === 'connected' && } + {server.client.type === 'connected' && ( + + )} - {server.client.type === 'connected' && serverToolsCount > 0 && + {server.client.type === 'connected' && serverToolsCount > 0 && ( + Tools: {serverToolsCount} tools - } + + )} - {error && + {error && ( + Error: {error} - } - - {menuOptions.length > 0 && - { + switch (value) { + case 'tools': + onViewTools() + break + case 'auth': + case 'reauth': + await handleAuthenticate() + break + case 'clear-auth': + await handleClearAuth() + break + case 'claudeai-auth': + await handleClaudeAIAuth() + break + case 'claudeai-clear-auth': + handleClaudeAIClearAuth() + break + case 'reconnectMcpServer': + setIsReconnecting(true) + try { + const result = await reconnectMcpServer(server.name) + if (server.config.type === 'claudeai-proxy') { + logEvent('tengu_claudeai_mcp_reconnect', { + success: result.client.type === 'connected', + }) + } + const { message } = handleReconnectResult( + result, + server.name, + ) + onComplete?.(message) + } catch (err) { + if (server.config.type === 'claudeai-proxy') { + logEvent('tengu_claudeai_mcp_reconnect', { + success: false, + }) + } + onComplete?.(handleReconnectError(err, server.name)) + } finally { + setIsReconnecting(false) + } + break + case 'toggle-enabled': + await handleToggleEnabled() + break + case 'back': + onCancel() + break } - onComplete?.(handleReconnectError(err_2, server.name)); - } finally { - setIsReconnecting(false); - } - break; - case 'toggle-enabled': - await handleToggleEnabled(); - break; - case 'back': - onCancel(); - break; - } - }} onCancel={onCancel} /> - } + }} + onCancel={onCancel} + /> + + )} - {exitState.pending ? <>Press {exitState.keyName} again to exit : + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + - - } + + + )} - ; + + ) } diff --git a/src/components/mcp/MCPSettings.tsx b/src/components/mcp/MCPSettings.tsx index 4afa20504..b350bf91e 100644 --- a/src/components/mcp/MCPSettings.tsx +++ b/src/components/mcp/MCPSettings.tsx @@ -1,397 +1,247 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useMemo } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { ClaudeAuthProvider } from '../../services/mcp/auth.js'; -import type { McpClaudeAIProxyServerConfig, McpHTTPServerConfig, McpSSEServerConfig, McpStdioServerConfig } from '../../services/mcp/types.js'; -import { extractAgentMcpServers, filterToolsByServer } from '../../services/mcp/utils.js'; -import { useAppState } from '../../state/AppState.js'; -import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'; -import { MCPAgentServerMenu } from './MCPAgentServerMenu.js'; -import { MCPListPanel } from './MCPListPanel.js'; -import { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js'; -import { MCPStdioServerMenu } from './MCPStdioServerMenu.js'; -import { MCPToolDetailView } from './MCPToolDetailView.js'; -import { MCPToolListView } from './MCPToolListView.js'; -import type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js'; +import React, { useEffect, useMemo } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { ClaudeAuthProvider } from '../../services/mcp/auth.js' +import type { + McpClaudeAIProxyServerConfig, + McpHTTPServerConfig, + McpSSEServerConfig, + McpStdioServerConfig, +} from '../../services/mcp/types.js' +import { + extractAgentMcpServers, + filterToolsByServer, +} from '../../services/mcp/utils.js' +import { useAppState } from '../../state/AppState.js' +import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js' +import { MCPAgentServerMenu } from './MCPAgentServerMenu.js' +import { MCPListPanel } from './MCPListPanel.js' +import { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js' +import { MCPStdioServerMenu } from './MCPStdioServerMenu.js' +import { MCPToolDetailView } from './MCPToolDetailView.js' +import { MCPToolListView } from './MCPToolListView.js' +import type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js' + type Props = { - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function MCPSettings(t0) { - const $ = _c(66); - const { - onComplete - } = t0; - const mcp = useAppState(_temp); - const agentDefinitions = useAppState(_temp2); - const mcpClients = mcp.clients; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - type: "list" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const [viewState, setViewState] = React.useState(t1); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [servers, setServers] = React.useState(t2); - let t3; - if ($[2] !== agentDefinitions.allAgents) { - t3 = extractAgentMcpServers(agentDefinitions.allAgents); - $[2] = agentDefinitions.allAgents; - $[3] = t3; - } else { - t3 = $[3]; - } - const agentMcpServers = t3; - let t4; - if ($[4] !== mcpClients) { - t4 = mcpClients.filter(_temp3).sort(_temp4); - $[4] = mcpClients; - $[5] = t4; - } else { - t4 = $[5]; - } - const filteredClients = t4; - let t5; - let t6; - if ($[6] !== filteredClients || $[7] !== mcp.tools) { - t5 = () => { - let cancelled = false; - const prepareServers = async function prepareServers() { - const serverInfos = await Promise.all(filteredClients.map(async client_0 => { - const scope = client_0.config.scope; - const isSSE = client_0.config.type === "sse"; - const isHTTP = client_0.config.type === "http"; - const isClaudeAIProxy = client_0.config.type === "claudeai-proxy"; - let isAuthenticated = undefined; + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function MCPSettings({ onComplete }: Props): React.ReactNode { + const mcp = useAppState(s => s.mcp) + const agentDefinitions = useAppState(s => s.agentDefinitions) + const mcpClients = mcp.clients + const [viewState, setViewState] = React.useState({ + type: 'list', + }) + const [servers, setServers] = React.useState([]) + + // Extract agent-specific MCP servers from agent definitions + const agentMcpServers = useMemo( + () => extractAgentMcpServers(agentDefinitions.allAgents), + [agentDefinitions.allAgents], + ) + + const filteredClients = React.useMemo( + () => + mcpClients + .filter(client => client.name !== 'ide') + .sort((a, b) => a.name.localeCompare(b.name)), + [mcpClients], + ) + + React.useEffect(() => { + let cancelled = false + async function prepareServers() { + const serverInfos = await Promise.all( + filteredClients.map(async client => { + const scope = client.config.scope + const isSSE = client.config.type === 'sse' + const isHTTP = client.config.type === 'http' + const isClaudeAIProxy = client.config.type === 'claudeai-proxy' + let isAuthenticated: boolean | undefined = undefined + if (isSSE || isHTTP) { - const authProvider = new ClaudeAuthProvider(client_0.name, client_0.config as McpSSEServerConfig | McpHTTPServerConfig); - const tokens = await authProvider.tokens(); - const hasSessionAuth = getSessionIngressAuthToken() !== null && client_0.type === "connected"; - const hasToolsAndConnected = client_0.type === "connected" && filterToolsByServer(mcp.tools, client_0.name).length > 0; - isAuthenticated = Boolean(tokens) || hasSessionAuth || hasToolsAndConnected; + const authProvider = new ClaudeAuthProvider( + client.name, + client.config as McpSSEServerConfig | McpHTTPServerConfig, + ) + const tokens = await authProvider.tokens() + // Server is authenticated if: + // 1. It has OAuth tokens, OR + // 2. It's connected via session auth (has session token and is connected), OR + // 3. It's connected and has tools (meaning it's working, regardless of auth method) + const hasSessionAuth = + getSessionIngressAuthToken() !== null && + client.type === 'connected' + const hasToolsAndConnected = + client.type === 'connected' && + filterToolsByServer(mcp.tools, client.name).length > 0 + isAuthenticated = + Boolean(tokens) || hasSessionAuth || hasToolsAndConnected } + const baseInfo = { - name: client_0.name, - client: client_0, - scope - }; + name: client.name, + client, + scope, + } + if (isClaudeAIProxy) { return { ...baseInfo, - transport: "claudeai-proxy" as const, + transport: 'claudeai-proxy' as const, isAuthenticated: false, - config: client_0.config as McpClaudeAIProxyServerConfig - }; + config: client.config as McpClaudeAIProxyServerConfig, + } + } else if (isSSE) { + return { + ...baseInfo, + transport: 'sse' as const, + isAuthenticated, + config: client.config as McpSSEServerConfig, + } + } else if (isHTTP) { + return { + ...baseInfo, + transport: 'http' as const, + isAuthenticated, + config: client.config as McpHTTPServerConfig, + } } else { - if (isSSE) { - return { - ...baseInfo, - transport: "sse" as const, - isAuthenticated, - config: client_0.config as McpSSEServerConfig - }; - } else { - if (isHTTP) { - return { - ...baseInfo, - transport: "http" as const, - isAuthenticated, - config: client_0.config as McpHTTPServerConfig - }; - } else { - return { - ...baseInfo, - transport: "stdio" as const, - config: client_0.config as McpStdioServerConfig - }; - } + return { + ...baseInfo, + transport: 'stdio' as const, + config: client.config as McpStdioServerConfig, } } - })); - if (cancelled) { - return; - } - setServers(serverInfos); - }; - prepareServers(); - return () => { - cancelled = true; - }; - }; - t6 = [filteredClients, mcp.tools]; - $[6] = filteredClients; - $[7] = mcp.tools; - $[8] = t5; - $[9] = t6; - } else { - t5 = $[8]; - t6 = $[9]; - } - React.useEffect(t5, t6); - let t7; - let t8; - if ($[10] !== agentMcpServers.length || $[11] !== filteredClients.length || $[12] !== onComplete || $[13] !== servers.length) { - t7 = () => { - if (servers.length === 0 && filteredClients.length > 0) { - return; - } - if (servers.length === 0 && agentMcpServers.length === 0) { - onComplete("No MCP servers configured. Please run /doctor if this is unexpected. Otherwise, run `claude mcp --help` or visit https://code.claude.com/docs/en/mcp to learn more."); - } - }; - t8 = [servers.length, filteredClients.length, agentMcpServers.length, onComplete]; - $[10] = agentMcpServers.length; - $[11] = filteredClients.length; - $[12] = onComplete; - $[13] = servers.length; - $[14] = t7; - $[15] = t8; - } else { - t7 = $[14]; - t8 = $[15]; - } - useEffect(t7, t8); + }), + ) + + if (cancelled) return + setServers(serverInfos) + } + + void prepareServers() + return () => { + cancelled = true + } + }, [filteredClients, mcp.tools]) + + useEffect(() => { + if (servers.length === 0 && filteredClients.length > 0) { + // Still loading + return + } + + // Only show "no servers" message if no regular servers AND no agent servers + if (servers.length === 0 && agentMcpServers.length === 0) { + onComplete( + 'No MCP servers configured. Please run /doctor if this is unexpected. Otherwise, run `claude mcp --help` or visit https://code.claude.com/docs/en/mcp to learn more.', + ) + } + }, [ + servers.length, + filteredClients.length, + agentMcpServers.length, + onComplete, + ]) + switch (viewState.type) { - case "list": - { - let t10; - let t9; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t9 = server => setViewState({ - type: "server-menu", - server - }); - t10 = agentServer => setViewState({ - type: "agent-server-menu", - agentServer - }); - $[16] = t10; - $[17] = t9; - } else { - t10 = $[16]; - t9 = $[17]; - } - let t11; - if ($[18] !== agentMcpServers || $[19] !== onComplete || $[20] !== servers || $[21] !== viewState.defaultTab) { - t11 = ; - $[18] = agentMcpServers; - $[19] = onComplete; - $[20] = servers; - $[21] = viewState.defaultTab; - $[22] = t11; - } else { - t11 = $[22]; - } - return t11; - } - case "server-menu": - { - let t9; - if ($[23] !== mcp.tools || $[24] !== viewState.server.name) { - t9 = filterToolsByServer(mcp.tools, viewState.server.name); - $[23] = mcp.tools; - $[24] = viewState.server.name; - $[25] = t9; - } else { - t9 = $[25]; - } - const serverTools_0 = t9; - const defaultTab = viewState.server.transport === "claudeai-proxy" ? "claude.ai" : "Claude Code"; - if (viewState.server.transport === "stdio") { - let t10; - if ($[26] !== viewState.server) { - t10 = () => setViewState({ - type: "server-tools", - server: viewState.server - }); - $[26] = viewState.server; - $[27] = t10; - } else { - t10 = $[27]; + case 'list': + return ( + + setViewState({ type: 'server-menu', server }) } - let t11; - if ($[28] !== defaultTab) { - t11 = () => setViewState({ - type: "list", - defaultTab - }); - $[28] = defaultTab; - $[29] = t11; - } else { - t11 = $[29]; + onSelectAgentServer={(agentServer: AgentMcpServerInfo) => + setViewState({ type: 'agent-server-menu', agentServer }) } - let t12; - if ($[30] !== onComplete || $[31] !== serverTools_0.length || $[32] !== t10 || $[33] !== t11 || $[34] !== viewState.server) { - t12 = ; - $[30] = onComplete; - $[31] = serverTools_0.length; - $[32] = t10; - $[33] = t11; - $[34] = viewState.server; - $[35] = t12; - } else { - t12 = $[35]; - } - return t12; - } else { - let t10; - if ($[36] !== viewState.server) { - t10 = () => setViewState({ - type: "server-tools", - server: viewState.server - }); - $[36] = viewState.server; - $[37] = t10; - } else { - t10 = $[37]; + onComplete={onComplete} + defaultTab={viewState.defaultTab} + /> + ) + + case 'server-menu': { + const serverTools = filterToolsByServer(mcp.tools, viewState.server.name) + + const defaultTab = + viewState.server.transport === 'claudeai-proxy' + ? 'claude.ai' + : 'Claude Code' + + if (viewState.server.transport === 'stdio') { + return ( + + setViewState({ type: 'server-tools', server: viewState.server }) + } + onCancel={() => setViewState({ type: 'list', defaultTab })} + onComplete={onComplete} + /> + ) + } else { + return ( + + setViewState({ type: 'server-tools', server: viewState.server }) + } + onCancel={() => setViewState({ type: 'list', defaultTab })} + onComplete={onComplete} + /> + ) + } + } + + case 'server-tools': + return ( + + setViewState({ + type: 'server-tool-detail', + server: viewState.server, + toolIndex: index, + }) } - let t11; - if ($[38] !== defaultTab) { - t11 = () => setViewState({ - type: "list", - defaultTab - }); - $[38] = defaultTab; - $[39] = t11; - } else { - t11 = $[39]; + onBack={() => + setViewState({ type: 'server-menu', server: viewState.server }) } - let t12; - if ($[40] !== onComplete || $[41] !== serverTools_0.length || $[42] !== t10 || $[43] !== t11 || $[44] !== viewState.server) { - t12 = ; - $[40] = onComplete; - $[41] = serverTools_0.length; - $[42] = t10; - $[43] = t11; - $[44] = viewState.server; - $[45] = t12; - } else { - t12 = $[45]; - } - return t12; - } - } - case "server-tools": - { - let t10; - let t9; - if ($[46] !== viewState.server) { - t9 = (_, index) => setViewState({ - type: "server-tool-detail", - server: viewState.server, - toolIndex: index - }); - t10 = () => setViewState({ - type: "server-menu", - server: viewState.server - }); - $[46] = viewState.server; - $[47] = t10; - $[48] = t9; - } else { - t10 = $[47]; - t9 = $[48]; - } - let t11; - if ($[49] !== t10 || $[50] !== t9 || $[51] !== viewState.server) { - t11 = ; - $[49] = t10; - $[50] = t9; - $[51] = viewState.server; - $[52] = t11; - } else { - t11 = $[52]; - } - return t11; - } - case "server-tool-detail": - { - let t9; - if ($[53] !== mcp.tools || $[54] !== viewState.server.name) { - t9 = filterToolsByServer(mcp.tools, viewState.server.name); - $[53] = mcp.tools; - $[54] = viewState.server.name; - $[55] = t9; - } else { - t9 = $[55]; - } - const serverTools = t9; - const tool = serverTools[viewState.toolIndex]; - if (!tool) { - setViewState({ - type: "server-tools", - server: viewState.server - }); - return null; - } - let t10; - if ($[56] !== viewState.server) { - t10 = () => setViewState({ - type: "server-tools", - server: viewState.server - }); - $[56] = viewState.server; - $[57] = t10; - } else { - t10 = $[57]; - } - let t11; - if ($[58] !== t10 || $[59] !== tool || $[60] !== viewState.server) { - t11 = ; - $[58] = t10; - $[59] = tool; - $[60] = viewState.server; - $[61] = t11; - } else { - t11 = $[61]; - } - return t11; - } - case "agent-server-menu": - { - let t9; - if ($[62] === Symbol.for("react.memo_cache_sentinel")) { - t9 = () => setViewState({ - type: "list", - defaultTab: "Agents" - }); - $[62] = t9; - } else { - t9 = $[62]; - } - let t10; - if ($[63] !== onComplete || $[64] !== viewState.agentServer) { - t10 = ; - $[63] = onComplete; - $[64] = viewState.agentServer; - $[65] = t10; - } else { - t10 = $[65]; - } - return t10; + /> + ) + + case 'server-tool-detail': { + const serverTools = filterToolsByServer(mcp.tools, viewState.server.name) + const tool = serverTools[viewState.toolIndex] + if (!tool) { + setViewState({ type: 'server-tools', server: viewState.server }) + return null } + return ( + + setViewState({ type: 'server-tools', server: viewState.server }) + } + /> + ) + } + + case 'agent-server-menu': + return ( + setViewState({ type: 'list', defaultTab: 'Agents' })} + onComplete={onComplete} + /> + ) } } -function _temp4(a, b) { - return a.name.localeCompare(b.name); -} -function _temp3(client) { - return client.name !== "ide"; -} -function _temp2(s_0) { - return s_0.agentDefinitions; -} -function _temp(s) { - return s.mcp; -} diff --git a/src/components/mcp/MCPStdioServerMenu.tsx b/src/components/mcp/MCPStdioServerMenu.tsx index bd9361b1d..7caba350e 100644 --- a/src/components/mcp/MCPStdioServerMenu.tsx +++ b/src/components/mcp/MCPStdioServerMenu.tsx @@ -1,92 +1,116 @@ -import figures from 'figures'; -import React, { useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, color, Text, useTheme } from '../../ink.js'; -import { getMcpConfigByName } from '../../services/mcp/config.js'; -import { useMcpReconnect, useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; -import { describeMcpConfigFilePath, filterMcpPromptsByServer } from '../../services/mcp/utils.js'; -import { useAppState } from '../../state/AppState.js'; -import { errorMessage } from '../../utils/errors.js'; -import { capitalize } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Spinner } from '../Spinner.js'; -import { CapabilitiesSection } from './CapabilitiesSection.js'; -import type { StdioServerInfo } from './types.js'; -import { handleReconnectError, handleReconnectResult } from './utils/reconnectHelpers.js'; +import figures from 'figures' +import React, { useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, color, Text, useTheme } from '../../ink.js' +import { getMcpConfigByName } from '../../services/mcp/config.js' +import { + useMcpReconnect, + useMcpToggleEnabled, +} from '../../services/mcp/MCPConnectionManager.js' +import { + describeMcpConfigFilePath, + filterMcpPromptsByServer, +} from '../../services/mcp/utils.js' +import { useAppState } from '../../state/AppState.js' +import { errorMessage } from '../../utils/errors.js' +import { capitalize } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Spinner } from '../Spinner.js' +import { CapabilitiesSection } from './CapabilitiesSection.js' +import type { StdioServerInfo } from './types.js' +import { + handleReconnectError, + handleReconnectResult, +} from './utils/reconnectHelpers.js' + type Props = { - server: StdioServerInfo; - serverToolsCount: number; - onViewTools: () => void; - onCancel: () => void; - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - borderless?: boolean; -}; + server: StdioServerInfo + serverToolsCount: number + onViewTools: () => void + onCancel: () => void + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + borderless?: boolean +} + export function MCPStdioServerMenu({ server, serverToolsCount, onViewTools, onCancel, onComplete, - borderless = false + borderless = false, }: Props): React.ReactNode { - const [theme] = useTheme(); - const exitState = useExitOnCtrlCDWithKeybindings(); - const mcp = useAppState(s => s.mcp); - const reconnectMcpServer = useMcpReconnect(); - const toggleMcpServer = useMcpToggleEnabled(); - const [isReconnecting, setIsReconnecting] = useState(false); + const [theme] = useTheme() + const exitState = useExitOnCtrlCDWithKeybindings() + const mcp = useAppState(s => s.mcp) + const reconnectMcpServer = useMcpReconnect() + const toggleMcpServer = useMcpToggleEnabled() + const [isReconnecting, setIsReconnecting] = useState(false) + const handleToggleEnabled = React.useCallback(async () => { - const wasEnabled = server.client.type !== 'disabled'; + const wasEnabled = server.client.type !== 'disabled' + try { - await toggleMcpServer(server.name); + await toggleMcpServer(server.name) // Return to the server list so user can continue managing other servers - onCancel(); + onCancel() } catch (err) { - const action = wasEnabled ? 'disable' : 'enable'; - onComplete(`Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`); + const action = wasEnabled ? 'disable' : 'enable' + onComplete( + `Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`, + ) } - }, [server.client.type, server.name, toggleMcpServer, onCancel, onComplete]); - const capitalizedServerName = capitalize(String(server.name)); + }, [server.client.type, server.name, toggleMcpServer, onCancel, onComplete]) + + const capitalizedServerName = capitalize(String(server.name)) // Count MCP prompts for this server (skills are shown in /skills, not here) - const serverCommandsCount = filterMcpPromptsByServer(mcp.commands, server.name).length; - const menuOptions = []; + const serverCommandsCount = filterMcpPromptsByServer( + mcp.commands, + server.name, + ).length + + const menuOptions = [] // Only show "View tools" if server is not disabled and has tools if (server.client.type !== 'disabled' && serverToolsCount > 0) { menuOptions.push({ label: 'View tools', - value: 'tools' - }); + value: 'tools', + }) } // Only show reconnect option if the server is not disabled if (server.client.type !== 'disabled') { menuOptions.push({ label: 'Reconnect', - value: 'reconnectMcpServer' - }); + value: 'reconnectMcpServer', + }) } + menuOptions.push({ label: server.client.type !== 'disabled' ? 'Disable' : 'Enable', - value: 'toggle-enabled' - }); + value: 'toggle-enabled', + }) // If there are no other options, add a back option so Select handles escape if (menuOptions.length === 0) { menuOptions.push({ label: 'Back', - value: 'back' - }); + value: 'back', + }) } + if (isReconnecting) { - return + return ( + Reconnecting to {server.name} @@ -95,10 +119,17 @@ export function MCPStdioServerMenu({ Restarting MCP server process This may take a few moments. - ; + + ) } - return - + + return ( + + {capitalizedServerName} MCP Server @@ -106,10 +137,18 @@ export function MCPStdioServerMenu({ Status: - {server.client.type === 'disabled' ? {color('inactive', theme)(figures.radioOff)} disabled : server.client.type === 'connected' ? {color('success', theme)(figures.tick)} connected : server.client.type === 'pending' ? <> + {server.client.type === 'disabled' ? ( + {color('inactive', theme)(figures.radioOff)} disabled + ) : server.client.type === 'connected' ? ( + {color('success', theme)(figures.tick)} connected + ) : server.client.type === 'pending' ? ( + <> {figures.radioOff} connecting… - : {color('error', theme)(figures.cross)} failed} + + ) : ( + {color('error', theme)(figures.cross)} failed + )} @@ -117,60 +156,89 @@ export function MCPStdioServerMenu({ {server.config.command} - {server.config.args && server.config.args.length > 0 && + {server.config.args && server.config.args.length > 0 && ( + Args: {server.config.args.join(' ')} - } + + )} Config location: - {describeMcpConfigFilePath(getMcpConfigByName(server.name)?.scope ?? 'dynamic')} + {describeMcpConfigFilePath( + getMcpConfigByName(server.name)?.scope ?? 'dynamic', + )} - {server.client.type === 'connected' && } + {server.client.type === 'connected' && ( + + )} - {server.client.type === 'connected' && serverToolsCount > 0 && + {server.client.type === 'connected' && serverToolsCount > 0 && ( + Tools: {serverToolsCount} tools - } + + )} - {menuOptions.length > 0 && - { + if (value === 'tools') { + onViewTools() + } else if (value === 'reconnectMcpServer') { + setIsReconnecting(true) + try { + const result = await reconnectMcpServer(server.name) + const { message } = handleReconnectResult( + result, + server.name, + ) + onComplete?.(message) + } catch (err) { + onComplete?.(handleReconnectError(err, server.name)) + } finally { + setIsReconnecting(false) + } + } else if (value === 'toggle-enabled') { + await handleToggleEnabled() + } else if (value === 'back') { + onCancel() + } + }} + onCancel={onCancel} + /> + + )} - {exitState.pending ? <>Press {exitState.keyName} again to exit : + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + - - } + + + )} - ; + + ) } diff --git a/src/components/mcp/MCPToolDetailView.tsx b/src/components/mcp/MCPToolDetailView.tsx index 6ce619b4b..b1ccb4d73 100644 --- a/src/components/mcp/MCPToolDetailView.tsx +++ b/src/components/mcp/MCPToolDetailView.tsx @@ -1,211 +1,142 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { extractMcpToolDisplayName, getMcpDisplayName } from '../../services/mcp/mcpStringUtils.js'; -import type { Tool } from '../../Tool.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Dialog } from '../design-system/Dialog.js'; -import type { ServerInfo } from './types.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { + extractMcpToolDisplayName, + getMcpDisplayName, +} from '../../services/mcp/mcpStringUtils.js' +import type { Tool } from '../../Tool.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Dialog } from '../design-system/Dialog.js' +import type { ServerInfo } from './types.js' + type Props = { - tool: Tool; - server: ServerInfo; - onBack: () => void; -}; -export function MCPToolDetailView(t0) { - const $ = _c(44); - const { - tool, - server, - onBack - } = t0; - const [toolDescription, setToolDescription] = React.useState(""); - let t1; - let toolName; - if ($[0] !== server.name || $[1] !== tool) { - toolName = getMcpDisplayName(tool.name, server.name); - const fullDisplayName = tool.userFacingName ? tool.userFacingName({}) : toolName; - t1 = extractMcpToolDisplayName(fullDisplayName); - $[0] = server.name; - $[1] = tool; - $[2] = t1; - $[3] = toolName; - } else { - t1 = $[2]; - toolName = $[3]; - } - const displayName = t1; - let t2; - if ($[4] !== tool) { - t2 = tool.isReadOnly?.({}) ?? false; - $[4] = tool; - $[5] = t2; - } else { - t2 = $[5]; - } - const isReadOnly = t2; - let t3; - if ($[6] !== tool) { - t3 = tool.isDestructive?.({}) ?? false; - $[6] = tool; - $[7] = t3; - } else { - t3 = $[7]; - } - const isDestructive = t3; - let t4; - if ($[8] !== tool) { - t4 = tool.isOpenWorld?.({}) ?? false; - $[8] = tool; - $[9] = t4; - } else { - t4 = $[9]; - } - const isOpenWorld = t4; - let t5; - let t6; - if ($[10] !== tool) { - t5 = () => { - const loadDescription = async function loadDescription() { - try { - const desc = await tool.description({}, { + tool: Tool + server: ServerInfo + onBack: () => void +} + +export function MCPToolDetailView({ + tool, + server, + onBack, +}: Props): React.ReactNode { + const [toolDescription, setToolDescription] = React.useState('') + + const toolName = getMcpDisplayName(tool.name, server.name) + const fullDisplayName = tool.userFacingName + ? tool.userFacingName({}) + : toolName + const displayName = extractMcpToolDisplayName(fullDisplayName) + + const isReadOnly = tool.isReadOnly?.({}) ?? false + const isDestructive = tool.isDestructive?.({}) ?? false + const isOpenWorld = tool.isOpenWorld?.({}) ?? false + + React.useEffect(() => { + async function loadDescription() { + try { + const desc = await tool.description( + {}, + { isNonInteractiveSession: false, toolPermissionContext: { - mode: "default" as const, + mode: 'default' as const, additionalWorkingDirectories: new Map(), alwaysAllowRules: {}, alwaysDenyRules: {}, alwaysAskRules: {}, - isBypassPermissionsModeAvailable: false + isBypassPermissionsModeAvailable: false, }, - tools: [] - }); - setToolDescription(desc); - } catch { - setToolDescription("Failed to load description"); - } - }; - loadDescription(); - }; - t6 = [tool]; - $[10] = tool; - $[11] = t5; - $[12] = t6; - } else { - t5 = $[11]; - t6 = $[12]; - } - React.useEffect(t5, t6); - let t7; - if ($[13] !== isReadOnly) { - t7 = isReadOnly && [read-only]; - $[13] = isReadOnly; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== isDestructive) { - t8 = isDestructive && [destructive]; - $[15] = isDestructive; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== isOpenWorld) { - t9 = isOpenWorld && [open-world]; - $[17] = isOpenWorld; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== displayName || $[20] !== t7 || $[21] !== t8 || $[22] !== t9) { - t10 = <>{displayName}{t7}{t8}{t9}; - $[19] = displayName; - $[20] = t7; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const titleContent = t10; - let t11; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Tool name: ; - $[24] = t11; - } else { - t11 = $[24]; - } - let t12; - if ($[25] !== toolName) { - t12 = {t11}{toolName}; - $[25] = toolName; - $[26] = t12; - } else { - t12 = $[26]; - } - let t13; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Full name: ; - $[27] = t13; - } else { - t13 = $[27]; - } - let t14; - if ($[28] !== tool.name) { - t14 = {t13}{tool.name}; - $[28] = tool.name; - $[29] = t14; - } else { - t14 = $[29]; - } - let t15; - if ($[30] !== toolDescription) { - t15 = toolDescription && Description:{toolDescription}; - $[30] = toolDescription; - $[31] = t15; - } else { - t15 = $[31]; - } - let t16; - if ($[32] !== tool.inputJSONSchema) { - t16 = tool.inputJSONSchema && tool.inputJSONSchema.properties && Object.keys(tool.inputJSONSchema.properties).length > 0 && Parameters:{Object.entries(tool.inputJSONSchema.properties).map(t17 => { - const [key, value] = t17; - const required = tool.inputJSONSchema?.required as string[] | undefined; - const isRequired = required?.includes(key); - return • {key}{isRequired && (required)}:{" "}{typeof value === "object" && value && "type" in value ? String(value.type) : "unknown"}{typeof value === "object" && value && "description" in value && - {String(value.description)}}; - })}; - $[32] = tool.inputJSONSchema; - $[33] = t16; - } else { - t16 = $[33]; - } - let t17; - if ($[34] !== t12 || $[35] !== t14 || $[36] !== t15 || $[37] !== t16) { - t17 = {t12}{t14}{t15}{t16}; - $[34] = t12; - $[35] = t14; - $[36] = t15; - $[37] = t16; - $[38] = t17; - } else { - t17 = $[38]; - } - let t18; - if ($[39] !== onBack || $[40] !== server.name || $[41] !== t17 || $[42] !== titleContent) { - t18 = {t17}; - $[39] = onBack; - $[40] = server.name; - $[41] = t17; - $[42] = titleContent; - $[43] = t18; - } else { - t18 = $[43]; - } - return t18; -} -function _temp(exitState) { - return exitState.pending ? Press {exitState.keyName} again to exit : ; + tools: [], + }, + ) + setToolDescription(desc) + } catch { + setToolDescription('Failed to load description') + } + } + void loadDescription() + }, [tool]) + + const titleContent = ( + <> + {displayName} + {isReadOnly && [read-only]} + {isDestructive && [destructive]} + {isOpenWorld && [open-world]} + + ) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + ) + } + > + + + Tool name: + {toolName} + + + + Full name: + {tool.name} + + + {toolDescription && ( + + Description: + {toolDescription} + + )} + + {tool.inputJSONSchema && + tool.inputJSONSchema.properties && + Object.keys(tool.inputJSONSchema.properties).length > 0 && ( + + Parameters: + + {Object.entries(tool.inputJSONSchema.properties).map( + ([key, value]) => { + const required = tool.inputJSONSchema?.required as + | string[] + | undefined + const isRequired = required?.includes(key) + return ( + + • {key} + {isRequired && (required)}:{' '} + + {typeof value === 'object' && value && 'type' in value + ? String(value.type) + : 'unknown'} + + {typeof value === 'object' && + value && + 'description' in value && ( + - {String(value.description)} + )} + + ) + }, + )} + + + )} + + + ) } diff --git a/src/components/mcp/MCPToolListView.tsx b/src/components/mcp/MCPToolListView.tsx index 1ce14cd30..923791187 100644 --- a/src/components/mcp/MCPToolListView.tsx +++ b/src/components/mcp/MCPToolListView.tsx @@ -1,140 +1,104 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import { extractMcpToolDisplayName, getMcpDisplayName } from '../../services/mcp/mcpStringUtils.js'; -import { filterToolsByServer } from '../../services/mcp/utils.js'; -import { useAppState } from '../../state/AppState.js'; -import type { Tool } from '../../Tool.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import type { ServerInfo } from './types.js'; +import React from 'react' +import { Text } from '../../ink.js' +import { + extractMcpToolDisplayName, + getMcpDisplayName, +} from '../../services/mcp/mcpStringUtils.js' +import { filterToolsByServer } from '../../services/mcp/utils.js' +import { useAppState } from '../../state/AppState.js' +import type { Tool } from '../../Tool.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import type { ServerInfo } from './types.js' + type Props = { - server: ServerInfo; - onSelectTool: (tool: Tool, index: number) => void; - onBack: () => void; -}; -export function MCPToolListView(t0) { - const $ = _c(21); - const { - server, - onSelectTool, - onBack - } = t0; - const mcpTools = useAppState(_temp); - let t1; - bb0: { - if (server.client.type !== "connected") { - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[0] = t2; - } else { - t2 = $[0]; - } - t1 = t2; - break bb0; - } - let t2; - if ($[1] !== mcpTools || $[2] !== server.name) { - t2 = filterToolsByServer(mcpTools, server.name); - $[1] = mcpTools; - $[2] = server.name; - $[3] = t2; - } else { - t2 = $[3]; - } - t1 = t2; - } - const serverTools = t1; - let t2; - if ($[4] !== server.name || $[5] !== serverTools) { - let t3; - if ($[7] !== server.name) { - t3 = (tool, index) => { - const toolName = getMcpDisplayName(tool.name, server.name); - const fullDisplayName = tool.userFacingName ? tool.userFacingName({}) : toolName; - const displayName = extractMcpToolDisplayName(fullDisplayName); - const isReadOnly = tool.isReadOnly?.({}) ?? false; - const isDestructive = tool.isDestructive?.({}) ?? false; - const isOpenWorld = tool.isOpenWorld?.({}) ?? false; - const annotations = []; - if (isReadOnly) { - annotations.push("read-only"); - } - if (isDestructive) { - annotations.push("destructive"); - } - if (isOpenWorld) { - annotations.push("open-world"); - } - return { - label: displayName, - value: index.toString(), - description: annotations.length > 0 ? annotations.join(", ") : undefined, - descriptionColor: isDestructive ? "error" : isReadOnly ? "success" : undefined - }; - }; - $[7] = server.name; - $[8] = t3; - } else { - t3 = $[8]; + server: ServerInfo + onSelectTool: (tool: Tool, index: number) => void + onBack: () => void +} + +export function MCPToolListView({ + server, + onSelectTool, + onBack, +}: Props): React.ReactNode { + const mcpTools = useAppState(s => s.mcp.tools) + + const serverTools = React.useMemo(() => { + if (server.client.type !== 'connected') return [] + return filterToolsByServer(mcpTools, server.name) + }, [server, mcpTools]) + + const toolOptions = serverTools.map((tool, index) => { + const toolName = getMcpDisplayName(tool.name, server.name) + const fullDisplayName = tool.userFacingName + ? tool.userFacingName({}) + : toolName + // Extract just the tool display name without server prefix + const displayName = extractMcpToolDisplayName(fullDisplayName) + + const isReadOnly = tool.isReadOnly?.({}) ?? false + const isDestructive = tool.isDestructive?.({}) ?? false + const isOpenWorld = tool.isOpenWorld?.({}) ?? false + + const annotations = [] + if (isReadOnly) annotations.push('read-only') + if (isDestructive) annotations.push('destructive') + if (isOpenWorld) annotations.push('open-world') + + return { + label: displayName, + value: index.toString(), + description: annotations.length > 0 ? annotations.join(', ') : undefined, + descriptionColor: isDestructive + ? 'error' + : isReadOnly + ? 'success' + : undefined, } - t2 = serverTools.map(t3); - $[4] = server.name; - $[5] = serverTools; - $[6] = t2; - } else { - t2 = $[6]; - } - const toolOptions = t2; - const t3 = `Tools for ${server.name}`; - const t4 = serverTools.length; - let t5; - if ($[9] !== serverTools.length) { - t5 = plural(serverTools.length, "tool"); - $[9] = serverTools.length; - $[10] = t5; - } else { - t5 = $[10]; - } - const t6 = `${t4} ${t5}`; - let t7; - if ($[11] !== onBack || $[12] !== onSelectTool || $[13] !== serverTools || $[14] !== toolOptions) { - t7 = serverTools.length === 0 ? No tools available : { + const index = parseInt(value) + const tool = serverTools[index] + if (tool) { + onSelectTool(tool, index) + } + }} + onCancel={onBack} + /> + )} + + ) } diff --git a/src/components/mcp/McpParsingWarnings.tsx b/src/components/mcp/McpParsingWarnings.tsx index e0fd55d18..49e8353b9 100644 --- a/src/components/mcp/McpParsingWarnings.tsx +++ b/src/components/mcp/McpParsingWarnings.tsx @@ -1,212 +1,147 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import { getMcpConfigsByScope } from 'src/services/mcp/config.js'; -import type { ConfigScope } from 'src/services/mcp/types.js'; -import { describeMcpConfigFilePath, getScopeLabel } from 'src/services/mcp/utils.js'; -import type { ValidationError } from 'src/utils/settings/validation.js'; -import { Box, Link, Text } from '../../ink.js'; -function McpConfigErrorSection(t0) { - const $ = _c(26); - const { - scope, - parsingErrors, - warnings - } = t0; - const hasErrors = parsingErrors.length > 0; - const hasWarnings = warnings.length > 0; +import React, { useMemo } from 'react' +import { getMcpConfigsByScope } from 'src/services/mcp/config.js' +import type { ConfigScope } from 'src/services/mcp/types.js' +import { + describeMcpConfigFilePath, + getScopeLabel, +} from 'src/services/mcp/utils.js' +import type { ValidationError } from 'src/utils/settings/validation.js' +import { Box, Link, Text } from '../../ink.js' + +function McpConfigErrorSection({ + scope, + parsingErrors, + warnings, +}: { + scope: ConfigScope + parsingErrors: ValidationError[] + warnings: ValidationError[] +}): React.ReactNode { + const hasErrors = parsingErrors.length > 0 + const hasWarnings = warnings.length > 0 + if (!hasErrors && !hasWarnings) { - return null; - } - let t1; - if ($[0] !== hasErrors || $[1] !== hasWarnings) { - t1 = (hasErrors || hasWarnings) && [{hasErrors ? "Failed to parse" : "Contains warnings"}]{" "}; - $[0] = hasErrors; - $[1] = hasWarnings; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== scope) { - t2 = getScopeLabel(scope); - $[3] = scope; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== t2) { - t3 = {t2}; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t1 || $[8] !== t3) { - t4 = {t1}{t3}; - $[7] = t1; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Location: ; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== scope) { - t6 = describeMcpConfigFilePath(scope); - $[11] = scope; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== t6) { - t7 = {t5}{t6}; - $[13] = t6; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== parsingErrors) { - t8 = parsingErrors.map(_temp); - $[15] = parsingErrors; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== warnings) { - t9 = warnings.map(_temp2); - $[17] = warnings; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== t8 || $[20] !== t9) { - t10 = {t8}{t9}; - $[19] = t8; - $[20] = t9; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== t10 || $[23] !== t4 || $[24] !== t7) { - t11 = {t4}{t7}{t10}; - $[22] = t10; - $[23] = t4; - $[24] = t7; - $[25] = t11; - } else { - t11 = $[25]; - } - return t11; + return null + } + + return ( + + + {(hasErrors || hasWarnings) && ( + + [{hasErrors ? 'Failed to parse' : 'Contains warnings'}]{' '} + + )} + {getScopeLabel(scope)} + + + Location: + {describeMcpConfigFilePath(scope)} + + + {parsingErrors.map((error, i) => { + const serverName = error.mcpErrorMetadata?.serverName + return ( + + + + [Error] + + {' '} + {serverName && `[${serverName}] `} + {error.path && error.path !== '' ? `${error.path}: ` : ''} + {error.message} + + + + ) + })} + {warnings.map((warning, i) => { + const serverName = warning.mcpErrorMetadata?.serverName + + return ( + + + + [Warning] + + {' '} + {serverName && `[${serverName}] `} + {warning.path && warning.path !== '' + ? `${warning.path}: ` + : ''} + {warning.message} + + + + ) + })} + + + ) } -function _temp2(warning, i_0) { - const serverName_0 = warning.mcpErrorMetadata?.serverName; - return [Warning]{" "}{serverName_0 && `[${serverName_0}] `}{warning.path && warning.path !== "" ? `${warning.path}: ` : ""}{warning.message}; -} -function _temp(error, i) { - const serverName = error.mcpErrorMetadata?.serverName; - return [Error]{" "}{serverName && `[${serverName}] `}{error.path && error.path !== "" ? `${error.path}: ` : ""}{error.message}; -} -export function McpParsingWarnings() { - const $ = _c(6); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - scope: "user", - config: getMcpConfigsByScope("user") - }; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - scope: "project", - config: getMcpConfigsByScope("project") - }; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - scope: "local", - config: getMcpConfigsByScope("local") - }; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = [t0, t1, t2, { - scope: "enterprise", - config: getMcpConfigsByScope("enterprise") - }]; - $[3] = t3; - } else { - t3 = $[3]; - } - const scopes = t3 satisfies Array<{ - scope: ConfigScope; - config: { - errors: ValidationError[]; - }; - }>; - const hasParsingErrors = scopes.some(_temp3); - const hasWarnings = scopes.some(_temp4); + +export function McpParsingWarnings(): React.ReactNode { + // Config files don't change during dialog lifetime; read once on mount + // to avoid blocking file IO on every re-render. + const scopes = useMemo( + () => + [ + { scope: 'user', config: getMcpConfigsByScope('user') }, + { scope: 'project', config: getMcpConfigsByScope('project') }, + { scope: 'local', config: getMcpConfigsByScope('local') }, + { scope: 'enterprise', config: getMcpConfigsByScope('enterprise') }, + ] satisfies Array<{ + scope: ConfigScope + config: { errors: ValidationError[] } + }>, + [], + ) + + const hasParsingErrors = scopes.some( + ({ config }) => filterErrors(config.errors, 'fatal').length > 0, + ) + const hasWarnings = scopes.some( + ({ config }) => filterErrors(config.errors, 'warning').length > 0, + ) + if (!hasParsingErrors && !hasWarnings) { - return null; - } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = MCP Config Diagnostics; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {t4}For help configuring MCP servers, see:{" "}https://code.claude.com/docs/en/mcp{scopes.map(_temp5)}; - $[5] = t5; - } else { - t5 = $[5]; - } - return t5; -} -function _temp5(t0) { - const { - scope, - config: config_1 - } = t0; - return ; -} -function _temp4(t0) { - const { - config: config_0 - } = t0; - return filterErrors(config_0.errors, "warning").length > 0; -} -function _temp3(t0) { - const { - config - } = t0; - return filterErrors(config.errors, "fatal").length > 0; + return null + } + + return ( + + MCP Config Diagnostics + + + For help configuring MCP servers, see:{' '} + + https://code.claude.com/docs/en/mcp + + + + {scopes.map(({ scope, config }) => ( + + ))} + {/* TODO: Add additional diagnostic sections: + * - Duplicate Server Names (check for servers with same name across scopes) + * This section should include: + * - File paths where each server is defined + * - More detailed location info for user/local scopes + * - Approved / disabled status of servers + */} + + ) } -function filterErrors(errors: ValidationError[], severity: 'fatal' | 'warning'): ValidationError[] { - return errors.filter(e => e.mcpErrorMetadata?.severity === severity); + +function filterErrors( + errors: ValidationError[], + severity: 'fatal' | 'warning', +): ValidationError[] { + return errors.filter(e => e.mcpErrorMetadata?.severity === severity) } diff --git a/src/components/mcp/utils/reconnectHelpers.tsx b/src/components/mcp/utils/reconnectHelpers.tsx index c947b1999..cf7459804 100644 --- a/src/components/mcp/utils/reconnectHelpers.tsx +++ b/src/components/mcp/utils/reconnectHelpers.tsx @@ -1,48 +1,61 @@ -import type { Command } from '../../../commands.js'; -import type { MCPServerConnection, ServerResource } from '../../../services/mcp/types.js'; -import type { Tool } from '../../../Tool.js'; +import type { Command } from '../../../commands.js' +import type { + MCPServerConnection, + ServerResource, +} from '../../../services/mcp/types.js' +import type { Tool } from '../../../Tool.js' + export interface ReconnectResult { - message: string; - success: boolean; + message: string + success: boolean } /** * Handles the result of a reconnect attempt and returns an appropriate user message */ -export function handleReconnectResult(result: { - client: MCPServerConnection; - tools: Tool[]; - commands: Command[]; - resources?: ServerResource[]; -}, serverName: string): ReconnectResult { +export function handleReconnectResult( + result: { + client: MCPServerConnection + tools: Tool[] + commands: Command[] + resources?: ServerResource[] + }, + serverName: string, +): ReconnectResult { switch (result.client.type) { case 'connected': return { message: `Reconnected to ${serverName}.`, - success: true - }; + success: true, + } + case 'needs-auth': return { message: `${serverName} requires authentication. Use the 'Authenticate' option.`, - success: false - }; + success: false, + } + case 'failed': return { message: `Failed to reconnect to ${serverName}.`, - success: false - }; + success: false, + } + default: return { message: `Unknown result when reconnecting to ${serverName}.`, - success: false - }; + success: false, + } } } /** * Handles errors from reconnect attempts */ -export function handleReconnectError(error: unknown, serverName: string): string { - const errorMessage = error instanceof Error ? error.message : String(error); - return `Error reconnecting to ${serverName}: ${errorMessage}`; +export function handleReconnectError( + error: unknown, + serverName: string, +): string { + const errorMessage = error instanceof Error ? error.message : String(error) + return `Error reconnecting to ${serverName}: ${errorMessage}` } diff --git a/src/components/memory/MemoryFileSelector.tsx b/src/components/memory/MemoryFileSelector.tsx index 5fd03a849..2e2bdf623 100644 --- a/src/components/memory/MemoryFileSelector.tsx +++ b/src/components/memory/MemoryFileSelector.tsx @@ -1,437 +1,324 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import { mkdir } from 'fs/promises'; -import { join } from 'path'; -import * as React from 'react'; -import { use, useEffect, useState } from 'react'; -import { getOriginalCwd } from '../../bootstrap/state.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { getAutoMemPath, isAutoMemoryEnabled } from '../../memdir/paths.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { isAutoDreamEnabled } from '../../services/autoDream/config.js'; -import { readLastConsolidatedAt } from '../../services/autoDream/consolidationLock.js'; -import { useAppState } from '../../state/AppState.js'; -import { getAgentMemoryDir } from '../../tools/AgentTool/agentMemory.js'; -import { openPath } from '../../utils/browser.js'; -import { getMemoryFiles, type MemoryFileInfo } from '../../utils/claudemd.js'; -import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatRelativeTimeAgo } from '../../utils/format.js'; -import { projectIsInGitRepo } from '../../utils/memory/versions.js'; -import { updateSettingsForSource } from '../../utils/settings/settings.js'; -import { Select } from '../CustomSelect/index.js'; -import { ListItem } from '../design-system/ListItem.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import { mkdir } from 'fs/promises' +import { join } from 'path' +import * as React from 'react' +import { use, useEffect, useState } from 'react' +import { getOriginalCwd } from '../../bootstrap/state.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { getAutoMemPath, isAutoMemoryEnabled } from '../../memdir/paths.js' +import { logEvent } from '../../services/analytics/index.js' +import { isAutoDreamEnabled } from '../../services/autoDream/config.js' +import { readLastConsolidatedAt } from '../../services/autoDream/consolidationLock.js' +import { useAppState } from '../../state/AppState.js' +import { getAgentMemoryDir } from '../../tools/AgentTool/agentMemory.js' +import { openPath } from '../../utils/browser.js' +import { getMemoryFiles, type MemoryFileInfo } from '../../utils/claudemd.js' +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatRelativeTimeAgo } from '../../utils/format.js' +import { projectIsInGitRepo } from '../../utils/memory/versions.js' +import { updateSettingsForSource } from '../../utils/settings/settings.js' +import { Select } from '../CustomSelect/index.js' +import { ListItem } from '../design-system/ListItem.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const teamMemPaths = feature('TEAMMEM') ? require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') : null; +const teamMemPaths = feature('TEAMMEM') + ? (require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ interface ExtendedMemoryFileInfo extends MemoryFileInfo { - isNested?: boolean; - exists: boolean; + isNested?: boolean + exists: boolean } // Remember last selected path -let lastSelectedPath: string | undefined; -const OPEN_FOLDER_PREFIX = '__open_folder__'; +let lastSelectedPath: string | undefined + +const OPEN_FOLDER_PREFIX = '__open_folder__' + type Props = { - onSelect: (path: string) => void; - onCancel: () => void; -}; -export function MemoryFileSelector(t0) { - const $ = _c(58); - const { - onSelect, - onCancel - } = t0; - const existingMemoryFiles = use(getMemoryFiles()) as MemoryFileInfo[]; - const userMemoryPath = join(getClaudeConfigHomeDir(), "CLAUDE.md"); - const projectMemoryPath = join(getOriginalCwd(), "CLAUDE.md"); - const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath); - const hasProjectMemory = existingMemoryFiles.some(f_0 => f_0.path === projectMemoryPath); - const allMemoryFiles = [...existingMemoryFiles.filter(_temp).map(_temp2), ...(hasUserMemory ? [] : [{ - path: userMemoryPath, - type: "User" as const, - content: "", - exists: false - }]), ...(hasProjectMemory ? [] : [{ - path: projectMemoryPath, - type: "Project" as const, - content: "", - exists: false - }])]; - const depths = new Map(); + onSelect: (path: string) => void + onCancel: () => void +} + +export function MemoryFileSelector({ + onSelect, + onCancel, +}: Props): React.ReactNode { + const existingMemoryFiles = use(getMemoryFiles()) + + // Create entries for User and Project CLAUDE.md even if they don't exist + const userMemoryPath = join(getClaudeConfigHomeDir(), 'CLAUDE.md') + const projectMemoryPath = join(getOriginalCwd(), 'CLAUDE.md') + + // Check if these are already in the existing files + const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath) + const hasProjectMemory = existingMemoryFiles.some( + f => f.path === projectMemoryPath, + ) + + // Filter out AutoMem/TeamMem entrypoints: these are MEMORY.md files, and + // /memory already surfaces "Open auto-memory folder" / "Open team memory + // folder" options below. Listing the entrypoint file separately is redundant. + const allMemoryFiles: ExtendedMemoryFileInfo[] = [ + ...existingMemoryFiles + .filter(f => f.type !== 'AutoMem' && f.type !== 'TeamMem') + .map(f => ({ ...f, exists: true })), + // Add User memory if it doesn't exist + ...(hasUserMemory + ? [] + : [ + { + path: userMemoryPath, + type: 'User' as const, + content: '', + exists: false, + }, + ]), + // Add Project memory if it doesn't exist + ...(hasProjectMemory + ? [] + : [ + { + path: projectMemoryPath, + type: 'Project' as const, + content: '', + exists: false, + }, + ]), + ] + + const depths = new Map() + + // Create options for the select component const memoryOptions = allMemoryFiles.map(file => { - const displayPath = getDisplayPath(file.path); - const existsLabel = file.exists ? "" : " (new)"; - const depth = file.parent ? (depths.get(file.parent) ?? 0) + 1 : 0; - depths.set(file.path, depth); - const indent = depth > 0 ? " ".repeat(depth - 1) : ""; - let label; - if (file.type === "User" && !file.isNested && file.path === userMemoryPath) { - label = "User memory"; + const displayPath = getDisplayPath(file.path) + const existsLabel = file.exists ? '' : ' (new)' + + // Calculate depth based on parent + const depth = file.parent ? (depths.get(file.parent) ?? 0) + 1 : 0 + depths.set(file.path, depth) + const indent = depth > 0 ? ' '.repeat(depth - 1) : '' + + // Format label based on type + let label: string + if ( + file.type === 'User' && + !file.isNested && + file.path === userMemoryPath + ) { + label = `User memory` + } else if ( + file.type === 'Project' && + !file.isNested && + file.path === projectMemoryPath + ) { + label = `Project memory` + } else if (depth > 0) { + // For child nodes (imported files), show indented with L + label = `${indent}L ${displayPath}${existsLabel}` } else { - if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) { - label = "Project memory"; - } else { - if (depth > 0) { - label = `${indent}L ${displayPath}${existsLabel}`; - } else { - label = `${displayPath}`; - } - } + // For other memory files, just show the path + label = `${displayPath}` } - let description; - const isGit = projectIsInGitRepo(getOriginalCwd()); - if (file.type === "User" && !file.isNested) { - description = "Saved in ~/.claude/CLAUDE.md"; + + // Create description based on type - keep the original descriptions for built-in types + let description: string + const isGit = projectIsInGitRepo(getOriginalCwd()) + + if (file.type === 'User' && !file.isNested) { + description = 'Saved in ~/.claude/CLAUDE.md' + } else if ( + file.type === 'Project' && + !file.isNested && + file.path === projectMemoryPath + ) { + description = `${isGit ? 'Checked in at' : 'Saved in'} ./CLAUDE.md` + } else if (file.parent) { + // For imported files (with @-import) + description = '@-imported' + } else if (file.isNested) { + // For nested files (dynamically loaded) + description = 'dynamically loaded' } else { - if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) { - description = `${isGit ? "Checked in at" : "Saved in"} ./CLAUDE.md`; - } else { - if (file.parent) { - description = "@-imported"; - } else { - if (file.isNested) { - description = "dynamically loaded"; - } else { - description = ""; - } - } - } + description = '' } + return { label, value: file.path, - description - }; - }); - const folderOptions = []; - const agentDefinitions = useAppState(_temp3); - if (isAutoMemoryEnabled()) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - label: "Open auto-memory folder", - value: `${OPEN_FOLDER_PREFIX}${getAutoMemPath()}`, - description: "" - }; - $[0] = t1; - } else { - t1 = $[0]; + description, } - folderOptions.push(t1); - if (feature("TEAMMEM") && teamMemPaths.isTeamMemoryEnabled()) { - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - label: "Open team memory folder", - value: `${OPEN_FOLDER_PREFIX}${teamMemPaths.getTeamMemPath()}`, - description: "" - }; - $[1] = t2; - } else { - t2 = $[1]; - } - folderOptions.push(t2); + }) + + // Add "Open folder" options for auto-memory and agent memory directories + const folderOptions: Array<{ + label: string + value: string + description: string + }> = [] + + const agentDefinitions = useAppState(s => s.agentDefinitions) + if (isAutoMemoryEnabled()) { + // Always show auto-memory folder option + folderOptions.push({ + label: 'Open auto-memory folder', + value: `${OPEN_FOLDER_PREFIX}${getAutoMemPath()}`, + description: '', + }) + + // Team memory directly below auto-memory (team dir is a subdir of auto dir) + if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) { + folderOptions.push({ + label: 'Open team memory folder', + value: `${OPEN_FOLDER_PREFIX}${teamMemPaths!.getTeamMemPath()}`, + description: '', + }) } + + // Add agent memory folders for agents that have memory configured for (const agent of agentDefinitions.activeAgents) { if (agent.memory) { - const agentDir = getAgentMemoryDir(agent.agentType, agent.memory); + const agentDir = getAgentMemoryDir(agent.agentType, agent.memory) folderOptions.push({ label: `Open ${chalk.bold(agent.agentType)} agent memory`, value: `${OPEN_FOLDER_PREFIX}${agentDir}`, - description: `${agent.memory} scope` - }); + description: `${agent.memory} scope`, + }) } } } - memoryOptions.push(...folderOptions); - let t1; - if ($[2] !== memoryOptions) { - t1 = lastSelectedPath && memoryOptions.some(_temp4) ? lastSelectedPath : memoryOptions[0]?.value || ""; - $[2] = memoryOptions; - $[3] = t1; - } else { - t1 = $[3]; - } - const initialPath = t1; - const [autoMemoryOn, setAutoMemoryOn] = useState(isAutoMemoryEnabled); - const [autoDreamOn, setAutoDreamOn] = useState(isAutoDreamEnabled); - const [showDreamRow] = useState(isAutoMemoryEnabled); - const isDreamRunning = useAppState(_temp6); - const [lastDreamAt, setLastDreamAt] = useState(null); - let t2; - if ($[4] !== showDreamRow) { - t2 = () => { - if (!showDreamRow) { - return; - } - readLastConsolidatedAt().then(setLastDreamAt); - }; - $[4] = showDreamRow; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== isDreamRunning || $[7] !== showDreamRow) { - t3 = [showDreamRow, isDreamRunning]; - $[6] = isDreamRunning; - $[7] = showDreamRow; - $[8] = t3; - } else { - t3 = $[8]; - } - useEffect(t2, t3); - let t4; - if ($[9] !== isDreamRunning || $[10] !== lastDreamAt) { - t4 = isDreamRunning ? "running" : lastDreamAt === null ? "" : lastDreamAt === 0 ? "never" : `last ran ${formatRelativeTimeAgo(new Date(lastDreamAt))}`; - $[9] = isDreamRunning; - $[10] = lastDreamAt; - $[11] = t4; - } else { - t4 = $[11]; - } - const dreamStatus = t4; - const [focusedToggle, setFocusedToggle] = useState(null); - const toggleFocused = focusedToggle !== null; - const lastToggleIndex = showDreamRow ? 1 : 0; - let t5; - if ($[12] !== autoMemoryOn) { - t5 = function handleToggleAutoMemory() { - const newValue = !autoMemoryOn; - updateSettingsForSource("userSettings", { - autoMemoryEnabled: newValue - }); - setAutoMemoryOn(newValue); - logEvent("tengu_auto_memory_toggled", { - enabled: newValue - }); - }; - $[12] = autoMemoryOn; - $[13] = t5; - } else { - t5 = $[13]; - } - const handleToggleAutoMemory = t5; - let t6; - if ($[14] !== autoDreamOn) { - t6 = function handleToggleAutoDream() { - const newValue_0 = !autoDreamOn; - updateSettingsForSource("userSettings", { - autoDreamEnabled: newValue_0 - }); - setAutoDreamOn(newValue_0); - logEvent("tengu_auto_dream_toggled", { - enabled: newValue_0 - }); - }; - $[14] = autoDreamOn; - $[15] = t6; - } else { - t6 = $[15]; - } - const handleToggleAutoDream = t6; - useExitOnCtrlCDWithKeybindings(); - let t7; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - context: "Confirmation" - }; - $[16] = t7; - } else { - t7 = $[16]; - } - useKeybinding("confirm:no", onCancel, t7); - let t8; - if ($[17] !== focusedToggle || $[18] !== handleToggleAutoDream || $[19] !== handleToggleAutoMemory) { - t8 = () => { - if (focusedToggle === 0) { - handleToggleAutoMemory(); - } else { - if (focusedToggle === 1) { - handleToggleAutoDream(); - } - } - }; - $[17] = focusedToggle; - $[18] = handleToggleAutoDream; - $[19] = handleToggleAutoMemory; - $[20] = t8; - } else { - t8 = $[20]; - } - let t9; - if ($[21] !== toggleFocused) { - t9 = { - context: "Confirmation", - isActive: toggleFocused - }; - $[21] = toggleFocused; - $[22] = t9; - } else { - t9 = $[22]; - } - useKeybinding("confirm:yes", t8, t9); - let t10; - if ($[23] !== lastToggleIndex) { - t10 = () => { - setFocusedToggle(prev => prev !== null && prev < lastToggleIndex ? prev + 1 : null); - }; - $[23] = lastToggleIndex; - $[24] = t10; - } else { - t10 = $[24]; - } - let t11; - if ($[25] !== toggleFocused) { - t11 = { - context: "Select", - isActive: toggleFocused - }; - $[25] = toggleFocused; - $[26] = t11; - } else { - t11 = $[26]; - } - useKeybinding("select:next", t10, t11); - let t12; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t12 = () => { - setFocusedToggle(_temp7); - }; - $[27] = t12; - } else { - t12 = $[27]; - } - let t13; - if ($[28] !== toggleFocused) { - t13 = { - context: "Select", - isActive: toggleFocused - }; - $[28] = toggleFocused; - $[29] = t13; - } else { - t13 = $[29]; - } - useKeybinding("select:previous", t12, t13); - const t14 = focusedToggle === 0; - const t15 = autoMemoryOn ? "on" : "off"; - let t16; - if ($[30] !== t15) { - t16 = Auto-memory: {t15}; - $[30] = t15; - $[31] = t16; - } else { - t16 = $[31]; - } - let t17; - if ($[32] !== t14 || $[33] !== t16) { - t17 = {t16}; - $[32] = t14; - $[33] = t16; - $[34] = t17; - } else { - t17 = $[34]; - } - let t18; - if ($[35] !== autoDreamOn || $[36] !== dreamStatus || $[37] !== focusedToggle || $[38] !== isDreamRunning || $[39] !== showDreamRow) { - t18 = showDreamRow && Auto-dream: {autoDreamOn ? "on" : "off"}{dreamStatus && · {dreamStatus}}{!isDreamRunning && autoDreamOn && · /dream to run}; - $[35] = autoDreamOn; - $[36] = dreamStatus; - $[37] = focusedToggle; - $[38] = isDreamRunning; - $[39] = showDreamRow; - $[40] = t18; - } else { - t18 = $[40]; - } - let t19; - if ($[41] !== t17 || $[42] !== t18) { - t19 = {t17}{t18}; - $[41] = t17; - $[42] = t18; - $[43] = t19; - } else { - t19 = $[43]; - } - let t20; - if ($[44] !== onSelect) { - t20 = value => { - if (value.startsWith(OPEN_FOLDER_PREFIX)) { - const folderPath = value.slice(OPEN_FOLDER_PREFIX.length); - mkdir(folderPath, { - recursive: true - }).catch(_temp8).then(() => openPath(folderPath)); - return; - } - lastSelectedPath = value; - onSelect(value); - }; - $[44] = onSelect; - $[45] = t20; - } else { - t20 = $[45]; - } - let t21; - if ($[46] !== lastToggleIndex) { - t21 = () => setFocusedToggle(lastToggleIndex); - $[46] = lastToggleIndex; - $[47] = t21; - } else { - t21 = $[47]; - } - let t22; - if ($[48] !== initialPath || $[49] !== memoryOptions || $[50] !== onCancel || $[51] !== t20 || $[52] !== t21 || $[53] !== toggleFocused) { - t22 = { + if (value.startsWith(OPEN_FOLDER_PREFIX)) { + const folderPath = value.slice(OPEN_FOLDER_PREFIX.length) + // Ensure folder exists before opening (idempotent; swallow + // permission errors to match previous behavior) + void mkdir(folderPath, { recursive: true }) + .catch(() => {}) + .then(() => openPath(folderPath)) + return + } + lastSelectedPath = value // Remember the selection + onSelect(value) + }} + onCancel={onCancel} + onUpFromFirstItem={() => setFocusedToggle(lastToggleIndex)} + /> + + ) } diff --git a/src/components/memory/MemoryUpdateNotification.tsx b/src/components/memory/MemoryUpdateNotification.tsx index c96edb1e9..890a567b0 100644 --- a/src/components/memory/MemoryUpdateNotification.tsx +++ b/src/components/memory/MemoryUpdateNotification.tsx @@ -1,44 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -import { homedir } from 'os'; -import { relative } from 'path'; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getCwd } from '../../utils/cwd.js'; +import { homedir } from 'os' +import { relative } from 'path' +import React from 'react' +import { Box, Text } from '../../ink.js' +import { getCwd } from '../../utils/cwd.js' + export function getRelativeMemoryPath(path: string): string { - const homeDir = homedir(); - const cwd = getCwd(); + const homeDir = homedir() + const cwd = getCwd() // Calculate relative paths - const relativeToHome = path.startsWith(homeDir) ? '~' + path.slice(homeDir.length) : null; - const relativeToCwd = path.startsWith(cwd) ? './' + relative(cwd, path) : null; + const relativeToHome = path.startsWith(homeDir) + ? '~' + path.slice(homeDir.length) + : null + + const relativeToCwd = path.startsWith(cwd) ? './' + relative(cwd, path) : null // Return the shorter path, or absolute if neither is applicable if (relativeToHome && relativeToCwd) { - return relativeToHome.length <= relativeToCwd.length ? relativeToHome : relativeToCwd; + return relativeToHome.length <= relativeToCwd.length + ? relativeToHome + : relativeToCwd } - return relativeToHome || relativeToCwd || path; + + return relativeToHome || relativeToCwd || path } -export function MemoryUpdateNotification(t0) { - const $ = _c(4); - const { - memoryPath - } = t0; - let t1; - if ($[0] !== memoryPath) { - t1 = getRelativeMemoryPath(memoryPath); - $[0] = memoryPath; - $[1] = t1; - } else { - t1 = $[1]; - } - const displayPath = t1; - let t2; - if ($[2] !== displayPath) { - t2 = Memory updated in {displayPath} · /memory to edit; - $[2] = displayPath; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; + +export function MemoryUpdateNotification({ + memoryPath, +}: { + memoryPath: string +}): React.ReactNode { + const displayPath = getRelativeMemoryPath(memoryPath) + + return ( + + + Memory updated in {displayPath} · /memory to edit + + + ) } diff --git a/src/components/messageActions.tsx b/src/components/messageActions.tsx index d9800a10e..3e368c306 100644 --- a/src/components/messageActions.tsx +++ b/src/components/messageActions.tsx @@ -1,40 +1,55 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import type { RefObject } from 'react'; -import React, { useCallback, useMemo, useRef } from 'react'; -import { Box, Text } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { logEvent } from '../services/analytics/index.js'; -import type { ContentItem, NormalizedUserMessage, RenderableMessage } from '../types/message.js'; -import { isEmptyMessageText, SYNTHETIC_MESSAGES } from '../utils/messages.js'; -const NAVIGABLE_TYPES = ['user', 'assistant', 'grouped_tool_use', 'collapsed_read_search', 'system', 'attachment'] as const; -export type NavigableType = (typeof NAVIGABLE_TYPES)[number]; -export type NavigableOf = Extract; -export type NavigableMessage = RenderableMessage; +import figures from 'figures' +import type { RefObject } from 'react' +import React, { useCallback, useMemo, useRef } from 'react' +import { Box, Text } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { logEvent } from '../services/analytics/index.js' +import type { + NormalizedUserMessage, + RenderableMessage, +} from '../types/message.js' +import { isEmptyMessageText, SYNTHETIC_MESSAGES } from '../utils/messages.js' + +const NAVIGABLE_TYPES = [ + 'user', + 'assistant', + 'grouped_tool_use', + 'collapsed_read_search', + 'system', + 'attachment', +] as const +export type NavigableType = (typeof NAVIGABLE_TYPES)[number] + +export type NavigableOf = Extract< + RenderableMessage, + { type: T } +> +export type NavigableMessage = RenderableMessage // Tier-2 blocklist (tier-1 is height > 0) — things that render but aren't actionable. export function isNavigableMessage(msg: NavigableMessage): boolean { switch (msg.type) { - case 'assistant': - { - const b = msg.message.content[0] as ContentItem | undefined; - // Text responses (minus AssistantTextMessage's return-null cases — tier-1 - // misses unmeasured virtual items), or tool calls with extractable input. - return b?.type === 'text' && !isEmptyMessageText(b.text) && !SYNTHETIC_MESSAGES.has(b.text) || b?.type === 'tool_use' && b.name in PRIMARY_INPUT; - } - case 'user': - { - if (msg.isMeta || msg.isCompactSummary) return false; - const b = msg.message.content[0] as ContentItem | undefined; - if (b?.type !== 'text') return false; - // Interrupt etc. — synthetic, not user-authored. - if (SYNTHETIC_MESSAGES.has(b.text)) return false; - // Same filter as VirtualMessageList sticky-prompt: XML-wrapped (command - // expansions, bash-stdout, etc.) aren't real prompts. - return !stripSystemReminders(b.text).startsWith('<'); - } + case 'assistant': { + const b = msg.message.content[0] + // Text responses (minus AssistantTextMessage's return-null cases — tier-1 + // misses unmeasured virtual items), or tool calls with extractable input. + return ( + (b?.type === 'text' && + !isEmptyMessageText(b.text) && + !SYNTHETIC_MESSAGES.has(b.text)) || + (b?.type === 'tool_use' && b.name in PRIMARY_INPUT) + ) + } + case 'user': { + if (msg.isMeta || msg.isCompactSummary) return false + const b = msg.message.content[0] + if (b?.type !== 'text') return false + // Interrupt etc. — synthetic, not user-authored. + if (SYNTHETIC_MESSAGES.has(b.text)) return false + // Same filter as VirtualMessageList sticky-prompt: XML-wrapped (command + // expansions, bash-stdout, etc.) aren't real prompts. + return !stripSystemReminders(b.text).startsWith('<') + } case 'system': // biome-ignore lint/nursery/useExhaustiveSwitchCases: blocklist — fallthrough return-true is the design switch (msg.subtype) { @@ -45,184 +60,176 @@ export function isNavigableMessage(msg: NavigableMessage): boolean { case 'agents_killed': case 'away_summary': case 'thinking': - return false; + return false } - return true; + return true case 'grouped_tool_use': case 'collapsed_read_search': - return true; + return true case 'attachment': switch (msg.attachment.type) { case 'queued_command': case 'diagnostics': case 'hook_blocking_error': case 'hook_error_during_execution': - return true; + return true } - return false; + return false } } + type PrimaryInput = { - label: string; - extract: (input: Record) => string | undefined; -}; -const str = (k: string) => (i: Record) => typeof i[k] === 'string' ? i[k] : undefined; + label: string + extract: (input: Record) => string | undefined +} +const str = (k: string) => (i: Record) => + typeof i[k] === 'string' ? i[k] : undefined const PRIMARY_INPUT: Record = { - Read: { - label: 'path', - extract: str('file_path') - }, - Edit: { - label: 'path', - extract: str('file_path') - }, - Write: { - label: 'path', - extract: str('file_path') - }, - NotebookEdit: { - label: 'path', - extract: str('notebook_path') - }, - Bash: { - label: 'command', - extract: str('command') - }, - Grep: { - label: 'pattern', - extract: str('pattern') - }, - Glob: { - label: 'pattern', - extract: str('pattern') - }, - WebFetch: { - label: 'url', - extract: str('url') - }, - WebSearch: { - label: 'query', - extract: str('query') - }, - Task: { - label: 'prompt', - extract: str('prompt') - }, - Agent: { - label: 'prompt', - extract: str('prompt') - }, + Read: { label: 'path', extract: str('file_path') }, + Edit: { label: 'path', extract: str('file_path') }, + Write: { label: 'path', extract: str('file_path') }, + NotebookEdit: { label: 'path', extract: str('notebook_path') }, + Bash: { label: 'command', extract: str('command') }, + Grep: { label: 'pattern', extract: str('pattern') }, + Glob: { label: 'pattern', extract: str('pattern') }, + WebFetch: { label: 'url', extract: str('url') }, + WebSearch: { label: 'query', extract: str('query') }, + Task: { label: 'prompt', extract: str('prompt') }, + Agent: { label: 'prompt', extract: str('prompt') }, Tmux: { label: 'command', - extract: i => Array.isArray(i.args) ? `tmux ${i.args.join(' ')}` : undefined - } -}; + extract: i => + Array.isArray(i.args) ? `tmux ${i.args.join(' ')}` : undefined, + }, +} // Only AgentTool has renderGroupedToolUse — Edit/Bash/etc. stay as assistant tool_use blocks. -export function toolCallOf(msg: NavigableMessage): { - name: string; - input: Record; -} | undefined { +export function toolCallOf( + msg: NavigableMessage, +): { name: string; input: Record } | undefined { if (msg.type === 'assistant') { - const b = msg.message.content[0] as ContentItem | undefined; - if (b?.type === 'tool_use') return { - name: b.name, - input: b.input as Record - }; + const b = msg.message.content[0] + if (b?.type === 'tool_use') + return { name: b.name, input: b.input as Record } } if (msg.type === 'grouped_tool_use') { - const b = msg.messages[0]?.message.content[0] as ContentItem | undefined; - if (b?.type === 'tool_use') return { - name: msg.toolName, - input: b.input as Record - }; + const b = msg.messages[0]?.message.content[0] + if (b?.type === 'tool_use') + return { name: msg.toolName, input: b.input as Record } } - return undefined; + return undefined } + export type MessageActionCaps = { - copy: (text: string) => void; - edit: (msg: NormalizedUserMessage) => Promise; -}; + copy: (text: string) => void + edit: (msg: NormalizedUserMessage) => Promise +} // Identity builder — preserves tuple type so `run`'s param narrows (array literal widens without this). function action(a: { - key: K; - label: string | ((s: MessageActionsState) => string); - types: readonly T[]; - applies?: (s: MessageActionsState) => boolean; - stays?: true; - run: (m: NavigableOf, caps: MessageActionCaps) => void; + key: K + label: string | ((s: MessageActionsState) => string) + types: readonly T[] + applies?: (s: MessageActionsState) => boolean + stays?: true + run: (m: NavigableOf, caps: MessageActionCaps) => void }) { - return a; + return a } -export const MESSAGE_ACTIONS = [action({ - key: 'enter', - label: s => s.expanded ? 'collapse' : 'expand', - types: ['grouped_tool_use', 'collapsed_read_search', 'attachment', 'system'], - stays: true, - // Empty — `stays` handled inline by dispatch. - run: () => {} -}), action({ - key: 'enter', - label: 'edit', - types: ['user'], - run: (m, c) => void c.edit(m) -}), action({ - key: 'c', - label: 'copy', - types: NAVIGABLE_TYPES, - run: (m, c) => c.copy(copyTextOf(m)) -}), action({ - key: 'p', - // `!` safe: applies() guarantees toolName ∈ PRIMARY_INPUT. - label: s => `copy ${PRIMARY_INPUT[s.toolName!]!.label}`, - types: ['grouped_tool_use', 'assistant'], - applies: s => s.toolName != null && s.toolName in PRIMARY_INPUT, - run: (m, c) => { - const tc = toolCallOf(m); - if (!tc) return; - const val = PRIMARY_INPUT[tc.name]?.extract(tc.input); - if (val) c.copy(val); - } -})] as const; -function isApplicable(a: (typeof MESSAGE_ACTIONS)[number], c: MessageActionsState): boolean { - if (!(a.types as readonly string[]).includes(c.msgType)) return false; - return !a.applies || a.applies(c); + +export const MESSAGE_ACTIONS = [ + action({ + key: 'enter', + label: s => (s.expanded ? 'collapse' : 'expand'), + types: [ + 'grouped_tool_use', + 'collapsed_read_search', + 'attachment', + 'system', + ], + stays: true, + // Empty — `stays` handled inline by dispatch. + run: () => {}, + }), + action({ + key: 'enter', + label: 'edit', + types: ['user'], + run: (m, c) => void c.edit(m), + }), + action({ + key: 'c', + label: 'copy', + types: NAVIGABLE_TYPES, + run: (m, c) => c.copy(copyTextOf(m)), + }), + action({ + key: 'p', + // `!` safe: applies() guarantees toolName ∈ PRIMARY_INPUT. + label: s => `copy ${PRIMARY_INPUT[s.toolName!]!.label}`, + types: ['grouped_tool_use', 'assistant'], + applies: s => s.toolName != null && s.toolName in PRIMARY_INPUT, + run: (m, c) => { + const tc = toolCallOf(m) + if (!tc) return + const val = PRIMARY_INPUT[tc.name]?.extract(tc.input) + if (val) c.copy(val) + }, + }), +] as const + +function isApplicable( + a: (typeof MESSAGE_ACTIONS)[number], + c: MessageActionsState, +): boolean { + if (!(a.types as readonly string[]).includes(c.msgType)) return false + return !a.applies || a.applies(c) } + export type MessageActionsState = { - uuid: string; - msgType: NavigableType; - expanded: boolean; - toolName?: string; -}; + uuid: string + msgType: NavigableType + expanded: boolean + toolName?: string +} + export type MessageActionsNav = { - enterCursor: () => void; - navigatePrev: () => void; - navigateNext: () => void; - navigatePrevUser: () => void; - navigateNextUser: () => void; - navigateTop: () => void; - navigateBottom: () => void; - getSelected: () => NavigableMessage | null; -}; -export const MessageActionsSelectedContext = React.createContext(false); -export const InVirtualListContext = React.createContext(false); + enterCursor: () => void + navigatePrev: () => void + navigateNext: () => void + navigatePrevUser: () => void + navigateNextUser: () => void + navigateTop: () => void + navigateBottom: () => void + getSelected: () => NavigableMessage | null +} + +export const MessageActionsSelectedContext = React.createContext(false) +export const InVirtualListContext = React.createContext(false) // bg must go on the Box that HAS marginTop (margin stays outside paint) — that's inside each consumer. -export function useSelectedMessageBg() { - return React.useContext(MessageActionsSelectedContext) ? "messageActionsBackground" : undefined; +export function useSelectedMessageBg(): 'messageActionsBackground' | undefined { + return React.useContext(MessageActionsSelectedContext) + ? 'messageActionsBackground' + : undefined } // Can't call useKeybindings here — hook runs outside provider. Returns handlers instead. -export function useMessageActions(cursor: MessageActionsState | null, setCursor: React.Dispatch>, navRef: RefObject, caps: MessageActionCaps): { - enter: () => void; - handlers: Record void>; +export function useMessageActions( + cursor: MessageActionsState | null, + setCursor: React.Dispatch>, + navRef: RefObject, + caps: MessageActionCaps, +): { + enter: () => void + handlers: Record void> } { // Refs keep handlers stable — no useKeybindings re-register per message append. - const cursorRef = useRef(cursor); - cursorRef.current = cursor; - const capsRef = useRef(caps); - capsRef.current = caps; + const cursorRef = useRef(cursor) + cursorRef.current = cursor + const capsRef = useRef(caps) + capsRef.current = caps + const handlers = useMemo(() => { const h: Record void> = { 'messageActions:prev': () => navRef.current?.navigatePrev(), @@ -231,219 +238,159 @@ export function useMessageActions(cursor: MessageActionsState | null, setCursor: 'messageActions:nextUser': () => navRef.current?.navigateNextUser(), 'messageActions:top': () => navRef.current?.navigateTop(), 'messageActions:bottom': () => navRef.current?.navigateBottom(), - 'messageActions:escape': () => setCursor(c => c?.expanded ? { - ...c, - expanded: false - } : null), + 'messageActions:escape': () => + setCursor(c => (c?.expanded ? { ...c, expanded: false } : null)), // ctrl+c skips the collapse step — from expanded-during-streaming, two-stage // would mean 3 presses to interrupt (collapse→null→cancel). - 'messageActions:ctrlc': () => setCursor(null) - }; - for (const key of new Set(MESSAGE_ACTIONS.map(a_1 => a_1.key))) { + 'messageActions:ctrlc': () => setCursor(null), + } + for (const key of new Set(MESSAGE_ACTIONS.map(a => a.key))) { h[`messageActions:${key}`] = () => { - const c_0 = cursorRef.current; - if (!c_0) return; - const a_0 = MESSAGE_ACTIONS.find(a => a.key === key && isApplicable(a, c_0)); - if (!a_0) return; - if (a_0.stays) { - setCursor(c_1 => c_1 ? { - ...c_1, - expanded: !c_1.expanded - } : null); - return; + const c = cursorRef.current + if (!c) return + const a = MESSAGE_ACTIONS.find(a => a.key === key && isApplicable(a, c)) + if (!a) return + if (a.stays) { + setCursor(c => (c ? { ...c, expanded: !c.expanded } : null)) + return } - const m = navRef.current?.getSelected(); - if (!m) return; - (a_0.run as (m: NavigableMessage, c_0: MessageActionCaps) => void)(m, capsRef.current); - setCursor(null); - }; + const m = navRef.current?.getSelected() + if (!m) return + ;(a.run as (m: NavigableMessage, c: MessageActionCaps) => void)( + m, + capsRef.current, + ) + setCursor(null) + } } - return h; - }, [setCursor, navRef]); + return h + }, [setCursor, navRef]) + const enter = useCallback(() => { - logEvent('tengu_message_actions_enter', {}); - navRef.current?.enterCursor(); - }, [navRef]); - return { - enter, - handlers - }; + logEvent('tengu_message_actions_enter', {}) + navRef.current?.enterCursor() + }, [navRef]) + + return { enter, handlers } } // Must mount inside . -export function MessageActionsKeybindings(t0) { - const $ = _c(2); - const { - handlers, - isActive - } = t0; - let t1; - if ($[0] !== isActive) { - t1 = { - context: "MessageActions", - isActive - }; - $[0] = isActive; - $[1] = t1; - } else { - t1 = $[1]; - } - useKeybindings(handlers, t1); - return null; +export function MessageActionsKeybindings({ + handlers, + isActive, +}: { + handlers: Record void> + isActive: boolean +}): null { + useKeybindings(handlers, { context: 'MessageActions', isActive }) + return null } // borderTop-only Box matches PromptInput's ─── line for stable footer height. -export function MessageActionsBar(t0) { - const $ = _c(28); - const { - cursor - } = t0; - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - if ($[0] !== cursor) { - const applicable = MESSAGE_ACTIONS.filter(a => isApplicable(a, cursor)); - T1 = Box; - t4 = "column"; - t5 = 0; - t6 = 1; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = ; - $[10] = t7; - } else { - t7 = $[10]; - } - T0 = Box; - t1 = 2; - t2 = 1; - t3 = applicable.map((a_0, i) => { - const label = typeof a_0.label === "function" ? a_0.label(cursor) : a_0.label; - return {i > 0 && · }{a_0.key} {label}; - }); - $[0] = cursor; - $[1] = T0; - $[2] = T1; - $[3] = t1; - $[4] = t2; - $[5] = t3; - $[6] = t4; - $[7] = t5; - $[8] = t6; - $[9] = t7; - } else { - T0 = $[1]; - T1 = $[2]; - t1 = $[3]; - t2 = $[4]; - t3 = $[5]; - t4 = $[6]; - t5 = $[7]; - t6 = $[8]; - t7 = $[9]; - } - let t10; - let t11; - let t12; - let t8; - let t9; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t8 = · ; - t9 = {figures.arrowUp}{figures.arrowDown}; - t10 = navigate · ; - t11 = esc; - t12 = back; - $[11] = t10; - $[12] = t11; - $[13] = t12; - $[14] = t8; - $[15] = t9; - } else { - t10 = $[11]; - t11 = $[12]; - t12 = $[13]; - t8 = $[14]; - t9 = $[15]; - } - let t13; - if ($[16] !== T0 || $[17] !== t1 || $[18] !== t2 || $[19] !== t3) { - t13 = {t3}{t8}{t9}{t10}{t11}{t12}; - $[16] = T0; - $[17] = t1; - $[18] = t2; - $[19] = t3; - $[20] = t13; - } else { - t13 = $[20]; - } - let t14; - if ($[21] !== T1 || $[22] !== t13 || $[23] !== t4 || $[24] !== t5 || $[25] !== t6 || $[26] !== t7) { - t14 = {t7}{t13}; - $[21] = T1; - $[22] = t13; - $[23] = t4; - $[24] = t5; - $[25] = t6; - $[26] = t7; - $[27] = t14; - } else { - t14 = $[27]; - } - return t14; +export function MessageActionsBar({ + cursor, +}: { + cursor: MessageActionsState +}): React.ReactNode { + const applicable = MESSAGE_ACTIONS.filter(a => isApplicable(a, cursor)) + return ( + + + + {applicable.map((a, i) => { + const label = + typeof a.label === 'function' ? a.label(cursor) : a.label + return ( + + {i > 0 && · } + {/* dimColor={false} forces SGR 22 — borderDimColor sibling bleeds dim into first cell */} + + {a.key} + + {label} + + ) + })} + · + + {figures.arrowUp} + {figures.arrowDown} + + navigate · + + esc + + back + + + ) } + export function stripSystemReminders(text: string): string { - const CLOSE = ''; - let t = text.trimStart(); + const CLOSE = '' + let t = text.trimStart() while (t.startsWith('')) { - const end = t.indexOf(CLOSE); - if (end < 0) break; - t = t.slice(end + CLOSE.length).trimStart(); + const end = t.indexOf(CLOSE) + if (end < 0) break + t = t.slice(end + CLOSE.length).trimStart() } - return t; + return t } + export function copyTextOf(msg: NavigableMessage): string { switch (msg.type) { - case 'user': - { - const b = msg.message.content[0] as ContentItem | undefined; - return b?.type === 'text' ? stripSystemReminders(b.text) : ''; - } - case 'assistant': - { - const b = msg.message.content[0] as ContentItem | undefined; - if (b?.type === 'text') return b.text; - const tc = toolCallOf(msg); - return tc ? PRIMARY_INPUT[tc.name]?.extract(tc.input) ?? '' : ''; - } + case 'user': { + const b = msg.message.content[0] + return b?.type === 'text' ? stripSystemReminders(b.text) : '' + } + case 'assistant': { + const b = msg.message.content[0] + if (b?.type === 'text') return b.text + const tc = toolCallOf(msg) + return tc ? (PRIMARY_INPUT[tc.name]?.extract(tc.input) ?? '') : '' + } case 'grouped_tool_use': - return msg.results.map(toolResultText).filter(Boolean).join('\n\n'); + return msg.results.map(toolResultText).filter(Boolean).join('\n\n') case 'collapsed_read_search': - return msg.messages.flatMap(m => m.type === 'user' ? [toolResultText(m)] : m.type === 'grouped_tool_use' ? m.results.map(toolResultText) : []).filter(Boolean).join('\n\n'); + return msg.messages + .flatMap(m => + m.type === 'user' + ? [toolResultText(m)] + : m.type === 'grouped_tool_use' + ? m.results.map(toolResultText) + : [], + ) + .filter(Boolean) + .join('\n\n') case 'system': - if ('content' in msg) return msg.content as string; - if ('error' in msg) return String(msg.error); - return msg.subtype as string; - case 'attachment': - { - const a = msg.attachment; - if (a.type === 'queued_command') { - const p = a.prompt as string | ContentItem[]; - return typeof p === 'string' ? p : p.flatMap(b => b.type === 'text' ? [b.text] : []).join('\n'); - } - return `[${a.type}]`; + if ('content' in msg) return msg.content + if ('error' in msg) return String(msg.error) + return msg.subtype + case 'attachment': { + const a = msg.attachment + if (a.type === 'queued_command') { + const p = a.prompt + return typeof p === 'string' + ? p + : p.flatMap(b => (b.type === 'text' ? [b.text] : [])).join('\n') } + return `[${a.type}]` + } } } + function toolResultText(r: NormalizedUserMessage): string { - const b = r.message.content[0] as ContentItem | undefined; - if (b?.type !== 'tool_result') return ''; - const c = b.content; - if (typeof c === 'string') return c; - if (!c) return ''; - return c.flatMap(x => x.type === 'text' ? [x.text] : []).join('\n'); + const b = r.message.content[0] + if (b?.type !== 'tool_result') return '' + const c = b.content + if (typeof c === 'string') return c + if (!c) return '' + return c.flatMap(x => (x.type === 'text' ? [x.text] : [])).join('\n') } diff --git a/src/components/messages/AdvisorMessage.tsx b/src/components/messages/AdvisorMessage.tsx index a3fb3bd7c..4a77fe7ca 100644 --- a/src/components/messages/AdvisorMessage.tsx +++ b/src/components/messages/AdvisorMessage.tsx @@ -1,157 +1,85 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import type { AdvisorBlock } from '../../utils/advisor.js'; -import { renderModelName } from '../../utils/model/model.js'; -import { jsonStringify } from '../../utils/slowOperations.js'; -import { CtrlOToExpand } from '../CtrlOToExpand.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { ToolUseLoader } from '../ToolUseLoader.js'; +import figures from 'figures' +import React from 'react' +import { Box, Text } from '../../ink.js' +import type { AdvisorBlock } from '../../utils/advisor.js' +import { renderModelName } from '../../utils/model/model.js' +import { jsonStringify } from '../../utils/slowOperations.js' +import { CtrlOToExpand } from '../CtrlOToExpand.js' +import { MessageResponse } from '../MessageResponse.js' +import { ToolUseLoader } from '../ToolUseLoader.js' + type Props = { - block: AdvisorBlock; - addMargin: boolean; - resolvedToolUseIDs: Set; - erroredToolUseIDs: Set; - shouldAnimate: boolean; - verbose: boolean; - advisorModel?: string; -}; -export function AdvisorMessage(t0) { - const $ = _c(30); - const { - block, - addMargin, - resolvedToolUseIDs, - erroredToolUseIDs, - shouldAnimate, - verbose, - advisorModel - } = t0; - if (block.type === "server_tool_use") { - let t1; - if ($[0] !== block.input) { - t1 = block.input && Object.keys(block.input).length > 0 ? jsonStringify(block.input) : null; - $[0] = block.input; - $[1] = t1; - } else { - t1 = $[1]; - } - const input = t1; - const t2 = addMargin ? 1 : 0; - let t3; - if ($[2] !== block.id || $[3] !== resolvedToolUseIDs) { - t3 = resolvedToolUseIDs.has(block.id); - $[2] = block.id; - $[3] = resolvedToolUseIDs; - $[4] = t3; - } else { - t3 = $[4]; - } - const t4 = !t3; - let t5; - if ($[5] !== block.id || $[6] !== erroredToolUseIDs) { - t5 = erroredToolUseIDs.has(block.id); - $[5] = block.id; - $[6] = erroredToolUseIDs; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== shouldAnimate || $[9] !== t4 || $[10] !== t5) { - t6 = ; - $[8] = shouldAnimate; - $[9] = t4; - $[10] = t5; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Advising; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== advisorModel) { - t8 = advisorModel ? using {renderModelName(advisorModel)} : null; - $[13] = advisorModel; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== input) { - t9 = input ? · {input} : null; - $[15] = input; - $[16] = t9; - } else { - t9 = $[16]; - } - let t10; - if ($[17] !== t2 || $[18] !== t6 || $[19] !== t8 || $[20] !== t9) { - t10 = {t6}{t7}{t8}{t9}; - $[17] = t2; - $[18] = t6; - $[19] = t8; - $[20] = t9; - $[21] = t10; - } else { - t10 = $[21]; - } - return t10; - } - let body; - bb0: switch (block.content.type) { - case "advisor_tool_result_error": - { - let t1; - if ($[22] !== block.content.error_code) { - t1 = Advisor unavailable ({block.content.error_code}); - $[22] = block.content.error_code; - $[23] = t1; - } else { - t1 = $[23]; - } - body = t1; - break bb0; - } - case "advisor_result": - { - let t1; - if ($[24] !== block.content.text || $[25] !== verbose) { - t1 = verbose ? {block.content.text} : {figures.tick} Advisor has reviewed the conversation and will apply the feedback ; - $[24] = block.content.text; - $[25] = verbose; - $[26] = t1; - } else { - t1 = $[26]; - } - body = t1; - break bb0; - } - case "advisor_redacted_result": - { - let t1; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {figures.tick} Advisor has reviewed the conversation and will apply the feedback; - $[27] = t1; - } else { - t1 = $[27]; - } - body = t1; - } + block: AdvisorBlock + addMargin: boolean + resolvedToolUseIDs: Set + erroredToolUseIDs: Set + shouldAnimate: boolean + verbose: boolean + advisorModel?: string +} + +export function AdvisorMessage({ + block, + addMargin, + resolvedToolUseIDs, + erroredToolUseIDs, + shouldAnimate, + verbose, + advisorModel, +}: Props): React.ReactNode { + if (block.type === 'server_tool_use') { + const input = + block.input && Object.keys(block.input).length > 0 + ? jsonStringify(block.input) + : null + return ( + + + Advising + {advisorModel ? ( + using {renderModelName(advisorModel)} + ) : null} + {input ? · {input} : null} + + ) } - let t1; - if ($[28] !== body) { - t1 = {body}; - $[28] = body; - $[29] = t1; - } else { - t1 = $[29]; + + let body: React.ReactNode + switch (block.content.type) { + case 'advisor_tool_result_error': + body = ( + + Advisor unavailable ({block.content.error_code}) + + ) + break + case 'advisor_result': + body = verbose ? ( + {block.content.text} + ) : ( + + {figures.tick} Advisor has reviewed the conversation and will apply + the feedback + + ) + break + case 'advisor_redacted_result': + body = ( + + {figures.tick} Advisor has reviewed the conversation and will apply + the feedback + + ) + break } - return t1; + + return ( + + {body} + + ) } diff --git a/src/components/messages/AssistantRedactedThinkingMessage.tsx b/src/components/messages/AssistantRedactedThinkingMessage.tsx index f7528a5d0..eb0f66d35 100644 --- a/src/components/messages/AssistantRedactedThinkingMessage.tsx +++ b/src/components/messages/AssistantRedactedThinkingMessage.tsx @@ -1,30 +1,18 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' + type Props = { - addMargin: boolean; -}; -export function AssistantRedactedThinkingMessage(t0) { - const $ = _c(3); - const { - addMargin: t1 - } = t0; - const addMargin = t1 === undefined ? false : t1; - const t2 = addMargin ? 1 : 0; - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ✻ Thinking…; - $[0] = t3; - } else { - t3 = $[0]; - } - let t4; - if ($[1] !== t2) { - t4 = {t3}; - $[1] = t2; - $[2] = t4; - } else { - t4 = $[2]; - } - return t4; + addMargin: boolean +} + +export function AssistantRedactedThinkingMessage({ + addMargin = false, +}: Props): React.ReactNode { + return ( + + + ✻ Thinking… + + + ) } diff --git a/src/components/messages/AssistantTextMessage.tsx b/src/components/messages/AssistantTextMessage.tsx index 9f70616ce..005d2481e 100644 --- a/src/components/messages/AssistantTextMessage.tsx +++ b/src/components/messages/AssistantTextMessage.tsx @@ -1,269 +1,222 @@ -import { c as _c } from "react/compiler-runtime"; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import React, { useContext } from 'react'; -import { ERROR_MESSAGE_USER_ABORT } from 'src/services/compact/compact.js'; -import { isRateLimitErrorMessage } from 'src/services/rateLimitMessages.js'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { Box, NoSelect, Text } from '../../ink.js'; -import { API_ERROR_MESSAGE_PREFIX, API_TIMEOUT_ERROR_MESSAGE, CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, CUSTOM_OFF_SWITCH_MESSAGE, INVALID_API_KEY_ERROR_MESSAGE, INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL, ORG_DISABLED_ERROR_MESSAGE_ENV_KEY, ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH, PROMPT_TOO_LONG_ERROR_MESSAGE, startsWithApiErrorPrefix, TOKEN_REVOKED_ERROR_MESSAGE } from '../../services/api/errors.js'; -import { isEmptyMessageText, NO_RESPONSE_REQUESTED } from '../../utils/messages.js'; -import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'; -import { getDefaultSonnetModel, renderModelName } from '../../utils/model/model.js'; -import { isMacOsKeychainLocked } from '../../utils/secureStorage/macOsKeychainStorage.js'; -import { CtrlOToExpand } from '../CtrlOToExpand.js'; -import { InterruptedByUser } from '../InterruptedByUser.js'; -import { Markdown } from '../Markdown.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { MessageActionsSelectedContext } from '../messageActions.js'; -import { RateLimitMessage } from './RateLimitMessage.js'; -const MAX_API_ERROR_CHARS = 1000; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import React, { useContext } from 'react' +import { ERROR_MESSAGE_USER_ABORT } from 'src/services/compact/compact.js' +import { isRateLimitErrorMessage } from 'src/services/rateLimitMessages.js' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { Box, NoSelect, Text } from '../../ink.js' +import { + API_ERROR_MESSAGE_PREFIX, + API_TIMEOUT_ERROR_MESSAGE, + CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, + CUSTOM_OFF_SWITCH_MESSAGE, + INVALID_API_KEY_ERROR_MESSAGE, + INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL, + ORG_DISABLED_ERROR_MESSAGE_ENV_KEY, + ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH, + PROMPT_TOO_LONG_ERROR_MESSAGE, + startsWithApiErrorPrefix, + TOKEN_REVOKED_ERROR_MESSAGE, +} from '../../services/api/errors.js' +import { + isEmptyMessageText, + NO_RESPONSE_REQUESTED, +} from '../../utils/messages.js' +import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js' +import { + getDefaultSonnetModel, + renderModelName, +} from '../../utils/model/model.js' +import { isMacOsKeychainLocked } from '../../utils/secureStorage/macOsKeychainStorage.js' +import { CtrlOToExpand } from '../CtrlOToExpand.js' +import { InterruptedByUser } from '../InterruptedByUser.js' +import { Markdown } from '../Markdown.js' +import { MessageResponse } from '../MessageResponse.js' +import { MessageActionsSelectedContext } from '../messageActions.js' +import { RateLimitMessage } from './RateLimitMessage.js' + +const MAX_API_ERROR_CHARS = 1000 + type Props = { - param: TextBlockParam; - addMargin: boolean; - shouldShowDot: boolean; - verbose: boolean; - width?: number | string; - onOpenRateLimitOptions?: () => void; -}; -function InvalidApiKeyMessage() { - const $ = _c(2); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = isMacOsKeychainLocked(); - $[0] = t0; - } else { - t0 = $[0]; - } - const isKeychainLocked = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {INVALID_API_KEY_ERROR_MESSAGE}{isKeychainLocked && · Run in another terminal: security unlock-keychain}; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + param: TextBlockParam + addMargin: boolean + shouldShowDot: boolean + verbose: boolean + width?: number | string + onOpenRateLimitOptions?: () => void } -export function AssistantTextMessage(t0) { - const $ = _c(34); - const { - param: t1, - addMargin, - shouldShowDot, - verbose, - onOpenRateLimitOptions - } = t0; - const { - text - } = t1; - const isSelected = useContext(MessageActionsSelectedContext); + +function InvalidApiKeyMessage(): React.ReactNode { + const isKeychainLocked = isMacOsKeychainLocked() + + return ( + + + {INVALID_API_KEY_ERROR_MESSAGE} + {isKeychainLocked && ( + + · Run in another terminal: security unlock-keychain + + )} + + + ) +} + +export function AssistantTextMessage({ + param: { text }, + addMargin, + shouldShowDot, + verbose, + onOpenRateLimitOptions, +}: Props): React.ReactNode { + const isSelected = useContext(MessageActionsSelectedContext) if (isEmptyMessageText(text)) { - return null; + return null } + + // Handle all rate limit error messages from getRateLimitErrorMessage + // Use the exported function to avoid fragile string coupling if (isRateLimitErrorMessage(text)) { - let t2; - if ($[0] !== onOpenRateLimitOptions || $[1] !== text) { - t2 = ; - $[0] = onOpenRateLimitOptions; - $[1] = text; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + return ( + + ) } + switch (text) { + // Local JSX commands don't need a response, but we still want Claude to see them + // Tool results render their own interrupt messages case NO_RESPONSE_REQUESTED: - { - return null; - } - case PROMPT_TOO_LONG_ERROR_MESSAGE: - { - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getUpgradeMessage("warning"); - $[3] = t2; - } else { - t2 = $[3]; - } - const upgradeHint = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Context limit reached · /compact or /clear to continue{upgradeHint ? ` · ${upgradeHint}` : ""}; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; - } + return null + + case PROMPT_TOO_LONG_ERROR_MESSAGE: { + const upgradeHint = getUpgradeMessage('warning') + return ( + + + Context limit reached · /compact or /clear to continue + {upgradeHint ? ` · ${upgradeHint}` : ''} + + + ) + } + case CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE: - { - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Credit balance too low · Add funds: https://platform.claude.com/settings/billing; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; - } + return ( + + + Credit balance too low · Add funds: + https://platform.claude.com/settings/billing + + + ) + case INVALID_API_KEY_ERROR_MESSAGE: - { - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; - } + return + case INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL: - { - let t2; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL}; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; - } + return ( + + {INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL} + + ) + case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY: case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH: - { - let t2; - if ($[8] !== text) { - t2 = {text}; - $[8] = text; - $[9] = t2; - } else { - t2 = $[9]; - } - return t2; - } + return ( + + {text} + + ) + case TOKEN_REVOKED_ERROR_MESSAGE: - { - let t2; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {TOKEN_REVOKED_ERROR_MESSAGE}; - $[10] = t2; - } else { - t2 = $[10]; - } - return t2; - } + return ( + + {TOKEN_REVOKED_ERROR_MESSAGE} + + ) + case API_TIMEOUT_ERROR_MESSAGE: - { - let t2; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {API_TIMEOUT_ERROR_MESSAGE}{process.env.API_TIMEOUT_MS && <>{" "}(API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing it)}; - $[11] = t2; - } else { - t2 = $[11]; - } - return t2; - } + return ( + + + {API_TIMEOUT_ERROR_MESSAGE} + {process.env.API_TIMEOUT_MS && ( + <> + {' '} + (API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing + it) + + )} + + + ) + case CUSTOM_OFF_SWITCH_MESSAGE: - { - let t2; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t2 = We are experiencing high demand for Opus 4.; - $[12] = t2; - } else { - t2 = $[12]; - } - let t3; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {t2}To continue immediately, use /model to switch to{" "}{renderModelName(getDefaultSonnetModel())} and continue coding.; - $[13] = t3; - } else { - t3 = $[13]; - } - return t3; - } + return ( + + + + We are experiencing high demand for Opus 4. + + + To continue immediately, use /model to switch to{' '} + {renderModelName(getDefaultSonnetModel())} and continue coding. + + + + ) + + // TODO: Move this to a user turn case ERROR_MESSAGE_USER_ABORT: - { - let t2; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[14] = t2; - } else { - t2 = $[14]; - } - return t2; - } + return ( + + + + ) + default: - { - if (startsWithApiErrorPrefix(text)) { - const truncated = !verbose && text.length > MAX_API_ERROR_CHARS; - const t2 = text === API_ERROR_MESSAGE_PREFIX ? `${API_ERROR_MESSAGE_PREFIX}: Please wait a moment and try again.` : truncated ? text.slice(0, MAX_API_ERROR_CHARS) + "\u2026" : text; - let t3; - if ($[15] !== t2) { - t3 = {t2}; - $[15] = t2; - $[16] = t3; - } else { - t3 = $[16]; - } - let t4; - if ($[17] !== truncated) { - t4 = truncated && ; - $[17] = truncated; - $[18] = t4; - } else { - t4 = $[18]; - } - let t5; - if ($[19] !== t3 || $[20] !== t4) { - t5 = {t3}{t4}; - $[19] = t3; - $[20] = t4; - $[21] = t5; - } else { - t5 = $[21]; - } - return t5; - } - const t2 = addMargin ? 1 : 0; - const t3 = isSelected ? "messageActionsBackground" : undefined; - let t4; - if ($[22] !== isSelected || $[23] !== shouldShowDot) { - t4 = shouldShowDot && {BLACK_CIRCLE}; - $[22] = isSelected; - $[23] = shouldShowDot; - $[24] = t4; - } else { - t4 = $[24]; - } - let t5; - if ($[25] !== text) { - t5 = {text}; - $[25] = text; - $[26] = t5; - } else { - t5 = $[26]; - } - let t6; - if ($[27] !== t4 || $[28] !== t5) { - t6 = {t4}{t5}; - $[27] = t4; - $[28] = t5; - $[29] = t6; - } else { - t6 = $[29]; - } - let t7; - if ($[30] !== t2 || $[31] !== t3 || $[32] !== t6) { - t7 = {t6}; - $[30] = t2; - $[31] = t3; - $[32] = t6; - $[33] = t7; - } else { - t7 = $[33]; - } - return t7; + if (startsWithApiErrorPrefix(text)) { + const truncated = !verbose && text.length > MAX_API_ERROR_CHARS + return ( + + + + {text === API_ERROR_MESSAGE_PREFIX + ? `${API_ERROR_MESSAGE_PREFIX}: Please wait a moment and try again.` + : truncated + ? text.slice(0, MAX_API_ERROR_CHARS) + '…' + : text} + + {truncated && } + + + ) } + return ( + + + {shouldShowDot && ( + + + {BLACK_CIRCLE} + + + )} + + {text} + + + + ) } } diff --git a/src/components/messages/AssistantThinkingMessage.tsx b/src/components/messages/AssistantThinkingMessage.tsx index 31a691f77..2fc88512d 100644 --- a/src/components/messages/AssistantThinkingMessage.tsx +++ b/src/components/messages/AssistantThinkingMessage.tsx @@ -1,85 +1,66 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ThinkingBlock, ThinkingBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { CtrlOToExpand } from '../CtrlOToExpand.js'; -import { Markdown } from '../Markdown.js'; +import type { + ThinkingBlock, + ThinkingBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import React from 'react' +import { Box, Text } from '../../ink.js' +import { CtrlOToExpand } from '../CtrlOToExpand.js' +import { Markdown } from '../Markdown.js' + type Props = { // Accept either full ThinkingBlock/ThinkingBlockParam or a minimal shape with just type and thinking - param: ThinkingBlock | ThinkingBlockParam | { - type: 'thinking'; - thinking: string; - }; - addMargin: boolean; - isTranscriptMode: boolean; - verbose: boolean; + param: + | ThinkingBlock + | ThinkingBlockParam + | { type: 'thinking'; thinking: string } + addMargin: boolean + isTranscriptMode: boolean + verbose: boolean /** When true, hide this thinking block entirely (used for past thinking in transcript mode) */ - hideInTranscript?: boolean; -}; -export function AssistantThinkingMessage(t0) { - const $ = _c(9); - const { - param: t1, - addMargin: t2, - isTranscriptMode, - verbose, - hideInTranscript: t3 - } = t0; - const { - thinking - } = t1; - const addMargin = t2 === undefined ? false : t2; - const hideInTranscript = t3 === undefined ? false : t3; + hideInTranscript?: boolean +} + +export function AssistantThinkingMessage({ + param: { thinking }, + addMargin = false, + isTranscriptMode, + verbose, + hideInTranscript = false, +}: Props): React.ReactNode { if (!thinking) { - return null; + return null } + if (hideInTranscript) { - return null; + return null } - const shouldShowFullThinking = isTranscriptMode || verbose; + + const shouldShowFullThinking = isTranscriptMode || verbose + const label = '∴ Thinking' + if (!shouldShowFullThinking) { - const t4 = addMargin ? 1 : 0; - let t5; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {"\u2234 Thinking"} ; - $[0] = t5; - } else { - t5 = $[0]; - } - let t6; - if ($[1] !== t4) { - t6 = {t5}; - $[1] = t4; - $[2] = t6; - } else { - t6 = $[2]; - } - return t6; - } - const t4 = addMargin ? 1 : 0; - let t5; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {"\u2234 Thinking"}…; - $[3] = t5; - } else { - t5 = $[3]; - } - let t6; - if ($[4] !== thinking) { - t6 = {thinking}; - $[4] = thinking; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== t4 || $[7] !== t6) { - t7 = {t5}{t6}; - $[6] = t4; - $[7] = t6; - $[8] = t7; - } else { - t7 = $[8]; + return ( + + + {label} + + + ) } - return t7; + + return ( + + + {label}… + + + {thinking} + + + ) } diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx index f0c8f028f..65a92aad6 100644 --- a/src/components/messages/AssistantToolUseMessage.tsx +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -1,367 +1,326 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import React, { useMemo } from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import type { ThemeName } from 'src/utils/theme.js'; -import type { Command } from '../../commands.js'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js'; -import { findToolByName, type Tool, type ToolProgressData, type Tools } from '../../Tool.js'; -import type { ProgressMessage } from '../../types/message.js'; -import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js'; -import { logError } from '../../utils/log.js'; -import type { buildMessageLookups } from '../../utils/messages.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { useSelectedMessageBg } from '../messageActions.js'; -import { SentryErrorBoundary } from '../SentryErrorBoundary.js'; -import { ToolUseLoader } from '../ToolUseLoader.js'; -import { HookProgressMessage } from './HookProgressMessage.js'; +import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import React, { useMemo } from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import type { ThemeName } from 'src/utils/theme.js' +import type { Command } from '../../commands.js' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text, useTheme } from '../../ink.js' +import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js' +import { + findToolByName, + type Tool, + type ToolProgressData, + type Tools, +} from '../../Tool.js' +import type { ProgressMessage } from '../../types/message.js' +import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js' +import { logError } from '../../utils/log.js' +import type { buildMessageLookups } from '../../utils/messages.js' +import { MessageResponse } from '../MessageResponse.js' +import { useSelectedMessageBg } from '../messageActions.js' +import { SentryErrorBoundary } from '../SentryErrorBoundary.js' +import { ToolUseLoader } from '../ToolUseLoader.js' +import { HookProgressMessage } from './HookProgressMessage.js' + type Props = { - param: ToolUseBlockParam; - addMargin: boolean; - tools: Tools; - commands: Command[]; - verbose: boolean; - inProgressToolUseIDs: Set; - progressMessagesForMessage: ProgressMessage[]; - shouldAnimate: boolean; - shouldShowDot: boolean; - inProgressToolCallCount?: number; - lookups: ReturnType; - isTranscriptMode?: boolean; -}; -export function AssistantToolUseMessage(t0) { - const $ = _c(81); - const { - param, - addMargin, - tools, - commands, - verbose, - inProgressToolUseIDs, - progressMessagesForMessage, - shouldAnimate, - shouldShowDot, - inProgressToolCallCount, - lookups, - isTranscriptMode - } = t0; - const terminalSize = useTerminalSize(); - const [theme] = useTheme(); - const bg = useSelectedMessageBg(); - const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider(_temp); - const isClassifierCheckingRaw = useIsClassifierChecking(param.id); - const permissionMode = useAppStateMaybeOutsideOfProvider(_temp2); - const hasStrippedRules = useAppStateMaybeOutsideOfProvider(_temp3); - const isAutoClassifier = permissionMode === "auto" || permissionMode === "plan" && hasStrippedRules; - const isClassifierChecking = false && isClassifierCheckingRaw && permissionMode !== "auto"; - let t1; - if ($[0] !== param.input || $[1] !== param.name || $[2] !== tools) { - bb0: { - if (!tools) { - t1 = null; - break bb0; - } - const tool = findToolByName(tools, param.name); - if (!tool) { - t1 = null; - break bb0; - } - const input = tool.inputSchema.safeParse(param.input); - const data = input.success ? input.data : undefined; - t1 = { - tool, - input, - userFacingToolName: tool.userFacingName(data), - userFacingToolNameBackgroundColor: tool.userFacingNameBackgroundColor?.(data), - isTransparentWrapper: tool.isTransparentWrapper?.() ?? false - }; + param: ToolUseBlockParam + addMargin: boolean + tools: Tools + commands: Command[] + verbose: boolean + inProgressToolUseIDs: Set + progressMessagesForMessage: ProgressMessage[] + shouldAnimate: boolean + shouldShowDot: boolean + inProgressToolCallCount?: number + lookups: ReturnType + isTranscriptMode?: boolean +} + +export function AssistantToolUseMessage({ + param, + addMargin, + tools, + commands, + verbose, + inProgressToolUseIDs, + progressMessagesForMessage, + shouldAnimate, + shouldShowDot, + inProgressToolCallCount, + lookups, + isTranscriptMode, +}: Props): React.ReactNode { + const terminalSize = useTerminalSize() + const [theme] = useTheme() + const bg = useSelectedMessageBg() + const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider( + state => state.pendingWorkerRequest, + ) + const isClassifierCheckingRaw = useIsClassifierChecking(param.id) + const permissionMode = useAppStateMaybeOutsideOfProvider( + state => state.toolPermissionContext.mode, + ) + // strippedDangerousRules is set by stripDangerousPermissionsForAutoMode + // (even to {}) whenever auto is active, and cleared by restoreDangerousPermissions + // on deactivation — a reliable proxy for isAutoModeActive() during plan. + // prePlanMode would be stale after transitionPlanAutoMode deactivates mid-plan. + const hasStrippedRules = useAppStateMaybeOutsideOfProvider( + state => !!state.toolPermissionContext.strippedDangerousRules, + ) + const isAutoClassifier = + permissionMode === 'auto' || (permissionMode === 'plan' && hasStrippedRules) + const isClassifierChecking = + process.env.USER_TYPE === 'ant' && + isClassifierCheckingRaw && + permissionMode !== 'auto' + + // Memoize on param identity (stable — from the persisted message object). + // Zod safeParse allocates per call, and some tools' userFacingName() + // (BashTool → shouldUseSandbox → shell-quote parse) are expensive. Without + // this, ~50 bash messages × shell-quote-per-render pushed transition + // render past the shimmer tick → abort → infinite retry (#21605). + const parsed = useMemo(() => { + if (!tools) return null + const tool = findToolByName(tools, param.name) + if (!tool) return null + const input = tool.inputSchema.safeParse(param.input) + const data = input.success ? input.data : undefined + return { + tool, + input, + userFacingToolName: tool.userFacingName(data), + userFacingToolNameBackgroundColor: + tool.userFacingNameBackgroundColor?.(data), + isTransparentWrapper: tool.isTransparentWrapper?.() ?? false, } - $[0] = param.input; - $[1] = param.name; - $[2] = tools; - $[3] = t1; - } else { - t1 = $[3]; - } - const parsed = t1; + }, [tools, param]) + if (!parsed) { - logError(new Error(tools ? `Tool ${param.name} not found` : `Tools array is undefined for tool ${param.name}`)); - return null; + // Guard against undefined tools (required prop) or unknown tool name + logError( + new Error( + tools + ? `Tool ${param.name} not found` + : `Tools array is undefined for tool ${param.name}`, + ), + ) + return null } + const { - tool: tool_0, - input: input_0, + tool, + input, userFacingToolName, userFacingToolNameBackgroundColor, - isTransparentWrapper - } = parsed; - let t2; - if ($[4] !== lookups.resolvedToolUseIDs || $[5] !== param.id) { - t2 = lookups.resolvedToolUseIDs.has(param.id); - $[4] = lookups.resolvedToolUseIDs; - $[5] = param.id; - $[6] = t2; - } else { - t2 = $[6]; - } - const isResolved = t2; - let t3; - if ($[7] !== inProgressToolUseIDs || $[8] !== isResolved || $[9] !== param.id) { - t3 = !inProgressToolUseIDs.has(param.id) && !isResolved; - $[7] = inProgressToolUseIDs; - $[8] = isResolved; - $[9] = param.id; - $[10] = t3; - } else { - t3 = $[10]; - } - const isQueued = t3; - const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id; + isTransparentWrapper, + } = parsed + + const isResolved = lookups.resolvedToolUseIDs.has(param.id) + const isQueued = !inProgressToolUseIDs.has(param.id) && !isResolved + const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id + if (isTransparentWrapper) { - if (isQueued || isResolved) { - return null; - } - let t4; - if ($[11] !== inProgressToolCallCount || $[12] !== isTranscriptMode || $[13] !== lookups || $[14] !== param.id || $[15] !== progressMessagesForMessage || $[16] !== terminalSize || $[17] !== tool_0 || $[18] !== tools || $[19] !== verbose) { - t4 = renderToolUseProgressMessage(tool_0, tools, lookups, param.id, progressMessagesForMessage, { - verbose, - inProgressToolCallCount, - isTranscriptMode - }, terminalSize); - $[11] = inProgressToolCallCount; - $[12] = isTranscriptMode; - $[13] = lookups; - $[14] = param.id; - $[15] = progressMessagesForMessage; - $[16] = terminalSize; - $[17] = tool_0; - $[18] = tools; - $[19] = verbose; - $[20] = t4; - } else { - t4 = $[20]; - } - let t5; - if ($[21] !== bg || $[22] !== t4) { - t5 = {t4}; - $[21] = bg; - $[22] = t4; - $[23] = t5; - } else { - t5 = $[23]; - } - return t5; + if (isQueued || isResolved) return null + return ( + + {renderToolUseProgressMessage( + tool, + tools, + lookups, + param.id, + progressMessagesForMessage, + { verbose, inProgressToolCallCount, isTranscriptMode }, + terminalSize, + )} + + ) } - if (userFacingToolName === "") { - return null; + + if (userFacingToolName === '') { + return null } - let t4; - if ($[24] !== commands || $[25] !== input_0.data || $[26] !== input_0.success || $[27] !== theme || $[28] !== tool_0 || $[29] !== verbose) { - t4 = input_0.success ? renderToolUseMessage(tool_0, input_0.data, { - theme, - verbose, - commands - }) : null; - $[24] = commands; - $[25] = input_0.data; - $[26] = input_0.success; - $[27] = theme; - $[28] = tool_0; - $[29] = verbose; - $[30] = t4; - } else { - t4 = $[30]; - } - const renderedToolUseMessage = t4; + + const renderedToolUseMessage = input.success + ? renderToolUseMessage(tool, input.data, { theme, verbose, commands }) + : null if (renderedToolUseMessage === null) { - return null; - } - const t5 = addMargin ? 1 : 0; - const t6 = stringWidth(userFacingToolName) + (shouldShowDot ? 2 : 0); - let t7; - if ($[31] !== isQueued || $[32] !== isResolved || $[33] !== lookups.erroredToolUseIDs || $[34] !== param.id || $[35] !== shouldAnimate || $[36] !== shouldShowDot) { - t7 = shouldShowDot && (isQueued ? {BLACK_CIRCLE} : ); - $[31] = isQueued; - $[32] = isResolved; - $[33] = lookups.erroredToolUseIDs; - $[34] = param.id; - $[35] = shouldAnimate; - $[36] = shouldShowDot; - $[37] = t7; - } else { - t7 = $[37]; - } - const t8 = userFacingToolNameBackgroundColor ? "inverseText" : undefined; - let t9; - if ($[38] !== t8 || $[39] !== userFacingToolName || $[40] !== userFacingToolNameBackgroundColor) { - t9 = {userFacingToolName}; - $[38] = t8; - $[39] = userFacingToolName; - $[40] = userFacingToolNameBackgroundColor; - $[41] = t9; - } else { - t9 = $[41]; - } - let t10; - if ($[42] !== renderedToolUseMessage) { - t10 = renderedToolUseMessage !== "" && ({renderedToolUseMessage}); - $[42] = renderedToolUseMessage; - $[43] = t10; - } else { - t10 = $[43]; + return null } - let t11; - if ($[44] !== input_0.data || $[45] !== input_0.success || $[46] !== tool_0) { - t11 = input_0.success && tool_0.renderToolUseTag && tool_0.renderToolUseTag(input_0.data); - $[44] = input_0.data; - $[45] = input_0.success; - $[46] = tool_0; - $[47] = t11; - } else { - t11 = $[47]; - } - let t12; - if ($[48] !== t10 || $[49] !== t11 || $[50] !== t6 || $[51] !== t7 || $[52] !== t9) { - t12 = {t7}{t9}{t10}{t11}; - $[48] = t10; - $[49] = t11; - $[50] = t6; - $[51] = t7; - $[52] = t9; - $[53] = t12; - } else { - t12 = $[53]; - } - let t13; - if ($[54] !== inProgressToolCallCount || $[55] !== isAutoClassifier || $[56] !== isClassifierChecking || $[57] !== isQueued || $[58] !== isResolved || $[59] !== isTranscriptMode || $[60] !== isWaitingForPermission || $[61] !== lookups || $[62] !== param.id || $[63] !== progressMessagesForMessage || $[64] !== terminalSize || $[65] !== tool_0 || $[66] !== tools || $[67] !== verbose) { - t13 = !isResolved && !isQueued && (isClassifierChecking ? {isAutoClassifier ? "Auto classifier checking\u2026" : "Bash classifier checking\u2026"} : isWaitingForPermission ? Waiting for permission… : renderToolUseProgressMessage(tool_0, tools, lookups, param.id, progressMessagesForMessage, { - verbose, - inProgressToolCallCount, - isTranscriptMode - }, terminalSize)); - $[54] = inProgressToolCallCount; - $[55] = isAutoClassifier; - $[56] = isClassifierChecking; - $[57] = isQueued; - $[58] = isResolved; - $[59] = isTranscriptMode; - $[60] = isWaitingForPermission; - $[61] = lookups; - $[62] = param.id; - $[63] = progressMessagesForMessage; - $[64] = terminalSize; - $[65] = tool_0; - $[66] = tools; - $[67] = verbose; - $[68] = t13; - } else { - t13 = $[68]; - } - let t14; - if ($[69] !== isQueued || $[70] !== isResolved || $[71] !== tool_0) { - t14 = !isResolved && isQueued && renderToolUseQueuedMessage(tool_0); - $[69] = isQueued; - $[70] = isResolved; - $[71] = tool_0; - $[72] = t14; - } else { - t14 = $[72]; - } - let t15; - if ($[73] !== t12 || $[74] !== t13 || $[75] !== t14) { - t15 = {t12}{t13}{t14}; - $[73] = t12; - $[74] = t13; - $[75] = t14; - $[76] = t15; - } else { - t15 = $[76]; - } - let t16; - if ($[77] !== bg || $[78] !== t15 || $[79] !== t5) { - t16 = {t15}; - $[77] = bg; - $[78] = t15; - $[79] = t5; - $[80] = t16; - } else { - t16 = $[80]; - } - return t16; -} -function _temp3(state_1) { - return !!state_1.toolPermissionContext.strippedDangerousRules; -} -function _temp2(state_0) { - return state_0.toolPermissionContext.mode; -} -function _temp(state) { - return state.pendingWorkerRequest; + + return ( + + + + {shouldShowDot && + (isQueued ? ( + + {BLACK_CIRCLE} + + ) : ( + // WARNING: The code here and in ToolUseLoader is particularly + // sensitive to what *should* just be trivial refactorings. See + // the comment in ToolUseLoader for more details. + + ))} + + + {userFacingToolName} + + + {renderedToolUseMessage !== '' && ( + + ({renderedToolUseMessage}) + + )} + {/* Render tool-specific tags (timeout, model, resume ID, etc.) */} + {input.success && + tool.renderToolUseTag && + tool.renderToolUseTag(input.data)} + + {!isResolved && + !isQueued && + (isClassifierChecking ? ( + + + {isAutoClassifier + ? 'Auto classifier checking\u2026' + : 'Bash classifier checking\u2026'} + + + ) : isWaitingForPermission ? ( + + Waiting for permission… + + ) : ( + renderToolUseProgressMessage( + tool, + tools, + lookups, + param.id, + progressMessagesForMessage, + { + verbose, + inProgressToolCallCount, + isTranscriptMode, + }, + terminalSize, + ) + ))} + {!isResolved && isQueued && renderToolUseQueuedMessage(tool)} + + + ) } -function renderToolUseMessage(tool: Tool, input: unknown, { - theme, - verbose, - commands -}: { - theme: ThemeName; - verbose: boolean; - commands: Command[]; -}): React.ReactNode { + +function renderToolUseMessage( + tool: Tool, + input: unknown, + { + theme, + verbose, + commands, + }: { theme: ThemeName; verbose: boolean; commands: Command[] }, +): React.ReactNode { try { - const parsed = tool.inputSchema.safeParse(input); + const parsed = tool.inputSchema.safeParse(input) if (!parsed.success) { - return ''; + return '' } - return tool.renderToolUseMessage(parsed.data, { - theme, - verbose, - commands - }); + return tool.renderToolUseMessage(parsed.data, { theme, verbose, commands }) } catch (error) { - logError(new Error(`Error rendering tool use message for ${tool.name}: ${error}`)); - return ''; + logError( + new Error(`Error rendering tool use message for ${tool.name}: ${error}`), + ) + return '' } } -function renderToolUseProgressMessage(tool: Tool, tools: Tools, lookups: ReturnType, toolUseID: string, progressMessagesForMessage: ProgressMessage[], { - verbose, - inProgressToolCallCount, - isTranscriptMode -}: { - verbose: boolean; - inProgressToolCallCount?: number; - isTranscriptMode?: boolean; -}, terminalSize: { - columns: number; - rows: number; -}): React.ReactNode { - const toolProgressMessages = progressMessagesForMessage.filter((msg): msg is ProgressMessage => (msg.data as { type?: string }).type !== 'hook_progress'); + +function renderToolUseProgressMessage( + tool: Tool, + tools: Tools, + lookups: ReturnType, + toolUseID: string, + progressMessagesForMessage: ProgressMessage[], + { + verbose, + inProgressToolCallCount, + isTranscriptMode, + }: { + verbose: boolean + inProgressToolCallCount?: number + isTranscriptMode?: boolean + }, + terminalSize: { columns: number; rows: number }, +): React.ReactNode { + const toolProgressMessages = progressMessagesForMessage.filter( + (msg): msg is ProgressMessage => + msg.data.type !== 'hook_progress', + ) try { - const toolMessages = tool.renderToolUseProgressMessage?.(toolProgressMessages, { - tools, - verbose, - terminalSize, - inProgressToolCallCount: inProgressToolCallCount ?? 1, - isTranscriptMode - }) ?? null; - return <> + const toolMessages = + tool.renderToolUseProgressMessage?.(toolProgressMessages, { + tools, + verbose, + terminalSize, + inProgressToolCallCount: inProgressToolCallCount ?? 1, + isTranscriptMode, + }) ?? null + return ( + <> - + {toolMessages} - ; + + ) } catch (error) { - logError(new Error(`Error rendering tool use progress message for ${tool.name}: ${error}`)); - return null; + logError( + new Error( + `Error rendering tool use progress message for ${tool.name}: ${error}`, + ), + ) + return null } } + function renderToolUseQueuedMessage(tool: Tool): React.ReactNode { try { - return tool.renderToolUseQueuedMessage?.(); + return tool.renderToolUseQueuedMessage?.() } catch (error) { - logError(new Error(`Error rendering tool use queued message for ${tool.name}: ${error}`)); - return null; + logError( + new Error( + `Error rendering tool use queued message for ${tool.name}: ${error}`, + ), + ) + return null } } diff --git a/src/components/messages/AttachmentMessage.tsx b/src/components/messages/AttachmentMessage.tsx index 3b23cd8ee..51f9ea67d 100644 --- a/src/components/messages/AttachmentMessage.tsx +++ b/src/components/messages/AttachmentMessage.tsx @@ -1,106 +1,134 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import React, { useMemo } from 'react'; -import { Ansi, Box, Text } from '../../ink.js'; -import type { Attachment } from 'src/utils/attachments.js'; -import type { NullRenderingAttachmentType } from './nullRenderingAttachments.js'; -import { type AppState, useAppState } from '../../state/AppState.js'; -import type { TaskState } from '../../tasks/types.js'; -import { getDisplayPath } from 'src/utils/file.js'; -import { formatFileSize } from 'src/utils/format.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { basename, sep } from 'path'; -import { UserTextMessage } from './UserTextMessage.js'; -import { DiagnosticsDisplay } from '../DiagnosticsDisplay.js'; -import { getContentText } from 'src/utils/messages.js'; -import type { Theme } from 'src/utils/theme.js'; -import { UserImageMessage } from './UserImageMessage.js'; -import { toInkColor } from '../../utils/ink.js'; -import { jsonParse } from '../../utils/slowOperations.js'; -import { plural } from '../../utils/stringUtils.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { tryRenderPlanApprovalMessage, formatTeammateMessageContent } from './PlanApprovalMessage.js'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { TeammateMessageContent } from './UserTeammateMessage.js'; -import { isShutdownApproved } from '../../utils/teammateMailbox.js'; -import { CtrlOToExpand } from '../CtrlOToExpand.js'; -import { FilePathLink } from '../FilePathLink.js'; -import { feature } from 'bun:bundle'; -import { useSelectedMessageBg } from '../messageActions.js'; +import React, { useMemo } from 'react' +import { Ansi, Box, Text } from '../../ink.js' +import type { Attachment } from 'src/utils/attachments.js' +import type { NullRenderingAttachmentType } from './nullRenderingAttachments.js' +import { useAppState } from '../../state/AppState.js' +import { getDisplayPath } from 'src/utils/file.js' +import { formatFileSize } from 'src/utils/format.js' +import { MessageResponse } from '../MessageResponse.js' +import { basename, sep } from 'path' +import { UserTextMessage } from './UserTextMessage.js' +import { DiagnosticsDisplay } from '../DiagnosticsDisplay.js' +import { getContentText } from 'src/utils/messages.js' +import type { Theme } from 'src/utils/theme.js' +import { UserImageMessage } from './UserImageMessage.js' +import { toInkColor } from '../../utils/ink.js' +import { jsonParse } from '../../utils/slowOperations.js' +import { plural } from '../../utils/stringUtils.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { + tryRenderPlanApprovalMessage, + formatTeammateMessageContent, +} from './PlanApprovalMessage.js' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { TeammateMessageContent } from './UserTeammateMessage.js' +import { isShutdownApproved } from '../../utils/teammateMailbox.js' +import { CtrlOToExpand } from '../CtrlOToExpand.js' +import { FilePathLink } from '../FilePathLink.js' +import { feature } from 'bun:bundle' +import { useSelectedMessageBg } from '../messageActions.js' + type Props = { - addMargin: boolean; - attachment: Attachment; - verbose: boolean; - isTranscriptMode?: boolean; -}; + addMargin: boolean + attachment: Attachment + verbose: boolean + isTranscriptMode?: boolean +} + export function AttachmentMessage({ attachment, addMargin, verbose, - isTranscriptMode + isTranscriptMode, }: Props): React.ReactNode { - const bg = useSelectedMessageBg(); + const bg = useSelectedMessageBg() // Hoisted to mount-time — per-message component, re-renders on every scroll. - const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) : false; + const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) + : false // Handle teammate_mailbox BEFORE switch if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') { // Filter out idle notifications BEFORE counting - they are hidden in the UI // so showing them in the count would be confusing ("2 messages in mailbox:" with nothing shown) const visibleMessages = attachment.messages.filter(msg => { if (isShutdownApproved(msg.text)) { - return false; + return false } try { - const parsed = jsonParse(msg.text); - return parsed?.type !== 'idle_notification' && parsed?.type !== 'teammate_terminated'; + const parsed = jsonParse(msg.text) + return ( + parsed?.type !== 'idle_notification' && + parsed?.type !== 'teammate_terminated' + ) } catch { - return true; // Non-JSON messages are visible + return true // Non-JSON messages are visible } - }); + }) + if (visibleMessages.length === 0) { - return null; + return null } - return - {visibleMessages.map((msg_0, idx) => { - // Try to parse as JSON for task_assignment messages - let parsedMsg: { - type?: string; - taskId?: string; - subject?: string; - assignedBy?: string; - } | null = null; - try { - parsedMsg = jsonParse(msg_0.text); - } catch { - // Not JSON, treat as plain text - } - if (parsedMsg?.type === 'task_assignment') { - return + return ( + + {visibleMessages.map((msg, idx) => { + // Try to parse as JSON for task_assignment messages + let parsedMsg: { + type?: string + taskId?: string + subject?: string + assignedBy?: string + } | null = null + try { + parsedMsg = jsonParse(msg.text) + } catch { + // Not JSON, treat as plain text + } + + if (parsedMsg?.type === 'task_assignment') { + return ( + {BLACK_CIRCLE} Task assigned: #{parsedMsg.taskId} - {parsedMsg.subject} - (from {parsedMsg.assignedBy || msg_0.from}) - ; - } + (from {parsedMsg.assignedBy || msg.from}) + + ) + } - // Note: idle_notification messages already filtered out above + // Note: idle_notification messages already filtered out above - // Try to render as plan approval message (request or response) - const planApprovalElement = tryRenderPlanApprovalMessage(msg_0.text, msg_0.from); - if (planApprovalElement) { - return {planApprovalElement}; - } + // Try to render as plan approval message (request or response) + const planApprovalElement = tryRenderPlanApprovalMessage( + msg.text, + msg.from, + ) + if (planApprovalElement) { + return ( + {planApprovalElement} + ) + } - // Plain text message - sender header with chevron, truncated content - const inkColor = toInkColor(msg_0.color); - const formattedContent = formatTeammateMessageContent(msg_0.text) ?? msg_0.text; - return ; - })} - ; + // Plain text message - sender header with chevron, truncated content + const inkColor = toInkColor(msg.color) + const formattedContent = + formatTeammateMessageContent(msg.text) ?? msg.text + return ( + + ) + })} + + ) } // skill_discovery rendered here (not in the switch) so the 'skill_discovery' @@ -108,83 +136,117 @@ export function AttachmentMessage({ // be conditionally eliminated; an if-body can. if (feature('EXPERIMENTAL_SKILL_SEARCH')) { if (attachment.type === 'skill_discovery') { - if (attachment.skills.length === 0) return null; + if (attachment.skills.length === 0) return null // Ant users get shortIds inline so they can /skill-feedback while the // turn is still fresh. External users (when this un-gates) just see // names — shortId is undefined outside ant builds anyway. - const names = attachment.skills.map(s => s.shortId ? `${s.name} [${s.shortId}]` : s.name).join(', '); - const firstId = attachment.skills[0]?.shortId; - const hint = (process.env.USER_TYPE) === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : ''; - return + const names = attachment.skills + .map(s => (s.shortId ? `${s.name} [${s.shortId}]` : s.name)) + .join(', ') + const firstId = attachment.skills[0]?.shortId + const hint = + process.env.USER_TYPE === 'ant' && !isDemoEnv && firstId + ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` + : '' + return ( + {attachment.skills.length} relevant{' '} {plural(attachment.skills.length, 'skill')}: {names} {hint && {hint}} - ; + + ) } } // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/skill_discovery handled before switch switch (attachment.type) { case 'directory': - return + return ( + Listed directory {attachment.displayPath + sep} - ; + + ) case 'file': case 'already_read_file': if (attachment.content.type === 'notebook') { - return + return ( + Read {attachment.displayPath} ( {attachment.content.file.cells.length} cells) - ; + + ) } if (attachment.content.type === 'file_unchanged') { - return + return ( + Read {attachment.displayPath} (unchanged) - ; + + ) } - return + return ( + Read {attachment.displayPath} ( - {attachment.content.type === 'text' ? `${attachment.content.file.numLines}${attachment.truncated ? '+' : ''} lines` : formatFileSize(attachment.content.file.originalSize)} + {attachment.content.type === 'text' + ? `${attachment.content.file.numLines}${attachment.truncated ? '+' : ''} lines` + : formatFileSize(attachment.content.file.originalSize)} ) - ; + + ) case 'compact_file_reference': - return + return ( + Referenced file {attachment.displayPath} - ; + + ) case 'pdf_reference': - return + return ( + Referenced PDF {attachment.displayPath} ( {attachment.pageCount} pages) - ; + + ) case 'selected_lines_in_ide': - return + return ( + ⧉ Selected{' '} {attachment.lineEnd - attachment.lineStart + 1}{' '} lines from {attachment.displayPath} in{' '} {attachment.ideName} - ; + + ) case 'nested_memory': - return + return ( + Loaded {attachment.displayPath} - ; + + ) case 'relevant_memories': // Usually absorbed into a CollapsedReadSearchGroup (collapseReadSearch.ts) // so this only renders when the preceding tool was non-collapsible (Edit, // Write) and no group was open. Match CollapsedReadSearchContent's style: // 2-space gutter, dim text, count only — filenames/content in ctrl+o. - return + return ( + Recalled {attachment.memories.length}{' '} {attachment.memories.length === 1 ? 'memory' : 'memories'} - {!isTranscriptMode && <> + {!isTranscriptMode && ( + <> {' '} - } + + )} - {(verbose || isTranscriptMode) && attachment.memories.map(m => + {(verbose || isTranscriptMode) && + attachment.memories.map(m => ( + @@ -192,156 +254,201 @@ export function AttachmentMessage({ - {isTranscriptMode && + {isTranscriptMode && ( + {m.content} - } - )} - ; - case 'dynamic_skill': - { - const skillCount = attachment.skillNames.length; - return + + )} + + ))} + + ) + case 'dynamic_skill': { + const skillCount = attachment.skillNames.length + return ( + Loaded{' '} {skillCount} {plural(skillCount, 'skill')} {' '} from {attachment.displayPath} - ; + + ) + } + case 'skill_listing': { + if (attachment.isInitial) { + return null } - case 'skill_listing': - { - if (attachment.isInitial) { - return null; - } - return + return ( + {attachment.skillCount}{' '} {plural(attachment.skillCount, 'skill')} available - ; + + ) + } + case 'agent_listing_delta': { + if (attachment.isInitial || attachment.addedTypes.length === 0) { + return null } - case 'agent_listing_delta': - { - if (attachment.isInitial || attachment.addedTypes.length === 0) { - return null; - } - const count = attachment.addedTypes.length; - return + const count = attachment.addedTypes.length + return ( + {count} agent {plural(count, 'type')} available - ; - } - case 'queued_command': - { - const text = typeof attachment.prompt === 'string' ? attachment.prompt : getContentText(attachment.prompt) || ''; - const hasImages = attachment.imagePasteIds && attachment.imagePasteIds.length > 0; - return - - {hasImages && attachment.imagePasteIds?.map(id => )} - ; - } + + ) + } + case 'queued_command': { + const text = + typeof attachment.prompt === 'string' + ? attachment.prompt + : getContentText(attachment.prompt) || '' + const hasImages = + attachment.imagePasteIds && attachment.imagePasteIds.length > 0 + return ( + + + {hasImages && + attachment.imagePasteIds?.map(id => ( + + ))} + + ) + } case 'plan_file_reference': - return + return ( + Plan file referenced ({getDisplayPath(attachment.planFilePath)}) - ; - case 'invoked_skills': - { - if (attachment.skills.length === 0) { - return null; - } - const skillNames = attachment.skills.map(s_0 => s_0.name).join(', '); - return Skills restored ({skillNames}); + + ) + case 'invoked_skills': { + if (attachment.skills.length === 0) { + return null } + const skillNames = attachment.skills.map(s => s.name).join(', ') + return Skills restored ({skillNames}) + } case 'diagnostics': - return ; + return case 'mcp_resource': - return + return ( + Read MCP resource {attachment.name} from{' '} {attachment.server} - ; + + ) case 'command_permissions': // The skill success message is rendered by SkillTool's renderToolResultMessage, // so we don't render anything here to avoid duplicate messages. - return null; - case 'async_hook_response': - { - // SessionStart hook completions are only shown in verbose mode - if (attachment.hookEvent === 'SessionStart' && !verbose) { - return null; - } - // Generally hide async hook completion messages unless in verbose mode - if (!verbose && !isTranscriptMode) { - return null; - } - return + return null + case 'async_hook_response': { + // SessionStart hook completions are only shown in verbose mode + if (attachment.hookEvent === 'SessionStart' && !verbose) { + return null + } + // Generally hide async hook completion messages unless in verbose mode + if (!verbose && !isTranscriptMode) { + return null + } + return ( + Async hook {attachment.hookEvent} completed - ; + + ) + } + case 'hook_blocking_error': { + // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage + if ( + attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop' + ) { + return null } - case 'hook_blocking_error': - { - // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { - return null; - } - // Show stderr to the user so they can understand why the hook blocked - const stderr = attachment.blockingError.blockingError.trim(); - return <> + // Show stderr to the user so they can understand why the hook blocked + const stderr = attachment.blockingError.blockingError.trim() + return ( + <> {attachment.hookName} hook returned blocking error {stderr ? {stderr} : null} - ; - } - case 'hook_non_blocking_error': - { - // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { - return null; - } - // Full hook output is logged to debug log via hookEvents.ts - return {attachment.hookName} hook error; + + ) + } + case 'hook_non_blocking_error': { + // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage + if ( + attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop' + ) { + return null } + // Full hook output is logged to debug log via hookEvents.ts + return {attachment.hookName} hook error + } case 'hook_error_during_execution': // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { - return null; + if ( + attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop' + ) { + return null } // Full hook output is logged to debug log via hookEvents.ts - return {attachment.hookName} hook warning; + return {attachment.hookName} hook warning case 'hook_success': // Full hook output is logged to debug log via hookEvents.ts - return null; + return null case 'hook_stopped_continuation': // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { - return null; + if ( + attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop' + ) { + return null } - return + return ( + {attachment.hookName} hook stopped continuation: {attachment.message} - ; + + ) case 'hook_system_message': - return + return ( + {attachment.hookName} says: {attachment.content} - ; - case 'hook_permission_decision': - { - const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied'; - return + + ) + case 'hook_permission_decision': { + const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied' + return ( + {action} by {attachment.hookEvent} hook - ; - } + + ) + } case 'task_status': - return ; + return case 'teammate_shutdown_batch': - return + return ( + {BLACK_CIRCLE} {attachment.count} {plural(attachment.count, 'teammate')} shut down gracefully - ; + + ) default: // Exhaustiveness: every type reaching here must be in NULL_RENDERING_TYPES. // If TS errors, a new Attachment type was added without a case above AND @@ -352,185 +459,110 @@ export function AttachmentMessage({ // skill_discovery and teammate_mailbox are handled BEFORE the switch in // runtime-gated blocks (feature() / isAgentSwarmsEnabled()) that TS can't // narrow through — excluded here via type union (compile-time only, no emit). - attachment.type satisfies NullRenderingAttachmentType | 'skill_discovery' | 'teammate_mailbox' | 'bagel_console'; - return null; + attachment.type satisfies + | NullRenderingAttachmentType + | 'skill_discovery' + | 'teammate_mailbox' + return null } } -type TaskStatusAttachment = Extract; -function TaskStatusMessage(t0) { - const $ = _c(4); - const { - attachment - } = t0; - if (false && attachment.status === "killed") { - return null; - } - if (isAgentSwarmsEnabled() && attachment.taskType === "in_process_teammate") { - let t1; - if ($[0] !== attachment) { - t1 = ; - $[0] = attachment; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + +type TaskStatusAttachment = Extract + +function TaskStatusMessage({ + attachment, +}: { + attachment: TaskStatusAttachment +}): React.ReactNode { + // For ants, killed task status is shown in the CoordinatorTaskPanel. + // Don't render it again in the chat. + if (process.env.USER_TYPE === 'ant' && attachment.status === 'killed') { + return null } - let t1; - if ($[2] !== attachment) { - t1 = ; - $[2] = attachment; - $[3] = t1; - } else { - t1 = $[3]; + + // Only access teammate-specific code when swarms are enabled. + // TeammateTaskStatus subscribes to AppState; by gating the mount we + // avoid adding a store listener for every non-teammate attachment. + if (isAgentSwarmsEnabled() && attachment.taskType === 'in_process_teammate') { + return } - return t1; + + return } -function GenericTaskStatus(t0) { - const $ = _c(9); - const { - attachment - } = t0; - const bg = useSelectedMessageBg(); - const statusText = attachment.status === "completed" ? "completed in background" : attachment.status === "killed" ? "stopped" : attachment.status === "running" ? "still running in background" : attachment.status; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {BLACK_CIRCLE} ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== attachment.description) { - t2 = {attachment.description}; - $[1] = attachment.description; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== statusText || $[4] !== t2) { - t3 = Task "{t2}" {statusText}; - $[3] = statusText; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== bg || $[7] !== t3) { - t4 = {t1}{t3}; - $[6] = bg; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + +function GenericTaskStatus({ + attachment, +}: { + attachment: TaskStatusAttachment +}): React.ReactNode { + const bg = useSelectedMessageBg() + const statusText = + attachment.status === 'completed' + ? 'completed in background' + : attachment.status === 'killed' + ? 'stopped' + : attachment.status === 'running' + ? 'still running in background' + : attachment.status + return ( + + {BLACK_CIRCLE} + + Task "{attachment.description}" {statusText} + + + ) } -function TeammateTaskStatus(t0: { attachment: TaskStatusAttachment }) { - const $ = _c(16); - const { - attachment - } = t0; - const bg = useSelectedMessageBg(); - let t1: (s: AppState) => TaskState; - if ($[0] !== attachment.taskId) { - t1 = s => s.tasks[attachment.taskId]; - $[0] = attachment.taskId; - $[1] = t1; - } else { - t1 = $[1] as (s: AppState) => TaskState; - } - const task = useAppState(t1); - if (task?.type !== "in_process_teammate") { - let t2; - if ($[2] !== attachment) { - t2 = ; - $[2] = attachment; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; - } - let t2; - if ($[4] !== task.identity.color) { - t2 = toInkColor(task.identity.color); - $[4] = task.identity.color; - $[5] = t2; - } else { - t2 = $[5]; - } - const agentColor = t2; - const statusText = attachment.status === "completed" ? "shut down gracefully" : attachment.status; - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {BLACK_CIRCLE} ; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== agentColor || $[8] !== task.identity.agentName) { - t4 = @{task.identity.agentName}; - $[7] = agentColor; - $[8] = task.identity.agentName; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== statusText || $[11] !== t4) { - t5 = Teammate{" "}{t4}{" "}{statusText}; - $[10] = statusText; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== bg || $[14] !== t5) { - t6 = {t3}{t5}; - $[13] = bg; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; + +function TeammateTaskStatus({ + attachment, +}: { + attachment: TaskStatusAttachment +}): React.ReactNode { + const bg = useSelectedMessageBg() + // Narrow selector: only re-render when this specific task changes. + const task = useAppState(s => s.tasks[attachment.taskId]) + if (task?.type !== 'in_process_teammate') { + // Fall through to generic rendering (task not yet in store, or wrong type) + return } - return t6; + const agentColor = toInkColor(task.identity.color) + const statusText = + attachment.status === 'completed' + ? 'shut down gracefully' + : attachment.status + return ( + + {BLACK_CIRCLE} + + Teammate{' '} + + @{task.identity.agentName} + {' '} + {statusText} + + + ) } // We allow setting dimColor to false here to help work around the dim-bold bug. // https://github.com/chalk/chalk/issues/290 -function Line(t0) { - const $ = _c(7); - const { - dimColor: t1, - children, - color - } = t0; - const dimColor = t1 === undefined ? true : t1; - const bg = useSelectedMessageBg(); - let t2; - if ($[0] !== children || $[1] !== color || $[2] !== dimColor) { - t2 = {children}; - $[0] = children; - $[1] = color; - $[2] = dimColor; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== bg || $[5] !== t2) { - t3 = {t2}; - $[4] = bg; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; +function Line({ + dimColor = true, + children, + color, +}: { + dimColor?: boolean + children: React.ReactNode + color?: keyof Theme +}): React.ReactNode { + const bg = useSelectedMessageBg() + return ( + + + + {children} + + + + ) } diff --git a/src/components/messages/CollapsedReadSearchContent.tsx b/src/components/messages/CollapsedReadSearchContent.tsx index e21659c59..d8df34f69 100644 --- a/src/components/messages/CollapsedReadSearchContent.tsx +++ b/src/components/messages/CollapsedReadSearchContent.tsx @@ -1,144 +1,122 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import { basename } from 'path'; -import React, { useRef } from 'react'; -import { useMinDisplayTime } from '../../hooks/useMinDisplayTime.js'; -import { Ansi, Box, Text, useTheme } from '../../ink.js'; -import { findToolByName, type Tools } from '../../Tool.js'; -import { getReplPrimitiveTools } from '../../tools/REPLTool/primitiveTools.js'; -import type { CollapsedReadSearchGroup, NormalizedAssistantMessage } from '../../types/message.js'; -import { uniq } from '../../utils/array.js'; -import { getToolUseIdsFromCollapsedGroup } from '../../utils/collapseReadSearch.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatDuration, formatSecondsShort } from '../../utils/format.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import type { buildMessageLookups } from '../../utils/messages.js'; -import type { ThemeName } from '../../utils/theme.js'; -import { CtrlOToExpand } from '../CtrlOToExpand.js'; -import { useSelectedMessageBg } from '../messageActions.js'; -import { PrBadge } from '../PrBadge.js'; -import { ToolUseLoader } from '../ToolUseLoader.js'; +import { feature } from 'bun:bundle' +import { basename } from 'path' +import React, { useRef } from 'react' +import { useMinDisplayTime } from '../../hooks/useMinDisplayTime.js' +import { Ansi, Box, Text, useTheme } from '../../ink.js' +import { findToolByName, type Tools } from '../../Tool.js' +import { getReplPrimitiveTools } from '../../tools/REPLTool/primitiveTools.js' +import type { + CollapsedReadSearchGroup, + NormalizedAssistantMessage, +} from '../../types/message.js' +import { uniq } from '../../utils/array.js' +import { getToolUseIdsFromCollapsedGroup } from '../../utils/collapseReadSearch.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatDuration, formatSecondsShort } from '../../utils/format.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import type { buildMessageLookups } from '../../utils/messages.js' +import type { ThemeName } from '../../utils/theme.js' +import { CtrlOToExpand } from '../CtrlOToExpand.js' +import { useSelectedMessageBg } from '../messageActions.js' +import { PrBadge } from '../PrBadge.js' +import { ToolUseLoader } from '../ToolUseLoader.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const teamMemCollapsed = feature('TEAMMEM') ? require('./teamMemCollapsed.js') as typeof import('./teamMemCollapsed.js') : null; +const teamMemCollapsed = feature('TEAMMEM') + ? (require('./teamMemCollapsed.js') as typeof import('./teamMemCollapsed.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Hold each ⤿ hint for a minimum duration so fast-completing tool calls // (bash commands, file reads, search patterns) are actually readable instead // of flickering past in a single frame. -const MIN_HINT_DISPLAY_MS = 700; +const MIN_HINT_DISPLAY_MS = 700 + type Props = { - message: CollapsedReadSearchGroup; - inProgressToolUseIDs: Set; - shouldAnimate: boolean; - verbose: boolean; - tools: Tools; - lookups: ReturnType; + message: CollapsedReadSearchGroup + inProgressToolUseIDs: Set + shouldAnimate: boolean + verbose: boolean + tools: Tools + lookups: ReturnType /** True if this is the currently active collapsed group (last one, still loading) */ - isActiveGroup?: boolean; -}; + isActiveGroup?: boolean +} /** Render a single tool use in verbose mode */ -function VerboseToolUse(t0) { - const $ = _c(24); - const { - content, - tools, - lookups, - inProgressToolUseIDs, - shouldAnimate, - theme - } = t0; - const bg = useSelectedMessageBg(); - let t1; - let t2; - if ($[0] !== bg || $[1] !== content.id || $[2] !== content.input || $[3] !== content.name || $[4] !== inProgressToolUseIDs || $[5] !== lookups || $[6] !== shouldAnimate || $[7] !== theme || $[8] !== tools) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const tool = findToolByName(tools, content.name) ?? findToolByName(getReplPrimitiveTools(), content.name); - if (!tool) { - t2 = null; - break bb0; - } - let t3; - if ($[11] !== content.id || $[12] !== lookups.resolvedToolUseIDs) { - t3 = lookups.resolvedToolUseIDs.has(content.id); - $[11] = content.id; - $[12] = lookups.resolvedToolUseIDs; - $[13] = t3; - } else { - t3 = $[13]; - } - const isResolved = t3; - let t4; - if ($[14] !== content.id || $[15] !== lookups.erroredToolUseIDs) { - t4 = lookups.erroredToolUseIDs.has(content.id); - $[14] = content.id; - $[15] = lookups.erroredToolUseIDs; - $[16] = t4; - } else { - t4 = $[16]; - } - const isError = t4; - let t5; - if ($[17] !== content.id || $[18] !== inProgressToolUseIDs) { - t5 = inProgressToolUseIDs.has(content.id); - $[17] = content.id; - $[18] = inProgressToolUseIDs; - $[19] = t5; - } else { - t5 = $[19]; - } - const isInProgress = t5; - const resultMsg = lookups.toolResultByToolUseID.get(content.id); - const rawToolResult = resultMsg?.type === "user" ? resultMsg.toolUseResult : undefined; - const parsedOutput = tool.outputSchema?.safeParse(rawToolResult); - const toolResult = parsedOutput?.success ? parsedOutput.data : undefined; - const parsedInput = tool.inputSchema.safeParse(content.input); - const input = parsedInput.success ? parsedInput.data : undefined; - const userFacingName = tool.userFacingName(input); - const toolUseMessage = input ? tool.renderToolUseMessage(input, { - theme, - verbose: true - }) : null; - const t6 = shouldAnimate && isInProgress; - const t7 = !isResolved; - let t8; - if ($[20] !== isError || $[21] !== t6 || $[22] !== t7) { - t8 = ; - $[20] = isError; - $[21] = t6; - $[22] = t7; - $[23] = t8; - } else { - t8 = $[23]; - } - t1 = {t8}{userFacingName}{toolUseMessage && ({toolUseMessage})}{input && tool.renderToolUseTag?.(input)}{isResolved && !isError && toolResult !== undefined && {tool.renderToolResultMessage?.(toolResult, [], { +function VerboseToolUse({ + content, + tools, + lookups, + inProgressToolUseIDs, + shouldAnimate, + theme, +}: { + content: { type: 'tool_use'; id: string; name: string; input: unknown } + tools: Tools + lookups: ReturnType + inProgressToolUseIDs: Set + shouldAnimate: boolean + theme: ThemeName +}): React.ReactNode { + const bg = useSelectedMessageBg() + // Same REPL-primitive fallback as getToolSearchOrReadInfo — REPL mode strips + // these from the execution tools list, but virtual messages still need them + // to render in verbose mode. + const tool = + findToolByName(tools, content.name) ?? + findToolByName(getReplPrimitiveTools(), content.name) + if (!tool) return null + + const isResolved = lookups.resolvedToolUseIDs.has(content.id) + const isError = lookups.erroredToolUseIDs.has(content.id) + const isInProgress = inProgressToolUseIDs.has(content.id) + + const resultMsg = lookups.toolResultByToolUseID.get(content.id) + const rawToolResult = + resultMsg?.type === 'user' ? resultMsg.toolUseResult : undefined + const parsedOutput = tool.outputSchema?.safeParse(rawToolResult) + const toolResult = parsedOutput?.success ? parsedOutput.data : undefined + + const parsedInput = tool.inputSchema.safeParse(content.input) + const input = parsedInput.success ? parsedInput.data : undefined + const userFacingName = tool.userFacingName(input) + const toolUseMessage = input + ? tool.renderToolUseMessage(input, { theme, verbose: true }) + : null + + return ( + + + + + {userFacingName} + {toolUseMessage && ({toolUseMessage})} + + {input && tool.renderToolUseTag?.(input)} + + {isResolved && !isError && toolResult !== undefined && ( + + {tool.renderToolResultMessage?.(toolResult, [], { verbose: true, tools, - theme - })}}; - } - $[0] = bg; - $[1] = content.id; - $[2] = content.input; - $[3] = content.name; - $[4] = inProgressToolUseIDs; - $[5] = lookups; - $[6] = shouldAnimate; - $[7] = theme; - $[8] = tools; - $[9] = t1; - $[10] = t2; - } else { - t1 = $[9]; - t2 = $[10]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - return t1; + theme, + })} + + )} + + ) } + export function CollapsedReadSearchContent({ message, inProgressToolUseIDs, @@ -146,9 +124,9 @@ export function CollapsedReadSearchContent({ verbose, tools, lookups, - isActiveGroup + isActiveGroup, }: Props): React.ReactNode { - const bg = useSelectedMessageBg(); + const bg = useSelectedMessageBg() const { searchCount: rawSearchCount, readCount: rawReadCount, @@ -157,94 +135,141 @@ export function CollapsedReadSearchContent({ memorySearchCount, memoryReadCount, memoryWriteCount, - messages: groupMessages - } = message; - const [theme] = useTheme(); - const toolUseIds = getToolUseIdsFromCollapsedGroup(message); - const anyError = toolUseIds.some(id => lookups.erroredToolUseIDs.has(id)); - const hasMemoryOps = memorySearchCount > 0 || memoryReadCount > 0 || memoryWriteCount > 0; - const hasTeamMemoryOps = feature('TEAMMEM') ? teamMemCollapsed!.checkHasTeamMemOps(message) : false; + messages: groupMessages, + } = message + const [theme] = useTheme() + const toolUseIds = getToolUseIdsFromCollapsedGroup(message) + const anyError = toolUseIds.some(id => lookups.erroredToolUseIDs.has(id)) + const hasMemoryOps = + memorySearchCount > 0 || memoryReadCount > 0 || memoryWriteCount > 0 + const hasTeamMemoryOps = feature('TEAMMEM') + ? teamMemCollapsed!.checkHasTeamMemOps(message) + : false // Track the max seen counts so they only ever increase. The debounce timer // causes extra re-renders at arbitrary times; during a brief "invisible window" // in the streaming executor the group count can dip, which causes jitter. - const maxReadCountRef = useRef(0); - const maxSearchCountRef = useRef(0); - const maxListCountRef = useRef(0); - const maxMcpCountRef = useRef(0); - const maxBashCountRef = useRef(0); - maxReadCountRef.current = Math.max(maxReadCountRef.current, rawReadCount); - maxSearchCountRef.current = Math.max(maxSearchCountRef.current, rawSearchCount); - maxListCountRef.current = Math.max(maxListCountRef.current, rawListCount); - maxMcpCountRef.current = Math.max(maxMcpCountRef.current, message.mcpCallCount ?? 0); - maxBashCountRef.current = Math.max(maxBashCountRef.current, message.bashCount ?? 0); - const readCount = maxReadCountRef.current; - const searchCount = maxSearchCountRef.current; - const listCount = maxListCountRef.current; - const mcpCallCount = maxMcpCountRef.current; + const maxReadCountRef = useRef(0) + const maxSearchCountRef = useRef(0) + const maxListCountRef = useRef(0) + const maxMcpCountRef = useRef(0) + const maxBashCountRef = useRef(0) + maxReadCountRef.current = Math.max(maxReadCountRef.current, rawReadCount) + maxSearchCountRef.current = Math.max( + maxSearchCountRef.current, + rawSearchCount, + ) + maxListCountRef.current = Math.max(maxListCountRef.current, rawListCount) + maxMcpCountRef.current = Math.max( + maxMcpCountRef.current, + message.mcpCallCount ?? 0, + ) + maxBashCountRef.current = Math.max( + maxBashCountRef.current, + message.bashCount ?? 0, + ) + const readCount = maxReadCountRef.current + const searchCount = maxSearchCountRef.current + const listCount = maxListCountRef.current + const mcpCallCount = maxMcpCountRef.current // Subtract commands surfaced as "Committed …" / "Created PR …" so the // same command isn't counted twice. gitOpBashCount is read live (no max-ref // needed — it's 0 until results arrive, then only grows). - const gitOpBashCount = message.gitOpBashCount ?? 0; - const bashCount = isFullscreenEnvEnabled() ? Math.max(0, maxBashCountRef.current - gitOpBashCount) : 0; - const hasNonMemoryOps = searchCount > 0 || readCount > 0 || listCount > 0 || replCount > 0 || mcpCallCount > 0 || bashCount > 0 || gitOpBashCount > 0; - const readPaths = message.readFilePaths; - const searchArgs = message.searchArgs; - let incomingHint = message.latestDisplayHint; + const gitOpBashCount = message.gitOpBashCount ?? 0 + const bashCount = isFullscreenEnvEnabled() + ? Math.max(0, maxBashCountRef.current - gitOpBashCount) + : 0 + + const hasNonMemoryOps = + searchCount > 0 || + readCount > 0 || + listCount > 0 || + replCount > 0 || + mcpCallCount > 0 || + bashCount > 0 || + gitOpBashCount > 0 + + const readPaths = message.readFilePaths + const searchArgs = message.searchArgs + let incomingHint = message.latestDisplayHint if (incomingHint === undefined) { - const lastSearchRaw = searchArgs?.at(-1); - const lastSearch = lastSearchRaw !== undefined ? `"${lastSearchRaw}"` : undefined; - const lastRead = readPaths?.at(-1); - incomingHint = lastRead !== undefined ? getDisplayPath(lastRead) : lastSearch; + const lastSearchRaw = searchArgs?.at(-1) + const lastSearch = + lastSearchRaw !== undefined ? `"${lastSearchRaw}"` : undefined + const lastRead = readPaths?.at(-1) + incomingHint = + lastRead !== undefined ? getDisplayPath(lastRead) : lastSearch } // Active REPL calls emit repl_tool_call progress with the current inner // tool's name+input. Virtual messages don't arrive until REPL completes, // so this is the only source of a live hint during execution. if (isActiveGroup) { - for (const id_0 of toolUseIds) { - if (!inProgressToolUseIDs.has(id_0)) continue; - const latest = lookups.progressMessagesByToolUseID.get(id_0)?.at(-1)?.data as { type?: string; phase?: string; toolInput?: unknown; toolName?: string } | undefined; + for (const id of toolUseIds) { + if (!inProgressToolUseIDs.has(id)) continue + const latest = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data if (latest?.type === 'repl_tool_call' && latest.phase === 'start') { const input = latest.toolInput as { - command?: string; - pattern?: string; - file_path?: string; - }; - incomingHint = input.file_path ?? (input.pattern ? `"${input.pattern}"` : undefined) ?? input.command ?? latest.toolName; + command?: string + pattern?: string + file_path?: string + } + incomingHint = + input.file_path ?? + (input.pattern ? `"${input.pattern}"` : undefined) ?? + input.command ?? + latest.toolName } } } - const displayedHint = useMinDisplayTime(incomingHint, MIN_HINT_DISPLAY_MS); + + const displayedHint = useMinDisplayTime(incomingHint, MIN_HINT_DISPLAY_MS) // In verbose mode, render each tool use with its 1-line result summary if (verbose) { - const toolUses: NormalizedAssistantMessage[] = []; + const toolUses: NormalizedAssistantMessage[] = [] for (const msg of groupMessages) { if (msg.type === 'assistant') { - toolUses.push(msg); + toolUses.push(msg) } else if (msg.type === 'grouped_tool_use') { - toolUses.push(...msg.messages); + toolUses.push(...msg.messages) } } - return - {toolUses.map(msg_0 => { - const content = msg_0.message.content[0]; - if (!content || typeof content === 'string' || content?.type !== 'tool_use') return null; - return ; - })} - {message.hookInfos && message.hookInfos.length > 0 && <> + + return ( + + {toolUses.map(msg => { + const content = msg.message.content[0] + if (content?.type !== 'tool_use') return null + return ( + + ) + })} + {message.hookInfos && message.hookInfos.length > 0 && ( + <> {' ⎿ '}Ran {message.hookCount} PreToolUse{' '} {message.hookCount === 1 ? 'hook' : 'hooks'} ( {formatSecondsShort(message.hookTotalMs ?? 0)}) - {message.hookInfos.map((info, idx) => + {message.hookInfos.map((info, idx) => ( + {' ⎿ '} {info.command} ({formatSecondsShort(info.durationMs ?? 0)}) - )} - } - {message.relevantMemories?.map(m => + + ))} + + )} + {message.relevantMemories?.map(m => ( + {' ⎿ '}Recalled {basename(m.path)} @@ -253,8 +278,10 @@ export function CollapsedReadSearchContent({ {m.content} - )} - ; + + ))} + + ) } // Non-verbose mode: Show counts with blinking grey dot while active, green dot when finalized @@ -263,70 +290,79 @@ export function CollapsedReadSearchContent({ // Defensive: If all counts are 0, don't render the collapsed group // This shouldn't happen in normal operation, but handles edge cases if (!hasMemoryOps && !hasTeamMemoryOps && !hasNonMemoryOps) { - return null; + return null } // Find the slowest in-progress shell command in this group. BashTool yields // progress every second but the collapsed renderer never showed it — long // commands (npm install, tests) looked frozen. Shown after 2s so fast // commands stay clean; the ticking counter reassures that slow ones aren't stuck. - let shellProgressSuffix = ''; + let shellProgressSuffix = '' if (isFullscreenEnvEnabled() && isActiveGroup) { - let elapsed: number | undefined; - let lines = 0; - for (const id_1 of toolUseIds) { - if (!inProgressToolUseIDs.has(id_1)) continue; - const data = lookups.progressMessagesByToolUseID.get(id_1)?.at(-1)?.data as { type?: string; elapsedTimeSeconds?: number; totalLines?: number } | undefined; - if (data?.type !== 'bash_progress' && data?.type !== 'powershell_progress') { - continue; + let elapsed: number | undefined + let lines = 0 + for (const id of toolUseIds) { + if (!inProgressToolUseIDs.has(id)) continue + const data = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data + if ( + data?.type !== 'bash_progress' && + data?.type !== 'powershell_progress' + ) { + continue } - if (elapsed === undefined || (data.elapsedTimeSeconds ?? 0) > elapsed) { - elapsed = data.elapsedTimeSeconds ?? 0; - lines = data.totalLines ?? 0; + if (elapsed === undefined || data.elapsedTimeSeconds > elapsed) { + elapsed = data.elapsedTimeSeconds + lines = data.totalLines } } if (elapsed !== undefined && elapsed >= 2) { - const time = formatDuration(elapsed * 1000); - shellProgressSuffix = lines > 0 ? ` (${time} · ${lines} ${lines === 1 ? 'line' : 'lines'})` : ` (${time})`; + const time = formatDuration(elapsed * 1000) + shellProgressSuffix = + lines > 0 + ? ` (${time} · ${lines} ${lines === 1 ? 'line' : 'lines'})` + : ` (${time})` } } // Build non-memory parts first (search, read, repl, mcp, bash) — these render // before memory so the line reads "Ran 3 bash commands, recalled 1 memory". - const nonMemParts: React.ReactNode[] = []; + const nonMemParts: React.ReactNode[] = [] // Git operations lead the line — they're the load-bearing outcome. function pushPart(key: string, verb: string, body: React.ReactNode): void { - const isFirst = nonMemParts.length === 0; - if (!isFirst) nonMemParts.push(, ); - nonMemParts.push( + const isFirst = nonMemParts.length === 0 + if (!isFirst) nonMemParts.push(, ) + nonMemParts.push( + {isFirst ? verb[0]!.toUpperCase() + verb.slice(1) : verb} {body} - ); + , + ) } if (isFullscreenEnvEnabled() && message.commits?.length) { const byKind = { committed: 'committed', amended: 'amended commit', - 'cherry-picked': 'cherry-picked' - }; + 'cherry-picked': 'cherry-picked', + } for (const kind of ['committed', 'amended', 'cherry-picked'] as const) { - const shas = message.commits.filter(c => c.kind === kind).map(c_0 => c_0.sha); + const shas = message.commits.filter(c => c.kind === kind).map(c => c.sha) if (shas.length) { - pushPart(kind, byKind[kind], {shas.join(', ')}); + pushPart(kind, byKind[kind], {shas.join(', ')}) } } } if (isFullscreenEnvEnabled() && message.pushes?.length) { - const branches = uniq(message.pushes.map(p => p.branch)); - pushPart('push', 'pushed to', {branches.join(', ')}); + const branches = uniq(message.pushes.map(p => p.branch)) + pushPart('push', 'pushed to', {branches.join(', ')}) } if (isFullscreenEnvEnabled() && message.branches?.length) { - const byAction = { - merged: 'merged', - rebased: 'rebased onto' - }; + const byAction = { merged: 'merged', rebased: 'rebased onto' } for (const b of message.branches) { - pushPart(`br-${b.action}-${b.ref}`, byAction[b.action], {b.ref}); + pushPart( + `br-${b.action}-${b.ref}`, + byAction[b.action], + {b.ref}, + ) } } if (isFullscreenEnvEnabled() && message.prs?.length) { @@ -336,148 +372,248 @@ export function CollapsedReadSearchContent({ merged: 'merged', commented: 'commented on', closed: 'closed', - ready: 'marked ready' - }; + ready: 'marked ready', + } for (const pr of message.prs) { - pushPart(`pr-${pr.action}-${pr.number}`, verbs[pr.action], pr.url ? : PR #{pr.number}); + pushPart( + `pr-${pr.action}-${pr.number}`, + verbs[pr.action], + pr.url ? ( + + ) : ( + PR #{pr.number} + ), + ) } } + if (searchCount > 0) { - const isFirst_0 = nonMemParts.length === 0; - const searchVerb = isActiveGroup ? isFirst_0 ? 'Searching for' : 'searching for' : isFirst_0 ? 'Searched for' : 'searched for'; - if (!isFirst_0) { - nonMemParts.push(, ); + const isFirst = nonMemParts.length === 0 + const searchVerb = isActiveGroup + ? isFirst + ? 'Searching for' + : 'searching for' + : isFirst + ? 'Searched for' + : 'searched for' + if (!isFirst) { + nonMemParts.push(, ) } - nonMemParts.push( + nonMemParts.push( + {searchVerb} {searchCount}{' '} {searchCount === 1 ? 'pattern' : 'patterns'} - ); + , + ) } + if (readCount > 0) { - const isFirst_1 = nonMemParts.length === 0; - const readVerb = isActiveGroup ? isFirst_1 ? 'Reading' : 'reading' : isFirst_1 ? 'Read' : 'read'; - if (!isFirst_1) { - nonMemParts.push(, ); + const isFirst = nonMemParts.length === 0 + const readVerb = isActiveGroup + ? isFirst + ? 'Reading' + : 'reading' + : isFirst + ? 'Read' + : 'read' + if (!isFirst) { + nonMemParts.push(, ) } - nonMemParts.push( + nonMemParts.push( + {readVerb} {readCount}{' '} {readCount === 1 ? 'file' : 'files'} - ); + , + ) } + if (listCount > 0) { - const isFirst_2 = nonMemParts.length === 0; - const listVerb = isActiveGroup ? isFirst_2 ? 'Listing' : 'listing' : isFirst_2 ? 'Listed' : 'listed'; - if (!isFirst_2) { - nonMemParts.push(, ); + const isFirst = nonMemParts.length === 0 + const listVerb = isActiveGroup + ? isFirst + ? 'Listing' + : 'listing' + : isFirst + ? 'Listed' + : 'listed' + if (!isFirst) { + nonMemParts.push(, ) } - nonMemParts.push( + nonMemParts.push( + {listVerb} {listCount}{' '} {listCount === 1 ? 'directory' : 'directories'} - ); + , + ) } + if (replCount > 0) { - const replVerb = isActiveGroup ? "REPL'ing" : "REPL'd"; + const replVerb = isActiveGroup ? "REPL'ing" : "REPL'd" if (nonMemParts.length > 0) { - nonMemParts.push(, ); + nonMemParts.push(, ) } - nonMemParts.push( + nonMemParts.push( + {replVerb} {replCount}{' '} {replCount === 1 ? 'time' : 'times'} - ); + , + ) } + if (mcpCallCount > 0) { - const serverLabel = message.mcpServerNames?.map(n => n.replace(/^claude\.ai /, '')).join(', ') || 'MCP'; - const isFirst_3 = nonMemParts.length === 0; - const verb_0 = isActiveGroup ? isFirst_3 ? 'Querying' : 'querying' : isFirst_3 ? 'Queried' : 'queried'; - if (!isFirst_3) { - nonMemParts.push(, ); + const serverLabel = + message.mcpServerNames + ?.map(n => n.replace(/^claude\.ai /, '')) + .join(', ') || 'MCP' + const isFirst = nonMemParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Querying' + : 'querying' + : isFirst + ? 'Queried' + : 'queried' + if (!isFirst) { + nonMemParts.push(, ) } - nonMemParts.push( - {verb_0} {serverLabel} - {mcpCallCount > 1 && <> + nonMemParts.push( + + {verb} {serverLabel} + {mcpCallCount > 1 && ( + <> {' '} {mcpCallCount} times - } - ); + + )} + , + ) } + if (isFullscreenEnvEnabled() && bashCount > 0) { - const isFirst_4 = nonMemParts.length === 0; - const verb_1 = isActiveGroup ? isFirst_4 ? 'Running' : 'running' : isFirst_4 ? 'Ran' : 'ran'; - if (!isFirst_4) { - nonMemParts.push(, ); + const isFirst = nonMemParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Running' + : 'running' + : isFirst + ? 'Ran' + : 'ran' + if (!isFirst) { + nonMemParts.push(, ) } - nonMemParts.push( - {verb_1} {bashCount} bash{' '} + nonMemParts.push( + + {verb} {bashCount} bash{' '} {bashCount === 1 ? 'command' : 'commands'} - ); + , + ) } // Build memory parts (auto-memory) — rendered after nonMemParts - const hasPrecedingNonMem = nonMemParts.length > 0; - const memParts: React.ReactNode[] = []; + const hasPrecedingNonMem = nonMemParts.length > 0 + const memParts: React.ReactNode[] = [] + if (memoryReadCount > 0) { - const isFirst_5 = !hasPrecedingNonMem && memParts.length === 0; - const verb_2 = isActiveGroup ? isFirst_5 ? 'Recalling' : 'recalling' : isFirst_5 ? 'Recalled' : 'recalled'; - if (!isFirst_5) { - memParts.push(, ); + const isFirst = !hasPrecedingNonMem && memParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Recalling' + : 'recalling' + : isFirst + ? 'Recalled' + : 'recalled' + if (!isFirst) { + memParts.push(, ) } - memParts.push( - {verb_2} {memoryReadCount}{' '} + memParts.push( + + {verb} {memoryReadCount}{' '} {memoryReadCount === 1 ? 'memory' : 'memories'} - ); + , + ) } + if (memorySearchCount > 0) { - const isFirst_6 = !hasPrecedingNonMem && memParts.length === 0; - const verb_3 = isActiveGroup ? isFirst_6 ? 'Searching' : 'searching' : isFirst_6 ? 'Searched' : 'searched'; - if (!isFirst_6) { - memParts.push(, ); + const isFirst = !hasPrecedingNonMem && memParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Searching' + : 'searching' + : isFirst + ? 'Searched' + : 'searched' + if (!isFirst) { + memParts.push(, ) } - memParts.push({`${verb_3} memories`}); + memParts.push({`${verb} memories`}) } + if (memoryWriteCount > 0) { - const isFirst_7 = !hasPrecedingNonMem && memParts.length === 0; - const verb_4 = isActiveGroup ? isFirst_7 ? 'Writing' : 'writing' : isFirst_7 ? 'Wrote' : 'wrote'; - if (!isFirst_7) { - memParts.push(, ); + const isFirst = !hasPrecedingNonMem && memParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Writing' + : 'writing' + : isFirst + ? 'Wrote' + : 'wrote' + if (!isFirst) { + memParts.push(, ) } - memParts.push( - {verb_4} {memoryWriteCount}{' '} + memParts.push( + + {verb} {memoryWriteCount}{' '} {memoryWriteCount === 1 ? 'memory' : 'memories'} - ); + , + ) } - return + + return ( + - {isActiveGroup ? : } + {isActiveGroup ? ( + + ) : ( + + )} {nonMemParts} {memParts} - {feature('TEAMMEM') ? teamMemCollapsed!.TeamMemCountParts({ - message, - isActiveGroup, - hasPrecedingParts: hasPrecedingNonMem || memParts.length > 0 - }) : null} + {feature('TEAMMEM') + ? teamMemCollapsed!.TeamMemCountParts({ + message, + isActiveGroup, + hasPrecedingParts: hasPrecedingNonMem || memParts.length > 0, + }) + : null} {isActiveGroup && } - {isActiveGroup && displayedHint !== undefined && - // Row layout: 5-wide gutter for ⎿, then a flex column for the text. - // Ink's wrap stays inside the right column so continuation lines - // indent under ⎿. MAX_HINT_CHARS in commandAsHint caps total at ~5 lines. - + {isActiveGroup && displayedHint !== undefined && ( + // Row layout: 5-wide gutter for ⎿, then a flex column for the text. + // Ink's wrap stays inside the right column so continuation lines + // indent under ⎿. MAX_HINT_CHARS in commandAsHint caps total at ~5 lines. + {' ⎿ '} - {displayedHint.split('\n').map((line, i, arr) => + {displayedHint.split('\n').map((line, i, arr) => ( + {line} {i === arr.length - 1 && shellProgressSuffix} - )} + + ))} - } - {message.hookTotalMs !== undefined && message.hookTotalMs > 0 && + + )} + {message.hookTotalMs !== undefined && message.hookTotalMs > 0 && ( + {' ⎿ '}Ran {message.hookCount} PreToolUse{' '} {message.hookCount === 1 ? 'hook' : 'hooks'} ( {formatSecondsShort(message.hookTotalMs)}) - } - ; + + )} + + ) } diff --git a/src/components/messages/CompactBoundaryMessage.tsx b/src/components/messages/CompactBoundaryMessage.tsx index f8c373f15..7c4e87af1 100644 --- a/src/components/messages/CompactBoundaryMessage.tsx +++ b/src/components/messages/CompactBoundaryMessage.tsx @@ -1,17 +1,19 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -export function CompactBoundaryMessage() { - const $ = _c(2); - const historyShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let t0; - if ($[0] !== historyShortcut) { - t0 = ✻ Conversation compacted ({historyShortcut} for history); - $[0] = historyShortcut; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' + +export function CompactBoundaryMessage(): React.ReactNode { + const historyShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + + return ( + + + ✻ Conversation compacted ({historyShortcut} for history) + + + ) } diff --git a/src/components/messages/GroupedToolUseContent.tsx b/src/components/messages/GroupedToolUseContent.tsx index 218fbba42..2376e377c 100644 --- a/src/components/messages/GroupedToolUseContent.tsx +++ b/src/components/messages/GroupedToolUseContent.tsx @@ -1,62 +1,71 @@ -import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; -import * as React from 'react'; -import { filterToolProgressMessages, findToolByName, type Tools } from '../../Tool.js'; -import type { GroupedToolUseMessage } from '../../types/message.js'; -import type { buildMessageLookups } from '../../utils/messages.js'; +import type { + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/messages/messages.mjs' +import * as React from 'react' +import { + filterToolProgressMessages, + findToolByName, + type Tools, +} from '../../Tool.js' +import type { GroupedToolUseMessage } from '../../types/message.js' +import type { buildMessageLookups } from '../../utils/messages.js' + type Props = { - message: GroupedToolUseMessage; - tools: Tools; - lookups: ReturnType; - inProgressToolUseIDs: Set; - shouldAnimate: boolean; -}; + message: GroupedToolUseMessage + tools: Tools + lookups: ReturnType + inProgressToolUseIDs: Set + shouldAnimate: boolean +} + export function GroupedToolUseContent({ message, tools, lookups, inProgressToolUseIDs, - shouldAnimate + shouldAnimate, }: Props): React.ReactNode { - const tool = findToolByName(tools, message.toolName); + const tool = findToolByName(tools, message.toolName) if (!tool?.renderGroupedToolUse) { - return null; + return null } // Build a map from tool_use_id to result data - const resultsByToolUseId = new Map(); + const resultsByToolUseId = new Map< + string, + { param: ToolResultBlockParam; output: unknown } + >() for (const resultMsg of message.results) { - const contentArr = resultMsg.message.content; - if (!Array.isArray(contentArr)) continue; - for (const content of contentArr) { - if (typeof content === 'string') continue; + for (const content of resultMsg.message.content) { if (content.type === 'tool_result') { - resultsByToolUseId.set((content as ToolResultBlockParam).tool_use_id, { - param: content as ToolResultBlockParam, - output: resultMsg.toolUseResult - }); + resultsByToolUseId.set(content.tool_use_id, { + param: content, + output: resultMsg.toolUseResult, + }) } } } + const toolUsesData = message.messages.map(msg => { - const contentArr = msg.message.content; - const rawContent = Array.isArray(contentArr) ? contentArr[0] : undefined; - const content = rawContent as ToolUseBlockParam; - const result = resultsByToolUseId.get(content.id); + const content = msg.message.content[0] + const result = resultsByToolUseId.get(content.id) return { - param: content, + param: content as ToolUseBlockParam, isResolved: lookups.resolvedToolUseIDs.has(content.id), isError: lookups.erroredToolUseIDs.has(content.id), isInProgress: inProgressToolUseIDs.has(content.id), - progressMessages: filterToolProgressMessages(lookups.progressMessagesByToolUseID.get(content.id) ?? []), - result - }; - }); - const anyInProgress = toolUsesData.some(d => d.isInProgress); + progressMessages: filterToolProgressMessages( + lookups.progressMessagesByToolUseID.get(content.id) ?? [], + ), + result, + } + }) + + const anyInProgress = toolUsesData.some(d => d.isInProgress) + return tool.renderGroupedToolUse(toolUsesData, { shouldAnimate: shouldAnimate && anyInProgress, - tools - }); + tools, + }) } diff --git a/src/components/messages/HighlightedThinkingText.tsx b/src/components/messages/HighlightedThinkingText.tsx index 3109f2048..1b4fd0c3c 100644 --- a/src/components/messages/HighlightedThinkingText.tsx +++ b/src/components/messages/HighlightedThinkingText.tsx @@ -1,161 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useContext } from 'react'; -import { useQueuedMessage } from '../../context/QueuedMessageContext.js'; -import { Box, Text } from '../../ink.js'; -import { formatBriefTimestamp } from '../../utils/formatBriefTimestamp.js'; -import { findThinkingTriggerPositions, getRainbowColor, isUltrathinkEnabled } from '../../utils/thinking.js'; -import { MessageActionsSelectedContext } from '../messageActions.js'; +import figures from 'figures' +import * as React from 'react' +import { useContext } from 'react' +import { useQueuedMessage } from '../../context/QueuedMessageContext.js' +import { Box, Text } from '../../ink.js' +import { formatBriefTimestamp } from '../../utils/formatBriefTimestamp.js' +import { + findThinkingTriggerPositions, + getRainbowColor, + isUltrathinkEnabled, +} from '../../utils/thinking.js' +import { MessageActionsSelectedContext } from '../messageActions.js' + type Props = { - text: string; - useBriefLayout?: boolean; - timestamp?: string; -}; -export function HighlightedThinkingText(t0) { - const $ = _c(31); - const { - text, - useBriefLayout, - timestamp - } = t0; - const isQueued = useQueuedMessage()?.isQueued ?? false; - const isSelected = useContext(MessageActionsSelectedContext); - const pointerColor = isSelected ? "suggestion" : "subtle"; + text: string + useBriefLayout?: boolean + timestamp?: string +} + +export function HighlightedThinkingText({ + text, + useBriefLayout, + timestamp, +}: Props): React.ReactNode { + // Brief/assistant mode: chat-style "You" label instead of the ❯ highlight. + // Parent drops its backgroundColor when this is true, so no grey shows + // through. No manual wrap needed — Ink wraps inside the parent Box. + const isQueued = useQueuedMessage()?.isQueued ?? false + const isSelected = useContext(MessageActionsSelectedContext) + const pointerColor = isSelected ? 'suggestion' : 'subtle' if (useBriefLayout) { - let t1; - if ($[0] !== timestamp) { - t1 = timestamp ? formatBriefTimestamp(timestamp) : ""; - $[0] = timestamp; - $[1] = t1; - } else { - t1 = $[1]; - } - const ts = t1; - const t2 = isQueued ? "subtle" : "briefLabelYou"; - let t3; - if ($[2] !== t2) { - t3 = You; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== ts) { - t4 = ts ? {ts} : null; - $[4] = ts; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t3 || $[7] !== t4) { - t5 = {t3}{t4}; - $[6] = t3; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - const t6 = isQueued ? "subtle" : "text"; - let t7; - if ($[9] !== t6 || $[10] !== text) { - t7 = {text}; - $[9] = t6; - $[10] = text; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== t5 || $[13] !== t7) { - t8 = {t5}{t7}; - $[12] = t5; - $[13] = t7; - $[14] = t8; - } else { - t8 = $[14]; - } - return t8; - } - let parts; - let t1; - if ($[15] !== pointerColor || $[16] !== text) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const triggers = isUltrathinkEnabled() ? findThinkingTriggerPositions(text) : []; - if (triggers.length === 0) { - let t2; - if ($[19] !== pointerColor) { - t2 = {figures.pointer} ; - $[19] = pointerColor; - $[20] = t2; - } else { - t2 = $[20]; - } - let t3; - if ($[21] !== text) { - t3 = {text}; - $[21] = text; - $[22] = t3; - } else { - t3 = $[22]; - } - let t4; - if ($[23] !== t2 || $[24] !== t3) { - t4 = {t2}{t3}; - $[23] = t2; - $[24] = t3; - $[25] = t4; - } else { - t4 = $[25]; - } - t1 = t4; - break bb0; - } - parts = []; - let cursor = 0; - for (const t of triggers) { - if (t.start > cursor) { - parts.push({text.slice(cursor, t.start)}); - } - for (let i = t.start; i < t.end; i++) { - parts.push({text[i]}); - } - cursor = t.end; - } - if (cursor < text.length) { - parts.push({text.slice(cursor)}); - } - } - $[15] = pointerColor; - $[16] = text; - $[17] = parts; - $[18] = t1; - } else { - parts = $[17]; - t1 = $[18]; + const ts = timestamp ? formatBriefTimestamp(timestamp) : '' + return ( + + + You + {ts ? {ts} : null} + + {text} + + ) } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; + + const triggers = isUltrathinkEnabled() + ? findThinkingTriggerPositions(text) + : [] + + if (triggers.length === 0) { + return ( + + {figures.pointer} + {text} + + ) } - let t2; - if ($[26] !== pointerColor) { - t2 = {figures.pointer} ; - $[26] = pointerColor; - $[27] = t2; - } else { - t2 = $[27]; + + // Static rainbow (no shimmer — transcript messages don't animate) + const parts: React.ReactNode[] = [] + let cursor = 0 + for (const t of triggers) { + if (t.start > cursor) { + parts.push( + + {text.slice(cursor, t.start)} + , + ) + } + for (let i = t.start; i < t.end; i++) { + parts.push( + + {text[i]} + , + ) + } + cursor = t.end } - let t3; - if ($[28] !== parts || $[29] !== t2) { - t3 = {t2}{parts}; - $[28] = parts; - $[29] = t2; - $[30] = t3; - } else { - t3 = $[30]; + if (cursor < text.length) { + parts.push( + + {text.slice(cursor)} + , + ) } - return t3; + + return ( + + {figures.pointer} + {parts} + + ) } diff --git a/src/components/messages/HookProgressMessage.tsx b/src/components/messages/HookProgressMessage.tsx index eabd2e0e2..61bfddf96 100644 --- a/src/components/messages/HookProgressMessage.tsx +++ b/src/components/messages/HookProgressMessage.tsx @@ -1,115 +1,67 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; -import type { buildMessageLookups } from 'src/utils/messages.js'; -import { Box, Text } from '../../ink.js'; -import { MessageResponse } from '../MessageResponse.js'; +import * as React from 'react' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import type { buildMessageLookups } from 'src/utils/messages.js' +import { Box, Text } from '../../ink.js' +import { MessageResponse } from '../MessageResponse.js' + type Props = { - hookEvent: HookEvent; - lookups: ReturnType; - toolUseID: string; - verbose: boolean; - isTranscriptMode?: boolean; -}; -export function HookProgressMessage(t0) { - const $ = _c(22); - const { - hookEvent, - lookups, - toolUseID, - isTranscriptMode - } = t0; - let t1; - if ($[0] !== hookEvent || $[1] !== lookups.inProgressHookCounts || $[2] !== toolUseID) { - t1 = lookups.inProgressHookCounts.get(toolUseID)?.get(hookEvent) ?? 0; - $[0] = hookEvent; - $[1] = lookups.inProgressHookCounts; - $[2] = toolUseID; - $[3] = t1; - } else { - t1 = $[3]; - } - const inProgressHookCount = t1; - const resolvedHookCount = lookups.resolvedHookCounts.get(toolUseID)?.get(hookEvent) ?? 0; + hookEvent: HookEvent + lookups: ReturnType + toolUseID: string + verbose: boolean + isTranscriptMode?: boolean +} + +export function HookProgressMessage({ + hookEvent, + lookups, + toolUseID, + isTranscriptMode, +}: Props): React.ReactNode { + const inProgressHookCount = + lookups.inProgressHookCounts.get(toolUseID)?.get(hookEvent) ?? 0 + const resolvedHookCount = + lookups.resolvedHookCounts.get(toolUseID)?.get(hookEvent) ?? 0 if (inProgressHookCount === 0) { - return null; + return null } - if (hookEvent === "PreToolUse" || hookEvent === "PostToolUse") { + + if (hookEvent === 'PreToolUse' || hookEvent === 'PostToolUse') { + // In transcript mode, show a static summary since messages never re-render + // (so a transient "Running..." would get stuck). if (isTranscriptMode) { - let t2; - if ($[4] !== inProgressHookCount) { - t2 = {inProgressHookCount} ; - $[4] = inProgressHookCount; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== hookEvent) { - t3 = {hookEvent}; - $[6] = hookEvent; - $[7] = t3; - } else { - t3 = $[7]; - } - const t4 = inProgressHookCount === 1 ? " hook" : " hooks"; - let t5; - if ($[8] !== t4) { - t5 = {t4} ran; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t2 || $[11] !== t3 || $[12] !== t5) { - t6 = {t2}{t3}{t5}; - $[10] = t2; - $[11] = t3; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; + return ( + + + {inProgressHookCount} + + {hookEvent} + + + {inProgressHookCount === 1 ? ' hook' : ' hooks'} ran + + + + ) } - return null; + // Outside transcript mode, hide — completion info is shown via + // async_hook_response attachments instead. + return null } + if (resolvedHookCount === inProgressHookCount) { - return null; - } - let t2; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Running ; - $[14] = t2; - } else { - t2 = $[14]; - } - let t3; - if ($[15] !== hookEvent) { - t3 = {hookEvent}; - $[15] = hookEvent; - $[16] = t3; - } else { - t3 = $[16]; - } - const t4 = inProgressHookCount === 1 ? " hook\u2026" : " hooks\u2026"; - let t5; - if ($[17] !== t4) { - t5 = {t4}; - $[17] = t4; - $[18] = t5; - } else { - t5 = $[18]; - } - let t6; - if ($[19] !== t3 || $[20] !== t5) { - t6 = {t2}{t3}{t5}; - $[19] = t3; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; + return null } - return t6; + + return ( + + + Running + + {hookEvent} + + {inProgressHookCount === 1 ? ' hook…' : ' hooks…'} + + + ) } diff --git a/src/components/messages/PlanApprovalMessage.tsx b/src/components/messages/PlanApprovalMessage.tsx index 33a3947cf..a7fbced71 100644 --- a/src/components/messages/PlanApprovalMessage.tsx +++ b/src/components/messages/PlanApprovalMessage.tsx @@ -1,149 +1,158 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Markdown } from '../../components/Markdown.js'; -import { Box, Text } from '../../ink.js'; -import { jsonParse } from '../../utils/slowOperations.js'; -import { type IdleNotificationMessage, isIdleNotification, isPlanApprovalRequest, isPlanApprovalResponse, type PlanApprovalRequestMessage, type PlanApprovalResponseMessage } from '../../utils/teammateMailbox.js'; -import { getShutdownMessageSummary } from './ShutdownMessage.js'; -import { getTaskAssignmentSummary } from './TaskAssignmentMessage.js'; +import * as React from 'react' +import { Markdown } from '../../components/Markdown.js' +import { Box, Text } from '../../ink.js' +import { jsonParse } from '../../utils/slowOperations.js' +import { + type IdleNotificationMessage, + isIdleNotification, + isPlanApprovalRequest, + isPlanApprovalResponse, + type PlanApprovalRequestMessage, + type PlanApprovalResponseMessage, +} from '../../utils/teammateMailbox.js' +import { getShutdownMessageSummary } from './ShutdownMessage.js' +import { getTaskAssignmentSummary } from './TaskAssignmentMessage.js' + type PlanApprovalRequestProps = { - request: PlanApprovalRequestMessage; -}; + request: PlanApprovalRequestMessage +} /** * Renders a plan approval request with a planMode-colored border, * showing the plan content and instructions for approving/rejecting. */ -export function PlanApprovalRequestDisplay(t0) { - const $ = _c(10); - const { - request - } = t0; - let t1; - if ($[0] !== request.from) { - t1 = Plan Approval Request from {request.from}; - $[0] = request.from; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== request.planContent) { - t2 = {request.planContent}; - $[2] = request.planContent; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== request.planFilePath) { - t3 = Plan file: {request.planFilePath}; - $[4] = request.planFilePath; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== t1 || $[7] !== t2 || $[8] !== t3) { - t4 = {t1}{t2}{t3}; - $[6] = t1; - $[7] = t2; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - return t4; +export function PlanApprovalRequestDisplay({ + request, +}: PlanApprovalRequestProps): React.ReactNode { + return ( + + + + + Plan Approval Request from {request.from} + + + + {request.planContent} + + Plan file: {request.planFilePath} + + + ) } + type PlanApprovalResponseProps = { - response: PlanApprovalResponseMessage; - senderName: string; -}; + response: PlanApprovalResponseMessage + senderName: string +} /** * Renders a plan approval response with a success (green) or error (red) border. */ -export function PlanApprovalResponseDisplay(t0) { - const $ = _c(13); - const { - response, - senderName - } = t0; +export function PlanApprovalResponseDisplay({ + response, + senderName, +}: PlanApprovalResponseProps): React.ReactNode { if (response.approved) { - let t1; - if ($[0] !== senderName) { - t1 = ✓ Plan Approved by {senderName}; - $[0] = senderName; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = You can now proceed with implementation. Your plan mode restrictions have been lifted.; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t1) { - t3 = {t1}{t2}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + return ( + + + + + ✓ Plan Approved by {senderName} + + + + + You can now proceed with implementation. Your plan mode + restrictions have been lifted. + + + + + ) } - let t1; - if ($[5] !== senderName) { - t1 = ✗ Plan Rejected by {senderName}; - $[5] = senderName; - $[6] = t1; - } else { - t1 = $[6]; - } - let t2; - if ($[7] !== response.feedback) { - t2 = response.feedback && Feedback: {response.feedback}; - $[7] = response.feedback; - $[8] = t2; - } else { - t2 = $[8]; - } - let t3; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Please revise your plan based on the feedback and call ExitPlanMode again.; - $[9] = t3; - } else { - t3 = $[9]; - } - let t4; - if ($[10] !== t1 || $[11] !== t2) { - t4 = {t1}{t2}{t3}; - $[10] = t1; - $[11] = t2; - $[12] = t4; - } else { - t4 = $[12]; - } - return t4; + + return ( + + + + + ✗ Plan Rejected by {senderName} + + + {response.feedback && ( + + Feedback: {response.feedback} + + )} + + + Please revise your plan based on the feedback and call ExitPlanMode + again. + + + + + ) } /** * Try to parse and render a plan approval message from raw content. * Returns the rendered component if it's a plan approval message, null otherwise. */ -export function tryRenderPlanApprovalMessage(content: string, senderName: string): React.ReactNode | null { - const request = isPlanApprovalRequest(content); +export function tryRenderPlanApprovalMessage( + content: string, + senderName: string, +): React.ReactNode | null { + const request = isPlanApprovalRequest(content) if (request) { - return ; + return } - const response = isPlanApprovalResponse(content); + + const response = isPlanApprovalResponse(content) if (response) { - return ; + return ( + + ) } - return null; + + return null } /** @@ -152,34 +161,36 @@ export function tryRenderPlanApprovalMessage(content: string, senderName: string * Returns null if the content is not a plan approval message. */ function getPlanApprovalSummary(content: string): string | null { - const request = isPlanApprovalRequest(content); + const request = isPlanApprovalRequest(content) if (request) { - return `[Plan Approval Request from ${request.from}]`; + return `[Plan Approval Request from ${request.from}]` } - const response = isPlanApprovalResponse(content); + + const response = isPlanApprovalResponse(content) if (response) { if (response.approved) { - return '[Plan Approved] You can now proceed with implementation'; + return '[Plan Approved] You can now proceed with implementation' } else { - return `[Plan Rejected] ${response.feedback || 'Please revise your plan'}`; + return `[Plan Rejected] ${response.feedback || 'Please revise your plan'}` } } - return null; + + return null } /** * Get a brief summary text for an idle notification. */ function getIdleNotificationSummary(msg: IdleNotificationMessage): string { - const parts: string[] = ['Agent idle']; + const parts: string[] = ['Agent idle'] if (msg.completedTaskId) { - const status = msg.completedStatus || 'completed'; - parts.push(`Task ${msg.completedTaskId} ${status}`); + const status = msg.completedStatus || 'completed' + parts.push(`Task ${msg.completedTaskId} ${status}`) } if (msg.summary) { - parts.push(`Last DM: ${msg.summary}`); + parts.push(`Last DM: ${msg.summary}`) } - return parts.join(' · '); + return parts.join(' · ') } /** @@ -188,34 +199,35 @@ function getIdleNotificationSummary(msg: IdleNotificationMessage): string { * Otherwise returns the original content. */ export function formatTeammateMessageContent(content: string): string { - const planSummary = getPlanApprovalSummary(content); + const planSummary = getPlanApprovalSummary(content) if (planSummary) { - return planSummary; + return planSummary } - const shutdownSummary = getShutdownMessageSummary(content); + + const shutdownSummary = getShutdownMessageSummary(content) if (shutdownSummary) { - return shutdownSummary; + return shutdownSummary } - const idleMsg = isIdleNotification(content); + + const idleMsg = isIdleNotification(content) if (idleMsg) { - return getIdleNotificationSummary(idleMsg); + return getIdleNotificationSummary(idleMsg) } - const taskAssignmentSummary = getTaskAssignmentSummary(content); + + const taskAssignmentSummary = getTaskAssignmentSummary(content) if (taskAssignmentSummary) { - return taskAssignmentSummary; + return taskAssignmentSummary } // Check for teammate_terminated message try { - const parsed = jsonParse(content) as { - type?: string; - message?: string; - }; + const parsed = jsonParse(content) as { type?: string; message?: string } if (parsed?.type === 'teammate_terminated' && parsed.message) { - return parsed.message; + return parsed.message } } catch { // Not JSON } - return content; + + return content } diff --git a/src/components/messages/RateLimitMessage.tsx b/src/components/messages/RateLimitMessage.tsx index e8b439e2b..c9a42815b 100644 --- a/src/components/messages/RateLimitMessage.tsx +++ b/src/components/messages/RateLimitMessage.tsx @@ -1,160 +1,131 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useMemo, useState } from 'react'; -import { extraUsage } from 'src/commands/extra-usage/index.js'; -import { Box, Text } from 'src/ink.js'; -import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js'; -import { shouldProcessMockLimits } from 'src/services/rateLimitMocking.js'; // Used for /mock-limits command -import { getRateLimitTier, getSubscriptionType, isClaudeAISubscriber } from 'src/utils/auth.js'; -import { hasClaudeAiBillingAccess } from 'src/utils/billing.js'; -import { MessageResponse } from '../MessageResponse.js'; +import React, { useEffect, useMemo, useState } from 'react' +import { extraUsage } from 'src/commands/extra-usage/index.js' +import { Box, Text } from 'src/ink.js' +import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js' +import { shouldProcessMockLimits } from 'src/services/rateLimitMocking.js' // Used for /mock-limits command +import { + getRateLimitTier, + getSubscriptionType, + isClaudeAISubscriber, +} from 'src/utils/auth.js' +import { hasClaudeAiBillingAccess } from 'src/utils/billing.js' +import { MessageResponse } from '../MessageResponse.js' + type UpsellParams = { - shouldShowUpsell: boolean; - isMax20x: boolean; - isExtraUsageCommandEnabled: boolean; - shouldAutoOpenRateLimitOptionsMenu: boolean; - isTeamOrEnterprise: boolean; - hasBillingAccess: boolean; -}; + shouldShowUpsell: boolean + isMax20x: boolean + isExtraUsageCommandEnabled: boolean + shouldAutoOpenRateLimitOptionsMenu: boolean + isTeamOrEnterprise: boolean + hasBillingAccess: boolean +} + export function getUpsellMessage({ shouldShowUpsell, isMax20x, isExtraUsageCommandEnabled, shouldAutoOpenRateLimitOptionsMenu, isTeamOrEnterprise, - hasBillingAccess + hasBillingAccess, }: UpsellParams): string | null { - if (!shouldShowUpsell) return null; + if (!shouldShowUpsell) return null + if (isMax20x) { if (isExtraUsageCommandEnabled) { - return '/extra-usage to finish what you\u2019re working on.'; + return '/extra-usage to finish what you\u2019re working on.' } - return '/login to switch to an API usage-billed account.'; + return '/login to switch to an API usage-billed account.' } + if (shouldAutoOpenRateLimitOptionsMenu) { - return 'Opening your options\u2026'; + return 'Opening your options\u2026' } + if (!isTeamOrEnterprise && !isExtraUsageCommandEnabled) { - return '/upgrade to increase your usage limit.'; + return '/upgrade to increase your usage limit.' } + if (isTeamOrEnterprise) { - if (!isExtraUsageCommandEnabled) return null; + if (!isExtraUsageCommandEnabled) return null + if (hasBillingAccess) { - return '/extra-usage to finish what you\u2019re working on.'; + return '/extra-usage to finish what you\u2019re working on.' } - return '/extra-usage to request more usage from your admin.'; + + return '/extra-usage to request more usage from your admin.' } - return '/upgrade or /extra-usage to finish what you\u2019re working on.'; + + return '/upgrade or /extra-usage to finish what you\u2019re working on.' } + type RateLimitMessageProps = { - text: string; - onOpenRateLimitOptions?: () => void; -}; -export function RateLimitMessage(t0) { - const $ = _c(16); - const { - text, + text: string + onOpenRateLimitOptions?: () => void +} + +export function RateLimitMessage({ + text, + onOpenRateLimitOptions, +}: RateLimitMessageProps): React.ReactNode { + const subscriptionType = getSubscriptionType() + const rateLimitTier = getRateLimitTier() + const isTeamOrEnterprise = + subscriptionType === 'team' || subscriptionType === 'enterprise' + const isMax20x = rateLimitTier === 'default_claude_max_20x' + // Always show upsell when using /mock-limits command, otherwise show for subscribers + const shouldShowUpsell = shouldProcessMockLimits() || isClaudeAISubscriber() + + const canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x + + const [hasOpenedInteractiveMenu, setHasOpenedInteractiveMenu] = + useState(false) + + // Check actual rate limit status - only auto-open if user is currently rate limited + // AND we've verified this with the API (resetsAt is only set after API response). + // This prevents false alerts when resuming sessions with old rate limit messages. + const claudeAiLimits = useClaudeAiLimits() + const isCurrentlyRateLimited = + claudeAiLimits.status === 'rejected' && + claudeAiLimits.resetsAt !== undefined && + !claudeAiLimits.isUsingOverage + + const shouldAutoOpenRateLimitOptionsMenu = + canSeeRateLimitOptionsUpsell && + !hasOpenedInteractiveMenu && + isCurrentlyRateLimited && onOpenRateLimitOptions - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getSubscriptionType(); - $[0] = t1; - } else { - t1 = $[0]; - } - const subscriptionType = t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getRateLimitTier(); - $[1] = t2; - } else { - t2 = $[1]; - } - const rateLimitTier = t2; - const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise"; - const isMax20x = rateLimitTier === "default_claude_max_20x"; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldProcessMockLimits() || isClaudeAISubscriber(); - $[2] = t3; - } else { - t3 = $[2]; - } - const shouldShowUpsell = t3; - const canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x; - const [hasOpenedInteractiveMenu, setHasOpenedInteractiveMenu] = useState(false); - const claudeAiLimits = useClaudeAiLimits(); - const isCurrentlyRateLimited = claudeAiLimits.status === "rejected" && claudeAiLimits.resetsAt !== undefined && !claudeAiLimits.isUsingOverage; - const shouldAutoOpenRateLimitOptionsMenu = canSeeRateLimitOptionsUpsell && !hasOpenedInteractiveMenu && isCurrentlyRateLimited && onOpenRateLimitOptions; - let t4; - let t5; - if ($[3] !== onOpenRateLimitOptions || $[4] !== shouldAutoOpenRateLimitOptionsMenu) { - t4 = () => { - if (shouldAutoOpenRateLimitOptionsMenu) { - setHasOpenedInteractiveMenu(true); - onOpenRateLimitOptions(); - } - }; - t5 = [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions]; - $[3] = onOpenRateLimitOptions; - $[4] = shouldAutoOpenRateLimitOptionsMenu; - $[5] = t4; - $[6] = t5; - } else { - t4 = $[5]; - t5 = $[6]; - } - useEffect(t4, t5); - let t6; - bb0: { - let t7; - if ($[7] !== shouldAutoOpenRateLimitOptionsMenu) { - t7 = getUpsellMessage({ - shouldShowUpsell, - isMax20x, - isExtraUsageCommandEnabled: extraUsage.isEnabled(), - shouldAutoOpenRateLimitOptionsMenu: !!shouldAutoOpenRateLimitOptionsMenu, - isTeamOrEnterprise, - hasBillingAccess: hasClaudeAiBillingAccess() - }); - $[7] = shouldAutoOpenRateLimitOptionsMenu; - $[8] = t7; - } else { - t7 = $[8]; + + useEffect(() => { + if (shouldAutoOpenRateLimitOptionsMenu) { + setHasOpenedInteractiveMenu(true) + onOpenRateLimitOptions() } - const message = t7; - if (!message) { - t6 = null; - break bb0; - } - let t8; - if ($[9] !== message) { - t8 = {message}; - $[9] = message; - $[10] = t8; - } else { - t8 = $[10]; - } - t6 = t8; - } - const upsell = t6; - let t7; - if ($[11] !== text) { - t7 = {text}; - $[11] = text; - $[12] = t7; - } else { - t7 = $[12]; - } - const t8 = hasOpenedInteractiveMenu ? null : upsell; - let t9; - if ($[13] !== t7 || $[14] !== t8) { - t9 = {t7}{t8}; - $[13] = t7; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - return t9; + }, [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions]) + + const upsell = useMemo(() => { + const message = getUpsellMessage({ + shouldShowUpsell, + isMax20x, + isExtraUsageCommandEnabled: extraUsage.isEnabled(), + shouldAutoOpenRateLimitOptionsMenu: !!shouldAutoOpenRateLimitOptionsMenu, + isTeamOrEnterprise, + hasBillingAccess: hasClaudeAiBillingAccess(), + }) + if (!message) return null + return {message} + }, [ + shouldShowUpsell, + isMax20x, + isTeamOrEnterprise, + shouldAutoOpenRateLimitOptionsMenu, + ]) + + return ( + + + {text} + {hasOpenedInteractiveMenu ? null : upsell} + + + ) } diff --git a/src/components/messages/ShutdownMessage.tsx b/src/components/messages/ShutdownMessage.tsx index 257f29a7c..82e0d59e1 100644 --- a/src/components/messages/ShutdownMessage.tsx +++ b/src/components/messages/ShutdownMessage.tsx @@ -1,112 +1,113 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { isShutdownApproved, isShutdownRejected, isShutdownRequest, type ShutdownRejectedMessage, type ShutdownRequestMessage } from '../../utils/teammateMailbox.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + isShutdownApproved, + isShutdownRejected, + isShutdownRequest, + type ShutdownRejectedMessage, + type ShutdownRequestMessage, +} from '../../utils/teammateMailbox.js' + type ShutdownRequestProps = { - request: ShutdownRequestMessage; -}; + request: ShutdownRequestMessage +} /** * Renders a shutdown request with a warning-colored border. */ -export function ShutdownRequestDisplay(t0) { - const $ = _c(7); - const { - request - } = t0; - let t1; - if ($[0] !== request.from) { - t1 = Shutdown request from {request.from}; - $[0] = request.from; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== request.reason) { - t2 = request.reason && Reason: {request.reason}; - $[2] = request.reason; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1 || $[5] !== t2) { - t3 = {t1}{t2}; - $[4] = t1; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; +export function ShutdownRequestDisplay({ + request, +}: ShutdownRequestProps): React.ReactNode { + return ( + + + + + Shutdown request from {request.from} + + + {request.reason && ( + + Reason: {request.reason} + + )} + + + ) } + type ShutdownRejectedProps = { - response: ShutdownRejectedMessage; -}; + response: ShutdownRejectedMessage +} /** * Renders a shutdown rejected message with a subtle (grey) border. */ -export function ShutdownRejectedDisplay(t0) { - const $ = _c(8); - const { - response - } = t0; - let t1; - if ($[0] !== response.from) { - t1 = Shutdown rejected by {response.from}; - $[0] = response.from; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== response.reason) { - t2 = Reason: {response.reason}; - $[2] = response.reason; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Teammate is continuing to work. You may request shutdown again later.; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t1 || $[6] !== t2) { - t4 = {t1}{t2}{t3}; - $[5] = t1; - $[6] = t2; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; +export function ShutdownRejectedDisplay({ + response, +}: ShutdownRejectedProps): React.ReactNode { + return ( + + + + Shutdown rejected by {response.from} + + + Reason: {response.reason} + + + + Teammate is continuing to work. You may request shutdown again + later. + + + + + ) } /** * Try to parse and render a shutdown message from raw content. * Returns the rendered component if it's a shutdown message, null otherwise. */ -export function tryRenderShutdownMessage(content: string): React.ReactNode | null { - const request = isShutdownRequest(content); +export function tryRenderShutdownMessage( + content: string, +): React.ReactNode | null { + const request = isShutdownRequest(content) if (request) { - return ; + return } // Shutdown approved is handled inline by the caller — skip it here if (isShutdownApproved(content)) { - return null; + return null } - const rejected = isShutdownRejected(content); + + const rejected = isShutdownRejected(content) if (rejected) { - return ; + return } - return null; + + return null } /** @@ -115,17 +116,20 @@ export function tryRenderShutdownMessage(content: string): React.ReactNode | nul * Returns null if the content is not a shutdown message. */ export function getShutdownMessageSummary(content: string): string | null { - const request = isShutdownRequest(content); + const request = isShutdownRequest(content) if (request) { - return `[Shutdown Request from ${request.from}]${request.reason ? ` ${request.reason}` : ''}`; + return `[Shutdown Request from ${request.from}]${request.reason ? ` ${request.reason}` : ''}` } - const approved = isShutdownApproved(content); + + const approved = isShutdownApproved(content) if (approved) { - return `[Shutdown Approved] ${approved.from} is now exiting`; + return `[Shutdown Approved] ${approved.from} is now exiting` } - const rejected = isShutdownRejected(content); + + const rejected = isShutdownRejected(content) if (rejected) { - return `[Shutdown Rejected] ${rejected.from}: ${rejected.reason}`; + return `[Shutdown Rejected] ${rejected.from}: ${rejected.reason}` } - return null; + + return null } diff --git a/src/components/messages/SystemAPIErrorMessage.tsx b/src/components/messages/SystemAPIErrorMessage.tsx index 6bf5e60d6..c87dec717 100644 --- a/src/components/messages/SystemAPIErrorMessage.tsx +++ b/src/components/messages/SystemAPIErrorMessage.tsx @@ -1,140 +1,64 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { Box, Text } from 'src/ink.js'; -import { formatAPIError } from 'src/services/api/errorUtils.js'; -import type { SystemAPIErrorMessage } from 'src/types/message.js'; -import { useInterval } from 'usehooks-ts'; -import { CtrlOToExpand } from '../CtrlOToExpand.js'; -import { MessageResponse } from '../MessageResponse.js'; -const MAX_API_ERROR_CHARS = 1000; +import * as React from 'react' +import { useState } from 'react' +import { Box, Text } from 'src/ink.js' +import { formatAPIError } from 'src/services/api/errorUtils.js' +import type { SystemAPIErrorMessage } from 'src/types/message.js' +import { useInterval } from 'usehooks-ts' +import { CtrlOToExpand } from '../CtrlOToExpand.js' +import { MessageResponse } from '../MessageResponse.js' + +const MAX_API_ERROR_CHARS = 1000 + type Props = { - message: SystemAPIErrorMessage; - verbose: boolean; -}; -export function SystemAPIErrorMessage(t0) { - const $ = _c(33); - const { - message: t1, - verbose - } = t0; - const { - retryAttempt, - error, - retryInMs, - maxRetries - } = t1; - const hidden = true && retryAttempt < 4; - const [countdownMs, setCountdownMs] = useState(0); - const done = countdownMs >= retryInMs; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => setCountdownMs(_temp); - $[0] = t2; - } else { - t2 = $[0]; - } - useInterval(t2, hidden || done ? null : 1000); + message: SystemAPIErrorMessage + verbose: boolean +} + +export function SystemAPIErrorMessage({ + message: { retryAttempt, error, retryInMs, maxRetries }, + verbose, +}: Props): React.ReactNode { + // Hidden for early retries on external builds to avoid noise. Compute before + // useInterval so we never register a timer that just drives a null render. + const hidden = process.env.USER_TYPE === 'external' && retryAttempt < 4 + + const [countdownMs, setCountdownMs] = useState(0) + const done = countdownMs >= retryInMs + useInterval( + () => setCountdownMs(ms => ms + 1000), + hidden || done ? null : 1000, + ) + if (hidden) { - return null; - } - let t3; - if ($[1] !== countdownMs || $[2] !== retryInMs) { - t3 = Math.round((retryInMs - countdownMs) / 1000); - $[1] = countdownMs; - $[2] = retryInMs; - $[3] = t3; - } else { - t3 = $[3]; - } - const retryInSecondsLive = Math.max(0, t3); - let T0; - let T1; - let T2; - let t4; - let t5; - let t6; - let truncated; - if ($[4] !== error || $[5] !== verbose) { - const formatted = formatAPIError(error); - truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS; - T2 = MessageResponse; - T1 = Box; - t6 = "column"; - T0 = Text; - t4 = "error"; - t5 = truncated ? formatted.slice(0, MAX_API_ERROR_CHARS) + "\u2026" : formatted; - $[4] = error; - $[5] = verbose; - $[6] = T0; - $[7] = T1; - $[8] = T2; - $[9] = t4; - $[10] = t5; - $[11] = t6; - $[12] = truncated; - } else { - T0 = $[6]; - T1 = $[7]; - T2 = $[8]; - t4 = $[9]; - t5 = $[10]; - t6 = $[11]; - truncated = $[12]; - } - let t7; - if ($[13] !== T0 || $[14] !== t4 || $[15] !== t5) { - t7 = {t5}; - $[13] = T0; - $[14] = t4; - $[15] = t5; - $[16] = t7; - } else { - t7 = $[16]; + return null } - let t8; - if ($[17] !== truncated) { - t8 = truncated && ; - $[17] = truncated; - $[18] = t8; - } else { - t8 = $[18]; - } - const t9 = retryInSecondsLive === 1 ? "second" : "seconds"; - let t10; - if ($[19] !== maxRetries || $[20] !== retryAttempt || $[21] !== retryInSecondsLive || $[22] !== t9) { - t10 = Retrying in {retryInSecondsLive}{" "}{t9}… (attempt{" "}{retryAttempt}/{maxRetries}){process.env.API_TIMEOUT_MS ? ` · API_TIMEOUT_MS=${process.env.API_TIMEOUT_MS}ms, try increasing it` : ""}; - $[19] = maxRetries; - $[20] = retryAttempt; - $[21] = retryInSecondsLive; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - let t11; - if ($[24] !== T1 || $[25] !== t10 || $[26] !== t6 || $[27] !== t7 || $[28] !== t8) { - t11 = {t7}{t8}{t10}; - $[24] = T1; - $[25] = t10; - $[26] = t6; - $[27] = t7; - $[28] = t8; - $[29] = t11; - } else { - t11 = $[29]; - } - let t12; - if ($[30] !== T2 || $[31] !== t11) { - t12 = {t11}; - $[30] = T2; - $[31] = t11; - $[32] = t12; - } else { - t12 = $[32]; - } - return t12; -} -function _temp(ms) { - return ms + 1000; + + const retryInSecondsLive = Math.max( + 0, + Math.round((retryInMs - countdownMs) / 1000), + ) + + const formatted = formatAPIError(error) + const truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS + + return ( + + + + {truncated + ? formatted.slice(0, MAX_API_ERROR_CHARS) + '…' + : formatted} + + {truncated && } + + Retrying in {retryInSecondsLive}{' '} + {retryInSecondsLive === 1 ? 'second' : 'seconds'}… (attempt{' '} + {retryAttempt}/{maxRetries}) + {process.env.API_TIMEOUT_MS + ? ` · API_TIMEOUT_MS=${process.env.API_TIMEOUT_MS}ms, try increasing it` + : ''} + + + + ) } diff --git a/src/components/messages/SystemTextMessage.tsx b/src/components/messages/SystemTextMessage.tsx index 169654fca..7d05f054a 100644 --- a/src/components/messages/SystemTextMessage.tsx +++ b/src/components/messages/SystemTextMessage.tsx @@ -1,826 +1,509 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { Box, Text, type TextProps } from '../../ink.js'; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useState } from 'react'; -import sample from 'lodash-es/sample.js'; -import { BLACK_CIRCLE, REFERENCE_MARK, TEARDROP_ASTERISK } from '../../constants/figures.js'; -import figures from 'figures'; -import { basename } from 'path'; -import { MessageResponse } from '../MessageResponse.js'; -import { FilePathLink } from '../FilePathLink.js'; -import { openPath } from '../../utils/browser.js'; +import { Box, Text, type TextProps } from '../../ink.js' +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useState } from 'react' +import sample from 'lodash-es/sample.js' +import { + BLACK_CIRCLE, + REFERENCE_MARK, + TEARDROP_ASTERISK, +} from '../../constants/figures.js' +import figures from 'figures' +import { basename } from 'path' +import { MessageResponse } from '../MessageResponse.js' +import { FilePathLink } from '../FilePathLink.js' +import { openPath } from '../../utils/browser.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const teamMemSaved = feature('TEAMMEM') ? require('./teamMemSaved.js') as typeof import('./teamMemSaved.js') : null; +const teamMemSaved = feature('TEAMMEM') + ? (require('./teamMemSaved.js') as typeof import('./teamMemSaved.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import type { SystemMessage, SystemStopHookSummaryMessage, SystemBridgeStatusMessage, SystemTurnDurationMessage, SystemThinkingMessage, SystemMemorySavedMessage } from '../../types/message.js'; -import { SystemAPIErrorMessage } from './SystemAPIErrorMessage.js'; -import { formatDuration, formatNumber, formatSecondsShort } from '../../utils/format.js'; -import { getGlobalConfig } from '../../utils/config.js'; -import Link from '../../ink/components/Link.js'; -import ThemedText from '../design-system/ThemedText.js'; -import { CtrlOToExpand } from '../CtrlOToExpand.js'; -import { useAppStateStore } from '../../state/AppState.js'; -import { isBackgroundTask, type TaskState } from '../../tasks/types.js'; -import { getPillLabel } from '../../tasks/pillLabel.js'; -import { useSelectedMessageBg } from '../messageActions.js'; +import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { + SystemMessage, + SystemStopHookSummaryMessage, + SystemBridgeStatusMessage, + SystemTurnDurationMessage, + SystemThinkingMessage, + SystemMemorySavedMessage, +} from '../../types/message.js' +import { SystemAPIErrorMessage } from './SystemAPIErrorMessage.js' +import { + formatDuration, + formatNumber, + formatSecondsShort, +} from '../../utils/format.js' +import { getGlobalConfig } from '../../utils/config.js' +import Link from '../../ink/components/Link.js' +import ThemedText from '../design-system/ThemedText.js' +import { CtrlOToExpand } from '../CtrlOToExpand.js' +import { useAppStateStore } from '../../state/AppState.js' +import { isBackgroundTask, type TaskState } from '../../tasks/types.js' +import { getPillLabel } from '../../tasks/pillLabel.js' +import { useSelectedMessageBg } from '../messageActions.js' + type Props = { - message: SystemMessage; - addMargin: boolean; - verbose: boolean; - isTranscriptMode?: boolean; -}; -export function SystemTextMessage(t0) { - const $ = _c(51); - const { - message, - addMargin, - verbose, - isTranscriptMode - } = t0; - const bg = useSelectedMessageBg(); - if (message.subtype === "turn_duration") { - let t1; - if ($[0] !== addMargin || $[1] !== message) { - t1 = ; - $[0] = addMargin; - $[1] = message; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - if (message.subtype === "memory_saved") { - let t1; - if ($[3] !== addMargin || $[4] !== message) { - t1 = ; - $[3] = addMargin; - $[4] = message; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } - if (message.subtype === "away_summary") { - const t1 = addMargin ? 1 : 0; - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {REFERENCE_MARK}; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== message.content) { - t3 = {message.content}; - $[7] = message.content; - $[8] = t3; - } else { - t3 = $[8]; - } - let t4; - if ($[9] !== bg || $[10] !== t1 || $[11] !== t3) { - t4 = {t2}{t3}; - $[9] = bg; - $[10] = t1; - $[11] = t3; - $[12] = t4; - } else { - t4 = $[12]; - } - return t4; - } - if (message.subtype === "agents_killed") { - const t1 = addMargin ? 1 : 0; - let t2; - let t3; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {BLACK_CIRCLE}; - t3 = All background agents stopped; - $[13] = t2; - $[14] = t3; - } else { - t2 = $[13]; - t3 = $[14]; - } - let t4; - if ($[15] !== bg || $[16] !== t1) { - t4 = {t2}{t3}; - $[15] = bg; - $[16] = t1; - $[17] = t4; - } else { - t4 = $[17]; - } - return t4; - } - if (message.subtype === "thinking") { - return null; - } - if (message.subtype === "bridge_status") { - let t1; - if ($[18] !== addMargin || $[19] !== message) { - t1 = ; - $[18] = addMargin; - $[19] = message; - $[20] = t1; - } else { - t1 = $[20]; - } - return t1; - } - if (message.subtype === "scheduled_task_fire") { - const t1 = addMargin ? 1 : 0; - let t2; - if ($[21] !== message.content) { - t2 = {TEARDROP_ASTERISK} {message.content}; - $[21] = message.content; - $[22] = t2; - } else { - t2 = $[22]; - } - let t3; - if ($[23] !== bg || $[24] !== t1 || $[25] !== t2) { - t3 = {t2}; - $[23] = bg; - $[24] = t1; - $[25] = t2; - $[26] = t3; - } else { - t3 = $[26]; - } - return t3; - } - if (message.subtype === "permission_retry") { - const t1 = addMargin ? 1 : 0; - let t2; - let t3; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {TEARDROP_ASTERISK} ; - t3 = Allowed ; - $[27] = t2; - $[28] = t3; - } else { - t2 = $[27]; - t3 = $[28]; - } - let t4; - if ($[29] !== message.commands) { - t4 = message.commands.join(", "); - $[29] = message.commands; - $[30] = t4; - } else { - t4 = $[30]; - } - let t5; - if ($[31] !== t4) { - t5 = {t4}; - $[31] = t4; - $[32] = t5; - } else { - t5 = $[32]; - } - let t6; - if ($[33] !== bg || $[34] !== t1 || $[35] !== t5) { - t6 = {t2}{t3}{t5}; - $[33] = bg; - $[34] = t1; - $[35] = t5; - $[36] = t6; - } else { - t6 = $[36]; - } - return t6; - } - const isStopHookSummary = message.subtype === "stop_hook_summary"; - if (!isStopHookSummary && !verbose && message.level === "info") { - return null; - } - if (message.subtype === "api_error") { - let t1; - if ($[37] !== message || $[38] !== verbose) { - t1 = ; - $[37] = message; - $[38] = verbose; - $[39] = t1; - } else { - t1 = $[39]; - } - return t1; - } - if (message.subtype === "stop_hook_summary") { - let t1; - if ($[40] !== addMargin || $[41] !== isTranscriptMode || $[42] !== message || $[43] !== verbose) { - t1 = ; - $[40] = addMargin; - $[41] = isTranscriptMode; - $[42] = message; - $[43] = verbose; - $[44] = t1; - } else { - t1 = $[44]; + message: SystemMessage + addMargin: boolean + verbose: boolean + isTranscriptMode?: boolean +} + +export function SystemTextMessage({ + message, + addMargin, + verbose, + isTranscriptMode, +}: Props): React.ReactNode { + const bg = useSelectedMessageBg() + // Turn duration messages are always shown in grey + if (message.subtype === 'turn_duration') { + return + } + + if (message.subtype === 'memory_saved') { + return + } + + if (message.subtype === 'away_summary') { + return ( + + + {REFERENCE_MARK} + + {message.content} + + ) + } + + // Agents killed confirmation + if (message.subtype === 'agents_killed') { + return ( + + + {BLACK_CIRCLE} + + All background agents stopped + + ) + } + + // Thinking messages are subtle, like turn duration (ant-only) + if (message.subtype === 'thinking') { + if (process.env.USER_TYPE === 'ant') { + return } - return t1; - } - const content = message.content; - if (typeof content !== "string") { - return null; - } - const t1 = message.level !== "info"; - const t2 = message.level === "warning" ? "warning" : undefined; - const t3 = message.level === "info"; - let t4; - if ($[45] !== addMargin || $[46] !== content || $[47] !== t1 || $[48] !== t2 || $[49] !== t3) { - t4 = ; - $[45] = addMargin; - $[46] = content; - $[47] = t1; - $[48] = t2; - $[49] = t3; - $[50] = t4; - } else { - t4 = $[50]; - } - return t4; + return null + } + + + if (message.subtype === 'bridge_status') { + return + } + + if (message.subtype === 'scheduled_task_fire') { + return ( + + + {TEARDROP_ASTERISK} {message.content} + + + ) + } + + if (message.subtype === 'permission_retry') { + return ( + + {TEARDROP_ASTERISK} + Allowed + {message.commands.join(', ')} + + ) + } + + // Stop hook summaries should always be visible + const isStopHookSummary = message.subtype === 'stop_hook_summary' + + if (!isStopHookSummary && !verbose && message.level === 'info') { + return null + } + + if (message.subtype === 'api_error') { + return + } + + if (message.subtype === 'stop_hook_summary') { + return ( + + ) + } + + const content = message.content + // In case the event doesn't have a content + // validation, so content can be undefined at runtime despite the types. + if (typeof content !== 'string') { + return null + } + return ( + + + + ) } -function StopHookSummaryMessage(t0) { - const $ = _c(47); - const { - message, - addMargin, - verbose, - isTranscriptMode - } = t0; - const bg = useSelectedMessageBg(); + +function StopHookSummaryMessage({ + message, + addMargin, + verbose, + isTranscriptMode, +}: { + message: SystemStopHookSummaryMessage + addMargin: boolean + verbose: boolean + isTranscriptMode?: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() const { hookCount, hookInfos, hookErrors, preventedContinuation, - stopReason - } = message; - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== hookInfos || $[1] !== message.totalDurationMs) { - t1 = message.totalDurationMs ?? hookInfos.reduce(_temp, 0); - $[0] = hookInfos; - $[1] = message.totalDurationMs; - $[2] = t1; - } else { - t1 = $[2]; - } - const totalDurationMs = t1; + stopReason, + } = message + const { columns } = useTerminalSize() + + // Prefer wall-clock time when available (hooks run in parallel) + const totalDurationMs = + message.totalDurationMs ?? + hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0) + const isAnt = process.env.USER_TYPE === 'ant' + + // Only show summary if there are errors or continuation was prevented + // For ants: also show when hooks took > 500ms + // Non-stop hooks (e.g. PreToolUse) are pre-filtered by the caller if (hookErrors.length === 0 && !preventedContinuation && !message.hookLabel) { - if (true || totalDurationMs < HOOK_TIMING_DISPLAY_THRESHOLD_MS) { - return null; + if (!isAnt || totalDurationMs < HOOK_TIMING_DISPLAY_THRESHOLD_MS) { + return null } } - let t2; - if ($[3] !== totalDurationMs) { - t2 = false && totalDurationMs > 0 ? ` (${formatSecondsShort(totalDurationMs)})` : ""; - $[3] = totalDurationMs; - $[4] = t2; - } else { - t2 = $[4]; - } - const totalStr = t2; + + const totalStr = + isAnt && totalDurationMs > 0 + ? ` (${formatSecondsShort(totalDurationMs)})` + : '' + // Non-stop hooks (e.g. PreToolUse) render as a child line without bullet if (message.hookLabel) { - const t3 = hookCount === 1 ? "hook" : "hooks"; - let t4; - if ($[5] !== hookCount || $[6] !== message.hookLabel || $[7] !== t3 || $[8] !== totalStr) { - t4 = {" \u23BF "}Ran {hookCount} {message.hookLabel}{" "}{t3}{totalStr}; - $[5] = hookCount; - $[6] = message.hookLabel; - $[7] = t3; - $[8] = totalStr; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== hookInfos || $[11] !== isTranscriptMode) { - t5 = isTranscriptMode && hookInfos.map(_temp2); - $[10] = hookInfos; - $[11] = isTranscriptMode; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - return t6; - } - const t3 = addMargin ? 1 : 0; - let t4; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {BLACK_CIRCLE}; - $[16] = t4; - } else { - t4 = $[16]; - } - const t5 = columns - 10; - let t6; - if ($[17] !== hookCount) { - t6 = {hookCount}; - $[17] = hookCount; - $[18] = t6; - } else { - t6 = $[18]; - } - const t7 = message.hookLabel ?? "stop"; - const t8 = hookCount === 1 ? "hook" : "hooks"; - let t9; - if ($[19] !== hookInfos || $[20] !== verbose) { - t9 = !verbose && hookInfos.length > 0 && <>{" "}; - $[19] = hookInfos; - $[20] = verbose; - $[21] = t9; - } else { - t9 = $[21]; - } - let t10; - if ($[22] !== t6 || $[23] !== t7 || $[24] !== t8 || $[25] !== t9 || $[26] !== totalStr) { - t10 = Ran {t6} {t7}{" "}{t8}{totalStr}{t9}; - $[22] = t6; - $[23] = t7; - $[24] = t8; - $[25] = t9; - $[26] = totalStr; - $[27] = t10; - } else { - t10 = $[27]; - } - let t11; - if ($[28] !== hookInfos || $[29] !== verbose) { - t11 = verbose && hookInfos.length > 0 && hookInfos.map(_temp3); - $[28] = hookInfos; - $[29] = verbose; - $[30] = t11; - } else { - t11 = $[30]; - } - let t12; - if ($[31] !== preventedContinuation || $[32] !== stopReason) { - t12 = preventedContinuation && stopReason && ⎿  {stopReason}; - $[31] = preventedContinuation; - $[32] = stopReason; - $[33] = t12; - } else { - t12 = $[33]; - } - let t13; - if ($[34] !== hookErrors || $[35] !== message.hookLabel) { - t13 = hookErrors.length > 0 && hookErrors.map((err, idx_1) => ⎿  {message.hookLabel ?? "Stop"} hook error: {err}); - $[34] = hookErrors; - $[35] = message.hookLabel; - $[36] = t13; - } else { - t13 = $[36]; - } - let t14; - if ($[37] !== t10 || $[38] !== t11 || $[39] !== t12 || $[40] !== t13 || $[41] !== t5) { - t14 = {t10}{t11}{t12}{t13}; - $[37] = t10; - $[38] = t11; - $[39] = t12; - $[40] = t13; - $[41] = t5; - $[42] = t14; - } else { - t14 = $[42]; - } - let t15; - if ($[43] !== bg || $[44] !== t14 || $[45] !== t3) { - t15 = {t4}{t14}; - $[43] = bg; - $[44] = t14; - $[45] = t3; - $[46] = t15; - } else { - t15 = $[46]; - } - return t15; -} -function _temp3(info_0, idx_0) { - const durationStr_0 = false && info_0.durationMs !== undefined ? ` (${formatSecondsShort(info_0.durationMs)})` : ""; - return ⎿  {info_0.command === "prompt" ? `prompt: ${info_0.promptText || ""}` : info_0.command}{durationStr_0}; + return ( + + + {' ⎿ '}Ran {hookCount} {message.hookLabel}{' '} + {hookCount === 1 ? 'hook' : 'hooks'} + {totalStr} + + {isTranscriptMode && + hookInfos.map((info, idx) => { + const durationStr = + isAnt && info.durationMs !== undefined + ? ` (${formatSecondsShort(info.durationMs)})` + : '' + return ( + + {' ⎿ '} + {info.command === 'prompt' + ? `prompt: ${info.promptText || ''}` + : info.command} + {durationStr} + + ) + })} + + ) + } + + return ( + + + {BLACK_CIRCLE} + + + + Ran {hookCount} {message.hookLabel ?? 'stop'}{' '} + {hookCount === 1 ? 'hook' : 'hooks'} + {totalStr} + {!verbose && hookInfos.length > 0 && ( + <> + {' '} + + + )} + + {verbose && + hookInfos.length > 0 && + hookInfos.map((info, idx) => { + const durationStr = + isAnt && info.durationMs !== undefined + ? ` (${formatSecondsShort(info.durationMs)})` + : '' + return ( + + ⎿   + {info.command === 'prompt' + ? `prompt: ${info.promptText || ''}` + : info.command} + {durationStr} + + ) + })} + {preventedContinuation && stopReason && ( + + ⎿   + {stopReason} + + )} + {hookErrors.length > 0 && + hookErrors.map((err, idx) => ( + + ⎿   + {message.hookLabel ?? 'Stop'} hook error: {err} + + ))} + + + ) } -function _temp2(info, idx) { - const durationStr = false && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : ""; - return {" \u23BF "}{info.command === "prompt" ? `prompt: ${info.promptText || ""}` : info.command}{durationStr}; + +function SystemTextMessageInner({ + content, + addMargin, + dot, + color, + dimColor, +}: { + content: string + addMargin: boolean + dot: boolean + color?: TextProps['color'] + dimColor?: boolean +}): React.ReactNode { + const { columns } = useTerminalSize() + const bg = useSelectedMessageBg() + + return ( + + {dot && ( + + + {BLACK_CIRCLE} + + + )} + + + {content.trim()} + + + + ) } -function _temp(sum, h) { - return sum + (h.durationMs ?? 0); -} -function SystemTextMessageInner(t0) { - const $ = _c(18); - const { - content, - addMargin, - dot, - color, - dimColor - } = t0; - const { - columns - } = useTerminalSize(); - const bg = useSelectedMessageBg(); - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] !== color || $[1] !== dimColor || $[2] !== dot) { - t2 = dot && {BLACK_CIRCLE}; - $[0] = color; - $[1] = dimColor; - $[2] = dot; - $[3] = t2; - } else { - t2 = $[3]; - } - const t3 = columns - 10; - let t4; - if ($[4] !== content) { - t4 = content.trim(); - $[4] = content; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== color || $[7] !== dimColor || $[8] !== t4) { - t5 = {t4}; - $[6] = color; - $[7] = dimColor; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t3 || $[11] !== t5) { - t6 = {t5}; - $[10] = t3; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== bg || $[14] !== t1 || $[15] !== t2 || $[16] !== t6) { - t7 = {t2}{t6}; - $[13] = bg; - $[14] = t1; - $[15] = t2; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; - } - return t7; -} -function TurnDurationMessage(t0) { - const $ = _c(17); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const [verb] = useState(_temp4); - const store = useAppStateStore(); - let t1; - if ($[0] !== store) { - t1 = () => { - const tasks = store.getState().tasks; - const running = (Object.values(tasks ?? {}) as TaskState[]).filter(isBackgroundTask); - return running.length > 0 ? getPillLabel(running) : null; - }; - $[0] = store; - $[1] = t1; - } else { - t1 = $[1]; - } - const [backgroundTaskSummary] = useState(t1); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getGlobalConfig().showTurnDuration ?? true; - $[2] = t2; - } else { - t2 = $[2]; - } - const showTurnDuration = t2; - let t3; - if ($[3] !== message.durationMs) { - t3 = formatDuration(message.durationMs); - $[3] = message.durationMs; - $[4] = t3; - } else { - t3 = $[4]; - } - const duration = t3; - const hasBudget = message.budgetLimit !== undefined; - let t4; - bb0: { - if (!hasBudget) { - t4 = ""; - break bb0; - } - const tokens = message.budgetTokens; - const limit = message.budgetLimit; - let t5; - if ($[5] !== limit || $[6] !== tokens) { - t5 = tokens >= limit ? `${formatNumber(tokens)} used (${formatNumber(limit)} min ${figures.tick})` : `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round(tokens / limit * 100)}%)`; - $[5] = limit; - $[6] = tokens; - $[7] = t5; - } else { - t5 = $[7]; - } - const usage = t5; - const nudges = message.budgetNudges > 0 ? ` \u00B7 ${message.budgetNudges} ${message.budgetNudges === 1 ? "nudge" : "nudges"}` : ""; - t4 = `${showTurnDuration ? " \xB7 " : ""}${usage}${nudges}`; - } - const budgetSuffix = t4; + +function TurnDurationMessage({ + message, + addMargin, +}: { + message: SystemTurnDurationMessage + addMargin: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() + const [verb] = useState(() => sample(TURN_COMPLETION_VERBS) ?? 'Worked') + const store = useAppStateStore() + const [backgroundTaskSummary] = useState(() => { + const tasks = store.getState().tasks + const running = (Object.values(tasks ?? {}) as TaskState[]).filter( + isBackgroundTask, + ) + return running.length > 0 ? getPillLabel(running) : null + }) + + const showTurnDuration = getGlobalConfig().showTurnDuration ?? true + + const duration = formatDuration(message.durationMs) + const hasBudget = message.budgetLimit !== undefined + const budgetSuffix = (() => { + if (!hasBudget) return '' + const tokens = message.budgetTokens! + const limit = message.budgetLimit! + const usage = + tokens >= limit + ? `${formatNumber(tokens)} used (${formatNumber(limit)} min ${figures.tick})` + : `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round((tokens / limit) * 100)}%)` + const nudges = + message.budgetNudges! > 0 + ? ` \u00B7 ${message.budgetNudges} ${message.budgetNudges === 1 ? 'nudge' : 'nudges'}` + : '' + return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}` + })() + if (!showTurnDuration && !hasBudget) { - return null; - } - const t5 = addMargin ? 1 : 0; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {TEARDROP_ASTERISK}; - $[8] = t6; - } else { - t6 = $[8]; - } - const t7 = showTurnDuration && `${verb} for ${duration}`; - const t8 = backgroundTaskSummary && ` \u00B7 ${backgroundTaskSummary} still running`; - let t9; - if ($[9] !== budgetSuffix || $[10] !== t7 || $[11] !== t8) { - t9 = {t7}{budgetSuffix}{t8}; - $[9] = budgetSuffix; - $[10] = t7; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] !== bg || $[14] !== t5 || $[15] !== t9) { - t10 = {t6}{t9}; - $[13] = bg; - $[14] = t5; - $[15] = t9; - $[16] = t10; - } else { - t10 = $[16]; - } - return t10; -} -function _temp4() { - return sample(TURN_COMPLETION_VERBS) ?? "Worked"; -} -function MemorySavedMessage(t0) { - const $ = _c(16); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const { - writtenPaths - } = message; - let t1; - if ($[0] !== message) { - t1 = feature("TEAMMEM") ? teamMemSaved.teamMemSavedPart(message) : null; - $[0] = message; - $[1] = t1; - } else { - t1 = $[1]; - } - const team = t1; - const privateCount = writtenPaths.length - (team?.count ?? 0); - const t2 = privateCount > 0 ? `${privateCount} ${privateCount === 1 ? "memory" : "memories"}` : null; - const t3 = team?.segment; - let t4; - if ($[2] !== t2 || $[3] !== t3) { - t4 = [t2, t3].filter(Boolean); - $[2] = t2; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - const parts = t4; - const t5 = addMargin ? 1 : 0; - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {BLACK_CIRCLE}; - $[5] = t6; - } else { - t6 = $[5]; - } - const t7 = message.verb ?? "Saved"; - const t8 = parts.join(" \xB7 "); - let t9; - if ($[6] !== t7 || $[7] !== t8) { - t9 = {t6}{t7} {t8}; - $[6] = t7; - $[7] = t8; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] !== writtenPaths) { - t10 = writtenPaths.map(_temp5); - $[9] = writtenPaths; - $[10] = t10; - } else { - t10 = $[10]; - } - let t11; - if ($[11] !== bg || $[12] !== t10 || $[13] !== t5 || $[14] !== t9) { - t11 = {t9}{t10}; - $[11] = bg; - $[12] = t10; - $[13] = t5; - $[14] = t9; - $[15] = t11; - } else { - t11 = $[15]; - } - return t11; + return null + } + + return ( + + + {TEARDROP_ASTERISK} + + + {showTurnDuration && `${verb} for ${duration}`} + {budgetSuffix} + {backgroundTaskSummary && + ` \u00B7 ${backgroundTaskSummary} still running`} + + + ) } -function _temp5(p) { - return ; + +function MemorySavedMessage({ + message, + addMargin, +}: { + message: SystemMemorySavedMessage + addMargin: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() + const { writtenPaths } = message + const team = feature('TEAMMEM') + ? teamMemSaved!.teamMemSavedPart(message) + : null + const privateCount = writtenPaths.length - (team?.count ?? 0) + const parts = [ + privateCount > 0 + ? `${privateCount} ${privateCount === 1 ? 'memory' : 'memories'}` + : null, + team?.segment, + ].filter(Boolean) + return ( + + + + {BLACK_CIRCLE} + + + {message.verb ?? 'Saved'} {parts.join(' \u00B7 ')} + + + {writtenPaths.map(p => ( + + ))} + + ) } -function MemoryFileRow(t0) { - const $ = _c(16); - const { - path - } = t0; - const [hover, setHover] = useState(false); - let t1; - if ($[0] !== path) { - t1 = () => void openPath(path); - $[0] = path; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => setHover(true); - t3 = () => setHover(false); - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - const t4 = !hover; - let t5; - if ($[4] !== path) { - t5 = basename(path); - $[4] = path; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== path || $[7] !== t5) { - t6 = {t5}; - $[6] = path; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== hover || $[10] !== t4 || $[11] !== t6) { - t7 = {t6}; - $[9] = hover; - $[10] = t4; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== t1 || $[14] !== t7) { - t8 = {t7}; - $[13] = t1; - $[14] = t7; - $[15] = t8; - } else { - t8 = $[15]; - } - return t8; + +function MemoryFileRow({ path }: { path: string }): React.ReactNode { + const [hover, setHover] = useState(false) + return ( + + void openPath(path)} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {basename(path)} + + + + ) } -function ThinkingMessage(t0) { - const $ = _c(7); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {TEARDROP_ASTERISK}; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== message.content) { - t3 = {message.content}; - $[1] = message.content; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== bg || $[4] !== t1 || $[5] !== t3) { - t4 = {t2}{t3}; - $[3] = bg; - $[4] = t1; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - return t4; + +function ThinkingMessage({ + message, + addMargin, +}: { + message: SystemThinkingMessage + addMargin: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() + return ( + + + {TEARDROP_ASTERISK} + + {message.content} + + ) } -function BridgeStatusMessage(t0) { - const $ = _c(13); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t3 = /remote-control is active. Code in CLI or at; - $[1] = t3; - } else { - t3 = $[1]; - } - let t4; - if ($[2] !== message.url) { - t4 = {message.url}; - $[2] = message.url; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== message.upgradeNudge) { - t5 = message.upgradeNudge && ⎿ {message.upgradeNudge}; - $[4] = message.upgradeNudge; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== t4 || $[7] !== t5) { - t6 = {t3}{t4}{t5}; - $[6] = t4; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== bg || $[10] !== t1 || $[11] !== t6) { - t7 = {t2}{t6}; - $[9] = bg; - $[10] = t1; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; - } - return t7; + +function BridgeStatusMessage({ + message, + addMargin, +}: { + message: SystemBridgeStatusMessage + addMargin: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() + return ( + + + + + /remote-control is active. + Code in CLI or at + + {message.url} + {message.upgradeNudge && ⎿ {message.upgradeNudge}} + + + ) } diff --git a/src/components/messages/TaskAssignmentMessage.tsx b/src/components/messages/TaskAssignmentMessage.tsx index 8ae4dfe17..1f7797873 100644 --- a/src/components/messages/TaskAssignmentMessage.tsx +++ b/src/components/messages/TaskAssignmentMessage.tsx @@ -1,75 +1,65 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { isTaskAssignment, type TaskAssignmentMessage } from '../../utils/teammateMailbox.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + isTaskAssignment, + type TaskAssignmentMessage, +} from '../../utils/teammateMailbox.js' + type Props = { - assignment: TaskAssignmentMessage; -}; + assignment: TaskAssignmentMessage +} /** * Renders a task assignment with a cyan border (team-related color). */ -export function TaskAssignmentDisplay(t0) { - const $ = _c(11); - const { - assignment - } = t0; - let t1; - if ($[0] !== assignment.assignedBy || $[1] !== assignment.taskId) { - t1 = Task #{assignment.taskId} assigned by {assignment.assignedBy}; - $[0] = assignment.assignedBy; - $[1] = assignment.taskId; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== assignment.subject) { - t2 = {assignment.subject}; - $[3] = assignment.subject; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== assignment.description) { - t3 = assignment.description && {assignment.description}; - $[5] = assignment.description; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t1 || $[8] !== t2 || $[9] !== t3) { - t4 = {t1}{t2}{t3}; - $[7] = t1; - $[8] = t2; - $[9] = t3; - $[10] = t4; - } else { - t4 = $[10]; - } - return t4; +export function TaskAssignmentDisplay({ assignment }: Props): React.ReactNode { + return ( + + + + + Task #{assignment.taskId} assigned by {assignment.assignedBy} + + + + {assignment.subject} + + {assignment.description && ( + + {assignment.description} + + )} + + + ) } /** * Try to parse and render a task assignment message from raw content. */ -export function tryRenderTaskAssignmentMessage(content: string): React.ReactNode | null { - const assignment = isTaskAssignment(content); +export function tryRenderTaskAssignmentMessage( + content: string, +): React.ReactNode | null { + const assignment = isTaskAssignment(content) if (assignment) { - return ; + return } - return null; + return null } /** * Get a brief summary text for a task assignment message. */ export function getTaskAssignmentSummary(content: string): string | null { - const assignment = isTaskAssignment(content); + const assignment = isTaskAssignment(content) if (assignment) { - return `[Task Assigned] #${assignment.taskId} - ${assignment.subject}`; + return `[Task Assigned] #${assignment.taskId} - ${assignment.subject}` } - return null; + return null } diff --git a/src/components/messages/UserAgentNotificationMessage.tsx b/src/components/messages/UserAgentNotificationMessage.tsx index 22425e704..7e19c34d7 100644 --- a/src/components/messages/UserAgentNotificationMessage.tsx +++ b/src/components/messages/UserAgentNotificationMessage.tsx @@ -1,82 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { Box, Text, type TextProps } from '../../ink.js'; -import { extractTag } from '../../utils/messages.js'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { Box, Text, type TextProps } from '../../ink.js' +import { extractTag } from '../../utils/messages.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; -}; + addMargin: boolean + param: TextBlockParam +} + function getStatusColor(status: string | null): TextProps['color'] { switch (status) { case 'completed': - return 'success'; + return 'success' case 'failed': - return 'error'; + return 'error' case 'killed': - return 'warning'; + return 'warning' default: - return 'text'; + return 'text' } } -export function UserAgentNotificationMessage(t0) { - const $ = _c(12); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let t2; - if ($[0] !== text) { - t2 = extractTag(text, "summary"); - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - const summary = t2; - if (!summary) { - return null; - } - let t3; - if ($[2] !== text) { - const status = extractTag(text, "status"); - t3 = getStatusColor(status); - $[2] = text; - $[3] = t3; - } else { - t3 = $[3]; - } - const color = t3; - const t4 = addMargin ? 1 : 0; - let t5; - if ($[4] !== color) { - t5 = {BLACK_CIRCLE}; - $[4] = color; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== summary || $[7] !== t5) { - t6 = {t5} {summary}; - $[6] = summary; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== t4 || $[10] !== t6) { - t7 = {t6}; - $[9] = t4; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - return t7; + +export function UserAgentNotificationMessage({ + addMargin, + param: { text }, +}: Props): React.ReactNode { + const summary = extractTag(text, 'summary') + if (!summary) return null + + const status = extractTag(text, 'status') + const color = getStatusColor(status) + + return ( + + + {BLACK_CIRCLE} {summary} + + + ) } diff --git a/src/components/messages/UserBashInputMessage.tsx b/src/components/messages/UserBashInputMessage.tsx index 1eb384dc4..c78fafea1 100644 --- a/src/components/messages/UserBashInputMessage.tsx +++ b/src/components/messages/UserBashInputMessage.tsx @@ -1,57 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { extractTag } from '../../utils/messages.js'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { extractTag } from '../../utils/messages.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; -}; -export function UserBashInputMessage(t0) { - const $ = _c(8); - const { - param: t1, - addMargin - } = t0; - const { - text - } = t1; - let t2; - if ($[0] !== text) { - t2 = extractTag(text, "bash-input"); - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - const input = t2; + addMargin: boolean + param: TextBlockParam +} + +export function UserBashInputMessage({ + param: { text }, + addMargin, +}: Props): React.ReactNode { + const input = extractTag(text, 'bash-input') if (!input) { - return null; - } - const t3 = addMargin ? 1 : 0; - let t4; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ! ; - $[2] = t4; - } else { - t4 = $[2]; - } - let t5; - if ($[3] !== input) { - t5 = {input}; - $[3] = input; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] !== t3 || $[6] !== t5) { - t6 = {t4}{t5}; - $[5] = t3; - $[6] = t5; - $[7] = t6; - } else { - t6 = $[7]; + return null } - return t6; + return ( + + ! + {input} + + ) } diff --git a/src/components/messages/UserBashOutputMessage.tsx b/src/components/messages/UserBashOutputMessage.tsx index 30c4088d8..99ec8ea7e 100644 --- a/src/components/messages/UserBashOutputMessage.tsx +++ b/src/components/messages/UserBashOutputMessage.tsx @@ -1,53 +1,20 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import BashToolResultMessage from '../../tools/BashTool/BashToolResultMessage.js'; -import { extractTag } from '../../utils/messages.js'; -export function UserBashOutputMessage(t0) { - const $ = _c(10); - const { - content, - verbose - } = t0; - let t1; - if ($[0] !== content) { - const rawStdout = extractTag(content, "bash-stdout") ?? ""; - t1 = extractTag(rawStdout, "persisted-output") ?? rawStdout; - $[0] = content; - $[1] = t1; - } else { - t1 = $[1]; - } - const stdout = t1; - let t2; - if ($[2] !== content) { - t2 = extractTag(content, "bash-stderr") ?? ""; - $[2] = content; - $[3] = t2; - } else { - t2 = $[3]; - } - const stderr = t2; - let t3; - if ($[4] !== stderr || $[5] !== stdout) { - t3 = { - stdout, - stderr - }; - $[4] = stderr; - $[5] = stdout; - $[6] = t3; - } else { - t3 = $[6]; - } - const t4 = !!verbose; - let t5; - if ($[7] !== t3 || $[8] !== t4) { - t5 = ; - $[7] = t3; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; +import * as React from 'react' +import BashToolResultMessage from '../../tools/BashTool/BashToolResultMessage.js' +import { extractTag } from '../../utils/messages.js' + +export function UserBashOutputMessage({ + content, + verbose, +}: { + content: string + verbose?: boolean +}): React.ReactNode { + const rawStdout = extractTag(content, 'bash-stdout') ?? '' + // Unwrap if present — keep the inner content (file path + + // preview) for the user; the wrapper tag itself is model-facing signaling. + const stdout = extractTag(rawStdout, 'persisted-output') ?? rawStdout + const stderr = extractTag(content, 'bash-stderr') ?? '' + return ( + + ) } diff --git a/src/components/messages/UserChannelMessage.tsx b/src/components/messages/UserChannelMessage.tsx index af6e6c3ce..8e7101b1a 100644 --- a/src/components/messages/UserChannelMessage.tsx +++ b/src/components/messages/UserChannelMessage.tsx @@ -1,136 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { CHANNEL_ARROW } from '../../constants/figures.js'; -import { CHANNEL_TAG } from '../../constants/xml.js'; -import { Box, Text } from '../../ink.js'; -import { truncateToWidth } from '../../utils/format.js'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { CHANNEL_ARROW } from '../../constants/figures.js' +import { CHANNEL_TAG } from '../../constants/xml.js' +import { Box, Text } from '../../ink.js' +import { truncateToWidth } from '../../utils/format.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; -}; + addMargin: boolean + param: TextBlockParam +} // content // source is always first (wrapChannelMessage writes it), user is optional. -const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?`); -const USER_ATTR_RE = /\buser="([^"]+)"/; +const CHANNEL_RE = new RegExp( + `<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?`, +) +const USER_ATTR_RE = /\buser="([^"]+)"/ // Plugin-provided servers get names like plugin:slack-channel:slack via // addPluginScopeToServers — show just the leaf. Matches the suffix-match // logic in isServerInChannels. function displayServerName(name: string): string { - const i = name.lastIndexOf(':'); - return i === -1 ? name : name.slice(i + 1); + const i = name.lastIndexOf(':') + return i === -1 ? name : name.slice(i + 1) } -const TRUNCATE_AT = 60; -export function UserChannelMessage(t0) { - const $ = _c(29); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let T0; - let T1; - let T2; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let truncated; - let user; - if ($[0] !== addMargin || $[1] !== text) { - t7 = Symbol.for("react.early_return_sentinel"); - bb0: { - const m = CHANNEL_RE.exec(text); - if (!m) { - t7 = null; - break bb0; - } - const [, source, attrs, content] = m; - user = USER_ATTR_RE.exec(attrs ?? "")?.[1]; - const body = (content ?? "").trim().replace(/\s+/g, " "); - truncated = truncateToWidth(body, TRUNCATE_AT); - T2 = Box; - t6 = addMargin ? 1 : 0; - T1 = Text; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {CHANNEL_ARROW}; - $[13] = t4; - } else { - t4 = $[13]; - } - t5 = " "; - T0 = Text; - t2 = true; - t3 = displayServerName(source ?? ""); - } - $[0] = addMargin; - $[1] = text; - $[2] = T0; - $[3] = T1; - $[4] = T2; - $[5] = t2; - $[6] = t3; - $[7] = t4; - $[8] = t5; - $[9] = t6; - $[10] = t7; - $[11] = truncated; - $[12] = user; - } else { - T0 = $[2]; - T1 = $[3]; - T2 = $[4]; - t2 = $[5]; - t3 = $[6]; - t4 = $[7]; - t5 = $[8]; - t6 = $[9]; - t7 = $[10]; - truncated = $[11]; - user = $[12]; - } - if (t7 !== Symbol.for("react.early_return_sentinel")) { - return t7; - } - const t8 = user ? ` \u00b7 ${user}` : ""; - let t9; - if ($[14] !== T0 || $[15] !== t2 || $[16] !== t3 || $[17] !== t8) { - t9 = {t3}{t8}:; - $[14] = T0; - $[15] = t2; - $[16] = t3; - $[17] = t8; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== T1 || $[20] !== t4 || $[21] !== t5 || $[22] !== t9 || $[23] !== truncated) { - t10 = {t4}{t5}{t9}{" "}{truncated}; - $[19] = T1; - $[20] = t4; - $[21] = t5; - $[22] = t9; - $[23] = truncated; - $[24] = t10; - } else { - t10 = $[24]; - } - let t11; - if ($[25] !== T2 || $[26] !== t10 || $[27] !== t6) { - t11 = {t10}; - $[25] = T2; - $[26] = t10; - $[27] = t6; - $[28] = t11; - } else { - t11 = $[28]; - } - return t11; + +const TRUNCATE_AT = 60 + +export function UserChannelMessage({ + addMargin, + param: { text }, +}: Props): React.ReactNode { + const m = CHANNEL_RE.exec(text) + if (!m) return null + const [, source, attrs, content] = m + const user = USER_ATTR_RE.exec(attrs ?? '')?.[1] + const body = (content ?? '').trim().replace(/\s+/g, ' ') + const truncated = truncateToWidth(body, TRUNCATE_AT) + return ( + + + {CHANNEL_ARROW}{' '} + + {displayServerName(source ?? '')} + {user ? ` \u00b7 ${user}` : ''}: + {' '} + {truncated} + + + ) } diff --git a/src/components/messages/UserCommandMessage.tsx b/src/components/messages/UserCommandMessage.tsx index a77c95c4b..31f6b2871 100644 --- a/src/components/messages/UserCommandMessage.tsx +++ b/src/components/messages/UserCommandMessage.tsx @@ -1,107 +1,57 @@ -import { c as _c } from "react/compiler-runtime"; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import figures from 'figures'; -import * as React from 'react'; -import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js'; -import { Box, Text } from '../../ink.js'; -import { extractTag } from '../../utils/messages.js'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import figures from 'figures' +import * as React from 'react' +import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js' +import { Box, Text } from '../../ink.js' +import { extractTag } from '../../utils/messages.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; -}; -export function UserCommandMessage(t0) { - const $ = _c(19); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let t2; - if ($[0] !== text) { - t2 = extractTag(text, COMMAND_MESSAGE_TAG); - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - const commandMessage = t2; - let t3; - if ($[2] !== text) { - t3 = extractTag(text, "command-args"); - $[2] = text; - $[3] = t3; - } else { - t3 = $[3]; - } - const args = t3; - const isSkillFormat = extractTag(text, "skill-format") === "true"; + addMargin: boolean + param: TextBlockParam +} + +export function UserCommandMessage({ + addMargin, + param: { text }, +}: Props): React.ReactNode { + const commandMessage = extractTag(text, COMMAND_MESSAGE_TAG) + const args = extractTag(text, 'command-args') + const isSkillFormat = extractTag(text, 'skill-format') === 'true' + if (!commandMessage) { - return null; + return null } + + // Skills use "Skill(name)" format if (isSkillFormat) { - const t4 = addMargin ? 1 : 0; - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {figures.pointer} ; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] !== commandMessage) { - t6 = {t5}Skill({commandMessage}); - $[5] = commandMessage; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] !== t4 || $[8] !== t6) { - t7 = {t6}; - $[7] = t4; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - return t7; - } - let t4; - if ($[10] !== args || $[11] !== commandMessage) { - t4 = [commandMessage, args].filter(Boolean); - $[10] = args; - $[11] = commandMessage; - $[12] = t4; - } else { - t4 = $[12]; - } - const content = `/${t4.join(" ")}`; - const t5 = addMargin ? 1 : 0; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {figures.pointer} ; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== content) { - t7 = {t6}{content}; - $[14] = content; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t5 || $[17] !== t7) { - t8 = {t7}; - $[16] = t5; - $[17] = t7; - $[18] = t8; - } else { - t8 = $[18]; - } - return t8; + return ( + + + {figures.pointer} + Skill({commandMessage}) + + + ) + } + + // Slash command format: show as "❯ /command args" + const content = `/${[commandMessage, args].filter(Boolean).join(' ')}` + return ( + + + {figures.pointer} + {content} + + + ) } diff --git a/src/components/messages/UserImageMessage.tsx b/src/components/messages/UserImageMessage.tsx index cd5150a34..3f542dfb6 100644 --- a/src/components/messages/UserImageMessage.tsx +++ b/src/components/messages/UserImageMessage.tsx @@ -1,15 +1,15 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { pathToFileURL } from 'url'; -import Link from '../../ink/components/Link.js'; -import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'; -import { Box, Text } from '../../ink.js'; -import { getStoredImagePath } from '../../utils/imageStore.js'; -import { MessageResponse } from '../MessageResponse.js'; +import * as React from 'react' +import { pathToFileURL } from 'url' +import Link from '../../ink/components/Link.js' +import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js' +import { Box, Text } from '../../ink.js' +import { getStoredImagePath } from '../../utils/imageStore.js' +import { MessageResponse } from '../MessageResponse.js' + type Props = { - imageId?: number; - addMargin?: boolean; -}; + imageId?: number + addMargin?: boolean +} /** * Renders an image attachment in user messages. @@ -17,42 +17,27 @@ type Props = { * Uses MessageResponse styling to appear connected to the message above, * unless addMargin is true (image starts a new user turn without text). */ -export function UserImageMessage(t0) { - const $ = _c(7); - const { - imageId, - addMargin - } = t0; - const label = imageId ? `[Image #${imageId}]` : "[Image]"; - let t1; - if ($[0] !== imageId || $[1] !== label) { - const imagePath = imageId ? getStoredImagePath(imageId) : null; - t1 = imagePath && supportsHyperlinks() ? {label} : {label}; - $[0] = imageId; - $[1] = label; - $[2] = t1; - } else { - t1 = $[2]; - } - const content = t1; +export function UserImageMessage({ + imageId, + addMargin, +}: Props): React.ReactNode { + const label = imageId ? `[Image #${imageId}]` : '[Image]' + const imagePath = imageId ? getStoredImagePath(imageId) : null + + const content = + imagePath && supportsHyperlinks() ? ( + + {label} + + ) : ( + {label} + ) + + // When this image starts a new user turn (no text before it), + // show with margin instead of the connected line style if (addMargin) { - let t2; - if ($[3] !== content) { - t2 = {content}; - $[3] = content; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; - } - let t2; - if ($[5] !== content) { - t2 = {content}; - $[5] = content; - $[6] = t2; - } else { - t2 = $[6]; + return {content} } - return t2; + + return {content} } diff --git a/src/components/messages/UserLocalCommandOutputMessage.tsx b/src/components/messages/UserLocalCommandOutputMessage.tsx index 3de6acce3..b1c95616a 100644 --- a/src/components/messages/UserLocalCommandOutputMessage.tsx +++ b/src/components/messages/UserLocalCommandOutputMessage.tsx @@ -1,166 +1,80 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { NO_CONTENT_MESSAGE } from '../../constants/messages.js'; -import { Box, Text } from '../../ink.js'; -import { extractTag } from '../../utils/messages.js'; -import { Markdown } from '../Markdown.js'; -import { MessageResponse } from '../MessageResponse.js'; +import * as React from 'react' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { NO_CONTENT_MESSAGE } from '../../constants/messages.js' +import { Box, Text } from '../../ink.js' +import { extractTag } from '../../utils/messages.js' +import { Markdown } from '../Markdown.js' +import { MessageResponse } from '../MessageResponse.js' + type Props = { - content: string; -}; -export function UserLocalCommandOutputMessage(t0) { - const $ = _c(4); - const { - content - } = t0; - let lines; - let t1; - if ($[0] !== content) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const stdout = extractTag(content, "local-command-stdout"); - const stderr = extractTag(content, "local-command-stderr"); - if (!stdout && !stderr) { - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {NO_CONTENT_MESSAGE}; - $[3] = t2; - } else { - t2 = $[3]; - } - t1 = t2; - break bb0; - } - lines = []; - if (stdout?.trim()) { - lines.push({stdout.trim()}); - } - if (stderr?.trim()) { - lines.push({stderr.trim()}); - } - } - $[0] = content; - $[1] = lines; - $[2] = t1; - } else { - lines = $[1]; - t1 = $[2]; - } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - return lines; + content: string } -function IndentedContent(t0) { - const $ = _c(5); - const { - children - } = t0; - if (children.startsWith(`${DIAMOND_OPEN} `) || children.startsWith(`${DIAMOND_FILLED} `)) { - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + +export function UserLocalCommandOutputMessage({ + content, +}: Props): React.ReactNode { + const stdout = extractTag(content, 'local-command-stdout') + const stderr = extractTag(content, 'local-command-stderr') + if (!stdout && !stderr) { + return ( + + {NO_CONTENT_MESSAGE} + + ) } - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {" \u23BF "}; - $[2] = t1; - } else { - t1 = $[2]; + + const lines: React.ReactNode[] = [] + if (stdout?.trim()) { + lines.push({stdout.trim()}) } - let t2; - if ($[3] !== children) { - t2 = {t1}{children}; - $[3] = children; - $[4] = t2; - } else { - t2 = $[4]; + if (stderr?.trim()) { + lines.push({stderr.trim()}) } - return t2; + return lines } -function CloudLaunchContent(t0) { - const $ = _c(19); - const { - children - } = t0; - const diamond = children[0]; - let label; - let rest; - let t1; - if ($[0] !== children) { - const nl = children.indexOf("\n"); - const header = nl === -1 ? children.slice(2) : children.slice(2, nl); - rest = nl === -1 ? "" : children.slice(nl + 1).trim(); - const sep = header.indexOf(" \xB7 "); - label = sep === -1 ? header : header.slice(0, sep); - t1 = sep === -1 ? "" : header.slice(sep); - $[0] = children; - $[1] = label; - $[2] = rest; - $[3] = t1; - } else { - label = $[1]; - rest = $[2]; - t1 = $[3]; - } - const suffix = t1; - let t2; - if ($[4] !== diamond) { - t2 = {diamond} ; - $[4] = diamond; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== label) { - t3 = {label}; - $[6] = label; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== suffix) { - t4 = suffix && {suffix}; - $[8] = suffix; - $[9] = t4; - } else { - t4 = $[9]; + +function IndentedContent({ children }: { children: string }): React.ReactNode { + if ( + children.startsWith(`${DIAMOND_OPEN} `) || + children.startsWith(`${DIAMOND_FILLED} `) + ) { + return {children} } - let t5; - if ($[10] !== t2 || $[11] !== t3 || $[12] !== t4) { - t5 = {t2}{t3}{t4}; - $[10] = t2; - $[11] = t3; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - let t6; - if ($[14] !== rest) { - t6 = rest && {" \u23BF "}{rest}; - $[14] = rest; - $[15] = t6; - } else { - t6 = $[15]; - } - let t7; - if ($[16] !== t5 || $[17] !== t6) { - t7 = {t5}{t6}; - $[16] = t5; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - return t7; + return ( + + {' ⎿ '} + + {children} + + + ) +} + +function CloudLaunchContent({ + children, +}: { + children: string +}): React.ReactNode { + const diamond = children[0]! + const nl = children.indexOf('\n') + const header = nl === -1 ? children.slice(2) : children.slice(2, nl) + const rest = nl === -1 ? '' : children.slice(nl + 1).trim() + const sep = header.indexOf(' · ') + const label = sep === -1 ? header : header.slice(0, sep) + const suffix = sep === -1 ? '' : header.slice(sep) + return ( + + + {diamond} + {label} + {suffix && {suffix}} + + {rest && ( + + {' ⎿ '} + {rest} + + )} + + ) } diff --git a/src/components/messages/UserMemoryInputMessage.tsx b/src/components/messages/UserMemoryInputMessage.tsx index 513281bd9..25a8d7a1c 100644 --- a/src/components/messages/UserMemoryInputMessage.tsx +++ b/src/components/messages/UserMemoryInputMessage.tsx @@ -1,74 +1,44 @@ -import { c as _c } from "react/compiler-runtime"; -import sample from 'lodash-es/sample.js'; -import * as React from 'react'; -import { useMemo } from 'react'; -import { Box, Text } from '../../ink.js'; -import { extractTag } from '../../utils/messages.js'; -import { MessageResponse } from '../MessageResponse.js'; +import sample from 'lodash-es/sample.js' +import * as React from 'react' +import { useMemo } from 'react' +import { Box, Text } from '../../ink.js' +import { extractTag } from '../../utils/messages.js' +import { MessageResponse } from '../MessageResponse.js' + function getSavingMessage(): string { - return sample(['Got it.', 'Good to know.', 'Noted.']); + return sample(['Got it.', 'Good to know.', 'Noted.']) } + type Props = { - addMargin: boolean; - text: string; -}; -export function UserMemoryInputMessage(t0) { - const $ = _c(10); - const { - text, - addMargin - } = t0; - let t1; - if ($[0] !== text) { - t1 = extractTag(text, "user-memory-input"); - $[0] = text; - $[1] = t1; - } else { - t1 = $[1]; - } - const input = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getSavingMessage(); - $[2] = t2; - } else { - t2 = $[2]; - } - const savingText = t2; + addMargin: boolean + text: string +} + +export function UserMemoryInputMessage({ + text, + addMargin, +}: Props): React.ReactNode { + const input = extractTag(text, 'user-memory-input') + const savingText = useMemo(() => getSavingMessage(), []) + if (!input) { - return null; - } - const t3 = addMargin ? 1 : 0; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = #; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== input) { - t5 = {t4}{" "}{input}{" "}; - $[4] = input; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {savingText}; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] !== t3 || $[8] !== t5) { - t7 = {t5}{t6}; - $[7] = t3; - $[8] = t5; - $[9] = t7; - } else { - t7 = $[9]; + return null } - return t7; + + return ( + + + + # + + + {' '} + {input}{' '} + + + + {savingText} + + + ) } diff --git a/src/components/messages/UserPlanMessage.tsx b/src/components/messages/UserPlanMessage.tsx index 1b738e087..5ef8fa89a 100644 --- a/src/components/messages/UserPlanMessage.tsx +++ b/src/components/messages/UserPlanMessage.tsx @@ -1,41 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Markdown } from '../Markdown.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { Markdown } from '../Markdown.js' + type Props = { - addMargin: boolean; - planContent: string; -}; -export function UserPlanMessage(t0) { - const $ = _c(6); - const { - addMargin, - planContent - } = t0; - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Plan to implement; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== planContent) { - t3 = {planContent}; - $[1] = planContent; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== t1 || $[4] !== t3) { - t4 = {t2}{t3}; - $[3] = t1; - $[4] = t3; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; + addMargin: boolean + planContent: string +} + +export function UserPlanMessage({ + addMargin, + planContent, +}: Props): React.ReactNode { + return ( + + + + Plan to implement + + + {planContent} + + ) } diff --git a/src/components/messages/UserPromptMessage.tsx b/src/components/messages/UserPromptMessage.tsx index 617181437..090cac272 100644 --- a/src/components/messages/UserPromptMessage.tsx +++ b/src/components/messages/UserPromptMessage.tsx @@ -1,21 +1,22 @@ -import { feature } from 'bun:bundle'; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import React, { useContext, useMemo } from 'react'; -import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js'; -import { Box } from '../../ink.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { useAppState } from '../../state/AppState.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { logError } from '../../utils/log.js'; -import { countCharInString } from '../../utils/stringUtils.js'; -import { MessageActionsSelectedContext } from '../messageActions.js'; -import { HighlightedThinkingText } from './HighlightedThinkingText.js'; +import { feature } from 'bun:bundle' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import React, { useContext, useMemo } from 'react' +import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js' +import { Box } from '../../ink.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { useAppState } from '../../state/AppState.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { logError } from '../../utils/log.js' +import { countCharInString } from '../../utils/stringUtils.js' +import { MessageActionsSelectedContext } from '../messageActions.js' +import { HighlightedThinkingText } from './HighlightedThinkingText.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; - isTranscriptMode?: boolean; - timestamp?: string; -}; + addMargin: boolean + param: TextBlockParam + isTranscriptMode?: boolean + timestamp?: string +} // Hard cap on displayed prompt text. Piping large files via stdin // (e.g. `cat 11k-line-file | claude`) creates a single user message whose @@ -25,16 +26,15 @@ type Props = { // avoids this via (print-and-forget to terminal scrollback). // Head+tail because `{ cat file; echo prompt; } | claude` puts the user's // actual question at the end. -const MAX_DISPLAY_CHARS = 10_000; -const TRUNCATE_HEAD_CHARS = 2_500; -const TRUNCATE_TAIL_CHARS = 2_500; +const MAX_DISPLAY_CHARS = 10_000 +const TRUNCATE_HEAD_CHARS = 2_500 +const TRUNCATE_TAIL_CHARS = 2_500 + export function UserPromptMessage({ addMargin, - param: { - text - }, + param: { text }, isTranscriptMode, - timestamp + timestamp, }: Props): React.ReactNode { // REPL.tsx passes isBriefOnly={viewedTeammateTask ? false : isBriefOnly} // but that prop isn't threaded this deep — replicate the override by @@ -48,32 +48,72 @@ export function UserPromptMessage({ // bypasses React.memo). Runtime-gated like isBriefEnabled() but inlined // to avoid pulling BriefTool.ts → prompt.ts tool-name strings into // external builds. - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) : false; - const viewingAgentTaskId = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.viewingAgentTaskId) : null; + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false + const viewingAgentTaskId = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.viewingAgentTaskId) + : null // Hoisted to mount-time — per-message component, re-renders on every scroll. - const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) : false; - const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? (getKairosActive() || getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false))) && isBriefOnly && !isTranscriptMode && !viewingAgentTaskId : false; + const briefEnvEnabled = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) + : false + const useBriefLayout = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? (getKairosActive() || + (getUserMsgOptIn() && + (briefEnvEnabled || + getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_kairos_brief', + false, + )))) && + isBriefOnly && + !isTranscriptMode && + !viewingAgentTaskId + : false // Truncate before the early return so the hook order is stable. const displayText = useMemo(() => { - if (text.length <= MAX_DISPLAY_CHARS) return text; - const head = text.slice(0, TRUNCATE_HEAD_CHARS); - const tail = text.slice(-TRUNCATE_TAIL_CHARS); - const hiddenLines = countCharInString(text, '\n', TRUNCATE_HEAD_CHARS) - countCharInString(tail, '\n'); - return `${head}\n… +${hiddenLines} lines …\n${tail}`; - }, [text]); - const isSelected = useContext(MessageActionsSelectedContext); + if (text.length <= MAX_DISPLAY_CHARS) return text + const head = text.slice(0, TRUNCATE_HEAD_CHARS) + const tail = text.slice(-TRUNCATE_TAIL_CHARS) + const hiddenLines = + countCharInString(text, '\n', TRUNCATE_HEAD_CHARS) - + countCharInString(tail, '\n') + return `${head}\n… +${hiddenLines} lines …\n${tail}` + }, [text]) + + const isSelected = useContext(MessageActionsSelectedContext) + if (!text) { - logError(new Error('No content found in user prompt message')); - return null; + logError(new Error('No content found in user prompt message')) + return null } - return - - ; + + return ( + + + + ) } diff --git a/src/components/messages/UserResourceUpdateMessage.tsx b/src/components/messages/UserResourceUpdateMessage.tsx index e7e4df2d3..ce1f4f5d5 100644 --- a/src/components/messages/UserResourceUpdateMessage.tsx +++ b/src/components/messages/UserResourceUpdateMessage.tsx @@ -1,120 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { REFRESH_ARROW } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { REFRESH_ARROW } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; -}; + addMargin: boolean + param: TextBlockParam +} + type ParsedUpdate = { - kind: 'resource' | 'polling'; - server: string; + kind: 'resource' | 'polling' + server: string /** URI for resource updates, tool name for polling updates */ - target: string; - reason?: string; -}; + target: string + reason?: string +} // Parse resource and polling updates from XML format function parseUpdates(text: string): ParsedUpdate[] { - const updates: ParsedUpdate[] = []; + const updates: ParsedUpdate[] = [] // Match - const resourceRegex = /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g; - let match; + const resourceRegex = + /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g + let match while ((match = resourceRegex.exec(text)) !== null) { updates.push({ kind: 'resource', server: match[1] ?? '', target: match[2] ?? '', - reason: match[3] - }); + reason: match[3], + }) } // Match - const pollingRegex = /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g; + const pollingRegex = + /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g while ((match = pollingRegex.exec(text)) !== null) { updates.push({ kind: 'polling', server: match[2] ?? '', target: match[3] ?? '', - reason: match[4] - }); + reason: match[4], + }) } - return updates; + + return updates } // Format URI for display - show just the meaningful part function formatUri(uri: string): string { // For file:// URIs, show just the filename if (uri.startsWith('file://')) { - const path = uri.slice(7); - const parts = path.split('/'); - return parts[parts.length - 1] || path; + const path = uri.slice(7) + const parts = path.split('/') + return parts[parts.length - 1] || path } // For other URIs, show the whole thing but truncated if (uri.length > 40) { - return uri.slice(0, 39) + '\u2026'; + return uri.slice(0, 39) + '\u2026' } - return uri; + return uri } -export function UserResourceUpdateMessage(t0) { - const $ = _c(12); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let T0; - let t2; - let t3; - let t4; - let t5; - if ($[0] !== addMargin || $[1] !== text) { - t5 = Symbol.for("react.early_return_sentinel"); - bb0: { - const updates = parseUpdates(text); - if (updates.length === 0) { - t5 = null; - break bb0; - } - T0 = Box; - t2 = "column"; - t3 = addMargin ? 1 : 0; - t4 = updates.map(_temp); - } - $[0] = addMargin; - $[1] = text; - $[2] = T0; - $[3] = t2; - $[4] = t3; - $[5] = t4; - $[6] = t5; - } else { - T0 = $[2]; - t2 = $[3]; - t3 = $[4]; - t4 = $[5]; - t5 = $[6]; - } - if (t5 !== Symbol.for("react.early_return_sentinel")) { - return t5; - } - let t6; - if ($[7] !== T0 || $[8] !== t2 || $[9] !== t3 || $[10] !== t4) { - t6 = {t4}; - $[7] = T0; - $[8] = t2; - $[9] = t3; - $[10] = t4; - $[11] = t6; - } else { - t6 = $[11]; - } - return t6; -} -function _temp(update, i) { - return {REFRESH_ARROW}{" "}{update.server}:{" "}{update.kind === "resource" ? formatUri(update.target) : update.target}{update.reason && · {update.reason}}; + +export function UserResourceUpdateMessage({ + addMargin, + param: { text }, +}: Props): React.ReactNode { + const updates = parseUpdates(text) + if (updates.length === 0) return null + + return ( + + {updates.map((update, i) => ( + + + {REFRESH_ARROW}{' '} + {update.server}:{' '} + + {update.kind === 'resource' + ? formatUri(update.target) + : update.target} + + {update.reason && · {update.reason}} + + + ))} + + ) } diff --git a/src/components/messages/UserTeammateMessage.tsx b/src/components/messages/UserTeammateMessage.tsx index 8ee66f8d3..4c174ff1c 100644 --- a/src/components/messages/UserTeammateMessage.tsx +++ b/src/components/messages/UserTeammateMessage.tsx @@ -1,28 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import figures from 'figures'; -import * as React from 'react'; -import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js'; -import { Ansi, Box, Text, type TextProps } from '../../ink.js'; -import { toInkColor } from '../../utils/ink.js'; -import { jsonParse } from '../../utils/slowOperations.js'; -import { isShutdownApproved } from '../../utils/teammateMailbox.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { tryRenderPlanApprovalMessage } from './PlanApprovalMessage.js'; -import { tryRenderShutdownMessage } from './ShutdownMessage.js'; -import { tryRenderTaskAssignmentMessage } from './TaskAssignmentMessage.js'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import figures from 'figures' +import * as React from 'react' +import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js' +import { Ansi, Box, Text, type TextProps } from '../../ink.js' +import { toInkColor } from '../../utils/ink.js' +import { jsonParse } from '../../utils/slowOperations.js' +import { isShutdownApproved } from '../../utils/teammateMailbox.js' +import { MessageResponse } from '../MessageResponse.js' +import { tryRenderPlanApprovalMessage } from './PlanApprovalMessage.js' +import { tryRenderShutdownMessage } from './ShutdownMessage.js' +import { tryRenderTaskAssignmentMessage } from './TaskAssignmentMessage.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; - isTranscriptMode?: boolean; -}; + addMargin: boolean + param: TextBlockParam + isTranscriptMode?: boolean +} + type ParsedMessage = { - teammateId: string; - content: string; - color?: string; - summary?: string; -}; -const TEAMMATE_MSG_REGEX = new RegExp(`<${TEAMMATE_MESSAGE_TAG}\\s+teammate_id="([^"]+)"(?:\\s+color="([^"]+)")?(?:\\s+summary="([^"]+)")?>\\n?([\\s\\S]*?)\\n?<\\/${TEAMMATE_MESSAGE_TAG}>`, 'g'); + teammateId: string + content: string + color?: string + summary?: string +} + +const TEAMMATE_MSG_REGEX = new RegExp( + `<${TEAMMATE_MESSAGE_TAG}\\s+teammate_id="([^"]+)"(?:\\s+color="([^"]+)")?(?:\\s+summary="([^"]+)")?>\\n?([\\s\\S]*?)\\n?<\\/${TEAMMATE_MESSAGE_TAG}>`, + 'g', +) /** * Parse all teammate messages from XML format: @@ -30,176 +35,169 @@ const TEAMMATE_MSG_REGEX = new RegExp(`<${TEAMMATE_MESSAGE_TAG}\\s+teammate_id=" * Supports multiple messages in a single text block. */ function parseTeammateMessages(text: string): ParsedMessage[] { - const messages: ParsedMessage[] = []; + const messages: ParsedMessage[] = [] // Use matchAll to find all matches (this is a RegExp method, not child_process) for (const match of text.matchAll(TEAMMATE_MSG_REGEX)) { if (match[1] && match[4]) { messages.push({ teammateId: match[1], - color: match[2], - // may be undefined - summary: match[3], - // may be undefined - content: match[4].trim() - }); + color: match[2], // may be undefined + summary: match[3], // may be undefined + content: match[4].trim(), + }) } } - return messages; + + return messages } + function getDisplayName(teammateId: string): string { if (teammateId === 'leader') { - return 'leader'; + return 'leader' } - return teammateId; + return teammateId } + export function UserTeammateMessage({ addMargin, - param: { - text - }, - isTranscriptMode + param: { text }, + isTranscriptMode, }: Props): React.ReactNode { const messages = parseTeammateMessages(text).filter(msg => { // Pre-filter shutdown lifecycle messages to avoid empty wrapper // Box elements creating blank lines between model turns if (isShutdownApproved(msg.content)) { - return false; + return false } try { - const parsed = jsonParse(msg.content); - if (parsed?.type === 'teammate_terminated') return false; + const parsed = jsonParse(msg.content) + if (parsed?.type === 'teammate_terminated') return false } catch { // Not JSON, keep the message } - return true; - }); + return true + }) if (messages.length === 0) { - return null; + return null } - return - {messages.map((msg_0, index) => { - const inkColor = toInkColor(msg_0.color); - const displayName = getDisplayName(msg_0.teammateId); - - // Try to render as plan approval message (request or response) - const planApprovalElement = tryRenderPlanApprovalMessage(msg_0.content, displayName); - if (planApprovalElement) { - return {planApprovalElement}; - } - - // Try to render as shutdown message (request or rejected) - const shutdownElement = tryRenderShutdownMessage(msg_0.content); - if (shutdownElement) { - return {shutdownElement}; - } - - // Try to render as task assignment message - const taskAssignmentElement = tryRenderTaskAssignmentMessage(msg_0.content); - if (taskAssignmentElement) { - return {taskAssignmentElement}; - } - - // Try to parse as structured JSON message - let parsedIdleNotification: { - type?: string; - } | null = null; - try { - parsedIdleNotification = jsonParse(msg_0.content); - } catch { - // Not JSON - } - - // Hide idle notifications - they are processed silently - if (parsedIdleNotification?.type === 'idle_notification') { - return null; - } - - // Task completed notification - show which task was completed - if (parsedIdleNotification?.type === 'task_completed') { - const taskCompleted = parsedIdleNotification as { - type: string; - from: string; - taskId: string; - taskSubject?: string; - }; - return - {`@${displayName}${figures.pointer}`} + + return ( + + {messages.map((msg, index) => { + const inkColor = toInkColor(msg.color) + const displayName = getDisplayName(msg.teammateId) + + // Try to render as plan approval message (request or response) + const planApprovalElement = tryRenderPlanApprovalMessage( + msg.content, + displayName, + ) + if (planApprovalElement) { + return ( + {planApprovalElement} + ) + } + + // Try to render as shutdown message (request or rejected) + const shutdownElement = tryRenderShutdownMessage(msg.content) + if (shutdownElement) { + return {shutdownElement} + } + + // Try to render as task assignment message + const taskAssignmentElement = tryRenderTaskAssignmentMessage( + msg.content, + ) + if (taskAssignmentElement) { + return ( + {taskAssignmentElement} + ) + } + + // Try to parse as structured JSON message + let parsedIdleNotification: { type?: string } | null = null + try { + parsedIdleNotification = jsonParse(msg.content) + } catch { + // Not JSON + } + + // Hide idle notifications - they are processed silently + if (parsedIdleNotification?.type === 'idle_notification') { + return null + } + + // Task completed notification - show which task was completed + if (parsedIdleNotification?.type === 'task_completed') { + const taskCompleted = parsedIdleNotification as { + type: string + from: string + taskId: string + taskSubject?: string + } + return ( + + {`@${displayName}${figures.pointer}`} {' '} Completed task #{taskCompleted.taskId} - {taskCompleted.taskSubject && ({taskCompleted.taskSubject})} + {taskCompleted.taskSubject && ( + ({taskCompleted.taskSubject}) + )} - ; - } + + ) + } - // Default: plain text message (truncated) - return ; - })} - ; + // Default: plain text message (truncated) + return ( + + ) + })} + + ) } + type TeammateMessageContentProps = { - displayName: string; - inkColor: TextProps['color']; - content: string; - summary?: string; - isTranscriptMode?: boolean; -}; -export function TeammateMessageContent(t0) { - const $ = _c(14); - const { - displayName, - inkColor, - content, - summary, - isTranscriptMode - } = t0; - const t1 = `@${displayName}${figures.pointer}`; - let t2; - if ($[0] !== inkColor || $[1] !== t1) { - t2 = {t1}; - $[0] = inkColor; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== summary) { - t3 = summary && {summary}; - $[3] = summary; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== content || $[9] !== isTranscriptMode) { - t5 = isTranscriptMode && {content}; - $[8] = content; - $[9] = isTranscriptMode; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t4 || $[12] !== t5) { - t6 = {t4}{t5}; - $[11] = t4; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; + displayName: string + inkColor: TextProps['color'] + content: string + summary?: string + isTranscriptMode?: boolean +} + +export function TeammateMessageContent({ + displayName, + inkColor, + content, + summary, + isTranscriptMode, +}: TeammateMessageContentProps): React.ReactNode { + return ( + + + {`@${displayName}${figures.pointer}`} + {summary && {summary}} + + {isTranscriptMode && ( + + + {content} + + + )} + + ) } diff --git a/src/components/messages/UserTextMessage.tsx b/src/components/messages/UserTextMessage.tsx index 464460e9c..73ef3929d 100644 --- a/src/components/messages/UserTextMessage.tsx +++ b/src/components/messages/UserTextMessage.tsx @@ -1,274 +1,197 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { NO_CONTENT_MESSAGE } from '../../constants/messages.js'; -import { COMMAND_MESSAGE_TAG, LOCAL_COMMAND_CAVEAT_TAG, TASK_NOTIFICATION_TAG, TEAMMATE_MESSAGE_TAG, TICK_TAG } from '../../constants/xml.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { extractTag, INTERRUPT_MESSAGE, INTERRUPT_MESSAGE_FOR_TOOL_USE } from '../../utils/messages.js'; -import { InterruptedByUser } from '../InterruptedByUser.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { UserAgentNotificationMessage } from './UserAgentNotificationMessage.js'; -import { UserBashInputMessage } from './UserBashInputMessage.js'; -import { UserBashOutputMessage } from './UserBashOutputMessage.js'; -import { UserCommandMessage } from './UserCommandMessage.js'; -import { UserLocalCommandOutputMessage } from './UserLocalCommandOutputMessage.js'; -import { UserMemoryInputMessage } from './UserMemoryInputMessage.js'; -import { UserPlanMessage } from './UserPlanMessage.js'; -import { UserPromptMessage } from './UserPromptMessage.js'; -import { UserResourceUpdateMessage } from './UserResourceUpdateMessage.js'; -import { UserTeammateMessage } from './UserTeammateMessage.js'; +import { feature } from 'bun:bundle' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { NO_CONTENT_MESSAGE } from '../../constants/messages.js' +import { + COMMAND_MESSAGE_TAG, + LOCAL_COMMAND_CAVEAT_TAG, + TASK_NOTIFICATION_TAG, + TEAMMATE_MESSAGE_TAG, + TICK_TAG, +} from '../../constants/xml.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { + extractTag, + INTERRUPT_MESSAGE, + INTERRUPT_MESSAGE_FOR_TOOL_USE, +} from '../../utils/messages.js' +import { InterruptedByUser } from '../InterruptedByUser.js' +import { MessageResponse } from '../MessageResponse.js' +import { UserAgentNotificationMessage } from './UserAgentNotificationMessage.js' +import { UserBashInputMessage } from './UserBashInputMessage.js' +import { UserBashOutputMessage } from './UserBashOutputMessage.js' +import { UserCommandMessage } from './UserCommandMessage.js' +import { UserLocalCommandOutputMessage } from './UserLocalCommandOutputMessage.js' +import { UserMemoryInputMessage } from './UserMemoryInputMessage.js' +import { UserPlanMessage } from './UserPlanMessage.js' +import { UserPromptMessage } from './UserPromptMessage.js' +import { UserResourceUpdateMessage } from './UserResourceUpdateMessage.js' +import { UserTeammateMessage } from './UserTeammateMessage.js' + type Props = { - addMargin: boolean; - param: TextBlockParam; - verbose: boolean; - planContent?: string; - isTranscriptMode?: boolean; - timestamp?: string; -}; -export function UserTextMessage(t0) { - const $ = _c(49); - const { - addMargin, - param, - verbose, - planContent, - isTranscriptMode, - timestamp - } = t0; + addMargin: boolean + param: TextBlockParam + verbose: boolean + planContent?: string + isTranscriptMode?: boolean + timestamp?: string +} + +export function UserTextMessage({ + addMargin, + param, + verbose, + planContent, + isTranscriptMode, + timestamp, +}: Props): React.ReactNode { if (param.text.trim() === NO_CONTENT_MESSAGE) { - return null; + return null } + + // Plan to implement message (cleared context flow) if (planContent) { - let t1; - if ($[0] !== addMargin || $[1] !== planContent) { - t1 = ; - $[0] = addMargin; - $[1] = planContent; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + return } + if (extractTag(param.text, TICK_TAG)) { - return null; + return null } + + // Hide synthetic caveat messages (should be filtered by isMeta, this is defensive) if (param.text.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`)) { - return null; - } - if (param.text.startsWith("; - $[3] = param.text; - $[4] = verbose; - $[5] = t1; - } else { - t1 = $[5]; + return null + } + + // Show bash output + if ( + param.text.startsWith(' + } + + // Show command output + if ( + param.text.startsWith(' + } + + // Handle interruption messages specially + if ( + param.text === INTERRUPT_MESSAGE || + param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE + ) { + return ( + + + + ) + } + + // GitHub webhook events (check_run, review comments, pushes) delivered via + // bound-session routing after /subscribe-pr. The tag constant is stripped + // from external builds — inline the literal so the import doesn't fail. + // The require() below DCEs when both flags are off. startsWith (not + // includes) and before the includes-checks below: defense-in-depth if + // the sanitizer were ever weakened. + if (feature('KAIROS_GITHUB_WEBHOOKS')) { + if (param.text.startsWith('')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { UserGitHubWebhookMessage } = + require('./UserGitHubWebhookMessage.js') as typeof import('./UserGitHubWebhookMessage.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + return } - return t1; } - if (param.text.startsWith("; - $[6] = param.text; - $[7] = t1; - } else { - t1 = $[7]; - } - return t1; - } - if (param.text === INTERRUPT_MESSAGE || param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE) { - let t1; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[8] = t1; - } else { - t1 = $[8]; - } - return t1; - } - if (feature("KAIROS_GITHUB_WEBHOOKS")) { - if (param.text.startsWith("")) { - let t1; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t1 = require("./UserGitHubWebhookMessage.js"); - $[9] = t1; - } else { - t1 = $[9]; - } - const { - UserGitHubWebhookMessage - } = t1 as typeof import('./UserGitHubWebhookMessage.js'); - let t2; - if ($[10] !== addMargin || $[11] !== param) { - t2 = ; - $[10] = addMargin; - $[11] = param; - $[12] = t2; - } else { - t2 = $[12]; - } - return t2; - } - } - if (param.text.includes("")) { - let t1; - if ($[13] !== addMargin || $[14] !== param) { - t1 = ; - $[13] = addMargin; - $[14] = param; - $[15] = t1; - } else { - t1 = $[15]; - } - return t1; + + // Bash inputs! + if (param.text.includes('')) { + return } + + // Slash commands/ if (param.text.includes(`<${COMMAND_MESSAGE_TAG}>`)) { - let t1; - if ($[16] !== addMargin || $[17] !== param) { - t1 = ; - $[16] = addMargin; - $[17] = param; - $[18] = t1; - } else { - t1 = $[18]; - } - return t1; - } - if (param.text.includes("")) { - let t1; - if ($[19] !== addMargin || $[20] !== param.text) { - t1 = ; - $[19] = addMargin; - $[20] = param.text; - $[21] = t1; - } else { - t1 = $[21]; - } - return t1; - } - if (isAgentSwarmsEnabled() && param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`)) { - let t1; - if ($[22] !== addMargin || $[23] !== isTranscriptMode || $[24] !== param) { - t1 = ; - $[22] = addMargin; - $[23] = isTranscriptMode; - $[24] = param; - $[25] = t1; - } else { - t1 = $[25]; - } - return t1; - } + return + } + + if (param.text.includes('')) { + return + } + + // Teammate messages - only check when swarms enabled + if ( + isAgentSwarmsEnabled() && + param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`) + ) { + return ( + + ) + } + + // Task notifications (agent completions, bash completions, etc.) if (param.text.includes(`<${TASK_NOTIFICATION_TAG}`)) { - let t1; - if ($[26] !== addMargin || $[27] !== param) { - t1 = ; - $[26] = addMargin; - $[27] = param; - $[28] = t1; - } else { - t1 = $[28]; + return + } + + // MCP resource and polling update notifications + if ( + param.text.includes(' + } + + // Fork child's first message: collapse the rules/format boilerplate, show + // only the directive. FORK_BOILERPLATE_TAG is inlined so the import doesn't + // ship in external builds where feature('FORK_SUBAGENT') is false. + if (feature('FORK_SUBAGENT')) { + if (param.text.includes('')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { UserForkBoilerplateMessage } = + require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + return } - return t1; } - if (param.text.includes("; - $[29] = addMargin; - $[30] = param; - $[31] = t1; - } else { - t1 = $[31]; + + // Cross-session UDS message (from another Claude session's SendMessage). + // CROSS_SESSION_MESSAGE_TAG is inlined so the import doesn't ship in + // external builds where feature('UDS_INBOX') is false. + if (feature('UDS_INBOX')) { + if (param.text.includes(' } - return t1; } - if (feature("FORK_SUBAGENT")) { - if (param.text.includes("")) { - let t1; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t1 = require("./UserForkBoilerplateMessage.js"); - $[32] = t1; - } else { - t1 = $[32]; - } - const { - UserForkBoilerplateMessage - } = t1 as typeof import('./UserForkBoilerplateMessage.js'); - let t2; - if ($[33] !== addMargin || $[34] !== param) { - t2 = ; - $[33] = addMargin; - $[34] = param; - $[35] = t2; - } else { - t2 = $[35]; - } - return t2; + + // Inbound channel message (MCP server push). + if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { + if (param.text.includes('; - $[37] = addMargin; - $[38] = param; - $[39] = t2; - } else { - t2 = $[39]; - } - return t2; - } - } - if (feature("KAIROS") || feature("KAIROS_CHANNELS")) { - if (param.text.includes("; - $[41] = addMargin; - $[42] = param; - $[43] = t2; - } else { - t2 = $[43]; - } - return t2; - } - } - let t1; - if ($[44] !== addMargin || $[45] !== isTranscriptMode || $[46] !== param || $[47] !== timestamp) { - t1 = ; - $[44] = addMargin; - $[45] = isTranscriptMode; - $[46] = param; - $[47] = timestamp; - $[48] = t1; - } else { - t1 = $[48]; - } - return t1; + + // User prompts> + return ( + + ) } diff --git a/src/components/messages/UserToolResultMessage/RejectedPlanMessage.tsx b/src/components/messages/UserToolResultMessage/RejectedPlanMessage.tsx index 09cf59ced..bee8d5c3a 100644 --- a/src/components/messages/UserToolResultMessage/RejectedPlanMessage.tsx +++ b/src/components/messages/UserToolResultMessage/RejectedPlanMessage.tsx @@ -1,30 +1,27 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Markdown } from 'src/components/Markdown.js'; -import { MessageResponse } from 'src/components/MessageResponse.js'; -import { Box, Text } from '../../../ink.js'; +import * as React from 'react' +import { Markdown } from 'src/components/Markdown.js' +import { MessageResponse } from 'src/components/MessageResponse.js' +import { Box, Text } from '../../../ink.js' + type Props = { - plan: string; -}; -export function RejectedPlanMessage(t0) { - const $ = _c(3); - const { - plan - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = User rejected Claude's plan:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== plan) { - t2 = {t1}{plan}; - $[1] = plan; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + plan: string +} + +export function RejectedPlanMessage({ plan }: Props): React.ReactNode { + return ( + + + User rejected Claude's plan: + + {plan} + + + + ) } diff --git a/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx b/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx index 2fd528995..b387b0fea 100644 --- a/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx +++ b/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx @@ -1,15 +1,11 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../../../ink.js'; -import { MessageResponse } from '../../MessageResponse.js'; -export function RejectedToolUseMessage() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Tool use rejected; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { Text } from '../../../ink.js' +import { MessageResponse } from '../../MessageResponse.js' + +export function RejectedToolUseMessage(): React.ReactNode { + return ( + + Tool use rejected + + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx index c5668443c..cb8f242eb 100644 --- a/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx @@ -1,15 +1,11 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { InterruptedByUser } from 'src/components/InterruptedByUser.js'; -import { MessageResponse } from 'src/components/MessageResponse.js'; -export function UserToolCanceledMessage() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { InterruptedByUser } from 'src/components/InterruptedByUser.js' +import { MessageResponse } from 'src/components/MessageResponse.js' + +export function UserToolCanceledMessage(): React.ReactNode { + return ( + + + + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx index 74b32dcf5..33249d591 100644 --- a/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx @@ -1,102 +1,95 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { BULLET_OPERATOR } from '../../../constants/figures.js'; -import { Text } from '../../../ink.js'; -import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js'; -import type { ProgressMessage } from '../../../types/message.js'; -import { INTERRUPT_MESSAGE_FOR_TOOL_USE, isClassifierDenial, PLAN_REJECTION_PREFIX, REJECT_MESSAGE_WITH_REASON_PREFIX } from '../../../utils/messages.js'; -import { FallbackToolUseErrorMessage } from '../../FallbackToolUseErrorMessage.js'; -import { InterruptedByUser } from '../../InterruptedByUser.js'; -import { MessageResponse } from '../../MessageResponse.js'; -import { RejectedPlanMessage } from './RejectedPlanMessage.js'; -import { RejectedToolUseMessage } from './RejectedToolUseMessage.js'; +import { feature } from 'bun:bundle' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { BULLET_OPERATOR } from '../../../constants/figures.js' +import { Text } from '../../../ink.js' +import { + filterToolProgressMessages, + type Tool, + type Tools, +} from '../../../Tool.js' +import type { ProgressMessage } from '../../../types/message.js' +import { + INTERRUPT_MESSAGE_FOR_TOOL_USE, + isClassifierDenial, + PLAN_REJECTION_PREFIX, + REJECT_MESSAGE_WITH_REASON_PREFIX, +} from '../../../utils/messages.js' +import { FallbackToolUseErrorMessage } from '../../FallbackToolUseErrorMessage.js' +import { InterruptedByUser } from '../../InterruptedByUser.js' +import { MessageResponse } from '../../MessageResponse.js' +import { RejectedPlanMessage } from './RejectedPlanMessage.js' +import { RejectedToolUseMessage } from './RejectedToolUseMessage.js' + type Props = { - progressMessagesForMessage: ProgressMessage[]; - tool?: Tool; // undefined when resuming an old conversation that uses an old tool - tools: Tools; - param: ToolResultBlockParam; - verbose: boolean; - isTranscriptMode?: boolean; -}; -export function UserToolErrorMessage(t0) { - const $ = _c(14); - const { - progressMessagesForMessage, - tool, - tools, - param, - verbose, - isTranscriptMode - } = t0; - if (typeof param.content === "string" && param.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE)) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + progressMessagesForMessage: ProgressMessage[] + tool?: Tool // undefined when resuming an old conversation that uses an old tool + tools: Tools + param: ToolResultBlockParam + verbose: boolean + isTranscriptMode?: boolean +} + +export function UserToolErrorMessage({ + progressMessagesForMessage, + tool, + tools, + param, + verbose, + isTranscriptMode, +}: Props): React.ReactNode { + if ( + typeof param.content === 'string' && + param.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE) + ) { + return ( + + + + ) } - if (typeof param.content === "string" && param.content.startsWith(PLAN_REJECTION_PREFIX)) { - let t1; - if ($[1] !== param.content) { - t1 = param.content.substring(PLAN_REJECTION_PREFIX.length); - $[1] = param.content; - $[2] = t1; - } else { - t1 = $[2]; - } - const planContent = t1; - let t2; - if ($[3] !== planContent) { - t2 = ; - $[3] = planContent; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + + if ( + typeof param.content === 'string' && + param.content.startsWith(PLAN_REJECTION_PREFIX) + ) { + // Extract the plan content from the error message + const planContent = param.content.substring(PLAN_REJECTION_PREFIX.length) + return } - if (typeof param.content === "string" && param.content.startsWith(REJECT_MESSAGE_WITH_REASON_PREFIX)) { - let t1; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; + + if ( + typeof param.content === 'string' && + param.content.startsWith(REJECT_MESSAGE_WITH_REASON_PREFIX) + ) { + return } - if (feature("TRANSCRIPT_CLASSIFIER") && typeof param.content === "string" && isClassifierDenial(param.content)) { - let t1; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Denied by auto mode classifier {BULLET_OPERATOR} /feedback if incorrect; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + + if ( + feature('TRANSCRIPT_CLASSIFIER') && + typeof param.content === 'string' && + isClassifierDenial(param.content) + ) { + return ( + + + Denied by auto mode classifier {BULLET_OPERATOR} /feedback if + incorrect + + + ) } - let t1; - if ($[7] !== isTranscriptMode || $[8] !== param.content || $[9] !== progressMessagesForMessage || $[10] !== tool || $[11] !== tools || $[12] !== verbose) { - t1 = tool?.renderToolUseErrorMessage?.(param.content, { - progressMessagesForMessage: filterToolProgressMessages(progressMessagesForMessage), + + return ( + tool?.renderToolUseErrorMessage?.(param.content, { + progressMessagesForMessage: filterToolProgressMessages( + progressMessagesForMessage, + ), tools, verbose, - isTranscriptMode - }) ?? ; - $[7] = isTranscriptMode; - $[8] = param.content; - $[9] = progressMessagesForMessage; - $[10] = tool; - $[11] = tools; - $[12] = verbose; - $[13] = t1; - } else { - t1 = $[13]; - } - return t1; + isTranscriptMode, + }) ?? ( + + ) + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx index 5ed3571bd..c1a37ef4e 100644 --- a/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx @@ -1,94 +1,59 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { useTheme } from '../../../ink.js'; -import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js'; -import type { ProgressMessage } from '../../../types/message.js'; -import type { buildMessageLookups } from '../../../utils/messages.js'; -import { FallbackToolUseRejectedMessage } from '../../FallbackToolUseRejectedMessage.js'; +import * as React from 'react' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { useTheme } from '../../../ink.js' +import { + filterToolProgressMessages, + type Tool, + type Tools, +} from '../../../Tool.js' +import type { ProgressMessage } from '../../../types/message.js' +import type { buildMessageLookups } from '../../../utils/messages.js' +import { FallbackToolUseRejectedMessage } from '../../FallbackToolUseRejectedMessage.js' + type Props = { - input: { - [key: string]: unknown; - }; - progressMessagesForMessage: ProgressMessage[]; - style?: 'condensed'; - tool?: Tool; - tools: Tools; - lookups: ReturnType; - verbose: boolean; - isTranscriptMode?: boolean; -}; -export function UserToolRejectMessage(t0) { - const $ = _c(13); - const { - input, - progressMessagesForMessage, - style, - tool, - tools, - verbose, - isTranscriptMode - } = t0; - const { - columns - } = useTerminalSize(); - const [theme] = useTheme(); + input: { [key: string]: unknown } + progressMessagesForMessage: ProgressMessage[] + style?: 'condensed' + tool?: Tool + tools: Tools + lookups: ReturnType + verbose: boolean + isTranscriptMode?: boolean +} + +export function UserToolRejectMessage({ + input, + progressMessagesForMessage, + style, + tool, + tools, + verbose, + isTranscriptMode, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const [theme] = useTheme() + if (!tool || !tool.renderToolUseRejectedMessage) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - const t1 = tool.inputSchema; - let t2; - let t3; - if ($[1] !== columns || $[2] !== input || $[3] !== isTranscriptMode || $[4] !== progressMessagesForMessage || $[5] !== style || $[6] !== theme || $[7] !== tool || $[8] !== tools || $[9] !== verbose) { - t3 = Symbol.for("react.early_return_sentinel"); - bb0: { - const parsedInput = t1.safeParse(input); - if (!parsedInput.success) { - let t4; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[12] = t4; - } else { - t4 = $[12]; - } - t3 = t4; - break bb0; - } - t2 = tool.renderToolUseRejectedMessage(parsedInput.data, { - columns, - messages: [], - tools, - verbose, - progressMessagesForMessage: filterToolProgressMessages(progressMessagesForMessage), - style, - theme, - isTranscriptMode - }) ?? ; - } - $[1] = columns; - $[2] = input; - $[3] = isTranscriptMode; - $[4] = progressMessagesForMessage; - $[5] = style; - $[6] = theme; - $[7] = tool; - $[8] = tools; - $[9] = verbose; - $[10] = t2; - $[11] = t3; - } else { - t2 = $[10]; - t3 = $[11]; + return } - if (t3 !== Symbol.for("react.early_return_sentinel")) { - return t3; + + const parsedInput = tool.inputSchema.safeParse(input) + if (!parsedInput.success) { + return } - return t2; + + return ( + tool.renderToolUseRejectedMessage(parsedInput.data, { + columns, + messages: [], + tools, + verbose, + progressMessagesForMessage: filterToolProgressMessages( + progressMessagesForMessage, + ), + style, + theme, + isTranscriptMode, + }) ?? + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx index 29f7ee31f..abd7a8fce 100644 --- a/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx @@ -1,105 +1,101 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import type { Tools } from '../../../Tool.js'; -import type { NormalizedUserMessage, ProgressMessage } from '../../../types/message.js'; -import { type buildMessageLookups, CANCEL_MESSAGE, INTERRUPT_MESSAGE_FOR_TOOL_USE, REJECT_MESSAGE } from '../../../utils/messages.js'; -import { UserToolCanceledMessage } from './UserToolCanceledMessage.js'; -import { UserToolErrorMessage } from './UserToolErrorMessage.js'; -import { UserToolRejectMessage } from './UserToolRejectMessage.js'; -import { UserToolSuccessMessage } from './UserToolSuccessMessage.js'; -import { useGetToolFromMessages } from './utils.js'; +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import type { Tools } from '../../../Tool.js' +import type { + NormalizedUserMessage, + ProgressMessage, +} from '../../../types/message.js' +import { + type buildMessageLookups, + CANCEL_MESSAGE, + INTERRUPT_MESSAGE_FOR_TOOL_USE, + REJECT_MESSAGE, +} from '../../../utils/messages.js' +import { UserToolCanceledMessage } from './UserToolCanceledMessage.js' +import { UserToolErrorMessage } from './UserToolErrorMessage.js' +import { UserToolRejectMessage } from './UserToolRejectMessage.js' +import { UserToolSuccessMessage } from './UserToolSuccessMessage.js' +import { useGetToolFromMessages } from './utils.js' + type Props = { - param: ToolResultBlockParam; - message: NormalizedUserMessage; - lookups: ReturnType; - progressMessagesForMessage: ProgressMessage[]; - style?: 'condensed'; - tools: Tools; - verbose: boolean; - width: number | string; - isTranscriptMode?: boolean; -}; -export function UserToolResultMessage(t0) { - const $ = _c(28); - const { - param, - message, - lookups, - progressMessagesForMessage, - style, - tools, - verbose, - width, - isTranscriptMode - } = t0; - const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups); + param: ToolResultBlockParam + message: NormalizedUserMessage + lookups: ReturnType + progressMessagesForMessage: ProgressMessage[] + style?: 'condensed' + tools: Tools + verbose: boolean + width: number | string + isTranscriptMode?: boolean +} + +export function UserToolResultMessage({ + param, + message, + lookups, + progressMessagesForMessage, + style, + tools, + verbose, + width, + isTranscriptMode, +}: Props): React.ReactNode { + const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups) if (!toolUse) { - return null; + return null } - if (typeof param.content === "string" && param.content.startsWith(CANCEL_MESSAGE)) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + + if ( + typeof param.content === 'string' && + param.content.startsWith(CANCEL_MESSAGE) + ) { + return } - if (typeof param.content === "string" && param.content.startsWith(REJECT_MESSAGE) || param.content === INTERRUPT_MESSAGE_FOR_TOOL_USE) { - const t1 = toolUse.toolUse.input as { - [key: string]: unknown; - }; - let t2; - if ($[1] !== isTranscriptMode || $[2] !== lookups || $[3] !== progressMessagesForMessage || $[4] !== style || $[5] !== t1 || $[6] !== toolUse.tool || $[7] !== tools || $[8] !== verbose) { - t2 = ; - $[1] = isTranscriptMode; - $[2] = lookups; - $[3] = progressMessagesForMessage; - $[4] = style; - $[5] = t1; - $[6] = toolUse.tool; - $[7] = tools; - $[8] = verbose; - $[9] = t2; - } else { - t2 = $[9]; - } - return t2; + + if ( + (typeof param.content === 'string' && + param.content.startsWith(REJECT_MESSAGE)) || + param.content === INTERRUPT_MESSAGE_FOR_TOOL_USE + ) { + return ( + + ) } + if (param.is_error) { - let t1; - if ($[10] !== isTranscriptMode || $[11] !== param || $[12] !== progressMessagesForMessage || $[13] !== toolUse.tool || $[14] !== tools || $[15] !== verbose) { - t1 = ; - $[10] = isTranscriptMode; - $[11] = param; - $[12] = progressMessagesForMessage; - $[13] = toolUse.tool; - $[14] = tools; - $[15] = verbose; - $[16] = t1; - } else { - t1 = $[16]; - } - return t1; - } - let t1; - if ($[17] !== isTranscriptMode || $[18] !== lookups || $[19] !== message || $[20] !== progressMessagesForMessage || $[21] !== style || $[22] !== toolUse.tool || $[23] !== toolUse.toolUse.id || $[24] !== tools || $[25] !== verbose || $[26] !== width) { - t1 = ; - $[17] = isTranscriptMode; - $[18] = lookups; - $[19] = message; - $[20] = progressMessagesForMessage; - $[21] = style; - $[22] = toolUse.tool; - $[23] = toolUse.toolUse.id; - $[24] = tools; - $[25] = verbose; - $[26] = width; - $[27] = t1; - } else { - t1 = $[27]; + return ( + + ) } - return t1; + + return ( + + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx index 4ad021c60..931e0df3e 100644 --- a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx @@ -1,27 +1,40 @@ -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import * as React from 'react'; -import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { useAppState } from '../../../state/AppState.js'; -import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js'; -import type { NormalizedUserMessage, ProgressMessage } from '../../../types/message.js'; -import { deleteClassifierApproval, getClassifierApproval, getYoloClassifierApproval } from '../../../utils/classifierApprovals.js'; -import type { buildMessageLookups } from '../../../utils/messages.js'; -import { MessageResponse } from '../../MessageResponse.js'; -import { HookProgressMessage } from '../HookProgressMessage.js'; +import { feature } from 'bun:bundle' +import figures from 'figures' +import * as React from 'react' +import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js' +import { Box, Text, useTheme } from '../../../ink.js' +import { useAppState } from '../../../state/AppState.js' +import { + filterToolProgressMessages, + type Tool, + type Tools, +} from '../../../Tool.js' +import type { + NormalizedUserMessage, + ProgressMessage, +} from '../../../types/message.js' +import { + deleteClassifierApproval, + getClassifierApproval, + getYoloClassifierApproval, +} from '../../../utils/classifierApprovals.js' +import type { buildMessageLookups } from '../../../utils/messages.js' +import { MessageResponse } from '../../MessageResponse.js' +import { HookProgressMessage } from '../HookProgressMessage.js' + type Props = { - message: NormalizedUserMessage; - lookups: ReturnType; - toolUseID: string; - progressMessagesForMessage: ProgressMessage[]; - style?: 'condensed'; - tool?: Tool; - tools: Tools; - verbose: boolean; - width: number | string; - isTranscriptMode?: boolean; -}; + message: NormalizedUserMessage + lookups: ReturnType + toolUseID: string + progressMessagesForMessage: ProgressMessage[] + style?: 'condensed' + tool?: Tool + tools: Tools + verbose: boolean + width: number | string + isTranscriptMode?: boolean +} + export function UserToolSuccessMessage({ message, lookups, @@ -32,72 +45,105 @@ export function UserToolSuccessMessage({ tools, verbose, width, - isTranscriptMode + isTranscriptMode, }: Props): React.ReactNode { - const [theme] = useTheme(); + const [theme] = useTheme() // Hook stays inside feature() ternary so external builds don't pay a // per-scrollback-message store subscription — same pattern as // UserPromptMessage.tsx. - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) : false; + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false // Capture classifier approval once on mount, then delete from Map to prevent linear growth. // useState lazy initializer ensures the value persists across re-renders. - const [classifierRule] = React.useState(() => getClassifierApproval(toolUseID)); - const [yoloReason] = React.useState(() => getYoloClassifierApproval(toolUseID)); + const [classifierRule] = React.useState(() => + getClassifierApproval(toolUseID), + ) + const [yoloReason] = React.useState(() => + getYoloClassifierApproval(toolUseID), + ) React.useEffect(() => { - deleteClassifierApproval(toolUseID); - }, [toolUseID]); + deleteClassifierApproval(toolUseID) + }, [toolUseID]) + if (!message.toolUseResult || !tool) { - return null; + return null } // Resumed transcripts deserialize toolUseResult via raw JSON.parse with no // validation (parseJSONL). A partial/corrupt/old-format result crashes // renderToolResultMessage on first field access (anthropics/claude-code#39817). // Validate against outputSchema before rendering — mirrors CollapsedReadSearchContent. - const parsedOutput = tool.outputSchema?.safeParse(message.toolUseResult); + const parsedOutput = tool.outputSchema?.safeParse(message.toolUseResult) if (parsedOutput && !parsedOutput.success) { - return null; + return null } - const toolResult = parsedOutput?.data ?? message.toolUseResult; - const renderedMessage = tool.renderToolResultMessage?.(toolResult as never, filterToolProgressMessages(progressMessagesForMessage), { - style, - theme, - tools, - verbose, - isTranscriptMode, - isBriefOnly, - input: lookups.toolUseByToolUseID.get(toolUseID)?.input - }) ?? null; + const toolResult = parsedOutput?.data ?? message.toolUseResult + + const renderedMessage = + tool.renderToolResultMessage?.( + toolResult as never, + filterToolProgressMessages(progressMessagesForMessage), + { + style, + theme, + tools, + verbose, + isTranscriptMode, + isBriefOnly, + input: lookups.toolUseByToolUseID.get(toolUseID)?.input, + }, + ) ?? null // Don't render anything if the tool result message is null if (renderedMessage === null) { - return null; + return null } // Tools that return '' from userFacingName opt out of tool chrome and // render like plain assistant text. Skip the tool-result width constraint // so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col // dot gutter) holds — otherwise tables wrap their box-drawing chars. - const rendersAsAssistantText = tool.userFacingName(undefined) === ''; - return - + const rendersAsAssistantText = tool.userFacingName(undefined) === '' + + return ( + + {renderedMessage} - {feature('BASH_CLASSIFIER') ? classifierRule && + {feature('BASH_CLASSIFIER') + ? classifierRule && ( + {figures.tick} {' Auto-approved \u00b7 matched '} {`"${classifierRule}"`} - : null} - {feature('TRANSCRIPT_CLASSIFIER') ? yoloReason && + + ) + : null} + {feature('TRANSCRIPT_CLASSIFIER') + ? yoloReason && ( + Allowed by auto mode classifier - : null} + + ) + : null} - + - ; + + ) } diff --git a/src/components/messages/UserToolResultMessage/utils.tsx b/src/components/messages/UserToolResultMessage/utils.tsx index d86dccf16..4eeeb8004 100644 --- a/src/components/messages/UserToolResultMessage/utils.tsx +++ b/src/components/messages/UserToolResultMessage/utils.tsx @@ -1,43 +1,22 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import { useMemo } from 'react'; -import { findToolByName, type Tool, type Tools } from '../../../Tool.js'; -import type { buildMessageLookups } from '../../../utils/messages.js'; -export function useGetToolFromMessages(toolUseID, tools, lookups) { - const $ = _c(7); - let t0; - if ($[0] !== lookups.toolUseByToolUseID || $[1] !== toolUseID || $[2] !== tools) { - bb0: { - const toolUse = lookups.toolUseByToolUseID.get(toolUseID); - if (!toolUse) { - t0 = null; - break bb0; - } - const tool = findToolByName(tools, toolUse.name); - if (!tool) { - t0 = null; - break bb0; - } - let t1; - if ($[4] !== tool || $[5] !== toolUse) { - t1 = { - tool, - toolUse - }; - $[4] = tool; - $[5] = toolUse; - $[6] = t1; - } else { - t1 = $[6]; - } - t0 = t1; +import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { useMemo } from 'react' +import { findToolByName, type Tool, type Tools } from '../../../Tool.js' +import type { buildMessageLookups } from '../../../utils/messages.js' + +export function useGetToolFromMessages( + toolUseID: string, + tools: Tools, + lookups: ReturnType, +): { tool: Tool; toolUse: ToolUseBlockParam } | null { + return useMemo(() => { + const toolUse = lookups.toolUseByToolUseID.get(toolUseID) + if (!toolUse) { + return null } - $[0] = lookups.toolUseByToolUseID; - $[1] = toolUseID; - $[2] = tools; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + const tool = findToolByName(tools, toolUse.name) + if (!tool) { + return null + } + return { tool, toolUse } + }, [toolUseID, lookups, tools]) } diff --git a/src/components/messages/teamMemCollapsed.tsx b/src/components/messages/teamMemCollapsed.tsx index bcb0362c7..63fcdaf0e 100644 --- a/src/components/messages/teamMemCollapsed.tsx +++ b/src/components/messages/teamMemCollapsed.tsx @@ -1,7 +1,6 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import type { CollapsedReadSearchGroup } from '../../types/message.js'; +import React from 'react' +import { Text } from '../../ink.js' +import type { CollapsedReadSearchGroup } from '../../types/message.js' /** * Plain function (not a React component) so the React Compiler won't @@ -9,7 +8,11 @@ import type { CollapsedReadSearchGroup } from '../../types/message.js'; * is only loaded when feature('TEAMMEM') is true. */ export function checkHasTeamMemOps(message: CollapsedReadSearchGroup): boolean { - return (message.teamMemorySearchCount ?? 0) > 0 || (message.teamMemoryReadCount ?? 0) > 0 || (message.teamMemoryWriteCount ?? 0) > 0; + return ( + (message.teamMemorySearchCount ?? 0) > 0 || + (message.teamMemoryReadCount ?? 0) > 0 || + (message.teamMemoryWriteCount ?? 0) > 0 + ) } /** @@ -17,123 +20,79 @@ export function checkHasTeamMemOps(message: CollapsedReadSearchGroup): boolean { * This module is only loaded when feature('TEAMMEM') is true, * so DCE removes it entirely from external builds. */ -export function TeamMemCountParts(t0) { - const $ = _c(23); - const { - message, - isActiveGroup, - hasPrecedingParts - } = t0; - const tmReadCount = message.teamMemoryReadCount ?? 0; - const tmSearchCount = message.teamMemorySearchCount ?? 0; - const tmWriteCount = message.teamMemoryWriteCount ?? 0; +export function TeamMemCountParts({ + message, + isActiveGroup, + hasPrecedingParts, +}: { + message: CollapsedReadSearchGroup + isActiveGroup: boolean | undefined + hasPrecedingParts: boolean +}): React.ReactNode { + const tmReadCount = message.teamMemoryReadCount ?? 0 + const tmSearchCount = message.teamMemorySearchCount ?? 0 + const tmWriteCount = message.teamMemoryWriteCount ?? 0 + if (tmReadCount === 0 && tmSearchCount === 0 && tmWriteCount === 0) { - return null; + return null } - let t1; - if ($[0] !== hasPrecedingParts || $[1] !== isActiveGroup || $[2] !== tmReadCount || $[3] !== tmSearchCount || $[4] !== tmWriteCount) { - const nodes = []; - let count = hasPrecedingParts ? 1 : 0; - if (tmReadCount > 0) { - const verb = isActiveGroup ? count === 0 ? "Recalling" : "recalling" : count === 0 ? "Recalled" : "recalled"; - if (count > 0) { - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = , ; - $[6] = t2; - } else { - t2 = $[6]; - } - nodes.push(t2); - } - let t2; - if ($[7] !== tmReadCount) { - t2 = {tmReadCount}; - $[7] = tmReadCount; - $[8] = t2; - } else { - t2 = $[8]; - } - const t3 = tmReadCount === 1 ? "memory" : "memories"; - let t4; - if ($[9] !== t2 || $[10] !== t3 || $[11] !== verb) { - t4 = {verb} {t2} team{" "}{t3}; - $[9] = t2; - $[10] = t3; - $[11] = verb; - $[12] = t4; - } else { - t4 = $[12]; - } - nodes.push(t4); - count++; + + const nodes: React.ReactNode[] = [] + let count = hasPrecedingParts ? 1 : 0 + + if (tmReadCount > 0) { + const verb = isActiveGroup + ? count === 0 + ? 'Recalling' + : 'recalling' + : count === 0 + ? 'Recalled' + : 'recalled' + if (count > 0) { + nodes.push(, ) } - if (tmSearchCount > 0) { - const verb_0 = isActiveGroup ? count === 0 ? "Searching" : "searching" : count === 0 ? "Searched" : "searched"; - if (count > 0) { - let t2; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = , ; - $[13] = t2; - } else { - t2 = $[13]; - } - nodes.push(t2); - } - const t2 = `${verb_0} team memories`; - let t3; - if ($[14] !== t2) { - t3 = {t2}; - $[14] = t2; - $[15] = t3; - } else { - t3 = $[15]; - } - nodes.push(t3); - count++; + nodes.push( + + {verb} {tmReadCount} team{' '} + {tmReadCount === 1 ? 'memory' : 'memories'} + , + ) + count++ + } + + if (tmSearchCount > 0) { + const verb = isActiveGroup + ? count === 0 + ? 'Searching' + : 'searching' + : count === 0 + ? 'Searched' + : 'searched' + if (count > 0) { + nodes.push(, ) } - if (tmWriteCount > 0) { - const verb_1 = isActiveGroup ? count === 0 ? "Writing" : "writing" : count === 0 ? "Wrote" : "wrote"; - if (count > 0) { - let t2; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t2 = , ; - $[16] = t2; - } else { - t2 = $[16]; - } - nodes.push(t2); - } - let t2; - if ($[17] !== tmWriteCount) { - t2 = {tmWriteCount}; - $[17] = tmWriteCount; - $[18] = t2; - } else { - t2 = $[18]; - } - const t3 = tmWriteCount === 1 ? "memory" : "memories"; - let t4; - if ($[19] !== t2 || $[20] !== t3 || $[21] !== verb_1) { - t4 = {verb_1} {t2} team{" "}{t3}; - $[19] = t2; - $[20] = t3; - $[21] = verb_1; - $[22] = t4; - } else { - t4 = $[22]; - } - nodes.push(t4); + nodes.push({`${verb} team memories`}) + count++ + } + + if (tmWriteCount > 0) { + const verb = isActiveGroup + ? count === 0 + ? 'Writing' + : 'writing' + : count === 0 + ? 'Wrote' + : 'wrote' + if (count > 0) { + nodes.push(, ) } - t1 = <>{nodes}; - $[0] = hasPrecedingParts; - $[1] = isActiveGroup; - $[2] = tmReadCount; - $[3] = tmSearchCount; - $[4] = tmWriteCount; - $[5] = t1; - } else { - t1 = $[5]; + nodes.push( + + {verb} {tmWriteCount} team{' '} + {tmWriteCount === 1 ? 'memory' : 'memories'} + , + ) } - return t1; + + return <>{nodes} } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx index 81411534c..3768dbd73 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx @@ -1,243 +1,233 @@ -import { c as _c } from "react/compiler-runtime"; -import type { Base64ImageSource, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; -import React, { Suspense, use, useCallback, useMemo, useRef, useState } from 'react'; -import { useSettings } from '../../../hooks/useSettings.js'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../../ink/stringWidth.js'; -import { useTheme } from '../../../ink.js'; -import { useKeybindings } from '../../../keybindings/useKeybinding.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../../services/analytics/index.js'; -import { useAppState } from '../../../state/AppState.js'; -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import { AskUserQuestionTool } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import { type CliHighlight, getCliHighlightPromise } from '../../../utils/cliHighlight.js'; -import type { PastedContent } from '../../../utils/config.js'; -import type { ImageDimensions } from '../../../utils/imageResizer.js'; -import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js'; -import { cacheImagePath, storeImage } from '../../../utils/imageStore.js'; -import { logError } from '../../../utils/log.js'; -import { applyMarkdown } from '../../../utils/markdown.js'; -import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js'; -import { getPlanFilePath } from '../../../utils/plans.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { QuestionView } from './QuestionView.js'; -import { SubmitQuestionsView } from './SubmitQuestionsView.js'; -import { useMultipleChoiceState } from './use-multiple-choice-state.js'; -const MIN_CONTENT_HEIGHT = 12; -const MIN_CONTENT_WIDTH = 40; +import type { + Base64ImageSource, + ImageBlockParam, +} from '@anthropic-ai/sdk/resources/messages.mjs' +import React, { + Suspense, + use, + useCallback, + useMemo, + useRef, + useState, +} from 'react' +import { useSettings } from '../../../hooks/useSettings.js' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { stringWidth } from '../../../ink/stringWidth.js' +import { useTheme } from '../../../ink.js' +import { useKeybindings } from '../../../keybindings/useKeybinding.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../../services/analytics/index.js' +import { useAppState } from '../../../state/AppState.js' +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import { AskUserQuestionTool } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import { + type CliHighlight, + getCliHighlightPromise, +} from '../../../utils/cliHighlight.js' +import type { PastedContent } from '../../../utils/config.js' +import type { ImageDimensions } from '../../../utils/imageResizer.js' +import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js' +import { cacheImagePath, storeImage } from '../../../utils/imageStore.js' +import { logError } from '../../../utils/log.js' +import { applyMarkdown } from '../../../utils/markdown.js' +import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js' +import { getPlanFilePath } from '../../../utils/plans.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { QuestionView } from './QuestionView.js' +import { SubmitQuestionsView } from './SubmitQuestionsView.js' +import { useMultipleChoiceState } from './use-multiple-choice-state.js' + +const MIN_CONTENT_HEIGHT = 12 +const MIN_CONTENT_WIDTH = 40 // Lines used by chrome around the content area (nav bar, title, footer, help text, etc.) -const CONTENT_CHROME_OVERHEAD = 15; -export function AskUserQuestionPermissionRequest(props) { - const $ = _c(4); - const settings = useSettings(); +const CONTENT_CHROME_OVERHEAD = 15 + +export function AskUserQuestionPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const settings = useSettings() if (settings.syntaxHighlightingDisabled) { - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; + return } - let t0; - if ($[2] !== props) { - t0 = }>; - $[2] = props; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + return ( + + } + > + + + ) } -function AskUserQuestionWithHighlight(props) { - const $ = _c(4); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = getCliHighlightPromise(); - $[0] = t0; - } else { - t0 = $[0]; - } - const highlight = use(t0); - let t1; - if ($[1] !== highlight || $[2] !== props) { - t1 = ; - $[1] = highlight; - $[2] = props; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + +function AskUserQuestionWithHighlight( + props: PermissionRequestProps, +): React.ReactNode { + const highlight = use(getCliHighlightPromise()) + return ( + + ) } -function AskUserQuestionPermissionRequestBody(t0) { - const $ = _c(115); - const { - toolUseConfirm, - onDone, - onReject, - highlight - } = t0; - let t1; - if ($[0] !== toolUseConfirm.input) { - t1 = AskUserQuestionTool.inputSchema.safeParse(toolUseConfirm.input); - $[0] = toolUseConfirm.input; - $[1] = t1; - } else { - t1 = $[1]; - } - const result = t1; - let t2; - if ($[2] !== result.data || $[3] !== result.success) { - t2 = result.success ? result.data.questions || [] : []; - $[2] = result.data; - $[3] = result.success; - $[4] = t2; - } else { - t2 = $[4]; - } - const questions = t2; - const { - rows: terminalRows - } = useTerminalSize(); - const [theme] = useTheme(); - let maxHeight = 0; - let maxWidth = 0; - const maxAllowedHeight = Math.max(MIN_CONTENT_HEIGHT, terminalRows - CONTENT_CHROME_OVERHEAD); - if ($[5] !== highlight || $[6] !== maxAllowedHeight || $[7] !== maxHeight || $[8] !== maxWidth || $[9] !== questions || $[10] !== theme) { + +function AskUserQuestionPermissionRequestBody({ + toolUseConfirm, + onDone, + onReject, + highlight, +}: PermissionRequestProps & { + highlight: CliHighlight | null +}): React.ReactNode { + // Memoize parse result: safeParse returns a new object (and new `questions` + // array) on every call. Without this, the render-body ref writes below make + // React Compiler bail out on this component, so nothing is auto-memoized — + // `questions` changes identity every render, and the `globalContentHeight` + // useMemo (which runs applyMarkdown over every preview) never hits its cache. + // `toolUseConfirm.input` is stable for the dialog's lifetime (this tool + // returns `behavior: 'ask'` directly and never goes through the classifier). + const result = useMemo( + () => AskUserQuestionTool.inputSchema.safeParse(toolUseConfirm.input), + [toolUseConfirm.input], + ) + const questions = result.success ? result.data.questions || [] : [] + const { rows: terminalRows } = useTerminalSize() + const [theme] = useTheme() + + // Calculate consistent content dimensions across all questions to prevent layout shifts. + // globalContentHeight represents the total height of the content area below the nav/title, + // INCLUDING footer and help text, so all views (questions, previews, submit) match. + const { globalContentHeight, globalContentWidth } = useMemo(() => { + let maxHeight = 0 + let maxWidth = 0 + + // Footer (divider + "Chat about this" + optional plan) + help text ≈ 7 lines + const FOOTER_HELP_LINES = 7 + + // Cap at terminal height minus chrome overhead, but ensure at least MIN_CONTENT_HEIGHT + const maxAllowedHeight = Math.max( + MIN_CONTENT_HEIGHT, + terminalRows - CONTENT_CHROME_OVERHEAD, + ) + + // PREVIEW_OVERHEAD matches the constant in PreviewQuestionView.tsx — lines + // used by non-preview elements within the content area (margins, borders, + // notes, footer, help text). Used here to cap preview content so that + // globalContentHeight reflects the *truncated* height, not the raw height. + const PREVIEW_OVERHEAD = 11 + for (const q of questions) { - const hasPreview = q.options.some(_temp); + const hasPreview = q.options.some(opt => opt.preview) + if (hasPreview) { - const maxPreviewContentLines = Math.max(1, maxAllowedHeight - 11); - let maxPreviewBoxHeight = 0; - for (const opt_0 of q.options) { - if (opt_0.preview) { - const rendered = applyMarkdown(opt_0.preview, theme, highlight); - const previewLines = rendered.split("\n"); - const isTruncated = previewLines.length > maxPreviewContentLines; - const displayedLines = isTruncated ? maxPreviewContentLines : previewLines.length; - maxPreviewBoxHeight = Math.max(maxPreviewBoxHeight, displayedLines + (isTruncated ? 1 : 0) + 2); + // Compute the max preview content lines that would actually display + // after truncation, matching the logic in PreviewQuestionView. + const maxPreviewContentLines = Math.max( + 1, + maxAllowedHeight - PREVIEW_OVERHEAD, + ) + + // For preview questions, total = side-by-side height + footer/help + // Side-by-side = max(left panel, right panel) + // Right panel = preview box (content + borders + truncation indicator) + notes + let maxPreviewBoxHeight = 0 + for (const opt of q.options) { + if (opt.preview) { + // Measure the *rendered* markdown (same transform as PreviewBox) so + // that line counts and widths match what will actually be displayed. + // applyMarkdown removes code fence markers, bold/italic syntax, etc. + const rendered = applyMarkdown(opt.preview, theme, highlight) + const previewLines = rendered.split('\n') + const isTruncated = previewLines.length > maxPreviewContentLines + const displayedLines = isTruncated + ? maxPreviewContentLines + : previewLines.length + // Preview box: displayed content + truncation indicator + 2 borders + maxPreviewBoxHeight = Math.max( + maxPreviewBoxHeight, + displayedLines + (isTruncated ? 1 : 0) + 2, + ) for (const line of previewLines) { - maxWidth = Math.max(maxWidth, stringWidth(line)); + maxWidth = Math.max(maxWidth, stringWidth(line)) } } } - const rightPanelHeight = maxPreviewBoxHeight + 2; - const leftPanelHeight = q.options.length + 2; - const sideByHeight = Math.max(leftPanelHeight, rightPanelHeight); - maxHeight = Math.max(maxHeight, sideByHeight + 7); + // Right panel: preview box + notes (2 lines with margin) + const rightPanelHeight = maxPreviewBoxHeight + 2 + // Left panel: options + description + const leftPanelHeight = q.options.length + 2 + const sideByHeight = Math.max(leftPanelHeight, rightPanelHeight) + maxHeight = Math.max(maxHeight, sideByHeight + FOOTER_HELP_LINES) } else { - maxHeight = Math.max(maxHeight, q.options.length + 3 + 7); + // For regular questions: options + "Other" + footer/help + maxHeight = Math.max( + maxHeight, + q.options.length + 3 + FOOTER_HELP_LINES, + ) } } - $[5] = highlight; - $[6] = maxAllowedHeight; - $[7] = maxHeight; - $[8] = maxWidth; - $[9] = questions; - $[10] = theme; - $[11] = maxHeight; - } else { - maxHeight = $[11] as number; - } - const t3 = Math.min(Math.max(maxHeight, MIN_CONTENT_HEIGHT), maxAllowedHeight); - const t4 = Math.max(maxWidth, MIN_CONTENT_WIDTH); - let t5; - if ($[12] !== t3 || $[13] !== t4) { - t5 = { - globalContentHeight: t3, - globalContentWidth: t4 - }; - $[12] = t3; - $[13] = t4; - $[14] = t5; - } else { - t5 = $[14]; - } - const { - globalContentHeight, - globalContentWidth - } = t5; - const metadataSource = result.success ? result.data.metadata?.source : undefined; - let t6; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {}; - $[15] = t6; - } else { - t6 = $[15]; - } - const [pastedContentsByQuestion, setPastedContentsByQuestion] = useState(t6); - const nextPasteIdRef = useRef(0); - let t7; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t7 = function onImagePaste(questionText, base64Image, mediaType, filename, dimensions, _sourcePath) { - nextPasteIdRef.current = nextPasteIdRef.current + 1; - const pasteId = nextPasteIdRef.current; - const newContent = { - id: pasteId, - type: "image" as const, - content: base64Image, - mediaType: mediaType || "image/png", - filename: filename || "Pasted image", - dimensions - }; - cacheImagePath(newContent); - storeImage(newContent); - setPastedContentsByQuestion(prev => ({ - ...prev, - [questionText]: { - ...(prev[questionText] ?? {}), - [pasteId]: newContent - } - })); - }; - $[16] = t7; - } else { - t7 = $[16]; - } - const onImagePaste = t7; - let t8; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t8 = (questionText_0, id) => { - setPastedContentsByQuestion(prev_0 => { - const questionContents = { - ...(prev_0[questionText_0] ?? {}) - }; - delete questionContents[id]; - return { - ...prev_0, - [questionText_0]: questionContents - }; - }); - }; - $[17] = t8; - } else { - t8 = $[17]; - } - const onRemoveImage = t8; - let t9; - if ($[18] !== pastedContentsByQuestion) { - t9 = Object.values(pastedContentsByQuestion).flatMap(_temp2).filter(_temp3); - $[18] = pastedContentsByQuestion; - $[19] = t9; - } else { - t9 = $[19]; - } - const allImageAttachments = t9; - const toolPermissionContextMode = useAppState(_temp4); - const isInPlanMode = toolPermissionContextMode === "plan"; - let t10; - if ($[20] !== isInPlanMode) { - t10 = isInPlanMode ? getPlanFilePath() : undefined; - $[20] = isInPlanMode; - $[21] = t10; - } else { - t10 = $[21]; + + return { + globalContentHeight: Math.min( + Math.max(maxHeight, MIN_CONTENT_HEIGHT), + maxAllowedHeight, + ), + globalContentWidth: Math.max(maxWidth, MIN_CONTENT_WIDTH), + } + }, [questions, terminalRows, theme, highlight]) + const metadataSource = result.success + ? result.data.metadata?.source + : undefined + + const [pastedContentsByQuestion, setPastedContentsByQuestion] = useState< + Record> + >({}) + const nextPasteIdRef = useRef(0) + + function onImagePaste( + questionText: string, + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + _sourcePath?: string, + ) { + const pasteId = nextPasteIdRef.current++ + const newContent: PastedContent = { + id: pasteId, + type: 'image', + content: base64Image, + mediaType: mediaType || 'image/png', + filename: filename || 'Pasted image', + dimensions, + } + cacheImagePath(newContent) + void storeImage(newContent) + setPastedContentsByQuestion(prev => ({ + ...prev, + [questionText]: { ...(prev[questionText] ?? {}), [pasteId]: newContent }, + })) } - const planFilePath = t10; - const state = useMultipleChoiceState(); + + const onRemoveImage = useCallback((questionText: string, id: number) => { + setPastedContentsByQuestion(prev => { + const questionContents = { ...(prev[questionText] ?? {}) } + delete questionContents[id] + return { ...prev, [questionText]: questionContents } + }) + }, []) + + const allImageAttachments = Object.values(pastedContentsByQuestion) + .flatMap(contents => Object.values(contents)) + .filter(c => c.type === 'image') + + const toolPermissionContextMode = useAppState( + s => s.toolPermissionContext.mode, + ) + const isInPlanMode = toolPermissionContextMode === 'plan' + const planFilePath = isInPlanMode ? getPlanFilePath() : undefined + + const state = useMultipleChoiceState() const { currentQuestionIndex, answers, @@ -247,398 +237,369 @@ function AskUserQuestionPermissionRequestBody(t0) { prevQuestion, updateQuestionState, setAnswer, - setTextInputMode - } = state; - const currentQuestion = currentQuestionIndex < (questions?.length || 0) ? questions?.[currentQuestionIndex] : null; - const isInSubmitView = currentQuestionIndex === (questions?.length || 0); - let t11; - if ($[22] !== answers || $[23] !== questions) { - t11 = questions?.every(q_0 => q_0?.question && !!answers[q_0.question]) ?? false; - $[22] = answers; - $[23] = questions; - $[24] = t11; - } else { - t11 = $[24]; - } - const allQuestionsAnswered = t11; - const hideSubmitTab = questions.length === 1 && !questions[0]?.multiSelect; - let t12; - if ($[25] !== isInPlanMode || $[26] !== metadataSource || $[27] !== onDone || $[28] !== onReject || $[29] !== questions.length || $[30] !== toolUseConfirm) { - t12 = () => { - if (metadataSource) { - logEvent("tengu_ask_user_question_rejected", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - questionCount: questions.length, - isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); - } - onDone(); - onReject(); - toolUseConfirm.onReject(); - }; - $[25] = isInPlanMode; - $[26] = metadataSource; - $[27] = onDone; - $[28] = onReject; - $[29] = questions.length; - $[30] = toolUseConfirm; - $[31] = t12; - } else { - t12 = $[31]; - } - const handleCancel = t12; - let t13; - if ($[32] !== allImageAttachments || $[33] !== answers || $[34] !== isInPlanMode || $[35] !== metadataSource || $[36] !== onDone || $[37] !== questions || $[38] !== toolUseConfirm) { - t13 = async () => { - const questionsWithAnswers = questions.map(q_1 => { - const answer = answers[q_1.question]; + setTextInputMode, + } = state + + const currentQuestion = + currentQuestionIndex < (questions?.length || 0) + ? questions?.[currentQuestionIndex] + : null + + const isInSubmitView = currentQuestionIndex === (questions?.length || 0) + const allQuestionsAnswered = + questions?.every((q: Question) => q?.question && !!answers[q.question]) ?? + false + + // Hide submit tab when there's only one question and it's single-select (auto-submit scenario) + const hideSubmitTab = questions.length === 1 && !questions[0]?.multiSelect + + const handleCancel = useCallback(() => { + // Log rejection with metadata source if present + if (metadataSource) { + logEvent('tengu_ask_user_question_rejected', { + source: + metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + questionCount: questions.length, + isInPlanMode, + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }) + } + onDone() + onReject() + toolUseConfirm.onReject() + }, [ + onDone, + onReject, + toolUseConfirm, + metadataSource, + questions.length, + isInPlanMode, + ]) + + const handleRespondToClaude = useCallback(async () => { + const questionsWithAnswers = questions + .map((q: Question) => { + const answer = answers[q.question] if (answer) { - return `- "${q_1.question}"\n Answer: ${answer}`; + return `- "${q.question}"\n Answer: ${answer}` } - return `- "${q_1.question}"\n (No answer provided)`; - }).join("\n"); - const feedback = `The user wants to clarify these questions. + return `- "${q.question}"\n (No answer provided)` + }) + .join('\n') + + const feedback = `The user wants to clarify these questions. This means they may have additional information, context or questions for you. Take their response into account and then reformulate the questions if appropriate. Start by asking them what they would like to clarify. - Questions asked:\n${questionsWithAnswers}`; - if (metadataSource) { - logEvent("tengu_ask_user_question_respond_to_claude", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - questionCount: questions.length, - isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); - } - const imageBlocks = await convertImagesToBlocks(allImageAttachments); - onDone(); - toolUseConfirm.onReject(feedback, imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined); - }; - $[32] = allImageAttachments; - $[33] = answers; - $[34] = isInPlanMode; - $[35] = metadataSource; - $[36] = onDone; - $[37] = questions; - $[38] = toolUseConfirm; - $[39] = t13; - } else { - t13 = $[39]; - } - const handleRespondToClaude = t13; - let t14; - if ($[40] !== allImageAttachments || $[41] !== answers || $[42] !== isInPlanMode || $[43] !== metadataSource || $[44] !== onDone || $[45] !== questions || $[46] !== toolUseConfirm) { - t14 = async () => { - const questionsWithAnswers_0 = questions.map(q_2 => { - const answer_0 = answers[q_2.question]; - if (answer_0) { - return `- "${q_2.question}"\n Answer: ${answer_0}`; + Questions asked:\n${questionsWithAnswers}` + + if (metadataSource) { + logEvent('tengu_ask_user_question_respond_to_claude', { + source: + metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + questionCount: questions.length, + isInPlanMode, + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }) + } + + const imageBlocks = await convertImagesToBlocks(allImageAttachments) + + onDone() + toolUseConfirm.onReject( + feedback, + imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, + ) + }, [ + questions, + answers, + onDone, + toolUseConfirm, + metadataSource, + isInPlanMode, + allImageAttachments, + ]) + + const handleFinishPlanInterview = useCallback(async () => { + const questionsWithAnswers = questions + .map((q: Question) => { + const answer = answers[q.question] + if (answer) { + return `- "${q.question}"\n Answer: ${answer}` } - return `- "${q_2.question}"\n (No answer provided)`; - }).join("\n"); - const feedback_0 = `The user has indicated they have provided enough answers for the plan interview. + return `- "${q.question}"\n (No answer provided)` + }) + .join('\n') + + const feedback = `The user has indicated they have provided enough answers for the plan interview. Stop asking clarifying questions and proceed to finish the plan with the information you have. -Questions asked and answers provided:\n${questionsWithAnswers_0}`; - if (metadataSource) { - logEvent("tengu_ask_user_question_finish_plan_interview", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - questionCount: questions.length, - isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); - } - const imageBlocks_0 = await convertImagesToBlocks(allImageAttachments); - onDone(); - toolUseConfirm.onReject(feedback_0, imageBlocks_0 && imageBlocks_0.length > 0 ? imageBlocks_0 : undefined); - }; - $[40] = allImageAttachments; - $[41] = answers; - $[42] = isInPlanMode; - $[43] = metadataSource; - $[44] = onDone; - $[45] = questions; - $[46] = toolUseConfirm; - $[47] = t14; - } else { - t14 = $[47]; - } - const handleFinishPlanInterview = t14; - let t15; - if ($[48] !== allImageAttachments || $[49] !== isInPlanMode || $[50] !== metadataSource || $[51] !== onDone || $[52] !== questionStates || $[53] !== questions || $[54] !== toolUseConfirm) { - t15 = async answersToSubmit => { +Questions asked and answers provided:\n${questionsWithAnswers}` + + if (metadataSource) { + logEvent('tengu_ask_user_question_finish_plan_interview', { + source: + metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + questionCount: questions.length, + isInPlanMode, + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }) + } + + const imageBlocks = await convertImagesToBlocks(allImageAttachments) + + onDone() + toolUseConfirm.onReject( + feedback, + imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, + ) + }, [ + questions, + answers, + onDone, + toolUseConfirm, + metadataSource, + isInPlanMode, + allImageAttachments, + ]) + + const submitAnswers = useCallback( + async (answersToSubmit: Record) => { + // Log acceptance with metadata source if present if (metadataSource) { - logEvent("tengu_ask_user_question_accepted", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent('tengu_ask_user_question_accepted', { + source: + metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, questionCount: questions.length, answerCount: Object.keys(answersToSubmit).length, isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }) } - const annotations = {}; - for (const q_3 of questions) { - const answer_1 = answersToSubmit[q_3.question]; - const notes = questionStates[q_3.question]?.textInputValue; - const selectedOption = answer_1 ? q_3.options.find(opt_1 => opt_1.label === answer_1) : undefined; - const preview = selectedOption?.preview; + // Build annotations from questionStates (e.g., selected preview, user notes) + const annotations: Record = + {} + for (const q of questions) { + const answer = answersToSubmit[q.question] + const notes = questionStates[q.question]?.textInputValue + // Find the selected option's preview content + const selectedOption = answer + ? q.options.find(opt => opt.label === answer) + : undefined + const preview = selectedOption?.preview if (preview || notes?.trim()) { - annotations[q_3.question] = { - ...(preview && { - preview - }), - ...(notes?.trim() && { - notes: notes.trim() - }) - }; + annotations[q.question] = { + ...(preview && { preview }), + ...(notes?.trim() && { notes: notes.trim() }), + } } } + const updatedInput = { ...toolUseConfirm.input, answers: answersToSubmit, - ...(Object.keys(annotations).length > 0 && { - annotations - }) - }; - const contentBlocks = await convertImagesToBlocks(allImageAttachments); - onDone(); - toolUseConfirm.onAllow(updatedInput, [], undefined, contentBlocks && contentBlocks.length > 0 ? contentBlocks : undefined); - }; - $[48] = allImageAttachments; - $[49] = isInPlanMode; - $[50] = metadataSource; - $[51] = onDone; - $[52] = questionStates; - $[53] = questions; - $[54] = toolUseConfirm; - $[55] = t15; - } else { - t15 = $[55]; - } - const submitAnswers = t15; - let t16; - if ($[56] !== answers || $[57] !== pastedContentsByQuestion || $[58] !== questions.length || $[59] !== setAnswer || $[60] !== submitAnswers) { - t16 = (questionText_1, label, textInput, t17) => { - const shouldAdvance = t17 === undefined ? true : t17; - let answer_2; - const isMultiSelect = Array.isArray(label); + ...(Object.keys(annotations).length > 0 && { annotations }), + } + + const contentBlocks = await convertImagesToBlocks(allImageAttachments) + + onDone() + toolUseConfirm.onAllow( + updatedInput, + [], + undefined, + contentBlocks && contentBlocks.length > 0 ? contentBlocks : undefined, + ) + }, + [ + toolUseConfirm, + onDone, + metadataSource, + questions, + questionStates, + isInPlanMode, + allImageAttachments, + ], + ) + + const handleQuestionAnswer = useCallback( + ( + questionText: string, + label: string | string[], + textInput?: string, + shouldAdvance: boolean = true, + ) => { + let answer: string + const isMultiSelect = Array.isArray(label) if (isMultiSelect) { - answer_2 = label.join(", "); + answer = label.join(', ') } else { if (textInput) { - const questionImages = Object.values(pastedContentsByQuestion[questionText_1] ?? {}).filter(_temp5); - answer_2 = questionImages.length > 0 ? `${textInput} (Image attached)` : textInput; + const questionImages = Object.values( + pastedContentsByQuestion[questionText] ?? {}, + ).filter(c => c.type === 'image') + answer = + questionImages.length > 0 + ? `${textInput} (Image attached)` + : textInput + } else if (label === '__other__') { + // Image-only submission — check if this question has images + const questionImages = Object.values( + pastedContentsByQuestion[questionText] ?? {}, + ).filter(c => c.type === 'image') + answer = questionImages.length > 0 ? '(Image attached)' : label } else { - if (label === "__other__") { - const questionImages_0 = Object.values(pastedContentsByQuestion[questionText_1] ?? {}).filter(_temp6); - answer_2 = questionImages_0.length > 0 ? "(Image attached)" : label; - } else { - answer_2 = label; - } + answer = label } } - const isSingleQuestion = questions.length === 1; + + // For single-select with only one question, auto-submit instead of showing review screen + const isSingleQuestion = questions.length === 1 if (!isMultiSelect && isSingleQuestion && shouldAdvance) { const updatedAnswers = { ...answers, - [questionText_1]: answer_2 - }; - submitAnswers(updatedAnswers).catch(logError); - return; - } - setAnswer(questionText_1, answer_2, shouldAdvance); - }; - $[56] = answers; - $[57] = pastedContentsByQuestion; - $[58] = questions.length; - $[59] = setAnswer; - $[60] = submitAnswers; - $[61] = t16; - } else { - t16 = $[61]; - } - const handleQuestionAnswer = t16; - let t17; - if ($[62] !== answers || $[63] !== handleCancel || $[64] !== submitAnswers) { - t17 = function handleFinalResponse(value) { - if (value === "cancel") { - handleCancel(); - return; - } - if (value === "submit") { - submitAnswers(answers).catch(logError); - } - }; - $[62] = answers; - $[63] = handleCancel; - $[64] = submitAnswers; - $[65] = t17; - } else { - t17 = $[65]; - } - const handleFinalResponse = t17; - const maxIndex = hideSubmitTab ? (questions?.length || 1) - 1 : questions?.length || 0; - let t18; - if ($[66] !== currentQuestionIndex || $[67] !== prevQuestion) { - t18 = () => { - if (currentQuestionIndex > 0) { - prevQuestion(); - } - }; - $[66] = currentQuestionIndex; - $[67] = prevQuestion; - $[68] = t18; - } else { - t18 = $[68]; - } - const handleTabPrev = t18; - let t19; - if ($[69] !== currentQuestionIndex || $[70] !== maxIndex || $[71] !== nextQuestion) { - t19 = () => { - if (currentQuestionIndex < maxIndex) { - nextQuestion(); + [questionText]: answer, + } + void submitAnswers(updatedAnswers).catch(logError) + return } - }; - $[69] = currentQuestionIndex; - $[70] = maxIndex; - $[71] = nextQuestion; - $[72] = t19; - } else { - t19 = $[72]; - } - const handleTabNext = t19; - let t20; - if ($[73] !== handleTabNext || $[74] !== handleTabPrev) { - t20 = { - "tabs:previous": handleTabPrev, - "tabs:next": handleTabNext - }; - $[73] = handleTabNext; - $[74] = handleTabPrev; - $[75] = t20; - } else { - t20 = $[75]; - } - const t21 = !(isInTextInput && !isInSubmitView); - let t22; - if ($[76] !== t21) { - t22 = { - context: "Tabs", - isActive: t21 - }; - $[76] = t21; - $[77] = t22; - } else { - t22 = $[77]; - } - useKeybindings(t20, t22); - if (currentQuestion) { - let t23; - if ($[78] !== currentQuestion.question) { - t23 = (base64, mediaType_0, filename_0, dims, path) => onImagePaste(currentQuestion.question, base64, mediaType_0, filename_0, dims, path); - $[78] = currentQuestion.question; - $[79] = t23; - } else { - t23 = $[79]; + + setAnswer(questionText, answer, shouldAdvance) + }, + [ + setAnswer, + questions.length, + answers, + submitAnswers, + pastedContentsByQuestion, + ], + ) + + function handleFinalResponse(value: 'submit' | 'cancel'): void { + if (value === 'cancel') { + handleCancel() + return } - let t24; - if ($[80] !== currentQuestion.question || $[81] !== pastedContentsByQuestion) { - t24 = pastedContentsByQuestion[currentQuestion.question] ?? {}; - $[80] = currentQuestion.question; - $[81] = pastedContentsByQuestion; - $[82] = t24; - } else { - t24 = $[82]; + + if (value === 'submit') { + void submitAnswers(answers).catch(logError) } - let t25; - if ($[83] !== currentQuestion.question) { - t25 = id_0 => onRemoveImage(currentQuestion.question, id_0); - $[83] = currentQuestion.question; - $[84] = t25; - } else { - t25 = $[84]; + } + + // When submit tab is hidden, don't allow navigating past the last question + const maxIndex = hideSubmitTab + ? (questions?.length || 1) - 1 + : questions?.length || 0 + + // Bounded navigation callbacks for question tabs + const handleTabPrev = useCallback(() => { + if (currentQuestionIndex > 0) { + prevQuestion() } - let t26; - if ($[85] !== answers || $[86] !== currentQuestion || $[87] !== currentQuestionIndex || $[88] !== globalContentHeight || $[89] !== globalContentWidth || $[90] !== handleCancel || $[91] !== handleFinishPlanInterview || $[92] !== handleQuestionAnswer || $[93] !== handleRespondToClaude || $[94] !== handleTabNext || $[95] !== handleTabPrev || $[96] !== hideSubmitTab || $[97] !== nextQuestion || $[98] !== planFilePath || $[99] !== questionStates || $[100] !== questions || $[101] !== setTextInputMode || $[102] !== t23 || $[103] !== t24 || $[104] !== t25 || $[105] !== updateQuestionState) { - t26 = <>; - $[85] = answers; - $[86] = currentQuestion; - $[87] = currentQuestionIndex; - $[88] = globalContentHeight; - $[89] = globalContentWidth; - $[90] = handleCancel; - $[91] = handleFinishPlanInterview; - $[92] = handleQuestionAnswer; - $[93] = handleRespondToClaude; - $[94] = handleTabNext; - $[95] = handleTabPrev; - $[96] = hideSubmitTab; - $[97] = nextQuestion; - $[98] = planFilePath; - $[99] = questionStates; - $[100] = questions; - $[101] = setTextInputMode; - $[102] = t23; - $[103] = t24; - $[104] = t25; - $[105] = updateQuestionState; - $[106] = t26; - } else { - t26 = $[106]; + }, [currentQuestionIndex, prevQuestion]) + + const handleTabNext = useCallback(() => { + if (currentQuestionIndex < maxIndex) { + nextQuestion() } - return t26; + }, [currentQuestionIndex, maxIndex, nextQuestion]) + + // Use keybindings system for question navigation (left/right arrows, tab/shift+tab) + // Raw useInput doesn't work because the keybinding system resolves left/right arrows + // to tabs:next/tabs:previous and may stopImmediatePropagation before useInput fires. + // Child components (e.g., PreviewQuestionView) also register their own tabs:next/tabs:previous + // keybindings to ensure reliable handling regardless of listener ordering. + useKeybindings( + { + 'tabs:previous': handleTabPrev, + 'tabs:next': handleTabNext, + }, + { context: 'Tabs', isActive: !(isInTextInput && !isInSubmitView) }, + ) + + if (currentQuestion) { + return ( + <> + + onImagePaste( + currentQuestion.question, + base64, + mediaType, + filename, + dims, + path, + ) + } + pastedContents={ + pastedContentsByQuestion[currentQuestion.question] ?? {} + } + onRemoveImage={id => onRemoveImage(currentQuestion.question, id)} + /> + + ) } + if (isInSubmitView) { - let t23; - if ($[107] !== allQuestionsAnswered || $[108] !== answers || $[109] !== currentQuestionIndex || $[110] !== globalContentHeight || $[111] !== handleFinalResponse || $[112] !== questions || $[113] !== toolUseConfirm.permissionResult) { - t23 = <>; - $[107] = allQuestionsAnswered; - $[108] = answers; - $[109] = currentQuestionIndex; - $[110] = globalContentHeight; - $[111] = handleFinalResponse; - $[112] = questions; - $[113] = toolUseConfirm.permissionResult; - $[114] = t23; - } else { - t23 = $[114]; - } - return t23; + return ( + <> + + + ) } - return null; -} -function _temp6(c_1) { - return c_1.type === "image"; -} -function _temp5(c_0) { - return c_0.type === "image"; -} -function _temp4(s) { - return s.toolPermissionContext.mode; -} -function _temp3(c) { - return c.type === "image"; -} -function _temp2(contents) { - return Object.values(contents); -} -function _temp(opt) { - return opt.preview; + + // This should never be reached + return null } -async function convertImagesToBlocks(images: PastedContent[]): Promise { - if (images.length === 0) return undefined; - return Promise.all(images.map(async img => { - const block: ImageBlockParam = { - type: 'image', - source: { - type: 'base64', - media_type: (img.mediaType || 'image/png') as Base64ImageSource['media_type'], - data: img.content + +async function convertImagesToBlocks( + images: PastedContent[], +): Promise { + if (images.length === 0) return undefined + return Promise.all( + images.map(async img => { + const block: ImageBlockParam = { + type: 'image', + source: { + type: 'base64', + media_type: (img.mediaType || + 'image/png') as Base64ImageSource['media_type'], + data: img.content, + }, } - }; - const resized = await maybeResizeAndDownsampleImageBlock(block); - return resized.block; - })); + const resized = await maybeResizeAndDownsampleImageBlock(block) + return resized.block + }), + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx index c48f4e4d9..7b4fd6149 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx @@ -1,25 +1,29 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Suspense, use, useMemo } from 'react'; -import { useSettings } from '../../../hooks/useSettings.js'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../../ink/stringWidth.js'; -import { Ansi, Box, Text, useTheme } from '../../../ink.js'; -import { type CliHighlight, getCliHighlightPromise } from '../../../utils/cliHighlight.js'; -import { applyMarkdown } from '../../../utils/markdown.js'; -import sliceAnsi from '../../../utils/sliceAnsi.js'; +import React, { Suspense, use, useMemo } from 'react' +import { useSettings } from '../../../hooks/useSettings.js' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { stringWidth } from '../../../ink/stringWidth.js' +import { Ansi, Box, Text, useTheme } from '../../../ink.js' +import { + type CliHighlight, + getCliHighlightPromise, +} from '../../../utils/cliHighlight.js' +import { applyMarkdown } from '../../../utils/markdown.js' +import sliceAnsi from '../../../utils/sliceAnsi.js' + type PreviewBoxProps = { /** The preview content to display. Markdown is rendered with syntax highlighting * for code blocks (```ts, ```py, etc.). Also supports plain multi-line text. */ - content: string; + content: string /** Maximum number of lines to display before truncating. @default 20 */ - maxLines?: number; + maxLines?: number /** Minimum height (in lines) for the preview box. Content will be padded if shorter. */ - minHeight?: number; + minHeight?: number /** Minimum width for the preview box. @default 40 */ - minWidth?: number; + minWidth?: number /** Maximum width available for this box (e.g., the container width). */ - maxWidth?: number; -}; + maxWidth?: number +} + const BOX_CHARS = { topLeft: '┌', topRight: '┐', @@ -28,201 +32,127 @@ const BOX_CHARS = { horizontal: '─', vertical: '│', teeLeft: '├', - teeRight: '┤' -}; + teeRight: '┤', +} /** * A bordered monospace box for displaying preview content. * Truncates content that exceeds maxLines with an indicator. * The parent component should pass maxLines based on its available height budget. */ -export function PreviewBox(props) { - const $ = _c(4); - const settings = useSettings(); +export function PreviewBox(props: PreviewBoxProps): React.ReactNode { + const settings = useSettings() if (settings.syntaxHighlightingDisabled) { - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; - } - let t0; - if ($[2] !== props) { - t0 = }>; - $[2] = props; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; -} -function PreviewBoxWithHighlight(props) { - const $ = _c(4); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = getCliHighlightPromise(); - $[0] = t0; - } else { - t0 = $[0]; + return } - const highlight = use(t0); - let t1; - if ($[1] !== highlight || $[2] !== props) { - t1 = ; - $[1] = highlight; - $[2] = props; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + return ( + }> + + + ) } -function PreviewBoxBody(t0) { - const $ = _c(34); - const { - content, - maxLines, - minHeight, - minWidth: t1, - maxWidth, - highlight - } = t0; - const minWidth = t1 === undefined ? 40 : t1; - const { - columns: terminalWidth - } = useTerminalSize(); - const [theme] = useTheme(); - const effectiveMaxWidth = maxWidth ?? terminalWidth - 4; - const effectiveMaxLines = maxLines ?? 20; - let t2; - if ($[0] !== content || $[1] !== highlight || $[2] !== theme) { - t2 = applyMarkdown(content, theme, highlight); - $[0] = content; - $[1] = highlight; - $[2] = theme; - $[3] = t2; - } else { - t2 = $[3]; - } - const rendered = t2; - let T0; - let bottomBorder; - let t3; - let t4; - let t5; - let truncationBar; - if ($[4] !== effectiveMaxLines || $[5] !== effectiveMaxWidth || $[6] !== minHeight || $[7] !== minWidth || $[8] !== rendered) { - const contentLines = rendered.split("\n"); - const isTruncated = contentLines.length > effectiveMaxLines; - const truncatedLines = isTruncated ? contentLines.slice(0, effectiveMaxLines) : contentLines; - const effectiveMinHeight = Math.min(minHeight ?? 0, effectiveMaxLines); - const paddingNeeded = Math.max(0, effectiveMinHeight - truncatedLines.length - (isTruncated ? 1 : 0)); - const lines = paddingNeeded > 0 ? [...truncatedLines, ...Array(paddingNeeded).fill("")] : truncatedLines; - const contentWidth = Math.max(minWidth, ...lines.map(_temp)); - const boxWidth = Math.min(contentWidth + 4, effectiveMaxWidth); - const innerWidth = boxWidth - 4; - let t6; - if ($[15] !== boxWidth) { - t6 = BOX_CHARS.horizontal.repeat(boxWidth - 2); - $[15] = boxWidth; - $[16] = t6; - } else { - t6 = $[16]; - } - const topBorder = `${BOX_CHARS.topLeft}${t6}${BOX_CHARS.topRight}`; - let t7; - if ($[17] !== boxWidth) { - t7 = BOX_CHARS.horizontal.repeat(boxWidth - 2); - $[17] = boxWidth; - $[18] = t7; - } else { - t7 = $[18]; - } - bottomBorder = `${BOX_CHARS.bottomLeft}${t7}${BOX_CHARS.bottomRight}`; - truncationBar = isTruncated ? (() => { - const hiddenCount = contentLines.length - effectiveMaxLines; - const label = `${BOX_CHARS.horizontal.repeat(3)} \u2702 ${BOX_CHARS.horizontal.repeat(3)} ${hiddenCount} lines hidden `; - const labelWidth = stringWidth(label); - const fillWidth = Math.max(0, boxWidth - 2 - labelWidth); - return `${BOX_CHARS.teeLeft}${label}${BOX_CHARS.horizontal.repeat(fillWidth)}${BOX_CHARS.teeRight}`; - })() : null; - T0 = Box; - t3 = "column"; - if ($[19] !== topBorder) { - t4 = {topBorder}; - $[19] = topBorder; - $[20] = t4; - } else { - t4 = $[20]; - } - let t8; - if ($[21] !== innerWidth) { - t8 = (line_0, index) => { - const lineWidth = stringWidth(line_0); - const displayLine = lineWidth > innerWidth ? sliceAnsi(line_0, 0, innerWidth) : line_0; - const padding = " ".repeat(Math.max(0, innerWidth - stringWidth(displayLine))); - return {BOX_CHARS.vertical} {displayLine}{padding} {BOX_CHARS.vertical}; - }; - $[21] = innerWidth; - $[22] = t8; - } else { - t8 = $[22]; - } - t5 = lines.map(t8); - $[4] = effectiveMaxLines; - $[5] = effectiveMaxWidth; - $[6] = minHeight; - $[7] = minWidth; - $[8] = rendered; - $[9] = T0; - $[10] = bottomBorder; - $[11] = t3; - $[12] = t4; - $[13] = t5; - $[14] = truncationBar; - } else { - T0 = $[9]; - bottomBorder = $[10]; - t3 = $[11]; - t4 = $[12]; - t5 = $[13]; - truncationBar = $[14]; - } - let t6; - if ($[23] !== truncationBar) { - t6 = truncationBar && {truncationBar}; - $[23] = truncationBar; - $[24] = t6; - } else { - t6 = $[24]; - } - let t7; - if ($[25] !== bottomBorder) { - t7 = {bottomBorder}; - $[25] = bottomBorder; - $[26] = t7; - } else { - t7 = $[26]; - } - let t8; - if ($[27] !== T0 || $[28] !== t3 || $[29] !== t4 || $[30] !== t5 || $[31] !== t6 || $[32] !== t7) { - t8 = {t4}{t5}{t6}{t7}; - $[27] = T0; - $[28] = t3; - $[29] = t4; - $[30] = t5; - $[31] = t6; - $[32] = t7; - $[33] = t8; - } else { - t8 = $[33]; - } - return t8; + +function PreviewBoxWithHighlight(props: PreviewBoxProps): React.ReactNode { + const highlight = use(getCliHighlightPromise()) + return } -function _temp(line) { - return stringWidth(line); + +function PreviewBoxBody({ + content, + maxLines, + minHeight, + minWidth = 40, + maxWidth, + highlight, +}: PreviewBoxProps & { highlight: CliHighlight | null }): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const [theme] = useTheme() + const effectiveMaxWidth = maxWidth ?? terminalWidth - 4 + + // Use provided maxLines, or a reasonable default + const effectiveMaxLines = maxLines ?? 20 + + // Render markdown with syntax highlighting for code blocks. applyMarkdown + // returns an ANSI-styled string (bold, colors, etc.) that we split into + // lines. stringWidth and sliceAnsi below correctly handle ANSI codes. + const rendered = useMemo( + () => applyMarkdown(content, theme, highlight), + [content, theme, highlight], + ) + const contentLines = rendered.split('\n') + const isTruncated = contentLines.length > effectiveMaxLines + + // Truncate to effectiveMaxLines + const truncatedLines = isTruncated + ? contentLines.slice(0, effectiveMaxLines) + : contentLines + + // Pad content with empty lines if shorter than minHeight, but never exceed + // the truncation limit — otherwise padding undoes the truncation + const effectiveMinHeight = Math.min(minHeight ?? 0, effectiveMaxLines) + const paddingNeeded = Math.max( + 0, + effectiveMinHeight - truncatedLines.length - (isTruncated ? 1 : 0), + ) + const lines = + paddingNeeded > 0 + ? [...truncatedLines, ...Array(paddingNeeded).fill('')] + : truncatedLines + + // Calculate content width (max visual line width, handling unicode/emoji/CJK) + const contentWidth = Math.max( + minWidth, + ...lines.map(line => stringWidth(line)), + ) + // Add 2 for border padding, cap at the container width to prevent line wrapping + const boxWidth = Math.min(contentWidth + 4, effectiveMaxWidth) + const innerWidth = boxWidth - 4 // Account for borders and padding + + // Render top border + const topBorder = `${BOX_CHARS.topLeft}${BOX_CHARS.horizontal.repeat(boxWidth - 2)}${BOX_CHARS.topRight}` + + // Render bottom border + const bottomBorder = `${BOX_CHARS.bottomLeft}${BOX_CHARS.horizontal.repeat(boxWidth - 2)}${BOX_CHARS.bottomRight}` + + // Build the truncation separator bar (e.g. ├─── ✂ ─── 42 lines hidden ──────┤) + const truncationBar = isTruncated + ? (() => { + const hiddenCount = contentLines.length - effectiveMaxLines + const label = `${BOX_CHARS.horizontal.repeat(3)} \u2702 ${BOX_CHARS.horizontal.repeat(3)} ${hiddenCount} lines hidden ` + const labelWidth = stringWidth(label) + const fillWidth = Math.max(0, boxWidth - 2 - labelWidth) + return `${BOX_CHARS.teeLeft}${label}${BOX_CHARS.horizontal.repeat(fillWidth)}${BOX_CHARS.teeRight}` + })() + : null + + return ( + + {topBorder} + + {lines.map((line, index) => { + // Pad or truncate line to fit inner width (using visual width for unicode/emoji/CJK). + // sliceAnsi handles ANSI escape codes correctly; stringWidth strips them before measuring. + const lineWidth = stringWidth(line) + const displayLine = + lineWidth > innerWidth ? sliceAnsi(line, 0, innerWidth) : line + const padding = ' '.repeat( + Math.max(0, innerWidth - stringWidth(displayLine)), + ) + + return ( + + {BOX_CHARS.vertical} + {displayLine} + + {padding} {BOX_CHARS.vertical} + + + ) + })} + + {truncationBar && {truncationBar}} + + {bottomBorder} + + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx index f00c9b4a2..78289da5f 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx @@ -1,38 +1,51 @@ -import figures from 'figures'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../ink.js'; -import { useKeybinding, useKeybindings } from '../../../keybindings/useKeybinding.js'; -import { useAppState } from '../../../state/AppState.js'; -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import { getExternalEditor } from '../../../utils/editor.js'; -import { toIDEDisplayName } from '../../../utils/ide.js'; -import { editPromptInEditor } from '../../../utils/promptEditor.js'; -import { Divider } from '../../design-system/Divider.js'; -import TextInput from '../../TextInput.js'; -import { PermissionRequestTitle } from '../PermissionRequestTitle.js'; -import { PreviewBox } from './PreviewBox.js'; -import { QuestionNavigationBar } from './QuestionNavigationBar.js'; -import type { QuestionState } from './use-multiple-choice-state.js'; +import figures from 'figures' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../ink.js' +import { + useKeybinding, + useKeybindings, +} from '../../../keybindings/useKeybinding.js' +import { useAppState } from '../../../state/AppState.js' +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import { getExternalEditor } from '../../../utils/editor.js' +import { toIDEDisplayName } from '../../../utils/ide.js' +import { editPromptInEditor } from '../../../utils/promptEditor.js' +import { Divider } from '../../design-system/Divider.js' +import TextInput from '../../TextInput.js' +import { PermissionRequestTitle } from '../PermissionRequestTitle.js' +import { PreviewBox } from './PreviewBox.js' +import { QuestionNavigationBar } from './QuestionNavigationBar.js' +import type { QuestionState } from './use-multiple-choice-state.js' + type Props = { - question: Question; - questions: Question[]; - currentQuestionIndex: number; - answers: Record; - questionStates: Record; - hideSubmitTab?: boolean; - minContentHeight?: number; - minContentWidth?: number; - onUpdateQuestionState: (questionText: string, updates: Partial, isMultiSelect: boolean) => void; - onAnswer: (questionText: string, label: string | string[], textInput?: string, shouldAdvance?: boolean) => void; - onTextInputFocus: (isInInput: boolean) => void; - onCancel: () => void; - onTabPrev?: () => void; - onTabNext?: () => void; - onRespondToClaude: () => void; - onFinishPlanInterview: () => void; -}; + question: Question + questions: Question[] + currentQuestionIndex: number + answers: Record + questionStates: Record + hideSubmitTab?: boolean + minContentHeight?: number + minContentWidth?: number + onUpdateQuestionState: ( + questionText: string, + updates: Partial, + isMultiSelect: boolean, + ) => void + onAnswer: ( + questionText: string, + label: string | string[], + textInput?: string, + shouldAdvance?: boolean, + ) => void + onTextInputFocus: (isInInput: boolean) => void + onCancel: () => void + onTabPrev?: () => void + onTabNext?: () => void + onRespondToClaude: () => void + onFinishPlanInterview: () => void +} /** * A side-by-side question view for questions with preview content. @@ -54,188 +67,235 @@ export function PreviewQuestionView({ onTabPrev, onTabNext, onRespondToClaude, - onFinishPlanInterview + onFinishPlanInterview, }: Props): React.ReactNode { - const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan'; - const [isFooterFocused, setIsFooterFocused] = useState(false); - const [footerIndex, setFooterIndex] = useState(0); - const [isInNotesInput, setIsInNotesInput] = useState(false); - const [cursorOffset, setCursorOffset] = useState(0); - const editor = getExternalEditor(); - const editorName = editor ? toIDEDisplayName(editor) : null; - const questionText = question.question; - const questionState = questionStates[questionText]; + const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan' + const [isFooterFocused, setIsFooterFocused] = useState(false) + const [footerIndex, setFooterIndex] = useState(0) + const [isInNotesInput, setIsInNotesInput] = useState(false) + const [cursorOffset, setCursorOffset] = useState(0) + + const editor = getExternalEditor() + const editorName = editor ? toIDEDisplayName(editor) : null + + const questionText = question.question + const questionState = questionStates[questionText] // Only real options — no "Other" for preview questions - const allOptions = question.options; + const allOptions = question.options // Track which option is focused (for preview display) - const [focusedIndex, setFocusedIndex] = useState(0); + const [focusedIndex, setFocusedIndex] = useState(0) // Reset focusedIndex when navigating to a different question - const prevQuestionText = useRef(questionText); + const prevQuestionText = useRef(questionText) if (prevQuestionText.current !== questionText) { - prevQuestionText.current = questionText; - const selected = questionState?.selectedValue as string | undefined; - const idx = selected ? allOptions.findIndex(opt => opt.label === selected) : -1; - setFocusedIndex(idx >= 0 ? idx : 0); + prevQuestionText.current = questionText + const selected = questionState?.selectedValue as string | undefined + const idx = selected + ? allOptions.findIndex(opt => opt.label === selected) + : -1 + setFocusedIndex(idx >= 0 ? idx : 0) } - const focusedOption = allOptions[focusedIndex]; - const selectedValue = questionState?.selectedValue as string | undefined; - const notesValue = questionState?.textInputValue || ''; - const handleSelectOption = useCallback((index: number) => { - const option = allOptions[index]; - if (!option) return; - setFocusedIndex(index); - onUpdateQuestionState(questionText, { - selectedValue: option.label - }, false); - onAnswer(questionText, option.label); - }, [allOptions, questionText, onUpdateQuestionState, onAnswer]); - const handleNavigate = useCallback((direction: 'up' | 'down' | number) => { - if (isInNotesInput) return; - let newIndex: number; - if (typeof direction === 'number') { - newIndex = direction; - } else if (direction === 'up') { - newIndex = focusedIndex > 0 ? focusedIndex - 1 : focusedIndex; - } else { - newIndex = focusedIndex < allOptions.length - 1 ? focusedIndex + 1 : focusedIndex; - } - if (newIndex >= 0 && newIndex < allOptions.length) { - setFocusedIndex(newIndex); - } - }, [focusedIndex, allOptions.length, isInNotesInput]); + + const focusedOption = allOptions[focusedIndex] + const selectedValue = questionState?.selectedValue as string | undefined + const notesValue = questionState?.textInputValue || '' + + const handleSelectOption = useCallback( + (index: number) => { + const option = allOptions[index] + if (!option) return + + setFocusedIndex(index) + onUpdateQuestionState( + questionText, + { selectedValue: option.label }, + false, + ) + + onAnswer(questionText, option.label) + }, + [allOptions, questionText, onUpdateQuestionState, onAnswer], + ) + + const handleNavigate = useCallback( + (direction: 'up' | 'down' | number) => { + if (isInNotesInput) return + + let newIndex: number + if (typeof direction === 'number') { + newIndex = direction + } else if (direction === 'up') { + newIndex = focusedIndex > 0 ? focusedIndex - 1 : focusedIndex + } else { + newIndex = + focusedIndex < allOptions.length - 1 ? focusedIndex + 1 : focusedIndex + } + + if (newIndex >= 0 && newIndex < allOptions.length) { + setFocusedIndex(newIndex) + } + }, + [focusedIndex, allOptions.length, isInNotesInput], + ) // Handle ctrl+g to open external editor for notes - useKeybinding('chat:externalEditor', async () => { - const currentValue = questionState?.textInputValue || ''; - const result = await editPromptInEditor(currentValue); - if (result.content !== null && result.content !== currentValue) { - onUpdateQuestionState(questionText, { - textInputValue: result.content - }, false); - } - }, { - context: 'Chat', - isActive: isInNotesInput && !!editor - }); + useKeybinding( + 'chat:externalEditor', + async () => { + const currentValue = questionState?.textInputValue || '' + const result = await editPromptInEditor(currentValue) + if (result.content !== null && result.content !== currentValue) { + onUpdateQuestionState( + questionText, + { textInputValue: result.content }, + false, + ) + } + }, + { context: 'Chat', isActive: isInNotesInput && !!editor }, + ) // Handle left/right arrow and tab for question navigation. // This must be in the child component (not just the parent) because child useInput // handlers register first on the event emitter and fire before parent handlers. // Without this, the parent's useKeybindings may not fire reliably depending on // listener ordering in the event emitter. - useKeybindings({ - 'tabs:previous': () => onTabPrev?.(), - 'tabs:next': () => onTabNext?.() - }, { - context: 'Tabs', - isActive: !isInNotesInput && !isFooterFocused - }); + useKeybindings( + { + 'tabs:previous': () => onTabPrev?.(), + 'tabs:next': () => onTabNext?.(), + }, + { context: 'Tabs', isActive: !isInNotesInput && !isFooterFocused }, + ) // Re-submit the answer (plain label) when exiting notes input. // Notes are stored in questionStates and collected at submit time via annotations. const handleNotesExit = useCallback(() => { - setIsInNotesInput(false); - onTextInputFocus(false); + setIsInNotesInput(false) + onTextInputFocus(false) if (selectedValue) { - onAnswer(questionText, selectedValue); + onAnswer(questionText, selectedValue) } - }, [selectedValue, questionText, onAnswer, onTextInputFocus]); + }, [selectedValue, questionText, onAnswer, onTextInputFocus]) + const handleDownFromPreview = useCallback(() => { - setIsFooterFocused(true); - }, []); + setIsFooterFocused(true) + }, []) + const handleUpFromFooter = useCallback(() => { - setIsFooterFocused(false); - }, []); + setIsFooterFocused(false) + }, []) // Handle keyboard input for option/footer/notes navigation. // Always active — the handler routes internally based on isFooterFocused/isInNotesInput. - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (isFooterFocused) { - if (e.key === 'up' || e.ctrl && e.key === 'p') { - e.preventDefault(); - if (footerIndex === 0) { - handleUpFromFooter(); - } else { - setFooterIndex(0); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isFooterFocused) { + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + if (footerIndex === 0) { + handleUpFromFooter() + } else { + setFooterIndex(0) + } + return } - return; + + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + if (isInPlanMode && footerIndex === 0) { + setFooterIndex(1) + } + return + } + + if (e.key === 'return') { + e.preventDefault() + if (footerIndex === 0) { + onRespondToClaude() + } else { + onFinishPlanInterview() + } + return + } + + if (e.key === 'escape') { + e.preventDefault() + onCancel() + } + return } - if (e.key === 'down' || e.ctrl && e.key === 'n') { - e.preventDefault(); - if (isInPlanMode && footerIndex === 0) { - setFooterIndex(1); + + if (isInNotesInput) { + // In notes input mode, handle escape to exit back to option navigation + if (e.key === 'escape') { + e.preventDefault() + handleNotesExit() } - return; + return } - if (e.key === 'return') { - e.preventDefault(); - if (footerIndex === 0) { - onRespondToClaude(); + + // Handle option navigation (vertical) + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + if (focusedIndex > 0) { + handleNavigate('up') + } + } else if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + if (focusedIndex === allOptions.length - 1) { + // At bottom of options, go to footer + handleDownFromPreview() } else { - onFinishPlanInterview(); + handleNavigate('down') + } + } else if (e.key === 'return') { + e.preventDefault() + handleSelectOption(focusedIndex) + } else if (e.key === 'n' && !e.ctrl && !e.meta) { + // Press 'n' to focus the notes input + e.preventDefault() + setIsInNotesInput(true) + onTextInputFocus(true) + } else if (e.key === 'escape') { + e.preventDefault() + onCancel() + } else if (e.key.length === 1 && e.key >= '1' && e.key <= '9') { + e.preventDefault() + const idx = parseInt(e.key, 10) - 1 + if (idx < allOptions.length) { + handleNavigate(idx) } - return; - } - if (e.key === 'escape') { - e.preventDefault(); - onCancel(); - } - return; - } - if (isInNotesInput) { - // In notes input mode, handle escape to exit back to option navigation - if (e.key === 'escape') { - e.preventDefault(); - handleNotesExit(); } - return; - } + }, + [ + isFooterFocused, + footerIndex, + isInPlanMode, + isInNotesInput, + focusedIndex, + allOptions.length, + handleUpFromFooter, + handleDownFromPreview, + handleNavigate, + handleSelectOption, + handleNotesExit, + onRespondToClaude, + onFinishPlanInterview, + onCancel, + onTextInputFocus, + ], + ) - // Handle option navigation (vertical) - if (e.key === 'up' || e.ctrl && e.key === 'p') { - e.preventDefault(); - if (focusedIndex > 0) { - handleNavigate('up'); - } - } else if (e.key === 'down' || e.ctrl && e.key === 'n') { - e.preventDefault(); - if (focusedIndex === allOptions.length - 1) { - // At bottom of options, go to footer - handleDownFromPreview(); - } else { - handleNavigate('down'); - } - } else if (e.key === 'return') { - e.preventDefault(); - handleSelectOption(focusedIndex); - } else if (e.key === 'n' && !e.ctrl && !e.meta) { - // Press 'n' to focus the notes input - e.preventDefault(); - setIsInNotesInput(true); - onTextInputFocus(true); - } else if (e.key === 'escape') { - e.preventDefault(); - onCancel(); - } else if (e.key.length === 1 && e.key >= '1' && e.key <= '9') { - e.preventDefault(); - const idx_0 = parseInt(e.key, 10) - 1; - if (idx_0 < allOptions.length) { - handleNavigate(idx_0); - } - } - }, [isFooterFocused, footerIndex, isInPlanMode, isInNotesInput, focusedIndex, allOptions.length, handleUpFromFooter, handleDownFromPreview, handleNavigate, handleSelectOption, handleNotesExit, onRespondToClaude, onFinishPlanInterview, onCancel, onTextInputFocus]); - const previewContent = focusedOption?.preview || null; + const previewContent = focusedOption?.preview || null // The right panel's available width is terminal minus the left panel and gap. - const LEFT_PANEL_WIDTH = 30; - const GAP = 4; - const { - columns - } = useTerminalSize(); - const previewMaxWidth = columns - LEFT_PANEL_WIDTH - GAP; + const LEFT_PANEL_WIDTH = 30 + const GAP = 4 + const { columns } = useTerminalSize() + const previewMaxWidth = columns - LEFT_PANEL_WIDTH - GAP // Lines used within the content area that aren't preview content: // 1: marginTop on side-by-side box @@ -245,19 +305,34 @@ export function PreviewQuestionView({ // 1: "Chat about this" line // 1: plan mode line (may or may not show) // 2: help text (marginTop=1 + text) - const PREVIEW_OVERHEAD = 11; + const PREVIEW_OVERHEAD = 11 // Compute the max lines available for preview content from the parent's // height budget to prevent terminal overflow. We do NOT pad shorter options // to match the tallest — the outer box's minHeight handles cross-question // layout consistency, and within-question shifts are acceptable. const previewMaxLines = useMemo(() => { - return minContentHeight ? Math.max(1, minContentHeight - PREVIEW_OVERHEAD) : undefined; - }, [minContentHeight]); - return + return minContentHeight + ? Math.max(1, minContentHeight - PREVIEW_OVERHEAD) + : undefined + }, [minContentHeight]) + + return ( + - + @@ -265,33 +340,71 @@ export function PreviewQuestionView({ {/* Left panel: vertical option list */} - {allOptions.map((option_0, index_0) => { - const isFocused = focusedIndex === index_0; - const isSelected = selectedValue === option_0.label; - return - {isFocused ? {figures.pointer} : } - {index_0 + 1}. - + {allOptions.map((option, index) => { + const isFocused = focusedIndex === index + const isSelected = selectedValue === option.label + + return ( + + {isFocused ? ( + {figures.pointer} + ) : ( + + )} + {index + 1}. + {' '} - {option_0.label} + {option.label} {isSelected && {figures.tick}} - ; - })} + + ) + })} {/* Right panel: preview + notes */} - + Notes: - {isInNotesInput ? { - onUpdateQuestionState(questionText, { - textInputValue: value - }, false); - }} onSubmit={handleNotesExit} onExit={handleNotesExit} focus={true} showCursor={true} columns={60} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} /> : + {isInNotesInput ? ( + { + onUpdateQuestionState( + questionText, + { textInputValue: value }, + false, + ) + }} + onSubmit={handleNotesExit} + onExit={handleNotesExit} + focus={true} + showCursor={true} + columns={60} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + /> + ) : ( + {notesValue || 'press n to add notes'} - } + + )} @@ -300,28 +413,53 @@ export function PreviewQuestionView({ - {isFooterFocused && footerIndex === 0 ? {figures.pointer} : } - + {isFooterFocused && footerIndex === 0 ? ( + {figures.pointer} + ) : ( + + )} + Chat about this - {isInPlanMode && - {isFooterFocused && footerIndex === 1 ? {figures.pointer} : } - + {isInPlanMode && ( + + {isFooterFocused && footerIndex === 1 ? ( + {figures.pointer} + ) : ( + + )} + Skip interview and plan immediately - } + + )} Enter to select · {figures.arrowUp}/{figures.arrowDown} to navigate · n to add notes {questions.length > 1 && <> · Tab to switch questions} - {isInNotesInput && editorName && <> · ctrl+g to edit in {editorName}}{' '} + {isInNotesInput && editorName && ( + <> · ctrl+g to edit in {editorName} + )}{' '} · Esc to cancel - ; + + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx index d6094492d..3440e9daf 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx @@ -1,177 +1,151 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useMemo } from 'react'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../../ink/stringWidth.js'; -import { Box, Text } from '../../../ink.js'; -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import { truncateToWidth } from '../../../utils/format.js'; +import figures from 'figures' +import React, { useMemo } from 'react' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { stringWidth } from '../../../ink/stringWidth.js' +import { Box, Text } from '../../../ink.js' +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import { truncateToWidth } from '../../../utils/format.js' + type Props = { - questions: Question[]; - currentQuestionIndex: number; - answers: Record; - hideSubmitTab?: boolean; -}; -export function QuestionNavigationBar(t0) { - const $ = _c(39); - const { - questions, - currentQuestionIndex, - answers, - hideSubmitTab: t1 - } = t0; - const hideSubmitTab = t1 === undefined ? false : t1; - const { - columns - } = useTerminalSize(); - let t2; - if ($[0] !== columns || $[1] !== currentQuestionIndex || $[2] !== hideSubmitTab || $[3] !== questions) { - bb0: { - const submitText = hideSubmitTab ? "" : ` ${figures.tick} Submit `; - const fixedWidth = stringWidth("\u2190 ") + stringWidth(" \u2192") + stringWidth(submitText); - const availableForTabs = columns - fixedWidth; - if (availableForTabs <= 0) { - let t3; - if ($[5] !== currentQuestionIndex || $[6] !== questions) { - let t4; - if ($[8] !== currentQuestionIndex) { - t4 = (q, index) => { - const header = q?.header || `Q${index + 1}`; - return index === currentQuestionIndex ? header.slice(0, 3) : ""; - }; - $[8] = currentQuestionIndex; - $[9] = t4; - } else { - t4 = $[9]; - } - t3 = questions.map(t4); - $[5] = currentQuestionIndex; - $[6] = questions; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = t3; - break bb0; - } - const tabHeaders = questions.map(_temp); - const idealWidths = tabHeaders.map(_temp2); - const totalIdealWidth = idealWidths.reduce(_temp3, 0); - if (totalIdealWidth <= availableForTabs) { - t2 = tabHeaders; - break bb0; - } - const currentHeader = tabHeaders[currentQuestionIndex] || ""; - const currentIdealWidth = 4 + stringWidth(currentHeader); - const currentTabWidth = Math.min(currentIdealWidth, availableForTabs / 2); - const remainingWidth = availableForTabs - currentTabWidth; - const otherTabCount = questions.length - 1; - const widthPerOtherTab = Math.max(6, Math.floor(remainingWidth / Math.max(otherTabCount, 1))); - let t3; - if ($[10] !== currentQuestionIndex || $[11] !== currentTabWidth || $[12] !== widthPerOtherTab) { - t3 = (header_1, index_1) => { - if (index_1 === currentQuestionIndex) { - const maxTextWidth = currentTabWidth - 2 - 2; - return truncateToWidth(header_1, maxTextWidth); - } else { - const maxTextWidth_0 = widthPerOtherTab - 2 - 2; - return truncateToWidth(header_1, maxTextWidth_0); - } - }; - $[10] = currentQuestionIndex; - $[11] = currentTabWidth; - $[12] = widthPerOtherTab; - $[13] = t3; - } else { - t3 = $[13]; - } - t2 = tabHeaders.map(t3); + questions: Question[] + currentQuestionIndex: number + answers: Record + hideSubmitTab?: boolean +} + +export function QuestionNavigationBar({ + questions, + currentQuestionIndex, + answers, + hideSubmitTab = false, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + + // Calculate the display text for each tab based on available width + const tabDisplayTexts = useMemo(() => { + // Calculate fixed width elements + const leftArrow = '← ' + const rightArrow = ' →' + const submitText = hideSubmitTab ? '' : ` ${figures.tick} Submit ` + const checkboxWidth = 2 // checkbox + space + const paddingPerTab = 2 // space before and after each tab text + + const fixedWidth = + stringWidth(leftArrow) + stringWidth(rightArrow) + stringWidth(submitText) + + // Available width for all question tabs + const availableForTabs = columns - fixedWidth + + if (availableForTabs <= 0) { + // Terminal too narrow, fallback to minimal display + return questions.map((q: Question, index: number) => { + const header = q?.header || `Q${index + 1}` + return index === currentQuestionIndex ? header.slice(0, 3) : '' + }) } - $[0] = columns; - $[1] = currentQuestionIndex; - $[2] = hideSubmitTab; - $[3] = questions; - $[4] = t2; - } else { - t2 = $[4]; - } - const tabDisplayTexts = t2; - const hideArrows = questions.length === 1 && hideSubmitTab; - let t3; - if ($[14] !== currentQuestionIndex || $[15] !== hideArrows) { - t3 = !hideArrows && ←{" "}; - $[14] = currentQuestionIndex; - $[15] = hideArrows; - $[16] = t3; - } else { - t3 = $[16]; - } - let t4; - if ($[17] !== answers || $[18] !== currentQuestionIndex || $[19] !== questions || $[20] !== tabDisplayTexts) { - let t5; - if ($[22] !== answers || $[23] !== currentQuestionIndex || $[24] !== tabDisplayTexts) { - t5 = (q_1, index_2) => { - const isSelected = index_2 === currentQuestionIndex; - const isAnswered = q_1?.question && !!answers[q_1.question]; - const checkbox = isAnswered ? figures.checkboxOn : figures.checkboxOff; - const displayText = tabDisplayTexts[index_2] || q_1?.header || `Q${index_2 + 1}`; - return {isSelected ? {" "}{checkbox} {displayText}{" "} : {" "}{checkbox} {displayText}{" "}}; - }; - $[22] = answers; - $[23] = currentQuestionIndex; - $[24] = tabDisplayTexts; - $[25] = t5; - } else { - t5 = $[25]; + + // Calculate ideal width for each tab (checkbox + padding + text) + const tabHeaders = questions.map( + (q: Question, index: number) => q?.header || `Q${index + 1}`, + ) + const idealWidths = tabHeaders.map( + header => checkboxWidth + paddingPerTab + stringWidth(header), + ) + + // Calculate total ideal width + const totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0) + + // If everything fits, use full headers + if (totalIdealWidth <= availableForTabs) { + return tabHeaders } - t4 = questions.map(t5); - $[17] = answers; - $[18] = currentQuestionIndex; - $[19] = questions; - $[20] = tabDisplayTexts; - $[21] = t4; - } else { - t4 = $[21]; - } - let t5; - if ($[26] !== currentQuestionIndex || $[27] !== hideSubmitTab || $[28] !== questions.length) { - t5 = !hideSubmitTab && {currentQuestionIndex === questions.length ? {" "}{figures.tick} Submit{" "} : {figures.tick} Submit }; - $[26] = currentQuestionIndex; - $[27] = hideSubmitTab; - $[28] = questions.length; - $[29] = t5; - } else { - t5 = $[29]; - } - let t6; - if ($[30] !== currentQuestionIndex || $[31] !== hideArrows || $[32] !== questions.length) { - t6 = !hideArrows && {" "}→; - $[30] = currentQuestionIndex; - $[31] = hideArrows; - $[32] = questions.length; - $[33] = t6; - } else { - t6 = $[33]; - } - let t7; - if ($[34] !== t3 || $[35] !== t4 || $[36] !== t5 || $[37] !== t6) { - t7 = {t3}{t4}{t5}{t6}; - $[34] = t3; - $[35] = t4; - $[36] = t5; - $[37] = t6; - $[38] = t7; - } else { - t7 = $[38]; - } - return t7; -} -function _temp3(sum, w) { - return sum + w; -} -function _temp2(header_0) { - return 4 + stringWidth(header_0); -} -function _temp(q_0, index_0) { - return q_0?.header || `Q${index_0 + 1}`; + + // Need to truncate - prioritize current tab + const currentHeader = tabHeaders[currentQuestionIndex] || '' + const currentIdealWidth = + checkboxWidth + paddingPerTab + stringWidth(currentHeader) + + // Minimum width for other tabs (checkbox + padding + 1 char + ellipsis) + const minWidthPerTab = checkboxWidth + paddingPerTab + 2 // "X…" + + // Calculate space for current tab (try to show full text) + const currentTabWidth = Math.min(currentIdealWidth, availableForTabs / 2) + const remainingWidth = availableForTabs - currentTabWidth + + // Calculate space for other tabs + const otherTabCount = questions.length - 1 + const widthPerOtherTab = Math.max( + minWidthPerTab, + Math.floor(remainingWidth / Math.max(otherTabCount, 1)), + ) + + return tabHeaders.map((header, index) => { + if (index === currentQuestionIndex) { + // Current tab - show as much as possible + const maxTextWidth = currentTabWidth - checkboxWidth - paddingPerTab + return truncateToWidth(header, maxTextWidth) + } else { + // Other tabs - truncate to fit + const maxTextWidth = widthPerOtherTab - checkboxWidth - paddingPerTab + return truncateToWidth(header, maxTextWidth) + } + }) + }, [questions, currentQuestionIndex, columns, hideSubmitTab]) + + const hideArrows = questions.length === 1 && hideSubmitTab + + return ( + + {!hideArrows && ( + + ←{' '} + + )} + {questions.map((q: Question, index: number) => { + const isSelected = index === currentQuestionIndex + const isAnswered = q?.question && !!answers[q.question] + const checkbox = isAnswered ? figures.checkboxOn : figures.checkboxOff + const displayText = + tabDisplayTexts[index] || q?.header || `Q${index + 1}` + + return ( + + {isSelected ? ( + + {' '} + {checkbox} {displayText}{' '} + + ) : ( + + {' '} + {checkbox} {displayText}{' '} + + )} + + ) + })} + {!hideSubmitTab && ( + + {currentQuestionIndex === questions.length ? ( + + {' '} + {figures.tick} Submit{' '} + + ) : ( + {figures.tick} Submit + )} + + )} + {!hideArrows && ( + + {' '} + → + + )} + + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx index 87be921ac..ef45238ab 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx @@ -1,464 +1,398 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useCallback, useState } from 'react'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../ink.js'; -import { useAppState } from '../../../state/AppState.js'; -import type { Question, QuestionOption } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import type { PastedContent } from '../../../utils/config.js'; -import { getExternalEditor } from '../../../utils/editor.js'; -import { toIDEDisplayName } from '../../../utils/ide.js'; -import type { ImageDimensions } from '../../../utils/imageResizer.js'; -import { editPromptInEditor } from '../../../utils/promptEditor.js'; -import { type OptionWithDescription, Select, SelectMulti } from '../../CustomSelect/index.js'; -import { Divider } from '../../design-system/Divider.js'; -import { FilePathLink } from '../../FilePathLink.js'; -import { PermissionRequestTitle } from '../PermissionRequestTitle.js'; -import { PreviewQuestionView } from './PreviewQuestionView.js'; -import { QuestionNavigationBar } from './QuestionNavigationBar.js'; -import type { QuestionState } from './use-multiple-choice-state.js'; +import figures from 'figures' +import React, { useCallback, useState } from 'react' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../ink.js' +import { useAppState } from '../../../state/AppState.js' +import type { + Question, + QuestionOption, +} from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import type { PastedContent } from '../../../utils/config.js' +import { getExternalEditor } from '../../../utils/editor.js' +import { toIDEDisplayName } from '../../../utils/ide.js' +import type { ImageDimensions } from '../../../utils/imageResizer.js' +import { editPromptInEditor } from '../../../utils/promptEditor.js' +import { + type OptionWithDescription, + Select, + SelectMulti, +} from '../../CustomSelect/index.js' +import { Divider } from '../../design-system/Divider.js' +import { FilePathLink } from '../../FilePathLink.js' +import { PermissionRequestTitle } from '../PermissionRequestTitle.js' +import { PreviewQuestionView } from './PreviewQuestionView.js' +import { QuestionNavigationBar } from './QuestionNavigationBar.js' +import type { QuestionState } from './use-multiple-choice-state.js' + type Props = { - question: Question; - questions: Question[]; - currentQuestionIndex: number; - answers: Record; - questionStates: Record; - hideSubmitTab?: boolean; - planFilePath?: string; - pastedContents?: Record; - minContentHeight?: number; - minContentWidth?: number; - onUpdateQuestionState: (questionText: string, updates: Partial, isMultiSelect: boolean) => void; - onAnswer: (questionText: string, label: string | string[], textInput?: string, shouldAdvance?: boolean) => void; - onTextInputFocus: (isInInput: boolean) => void; - onCancel: () => void; - onSubmit: () => void; - onTabPrev?: () => void; - onTabNext?: () => void; - onRespondToClaude: () => void; - onFinishPlanInterview: () => void; - onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; - onRemoveImage?: (id: number) => void; -}; -export function QuestionView(t0) { - const $ = _c(114); - const { - question, - questions, - currentQuestionIndex, - answers, - questionStates, - hideSubmitTab: t1, - planFilePath, - minContentHeight, - minContentWidth, - onUpdateQuestionState, - onAnswer, - onTextInputFocus, - onCancel, - onSubmit, - onTabPrev, - onTabNext, - onRespondToClaude, - onFinishPlanInterview, - onImagePaste, - pastedContents, - onRemoveImage - } = t0; - const hideSubmitTab = t1 === undefined ? false : t1; - const isInPlanMode = useAppState(_temp) === "plan"; - const [isFooterFocused, setIsFooterFocused] = useState(false); - const [footerIndex, setFooterIndex] = useState(0); - const [isOtherFocused, setIsOtherFocused] = useState(false); - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - const editor = getExternalEditor(); - t2 = editor ? toIDEDisplayName(editor) : null; - $[0] = t2; - } else { - t2 = $[0]; - } - const editorName = t2; - let t3; - if ($[1] !== onTextInputFocus) { - t3 = value => { - const isOther = value === "__other__"; - setIsOtherFocused(isOther); - onTextInputFocus(isOther); - }; - $[1] = onTextInputFocus; - $[2] = t3; - } else { - t3 = $[2]; - } - const handleFocus = t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = () => { - setIsFooterFocused(true); - }; - $[3] = t4; - } else { - t4 = $[3]; - } - const handleDownFromLastItem = t4; - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = () => { - setIsFooterFocused(false); - }; - $[4] = t5; - } else { - t5 = $[4]; - } - const handleUpFromFooter = t5; - let t6; - if ($[5] !== footerIndex || $[6] !== isFooterFocused || $[7] !== isInPlanMode || $[8] !== onCancel || $[9] !== onFinishPlanInterview || $[10] !== onRespondToClaude) { - t6 = e => { - if (!isFooterFocused) { - return; - } - if (e.key === "up" || e.ctrl && e.key === "p") { - e.preventDefault(); + question: Question + questions: Question[] + currentQuestionIndex: number + answers: Record + questionStates: Record + hideSubmitTab?: boolean + planFilePath?: string + pastedContents?: Record + minContentHeight?: number + minContentWidth?: number + onUpdateQuestionState: ( + questionText: string, + updates: Partial, + isMultiSelect: boolean, + ) => void + onAnswer: ( + questionText: string, + label: string | string[], + textInput?: string, + shouldAdvance?: boolean, + ) => void + onTextInputFocus: (isInInput: boolean) => void + onCancel: () => void + onSubmit: () => void + onTabPrev?: () => void + onTabNext?: () => void + onRespondToClaude: () => void + onFinishPlanInterview: () => void + onImagePaste?: ( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) => void + onRemoveImage?: (id: number) => void +} + +export function QuestionView({ + question, + questions, + currentQuestionIndex, + answers, + questionStates, + hideSubmitTab = false, + planFilePath, + minContentHeight, + minContentWidth, + onUpdateQuestionState, + onAnswer, + onTextInputFocus, + onCancel, + onSubmit, + onTabPrev, + onTabNext, + onRespondToClaude, + onFinishPlanInterview, + onImagePaste, + pastedContents, + onRemoveImage, +}: Props): React.ReactNode { + const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan' + const [isFooterFocused, setIsFooterFocused] = useState(false) + const [footerIndex, setFooterIndex] = useState(0) + const [isOtherFocused, setIsOtherFocused] = useState(false) + + const editor = getExternalEditor() + const editorName = editor ? toIDEDisplayName(editor) : null + + const handleFocus = useCallback( + (value: string) => { + const isOther = value === '__other__' + setIsOtherFocused(isOther) + onTextInputFocus(isOther) + }, + [onTextInputFocus], + ) + + const handleDownFromLastItem = useCallback(() => { + setIsFooterFocused(true) + }, []) + + const handleUpFromFooter = useCallback(() => { + setIsFooterFocused(false) + }, []) + + // Handle keyboard input when footer is focused + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isFooterFocused) return + + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() if (footerIndex === 0) { - handleUpFromFooter(); + handleUpFromFooter() } else { - setFooterIndex(0); + setFooterIndex(0) } - return; + return } - if (e.key === "down" || e.ctrl && e.key === "n") { - e.preventDefault(); + + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() if (isInPlanMode && footerIndex === 0) { - setFooterIndex(1); + setFooterIndex(1) } - return; + return } - if (e.key === "return") { - e.preventDefault(); + + if (e.key === 'return') { + e.preventDefault() if (footerIndex === 0) { - onRespondToClaude(); + onRespondToClaude() } else { - onFinishPlanInterview(); + onFinishPlanInterview() } - return; + return } - if (e.key === "escape") { - e.preventDefault(); - onCancel(); + + if (e.key === 'escape') { + e.preventDefault() + onCancel() } - }; - $[5] = footerIndex; - $[6] = isFooterFocused; - $[7] = isInPlanMode; - $[8] = onCancel; - $[9] = onFinishPlanInterview; - $[10] = onRespondToClaude; - $[11] = t6; - } else { - t6 = $[11]; - } - const handleKeyDown = t6; - let handleOpenEditor; - let questionText; - let t7; - if ($[12] !== onUpdateQuestionState || $[13] !== question || $[14] !== questionStates) { - const textOptions = question.options.map(_temp2); - questionText = question.question; - const questionState = questionStates[questionText]; - let t8; - if ($[18] !== onUpdateQuestionState || $[19] !== question.multiSelect || $[20] !== questionText) { - t8 = async (currentValue, setValue) => { - const result = await editPromptInEditor(currentValue); - if (result.content !== null && result.content !== currentValue) { - setValue(result.content); - onUpdateQuestionState(questionText, { - textInputValue: result.content - }, question.multiSelect ?? false); - } - }; - $[18] = onUpdateQuestionState; - $[19] = question.multiSelect; - $[20] = questionText; - $[21] = t8; - } else { - t8 = $[21]; - } - handleOpenEditor = t8; - const t9 = question.multiSelect ? "Type something" : "Type something."; - const t10 = questionState?.textInputValue ?? ""; - let t11; - if ($[22] !== onUpdateQuestionState || $[23] !== question.multiSelect || $[24] !== questionText) { - t11 = value_0 => { - onUpdateQuestionState(questionText, { - textInputValue: value_0 - }, question.multiSelect ?? false); - }; - $[22] = onUpdateQuestionState; - $[23] = question.multiSelect; - $[24] = questionText; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== t10 || $[27] !== t11 || $[28] !== t9) { - t12 = { - type: "input" as const, - value: "__other__", - label: "Other", - placeholder: t9, - initialValue: t10, - onChange: t11 - }; - $[26] = t10; - $[27] = t11; - $[28] = t9; - $[29] = t12; - } else { - t12 = $[29]; - } - const otherOption = t12; - t7 = [...textOptions, otherOption]; - $[12] = onUpdateQuestionState; - $[13] = question; - $[14] = questionStates; - $[15] = handleOpenEditor; - $[16] = questionText; - $[17] = t7; - } else { - handleOpenEditor = $[15]; - questionText = $[16]; - t7 = $[17]; + }, + [ + isFooterFocused, + footerIndex, + isInPlanMode, + handleUpFromFooter, + onRespondToClaude, + onFinishPlanInterview, + onCancel, + ], + ) + + const textOptions: OptionWithDescription[] = question.options.map( + (opt: QuestionOption) => ({ + type: 'text' as const, + value: opt.label, + label: opt.label, + description: opt.description, + }), + ) + + const questionText = question.question + const questionState = questionStates[questionText] + + const handleOpenEditor = useCallback( + async (currentValue: string, setValue: (value: string) => void) => { + const result = await editPromptInEditor(currentValue) + + if (result.content !== null && result.content !== currentValue) { + // Update the Select's internal state for immediate UI update + setValue(result.content) + // Also update the question state for persistence + onUpdateQuestionState( + questionText, + { textInputValue: result.content }, + question.multiSelect ?? false, + ) + } + }, + [questionText, onUpdateQuestionState, question.multiSelect], + ) + + const otherOption: OptionWithDescription = { + type: 'input' as const, + value: '__other__', + label: 'Other', + placeholder: question.multiSelect ? 'Type something' : 'Type something.', + initialValue: questionState?.textInputValue ?? '', + onChange: (value: string) => { + onUpdateQuestionState( + questionText, + { textInputValue: value }, + question.multiSelect ?? false, + ) + }, } - const options = t7; - const hasAnyPreview = !question.multiSelect && question.options.some(_temp3); + + const options = [...textOptions, otherOption] + + // Check if any option has a preview and it's not multi-select + // Previews only supported for single-select questions + const hasAnyPreview = + !question.multiSelect && question.options.some(opt => opt.preview) + + // Delegate to PreviewQuestionView for carousel-style preview mode if (hasAnyPreview) { - let t8; - if ($[30] !== answers || $[31] !== currentQuestionIndex || $[32] !== hideSubmitTab || $[33] !== minContentHeight || $[34] !== minContentWidth || $[35] !== onAnswer || $[36] !== onCancel || $[37] !== onFinishPlanInterview || $[38] !== onRespondToClaude || $[39] !== onTabNext || $[40] !== onTabPrev || $[41] !== onTextInputFocus || $[42] !== onUpdateQuestionState || $[43] !== question || $[44] !== questionStates || $[45] !== questions) { - t8 = ; - $[30] = answers; - $[31] = currentQuestionIndex; - $[32] = hideSubmitTab; - $[33] = minContentHeight; - $[34] = minContentWidth; - $[35] = onAnswer; - $[36] = onCancel; - $[37] = onFinishPlanInterview; - $[38] = onRespondToClaude; - $[39] = onTabNext; - $[40] = onTabPrev; - $[41] = onTextInputFocus; - $[42] = onUpdateQuestionState; - $[43] = question; - $[44] = questionStates; - $[45] = questions; - $[46] = t8; - } else { - t8 = $[46]; - } - return t8; - } - let t8; - if ($[47] !== isInPlanMode || $[48] !== planFilePath) { - t8 = isInPlanMode && planFilePath && Planning: ; - $[47] = isInPlanMode; - $[48] = planFilePath; - $[49] = t8; - } else { - t8 = $[49]; - } - let t9; - if ($[50] === Symbol.for("react.memo_cache_sentinel")) { - t9 = ; - $[50] = t9; - } else { - t9 = $[50]; - } - let t10; - if ($[51] !== answers || $[52] !== currentQuestionIndex || $[53] !== hideSubmitTab || $[54] !== questions) { - t10 = ; - $[51] = answers; - $[52] = currentQuestionIndex; - $[53] = hideSubmitTab; - $[54] = questions; - $[55] = t10; - } else { - t10 = $[55]; - } - let t11; - if ($[56] !== question.question) { - t11 = ; - $[56] = question.question; - $[57] = t11; - } else { - t11 = $[57]; - } - let t12; - if ($[58] !== currentQuestionIndex || $[59] !== handleFocus || $[60] !== handleOpenEditor || $[61] !== isFooterFocused || $[62] !== onAnswer || $[63] !== onCancel || $[64] !== onImagePaste || $[65] !== onRemoveImage || $[66] !== onSubmit || $[67] !== onUpdateQuestionState || $[68] !== options || $[69] !== pastedContents || $[70] !== question.multiSelect || $[71] !== question.question || $[72] !== questionStates || $[73] !== questionText || $[74] !== questions.length) { - t12 = {question.multiSelect ? { - onUpdateQuestionState(questionText, { - selectedValue: values - }, true); - const textInput = values.includes("__other__") ? questionStates[questionText]?.textInputValue : undefined; - const finalValues = values.filter(_temp4).concat(textInput ? [textInput] : []); - onAnswer(questionText, finalValues, undefined, false); - }} onFocus={handleFocus} onCancel={onCancel} submitButtonText={currentQuestionIndex === questions.length - 1 ? "Submit" : "Next"} onSubmit={onSubmit} onDownFromLastItem={handleDownFromLastItem} isDisabled={isFooterFocused} onOpenEditor={handleOpenEditor} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} /> : { + onUpdateQuestionState( + questionText, + { selectedValue: value }, + false, + ) + const textInput = + value === '__other__' + ? questionStates[questionText]?.textInputValue + : undefined + onAnswer(questionText, value, textInput) + }} + onFocus={handleFocus} + onCancel={onCancel} + onDownFromLastItem={handleDownFromLastItem} + isDisabled={isFooterFocused} + layout="compact-vertical" + onOpenEditor={handleOpenEditor} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + /> + )} + + {/* Footer section - always visible, separate from Select */} + + + + {isFooterFocused && footerIndex === 0 ? ( + {figures.pointer} + ) : ( + + )} + + {options.length + 1}. Chat about this + + + {isInPlanMode && ( + + {isFooterFocused && footerIndex === 1 ? ( + {figures.pointer} + ) : ( + + )} + + {options.length + 2}. Skip interview and plan immediately + + + )} + + + + Enter to select ·{' '} + {questions.length === 1 ? ( + <> + {figures.arrowUp}/{figures.arrowDown} to navigate + + ) : ( + 'Tab/Arrow keys to navigate' + )} + {isOtherFocused && editorName && ( + <> · ctrl+g to edit in {editorName} + )}{' '} + · Esc to cancel + + + + + + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx index 4ecc55635..b17a26c2a 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx @@ -1,143 +1,104 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Box, Text } from '../../../ink.js'; -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'; -import { Select } from '../../CustomSelect/index.js'; -import { Divider } from '../../design-system/Divider.js'; -import { PermissionRequestTitle } from '../PermissionRequestTitle.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -import { QuestionNavigationBar } from './QuestionNavigationBar.js'; +import figures from 'figures' +import React from 'react' +import { Box, Text } from '../../../ink.js' +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js' +import { Select } from '../../CustomSelect/index.js' +import { Divider } from '../../design-system/Divider.js' +import { PermissionRequestTitle } from '../PermissionRequestTitle.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { QuestionNavigationBar } from './QuestionNavigationBar.js' + type Props = { - questions: Question[]; - currentQuestionIndex: number; - answers: Record; - allQuestionsAnswered: boolean; - permissionResult: PermissionDecision; - minContentHeight?: number; - onFinalResponse: (value: 'submit' | 'cancel') => void; -}; -export function SubmitQuestionsView(t0) { - const $ = _c(27); - const { - questions, - currentQuestionIndex, - answers, - allQuestionsAnswered, - permissionResult, - minContentHeight, - onFinalResponse - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== answers || $[2] !== currentQuestionIndex || $[3] !== questions) { - t2 = ; - $[1] = answers; - $[2] = currentQuestionIndex; - $[3] = questions; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== allQuestionsAnswered) { - t4 = !allQuestionsAnswered && {figures.warning} You have not answered all questions; - $[6] = allQuestionsAnswered; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== answers || $[9] !== questions) { - t5 = Object.keys(answers).length > 0 && {questions.filter(q => q?.question && answers[q.question]).map(q_0 => { - const answer = answers[q_0?.question]; - return {figures.bullet} {q_0?.question || "Question"}{figures.arrowRight} {answer}; - })}; - $[8] = answers; - $[9] = questions; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== permissionResult) { - t6 = ; - $[11] = permissionResult; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Ready to submit your answers?; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - type: "text" as const, - label: "Submit answers", - value: "submit" - }; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [t8, { - type: "text" as const, - label: "Cancel", - value: "cancel" - }]; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== onFinalResponse) { - t10 = onFinalResponse(value as 'submit' | 'cancel')} + onCancel={() => onFinalResponse('cancel')} + /> + + + + + ) } diff --git a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx index bce88e24e..1eb1ffffe 100644 --- a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx +++ b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx @@ -1,37 +1,51 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../../services/analytics/index.js'; -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; -import { useAppState } from '../../../state/AppState.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import { getFirstWordPrefix, getSimpleCommandPrefix } from '../../../tools/BashTool/bashPermissions.js'; -import { getDestructiveCommandWarning } from '../../../tools/BashTool/destructiveCommandWarning.js'; -import { parseSedEditCommand } from '../../../tools/BashTool/sedEditParser.js'; -import { shouldUseSandbox } from '../../../tools/BashTool/shouldUseSandbox.js'; -import { getCompoundCommandPrefixesStatic } from '../../../utils/bash/prefix.js'; -import { createPromptRuleContent, generateGenericDescription, getBashPromptAllowDescriptions, isClassifierPermissionsEnabled } from '../../../utils/permissions/bashClassifier.js'; -import { extractRules } from '../../../utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js'; -import { Select } from '../../CustomSelect/select.js'; -import { ShimmerChar } from '../../Spinner/ShimmerChar.js'; -import { useShimmerAnimation } from '../../Spinner/useShimmerAnimation.js'; -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import { PermissionExplainerContent, usePermissionExplainerUI } from '../PermissionExplanation.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -import { SedEditPermissionRequest } from '../SedEditPermissionRequest/SedEditPermissionRequest.js'; -import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js'; -import { logUnaryPermissionEvent } from '../utils.js'; -import { bashToolUseOptions } from './bashToolUseOptions.js'; -const CHECKING_TEXT = 'Attempting to auto-approve\u2026'; +import { feature } from 'bun:bundle' +import figures from 'figures' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Box, Text, useTheme } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../../services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' +import { useAppState } from '../../../state/AppState.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { + getFirstWordPrefix, + getSimpleCommandPrefix, +} from '../../../tools/BashTool/bashPermissions.js' +import { getDestructiveCommandWarning } from '../../../tools/BashTool/destructiveCommandWarning.js' +import { parseSedEditCommand } from '../../../tools/BashTool/sedEditParser.js' +import { shouldUseSandbox } from '../../../tools/BashTool/shouldUseSandbox.js' +import { getCompoundCommandPrefixesStatic } from '../../../utils/bash/prefix.js' +import { + createPromptRuleContent, + generateGenericDescription, + getBashPromptAllowDescriptions, + isClassifierPermissionsEnabled, +} from '../../../utils/permissions/bashClassifier.js' +import { extractRules } from '../../../utils/permissions/PermissionUpdate.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js' +import { Select } from '../../CustomSelect/select.js' +import { ShimmerChar } from '../../Spinner/ShimmerChar.js' +import { useShimmerAnimation } from '../../Spinner/useShimmerAnimation.js' +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' +import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js' +import { PermissionDialog } from '../PermissionDialog.js' +import { + PermissionExplainerContent, + usePermissionExplainerUI, +} from '../PermissionExplanation.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { SedEditPermissionRequest } from '../SedEditPermissionRequest/SedEditPermissionRequest.js' +import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js' +import { logUnaryPermissionEvent } from '../utils.js' +import { bashToolUseOptions } from './bashToolUseOptions.js' + +const CHECKING_TEXT = 'Attempting to auto-approve\u2026' // Isolates the 20fps shimmer clock from BashPermissionRequestInner. Before this // extraction, useShimmerAnimation lived inside the 535-line Inner body, so every @@ -39,97 +53,77 @@ const CHECKING_TEXT = 'Attempting to auto-approve\u2026'; // all children) for the ~1-3 seconds the classifier typically takes. Inner also // has a Compiler bailout (see below), so nothing was auto-memoized — the full // JSX tree was reconstructed 20-60 times per classifier check. -function ClassifierCheckingSubtitle() { - const $ = _c(6); - const [ref, glimmerIndex] = useShimmerAnimation("requesting", CHECKING_TEXT, false); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = [...CHECKING_TEXT]; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] !== glimmerIndex) { - t1 = {t0.map((char, i) => )}; - $[1] = glimmerIndex; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== ref || $[4] !== t1) { - t2 = {t1}; - $[3] = ref; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; +function ClassifierCheckingSubtitle(): React.ReactNode { + const [ref, glimmerIndex] = useShimmerAnimation( + 'requesting', + CHECKING_TEXT, + false, + ) + return ( + + + {[...CHECKING_TEXT].map((char, i) => ( + + ))} + + + ) } -export function BashPermissionRequest(props) { - const $ = _c(21); + +export function BashPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { const { toolUseConfirm, toolUseContext, onDone, onReject, verbose, - workerBadge - } = props; - let command; - let description; - let t0; - if ($[0] !== toolUseConfirm.input) { - ({ - command, - description - } = BashTool.inputSchema.parse(toolUseConfirm.input)); - t0 = parseSedEditCommand(command); - $[0] = toolUseConfirm.input; - $[1] = command; - $[2] = description; - $[3] = t0; - } else { - command = $[1]; - description = $[2]; - t0 = $[3]; - } - const sedInfo = t0; + workerBadge, + } = props + + const { command, description } = BashTool.inputSchema.parse( + toolUseConfirm.input, + ) + + // Detect sed in-place edit commands and delegate to SedEditPermissionRequest + // This renders sed edits like file edits with a diff view + const sedInfo = parseSedEditCommand(command) + if (sedInfo) { - let t1; - if ($[4] !== onDone || $[5] !== onReject || $[6] !== sedInfo || $[7] !== toolUseConfirm || $[8] !== toolUseContext || $[9] !== verbose || $[10] !== workerBadge) { - t1 = ; - $[4] = onDone; - $[5] = onReject; - $[6] = sedInfo; - $[7] = toolUseConfirm; - $[8] = toolUseContext; - $[9] = verbose; - $[10] = workerBadge; - $[11] = t1; - } else { - t1 = $[11]; - } - return t1; + return ( + + ) } - let t1; - if ($[12] !== command || $[13] !== description || $[14] !== onDone || $[15] !== onReject || $[16] !== toolUseConfirm || $[17] !== toolUseContext || $[18] !== verbose || $[19] !== workerBadge) { - t1 = ; - $[12] = command; - $[13] = description; - $[14] = onDone; - $[15] = onReject; - $[16] = toolUseConfirm; - $[17] = toolUseContext; - $[18] = verbose; - $[19] = workerBadge; - $[20] = t1; - } else { - t1 = $[20]; - } - return t1; + + // Regular bash command - render with hooks + return ( + + ) } // Inner component that uses hooks - only called for non-MCP CLI commands @@ -141,19 +135,19 @@ function BashPermissionRequestInner({ verbose: _verbose, workerBadge, command, - description + description, }: PermissionRequestProps & { - command: string; - description?: string; + command: string + description?: string }): React.ReactNode { - const [theme] = useTheme(); - const toolPermissionContext = useAppState(s => s.toolPermissionContext); + const [theme] = useTheme() + const toolPermissionContext = useAppState(s => s.toolPermissionContext) const explainerState = usePermissionExplainerUI({ toolName: toolUseConfirm.tool.name, toolInput: toolUseConfirm.input, toolDescription: toolUseConfirm.description, - messages: toolUseContext.messages - }); + messages: toolUseContext.messages, + }) const { yesInputMode, noInputMode, @@ -166,31 +160,39 @@ function BashPermissionRequestInner({ focusedOption, handleInputModeToggle, handleReject, - handleFocus + handleFocus, } = useShellPermissionFeedback({ toolUseConfirm, onDone, onReject, - explainerVisible: explainerState.visible - }); - const [showPermissionDebug, setShowPermissionDebug] = useState(false); - const [classifierDescription, setClassifierDescription] = useState(description || ''); + explainerVisible: explainerState.visible, + }) + const [showPermissionDebug, setShowPermissionDebug] = useState(false) + const [classifierDescription, setClassifierDescription] = useState( + description || '', + ) // Track whether the initial description (from prop or async generation) was empty. // Once we receive a non-empty description, this stays false. - const [initialClassifierDescriptionEmpty, setInitialClassifierDescriptionEmpty] = useState(!description?.trim()); + const [ + initialClassifierDescriptionEmpty, + setInitialClassifierDescriptionEmpty, + ] = useState(!description?.trim()) // Asynchronously generate a generic description for the classifier useEffect(() => { - if (!isClassifierPermissionsEnabled()) return; - const abortController = new AbortController(); - generateGenericDescription(command, description, abortController.signal).then(generic => { - if (generic && !abortController.signal.aborted) { - setClassifierDescription(generic); - setInitialClassifierDescriptionEmpty(false); - } - }).catch(() => {}); // Keep original on error - return () => abortController.abort(); - }, [command, description]); + if (!isClassifierPermissionsEnabled()) return + + const abortController = new AbortController() + generateGenericDescription(command, description, abortController.signal) + .then(generic => { + if (generic && !abortController.signal.aborted) { + setClassifierDescription(generic) + setInitialClassifierDescriptionEmpty(false) + } + }) + .catch(() => {}) // Keep original on error + return () => abortController.abort() + }, [command, description]) // GH#11380: For compound commands (cd src && git status && npm test), the // backend already computed correct per-subcommand suggestions via tree-sitter @@ -206,7 +208,8 @@ function BashPermissionRequestInner({ // from the backend rule. When compound with 2+ rules, editablePrefix stays // undefined so bashToolUseOptions falls through to yes-apply-suggestions, // which saves all per-subcommand rules atomically. - const isCompound = toolUseConfirm.permissionResult.decisionReason?.type === 'subcommandResults'; + const isCompound = + toolUseConfirm.permissionResult.decisionReason?.type === 'subcommandResults' // Editable prefix — initialize synchronously with the best prefix we can // extract without tree-sitter, then refine via tree-sitter for compound @@ -216,49 +219,63 @@ function BashPermissionRequestInner({ // // Lazy initializer: this runs regex + split on every render if left in // the render body; it's only needed for initial state. - const [editablePrefix, setEditablePrefix] = useState(() => { - if (isCompound) { - // Backend suggestion is the source of truth for compound commands. - // Single rule → seed the editable input so the user can refine it. - // Multiple/zero rules → undefined → yes-apply-suggestions handles it. - const backendBashRules = extractRules('suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions : undefined).filter(r => r.toolName === BashTool.name && r.ruleContent); - return backendBashRules.length === 1 ? backendBashRules[0]!.ruleContent : undefined; - } - const two = getSimpleCommandPrefix(command); - if (two) return `${two}:*`; - const one = getFirstWordPrefix(command); - if (one) return `${one}:*`; - return command; - }); - const hasUserEditedPrefix = useRef(false); + const [editablePrefix, setEditablePrefix] = useState( + () => { + if (isCompound) { + // Backend suggestion is the source of truth for compound commands. + // Single rule → seed the editable input so the user can refine it. + // Multiple/zero rules → undefined → yes-apply-suggestions handles it. + const backendBashRules = extractRules( + 'suggestions' in toolUseConfirm.permissionResult + ? toolUseConfirm.permissionResult.suggestions + : undefined, + ).filter(r => r.toolName === BashTool.name && r.ruleContent) + return backendBashRules.length === 1 + ? backendBashRules[0]!.ruleContent + : undefined + } + const two = getSimpleCommandPrefix(command) + if (two) return `${two}:*` + const one = getFirstWordPrefix(command) + if (one) return `${one}:*` + return command + }, + ) + const hasUserEditedPrefix = useRef(false) const onEditablePrefixChange = useCallback((value: string) => { - hasUserEditedPrefix.current = true; - setEditablePrefix(value); - }, []); + hasUserEditedPrefix.current = true + setEditablePrefix(value) + }, []) useEffect(() => { // Skip async refinement for compound commands — the backend already ran // the full per-subcommand analysis and its suggestion is correct. - if (isCompound) return; - let cancelled = false; - getCompoundCommandPrefixesStatic(command, subcmd => BashTool.isReadOnly({ - command: subcmd - })).then(prefixes => { - if (cancelled || hasUserEditedPrefix.current) return; - if (prefixes.length > 0) { - setEditablePrefix(`${prefixes[0]}:*`); - } - }).catch(() => {}); // Keep sync prefix on tree-sitter failure + if (isCompound) return + let cancelled = false + getCompoundCommandPrefixesStatic(command, subcmd => + BashTool.isReadOnly({ command: subcmd }), + ) + .then(prefixes => { + if (cancelled || hasUserEditedPrefix.current) return + if (prefixes.length > 0) { + setEditablePrefix(`${prefixes[0]}:*`) + } + }) + .catch(() => {}) // Keep sync prefix on tree-sitter failure return () => { - cancelled = true; - }; - }, [command, isCompound]); + cancelled = true + } + }, [command, isCompound]) // Track whether classifier check was ever in progress (persists after completion). // classifierCheckInProgress is set once at queue-push time (interactiveHandler) // and only ever transitions true→false, so capturing the mount-time value is // sufficient — no latch/ref needed. The feature() ternary keeps the property // read out of external builds (forbidden-string check). - const [classifierWasChecking] = useState(feature('BASH_CLASSIFIER') ? !!toolUseConfirm.classifierCheckInProgress : false); + const [classifierWasChecking] = useState( + feature('BASH_CLASSIFIER') + ? !!toolUseConfirm.classifierCheckInProgress + : false, + ) // These derive solely from the tool input (fixed for the dialog lifetime). // The shimmer clock used to live in this component and re-render it at 20fps @@ -266,216 +283,330 @@ function BashPermissionRequestInner({ // extraction). React Compiler can't auto-memoize imported functions (can't // prove side-effect freedom), so this useMemo still guards against any // re-render source (e.g. Inner state updates). Same pattern as PR#20730. - const { - destructiveWarning: destructiveWarning_0, - sandboxingEnabled: sandboxingEnabled_0, - isSandboxed: isSandboxed_0 - } = useMemo(() => { - const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false) ? getDestructiveCommandWarning(command) : null; - const sandboxingEnabled = SandboxManager.isSandboxingEnabled(); - const isSandboxed = sandboxingEnabled && shouldUseSandbox(toolUseConfirm.input); - return { - destructiveWarning, - sandboxingEnabled, - isSandboxed - }; - }, [command, toolUseConfirm.input]); - const unaryEvent = useMemo(() => ({ - completion_type: 'tool_use_single', - language_name: 'none' - }), []); - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - const existingAllowDescriptions = useMemo(() => getBashPromptAllowDescriptions(toolPermissionContext), [toolPermissionContext]); - const options = useMemo(() => bashToolUseOptions({ - suggestions: toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined, - decisionReason: toolUseConfirm.permissionResult.decisionReason, - onRejectFeedbackChange: setRejectFeedback, - onAcceptFeedbackChange: setAcceptFeedback, - onClassifierDescriptionChange: setClassifierDescription, - classifierDescription, - initialClassifierDescriptionEmpty, - existingAllowDescriptions, - yesInputMode, - noInputMode, - editablePrefix, - onEditablePrefixChange - }), [toolUseConfirm, classifierDescription, initialClassifierDescriptionEmpty, existingAllowDescriptions, yesInputMode, noInputMode, editablePrefix, onEditablePrefixChange]); + const { destructiveWarning, sandboxingEnabled, isSandboxed } = useMemo(() => { + const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_destructive_command_warning', + false, + ) + ? getDestructiveCommandWarning(command) + : null + + const sandboxingEnabled = SandboxManager.isSandboxingEnabled() + const isSandboxed = + sandboxingEnabled && shouldUseSandbox(toolUseConfirm.input) + + return { destructiveWarning, sandboxingEnabled, isSandboxed } + }, [command, toolUseConfirm.input]) + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const existingAllowDescriptions = useMemo( + () => getBashPromptAllowDescriptions(toolPermissionContext), + [toolPermissionContext], + ) + + const options = useMemo( + () => + bashToolUseOptions({ + suggestions: + toolUseConfirm.permissionResult.behavior === 'ask' + ? toolUseConfirm.permissionResult.suggestions + : undefined, + decisionReason: toolUseConfirm.permissionResult.decisionReason, + onRejectFeedbackChange: setRejectFeedback, + onAcceptFeedbackChange: setAcceptFeedback, + onClassifierDescriptionChange: setClassifierDescription, + classifierDescription, + initialClassifierDescriptionEmpty, + existingAllowDescriptions, + yesInputMode, + noInputMode, + editablePrefix, + onEditablePrefixChange, + }), + [ + toolUseConfirm, + classifierDescription, + initialClassifierDescriptionEmpty, + existingAllowDescriptions, + yesInputMode, + noInputMode, + editablePrefix, + onEditablePrefixChange, + ], + ) // Toggle permission debug info with keybinding const handleToggleDebug = useCallback(() => { - setShowPermissionDebug(prev => !prev); - }, []); + setShowPermissionDebug(prev => !prev) + }, []) useKeybinding('permission:toggleDebug', handleToggleDebug, { - context: 'Confirmation' - }); + context: 'Confirmation', + }) // Allow Esc to dismiss the checkmark after auto-approval const handleDismissCheckmark = useCallback(() => { - toolUseConfirm.onDismissCheckmark?.(); - }, [toolUseConfirm]); + toolUseConfirm.onDismissCheckmark?.() + }, [toolUseConfirm]) useKeybinding('confirm:no', handleDismissCheckmark, { context: 'Confirmation', - isActive: feature('BASH_CLASSIFIER') ? !!toolUseConfirm.classifierAutoApproved : false - }); - function onSelect(value_0: string) { + isActive: feature('BASH_CLASSIFIER') + ? !!toolUseConfirm.classifierAutoApproved + : false, + }) + + function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) let optionIndex: Record = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, - no: 3 - }; + no: 3, + } if (feature('BASH_CLASSIFIER')) { optionIndex = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, 'yes-classifier-reviewed': 3, - no: 4 - }; + no: 4, + } } logEvent('tengu_permission_request_option_selected', { - option_index: optionIndex[value_0], - explainer_visible: explainerState.visible - }); - const toolNameForAnalytics = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; - if (value_0 === 'yes-prefix-edited') { - const trimmedPrefix = (editablePrefix ?? '').trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + option_index: optionIndex[value], + explainer_visible: explainerState.visible, + }) + + const toolNameForAnalytics = sanitizeToolNameForAnalytics( + toolUseConfirm.tool.name, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + + if (value === 'yes-prefix-edited') { + const trimmedPrefix = (editablePrefix ?? '').trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') if (!trimmedPrefix) { - toolUseConfirm.onAllow(toolUseConfirm.input, []); + toolUseConfirm.onAllow(toolUseConfirm.input, []) } else { - const prefixUpdates: PermissionUpdate[] = [{ - type: 'addRules', - rules: [{ - toolName: BashTool.name, - ruleContent: trimmedPrefix - }], - behavior: 'allow', - destination: 'localSettings' - }]; - toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates); + const prefixUpdates: PermissionUpdate[] = [ + { + type: 'addRules', + rules: [ + { + toolName: BashTool.name, + ruleContent: trimmedPrefix, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ] + toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates) } - onDone(); - return; + onDone() + return } - if (feature('BASH_CLASSIFIER') && value_0 === 'yes-classifier-reviewed') { - const trimmedDescription = classifierDescription.trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + + if (feature('BASH_CLASSIFIER') && value === 'yes-classifier-reviewed') { + const trimmedDescription = classifierDescription.trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') if (!trimmedDescription) { - toolUseConfirm.onAllow(toolUseConfirm.input, []); + toolUseConfirm.onAllow(toolUseConfirm.input, []) } else { - const permissionUpdates: PermissionUpdate[] = [{ - type: 'addRules', - rules: [{ - toolName: BashTool.name, - ruleContent: createPromptRuleContent(trimmedDescription) - }], - behavior: 'allow', - destination: 'session' - }]; - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates); + const permissionUpdates: PermissionUpdate[] = [ + { + type: 'addRules', + rules: [ + { + toolName: BashTool.name, + ruleContent: createPromptRuleContent(trimmedDescription), + }, + ], + behavior: 'allow', + destination: 'session', + }, + ] + toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates) } - onDone(); - return; + onDone() + return } - switch (value_0) { - case 'yes': - { - const trimmedFeedback_0 = acceptFeedback.trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Log accept submission with feedback context - logEvent('tengu_accept_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback_0, - instructions_length: trimmedFeedback_0.length, - entered_feedback_mode: yesFeedbackModeEntered - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], trimmedFeedback_0 || undefined); - onDone(); - break; - } - case 'yes-apply-suggestions': - { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) - const permissionUpdates_0 = 'suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions || [] : []; - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates_0); - onDone(); - break; - } - case 'no': - { - const trimmedFeedback = rejectFeedback.trim(); - // Log reject submission with feedback context - logEvent('tengu_reject_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: noFeedbackModeEntered - }); + switch (value) { + case 'yes': { + const trimmedFeedback = acceptFeedback.trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Log accept submission with feedback context + logEvent('tengu_accept_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: yesFeedbackModeEntered, + }) + toolUseConfirm.onAllow( + toolUseConfirm.input, + [], + trimmedFeedback || undefined, + ) + onDone() + break + } + case 'yes-apply-suggestions': { + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) + const permissionUpdates = + 'suggestions' in toolUseConfirm.permissionResult + ? toolUseConfirm.permissionResult.suggestions || [] + : [] + toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates) + onDone() + break + } + case 'no': { + const trimmedFeedback = rejectFeedback.trim() + + // Log reject submission with feedback context + logEvent('tengu_reject_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: noFeedbackModeEntered, + }) - // Process rejection (with or without feedback) - handleReject(trimmedFeedback || undefined); - break; - } + // Process rejection (with or without feedback) + handleReject(trimmedFeedback || undefined) + break + } } } - const classifierSubtitle = feature('BASH_CLASSIFIER') ? toolUseConfirm.classifierAutoApproved ? + + const classifierSubtitle = feature('BASH_CLASSIFIER') ? ( + toolUseConfirm.classifierAutoApproved ? ( + {figures.tick} Auto-approved - {toolUseConfirm.classifierMatchedRule && + {toolUseConfirm.classifierMatchedRule && ( + {' \u00b7 matched "'} {toolUseConfirm.classifierMatchedRule} {'"'} - } - : toolUseConfirm.classifierCheckInProgress ? : classifierWasChecking ? Requires manual approval : undefined : undefined; - return + + )} + + ) : toolUseConfirm.classifierCheckInProgress ? ( + + ) : classifierWasChecking ? ( + Requires manual approval + ) : undefined + ) : undefined + + return ( + - {BashTool.renderToolUseMessage({ - command, - description - }, { - theme, - verbose: true - } // always show the full command - )} + {BashTool.renderToolUseMessage( + { command, description }, + { theme, verbose: true }, // always show the full command + )} - {!explainerState.visible && {toolUseConfirm.description}} - + {!explainerState.visible && ( + {toolUseConfirm.description} + )} + - {showPermissionDebug ? <> - - {toolUseContext.options.debug && + {showPermissionDebug ? ( + <> + + {toolUseContext.options.debug && ( + Ctrl-D to hide debug info - } - : <> + + )} + + ) : ( + <> - - {destructiveWarning_0 && - - {destructiveWarning_0} + + {destructiveWarning && ( + + + {destructiveWarning} - } - + + )} + Do you want to proceed? - ({ ...o, disabled: true })) + : options + : options + } + isDisabled={ + feature('BASH_CLASSIFIER') + ? toolUseConfirm.classifierAutoApproved + : false + } + inlineDescriptions + onChange={onSelect} + onCancel={() => handleReject()} + onFocus={handleFocus} + onInputModeToggle={handleInputModeToggle} + /> Esc to cancel - {(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'} - {explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} + {explainerState.enabled && + ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} - {toolUseContext.options.debug && Ctrl+d to show debug info} + {toolUseContext.options.debug && ( + Ctrl+d to show debug info + )} - } - ; + + )} + + ) } diff --git a/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx b/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx index f1f7c4a89..18d35d062 100644 --- a/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx +++ b/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx @@ -1,33 +1,43 @@ -import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js'; -import { extractOutputRedirections } from '../../../utils/bash/commands.js'; -import { isClassifierPermissionsEnabled } from '../../../utils/permissions/bashClassifier.js'; -import type { PermissionDecisionReason } from '../../../utils/permissions/PermissionResult.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js'; -export type BashToolUseOption = 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited' | 'yes-classifier-reviewed' | 'no'; +import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js' +import { extractOutputRedirections } from '../../../utils/bash/commands.js' +import { isClassifierPermissionsEnabled } from '../../../utils/permissions/bashClassifier.js' +import type { PermissionDecisionReason } from '../../../utils/permissions/PermissionResult.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js' + +export type BashToolUseOption = + | 'yes' + | 'yes-apply-suggestions' + | 'yes-prefix-edited' + | 'yes-classifier-reviewed' + | 'no' /** * Check if a description already exists in the allow list. * Compares lowercase and trailing-whitespace-trimmed versions. */ -function descriptionAlreadyExists(description: string, existingDescriptions: string[]): boolean { - const normalized = description.toLowerCase().trimEnd(); - return existingDescriptions.some(existing => existing.toLowerCase().trimEnd() === normalized); +function descriptionAlreadyExists( + description: string, + existingDescriptions: string[], +): boolean { + const normalized = description.toLowerCase().trimEnd() + return existingDescriptions.some( + existing => existing.toLowerCase().trimEnd() === normalized, + ) } /** * Strip output redirections so filenames don't show as commands in the label. */ function stripBashRedirections(command: string): string { - const { - commandWithoutRedirections, - redirections - } = extractOutputRedirections(command); + const { commandWithoutRedirections, redirections } = + extractOutputRedirections(command) // Only use stripped version if there were actual redirections - return redirections.length > 0 ? commandWithoutRedirections : command; + return redirections.length > 0 ? commandWithoutRedirections : command } + export function bashToolUseOptions({ suggestions = [], decisionReason, @@ -40,25 +50,26 @@ export function bashToolUseOptions({ yesInputMode = false, noInputMode = false, editablePrefix, - onEditablePrefixChange + onEditablePrefixChange, }: { - suggestions?: PermissionUpdate[]; - decisionReason?: PermissionDecisionReason; - onRejectFeedbackChange: (value: string) => void; - onAcceptFeedbackChange: (value: string) => void; - onClassifierDescriptionChange?: (value: string) => void; - classifierDescription?: string; + suggestions?: PermissionUpdate[] + decisionReason?: PermissionDecisionReason + onRejectFeedbackChange: (value: string) => void + onAcceptFeedbackChange: (value: string) => void + onClassifierDescriptionChange?: (value: string) => void + classifierDescription?: string /** Whether the initial classifier description was empty. When true, hides the option. */ - initialClassifierDescriptionEmpty?: boolean; - existingAllowDescriptions?: string[]; - yesInputMode?: boolean; - noInputMode?: boolean; + initialClassifierDescriptionEmpty?: boolean + existingAllowDescriptions?: string[] + yesInputMode?: boolean + noInputMode?: boolean /** Editable prefix rule content (e.g., "npm run:*"). When set, replaces Haiku-based suggestions. */ - editablePrefix?: string; + editablePrefix?: string /** Callback when the user edits the prefix value. */ - onEditablePrefixChange?: (value: string) => void; + onEditablePrefixChange?: (value: string) => void }): OptionWithDescription[] { - const options: OptionWithDescription[] = []; + const options: OptionWithDescription[] = [] + if (yesInputMode) { options.push({ type: 'input', @@ -66,13 +77,13 @@ export function bashToolUseOptions({ value: 'yes', placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'Yes', - value: 'yes' - }); + value: 'yes', + }) } // Only show "always allow" options when not restricted by allowManagedPermissionRulesOnly @@ -81,8 +92,18 @@ export function bashToolUseOptions({ // Haiku-generated suggestion label — but only when the suggestions // don't contain non-Bash items (addDirectories, Read rules) that // the editable prefix can't represent. - const hasNonBashSuggestions = suggestions.some(s => s.type === 'addDirectories' || s.type === 'addRules' && s.rules?.some(r => r.toolName !== BASH_TOOL_NAME)); - if (editablePrefix !== undefined && onEditablePrefixChange && !hasNonBashSuggestions && suggestions.length > 0) { + const hasNonBashSuggestions = suggestions.some( + s => + s.type === 'addDirectories' || + (s.type === 'addRules' && + s.rules?.some(r => r.toolName !== BASH_TOOL_NAME)), + ) + if ( + editablePrefix !== undefined && + onEditablePrefixChange && + !hasNonBashSuggestions && + suggestions.length > 0 + ) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -93,15 +114,20 @@ export function bashToolUseOptions({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - resetCursorOnUpdate: true - }); + resetCursorOnUpdate: true, + }) } else if (suggestions.length > 0) { - const label = generateShellSuggestionsLabel(suggestions, BASH_TOOL_NAME, stripBashRedirections); + const label = generateShellSuggestionsLabel( + suggestions, + BASH_TOOL_NAME, + stripBashRedirections, + ) + if (label) { options.push({ label, - value: 'yes-apply-suggestions' - }); + value: 'yes-apply-suggestions', + }) } } @@ -111,8 +137,21 @@ export function bashToolUseOptions({ // (prompt-based rules don't help when the server-side classifier triggers first). // Skip when the editable prefix option is already shown — they serve the // same role and having two identical-looking "don't ask again" inputs is confusing. - const editablePrefixShown = options.some(o => o.value === 'yes-prefix-edited'); - if ((process.env.USER_TYPE) === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && onClassifierDescriptionChange && !initialClassifierDescriptionEmpty && !descriptionAlreadyExists(classifierDescription ?? '', existingAllowDescriptions) && decisionReason?.type !== 'classifier') { + const editablePrefixShown = options.some( + o => o.value === 'yes-prefix-edited', + ) + if ( + process.env.USER_TYPE === 'ant' && + !editablePrefixShown && + isClassifierPermissionsEnabled() && + onClassifierDescriptionChange && + !initialClassifierDescriptionEmpty && + !descriptionAlreadyExists( + classifierDescription ?? '', + existingAllowDescriptions, + ) && + decisionReason?.type !== 'classifier' + ) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -123,10 +162,11 @@ export function bashToolUseOptions({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - resetCursorOnUpdate: true - }); + resetCursorOnUpdate: true, + }) } } + if (noInputMode) { options.push({ type: 'input', @@ -134,13 +174,14 @@ export function bashToolUseOptions({ value: 'no', placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'No', - value: 'no' - }); + value: 'no', + }) } - return options; + + return options } diff --git a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx index bf4bdcbd5..c591082e4 100644 --- a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx +++ b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx @@ -1,25 +1,29 @@ -import { c as _c } from "react/compiler-runtime"; -import { getSentinelCategory } from '@ant/computer-use-mcp/sentinelApps'; -import type { CuPermissionRequest, CuPermissionResponse } from '@ant/computer-use-mcp/types'; -import { DEFAULT_GRANT_FLAGS } from '@ant/computer-use-mcp/types'; -import figures from 'figures'; -import * as React from 'react'; -import { useMemo, useState } from 'react'; -import { Box, Text } from '../../../ink.js'; -import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'; -import { plural } from '../../../utils/stringUtils.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { Select } from '../../CustomSelect/select.js'; -import { Dialog } from '../../design-system/Dialog.js'; +import { getSentinelCategory } from '@ant/computer-use-mcp/sentinelApps' +import type { + CuPermissionRequest, + CuPermissionResponse, +} from '@ant/computer-use-mcp/types' +import { DEFAULT_GRANT_FLAGS } from '@ant/computer-use-mcp/types' +import figures from 'figures' +import * as React from 'react' +import { useMemo, useState } from 'react' +import { Box, Text } from '../../../ink.js' +import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' +import { plural } from '../../../utils/stringUtils.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { Select } from '../../CustomSelect/select.js' +import { Dialog } from '../../design-system/Dialog.js' + type ComputerUseApprovalProps = { - request: CuPermissionRequest; - onDone: (response: CuPermissionResponse) => void; -}; + request: CuPermissionRequest + onDone: (response: CuPermissionResponse) => void +} + const DENY_ALL_RESPONSE: CuPermissionResponse = { granted: [], denied: [], - flags: DEFAULT_GRANT_FLAGS -}; + flags: DEFAULT_GRANT_FLAGS, +} /** * Two-panel dispatcher. When `request.tccState` is present, macOS permissions @@ -27,414 +31,271 @@ const DENY_ALL_RESPONSE: CuPermissionResponse = { * irrelevant — show a TCC panel that opens System Settings. Otherwise show the * app allowlist + grant-flags panel. */ -export function ComputerUseApproval(t0) { - const $ = _c(3); - const { - request, - onDone - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== request) { - t1 = request.tccState ? onDone(DENY_ALL_RESPONSE)} /> : ; - $[0] = onDone; - $[1] = request; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; +export function ComputerUseApproval({ + request, + onDone, +}: ComputerUseApprovalProps): React.ReactNode { + return request.tccState ? ( + onDone(DENY_ALL_RESPONSE)} + /> + ) : ( + + ) } // ── TCC panel ───────────────────────────────────────────────────────────── -type TccOption = 'open_accessibility' | 'open_screen_recording' | 'retry'; -function ComputerUseTccPanel(t0) { - const $ = _c(26); - const { - tccState, - onDone - } = t0; - let opts; - if ($[0] !== tccState.accessibility || $[1] !== tccState.screenRecording) { - opts = []; +type TccOption = 'open_accessibility' | 'open_screen_recording' | 'retry' + +function ComputerUseTccPanel({ + tccState, + onDone, +}: { + tccState: NonNullable + onDone: () => void +}): React.ReactNode { + const options = useMemo[]>(() => { + const opts: OptionWithDescription[] = [] if (!tccState.accessibility) { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - label: "Open System Settings \u2192 Accessibility", - value: "open_accessibility" - }; - $[3] = t1; - } else { - t1 = $[3]; - } - opts.push(t1); + opts.push({ + label: 'Open System Settings → Accessibility', + value: 'open_accessibility', + }) } if (!tccState.screenRecording) { - let t1; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - label: "Open System Settings \u2192 Screen Recording", - value: "open_screen_recording" - }; - $[4] = t1; - } else { - t1 = $[4]; - } - opts.push(t1); + opts.push({ + label: 'Open System Settings → Screen Recording', + value: 'open_screen_recording', + }) } - let t1; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - label: "Try again", - value: "retry" - }; - $[5] = t1; - } else { - t1 = $[5]; + opts.push({ label: 'Try again', value: 'retry' }) + return opts + }, [tccState.accessibility, tccState.screenRecording]) + + function onChange(value: TccOption): void { + switch (value) { + case 'open_accessibility': + void execFileNoThrow( + 'open', + [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + ], + { useCwd: false }, + ) + return + case 'open_screen_recording': + void execFileNoThrow( + 'open', + [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', + ], + { useCwd: false }, + ) + return + case 'retry': + // Resolve with deny-all — the model re-calls request_access, which + // re-checks TCC and renders the app list if now granted. + onDone() + return } - opts.push(t1); - $[0] = tccState.accessibility; - $[1] = tccState.screenRecording; - $[2] = opts; - } else { - opts = $[2]; - } - const options = opts; - let t1; - if ($[6] !== onDone) { - t1 = function onChange(value) { - switch (value) { - case "open_accessibility": - { - execFileNoThrow("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"], { - useCwd: false - }); - return; - } - case "open_screen_recording": - { - execFileNoThrow("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"], { - useCwd: false - }); - return; - } - case "retry": - { - onDone(); - return; - } - } - }; - $[6] = onDone; - $[7] = t1; - } else { - t1 = $[7]; - } - const onChange = t1; - const t2 = tccState.accessibility ? `${figures.tick} granted` : `${figures.cross} not granted`; - let t3; - if ($[8] !== t2) { - t3 = Accessibility:{" "}{t2}; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - const t4 = tccState.screenRecording ? `${figures.tick} granted` : `${figures.cross} not granted`; - let t5; - if ($[10] !== t4) { - t5 = Screen Recording:{" "}{t4}; - $[10] = t4; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t3 || $[13] !== t5) { - t6 = {t3}{t5}; - $[12] = t3; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Grant the missing permissions in System Settings, then select "Try again". macOS may require you to restart Claude Code after granting Screen Recording.; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== onChange || $[17] !== onDone || $[18] !== options) { - t8 = + + + ) } // ── App allowlist panel ─────────────────────────────────────────────────── -type AppListOption = 'allow_all' | 'deny'; -const SENTINEL_WARNING: Record>, string> = { +type AppListOption = 'allow_all' | 'deny' + +const SENTINEL_WARNING: Record< + NonNullable>, + string +> = { shell: 'equivalent to shell access', filesystem: 'can read/write any file', - system_settings: 'can change system settings' -}; -function ComputerUseAppListPanel(t0) { - const $ = _c(48); - const { - request, - onDone - } = t0; - let t1; - if ($[0] !== request.apps) { - t1 = () => new Set(request.apps.flatMap(_temp)); - $[0] = request.apps; - $[1] = t1; - } else { - t1 = $[1]; - } - const [checked] = useState(t1); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ["clipboardRead", "clipboardWrite", "systemKeyCombos"]; - $[2] = t2; - } else { - t2 = $[2]; - } - const ALL_FLAG_KEYS = t2; - let t3; - if ($[3] !== request.requestedFlags) { - t3 = ALL_FLAG_KEYS.filter(k => request.requestedFlags[k]); - $[3] = request.requestedFlags; - $[4] = t3; - } else { - t3 = $[4]; - } - const requestedFlagKeys = t3; - const t4 = checked.size; - let t5; - if ($[5] !== checked.size) { - t5 = plural(checked.size, "app"); - $[5] = checked.size; - $[6] = t5; - } else { - t5 = $[6]; - } - const t6 = `Allow for this session (${t4} ${t5})`; - let t7; - if ($[7] !== t6) { - t7 = { - label: t6, - value: "allow_all" - }; - $[7] = t6; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - label: Deny, and tell Claude what to do differently (esc), - value: "deny" - }; - $[9] = t8; - } else { - t8 = $[9]; - } - let t9; - if ($[10] !== t7) { - t9 = [t7, t8]; - $[10] = t7; - $[11] = t9; - } else { - t9 = $[11]; - } - const options = t9; - let t10; - if ($[12] !== checked || $[13] !== onDone || $[14] !== request.apps || $[15] !== requestedFlagKeys) { - t10 = function respond(allow) { - if (!allow) { - onDone(DENY_ALL_RESPONSE); - return; - } - const now = Date.now(); - const granted = request.apps.flatMap(a_0 => a_0.resolved && checked.has(a_0.resolved.bundleId) ? [{ - bundleId: a_0.resolved.bundleId, - displayName: a_0.resolved.displayName, - grantedAt: now - }] : []); - const denied = request.apps.filter(a_1 => !a_1.resolved || !checked.has(a_1.resolved.bundleId)).map(_temp2); - const flags = { - ...DEFAULT_GRANT_FLAGS, - ...Object.fromEntries(requestedFlagKeys.map(_temp3)) - }; - onDone({ - granted, - denied, - flags - }); - }; - $[12] = checked; - $[13] = onDone; - $[14] = request.apps; - $[15] = requestedFlagKeys; - $[16] = t10; - } else { - t10 = $[16]; - } - const respond = t10; - let t11; - if ($[17] !== respond) { - t11 = () => respond(false); - $[17] = respond; - $[18] = t11; - } else { - t11 = $[18]; - } - let t12; - if ($[19] !== request.reason) { - t12 = request.reason ? {request.reason} : null; - $[19] = request.reason; - $[20] = t12; - } else { - t12 = $[20]; - } - let t13; - if ($[21] !== checked || $[22] !== request.apps) { - let t14; - if ($[24] !== checked) { - t14 = a_3 => { - const resolved = a_3.resolved; - if (!resolved) { - return {" "}{figures.circle} {a_3.requestedName}{" "}(not installed); - } - if (a_3.alreadyGranted) { - return {" "}{figures.tick} {resolved.displayName}{" "}(already granted); - } - const sentinel = getSentinelCategory(resolved.bundleId); - const isChecked = checked.has(resolved.bundleId); - return {" "}{isChecked ? figures.circleFilled : figures.circle}{" "}{resolved.displayName}{sentinel ? {" "}{figures.warning} {SENTINEL_WARNING[sentinel]} : null}; - }; - $[24] = checked; - $[25] = t14; - } else { - t14 = $[25]; + system_settings: 'can change system settings', +} + +function ComputerUseAppListPanel({ + request, + onDone, +}: ComputerUseApprovalProps): React.ReactNode { + // Pre-check every resolved, not-yet-granted app. Sentinels stay checked + // too — the warning text is the signal, not an unchecked box. + // Per-item toggles are a follow-up; for now every resolved app is granted + // when the user accepts. `setChecked` is unused until then. + const [checked] = useState>( + () => + new Set( + request.apps.flatMap(a => + a.resolved && !a.alreadyGranted ? [a.resolved.bundleId] : [], + ), + ), + ) + + type FlagKey = keyof typeof DEFAULT_GRANT_FLAGS + const ALL_FLAG_KEYS: FlagKey[] = [ + 'clipboardRead', + 'clipboardWrite', + 'systemKeyCombos', + ] + const requestedFlagKeys = useMemo( + (): FlagKey[] => ALL_FLAG_KEYS.filter(k => request.requestedFlags[k]), + [request.requestedFlags], + ) + + const options = useMemo[]>( + () => [ + { + label: `Allow for this session (${checked.size} ${plural(checked.size, 'app')})`, + value: 'allow_all', + }, + { + label: ( + + Deny, and tell Claude what to do differently (esc) + + ), + value: 'deny', + }, + ], + [checked.size], + ) + + function respond(allow: boolean): void { + if (!allow) { + onDone(DENY_ALL_RESPONSE) + return } - t13 = request.apps.map(t14); - $[21] = checked; - $[22] = request.apps; - $[23] = t13; - } else { - t13 = $[23]; - } - let t14; - if ($[26] !== t13) { - t14 = {t13}; - $[26] = t13; - $[27] = t14; - } else { - t14 = $[27]; - } - let t15; - if ($[28] !== requestedFlagKeys) { - t15 = requestedFlagKeys.length > 0 ? Also requested:{requestedFlagKeys.map(_temp4)} : null; - $[28] = requestedFlagKeys; - $[29] = t15; - } else { - t15 = $[29]; - } - let t16; - if ($[30] !== request.willHide) { - t16 = request.willHide && request.willHide.length > 0 ? {request.willHide.length} other{" "}{plural(request.willHide.length, "app")} will be hidden while Claude works. : null; - $[30] = request.willHide; - $[31] = t16; - } else { - t16 = $[31]; - } - let t17; - let t18; - if ($[32] !== respond) { - t17 = v => respond(v === "allow_all"); - t18 = () => respond(false); - $[32] = respond; - $[33] = t17; - $[34] = t18; - } else { - t17 = $[33]; - t18 = $[34]; - } - let t19; - if ($[35] !== options || $[36] !== t17 || $[37] !== t18) { - t19 = respond(v === 'allow_all')} + onCancel={() => respond(false)} + /> + + + ) } diff --git a/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx b/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx index debc8888e..4251891e0 100644 --- a/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx +++ b/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx @@ -1,121 +1,82 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { handlePlanModeTransition } from '../../../bootstrap/state.js'; -import { Box, Text } from '../../../ink.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../../services/analytics/index.js'; -import { useAppState } from '../../../state/AppState.js'; -import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js'; -import { Select } from '../../CustomSelect/index.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -export function EnterPlanModePermissionRequest(t0) { - const $ = _c(18); - const { - toolUseConfirm, - onDone, - onReject, - workerBadge - } = t0; - const toolPermissionContextMode = useAppState(_temp); - let t1; - if ($[0] !== onDone || $[1] !== onReject || $[2] !== toolPermissionContextMode || $[3] !== toolUseConfirm) { - t1 = function handleResponse(value) { - if (value === "yes") { - logEvent("tengu_plan_enter", { - interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - entryMethod: "tool" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - handlePlanModeTransition(toolPermissionContextMode, "plan"); - onDone(); - toolUseConfirm.onAllow({}, [{ - type: "setMode", - mode: "plan", - destination: "session" - }]); - } else { - onDone(); - onReject(); - toolUseConfirm.onReject(); - } - }; - $[0] = onDone; - $[1] = onReject; - $[2] = toolPermissionContextMode; - $[3] = toolUseConfirm; - $[4] = t1; - } else { - t1 = $[4]; +import React from 'react' +import { handlePlanModeTransition } from '../../../bootstrap/state.js' +import { Box, Text } from '../../../ink.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../../services/analytics/index.js' +import { useAppState } from '../../../state/AppState.js' +import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js' +import { Select } from '../../CustomSelect/index.js' +import { PermissionDialog } from '../PermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' + +export function EnterPlanModePermissionRequest({ + toolUseConfirm, + onDone, + onReject, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const toolPermissionContextMode = useAppState( + s => s.toolPermissionContext.mode, + ) + + function handleResponse(value: 'yes' | 'no'): void { + if (value === 'yes') { + logEvent('tengu_plan_enter', { + interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), + entryMethod: + 'tool' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + handlePlanModeTransition(toolPermissionContextMode, 'plan') + onDone() + toolUseConfirm.onAllow({}, [ + { type: 'setMode', mode: 'plan', destination: 'session' }, + ]) + } else { + onDone() + onReject() + toolUseConfirm.onReject() + } } - const handleResponse = t1; - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Claude wants to enter plan mode to explore and design an implementation approach.; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = In plan mode, Claude will: · Explore the codebase thoroughly · Identify existing patterns · Design an implementation strategy · Present a plan for your approval; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = No code changes will be made until you approve the plan.; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Yes, enter plan mode", - value: "yes" as const - }; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [t5, { - label: "No, start implementing now", - value: "no" as const - }]; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== handleResponse) { - t7 = () => handleResponse("no"); - $[10] = handleResponse; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== handleResponse || $[13] !== t7) { - t8 = {t2}{t3}{t4} handleResponse('no')} + /> + + + + ) } diff --git a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx index 4741d0bde..fddadaa7e 100644 --- a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx +++ b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx @@ -1,78 +1,152 @@ -import { feature } from 'bun:bundle'; -import type { UUID } from 'crypto'; -import figures from 'figures'; -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useAppState, useAppStateStore, useSetAppState } from 'src/state/AppState.js'; -import { getSdkBetas, getSessionId, isSessionPersistenceDisabled, setHasExitedPlanMode, setNeedsAutoModeExitAttachment, setNeedsPlanModeExitAttachment } from '../../../bootstrap/state.js'; -import { generateSessionName } from '../../../commands/rename/generateSessionName.js'; -import { launchUltraplan } from '../../../commands/ultraplan.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../ink.js'; -import type { AppState } from '../../../state/AppStateStore.js'; -import { AGENT_TOOL_NAME } from '../../../tools/AgentTool/constants.js'; -import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../../tools/ExitPlanModeTool/constants.js'; -import type { AllowedPrompt } from '../../../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; -import { TEAM_CREATE_TOOL_NAME } from '../../../tools/TeamCreateTool/constants.js'; -import { isAgentSwarmsEnabled } from '../../../utils/agentSwarmsEnabled.js'; -import { calculateContextPercentages, getContextWindowForModel } from '../../../utils/context.js'; -import { getExternalEditor } from '../../../utils/editor.js'; -import { getDisplayPath } from '../../../utils/file.js'; -import { toIDEDisplayName } from '../../../utils/ide.js'; -import { logError } from '../../../utils/log.js'; -import { enqueuePendingNotification } from '../../../utils/messageQueueManager.js'; -import { createUserMessage } from '../../../utils/messages.js'; -import { getMainLoopModel, getRuntimeMainLoopModel } from '../../../utils/model/model.js'; -import { createPromptRuleContent, isClassifierPermissionsEnabled, PROMPT_PREFIX } from '../../../utils/permissions/bashClassifier.js'; -import { type PermissionMode, toExternalPermissionMode } from '../../../utils/permissions/PermissionMode.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { isAutoModeGateEnabled, restoreDangerousPermissions, stripDangerousPermissionsForAutoMode } from '../../../utils/permissions/permissionSetup.js'; -import { getPewterLedgerVariant, isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js'; -import { getPlan, getPlanFilePath } from '../../../utils/plans.js'; -import { editFileInEditor, editPromptInEditor } from '../../../utils/promptEditor.js'; -import { getCurrentSessionTitle, getTranscriptPath, saveAgentName, saveCustomTitle } from '../../../utils/sessionStorage.js'; -import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js'; -import { type OptionWithDescription, Select } from '../../CustomSelect/index.js'; -import { Markdown } from '../../Markdown.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; +import { feature } from 'bun:bundle' +import type { UUID } from 'crypto' +import figures from 'figures' +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + useAppState, + useAppStateStore, + useSetAppState, +} from 'src/state/AppState.js' +import { + getSdkBetas, + getSessionId, + isSessionPersistenceDisabled, + setHasExitedPlanMode, + setNeedsAutoModeExitAttachment, + setNeedsPlanModeExitAttachment, +} from '../../../bootstrap/state.js' +import { generateSessionName } from '../../../commands/rename/generateSessionName.js' +import { launchUltraplan } from '../../../commands/ultraplan.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../ink.js' +import type { AppState } from '../../../state/AppStateStore.js' +import { AGENT_TOOL_NAME } from '../../../tools/AgentTool/constants.js' +import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../../tools/ExitPlanModeTool/constants.js' +import type { AllowedPrompt } from '../../../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' +import { TEAM_CREATE_TOOL_NAME } from '../../../tools/TeamCreateTool/constants.js' +import { isAgentSwarmsEnabled } from '../../../utils/agentSwarmsEnabled.js' +import { + calculateContextPercentages, + getContextWindowForModel, +} from '../../../utils/context.js' +import { getExternalEditor } from '../../../utils/editor.js' +import { getDisplayPath } from '../../../utils/file.js' +import { toIDEDisplayName } from '../../../utils/ide.js' +import { logError } from '../../../utils/log.js' +import { enqueuePendingNotification } from '../../../utils/messageQueueManager.js' +import { createUserMessage } from '../../../utils/messages.js' +import { + getMainLoopModel, + getRuntimeMainLoopModel, +} from '../../../utils/model/model.js' +import { + createPromptRuleContent, + isClassifierPermissionsEnabled, + PROMPT_PREFIX, +} from '../../../utils/permissions/bashClassifier.js' +import { + type PermissionMode, + toExternalPermissionMode, +} from '../../../utils/permissions/PermissionMode.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { + isAutoModeGateEnabled, + restoreDangerousPermissions, + stripDangerousPermissionsForAutoMode, +} from '../../../utils/permissions/permissionSetup.js' +import { + getPewterLedgerVariant, + isPlanModeInterviewPhaseEnabled, +} from '../../../utils/planModeV2.js' +import { getPlan, getPlanFilePath } from '../../../utils/plans.js' +import { + editFileInEditor, + editPromptInEditor, +} from '../../../utils/promptEditor.js' +import { + getCurrentSessionTitle, + getTranscriptPath, + saveAgentName, + saveCustomTitle, +} from '../../../utils/sessionStorage.js' +import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js' +import { type OptionWithDescription, Select } from '../../CustomSelect/index.js' +import { Markdown } from '../../Markdown.js' +import { PermissionDialog } from '../PermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? require('../../../utils/permissions/autoModeState.js') as typeof import('../../../utils/permissions/autoModeState.js') : null; -import type { Base64ImageSource, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; +const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') + ? (require('../../../utils/permissions/autoModeState.js') as typeof import('../../../utils/permissions/autoModeState.js')) + : null + +import type { + Base64ImageSource, + ImageBlockParam, +} from '@anthropic-ai/sdk/resources/messages.mjs' /* eslint-enable @typescript-eslint/no-require-imports */ -import type { PastedContent } from '../../../utils/config.js'; -import type { ImageDimensions } from '../../../utils/imageResizer.js'; -import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js'; -import { cacheImagePath, storeImage } from '../../../utils/imageStore.js'; -type ResponseValue = 'yes-bypass-permissions' | 'yes-accept-edits' | 'yes-accept-edits-keep-context' | 'yes-default-keep-context' | 'yes-resume-auto-mode' | 'yes-auto-clear-context' | 'ultraplan' | 'no'; +import type { PastedContent } from '../../../utils/config.js' +import type { ImageDimensions } from '../../../utils/imageResizer.js' +import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js' +import { cacheImagePath, storeImage } from '../../../utils/imageStore.js' + +type ResponseValue = + | 'yes-bypass-permissions' + | 'yes-accept-edits' + | 'yes-accept-edits-keep-context' + | 'yes-default-keep-context' + | 'yes-resume-auto-mode' + | 'yes-auto-clear-context' + | 'ultraplan' + | 'no' /** * Build permission updates for plan approval, including prompt-based rules if provided. * Prompt-based rules are only added when classifier permissions are enabled (Ant-only). */ -export function buildPermissionUpdates(mode: PermissionMode, allowedPrompts?: AllowedPrompt[]): PermissionUpdate[] { - const updates: PermissionUpdate[] = [{ - type: 'setMode', - mode: toExternalPermissionMode(mode), - destination: 'session' - }]; +export function buildPermissionUpdates( + mode: PermissionMode, + allowedPrompts?: AllowedPrompt[], +): PermissionUpdate[] { + const updates: PermissionUpdate[] = [ + { + type: 'setMode', + mode: toExternalPermissionMode(mode), + destination: 'session', + }, + ] // Add prompt-based permission rules if provided (Ant-only feature) - if (isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0) { + if ( + isClassifierPermissionsEnabled() && + allowedPrompts && + allowedPrompts.length > 0 + ) { updates.push({ type: 'addRules', rules: allowedPrompts.map(p => ({ toolName: p.tool, - ruleContent: createPromptRuleContent(p.prompt) + ruleContent: createPromptRuleContent(p.prompt), })), behavior: 'allow', - destination: 'session' - }); + destination: 'session', + }) } - return updates; + + return updates } /** @@ -80,200 +154,242 @@ export function buildPermissionUpdates(mode: PermissionMode, allowedPrompts?: Al * if they haven't already named it via /rename or --name. Fire-and-forget. * Mirrors /rename: kebab-case name, updates the prompt-border badge. */ -export function autoNameSessionFromPlan(plan: string, setAppState: (updater: (prev: AppState) => AppState) => void, isClearContext: boolean): void { - if (isSessionPersistenceDisabled() || getSettings_DEPRECATED()?.cleanupPeriodDays === 0) { - return; +export function autoNameSessionFromPlan( + plan: string, + setAppState: (updater: (prev: AppState) => AppState) => void, + isClearContext: boolean, +): void { + if ( + isSessionPersistenceDisabled() || + getSettings_DEPRECATED()?.cleanupPeriodDays === 0 + ) { + return } // On clear-context, the current session is about to be abandoned — its // title (which may have been set by a PRIOR auto-name) is irrelevant. // Checking it would make the feature self-defeating after first use. - if (!isClearContext && getCurrentSessionTitle(getSessionId())) return; + if (!isClearContext && getCurrentSessionTitle(getSessionId())) return void generateSessionName( - // generateSessionName tail-slices to the last 1000 chars (correct for - // conversations, where recency matters). Plans front-load the goal and - // end with testing steps — head-slice so Haiku sees the summary. - [createUserMessage({ - content: plan.slice(0, 1000) - })], new AbortController().signal).then(async name => { - // On clear-context acceptance, regenerateSessionId() has run by now — - // this intentionally names the NEW execution session. Do not "fix" by - // capturing sessionId once; that would name the abandoned planning session. - if (!name || getCurrentSessionTitle(getSessionId())) return; - const sessionId = getSessionId() as UUID; - const fullPath = getTranscriptPath(); - await saveCustomTitle(sessionId, name, fullPath, 'auto'); - await saveAgentName(sessionId, name, fullPath, 'auto'); - setAppState(prev => { - if (prev.standaloneAgentContext?.name === name) return prev; - return { - ...prev, - standaloneAgentContext: { - ...prev.standaloneAgentContext, - name + // generateSessionName tail-slices to the last 1000 chars (correct for + // conversations, where recency matters). Plans front-load the goal and + // end with testing steps — head-slice so Haiku sees the summary. + [createUserMessage({ content: plan.slice(0, 1000) })], + new AbortController().signal, + ) + .then(async name => { + // On clear-context acceptance, regenerateSessionId() has run by now — + // this intentionally names the NEW execution session. Do not "fix" by + // capturing sessionId once; that would name the abandoned planning session. + if (!name || getCurrentSessionTitle(getSessionId())) return + const sessionId = getSessionId() as UUID + const fullPath = getTranscriptPath() + await saveCustomTitle(sessionId, name, fullPath, 'auto') + await saveAgentName(sessionId, name, fullPath, 'auto') + setAppState(prev => { + if (prev.standaloneAgentContext?.name === name) return prev + return { + ...prev, + standaloneAgentContext: { ...prev.standaloneAgentContext, name }, } - }; - }); - }).catch(logError); + }) + }) + .catch(logError) } + export function ExitPlanModePermissionRequest({ toolUseConfirm, onDone, onReject, workerBadge, - setStickyFooter + setStickyFooter, }: PermissionRequestProps): React.ReactNode { - const toolPermissionContext = useAppState(s => s.toolPermissionContext); - const setAppState = useSetAppState(); - const store = useAppStateStore(); - const { - addNotification - } = useNotifications(); + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const setAppState = useSetAppState() + const store = useAppStateStore() + const { addNotification } = useNotifications() // Feedback text from the 'No' option's input. Threaded through onAllow as // acceptFeedback when the user approves — lets users annotate the plan // ("also update the README") without a reject+re-plan round-trip. - const [planFeedback, setPlanFeedback] = useState(''); - const [pastedContents, setPastedContents] = useState>({}); - const nextPasteIdRef = useRef(0); - const showClearContext = useAppState(s => s.settings.showClearContextOnPlanAccept) ?? false; - const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl); - const ultraplanLaunching = useAppState(s => s.ultraplanLaunching); + const [planFeedback, setPlanFeedback] = useState('') + const [pastedContents, setPastedContents] = useState< + Record + >({}) + const nextPasteIdRef = useRef(0) + + const showClearContext = + useAppState(s => s.settings.showClearContextOnPlanAccept) ?? false + const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl) + const ultraplanLaunching = useAppState(s => s.ultraplanLaunching) // Hide the Ultraplan button while a session is active or launching — // selecting it would dismiss the dialog and reject locally before // launchUltraplan can notice the session exists and return "already polling". // feature() must sit directly in an if/ternary (bun:bundle DCE constraint). - const showUltraplan = feature('ULTRAPLAN') ? !ultraplanSessionUrl && !ultraplanLaunching : false; - const usage = toolUseConfirm.assistantMessage.message.usage; - const { - mode, - isAutoModeAvailable, - isBypassPermissionsModeAvailable - } = toolPermissionContext; - const options = useMemo(() => buildPlanApprovalOptions({ - showClearContext, - showUltraplan, - usedPercent: showClearContext ? getContextUsedPercent(usage as any, mode) : null, - isAutoModeAvailable, - isBypassPermissionsModeAvailable, - onFeedbackChange: setPlanFeedback - }), [showClearContext, showUltraplan, usage, mode, isAutoModeAvailable, isBypassPermissionsModeAvailable]); - function onImagePaste(base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, _sourcePath?: string) { - const pasteId = nextPasteIdRef.current++; + const showUltraplan = feature('ULTRAPLAN') + ? !ultraplanSessionUrl && !ultraplanLaunching + : false + const usage = toolUseConfirm.assistantMessage.message.usage + const { mode, isAutoModeAvailable, isBypassPermissionsModeAvailable } = + toolPermissionContext + const options = useMemo( + () => + buildPlanApprovalOptions({ + showClearContext, + showUltraplan, + usedPercent: showClearContext + ? getContextUsedPercent(usage, mode) + : null, + isAutoModeAvailable, + isBypassPermissionsModeAvailable, + onFeedbackChange: setPlanFeedback, + }), + [ + showClearContext, + showUltraplan, + usage, + mode, + isAutoModeAvailable, + isBypassPermissionsModeAvailable, + ], + ) + + function onImagePaste( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + _sourcePath?: string, + ) { + const pasteId = nextPasteIdRef.current++ const newContent: PastedContent = { id: pasteId, type: 'image', content: base64Image, mediaType: mediaType || 'image/png', filename: filename || 'Pasted image', - dimensions - }; - cacheImagePath(newContent); - void storeImage(newContent); - setPastedContents(prev => ({ - ...prev, - [pasteId]: newContent - })); + dimensions, + } + cacheImagePath(newContent) + void storeImage(newContent) + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) } + const onRemoveImage = useCallback((id: number) => { setPastedContents(prev => { - const next = { - ...prev - }; - delete next[id]; - return next; - }); - }, []); - const imageAttachments = Object.values(pastedContents).filter(c => c.type === 'image'); - const hasImages = imageAttachments.length > 0; + const next = { ...prev } + delete next[id] + return next + }) + }, []) + + const imageAttachments = Object.values(pastedContents).filter( + c => c.type === 'image', + ) + const hasImages = imageAttachments.length > 0 // TODO: Delete the branch after moving to V2 // Use tool name to detect V2 instead of checking input.plan, because PR #10394 // injects plan content into input.plan for hooks/SDK, which broke the old detection // (see issue #10878) - const isV2 = toolUseConfirm.tool.name === EXIT_PLAN_MODE_V2_TOOL_NAME; - const inputPlan = isV2 ? undefined : toolUseConfirm.input.plan as string | undefined; - const planFilePath = isV2 ? getPlanFilePath() : undefined; + const isV2 = toolUseConfirm.tool.name === EXIT_PLAN_MODE_V2_TOOL_NAME + const inputPlan = isV2 + ? undefined + : (toolUseConfirm.input.plan as string | undefined) + const planFilePath = isV2 ? getPlanFilePath() : undefined // Extract allowed prompts requested by the plan (Ant-only feature) - const allowedPrompts = toolUseConfirm.input.allowedPrompts as AllowedPrompt[] | undefined; + const allowedPrompts = toolUseConfirm.input.allowedPrompts as + | AllowedPrompt[] + | undefined // Get the raw plan to check if it's empty - const rawPlan = inputPlan ?? getPlan(); - const isEmpty = !rawPlan || rawPlan.trim() === ''; + const rawPlan = inputPlan ?? getPlan() + const isEmpty = !rawPlan || rawPlan.trim() === '' // Capture the variant once on mount. GrowthBook reads from a disk cache // so the value is stable across a single planning session. undefined = // control arm. The variant is a fixed 3-value enum of short literals, // not user input. - const [planStructureVariant] = useState(() => (getPewterLedgerVariant() ?? undefined) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS); + const [planStructureVariant] = useState( + () => + (getPewterLedgerVariant() ?? + undefined) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ) + const [currentPlan, setCurrentPlan] = useState(() => { - if (inputPlan) return inputPlan; - const plan = getPlan(); - return plan ?? 'No plan found. Please write your plan to the plan file first.'; - }); - const [showSaveMessage, setShowSaveMessage] = useState(false); + if (inputPlan) return inputPlan + const plan = getPlan() + return ( + plan ?? 'No plan found. Please write your plan to the plan file first.' + ) + }) + const [showSaveMessage, setShowSaveMessage] = useState(false) // Track Ctrl+G local edits so updatedInput can include the plan (the tool // only echoes the plan in tool_result when input.plan is set — otherwise // the model already has it in context from writing the plan file). - const [planEditedLocally, setPlanEditedLocally] = useState(false); + const [planEditedLocally, setPlanEditedLocally] = useState(false) // Auto-hide save message after 5 seconds useEffect(() => { if (showSaveMessage) { - const timer = setTimeout(setShowSaveMessage, 5000, false); - return () => clearTimeout(timer); + const timer = setTimeout(setShowSaveMessage, 5000, false) + return () => clearTimeout(timer) } - }, [showSaveMessage]); + }, [showSaveMessage]) // Handle Ctrl+G to edit plan in $EDITOR, Shift+Tab for auto-accept edits const handleKeyDown = (e: KeyboardEvent): void => { if (e.ctrl && e.key === 'g') { - e.preventDefault(); - logEvent('tengu_plan_external_editor_used', {}); + e.preventDefault() + logEvent('tengu_plan_external_editor_used', {}) + void (async () => { if (isV2 && planFilePath) { - const result = await editFileInEditor(planFilePath); + const result = await editFileInEditor(planFilePath) if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', - priority: 'high' - }); + priority: 'high', + }) } if (result.content !== null) { - if (result.content !== currentPlan) setPlanEditedLocally(true); - setCurrentPlan(result.content); - setShowSaveMessage(true); + if (result.content !== currentPlan) setPlanEditedLocally(true) + setCurrentPlan(result.content) + setShowSaveMessage(true) } } else { - const result = await editPromptInEditor(currentPlan); + const result = await editPromptInEditor(currentPlan) if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', - priority: 'high' - }); + priority: 'high', + }) } if (result.content !== null && result.content !== currentPlan) { - setCurrentPlan(result.content); - setShowSaveMessage(true); + setCurrentPlan(result.content) + setShowSaveMessage(true) } } - })(); - return; + })() + return } // Shift+Tab immediately selects "auto-accept edits" if (e.shift && e.key === 'tab') { - e.preventDefault(); - void handleResponse(showClearContext ? 'yes-accept-edits' : 'yes-accept-edits-keep-context'); - return; + e.preventDefault() + void handleResponse( + showClearContext ? 'yes-accept-edits' : 'yes-accept-edits-keep-context', + ) + return } - }; + } + async function handleResponse(value: ResponseValue): Promise { - const trimmedFeedback = planFeedback.trim(); - const acceptFeedback = trimmedFeedback || undefined; + const trimmedFeedback = planFeedback.trim() + const acceptFeedback = trimmedFeedback || undefined // Ultraplan: reject locally, teleport the plan to CCR as a seed draft. // Dialog dismisses immediately so the query loop unblocks; the teleport @@ -281,145 +397,179 @@ export function ExitPlanModePermissionRequest({ if (value === 'ultraplan') { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: 'ultraplan' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + 'ultraplan' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - planStructureVariant - }); - onDone(); - onReject(); - toolUseConfirm.onReject('Plan being refined via Ultraplan — please wait for the result.'); + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject( + 'Plan being refined via Ultraplan — please wait for the result.', + ) void launchUltraplan({ blurb: '', seedPlan: currentPlan, getAppState: store.getState, setAppState: store.setState, - signal: new AbortController().signal - }).then(msg => enqueuePendingNotification({ - value: msg, - mode: 'task-notification' - })).catch(logError); - return; + signal: new AbortController().signal, + }) + .then(msg => + enqueuePendingNotification({ value: msg, mode: 'task-notification' }), + ) + .catch(logError) + return } // V1: pass plan in input. V2: plan is on disk, but if the user edited it // via Ctrl+G we pass it through so the tool echoes the edit in tool_result // (otherwise the model never sees the user's changes). - const updatedInput = isV2 && !planEditedLocally ? {} : { - plan: currentPlan - }; + const updatedInput = isV2 && !planEditedLocally ? {} : { plan: currentPlan } // If auto was active during plan (from auto mode or opt-in) and NOT going // to auto, deactivate auto + restore permissions + fire exit attachment. if (feature('TRANSCRIPT_CLASSIFIER')) { - const goingToAuto = (value === 'yes-resume-auto-mode' || value === 'yes-auto-clear-context') && isAutoModeGateEnabled(); + const goingToAuto = + (value === 'yes-resume-auto-mode' || + value === 'yes-auto-clear-context') && + isAutoModeGateEnabled() // isAutoModeActive() is the authoritative signal — prePlanMode/ // strippedDangerousRules are stale after transitionPlanAutoMode // deactivates mid-plan (would cause duplicate exit attachment). - const autoWasUsedDuringPlan = autoModeStateModule?.isAutoModeActive() ?? false; + const autoWasUsedDuringPlan = + autoModeStateModule?.isAutoModeActive() ?? false if (value !== 'no' && !goingToAuto && autoWasUsedDuringPlan) { - autoModeStateModule?.setAutoModeActive(false); - setNeedsAutoModeExitAttachment(true); + autoModeStateModule?.setAutoModeActive(false) + setNeedsAutoModeExitAttachment(true) setAppState(prev => ({ ...prev, toolPermissionContext: { ...restoreDangerousPermissions(prev.toolPermissionContext), - prePlanMode: undefined - } - })); + prePlanMode: undefined, + }, + })) } } // Clear-context options: set pending plan implementation and reject the dialog // The REPL will handle context clear and trigger a fresh query // Keep-context options skip this block and go through the normal flow below - const isResumeAutoOption = feature('TRANSCRIPT_CLASSIFIER') ? value === 'yes-resume-auto-mode' : false; - const isKeepContextOption = value === 'yes-accept-edits-keep-context' || value === 'yes-default-keep-context' || isResumeAutoOption; + const isResumeAutoOption = feature('TRANSCRIPT_CLASSIFIER') + ? value === 'yes-resume-auto-mode' + : false + const isKeepContextOption = + value === 'yes-accept-edits-keep-context' || + value === 'yes-default-keep-context' || + isResumeAutoOption + if (value !== 'no') { - autoNameSessionFromPlan(currentPlan, setAppState, !isKeepContextOption); + autoNameSessionFromPlan(currentPlan, setAppState, !isKeepContextOption) } + if (value !== 'no' && !isKeepContextOption) { // Determine the permission mode based on the selected option - let mode: PermissionMode = 'default'; + let mode: PermissionMode = 'default' if (value === 'yes-bypass-permissions') { - mode = 'bypassPermissions'; + mode = 'bypassPermissions' } else if (value === 'yes-accept-edits') { - mode = 'acceptEdits'; - } else if (feature('TRANSCRIPT_CLASSIFIER') && value === 'yes-auto-clear-context' && isAutoModeGateEnabled()) { + mode = 'acceptEdits' + } else if ( + feature('TRANSCRIPT_CLASSIFIER') && + value === 'yes-auto-clear-context' && + isAutoModeGateEnabled() + ) { // REPL's processInitialMessage handles stripDangerousPermissions + mode, // but does NOT set autoModeActive. Gate-off falls through to 'default'. - mode = 'auto'; - autoModeStateModule?.setAutoModeActive(true); + mode = 'auto' + autoModeStateModule?.setAutoModeActive(true) } // Log plan exit event logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: true, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - hasFeedback: !!acceptFeedback - }); + hasFeedback: !!acceptFeedback, + }) // Set initial message - REPL will handle context clear and fresh query // Add verification instruction if the feature is enabled // Dead code elimination: CLAUDE_CODE_VERIFY_PLAN='false' in external builds, so === 'true' check allows Bun to eliminate the string - const verificationInstruction = undefined === 'true' ? `\n\nIMPORTANT: When you have finished implementing the plan, you MUST call the "VerifyPlanExecution" tool directly (NOT the ${AGENT_TOOL_NAME} tool or an agent) to trigger background verification.` : ''; + const verificationInstruction = + undefined === 'true' + ? `\n\nIMPORTANT: When you have finished implementing the plan, you MUST call the "VerifyPlanExecution" tool directly (NOT the ${AGENT_TOOL_NAME} tool or an agent) to trigger background verification.` + : '' // Capture the transcript path before context is cleared (session ID will be regenerated) - const transcriptPath = getTranscriptPath(); - const transcriptHint = `\n\nIf you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ${transcriptPath}`; - const teamHint = isAgentSwarmsEnabled() ? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.` : ''; - const feedbackSuffix = acceptFeedback ? `\n\nUser feedback on this plan: ${acceptFeedback}` : ''; + const transcriptPath = getTranscriptPath() + const transcriptHint = `\n\nIf you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ${transcriptPath}` + + const teamHint = isAgentSwarmsEnabled() + ? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.` + : '' + + const feedbackSuffix = acceptFeedback + ? `\n\nUser feedback on this plan: ${acceptFeedback}` + : '' + setAppState(prev => ({ ...prev, initialMessage: { message: { ...createUserMessage({ - content: `Implement the following plan:\n\n${currentPlan}${verificationInstruction}${transcriptHint}${teamHint}${feedbackSuffix}` + content: `Implement the following plan:\n\n${currentPlan}${verificationInstruction}${transcriptHint}${teamHint}${feedbackSuffix}`, }), - planContent: currentPlan + planContent: currentPlan, }, clearContext: true, mode, - allowedPrompts - } - })); - setHasExitedPlanMode(true); - onDone(); - onReject(); + allowedPrompts, + }, + })) + + setHasExitedPlanMode(true) + onDone() + onReject() // Reject the tool use to unblock the query loop // The REPL will see pendingInitialQuery and trigger fresh query - toolUseConfirm.onReject(); - return; + toolUseConfirm.onReject() + return } // Handle auto keep-context option — needs special handling because // buildPermissionUpdates maps auto to 'default' via toExternalPermissionMode. // We set the mode directly via setAppState and sync the bootstrap state. - if (feature('TRANSCRIPT_CLASSIFIER') && value === 'yes-resume-auto-mode' && isAutoModeGateEnabled()) { + if ( + feature('TRANSCRIPT_CLASSIFIER') && + value === 'yes-resume-auto-mode' && + isAutoModeGateEnabled() + ) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: false, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - hasFeedback: !!acceptFeedback - }); - setHasExitedPlanMode(true); - setNeedsPlanModeExitAttachment(true); - autoModeStateModule?.setAutoModeActive(true); + hasFeedback: !!acceptFeedback, + }) + setHasExitedPlanMode(true) + setNeedsPlanModeExitAttachment(true) + autoModeStateModule?.setAutoModeActive(true) setAppState(prev => ({ ...prev, toolPermissionContext: stripDangerousPermissionsForAutoMode({ ...prev.toolPermissionContext, mode: 'auto', - prePlanMode: undefined - }) - })); - onDone(); - toolUseConfirm.onAllow(updatedInput, [], acceptFeedback); - return; + prePlanMode: undefined, + }), + })) + onDone() + toolUseConfirm.onAllow(updatedInput, [], acceptFeedback) + return } // Handle keep-context options (goes through normal onAllow flow) @@ -428,86 +578,109 @@ export function ExitPlanModePermissionRequest({ // Without this fallback the function would return without resolving the // dialog, leaving the query loop blocked and safety state corrupted. const keepContextModes: Record = { - 'yes-accept-edits-keep-context': toolPermissionContext.isBypassPermissionsModeAvailable ? 'bypassPermissions' : 'acceptEdits', + 'yes-accept-edits-keep-context': + toolPermissionContext.isBypassPermissionsModeAvailable + ? 'bypassPermissions' + : 'acceptEdits', 'yes-default-keep-context': 'default', - ...(feature('TRANSCRIPT_CLASSIFIER') ? { - 'yes-resume-auto-mode': 'default' as const - } : {}) - }; - const keepContextMode = keepContextModes[value]; + ...(feature('TRANSCRIPT_CLASSIFIER') + ? { 'yes-resume-auto-mode': 'default' as const } + : {}), + } + const keepContextMode = keepContextModes[value] if (keepContextMode) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: false, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - hasFeedback: !!acceptFeedback - }); - setHasExitedPlanMode(true); - setNeedsPlanModeExitAttachment(true); - onDone(); - toolUseConfirm.onAllow(updatedInput, buildPermissionUpdates(keepContextMode, allowedPrompts), acceptFeedback); - return; + hasFeedback: !!acceptFeedback, + }) + setHasExitedPlanMode(true) + setNeedsPlanModeExitAttachment(true) + onDone() + toolUseConfirm.onAllow( + updatedInput, + buildPermissionUpdates(keepContextMode, allowedPrompts), + acceptFeedback, + ) + return } // Handle standard approval options const standardModes: Record = { 'yes-bypass-permissions': 'bypassPermissions', - 'yes-accept-edits': 'acceptEdits' - }; - const standardMode = standardModes[value]; + 'yes-accept-edits': 'acceptEdits', + } + const standardMode = standardModes[value] if (standardMode) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - hasFeedback: !!acceptFeedback - }); - setHasExitedPlanMode(true); - setNeedsPlanModeExitAttachment(true); - onDone(); - toolUseConfirm.onAllow(updatedInput, buildPermissionUpdates(standardMode, allowedPrompts), acceptFeedback); - return; + hasFeedback: !!acceptFeedback, + }) + setHasExitedPlanMode(true) + setNeedsPlanModeExitAttachment(true) + onDone() + toolUseConfirm.onAllow( + updatedInput, + buildPermissionUpdates(standardMode, allowedPrompts), + acceptFeedback, + ) + return } // Handle 'no' - stay in plan mode if (value === 'no') { if (!trimmedFeedback && !hasImages) { // No feedback yet - user is still on the input field - return; + return } + logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - planStructureVariant - }); + planStructureVariant, + }) // Convert pasted images to ImageBlockParam[] with resizing - let imageBlocks: ImageBlockParam[] | undefined; + let imageBlocks: ImageBlockParam[] | undefined if (hasImages) { - imageBlocks = await Promise.all(imageAttachments.map(async img => { - const block: ImageBlockParam = { - type: 'image', - source: { - type: 'base64', - media_type: (img.mediaType || 'image/png') as Base64ImageSource['media_type'], - data: img.content + imageBlocks = await Promise.all( + imageAttachments.map(async img => { + const block: ImageBlockParam = { + type: 'image', + source: { + type: 'base64', + media_type: (img.mediaType || + 'image/png') as Base64ImageSource['media_type'], + data: img.content, + }, } - }; - const resized = await maybeResizeAndDownsampleImageBlock(block); - return resized.block; - })); + const resized = await maybeResizeAndDownsampleImageBlock(block) + return resized.block + }), + ) } - onDone(); - onReject(); - toolUseConfirm.onReject(trimmedFeedback || (hasImages ? '(See attached image)' : undefined), imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined); + + onDone() + onReject() + toolUseConfirm.onReject( + trimmedFeedback || (hasImages ? '(See attached image)' : undefined), + imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, + ) } } - const editor = getExternalEditor(); - const editorName = editor ? toIDEDisplayName(editor) : null; + + const editor = getExternalEditor() + const editorName = editor ? toIDEDisplayName(editor) : null // Sticky footer: when setStickyFooter is provided (fullscreen mode), the // Select options render in FullscreenLayout's `bottom` slot so they stay @@ -515,44 +688,77 @@ export function ExitPlanModePermissionRequest({ // wrapped in a ref so the JSX (set once per options/images change) can call // the latest closure without re-registering on every keystroke. React // reconciles the sticky-footer Select by type, preserving focus/input state. - const handleResponseRef = useRef(handleResponse); - handleResponseRef.current = handleResponse; - const handleCancelRef = useRef<() => void>(undefined); + const handleResponseRef = useRef(handleResponse) + handleResponseRef.current = handleResponse + const handleCancelRef = useRef<() => void>(undefined) handleCancelRef.current = () => { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - planStructureVariant - }); - onDone(); - onReject(); - toolUseConfirm.onReject(); - }; - const useStickyFooter = !isEmpty && !!setStickyFooter; + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject() + } + const useStickyFooter = !isEmpty && !!setStickyFooter useLayoutEffect(() => { - if (!useStickyFooter) return; - setStickyFooter( + if (!useStickyFooter) return + setStickyFooter( + Would you like to proceed? - void handleResponseRef.current(v)} + onCancel={() => handleCancelRef.current?.()} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + /> - {editorName && + {editorName && ( + ctrl-g to edit in {editorName} - {isV2 && planFilePath && · {getDisplayPath(planFilePath)}} - {showSaveMessage && <> + {isV2 && planFilePath && ( + · {getDisplayPath(planFilePath)} + )} + {showSaveMessage && ( + <> {' · '} {figures.tick}Plan saved! - } - } - ); - return () => setStickyFooter(null); + + )} + + )} + , + ) + return () => setStickyFooter(null) // onImagePaste/onRemoveImage are stable (useCallback/useRef-backed above) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [useStickyFooter, setStickyFooter, options, pastedContents, editorName, isV2, planFilePath, showSaveMessage]); + }, [ + useStickyFooter, + setStickyFooter, + options, + pastedContents, + editorName, + isV2, + planFilePath, + showSaveMessage, + ]) // Simplified UI for empty plans if (isEmpty) { @@ -560,114 +766,169 @@ export function ExitPlanModePermissionRequest({ if (value === 'yes') { logEvent('tengu_plan_exit', { planLengthChars: 0, - outcome: 'yes-default' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + 'yes-default' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - planStructureVariant - }); + planStructureVariant, + }) if (feature('TRANSCRIPT_CLASSIFIER')) { - const autoWasUsedDuringPlan = autoModeStateModule?.isAutoModeActive() ?? false; + const autoWasUsedDuringPlan = + autoModeStateModule?.isAutoModeActive() ?? false if (autoWasUsedDuringPlan) { - autoModeStateModule?.setAutoModeActive(false); - setNeedsAutoModeExitAttachment(true); + autoModeStateModule?.setAutoModeActive(false) + setNeedsAutoModeExitAttachment(true) setAppState(prev => ({ ...prev, toolPermissionContext: { ...restoreDangerousPermissions(prev.toolPermissionContext), - prePlanMode: undefined - } - })); + prePlanMode: undefined, + }, + })) } } - setHasExitedPlanMode(true); - setNeedsPlanModeExitAttachment(true); - onDone(); - toolUseConfirm.onAllow({}, [{ - type: 'setMode', - mode: 'default', - destination: 'session' - }]); + setHasExitedPlanMode(true) + setNeedsPlanModeExitAttachment(true) + onDone() + toolUseConfirm.onAllow({}, [ + { type: 'setMode', mode: 'default', destination: 'session' }, + ]) } else { logEvent('tengu_plan_exit', { planLengthChars: 0, - outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: + 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - planStructureVariant - }); - onDone(); - onReject(); - toolUseConfirm.onReject(); + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject() } } - return + + return ( + Claude wants to exit plan mode - { + logEvent('tengu_plan_exit', { + planLengthChars: 0, + outcome: + 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject() + }} + /> - ; + + ) } - return - + + return ( + + Here is Claude's plan: - + {currentPlan} - - {isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0 && + + {isClassifierPermissionsEnabled() && + allowedPrompts && + allowedPrompts.length > 0 && ( + Requested permissions: - {allowedPrompts.map((p, i) => + {allowedPrompts.map((p, i) => ( + {' '}· {p.tool}({PROMPT_PREFIX} {p.prompt}) - )} - } - {!useStickyFooter && <> + + ))} + + )} + {!useStickyFooter && ( + <> Claude has written up a plan and is ready to execute. Would you like to proceed? - handleCancelRef.current?.()} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + /> - } + + )} - {!useStickyFooter && editorName && + {!useStickyFooter && editorName && ( + ctrl-g to edit in {editorName} - {isV2 && planFilePath && · {getDisplayPath(planFilePath)}} + {isV2 && planFilePath && ( + · {getDisplayPath(planFilePath)} + )} - {showSaveMessage && + {showSaveMessage && ( + {' · '} {figures.tick}Plan saved! - } - } - ; + + )} + + )} + + ) } /** @internal Exported for testing. */ @@ -677,33 +938,34 @@ export function buildPlanApprovalOptions({ usedPercent, isAutoModeAvailable, isBypassPermissionsModeAvailable, - onFeedbackChange + onFeedbackChange, }: { - showClearContext: boolean; - showUltraplan: boolean; - usedPercent: number | null; - isAutoModeAvailable: boolean | undefined; - isBypassPermissionsModeAvailable: boolean | undefined; - onFeedbackChange: (v: string) => void; + showClearContext: boolean + showUltraplan: boolean + usedPercent: number | null + isAutoModeAvailable: boolean | undefined + isBypassPermissionsModeAvailable: boolean | undefined + onFeedbackChange: (v: string) => void }): OptionWithDescription[] { - const options: OptionWithDescription[] = []; - const usedLabel = usedPercent !== null ? ` (${usedPercent}% used)` : ''; + const options: OptionWithDescription[] = [] + const usedLabel = usedPercent !== null ? ` (${usedPercent}% used)` : '' + if (showClearContext) { if (feature('TRANSCRIPT_CLASSIFIER') && isAutoModeAvailable) { options.push({ label: `Yes, clear context${usedLabel} and use auto mode`, - value: 'yes-auto-clear-context' - }); + value: 'yes-auto-clear-context', + }) } else if (isBypassPermissionsModeAvailable) { options.push({ label: `Yes, clear context${usedLabel} and bypass permissions`, - value: 'yes-bypass-permissions' - }); + value: 'yes-bypass-permissions', + }) } else { options.push({ label: `Yes, clear context${usedLabel} and auto-accept edits`, - value: 'yes-accept-edits' - }); + value: 'yes-accept-edits', + }) } } @@ -711,57 +973,71 @@ export function buildPlanApprovalOptions({ if (feature('TRANSCRIPT_CLASSIFIER') && isAutoModeAvailable) { options.push({ label: 'Yes, and use auto mode', - value: 'yes-resume-auto-mode' - }); + value: 'yes-resume-auto-mode', + }) } else if (isBypassPermissionsModeAvailable) { options.push({ label: 'Yes, and bypass permissions', - value: 'yes-accept-edits-keep-context' - }); + value: 'yes-accept-edits-keep-context', + }) } else { options.push({ label: 'Yes, auto-accept edits', - value: 'yes-accept-edits-keep-context' - }); + value: 'yes-accept-edits-keep-context', + }) } + options.push({ label: 'Yes, manually approve edits', - value: 'yes-default-keep-context' - }); + value: 'yes-default-keep-context', + }) + if (showUltraplan) { options.push({ label: 'No, refine with Ultraplan on Claude Code on the web', - value: 'ultraplan' - }); + value: 'ultraplan', + }) } + options.push({ type: 'input', label: 'No, keep planning', value: 'no', placeholder: 'Tell Claude what to change', description: 'shift+tab to approve with this feedback', - onChange: onFeedbackChange - }); - return options; + onChange: onFeedbackChange, + }) + + return options } -function getContextUsedPercent(usage: { - input_tokens: number; - cache_creation_input_tokens?: number | null; - cache_read_input_tokens?: number | null; -} | undefined, permissionMode: PermissionMode): number | null { - if (!usage) return null; + +function getContextUsedPercent( + usage: + | { + input_tokens: number + cache_creation_input_tokens?: number | null + cache_read_input_tokens?: number | null + } + | undefined, + permissionMode: PermissionMode, +): number | null { + if (!usage) return null const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel: getMainLoopModel(), - exceeds200kTokens: false - }); - const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas()); - const { - used - } = calculateContextPercentages({ - input_tokens: usage.input_tokens, - cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, - cache_read_input_tokens: usage.cache_read_input_tokens ?? 0 - }, contextWindowSize); - return used; + exceeds200kTokens: false, + }) + const contextWindowSize = getContextWindowForModel( + runtimeModel, + getSdkBetas(), + ) + const { used } = calculateContextPercentages( + { + input_tokens: usage.input_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, + cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, + }, + contextWindowSize, + ) + return used } diff --git a/src/components/permissions/FallbackPermissionRequest.tsx b/src/components/permissions/FallbackPermissionRequest.tsx index 38266d23e..9b7fee994 100644 --- a/src/components/permissions/FallbackPermissionRequest.tsx +++ b/src/components/permissions/FallbackPermissionRequest.tsx @@ -1,332 +1,196 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useMemo } from 'react'; -import { getOriginalCwd } from '../../bootstrap/state.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'; -import { env } from '../../utils/env.js'; -import { shouldShowAlwaysAllowOptions } from '../../utils/permissions/permissionsLoader.js'; -import { truncateToLines } from '../../utils/stringUtils.js'; -import { logUnaryEvent } from '../../utils/unaryLogging.js'; -import { type UnaryEvent, usePermissionRequestLogging } from './hooks.js'; -import { PermissionDialog } from './PermissionDialog.js'; -import { PermissionPrompt, type PermissionPromptOption, type ToolAnalyticsContext } from './PermissionPrompt.js'; -import type { PermissionRequestProps } from './PermissionRequest.js'; -import { PermissionRuleExplanation } from './PermissionRuleExplanation.js'; -type FallbackOptionValue = 'yes' | 'yes-dont-ask-again' | 'no'; -export function FallbackPermissionRequest(t0) { - const $ = _c(58); - const { - toolUseConfirm, - onDone, - onReject, - workerBadge - } = t0; - const [theme] = useTheme(); - let originalUserFacingName; - let t1; - if ($[0] !== toolUseConfirm.input || $[1] !== toolUseConfirm.tool) { - originalUserFacingName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); - t1 = originalUserFacingName.endsWith(" (MCP)") ? originalUserFacingName.slice(0, -6) : originalUserFacingName; - $[0] = toolUseConfirm.input; - $[1] = toolUseConfirm.tool; - $[2] = originalUserFacingName; - $[3] = t1; - } else { - originalUserFacingName = $[2]; - t1 = $[3]; - } - const userFacingName = t1; - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[4] = t2; - } else { - t2 = $[4]; - } - const unaryEvent = t2; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t3; - if ($[5] !== onDone || $[6] !== onReject || $[7] !== toolUseConfirm) { - t3 = (value, feedback) => { - bb8: switch (value) { - case "yes": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); - onDone(); - break bb8; - } - case "yes-dont-ask-again": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: toolUseConfirm.tool.name - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb8; - } - case "no": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onReject(feedback); - onReject(); - onDone(); - } - } - }; - $[5] = onDone; - $[6] = onReject; - $[7] = toolUseConfirm; - $[8] = t3; - } else { - t3 = $[8]; - } - const handleSelect = t3; - let t4; - if ($[9] !== onDone || $[10] !== onReject || $[11] !== toolUseConfirm) { - t4 = () => { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform +import React, { useCallback, useMemo } from 'react' +import { getOriginalCwd } from '../../bootstrap/state.js' +import { Box, Text, useTheme } from '../../ink.js' +import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js' +import { env } from '../../utils/env.js' +import { shouldShowAlwaysAllowOptions } from '../../utils/permissions/permissionsLoader.js' +import { truncateToLines } from '../../utils/stringUtils.js' +import { logUnaryEvent } from '../../utils/unaryLogging.js' +import { type UnaryEvent, usePermissionRequestLogging } from './hooks.js' +import { PermissionDialog } from './PermissionDialog.js' +import { + PermissionPrompt, + type PermissionPromptOption, + type ToolAnalyticsContext, +} from './PermissionPrompt.js' +import type { PermissionRequestProps } from './PermissionRequest.js' +import { PermissionRuleExplanation } from './PermissionRuleExplanation.js' + +type FallbackOptionValue = 'yes' | 'yes-dont-ask-again' | 'no' + +export function FallbackPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + verbose: _verbose, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [theme] = useTheme() + // TODO: Avoid these special cases + const originalUserFacingName = toolUseConfirm.tool.userFacingName( + toolUseConfirm.input as never, + ) + const userFacingName = originalUserFacingName.endsWith(' (MCP)') + ? originalUserFacingName.slice(0, -6) + : originalUserFacingName + + const unaryEvent = useMemo( + () => ({ + completion_type: 'tool_use_single', + language_name: 'none', + }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const handleSelect = useCallback( + (value: FallbackOptionValue, feedback?: string) => { + switch (value) { + case 'yes': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) + onDone() + break + case 'yes-dont-ask-again': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: toolUseConfirm.tool.name, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }); - toolUseConfirm.onReject(); - onReject(); - onDone(); - }; - $[9] = onDone; - $[10] = onReject; - $[11] = toolUseConfirm; - $[12] = t4; - } else { - t4 = $[12]; - } - const handleCancel = t4; - let t5; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t5 = getOriginalCwd(); - $[13] = t5; - } else { - t5 = $[13]; - } - const originalCwd = t5; - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = shouldShowAlwaysAllowOptions(); - $[14] = t6; - } else { - t6 = $[14]; - } - const showAlwaysAllowOptions = t6; - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - label: "Yes", - value: "yes", - feedbackConfig: { - type: "accept" + case 'no': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject(feedback) + onReject() + onDone() + break } - }; - $[15] = t7; - } else { - t7 = $[15]; - } - let result; - if ($[16] !== userFacingName) { - result = [t7]; + }, + [toolUseConfirm, onDone, onReject], + ) + + const handleCancel = useCallback(() => { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onReject() + onDone() + }, [toolUseConfirm, onDone, onReject]) + + const originalCwd = getOriginalCwd() + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): PermissionPromptOption[] => { + const result: PermissionPromptOption[] = [ + { + label: 'Yes', + value: 'yes', + feedbackConfig: { type: 'accept' }, + }, + ] + if (showAlwaysAllowOptions) { - const t8 = {userFacingName}; - let t9; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t9 = {originalCwd}; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== t8) { - t10 = { - label: Yes, and don't ask again for {t8}{" "}commands in {t9}, - value: "yes-dont-ask-again" - }; - $[19] = t8; - $[20] = t10; - } else { - t10 = $[20]; - } - result.push(t10); - } - let t8; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - label: "No", - value: "no", - feedbackConfig: { - type: "reject" - } - }; - $[21] = t8; - } else { - t8 = $[21]; + result.push({ + label: ( + + Yes, and don't ask again for {userFacingName}{' '} + commands in {originalCwd} + + ), + value: 'yes-dont-ask-again', + }) } - result.push(t8); - $[16] = userFacingName; - $[17] = result; - } else { - result = $[17]; - } - const options = result; - let t8; - if ($[22] !== toolUseConfirm.tool.name) { - t8 = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name); - $[22] = toolUseConfirm.tool.name; - $[23] = t8; - } else { - t8 = $[23]; - } - const t9 = toolUseConfirm.tool.isMcp ?? false; - let t10; - if ($[24] !== t8 || $[25] !== t9) { - t10 = { - toolName: t8, - isMcp: t9 - }; - $[24] = t8; - $[25] = t9; - $[26] = t10; - } else { - t10 = $[26]; - } - const toolAnalyticsContext = t10; - let t11; - if ($[27] !== theme || $[28] !== toolUseConfirm.input || $[29] !== toolUseConfirm.tool) { - t11 = toolUseConfirm.tool.renderToolUseMessage(toolUseConfirm.input as never, { - theme, - verbose: true - }); - $[27] = theme; - $[28] = toolUseConfirm.input; - $[29] = toolUseConfirm.tool; - $[30] = t11; - } else { - t11 = $[30]; - } - let t12; - if ($[31] !== originalUserFacingName) { - t12 = originalUserFacingName.endsWith(" (MCP)") ? (MCP) : ""; - $[31] = originalUserFacingName; - $[32] = t12; - } else { - t12 = $[32]; - } - let t13; - if ($[33] !== t11 || $[34] !== t12 || $[35] !== userFacingName) { - t13 = {userFacingName}({t11}){t12}; - $[33] = t11; - $[34] = t12; - $[35] = userFacingName; - $[36] = t13; - } else { - t13 = $[36]; - } - let t14; - if ($[37] !== toolUseConfirm.description) { - t14 = truncateToLines(toolUseConfirm.description, 3); - $[37] = toolUseConfirm.description; - $[38] = t14; - } else { - t14 = $[38]; - } - let t15; - if ($[39] !== t14) { - t15 = {t14}; - $[39] = t14; - $[40] = t15; - } else { - t15 = $[40]; - } - let t16; - if ($[41] !== t13 || $[42] !== t15) { - t16 = {t13}{t15}; - $[41] = t13; - $[42] = t15; - $[43] = t16; - } else { - t16 = $[43]; - } - let t17; - if ($[44] !== toolUseConfirm.permissionResult) { - t17 = ; - $[44] = toolUseConfirm.permissionResult; - $[45] = t17; - } else { - t17 = $[45]; - } - let t18; - if ($[46] !== handleCancel || $[47] !== handleSelect || $[48] !== options || $[49] !== toolAnalyticsContext) { - t18 = ; - $[46] = handleCancel; - $[47] = handleSelect; - $[48] = options; - $[49] = toolAnalyticsContext; - $[50] = t18; - } else { - t18 = $[50]; - } - let t19; - if ($[51] !== t17 || $[52] !== t18) { - t19 = {t17}{t18}; - $[51] = t17; - $[52] = t18; - $[53] = t19; - } else { - t19 = $[53]; - } - let t20; - if ($[54] !== t16 || $[55] !== t19 || $[56] !== workerBadge) { - t20 = {t16}{t19}; - $[54] = t16; - $[55] = t19; - $[56] = workerBadge; - $[57] = t20; - } else { - t20 = $[57]; - } - return t20; + + result.push({ + label: 'No', + value: 'no', + feedbackConfig: { type: 'reject' }, + }) + + return result + }, [userFacingName, originalCwd, showAlwaysAllowOptions]) + + const toolAnalyticsContext = useMemo( + (): ToolAnalyticsContext => ({ + toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name), + isMcp: toolUseConfirm.tool.isMcp ?? false, + }), + [toolUseConfirm.tool.name, toolUseConfirm.tool.isMcp], + ) + + return ( + + + + {userFacingName}( + {toolUseConfirm.tool.renderToolUseMessage( + toolUseConfirm.input as never, + { theme, verbose: true }, + )} + ) + {originalUserFacingName.endsWith(' (MCP)') ? ( + (MCP) + ) : ( + '' + )} + + {truncateToLines(toolUseConfirm.description, 3)} + + + + + + + + ) } diff --git a/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx index 44f620ce5..d3bae2c17 100644 --- a/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx +++ b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx @@ -1,181 +1,79 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename, relative } from 'path'; -import React from 'react'; -import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js'; -import { getCwd } from 'src/utils/cwd.js'; -import type { z } from 'zod/v4'; -import { Text } from '../../../ink.js'; -import { FileEditTool } from '../../../tools/FileEditTool/FileEditTool.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import { createSingleEditDiffConfig, type FileEdit, type IDEDiffSupport } from '../FilePermissionDialog/ideDiffConfig.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -type FileEditInput = z.infer; +import { basename, relative } from 'path' +import React from 'react' +import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js' +import { getCwd } from 'src/utils/cwd.js' +import type { z } from 'zod/v4' +import { Text } from '../../../ink.js' +import { FileEditTool } from '../../../tools/FileEditTool/FileEditTool.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import { + createSingleEditDiffConfig, + type FileEdit, + type IDEDiffSupport, +} from '../FilePermissionDialog/ideDiffConfig.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' + +type FileEditInput = z.infer + const ideDiffSupport: IDEDiffSupport = { - getConfig: (input: FileEditInput) => createSingleEditDiffConfig(input.file_path, input.old_string, input.new_string, input.replace_all), + getConfig: (input: FileEditInput) => + createSingleEditDiffConfig( + input.file_path, + input.old_string, + input.new_string, + input.replace_all, + ), applyChanges: (input: FileEditInput, modifiedEdits: FileEdit[]) => { - const firstEdit = modifiedEdits[0]; + const firstEdit = modifiedEdits[0] if (firstEdit) { return { ...input, old_string: firstEdit.old_string, new_string: firstEdit.new_string, - replace_all: firstEdit.replace_all - }; + replace_all: firstEdit.replace_all, + } } - return input; - } -}; -export function FileEditPermissionRequest(props) { - const $ = _c(51); - const parseInput = _temp; - let T0; - let T1; - let T2; - let file_path; - let new_string; - let old_string; - let replace_all; - let t0; - let t1; - let t10; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[0] !== props.onDone || $[1] !== props.onReject || $[2] !== props.toolUseConfirm || $[3] !== props.toolUseContext || $[4] !== props.workerBadge) { - const parsed = parseInput(props.toolUseConfirm.input); - ({ - file_path, - old_string, - new_string, - replace_all - } = parsed); - T2 = FilePermissionDialog; - t4 = props.toolUseConfirm; - t5 = props.toolUseContext; - t6 = props.onDone; - t7 = props.onReject; - t8 = props.workerBadge; - t9 = "Edit file"; - t10 = relative(getCwd(), file_path); - T1 = Text; - t2 = "Do you want to make this edit to"; - t3 = " "; - T0 = Text; - t0 = true; - t1 = basename(file_path); - $[0] = props.onDone; - $[1] = props.onReject; - $[2] = props.toolUseConfirm; - $[3] = props.toolUseContext; - $[4] = props.workerBadge; - $[5] = T0; - $[6] = T1; - $[7] = T2; - $[8] = file_path; - $[9] = new_string; - $[10] = old_string; - $[11] = replace_all; - $[12] = t0; - $[13] = t1; - $[14] = t10; - $[15] = t2; - $[16] = t3; - $[17] = t4; - $[18] = t5; - $[19] = t6; - $[20] = t7; - $[21] = t8; - $[22] = t9; - } else { - T0 = $[5]; - T1 = $[6]; - T2 = $[7]; - file_path = $[8]; - new_string = $[9]; - old_string = $[10]; - replace_all = $[11]; - t0 = $[12]; - t1 = $[13]; - t10 = $[14]; - t2 = $[15]; - t3 = $[16]; - t4 = $[17]; - t5 = $[18]; - t6 = $[19]; - t7 = $[20]; - t8 = $[21]; - t9 = $[22]; - } - let t11; - if ($[23] !== T0 || $[24] !== t0 || $[25] !== t1) { - t11 = {t1}; - $[23] = T0; - $[24] = t0; - $[25] = t1; - $[26] = t11; - } else { - t11 = $[26]; - } - let t12; - if ($[27] !== T1 || $[28] !== t11 || $[29] !== t2 || $[30] !== t3) { - t12 = {t2}{t3}{t11}?; - $[27] = T1; - $[28] = t11; - $[29] = t2; - $[30] = t3; - $[31] = t12; - } else { - t12 = $[31]; - } - const t13 = replace_all || false; - let t14; - if ($[32] !== new_string || $[33] !== old_string || $[34] !== t13) { - t14 = [{ - old_string, - new_string, - replace_all: t13 - }]; - $[32] = new_string; - $[33] = old_string; - $[34] = t13; - $[35] = t14; - } else { - t14 = $[35]; - } - let t15; - if ($[36] !== file_path || $[37] !== t14) { - t15 = ; - $[36] = file_path; - $[37] = t14; - $[38] = t15; - } else { - t15 = $[38]; - } - let t16; - if ($[39] !== T2 || $[40] !== file_path || $[41] !== t10 || $[42] !== t12 || $[43] !== t15 || $[44] !== t4 || $[45] !== t5 || $[46] !== t6 || $[47] !== t7 || $[48] !== t8 || $[49] !== t9) { - t16 = ; - $[39] = T2; - $[40] = file_path; - $[41] = t10; - $[42] = t12; - $[43] = t15; - $[44] = t4; - $[45] = t5; - $[46] = t6; - $[47] = t7; - $[48] = t8; - $[49] = t9; - $[50] = t16; - } else { - t16 = $[50]; - } - return t16; + return input + }, } -function _temp(input) { - return FileEditTool.inputSchema.parse(input); + +export function FileEditPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const parseInput = (input: unknown): FileEditInput => { + return FileEditTool.inputSchema.parse(input) + } + + const parsed = parseInput(props.toolUseConfirm.input) + const { file_path, old_string, new_string, replace_all } = parsed + + return ( + + Do you want to make this edit to{' '} + {basename(file_path)}? + + } + content={ + + } + path={file_path} + completionType="str_replace_single" + parseInput={parseInput} + ideDiffSupport={ideDiffSupport} + /> + ) } diff --git a/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx b/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx index 5814be3e9..b645949dc 100644 --- a/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx +++ b/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx @@ -1,50 +1,61 @@ -import { relative } from 'path'; -import React, { useMemo } from 'react'; -import { useDiffInIDE } from '../../../hooks/useDiffInIDE.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolUseContext } from '../../../Tool.js'; -import { getLanguageName } from '../../../utils/cliHighlight.js'; -import { getCwd } from '../../../utils/cwd.js'; -import { getFsImplementation, safeResolvePath } from '../../../utils/fsOperations.js'; -import { expandPath } from '../../../utils/path.js'; -import type { CompletionType } from '../../../utils/unaryLogging.js'; -import { Select } from '../../CustomSelect/index.js'; -import { ShowInIDEPrompt } from '../../ShowInIDEPrompt.js'; -import { usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import type { ToolUseConfirm } from '../PermissionRequest.js'; -import type { WorkerBadgeProps } from '../WorkerBadge.js'; -import type { IDEDiffSupport } from './ideDiffConfig.js'; -import type { FileOperationType, PermissionOption } from './permissionOptions.js'; -import { type ToolInput, useFilePermissionDialog } from './useFilePermissionDialog.js'; +import { relative } from 'path' +import React, { useMemo } from 'react' +import { useDiffInIDE } from '../../../hooks/useDiffInIDE.js' +import { Box, Text } from '../../../ink.js' +import type { ToolUseContext } from '../../../Tool.js' +import { getLanguageName } from '../../../utils/cliHighlight.js' +import { getCwd } from '../../../utils/cwd.js' +import { + getFsImplementation, + safeResolvePath, +} from '../../../utils/fsOperations.js' +import { expandPath } from '../../../utils/path.js' +import type { CompletionType } from '../../../utils/unaryLogging.js' +import { Select } from '../../CustomSelect/index.js' +import { ShowInIDEPrompt } from '../../ShowInIDEPrompt.js' +import { usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import type { ToolUseConfirm } from '../PermissionRequest.js' +import type { WorkerBadgeProps } from '../WorkerBadge.js' +import type { IDEDiffSupport } from './ideDiffConfig.js' +import type { + FileOperationType, + PermissionOption, +} from './permissionOptions.js' +import { + type ToolInput, + useFilePermissionDialog, +} from './useFilePermissionDialog.js' + export type FilePermissionDialogProps = { // Required props from PermissionRequestProps - toolUseConfirm: ToolUseConfirm; - toolUseContext: ToolUseContext; - onDone: () => void; - onReject: () => void; + toolUseConfirm: ToolUseConfirm + toolUseContext: ToolUseContext + onDone: () => void + onReject: () => void // Dialog customization - title: string; - subtitle?: React.ReactNode; - question?: string | React.ReactNode; - content?: React.ReactNode; // Can be general content or diff component + title: string + subtitle?: React.ReactNode + question?: string | React.ReactNode + content?: React.ReactNode // Can be general content or diff component // Logging - completionType?: CompletionType; - languageName?: string; // override — derived from path when omitted + completionType?: CompletionType + languageName?: string // override — derived from path when omitted // File/directory operations - path: string | null; - parseInput: (input: unknown) => T; - operationType?: FileOperationType; + path: string | null + parseInput: (input: unknown) => T + operationType?: FileOperationType // IDE diff support - ideDiffSupport?: IDEDiffSupport; + ideDiffSupport?: IDEDiffSupport // Worker badge for teammate permission requests - workerBadge: WorkerBadgeProps | undefined; -}; + workerBadge: WorkerBadgeProps | undefined +} + export function FilePermissionDialog({ toolUseConfirm, toolUseContext, @@ -60,33 +71,38 @@ export function FilePermissionDialog({ operationType = 'write', ideDiffSupport, workerBadge, - languageName: languageNameOverride + languageName: languageNameOverride, }: FilePermissionDialogProps): React.ReactNode { // Derive from path unless caller provided an explicit override (NotebookEdit // passes 'python'/'markdown' from cell_type). getLanguageName is async; // downstream UnaryEvent.language_name and logPermissionEvent already accept // Promise. useMemo keeps the promise stable across renders. - const languageName = useMemo(() => languageNameOverride ?? (path ? getLanguageName(path) : 'none'), [languageNameOverride, path]); - const unaryEvent = useMemo(() => ({ - completion_type: completionType, - language_name: languageName - }), [completionType, languageName]); - usePermissionRequestLogging(toolUseConfirm, unaryEvent); + const languageName = useMemo( + () => languageNameOverride ?? (path ? getLanguageName(path) : 'none'), + [languageNameOverride, path], + ) + const unaryEvent = useMemo( + () => ({ + completion_type: completionType, + language_name: languageName, + }), + [completionType, languageName], + ) + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + const symlinkTarget = useMemo(() => { if (!path || operationType === 'read') { - return null; + return null } - const expandedPath = expandPath(path); - const fs = getFsImplementation(); - const { - resolvedPath, - isSymlink - } = safeResolvePath(fs, expandedPath); + const expandedPath = expandPath(path) + const fs = getFsImplementation() + const { resolvedPath, isSymlink } = safeResolvePath(fs, expandedPath) if (isSymlink) { - return resolvedPath; + return resolvedPath } - return null; - }, [path, operationType]); + return null + }, [path, operationType]) + const fileDialogResult = useFilePermissionDialog({ filePath: path || '', completionType, @@ -95,8 +111,8 @@ export function FilePermissionDialog({ onDone, onReject, parseInput, - operationType - }); + operationType, + }) // Use file dialog results for options const { @@ -107,97 +123,150 @@ export function FilePermissionDialog({ handleInputModeToggle, focusedOption, yesInputMode, - noInputMode - } = fileDialogResult; + noInputMode, + } = fileDialogResult // Parse input using the provided parser - const parsedInput = parseInput(toolUseConfirm.input); + const parsedInput = parseInput(toolUseConfirm.input) // Set up IDE diff support if enabled. Memoized: getConfig may do disk I/O // (FileWrite's getConfig calls readFileSync for the old-content diff). // Keyed on the raw input — parseInput is a pure Zod parse whose result // depends only on toolUseConfirm.input. - const ideDiffConfig = useMemo(() => ideDiffSupport ? ideDiffSupport.getConfig(parseInput(toolUseConfirm.input)) : null, [ideDiffSupport, toolUseConfirm.input]); + const ideDiffConfig = useMemo( + () => + ideDiffSupport + ? ideDiffSupport.getConfig(parseInput(toolUseConfirm.input)) + : null, + [ideDiffSupport, toolUseConfirm.input], + ) // Create diff params based on whether IDE diff is available - const diffParams = ideDiffConfig ? { - onChange: (option: PermissionOption, input: { - file_path: string; - edits: Array<{ - old_string: string; - new_string: string; - replace_all?: boolean; - }>; - }) => { - const transformedInput = ideDiffSupport!.applyChanges(parsedInput, input.edits); - fileDialogResult.onChange(option, transformedInput); - }, - toolUseContext, - filePath: ideDiffConfig.filePath, - edits: (ideDiffConfig.edits || []).map(e => ({ - old_string: e.old_string, - new_string: e.new_string, - replace_all: e.replace_all || false - })), - editMode: ideDiffConfig.editMode || 'single' - } : { - onChange: () => {}, - toolUseContext, - filePath: '', - edits: [], - editMode: 'single' as const - }; - const { - closeTabInIDE, - showingDiffInIDE, - ideName - } = useDiffInIDE(diffParams); - const onChange = (option_0: PermissionOption, feedback?: string) => { - closeTabInIDE?.(); - fileDialogResult.onChange(option_0, parsedInput, feedback?.trim()); - }; + const diffParams = ideDiffConfig + ? { + onChange: ( + option: PermissionOption, + input: { + file_path: string + edits: Array<{ + old_string: string + new_string: string + replace_all?: boolean + }> + }, + ) => { + const transformedInput = ideDiffSupport!.applyChanges( + parsedInput, + input.edits, + ) + fileDialogResult.onChange(option, transformedInput) + }, + toolUseContext, + filePath: ideDiffConfig.filePath, + edits: (ideDiffConfig.edits || []).map(e => ({ + old_string: e.old_string, + new_string: e.new_string, + replace_all: e.replace_all || false, + })), + editMode: ideDiffConfig.editMode || 'single', + } + : { + onChange: () => {}, + toolUseContext, + filePath: '', + edits: [], + editMode: 'single' as const, + } + + const { closeTabInIDE, showingDiffInIDE, ideName } = useDiffInIDE(diffParams) + + const onChange = (option: PermissionOption, feedback?: string) => { + closeTabInIDE?.() + fileDialogResult.onChange(option, parsedInput, feedback?.trim()) + } + if (showingDiffInIDE && ideDiffConfig && path) { - return onChange(option_1, feedback_0)} options={options} filePath={path} input={parsedInput} ideName={ideName} symlinkTarget={symlinkTarget} rejectFeedback={rejectFeedback} acceptFeedback={acceptFeedback} setFocusedOption={setFocusedOption} onInputModeToggle={handleInputModeToggle} focusedOption={focusedOption} yesInputMode={yesInputMode} noInputMode={noInputMode} />; + return ( + + onChange(option, feedback) + } + options={options} + filePath={path} + input={parsedInput} + ideName={ideName} + symlinkTarget={symlinkTarget} + rejectFeedback={rejectFeedback} + acceptFeedback={acceptFeedback} + setFocusedOption={setFocusedOption} + onInputModeToggle={handleInputModeToggle} + focusedOption={focusedOption} + yesInputMode={yesInputMode} + noInputMode={noInputMode} + /> + ) } - const isSymlinkOutsideCwd = symlinkTarget != null && relative(getCwd(), symlinkTarget).startsWith('..'); - const symlinkWarning = symlinkTarget ? + + const isSymlinkOutsideCwd = + symlinkTarget != null && relative(getCwd(), symlinkTarget).startsWith('..') + + const symlinkWarning = symlinkTarget ? ( + - {isSymlinkOutsideCwd ? `This will modify ${symlinkTarget} (outside working directory) via a symlink` : `Symlink target: ${symlinkTarget}`} + {isSymlinkOutsideCwd + ? `This will modify ${symlinkTarget} (outside working directory) via a symlink` + : `Symlink target: ${symlinkTarget}`} - : null; - return <> - + + ) : null + + return ( + <> + {symlinkWarning} {content} {typeof question === 'string' ? {question} : question} - { + const selected = options.find(opt => opt.value === value) + if (selected) { + // For reject option + if (selected.option.type === 'reject') { + const trimmedFeedback = rejectFeedback.trim() + onChange(selected.option, trimmedFeedback || undefined) + return + } + // For accept-once option, pass accept feedback if present + if (selected.option.type === 'accept-once') { + const trimmedFeedback = acceptFeedback.trim() + onChange(selected.option, trimmedFeedback || undefined) + return + } + onChange(selected.option) + } + }} + onCancel={() => onChange({ type: 'reject' })} + onFocus={value => setFocusedOption(value)} + onInputModeToggle={handleInputModeToggle} + /> Esc to cancel - {(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'} + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} - ; + + ) } diff --git a/src/components/permissions/FilePermissionDialog/permissionOptions.tsx b/src/components/permissions/FilePermissionDialog/permissionOptions.tsx index e40d8df00..3a3507234 100644 --- a/src/components/permissions/FilePermissionDialog/permissionOptions.tsx +++ b/src/components/permissions/FilePermissionDialog/permissionOptions.tsx @@ -1,29 +1,37 @@ -import { homedir } from 'os'; -import { basename, join, sep } from 'path'; -import React, { type ReactNode } from 'react'; -import { getOriginalCwd } from '../../../bootstrap/state.js'; -import { Text } from '../../../ink.js'; -import { getShortcutDisplay } from '../../../keybindings/shortcutFormat.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { expandPath, getDirectoryForPath } from '../../../utils/path.js'; -import { normalizeCaseForComparison, pathInAllowedWorkingPath } from '../../../utils/permissions/filesystem.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; +import { homedir } from 'os' +import { basename, join, sep } from 'path' +import React, { type ReactNode } from 'react' +import { getOriginalCwd } from '../../../bootstrap/state.js' +import { Text } from '../../../ink.js' +import { getShortcutDisplay } from '../../../keybindings/shortcutFormat.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { expandPath, getDirectoryForPath } from '../../../utils/path.js' +import { + normalizeCaseForComparison, + pathInAllowedWorkingPath, +} from '../../../utils/permissions/filesystem.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' /** * Check if a path is within the project's .claude/ folder. * This is used to determine whether to show the special ".claude folder" permission option. */ export function isInClaudeFolder(filePath: string): boolean { - const absolutePath = expandPath(filePath); - const claudeFolderPath = expandPath(`${getOriginalCwd()}/.claude`); + const absolutePath = expandPath(filePath) + const claudeFolderPath = expandPath(`${getOriginalCwd()}/.claude`) // Check if the path is within the project's .claude folder - const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath); - const normalizedClaudeFolderPath = normalizeCaseForComparison(claudeFolderPath); + const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath) + const normalizedClaudeFolderPath = + normalizeCaseForComparison(claudeFolderPath) // Path must start with the .claude folder path (and be inside it, not just the folder itself) - return normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + sep.toLowerCase()) || - // Also match case where sep is / on posix systems - normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + '/'); + return ( + normalizedAbsolutePath.startsWith( + normalizedClaudeFolderPath + sep.toLowerCase(), + ) || + // Also match case where sep is / on posix systems + normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + '/') + ) } /** @@ -32,24 +40,33 @@ export function isInClaudeFolder(filePath: string): boolean { * for files in the user's home directory. */ export function isInGlobalClaudeFolder(filePath: string): boolean { - const absolutePath = expandPath(filePath); - const globalClaudeFolderPath = join(homedir(), '.claude'); - const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath); - const normalizedGlobalClaudeFolderPath = normalizeCaseForComparison(globalClaudeFolderPath); - return normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + sep.toLowerCase()) || normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + '/'); + const absolutePath = expandPath(filePath) + const globalClaudeFolderPath = join(homedir(), '.claude') + + const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath) + const normalizedGlobalClaudeFolderPath = normalizeCaseForComparison( + globalClaudeFolderPath, + ) + + return ( + normalizedAbsolutePath.startsWith( + normalizedGlobalClaudeFolderPath + sep.toLowerCase(), + ) || + normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + '/') + ) } -export type PermissionOption = { - type: 'accept-once'; -} | { - type: 'accept-session'; - scope?: 'claude-folder' | 'global-claude-folder'; -} | { - type: 'reject'; -}; + +export type PermissionOption = + | { type: 'accept-once' } + | { type: 'accept-session'; scope?: 'claude-folder' | 'global-claude-folder' } + | { type: 'reject' } + export type PermissionOptionWithLabel = OptionWithDescription & { - option: PermissionOption; -}; -export type FileOperationType = 'read' | 'write' | 'create'; + option: PermissionOption +} + +export type FileOperationType = 'read' | 'write' | 'create' + export function getFilePermissionOptions({ filePath, toolPermissionContext, @@ -57,18 +74,22 @@ export function getFilePermissionOptions({ onRejectFeedbackChange, onAcceptFeedbackChange, yesInputMode = false, - noInputMode = false + noInputMode = false, }: { - filePath: string; - toolPermissionContext: ToolPermissionContext; - operationType?: FileOperationType; - onRejectFeedbackChange?: (value: string) => void; - onAcceptFeedbackChange?: (value: string) => void; - yesInputMode?: boolean; - noInputMode?: boolean; + filePath: string + toolPermissionContext: ToolPermissionContext + operationType?: FileOperationType + onRejectFeedbackChange?: (value: string) => void + onAcceptFeedbackChange?: (value: string) => void + yesInputMode?: boolean + noInputMode?: boolean }): PermissionOptionWithLabel[] { - const options: PermissionOptionWithLabel[] = []; - const modeCycleShortcut = getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'); + const options: PermissionOptionWithLabel[] = [] + const modeCycleShortcut = getShortcutDisplay( + 'chat:cycleMode', + 'Chat', + 'shift+tab', + ) // When in input mode, show input field if (yesInputMode && onAcceptFeedbackChange) { @@ -79,24 +100,24 @@ export function getFilePermissionOptions({ placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, allowEmptySubmitToCancel: true, - option: { - type: 'accept-once' - } - }); + option: { type: 'accept-once' }, + }) } else { options.push({ label: 'Yes', value: 'yes', - option: { - type: 'accept-once' - } - }); + option: { type: 'accept-once' }, + }) } - const inAllowedPath = pathInAllowedWorkingPath(filePath, toolPermissionContext); + + const inAllowedPath = pathInAllowedWorkingPath( + filePath, + toolPermissionContext, + ) // Check if this is a .claude/ folder path (project or global) - const inClaudeFolder = isInClaudeFolder(filePath); - const inGlobalClaudeFolder = isInGlobalClaudeFolder(filePath); + const inClaudeFolder = isInClaudeFolder(filePath) + const inGlobalClaudeFolder = isInGlobalClaudeFolder(filePath) // Option 2: For .claude/ folder, show special option instead of generic session option // Note: Session-level options are always shown since they only affect in-memory state, @@ -108,45 +129,52 @@ export function getFilePermissionOptions({ value: 'yes-claude-folder', option: { type: 'accept-session', - scope: inGlobalClaudeFolder ? 'global-claude-folder' : 'claude-folder' - } - }); + scope: inGlobalClaudeFolder ? 'global-claude-folder' : 'claude-folder', + }, + }) } else { // Option 2: Allow all changes/reads during session - let sessionLabel: ReactNode; + let sessionLabel: ReactNode + if (inAllowedPath) { // Inside working directory if (operationType === 'read') { - sessionLabel = 'Yes, during this session'; + sessionLabel = 'Yes, during this session' } else { - sessionLabel = + sessionLabel = ( + Yes, allow all edits during this session{' '} ({modeCycleShortcut}) - ; + + ) } } else { // Outside working directory - include directory name - const dirPath = getDirectoryForPath(filePath); - const dirName = basename(dirPath) || 'this directory'; + const dirPath = getDirectoryForPath(filePath) + const dirName = basename(dirPath) || 'this directory' + if (operationType === 'read') { - sessionLabel = + sessionLabel = ( + Yes, allow reading from {dirName}/ during this session - ; + + ) } else { - sessionLabel = + sessionLabel = ( + Yes, allow all edits in {dirName}/ during this session ({modeCycleShortcut}) - ; + + ) } } + options.push({ label: sessionLabel, value: 'yes-session', - option: { - type: 'accept-session' - } - }); + option: { type: 'accept-session' }, + }) } // When in input mode, show input field for reject @@ -158,19 +186,16 @@ export function getFilePermissionOptions({ placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, allowEmptySubmitToCancel: true, - option: { - type: 'reject' - } - }); + option: { type: 'reject' }, + }) } else { // Not in input mode - simple option options.push({ label: 'No', value: 'no', - option: { - type: 'reject' - } - }); + option: { type: 'reject' }, + }) } - return options; + + return options } diff --git a/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx b/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx index eb7c6f8eb..ce352858d 100644 --- a/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx +++ b/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx @@ -1,160 +1,101 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename, relative } from 'path'; -import React, { useMemo } from 'react'; -import type { z } from 'zod/v4'; -import { Text } from '../../../ink.js'; -import { FileWriteTool } from '../../../tools/FileWriteTool/FileWriteTool.js'; -import { getCwd } from '../../../utils/cwd.js'; -import { isENOENT } from '../../../utils/errors.js'; -import { readFileSync } from '../../../utils/fileRead.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import { createSingleEditDiffConfig, type FileEdit, type IDEDiffSupport } from '../FilePermissionDialog/ideDiffConfig.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { FileWriteToolDiff } from './FileWriteToolDiff.js'; -type FileWriteToolInput = z.infer; +import { basename, relative } from 'path' +import React, { useMemo } from 'react' +import type { z } from 'zod/v4' +import { Text } from '../../../ink.js' +import { FileWriteTool } from '../../../tools/FileWriteTool/FileWriteTool.js' +import { getCwd } from '../../../utils/cwd.js' +import { isENOENT } from '../../../utils/errors.js' +import { readFileSync } from '../../../utils/fileRead.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import { + createSingleEditDiffConfig, + type FileEdit, + type IDEDiffSupport, +} from '../FilePermissionDialog/ideDiffConfig.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { FileWriteToolDiff } from './FileWriteToolDiff.js' + +type FileWriteToolInput = z.infer + const ideDiffSupport: IDEDiffSupport = { getConfig: (input: FileWriteToolInput) => { - let oldContent: string; + let oldContent: string try { - oldContent = readFileSync(input.file_path); + oldContent = readFileSync(input.file_path) } catch (e) { - if (!isENOENT(e)) throw e; - oldContent = ''; + if (!isENOENT(e)) throw e + oldContent = '' } - return createSingleEditDiffConfig(input.file_path, oldContent, input.content, false // For file writes, we replace the entire content - ); + + return createSingleEditDiffConfig( + input.file_path, + oldContent, + input.content, + false, // For file writes, we replace the entire content + ) }, applyChanges: (input: FileWriteToolInput, modifiedEdits: FileEdit[]) => { - const firstEdit = modifiedEdits[0]; + const firstEdit = modifiedEdits[0] if (firstEdit) { return { ...input, - content: firstEdit.new_string - }; + content: firstEdit.new_string, + } } - return input; - } -}; -export function FileWritePermissionRequest(props) { - const $ = _c(30); - const parseInput = _temp; - let t0; - if ($[0] !== props.toolUseConfirm.input) { - t0 = parseInput(props.toolUseConfirm.input); - $[0] = props.toolUseConfirm.input; - $[1] = t0; - } else { - t0 = $[1]; + return input + }, +} + +export function FileWritePermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const parseInput = (input: unknown): FileWriteToolInput => { + return FileWriteTool.inputSchema.parse(input) } - const parsed = t0; - const { - file_path, - content - } = parsed; - let t1; - if ($[2] !== file_path) { - ; + + const parsed = parseInput(props.toolUseConfirm.input) + const { file_path, content } = parsed + + // Single read drives both UI text ("Create" vs "Overwrite") and the diff + // shown by FileWriteToolDiff — avoids a redundant existsSync stat that would + // block first-mount commit on slow/networked filesystems. + const { fileExists, oldContent } = useMemo(() => { try { - t1 = { - fileExists: true, - oldContent: readFileSync(file_path) - }; - } catch (t2) { - const e = t2; - if (!isENOENT(e)) { - throw e; + return { fileExists: true, oldContent: readFileSync(file_path) } + } catch (e) { + if (!isENOENT(e)) throw e + return { fileExists: false, oldContent: '' } + } + }, [file_path]) + + const actionText = fileExists ? 'overwrite' : 'create' + + return ( + + Do you want to {actionText} {basename(file_path)}? + } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - fileExists: false, - oldContent: "" - }; - $[4] = t3; - } else { - t3 = $[4]; + content={ + } - t1 = t3; - } - $[2] = file_path; - $[3] = t1; - } else { - t1 = $[3]; - } - const { - fileExists, - oldContent - } = t1; - const actionText = fileExists ? "overwrite" : "create"; - const t2 = props.toolUseConfirm; - const t3 = props.toolUseContext; - const t4 = props.onDone; - const t5 = props.onReject; - const t6 = props.workerBadge; - const t7 = fileExists ? "Overwrite file" : "Create file"; - let t8; - if ($[5] !== file_path) { - t8 = relative(getCwd(), file_path); - $[5] = file_path; - $[6] = t8; - } else { - t8 = $[6]; - } - let t9; - if ($[7] !== file_path) { - t9 = basename(file_path); - $[7] = file_path; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] !== t9) { - t10 = {t9}; - $[9] = t9; - $[10] = t10; - } else { - t10 = $[10]; - } - let t11; - if ($[11] !== actionText || $[12] !== t10) { - t11 = Do you want to {actionText} {t10}?; - $[11] = actionText; - $[12] = t10; - $[13] = t11; - } else { - t11 = $[13]; - } - let t12; - if ($[14] !== content || $[15] !== fileExists || $[16] !== file_path || $[17] !== oldContent) { - t12 = ; - $[14] = content; - $[15] = fileExists; - $[16] = file_path; - $[17] = oldContent; - $[18] = t12; - } else { - t12 = $[18]; - } - let t13; - if ($[19] !== file_path || $[20] !== props.onDone || $[21] !== props.onReject || $[22] !== props.toolUseConfirm || $[23] !== props.toolUseContext || $[24] !== props.workerBadge || $[25] !== t11 || $[26] !== t12 || $[27] !== t7 || $[28] !== t8) { - t13 = ; - $[19] = file_path; - $[20] = props.onDone; - $[21] = props.onReject; - $[22] = props.toolUseConfirm; - $[23] = props.toolUseContext; - $[24] = props.workerBadge; - $[25] = t11; - $[26] = t12; - $[27] = t7; - $[28] = t8; - $[29] = t13; - } else { - t13 = $[29]; - } - return t13; -} -function _temp(input) { - return FileWriteTool.inputSchema.parse(input); + path={file_path} + completionType="write_file_single" + parseInput={parseInput} + ideDiffSupport={ideDiffSupport} + /> + ) } diff --git a/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx index c9fa7e83f..36147ef03 100644 --- a/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx +++ b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx @@ -1,88 +1,82 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useMemo } from 'react'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { Box, NoSelect, Text } from '../../../ink.js'; -import { intersperse } from '../../../utils/array.js'; -import { getPatchForDisplay } from '../../../utils/diff.js'; -import { HighlightedCode } from '../../HighlightedCode.js'; -import { StructuredDiff } from '../../StructuredDiff.js'; +import * as React from 'react' +import { useMemo } from 'react' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { Box, NoSelect, Text } from '../../../ink.js' +import { intersperse } from '../../../utils/array.js' +import { getPatchForDisplay } from '../../../utils/diff.js' +import { HighlightedCode } from '../../HighlightedCode.js' +import { StructuredDiff } from '../../StructuredDiff.js' + type Props = { - file_path: string; - content: string; - fileExists: boolean; - oldContent: string; -}; -export function FileWriteToolDiff(t0) { - const $ = _c(15); - const { - file_path, - content, - fileExists, - oldContent - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - bb0: { + file_path: string + content: string + fileExists: boolean + oldContent: string +} + +export function FileWriteToolDiff({ + file_path, + content, + fileExists, + oldContent, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const hunks = useMemo(() => { if (!fileExists) { - t1 = null; - break bb0; + return null } - let t2; - if ($[0] !== content || $[1] !== file_path || $[2] !== oldContent) { - t2 = getPatchForDisplay({ - filePath: file_path, - fileContents: oldContent, - edits: [{ + return getPatchForDisplay({ + filePath: file_path, + fileContents: oldContent, + edits: [ + { old_string: oldContent, new_string: content, - replace_all: false - }] - }); - $[0] = content; - $[1] = file_path; - $[2] = oldContent; - $[3] = t2; - } else { - t2 = $[3]; - } - t1 = t2; - } - const hunks = t1; - let t2; - if ($[4] !== content) { - t2 = content.split("\n")[0] ?? null; - $[4] = content; - $[5] = t2; - } else { - t2 = $[5]; - } - const firstLine = t2; - let t3; - if ($[6] !== columns || $[7] !== content || $[8] !== file_path || $[9] !== firstLine || $[10] !== hunks || $[11] !== oldContent) { - t3 = hunks ? intersperse(hunks.map(_ => ), _temp) : ; - $[6] = columns; - $[7] = content; - $[8] = file_path; - $[9] = firstLine; - $[10] = hunks; - $[11] = oldContent; - $[12] = t3; - } else { - t3 = $[12]; - } - let t4; - if ($[13] !== t3) { - t4 = {t3}; - $[13] = t3; - $[14] = t4; - } else { - t4 = $[14]; - } - return t4; -} -function _temp(i) { - return ...; + replace_all: false, + }, + ], + }) + }, [fileExists, file_path, oldContent, content]) + + const firstLine = content.split('\n')[0] ?? null + const paddingX = 1 + + return ( + + + {hunks ? ( + intersperse( + hunks.map(_ => ( + + )), + i => ( + + ... + + ), + ) + ) : ( + + )} + + + ) } diff --git a/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx index 8f5982b5a..ebfdc8817 100644 --- a/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx +++ b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx @@ -1,114 +1,89 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { FallbackPermissionRequest } from '../FallbackPermissionRequest.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import type { ToolInput } from '../FilePermissionDialog/useFilePermissionDialog.js'; -import type { PermissionRequestProps, ToolUseConfirm } from '../PermissionRequest.js'; +import React from 'react' +import { Box, Text, useTheme } from '../../../ink.js' +import { FallbackPermissionRequest } from '../FallbackPermissionRequest.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import type { ToolInput } from '../FilePermissionDialog/useFilePermissionDialog.js' +import type { + PermissionRequestProps, + ToolUseConfirm, +} from '../PermissionRequest.js' + function pathFromToolUse(toolUseConfirm: ToolUseConfirm): string | null { - const tool = toolUseConfirm.tool; + const tool = toolUseConfirm.tool if ('getPath' in tool && typeof tool.getPath === 'function') { try { - return tool.getPath(toolUseConfirm.input); + return tool.getPath(toolUseConfirm.input) } catch { - return null; + return null } } - return null; + return null } -export function FilesystemPermissionRequest(t0) { - const $ = _c(30); - const { - toolUseConfirm, - onDone, - onReject, - verbose, - toolUseContext, - workerBadge - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] !== toolUseConfirm) { - t1 = pathFromToolUse(toolUseConfirm); - $[0] = toolUseConfirm; - $[1] = t1; - } else { - t1 = $[1]; - } - const path = t1; - let t2; - if ($[2] !== toolUseConfirm.input || $[3] !== toolUseConfirm.tool) { - t2 = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); - $[2] = toolUseConfirm.input; - $[3] = toolUseConfirm.tool; - $[4] = t2; - } else { - t2 = $[4]; - } - const userFacingName = t2; - const isReadOnly = toolUseConfirm.tool.isReadOnly(toolUseConfirm.input); - const userFacingReadOrEdit = isReadOnly ? "Read" : "Edit"; - const title = `${userFacingReadOrEdit} file`; - const parseInput = _temp; + +export function FilesystemPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + verbose, + toolUseContext, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [theme] = useTheme() + const path = pathFromToolUse(toolUseConfirm) + const userFacingName = toolUseConfirm.tool.userFacingName( + toolUseConfirm.input as never, + ) + + const isReadOnly = toolUseConfirm.tool.isReadOnly(toolUseConfirm.input) + const userFacingReadOrEdit = isReadOnly ? 'Read' : 'Edit' + + // Use simple singular form - the actual operation details are shown in content + const title = `${userFacingReadOrEdit} file` + + // Simple pass-through parser since we don't need to transform the input + const parseInput = (input: unknown): ToolInput => input as ToolInput + + // Fall back to generic permission request if no path is found if (!path) { - let t3; - if ($[5] !== onDone || $[6] !== onReject || $[7] !== toolUseConfirm || $[8] !== toolUseContext || $[9] !== verbose || $[10] !== workerBadge) { - t3 = ; - $[5] = onDone; - $[6] = onReject; - $[7] = toolUseConfirm; - $[8] = toolUseContext; - $[9] = verbose; - $[10] = workerBadge; - $[11] = t3; - } else { - t3 = $[11]; - } - return t3; - } - let t3; - if ($[12] !== theme || $[13] !== toolUseConfirm.input || $[14] !== toolUseConfirm.tool || $[15] !== verbose) { - t3 = toolUseConfirm.tool.renderToolUseMessage(toolUseConfirm.input as never, { - theme, - verbose - }); - $[12] = theme; - $[13] = toolUseConfirm.input; - $[14] = toolUseConfirm.tool; - $[15] = verbose; - $[16] = t3; - } else { - t3 = $[16]; + return ( + + ) } - let t4; - if ($[17] !== t3 || $[18] !== userFacingName) { - t4 = {userFacingName}({t3}); - $[17] = t3; - $[18] = userFacingName; - $[19] = t4; - } else { - t4 = $[19]; - } - const content = t4; - const t5 = isReadOnly ? "read" : "write"; - let t6; - if ($[20] !== content || $[21] !== onDone || $[22] !== onReject || $[23] !== path || $[24] !== t5 || $[25] !== title || $[26] !== toolUseConfirm || $[27] !== toolUseContext || $[28] !== workerBadge) { - t6 = ; - $[20] = content; - $[21] = onDone; - $[22] = onReject; - $[23] = path; - $[24] = t5; - $[25] = title; - $[26] = toolUseConfirm; - $[27] = toolUseContext; - $[28] = workerBadge; - $[29] = t6; - } else { - t6 = $[29]; - } - return t6; -} -function _temp(input) { - return input as ToolInput; + + // Render tool use message content + const content = ( + + + {userFacingName}( + {toolUseConfirm.tool.renderToolUseMessage( + toolUseConfirm.input as never, + { theme, verbose }, + )} + ) + + + ) + + return ( + + ) } diff --git a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx index 6b2134cd4..6c03b94d3 100644 --- a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx +++ b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx @@ -1,165 +1,77 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename } from 'path'; -import React from 'react'; -import type { z } from 'zod/v4'; -import { Text } from '../../../ink.js'; -import { NotebookEditTool } from '../../../tools/NotebookEditTool/NotebookEditTool.js'; -import { logError } from '../../../utils/log.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { NotebookEditToolDiff } from './NotebookEditToolDiff.js'; -type NotebookEditInput = z.infer; -export function NotebookEditPermissionRequest(props) { - const $ = _c(52); - const parseInput = _temp; - let T0; - let T1; - let T2; - let language; - let notebook_path; - let parsed; - let t0; - let t1; - let t10; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[0] !== props.onDone || $[1] !== props.onReject || $[2] !== props.toolUseConfirm || $[3] !== props.toolUseContext || $[4] !== props.workerBadge) { - parsed = parseInput(props.toolUseConfirm.input); - const { - notebook_path: t11, - edit_mode, - cell_type - } = parsed; - notebook_path = t11; - language = cell_type === "markdown" ? "markdown" : "python"; - const editTypeText = edit_mode === "insert" ? "insert this cell into" : edit_mode === "delete" ? "delete this cell from" : "make this edit to"; - T2 = FilePermissionDialog; - t5 = props.toolUseConfirm; - t6 = props.toolUseContext; - t7 = props.onDone; - t8 = props.onReject; - t9 = props.workerBadge; - t10 = "Edit notebook"; - T1 = Text; - t2 = "Do you want to "; - t3 = editTypeText; - t4 = " "; - T0 = Text; - t0 = true; - t1 = basename(notebook_path); - $[0] = props.onDone; - $[1] = props.onReject; - $[2] = props.toolUseConfirm; - $[3] = props.toolUseContext; - $[4] = props.workerBadge; - $[5] = T0; - $[6] = T1; - $[7] = T2; - $[8] = language; - $[9] = notebook_path; - $[10] = parsed; - $[11] = t0; - $[12] = t1; - $[13] = t10; - $[14] = t2; - $[15] = t3; - $[16] = t4; - $[17] = t5; - $[18] = t6; - $[19] = t7; - $[20] = t8; - $[21] = t9; - } else { - T0 = $[5]; - T1 = $[6]; - T2 = $[7]; - language = $[8]; - notebook_path = $[9]; - parsed = $[10]; - t0 = $[11]; - t1 = $[12]; - t10 = $[13]; - t2 = $[14]; - t3 = $[15]; - t4 = $[16]; - t5 = $[17]; - t6 = $[18]; - t7 = $[19]; - t8 = $[20]; - t9 = $[21]; +import { basename } from 'path' +import React from 'react' +import type { z } from 'zod/v4' +import { Text } from '../../../ink.js' +import { NotebookEditTool } from '../../../tools/NotebookEditTool/NotebookEditTool.js' +import { logError } from '../../../utils/log.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { NotebookEditToolDiff } from './NotebookEditToolDiff.js' + +type NotebookEditInput = z.infer + +export function NotebookEditPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const parseInput = (input: unknown): NotebookEditInput => { + const result = NotebookEditTool.inputSchema.safeParse(input) + if (!result.success) { + logError( + new Error( + `Failed to parse notebook edit input: ${result.error.message}`, + ), + ) + // Return a default value to avoid crashing + return { + notebook_path: '', + new_source: '', + cell_id: '', + } as NotebookEditInput + } + return result.data } - let t11; - if ($[22] !== T0 || $[23] !== t0 || $[24] !== t1) { - t11 = {t1}; - $[22] = T0; - $[23] = t0; - $[24] = t1; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== T1 || $[27] !== t11 || $[28] !== t2 || $[29] !== t3 || $[30] !== t4) { - t12 = {t2}{t3}{t4}{t11}?; - $[26] = T1; - $[27] = t11; - $[28] = t2; - $[29] = t3; - $[30] = t4; - $[31] = t12; - } else { - t12 = $[31]; - } - const t13 = props.verbose ? 120 : 80; - let t14; - if ($[32] !== parsed.cell_id || $[33] !== parsed.cell_type || $[34] !== parsed.edit_mode || $[35] !== parsed.new_source || $[36] !== parsed.notebook_path || $[37] !== props.verbose || $[38] !== t13) { - t14 = ; - $[32] = parsed.cell_id; - $[33] = parsed.cell_type; - $[34] = parsed.edit_mode; - $[35] = parsed.new_source; - $[36] = parsed.notebook_path; - $[37] = props.verbose; - $[38] = t13; - $[39] = t14; - } else { - t14 = $[39]; - } - let t15; - if ($[40] !== T2 || $[41] !== language || $[42] !== notebook_path || $[43] !== t10 || $[44] !== t12 || $[45] !== t14 || $[46] !== t5 || $[47] !== t6 || $[48] !== t7 || $[49] !== t8 || $[50] !== t9) { - t15 = ; - $[40] = T2; - $[41] = language; - $[42] = notebook_path; - $[43] = t10; - $[44] = t12; - $[45] = t14; - $[46] = t5; - $[47] = t6; - $[48] = t7; - $[49] = t8; - $[50] = t9; - $[51] = t15; - } else { - t15 = $[51]; - } - return t15; -} -function _temp(input) { - const result = NotebookEditTool.inputSchema.safeParse(input); - if (!result.success) { - logError(new Error(`Failed to parse notebook edit input: ${result.error.message}`)); - return { - notebook_path: "", - new_source: "", - cell_id: "" - } as NotebookEditInput; - } - return result.data; + + const parsed = parseInput(props.toolUseConfirm.input) + const { notebook_path, edit_mode, cell_type } = parsed + + const language = cell_type === 'markdown' ? 'markdown' : 'python' + + const editTypeText = + edit_mode === 'insert' + ? 'insert this cell into' + : edit_mode === 'delete' + ? 'delete this cell from' + : 'make this edit to' + + return ( + + Do you want to {editTypeText}{' '} + {basename(notebook_path)}? + + } + content={ + + } + path={notebook_path} + completionType="tool_use_single" + languageName={language} + parseInput={parseInput} + /> + ) } diff --git a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx index 13e073aed..9b5373142 100644 --- a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx +++ b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx @@ -1,234 +1,172 @@ -import { c as _c } from "react/compiler-runtime"; -import { relative } from 'path'; -import * as React from 'react'; -import { Suspense, use, useMemo } from 'react'; -import { Box, NoSelect, Text } from '../../../ink.js'; -import type { NotebookCellType, NotebookContent } from '../../../types/notebook.js'; -import { intersperse } from '../../../utils/array.js'; -import { getCwd } from '../../../utils/cwd.js'; -import { getPatchForDisplay } from '../../../utils/diff.js'; -import { getFsImplementation } from '../../../utils/fsOperations.js'; -import { safeParseJSON } from '../../../utils/json.js'; -import { parseCellId } from '../../../utils/notebook.js'; -import { HighlightedCode } from '../../HighlightedCode.js'; -import { StructuredDiff } from '../../StructuredDiff.js'; +import { relative } from 'path' +import * as React from 'react' +import { Suspense, use, useMemo } from 'react' +import { Box, NoSelect, Text } from '../../../ink.js' +import type { + NotebookCellType, + NotebookContent, +} from '../../../types/notebook.js' +import { intersperse } from '../../../utils/array.js' +import { getCwd } from '../../../utils/cwd.js' +import { getPatchForDisplay } from '../../../utils/diff.js' +import { getFsImplementation } from '../../../utils/fsOperations.js' +import { safeParseJSON } from '../../../utils/json.js' +import { parseCellId } from '../../../utils/notebook.js' +import { HighlightedCode } from '../../HighlightedCode.js' +import { StructuredDiff } from '../../StructuredDiff.js' + type Props = { - notebook_path: string; - cell_id: string | undefined; - new_source: string; - cell_type?: NotebookCellType; - edit_mode?: string; - verbose: boolean; - width: number; -}; -type InnerProps = { - notebook_path: string; - cell_id: string | undefined; - new_source: string; - cell_type?: NotebookCellType; - edit_mode?: string; - verbose: boolean; - width: number; - promise: Promise; -}; -export function NotebookEditToolDiff(props: Props) { - const $ = _c(5); - let t0; - if ($[0] !== props.notebook_path) { - t0 = getFsImplementation().readFile(props.notebook_path, { - encoding: "utf-8" - }).then(_temp).catch(_temp2); - $[0] = props.notebook_path; - $[1] = t0; - } else { - t0 = $[1]; - } - const notebookDataPromise = t0; - let t1; - if ($[2] !== notebookDataPromise || $[3] !== props) { - t1 = ; - $[2] = notebookDataPromise; - $[3] = props; - $[4] = t1; - } else { - t1 = $[4]; - } - return t1; + notebook_path: string + cell_id: string | undefined + new_source: string + cell_type?: NotebookCellType + edit_mode?: string + verbose: boolean + width: number } -function _temp2() { - return null; + +type InnerProps = { + notebook_path: string + cell_id: string | undefined + new_source: string + cell_type?: NotebookCellType + edit_mode?: string + verbose: boolean + width: number + promise: Promise } -function _temp(content) { - return safeParseJSON(content) as NotebookContent | null; + +export function NotebookEditToolDiff(props: Props): React.ReactNode { + // Create a promise that never rejects so we can handle errors inline. + // Memoized on notebook_path so we don't re-read on every render. + const notebookDataPromise = useMemo( + () => + getFsImplementation() + .readFile(props.notebook_path, { encoding: 'utf-8' }) + .then(content => safeParseJSON(content) as NotebookContent | null) + .catch(() => null), + [props.notebook_path], + ) + + return ( + + + + ) } -function NotebookEditToolDiffInner(t0: InnerProps) { - const $ = _c(34); - const { - notebook_path, - cell_id, - new_source, - cell_type, - edit_mode: t1, - verbose, - width, - promise - } = t0; - const edit_mode = t1 === undefined ? "replace" : t1; - const notebookData = use(promise); - let t2; - if ($[0] !== cell_id || $[1] !== notebookData) { - bb0: { - if (!notebookData || !cell_id) { - t2 = ""; - break bb0; - } - const cellIndex = parseCellId(cell_id); - if (cellIndex !== undefined) { - if (notebookData.cells[cellIndex]) { - const source = notebookData.cells[cellIndex].source; - let t3; - if ($[3] !== source) { - t3 = Array.isArray(source) ? source.join("") : source; - $[3] = source; - $[4] = t3; - } else { - t3 = $[4]; - } - t2 = t3; - break bb0; - } - t2 = ""; - break bb0; - } - let t3; - if ($[5] !== cell_id) { - t3 = cell => cell.id === cell_id; - $[5] = cell_id; - $[6] = t3; - } else { - t3 = $[6]; - } - const cell_0 = notebookData.cells.find(t3); - if (!cell_0) { - t2 = ""; - break bb0; + +function NotebookEditToolDiffInner({ + notebook_path, + cell_id, + new_source, + cell_type, + edit_mode = 'replace', + verbose, + width, + promise, +}: InnerProps): React.ReactNode { + const notebookData = use(promise) + + const oldSource = useMemo(() => { + if (!notebookData || !cell_id) { + return '' + } + const cellIndex = parseCellId(cell_id) + if (cellIndex !== undefined) { + if (notebookData.cells[cellIndex]) { + const source = notebookData.cells[cellIndex].source + return Array.isArray(source) ? source.join('') : source } - t2 = Array.isArray(cell_0.source) ? cell_0.source.join("") : cell_0.source; + return '' } - $[0] = cell_id; - $[1] = notebookData; - $[2] = t2; - } else { - t2 = $[2]; - } - const oldSource = t2; - let t3; - bb1: { - if (!notebookData || edit_mode === "insert" || edit_mode === "delete") { - t3 = null; - break bb1; + const cell = notebookData.cells.find(cell => cell.id === cell_id) + if (!cell) { + return '' } - let t4; - if ($[7] !== new_source || $[8] !== notebook_path || $[9] !== oldSource) { - t4 = getPatchForDisplay({ - filePath: notebook_path, - fileContents: oldSource, - edits: [{ + return Array.isArray(cell.source) ? cell.source.join('') : cell.source + }, [notebookData, cell_id]) + + const hunks = useMemo(() => { + if (!notebookData || edit_mode === 'insert' || edit_mode === 'delete') { + return null + } + // Create a "fake" file content with just the cell source + // This allows us to use the regular diff mechanism + return getPatchForDisplay({ + filePath: notebook_path, + fileContents: oldSource, + edits: [ + { old_string: oldSource, new_string: new_source, - replace_all: false - }], - ignoreWhitespace: false - }); - $[7] = new_source; - $[8] = notebook_path; - $[9] = oldSource; - $[10] = t4; - } else { - t4 = $[10]; - } - t3 = t4; - } - const hunks = t3; - let editTypeDescription; - bb2: switch (edit_mode) { - case "insert": - { - editTypeDescription = "Insert new cell"; - break bb2; - } - case "delete": - { - editTypeDescription = "Delete cell"; - break bb2; - } + replace_all: false, + }, + ], + ignoreWhitespace: false, + }) + }, [notebookData, notebook_path, oldSource, new_source, edit_mode]) + + let editTypeDescription: string + switch (edit_mode) { + case 'insert': + editTypeDescription = 'Insert new cell' + break + case 'delete': + editTypeDescription = 'Delete cell' + break default: - { - editTypeDescription = "Replace cell contents"; - } - } - let t4; - if ($[11] !== notebook_path || $[12] !== verbose) { - t4 = verbose ? notebook_path : relative(getCwd(), notebook_path); - $[11] = notebook_path; - $[12] = verbose; - $[13] = t4; - } else { - t4 = $[13]; - } - let t5; - if ($[14] !== t4) { - t5 = {t4}; - $[14] = t4; - $[15] = t5; - } else { - t5 = $[15]; + editTypeDescription = 'Replace cell contents' } - const t6 = cell_type ? ` (${cell_type})` : ""; - let t7; - if ($[16] !== cell_id || $[17] !== editTypeDescription || $[18] !== t6) { - t7 = {editTypeDescription} for cell {cell_id}{t6}; - $[16] = cell_id; - $[17] = editTypeDescription; - $[18] = t6; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] !== t5 || $[21] !== t7) { - t8 = {t5}{t7}; - $[20] = t5; - $[21] = t7; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== cell_type || $[24] !== edit_mode || $[25] !== hunks || $[26] !== new_source || $[27] !== notebook_path || $[28] !== oldSource || $[29] !== width) { - t9 = edit_mode === "delete" ? : edit_mode === "insert" ? : hunks ? intersperse(hunks.map(_ => ), _temp3) : ; - $[23] = cell_type; - $[24] = edit_mode; - $[25] = hunks; - $[26] = new_source; - $[27] = notebook_path; - $[28] = oldSource; - $[29] = width; - $[30] = t9; - } else { - t9 = $[30]; - } - let t10; - if ($[31] !== t8 || $[32] !== t9) { - t10 = {t8}{t9}; - $[31] = t8; - $[32] = t9; - $[33] = t10; - } else { - t10 = $[33]; - } - return t10; -} -function _temp3(i) { - return ...; + + return ( + + + + + {verbose ? notebook_path : relative(getCwd(), notebook_path)} + + + {editTypeDescription} for cell {cell_id} + {cell_type ? ` (${cell_type})` : ''} + + + {edit_mode === 'delete' ? ( + + + + ) : edit_mode === 'insert' ? ( + + + + ) : hunks ? ( + intersperse( + hunks.map(_ => ( + + )), + i => ( + + ... + + ), + ) + ) : ( + + )} + + + ) } diff --git a/src/components/permissions/PermissionDecisionDebugInfo.tsx b/src/components/permissions/PermissionDecisionDebugInfo.tsx index d877faa33..afd855343 100644 --- a/src/components/permissions/PermissionDecisionDebugInfo.tsx +++ b/src/components/permissions/PermissionDecisionDebugInfo.tsx @@ -1,459 +1,350 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import figures from 'figures'; -import React, { useMemo } from 'react'; -import { Ansi, Box, color, Text, useTheme } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'; -import { permissionModeTitle } from '../../utils/permissions/PermissionMode.js'; -import type { PermissionDecision, PermissionDecisionReason } from '../../utils/permissions/PermissionResult.js'; -import { extractRules } from '../../utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; -import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js'; -import { detectUnreachableRules } from '../../utils/permissions/shadowedRuleDetection.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { getSettingSourceDisplayNameLowercase } from '../../utils/settings/constants.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import figures from 'figures' +import React, { useMemo } from 'react' +import { Ansi, Box, color, Text, useTheme } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' +import { permissionModeTitle } from '../../utils/permissions/PermissionMode.js' +import type { + PermissionDecision, + PermissionDecisionReason, +} from '../../utils/permissions/PermissionResult.js' +import { extractRules } from '../../utils/permissions/PermissionUpdate.js' +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' +import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' +import { detectUnreachableRules } from '../../utils/permissions/shadowedRuleDetection.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { getSettingSourceDisplayNameLowercase } from '../../utils/settings/constants.js' + type PermissionDecisionInfoItemProps = { - title?: string; - decisionReason: PermissionDecisionReason; -}; -function decisionReasonDisplayString(decisionReason: PermissionDecisionReason & { - type: Exclude; -}): string { - if ((feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && decisionReason.type === 'classifier') { - return `${chalk.bold(decisionReason.classifier)} classifier: ${decisionReason.reason}`; + title?: string + decisionReason: PermissionDecisionReason +} + +function decisionReasonDisplayString( + decisionReason: PermissionDecisionReason & { + type: Exclude + }, +): string { + if ( + (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && + decisionReason.type === 'classifier' + ) { + return `${chalk.bold(decisionReason.classifier)} classifier: ${decisionReason.reason}` } switch (decisionReason.type) { case 'rule': - return `${chalk.bold(permissionRuleValueToString(decisionReason.rule.ruleValue))} rule from ${getSettingSourceDisplayNameLowercase(decisionReason.rule.source)}`; + return `${chalk.bold(permissionRuleValueToString(decisionReason.rule.ruleValue))} rule from ${getSettingSourceDisplayNameLowercase(decisionReason.rule.source)}` case 'mode': - return `${permissionModeTitle(decisionReason.mode)} mode`; + return `${permissionModeTitle(decisionReason.mode)} mode` case 'sandboxOverride': - return 'Requires permission to bypass sandbox'; + return 'Requires permission to bypass sandbox' case 'workingDir': - return decisionReason.reason; + return decisionReason.reason case 'safetyCheck': case 'other': - return decisionReason.reason; + return decisionReason.reason case 'permissionPromptTool': - return `${chalk.bold(decisionReason.permissionPromptToolName)} permission prompt tool`; + return `${chalk.bold(decisionReason.permissionPromptToolName)} permission prompt tool` case 'hook': - return decisionReason.reason ? `${chalk.bold(decisionReason.hookName)} hook: ${decisionReason.reason}` : `${chalk.bold(decisionReason.hookName)} hook`; + return decisionReason.reason + ? `${chalk.bold(decisionReason.hookName)} hook: ${decisionReason.reason}` + : `${chalk.bold(decisionReason.hookName)} hook` case 'asyncAgent': - return decisionReason.reason; + return decisionReason.reason default: - return ''; - } -} -function PermissionDecisionInfoItem(t0) { - const $ = _c(10); - const { - title, - decisionReason - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] !== decisionReason || $[1] !== theme) { - t1 = function formatDecisionReason() { - switch (decisionReason.type) { - case "subcommandResults": - { - return {Array.from(decisionReason.reasons.entries()).map(t2 => { - const [subcommand, result] = t2 as [string, { behavior: string; decisionReason?: { type: string }; suggestions?: unknown }]; - const icon = result.behavior === "allow" ? color("success", theme)(figures.tick) : color("error", theme)(figures.cross); - return {icon} {subcommand}{result.decisionReason !== undefined && result.decisionReason.type !== "subcommandResults" && {" "}⎿{" "}{decisionReasonDisplayString(result.decisionReason as any)}}{result.behavior === "ask" && }; - })}; - } - default: - { - return {decisionReasonDisplayString(decisionReason)}; - } - } - }; - $[0] = decisionReason; - $[1] = theme; - $[2] = t1; - } else { - t1 = $[2]; - } - const formatDecisionReason = t1; - let t2; - if ($[3] !== title) { - t2 = title && {title}; - $[3] = title; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== formatDecisionReason) { - t3 = formatDecisionReason(); - $[5] = formatDecisionReason; - $[6] = t3; - } else { - t3 = $[6]; + return '' } - let t4; - if ($[7] !== t2 || $[8] !== t3) { - t4 = {t2}{t3}; - $[7] = t2; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - return t4; } -function SuggestedRules(t0) { - const $ = _c(18); - const { - suggestions - } = t0; - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - let t5; - if ($[0] !== suggestions) { - t5 = Symbol.for("react.early_return_sentinel"); - bb0: { - const rules = extractRules(suggestions); - if (rules.length === 0) { - t5 = null; - break bb0; - } - T1 = Text; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {" "}⎿{" "}; - $[8] = t2; - } else { - t2 = $[8]; - } - t3 = "Suggested rules:"; - t4 = " "; - T0 = Ansi; - t1 = rules.map(_temp).join(", "); + +function PermissionDecisionInfoItem({ + title, + decisionReason, +}: PermissionDecisionInfoItemProps): React.ReactNode { + const [theme] = useTheme() + + function formatDecisionReason(): React.ReactNode { + switch (decisionReason.type) { + case 'subcommandResults': + return ( + + {Array.from(decisionReason.reasons.entries()).map( + ([subcommand, result]) => { + const icon = + result.behavior === 'allow' + ? color('success', theme)(figures.tick) + : color('error', theme)(figures.cross) + return ( + + + {icon} {subcommand} + + {result.decisionReason !== undefined && + result.decisionReason.type !== 'subcommandResults' && ( + + + {' '}⎿{' '} + + + {decisionReasonDisplayString(result.decisionReason)} + + + )} + {result.behavior === 'ask' && ( + + )} + + ) + }, + )} + + ) + default: + return ( + + {decisionReasonDisplayString(decisionReason)} + + ) } - $[0] = suggestions; - $[1] = T0; - $[2] = T1; - $[3] = t1; - $[4] = t2; - $[5] = t3; - $[6] = t4; - $[7] = t5; - } else { - T0 = $[1]; - T1 = $[2]; - t1 = $[3]; - t2 = $[4]; - t3 = $[5]; - t4 = $[6]; - t5 = $[7]; - } - if (t5 !== Symbol.for("react.early_return_sentinel")) { - return t5; - } - let t6; - if ($[9] !== T0 || $[10] !== t1) { - t6 = {t1}; - $[9] = T0; - $[10] = t1; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] !== T1 || $[13] !== t2 || $[14] !== t3 || $[15] !== t4 || $[16] !== t6) { - t7 = {t2}{t3}{t4}{t6}; - $[12] = T1; - $[13] = t2; - $[14] = t3; - $[15] = t4; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; } - return t7; + + return ( + + {title && {title}} + {formatDecisionReason()} + + ) } -function _temp(rule) { - return chalk.bold(permissionRuleValueToString(rule)); + +function SuggestedRules({ + suggestions, +}: { + suggestions: PermissionUpdate[] | undefined +}): React.ReactNode { + const rules = extractRules(suggestions) + if (rules.length === 0) return null + return ( + + + {' '}⎿{' '} + + Suggested rules:{' '} + + {rules + .map(rule => chalk.bold(permissionRuleValueToString(rule))) + .join(', ')} + + + ) } + type Props = { - permissionResult: PermissionDecision; - toolName?: string; // Filter unreachable rules to this tool -}; + permissionResult: PermissionDecision + toolName?: string // Filter unreachable rules to this tool +} // Helper function to extract directories from permission updates function extractDirectories(updates: PermissionUpdate[] | undefined): string[] { - if (!updates) return []; + if (!updates) return [] + return updates.flatMap(update => { switch (update.type) { case 'addDirectories': - return update.directories; + return update.directories default: - return []; + return [] } - }); + }) } // Helper function to extract mode from permission updates -function extractMode(updates: PermissionUpdate[] | undefined): PermissionMode | undefined { - if (!updates) return undefined; - const update = updates.findLast(u => u.type === 'setMode'); - return update?.type === 'setMode' ? update.mode : undefined; +function extractMode( + updates: PermissionUpdate[] | undefined, +): PermissionMode | undefined { + if (!updates) return undefined + const update = updates.findLast(u => u.type === 'setMode') + return update?.type === 'setMode' ? update.mode : undefined } -function SuggestionDisplay(t0) { - const $ = _c(22); - const { - suggestions, - width - } = t0; + +function SuggestionDisplay({ + suggestions, + width, +}: { + suggestions: PermissionUpdate[] | undefined + width: number +}): React.ReactNode { if (!suggestions || suggestions.length === 0) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Suggestions ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== width) { - t2 = {t1}; - $[1] = width; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = None; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== t2) { - t4 = {t2}{t3}; - $[4] = t2; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; + return ( + + + Suggestions + + None + + ) } - let t1; - let t2; - if ($[6] !== suggestions || $[7] !== width) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const rules = extractRules(suggestions); - const directories = extractDirectories(suggestions); - const mode = extractMode(suggestions); - if (rules.length === 0 && directories.length === 0 && !mode) { - let t3; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Suggestion ; - $[10] = t3; - } else { - t3 = $[10]; - } - let t4; - if ($[11] !== width) { - t4 = {t3}; - $[11] = width; - $[12] = t4; - } else { - t4 = $[12]; - } - let t5; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t5 = None; - $[13] = t5; - } else { - t5 = $[13]; - } - let t6; - if ($[14] !== t4) { - t6 = {t4}{t5}; - $[14] = t4; - $[15] = t6; - } else { - t6 = $[15]; - } - t2 = t6; - break bb0; - } - let t3; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Suggestions ; - $[16] = t3; - } else { - t3 = $[16]; - } - let t4; - if ($[17] !== width) { - t4 = {t3}; - $[17] = width; - $[18] = t4; - } else { - t4 = $[18]; - } - let t5; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[19] = t5; - } else { - t5 = $[19]; - } - let t6; - if ($[20] !== t4) { - t6 = {t4}{t5}; - $[20] = t4; - $[21] = t6; - } else { - t6 = $[21]; - } - t1 = {t6}{rules.length > 0 && Rules {rules.map(_temp2)}}{directories.length > 0 && Directories {directories.map(_temp3)}}{mode && Mode {permissionModeTitle(mode)}}; - } - $[6] = suggestions; - $[7] = width; - $[8] = t1; - $[9] = t2; - } else { - t1 = $[8]; - t2 = $[9]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; + + const rules = extractRules(suggestions) + const directories = extractDirectories(suggestions) + const mode = extractMode(suggestions) + + // If nothing to display, show None + if (rules.length === 0 && directories.length === 0 && !mode) { + return ( + + + Suggestion + + None + + ) } - return t1; -} -function _temp3(dir, index_0) { - return {figures.bullet} {dir}; -} -function _temp2(rule, index) { - return {figures.bullet} {permissionRuleValueToString(rule)}; + + return ( + + + + Suggestions + + + + + {/* Display rules */} + {rules.length > 0 && ( + + + Rules + + + {rules.map((rule, index) => ( + + {figures.bullet} {permissionRuleValueToString(rule)} + + ))} + + + )} + + {/* Display directories */} + {directories.length > 0 && ( + + + Directories + + + {directories.map((dir, index) => ( + + {figures.bullet} {dir} + + ))} + + + )} + + {/* Display mode change */} + {mode && ( + + + Mode + + {permissionModeTitle(mode)} + + )} + + ) } -export function PermissionDecisionDebugInfo(t0) { - const $ = _c(25); - const { - permissionResult, - toolName - } = t0; - const toolPermissionContext = useAppState(_temp4); - const decisionReason = permissionResult.decisionReason; - const suggestions = "suggestions" in permissionResult ? permissionResult.suggestions : undefined; - let t1; - if ($[0] !== suggestions || $[1] !== toolName || $[2] !== toolPermissionContext) { - bb0: { - const sandboxAutoAllowEnabled = SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const all = detectUnreachableRules(toolPermissionContext, { - sandboxAutoAllowEnabled - }); - const suggestedRules = extractRules(suggestions); - if (suggestedRules.length > 0) { - t1 = all.filter(u => suggestedRules.some(suggested => suggested.toolName === u.rule.ruleValue.toolName && suggested.ruleContent === u.rule.ruleValue.ruleContent)); - break bb0; - } - if (toolName) { - let t2; - if ($[4] !== toolName) { - t2 = u_0 => u_0.rule.ruleValue.toolName === toolName; - $[4] = toolName; - $[5] = t2; - } else { - t2 = $[5]; - } - t1 = all.filter(t2); - break bb0; - } - t1 = all; + +export function PermissionDecisionDebugInfo({ + permissionResult, + toolName, +}: Props): React.ReactNode { + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const decisionReason = permissionResult.decisionReason + const suggestions = + 'suggestions' in permissionResult ? permissionResult.suggestions : undefined + + const unreachableRules = useMemo(() => { + const sandboxAutoAllowEnabled = + SandboxManager.isSandboxingEnabled() && + SandboxManager.isAutoAllowBashIfSandboxedEnabled() + const all = detectUnreachableRules(toolPermissionContext, { + sandboxAutoAllowEnabled, + }) + + // Get the suggested rules from the permission result + const suggestedRules = extractRules(suggestions) + + // Filter to rules that match any of the suggested rules + // A rule matches if it has the same toolName and ruleContent + if (suggestedRules.length > 0) { + return all.filter(u => + suggestedRules.some( + suggested => + suggested.toolName === u.rule.ruleValue.toolName && + suggested.ruleContent === u.rule.ruleValue.ruleContent, + ), + ) } - $[0] = suggestions; - $[1] = toolName; - $[2] = toolPermissionContext; - $[3] = t1; - } else { - t1 = $[3]; - } - const unreachableRules = t1; - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Behavior ; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== permissionResult.behavior) { - t3 = {t2}{permissionResult.behavior}; - $[7] = permissionResult.behavior; - $[8] = t3; - } else { - t3 = $[8]; - } - let t4; - if ($[9] !== permissionResult.behavior || $[10] !== permissionResult.message) { - t4 = permissionResult.behavior !== "allow" && Message {permissionResult.message}; - $[9] = permissionResult.behavior; - $[10] = permissionResult.message; - $[11] = t4; - } else { - t4 = $[11]; - } - let t5; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Reason ; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== decisionReason) { - t6 = {t5}{decisionReason === undefined ? undefined : }; - $[13] = decisionReason; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] !== suggestions) { - t7 = ; - $[15] = suggestions; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== unreachableRules) { - t8 = unreachableRules.length > 0 && {figures.warning} Unreachable Rules ({unreachableRules.length}){unreachableRules.map(_temp5)}; - $[17] = unreachableRules; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== t3 || $[20] !== t4 || $[21] !== t6 || $[22] !== t7 || $[23] !== t8) { - t9 = {t3}{t4}{t6}{t7}{t8}; - $[19] = t3; - $[20] = t4; - $[21] = t6; - $[22] = t7; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - return t9; -} -function _temp5(u_1, i) { - return {permissionRuleValueToString(u_1.rule.ruleValue)}{" "}{u_1.reason}{" "}Fix: {u_1.fix}; -} -function _temp4(s) { - return s.toolPermissionContext; + + // Fallback: filter by tool name if specified + if (toolName) { + return all.filter(u => u.rule.ruleValue.toolName === toolName) + } + + return all + }, [toolPermissionContext, toolName, suggestions]) + + const WIDTH = 10 + + return ( + + + + Behavior + + {permissionResult.behavior} + + {permissionResult.behavior !== 'allow' && ( + + + Message + + {permissionResult.message} + + )} + + + Reason + + {decisionReason === undefined ? ( + undefined + ) : ( + + )} + + + {unreachableRules.length > 0 && ( + + + {figures.warning} Unreachable Rules ({unreachableRules.length}) + + {unreachableRules.map((u, i) => ( + + + {permissionRuleValueToString(u.rule.ruleValue)} + + + {' '} + {u.reason} + + + {' '}Fix: {u.fix} + + + ))} + + )} + + ) } diff --git a/src/components/permissions/PermissionDialog.tsx b/src/components/permissions/PermissionDialog.tsx index 330f1d36d..210bbb16e 100644 --- a/src/components/permissions/PermissionDialog.tsx +++ b/src/components/permissions/PermissionDialog.tsx @@ -1,71 +1,54 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; -import { PermissionRequestTitle } from './PermissionRequestTitle.js'; -import type { WorkerBadgeProps } from './WorkerBadge.js'; +import * as React from 'react' +import { Box } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' +import { PermissionRequestTitle } from './PermissionRequestTitle.js' +import type { WorkerBadgeProps } from './WorkerBadge.js' + type Props = { - title: string; - subtitle?: React.ReactNode; - color?: keyof Theme; - titleColor?: keyof Theme; - innerPaddingX?: number; - workerBadge?: WorkerBadgeProps; - titleRight?: React.ReactNode; - children: React.ReactNode; -}; -export function PermissionDialog(t0) { - const $ = _c(15); - const { - title, - subtitle, - color: t1, - titleColor, - innerPaddingX: t2, - workerBadge, - titleRight, - children - } = t0; - const color = t1 === undefined ? "permission" : t1; - const innerPaddingX = t2 === undefined ? 1 : t2; - let t3; - if ($[0] !== subtitle || $[1] !== title || $[2] !== titleColor || $[3] !== workerBadge) { - t3 = ; - $[0] = subtitle; - $[1] = title; - $[2] = titleColor; - $[3] = workerBadge; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t3 || $[6] !== titleRight) { - t4 = {t3}{titleRight}; - $[5] = t3; - $[6] = titleRight; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== children || $[9] !== innerPaddingX) { - t5 = {children}; - $[8] = children; - $[9] = innerPaddingX; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== color || $[12] !== t4 || $[13] !== t5) { - t6 = {t4}{t5}; - $[11] = color; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + title: string + subtitle?: React.ReactNode + color?: keyof Theme + titleColor?: keyof Theme + innerPaddingX?: number + workerBadge?: WorkerBadgeProps + titleRight?: React.ReactNode + children: React.ReactNode +} + +export function PermissionDialog({ + title, + subtitle, + color = 'permission', + titleColor, + innerPaddingX = 1, + workerBadge, + titleRight, + children, +}: Props): React.ReactNode { + return ( + + + + + {titleRight} + + + + {children} + + + ) } diff --git a/src/components/permissions/PermissionExplanation.tsx b/src/components/permissions/PermissionExplanation.tsx index 0cb5e2390..2fe08a858 100644 --- a/src/components/permissions/PermissionExplanation.tsx +++ b/src/components/permissions/PermissionExplanation.tsx @@ -1,87 +1,93 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Suspense, use, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { logEvent } from '../../services/analytics/index.js'; -import type { Message } from '../../types/message.js'; -import { generatePermissionExplanation, isPermissionExplainerEnabled, type PermissionExplanation as PermissionExplanationType, type RiskLevel } from '../../utils/permissions/permissionExplainer.js'; -import { ShimmerChar } from '../Spinner/ShimmerChar.js'; -import { useShimmerAnimation } from '../Spinner/useShimmerAnimation.js'; -const LOADING_MESSAGE = 'Loading explanation…'; -function ShimmerLoadingText() { - const $ = _c(7); - const [ref, glimmerIndex] = useShimmerAnimation("responding", LOADING_MESSAGE, false); - let t0; - if ($[0] !== glimmerIndex) { - t0 = LOADING_MESSAGE.split("").map((char, index) => ); - $[0] = glimmerIndex; - $[1] = t0; - } else { - t0 = $[1]; - } - let t1; - if ($[2] !== t0) { - t1 = {t0}; - $[2] = t0; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== ref || $[5] !== t1) { - t2 = {t1}; - $[4] = ref; - $[5] = t1; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; +import React, { Suspense, use, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { logEvent } from '../../services/analytics/index.js' +import type { Message } from '../../types/message.js' +import { + generatePermissionExplanation, + isPermissionExplainerEnabled, + type PermissionExplanation as PermissionExplanationType, + type RiskLevel, +} from '../../utils/permissions/permissionExplainer.js' +import { ShimmerChar } from '../Spinner/ShimmerChar.js' +import { useShimmerAnimation } from '../Spinner/useShimmerAnimation.js' + +const LOADING_MESSAGE = 'Loading explanation…' + +function ShimmerLoadingText(): React.ReactNode { + const [ref, glimmerIndex] = useShimmerAnimation( + 'responding', + LOADING_MESSAGE, + false, + ) + + return ( + + + {LOADING_MESSAGE.split('').map((char, index) => ( + + ))} + + + ) } + function getRiskColor(riskLevel: RiskLevel): 'success' | 'warning' | 'error' { switch (riskLevel) { case 'LOW': - return 'success'; + return 'success' case 'MEDIUM': - return 'warning'; + return 'warning' case 'HIGH': - return 'error'; + return 'error' } } + function getRiskLabel(riskLevel: RiskLevel): string { switch (riskLevel) { case 'LOW': - return 'Low risk'; + return 'Low risk' case 'MEDIUM': - return 'Med risk'; + return 'Med risk' case 'HIGH': - return 'High risk'; + return 'High risk' } } + type PermissionExplanationProps = { - toolName: string; - toolInput: unknown; - toolDescription?: string; - messages?: Message[]; -}; + toolName: string + toolInput: unknown + toolDescription?: string + messages?: Message[] +} + type ExplainerState = { - visible: boolean; - enabled: boolean; - promise: Promise | null; -}; + visible: boolean + enabled: boolean + promise: Promise | null +} /** * Creates an explanation promise that never rejects. * Errors are caught and returned as null. */ -function createExplanationPromise(props: PermissionExplanationProps): Promise { +function createExplanationPromise( + props: PermissionExplanationProps, +): Promise { return generatePermissionExplanation({ toolName: props.toolName, toolInput: props.toolInput, toolDescription: props.toolDescription, messages: props.messages, - signal: new AbortController().signal // Won't abort - request is fast enough - }).catch(() => null); + signal: new AbortController().signal, // Won't abort - request is fast enough + }).catch(() => null) } /** @@ -89,183 +95,93 @@ function createExplanationPromise(props: PermissionExplanationProps): Promise { +export function usePermissionExplainerUI( + props: PermissionExplanationProps, +): ExplainerState { + const enabled = isPermissionExplainerEnabled() + const [visible, setVisible] = useState(false) + const [promise, setPromise] = + useState | null>(null) + + // Use keybinding for ctrl+e toggle (configurable via keybindings.json) + useKeybinding( + 'confirm:toggleExplanation', + () => { if (!visible) { - logEvent("tengu_permission_explainer_shortcut_used", {}); + logEvent('tengu_permission_explainer_shortcut_used', {}) + // Only create the promise on first toggle (lazy loading) if (!promise) { - setPromise(createExplanationPromise(props)); + setPromise(createExplanationPromise(props)) } } - setVisible(_temp); - }; - $[1] = promise; - $[2] = props; - $[3] = visible; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation", - isActive: enabled - }; - $[5] = t2; - } else { - t2 = $[5]; - } - useKeybinding("confirm:toggleExplanation", t1, t2); - let t3; - if ($[6] !== promise || $[7] !== visible) { - t3 = { - visible, - enabled, - promise - }; - $[6] = promise; - $[7] = visible; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; + setVisible(v => !v) + }, + { context: 'Confirmation', isActive: enabled }, + ) + + return { visible, enabled, promise } } /** * Inner component that uses React 19's use() to read the promise. * Suspends while loading, returns null on error. */ -function _temp(v) { - return !v; -} -function ExplanationResult(t0) { - const $ = _c(21); - const { - promise - } = t0; - const explanation = use(promise) as PermissionExplanationType | null; +function ExplanationResult({ + promise, +}: { + promise: Promise +}): React.ReactNode { + const explanation = use(promise) + if (!explanation) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Explanation unavailable; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - let t1; - if ($[1] !== explanation.explanation) { - t1 = {explanation.explanation}; - $[1] = explanation.explanation; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== explanation.reasoning) { - t2 = {explanation.reasoning}; - $[3] = explanation.reasoning; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== explanation.riskLevel) { - t3 = getRiskColor(explanation.riskLevel); - $[5] = explanation.riskLevel; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== explanation.riskLevel) { - t4 = getRiskLabel(explanation.riskLevel); - $[7] = explanation.riskLevel; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t3 || $[10] !== t4) { - t5 = {t4}:; - $[9] = t3; - $[10] = t4; - $[11] = t5; - } else { - t5 = $[11]; + return ( + + Explanation unavailable + + ) } - let t6; - if ($[12] !== explanation.risk) { - t6 = {explanation.risk}; - $[12] = explanation.risk; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== t5 || $[15] !== t6) { - t7 = {t5}{t6}; - $[14] = t5; - $[15] = t6; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== t1 || $[18] !== t2 || $[19] !== t7) { - t8 = {t1}{t2}{t7}; - $[17] = t1; - $[18] = t2; - $[19] = t7; - $[20] = t8; - } else { - t8 = $[20]; - } - return t8; + + return ( + + {explanation.explanation} + + {explanation.reasoning} + + + + + {getRiskLabel(explanation.riskLevel)}: + + {explanation.risk} + + + + ) } /** * Content component - shows loading (via Suspense) or explanation when visible */ -export function PermissionExplainerContent(t0) { - const $ = _c(3); - const { - visible, - promise - } = t0; +export function PermissionExplainerContent({ + visible, + promise, +}: { + visible: boolean + promise: Promise | null +}): React.ReactNode { if (!visible || !promise) { - return null; + return null } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== promise) { - t2 = ; - $[1] = promise; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + + return ( + + + + } + > + + + ) } diff --git a/src/components/permissions/PermissionPrompt.tsx b/src/components/permissions/PermissionPrompt.tsx index 3ec9fbb08..ae9ba0730 100644 --- a/src/components/permissions/PermissionPrompt.tsx +++ b/src/components/permissions/PermissionPrompt.tsx @@ -1,36 +1,43 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useMemo, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import type { KeybindingAction } from '../../keybindings/types.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { useSetAppState } from '../../state/AppState.js'; -import { type OptionWithDescription, Select } from '../CustomSelect/select.js'; -export type FeedbackType = 'accept' | 'reject'; +import React, { type ReactNode, useCallback, useMemo, useState } from 'react' +import { Box, Text } from '../../ink.js' +import type { KeybindingAction } from '../../keybindings/types.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { useSetAppState } from '../../state/AppState.js' +import { type OptionWithDescription, Select } from '../CustomSelect/select.js' + +export type FeedbackType = 'accept' | 'reject' + export type PermissionPromptOption = { - value: T; - label: ReactNode; + value: T + label: ReactNode feedbackConfig?: { - type: FeedbackType; - placeholder?: string; - }; - keybinding?: KeybindingAction; -}; + type: FeedbackType + placeholder?: string + } + keybinding?: KeybindingAction +} + export type ToolAnalyticsContext = { - toolName: string; - isMcp: boolean; -}; + toolName: string + isMcp: boolean +} + export type PermissionPromptProps = { - options: PermissionPromptOption[]; - onSelect: (value: T, feedback?: string) => void; - onCancel?: () => void; - question?: string | ReactNode; - toolAnalyticsContext?: ToolAnalyticsContext; -}; + options: PermissionPromptOption[] + onSelect: (value: T, feedback?: string) => void + onCancel?: () => void + question?: string | ReactNode + toolAnalyticsContext?: ToolAnalyticsContext +} + const DEFAULT_PLACEHOLDERS: Record = { accept: 'tell Claude what to do next', - reject: 'tell Claude what to do differently' -}; + reject: 'tell Claude what to do differently', +} /** * Shared component for permission prompts with optional feedback input. @@ -42,294 +49,219 @@ const DEFAULT_PLACEHOLDERS: Record = { * - Analytics events for feedback interactions * - Transforming options to Select-compatible format */ -export function PermissionPrompt(t0) { - const $ = _c(54); - const { - options, - onSelect, - onCancel, - question: t1, - toolAnalyticsContext - } = t0; - const question = t1 === undefined ? "Do you want to proceed?" : t1; - const setAppState = useSetAppState(); - const [acceptFeedback, setAcceptFeedback] = useState(""); - const [rejectFeedback, setRejectFeedback] = useState(""); - const [acceptInputMode, setAcceptInputMode] = useState(false); - const [rejectInputMode, setRejectInputMode] = useState(false); - const [focusedValue, setFocusedValue] = useState(null); - const [acceptFeedbackModeEntered, setAcceptFeedbackModeEntered] = useState(false); - const [rejectFeedbackModeEntered, setRejectFeedbackModeEntered] = useState(false); - let t2; - if ($[0] !== focusedValue || $[1] !== options) { - let t3; - if ($[3] !== focusedValue) { - t3 = opt => opt.value === focusedValue; - $[3] = focusedValue; - $[4] = t3; - } else { - t3 = $[4]; - } - t2 = options.find(t3); - $[0] = focusedValue; - $[1] = options; - $[2] = t2; - } else { - t2 = $[2]; - } - const focusedOption = t2; - const focusedFeedbackType = focusedOption?.feedbackConfig?.type; - const showTabHint = focusedFeedbackType === "accept" && !acceptInputMode || focusedFeedbackType === "reject" && !rejectInputMode; - let t3; - if ($[5] !== acceptInputMode || $[6] !== options || $[7] !== rejectInputMode) { - let t4; - if ($[9] !== acceptInputMode || $[10] !== rejectInputMode) { - t4 = opt_0 => { - const { - value, +export function PermissionPrompt({ + options, + onSelect, + onCancel, + question = 'Do you want to proceed?', + toolAnalyticsContext, +}: PermissionPromptProps): React.ReactNode { + const setAppState = useSetAppState() + const [acceptFeedback, setAcceptFeedback] = useState('') + const [rejectFeedback, setRejectFeedback] = useState('') + const [acceptInputMode, setAcceptInputMode] = useState(false) + const [rejectInputMode, setRejectInputMode] = useState(false) + const [focusedValue, setFocusedValue] = useState(null) + // Track whether user ever entered feedback mode (persists after collapse) + const [acceptFeedbackModeEntered, setAcceptFeedbackModeEntered] = + useState(false) + const [rejectFeedbackModeEntered, setRejectFeedbackModeEntered] = + useState(false) + + // Find which option is focused and whether it has feedback config + const focusedOption = options.find(opt => opt.value === focusedValue) + const focusedFeedbackType = focusedOption?.feedbackConfig?.type + + // Show Tab hint when focused on a feedback-enabled option that's not already in input mode + const showTabHint = + (focusedFeedbackType === 'accept' && !acceptInputMode) || + (focusedFeedbackType === 'reject' && !rejectInputMode) + + // Transform options to Select-compatible format + const selectOptions = useMemo((): OptionWithDescription[] => { + return options.map(opt => { + const { value, label, feedbackConfig } = opt + + // No feedback config = simple option + if (!feedbackConfig) { + return { label, - feedbackConfig - } = opt_0; - if (!feedbackConfig) { - return { - label, - value - }; - } - const { - type, - placeholder - } = feedbackConfig; - const isInputMode = type === "accept" ? acceptInputMode : rejectInputMode; - const onChange = type === "accept" ? setAcceptFeedback : setRejectFeedback; - const defaultPlaceholder = DEFAULT_PLACEHOLDERS[type]; - if (isInputMode) { - return { - type: "input" as const, - label, - value, - placeholder: placeholder ?? defaultPlaceholder, - onChange, - allowEmptySubmitToCancel: true - }; + value, } + } + + const { type, placeholder } = feedbackConfig + const isInputMode = type === 'accept' ? acceptInputMode : rejectInputMode + const onChange = type === 'accept' ? setAcceptFeedback : setRejectFeedback + const defaultPlaceholder = DEFAULT_PLACEHOLDERS[type] + + // When in input mode, show input field + if (isInputMode) { return { + type: 'input' as const, label, - value - }; - }; - $[9] = acceptInputMode; - $[10] = rejectInputMode; - $[11] = t4; - } else { - t4 = $[11]; - } - t3 = options.map(t4); - $[5] = acceptInputMode; - $[6] = options; - $[7] = rejectInputMode; - $[8] = t3; - } else { - t3 = $[8]; - } - const selectOptions = t3; - let t4; - if ($[12] !== acceptInputMode || $[13] !== options || $[14] !== rejectInputMode || $[15] !== toolAnalyticsContext?.isMcp || $[16] !== toolAnalyticsContext?.toolName) { - t4 = value_0 => { - const option = options.find(opt_1 => opt_1.value === value_0); - if (!option?.feedbackConfig) { - return; + value, + placeholder: placeholder ?? defaultPlaceholder, + onChange, + allowEmptySubmitToCancel: true, + } + } + + // Not in input mode - show simple option + return { + label, + value, } - const { - type: type_0 - } = option.feedbackConfig; + }) + }, [options, acceptInputMode, rejectInputMode]) + + // Handle Tab key to toggle input mode + const handleInputModeToggle = useCallback( + (value: T) => { + const option = options.find(opt => opt.value === value) + if (!option?.feedbackConfig) return + + const { type } = option.feedbackConfig const analyticsProps = { - toolName: toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - isMcp: toolAnalyticsContext?.isMcp ?? false - }; - if (type_0 === "accept") { + toolName: + toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + isMcp: toolAnalyticsContext?.isMcp ?? false, + } + + if (type === 'accept') { if (acceptInputMode) { - setAcceptInputMode(false); - logEvent("tengu_accept_feedback_mode_collapsed", analyticsProps); + setAcceptInputMode(false) + logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps) } else { - setAcceptInputMode(true); - setAcceptFeedbackModeEntered(true); - logEvent("tengu_accept_feedback_mode_entered", analyticsProps); + setAcceptInputMode(true) + setAcceptFeedbackModeEntered(true) + logEvent('tengu_accept_feedback_mode_entered', analyticsProps) } - } else { - if (type_0 === "reject") { - if (rejectInputMode) { - setRejectInputMode(false); - logEvent("tengu_reject_feedback_mode_collapsed", analyticsProps); - } else { - setRejectInputMode(true); - setRejectFeedbackModeEntered(true); - logEvent("tengu_reject_feedback_mode_entered", analyticsProps); - } + } else if (type === 'reject') { + if (rejectInputMode) { + setRejectInputMode(false) + logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps) + } else { + setRejectInputMode(true) + setRejectFeedbackModeEntered(true) + logEvent('tengu_reject_feedback_mode_entered', analyticsProps) } } - }; - $[12] = acceptInputMode; - $[13] = options; - $[14] = rejectInputMode; - $[15] = toolAnalyticsContext?.isMcp; - $[16] = toolAnalyticsContext?.toolName; - $[17] = t4; - } else { - t4 = $[17]; - } - const handleInputModeToggle = t4; - let t5; - if ($[18] !== acceptFeedback || $[19] !== acceptFeedbackModeEntered || $[20] !== onSelect || $[21] !== options || $[22] !== rejectFeedback || $[23] !== rejectFeedbackModeEntered || $[24] !== toolAnalyticsContext?.isMcp || $[25] !== toolAnalyticsContext?.toolName) { - t5 = value_1 => { - const option_0 = options.find(opt_2 => opt_2.value === value_1); - if (!option_0) { - return; - } - let feedback; - if (option_0.feedbackConfig) { - const rawFeedback = option_0.feedbackConfig.type === "accept" ? acceptFeedback : rejectFeedback; - const trimmedFeedback = rawFeedback.trim(); + }, + [options, acceptInputMode, rejectInputMode, toolAnalyticsContext], + ) + + // Handle selection + const handleSelect = useCallback( + (value: T) => { + const option = options.find(opt => opt.value === value) + if (!option) return + + // Get feedback if applicable + let feedback: string | undefined + if (option.feedbackConfig) { + const rawFeedback = + option.feedbackConfig.type === 'accept' + ? acceptFeedback + : rejectFeedback + const trimmedFeedback = rawFeedback.trim() + if (trimmedFeedback) { - feedback = trimmedFeedback; + feedback = trimmedFeedback } - const analyticsProps_0 = { - toolName: toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + + // Log accept/reject submission with feedback context + const analyticsProps = { + toolName: + toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: toolAnalyticsContext?.isMcp ?? false, has_instructions: !!trimmedFeedback, instructions_length: trimmedFeedback?.length ?? 0, - entered_feedback_mode: option_0.feedbackConfig.type === "accept" ? acceptFeedbackModeEntered : rejectFeedbackModeEntered - }; - if (option_0.feedbackConfig.type === "accept") { - logEvent("tengu_accept_submitted", analyticsProps_0); - } else { - if (option_0.feedbackConfig.type === "reject") { - logEvent("tengu_reject_submitted", analyticsProps_0); - } + entered_feedback_mode: + option.feedbackConfig.type === 'accept' + ? acceptFeedbackModeEntered + : rejectFeedbackModeEntered, + } + + if (option.feedbackConfig.type === 'accept') { + logEvent('tengu_accept_submitted', analyticsProps) + } else if (option.feedbackConfig.type === 'reject') { + logEvent('tengu_reject_submitted', analyticsProps) } } - onSelect(value_1, feedback); - }; - $[18] = acceptFeedback; - $[19] = acceptFeedbackModeEntered; - $[20] = onSelect; - $[21] = options; - $[22] = rejectFeedback; - $[23] = rejectFeedbackModeEntered; - $[24] = toolAnalyticsContext?.isMcp; - $[25] = toolAnalyticsContext?.toolName; - $[26] = t5; - } else { - t5 = $[26]; - } - const handleSelect = t5; - let handlers; - if ($[27] !== handleSelect || $[28] !== options) { - handlers = {}; - for (const opt_3 of options) { - if (opt_3.keybinding) { - handlers[opt_3.keybinding] = () => handleSelect(opt_3.value); - } - } - $[27] = handleSelect; - $[28] = options; - $[29] = handlers; - } else { - handlers = $[29]; - } - const keybindingHandlers = handlers; - let t6; - if ($[30] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - context: "Confirmation" - }; - $[30] = t6; - } else { - t6 = $[30]; - } - useKeybindings(keybindingHandlers, t6); - let t7; - if ($[31] !== onCancel || $[32] !== setAppState) { - t7 = () => { - logEvent("tengu_permission_request_escape", {}); - setAppState(_temp); - onCancel?.(); - }; - $[31] = onCancel; - $[32] = setAppState; - $[33] = t7; - } else { - t7 = $[33]; - } - const handleCancel = t7; - let t8; - if ($[34] !== question) { - t8 = typeof question === "string" ? {question} : question; - $[34] = question; - $[35] = t8; - } else { - t8 = $[35]; - } - let t9; - if ($[36] !== acceptFeedback || $[37] !== acceptInputMode || $[38] !== options || $[39] !== rejectFeedback || $[40] !== rejectInputMode) { - t9 = value_2 => { - const newOption = options.find(opt_4 => opt_4.value === value_2); - if (newOption?.feedbackConfig?.type !== "accept" && acceptInputMode && !acceptFeedback.trim()) { - setAcceptInputMode(false); - } - if (newOption?.feedbackConfig?.type !== "reject" && rejectInputMode && !rejectFeedback.trim()) { - setRejectInputMode(false); + + onSelect(value, feedback) + }, + [ + options, + acceptFeedback, + rejectFeedback, + onSelect, + toolAnalyticsContext, + acceptFeedbackModeEntered, + rejectFeedbackModeEntered, + ], + ) + + // Register keybinding handlers for options that have a keybinding set + const keybindingHandlers = useMemo(() => { + const handlers: Record void> = {} + for (const opt of options) { + if (opt.keybinding) { + handlers[opt.keybinding] = () => handleSelect(opt.value) } - setFocusedValue(value_2); - }; - $[36] = acceptFeedback; - $[37] = acceptInputMode; - $[38] = options; - $[39] = rejectFeedback; - $[40] = rejectInputMode; - $[41] = t9; - } else { - t9 = $[41]; - } - let t10; - if ($[42] !== handleCancel || $[43] !== handleInputModeToggle || $[44] !== handleSelect || $[45] !== selectOptions || $[46] !== t9) { - t10 = { + // Reset input mode when navigating away, but only if no text typed + const newOption = options.find(opt => opt.value === value) + if ( + newOption?.feedbackConfig?.type !== 'accept' && + acceptInputMode && + !acceptFeedback.trim() + ) { + setAcceptInputMode(false) + } + if ( + newOption?.feedbackConfig?.type !== 'reject' && + rejectInputMode && + !rejectFeedback.trim() + ) { + setRejectInputMode(false) + } + setFocusedValue(value) + }} + onInputModeToggle={handleInputModeToggle} + /> + + Esc to cancel{showTabHint && ' · Tab to amend'} + + + ) } diff --git a/src/components/permissions/PermissionRequest.tsx b/src/components/permissions/PermissionRequest.tsx index 9ab17c578..53dba4032 100644 --- a/src/components/permissions/PermissionRequest.tsx +++ b/src/components/permissions/PermissionRequest.tsx @@ -1,92 +1,125 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { EnterPlanModeTool } from 'src/tools/EnterPlanModeTool/EnterPlanModeTool.js'; -import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; -import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js'; -import { AskUserQuestionTool } from '../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import { BashTool } from '../../tools/BashTool/BashTool.js'; -import { FileEditTool } from '../../tools/FileEditTool/FileEditTool.js'; -import { FileReadTool } from '../../tools/FileReadTool/FileReadTool.js'; -import { FileWriteTool } from '../../tools/FileWriteTool/FileWriteTool.js'; -import { GlobTool } from '../../tools/GlobTool/GlobTool.js'; -import { GrepTool } from '../../tools/GrepTool/GrepTool.js'; -import { NotebookEditTool } from '../../tools/NotebookEditTool/NotebookEditTool.js'; -import { PowerShellTool } from '../../tools/PowerShellTool/PowerShellTool.js'; -import { SkillTool } from '../../tools/SkillTool/SkillTool.js'; -import { WebFetchTool } from '../../tools/WebFetchTool/WebFetchTool.js'; -import type { AssistantMessage } from '../../types/message.js'; -import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'; -import { AskUserQuestionPermissionRequest } from './AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.js'; -import { BashPermissionRequest } from './BashPermissionRequest/BashPermissionRequest.js'; -import { EnterPlanModePermissionRequest } from './EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.js'; -import { ExitPlanModePermissionRequest } from './ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'; -import { FallbackPermissionRequest } from './FallbackPermissionRequest.js'; -import { FileEditPermissionRequest } from './FileEditPermissionRequest/FileEditPermissionRequest.js'; -import { FilesystemPermissionRequest } from './FilesystemPermissionRequest/FilesystemPermissionRequest.js'; -import { FileWritePermissionRequest } from './FileWritePermissionRequest/FileWritePermissionRequest.js'; -import { NotebookEditPermissionRequest } from './NotebookEditPermissionRequest/NotebookEditPermissionRequest.js'; -import { PowerShellPermissionRequest } from './PowerShellPermissionRequest/PowerShellPermissionRequest.js'; -import { SkillPermissionRequest } from './SkillPermissionRequest/SkillPermissionRequest.js'; -import { WebFetchPermissionRequest } from './WebFetchPermissionRequest/WebFetchPermissionRequest.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { EnterPlanModeTool } from 'src/tools/EnterPlanModeTool/EnterPlanModeTool.js' +import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' +import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' +import { AskUserQuestionTool } from '../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import { BashTool } from '../../tools/BashTool/BashTool.js' +import { FileEditTool } from '../../tools/FileEditTool/FileEditTool.js' +import { FileReadTool } from '../../tools/FileReadTool/FileReadTool.js' +import { FileWriteTool } from '../../tools/FileWriteTool/FileWriteTool.js' +import { GlobTool } from '../../tools/GlobTool/GlobTool.js' +import { GrepTool } from '../../tools/GrepTool/GrepTool.js' +import { NotebookEditTool } from '../../tools/NotebookEditTool/NotebookEditTool.js' +import { PowerShellTool } from '../../tools/PowerShellTool/PowerShellTool.js' +import { SkillTool } from '../../tools/SkillTool/SkillTool.js' +import { WebFetchTool } from '../../tools/WebFetchTool/WebFetchTool.js' +import type { AssistantMessage } from '../../types/message.js' +import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' +import { AskUserQuestionPermissionRequest } from './AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.js' +import { BashPermissionRequest } from './BashPermissionRequest/BashPermissionRequest.js' +import { EnterPlanModePermissionRequest } from './EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.js' +import { ExitPlanModePermissionRequest } from './ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js' +import { FallbackPermissionRequest } from './FallbackPermissionRequest.js' +import { FileEditPermissionRequest } from './FileEditPermissionRequest/FileEditPermissionRequest.js' +import { FilesystemPermissionRequest } from './FilesystemPermissionRequest/FilesystemPermissionRequest.js' +import { FileWritePermissionRequest } from './FileWritePermissionRequest/FileWritePermissionRequest.js' +import { NotebookEditPermissionRequest } from './NotebookEditPermissionRequest/NotebookEditPermissionRequest.js' +import { PowerShellPermissionRequest } from './PowerShellPermissionRequest/PowerShellPermissionRequest.js' +import { SkillPermissionRequest } from './SkillPermissionRequest/SkillPermissionRequest.js' +import { WebFetchPermissionRequest } from './WebFetchPermissionRequest/WebFetchPermissionRequest.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const ReviewArtifactTool = feature('REVIEW_ARTIFACT') ? (require('../../tools/ReviewArtifactTool/ReviewArtifactTool.js') as typeof import('../../tools/ReviewArtifactTool/ReviewArtifactTool.js')).ReviewArtifactTool : null; -const ReviewArtifactPermissionRequest = feature('REVIEW_ARTIFACT') ? (require('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') as typeof import('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js')).ReviewArtifactPermissionRequest : null; -const WorkflowTool = feature('WORKFLOW_SCRIPTS') ? (require('../../tools/WorkflowTool/WorkflowTool.js') as typeof import('../../tools/WorkflowTool/WorkflowTool.js')).WorkflowTool : null; -const WorkflowPermissionRequest = feature('WORKFLOW_SCRIPTS') ? (require('../../tools/WorkflowTool/WorkflowPermissionRequest.js') as typeof import('../../tools/WorkflowTool/WorkflowPermissionRequest.js')).WorkflowPermissionRequest : null; -const MonitorTool = feature('MONITOR_TOOL') ? (require('../../tools/MonitorTool/MonitorTool.js') as typeof import('../../tools/MonitorTool/MonitorTool.js')).MonitorTool : null; -const MonitorPermissionRequest = feature('MONITOR_TOOL') ? (require('./MonitorPermissionRequest/MonitorPermissionRequest.js') as typeof import('./MonitorPermissionRequest/MonitorPermissionRequest.js')).MonitorPermissionRequest : null; -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; +const ReviewArtifactTool = feature('REVIEW_ARTIFACT') + ? ( + require('../../tools/ReviewArtifactTool/ReviewArtifactTool.js') as typeof import('../../tools/ReviewArtifactTool/ReviewArtifactTool.js') + ).ReviewArtifactTool + : null + +const ReviewArtifactPermissionRequest = feature('REVIEW_ARTIFACT') + ? ( + require('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') as typeof import('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') + ).ReviewArtifactPermissionRequest + : null + +const WorkflowTool = feature('WORKFLOW_SCRIPTS') + ? ( + require('../../tools/WorkflowTool/WorkflowTool.js') as typeof import('../../tools/WorkflowTool/WorkflowTool.js') + ).WorkflowTool + : null + +const WorkflowPermissionRequest = feature('WORKFLOW_SCRIPTS') + ? ( + require('../../tools/WorkflowTool/WorkflowPermissionRequest.js') as typeof import('../../tools/WorkflowTool/WorkflowPermissionRequest.js') + ).WorkflowPermissionRequest + : null + +const MonitorTool = feature('MONITOR_TOOL') + ? ( + require('../../tools/MonitorTool/MonitorTool.js') as typeof import('../../tools/MonitorTool/MonitorTool.js') + ).MonitorTool + : null + +const MonitorPermissionRequest = feature('MONITOR_TOOL') + ? ( + require('./MonitorPermissionRequest/MonitorPermissionRequest.js') as typeof import('./MonitorPermissionRequest/MonitorPermissionRequest.js') + ).MonitorPermissionRequest + : null + +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' /* eslint-enable @typescript-eslint/no-require-imports */ -import type { z } from 'zod/v4'; -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; -import type { WorkerBadgeProps } from './WorkerBadge.js'; -function permissionComponentForTool(tool: Tool): React.ComponentType { +import type { z } from 'zod/v4' +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' +import type { WorkerBadgeProps } from './WorkerBadge.js' + +function permissionComponentForTool( + tool: Tool, +): React.ComponentType { switch (tool) { case FileEditTool: - return FileEditPermissionRequest; + return FileEditPermissionRequest case FileWriteTool: - return FileWritePermissionRequest; + return FileWritePermissionRequest case BashTool: - return BashPermissionRequest; + return BashPermissionRequest case PowerShellTool: - return PowerShellPermissionRequest; + return PowerShellPermissionRequest case ReviewArtifactTool: - return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest; + return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest case WebFetchTool: - return WebFetchPermissionRequest; + return WebFetchPermissionRequest case NotebookEditTool: - return NotebookEditPermissionRequest; + return NotebookEditPermissionRequest case ExitPlanModeV2Tool: - return ExitPlanModePermissionRequest; + return ExitPlanModePermissionRequest case EnterPlanModeTool: - return EnterPlanModePermissionRequest; + return EnterPlanModePermissionRequest case SkillTool: - return SkillPermissionRequest; + return SkillPermissionRequest case AskUserQuestionTool: - return AskUserQuestionPermissionRequest; + return AskUserQuestionPermissionRequest case WorkflowTool: - return WorkflowPermissionRequest ?? FallbackPermissionRequest; + return WorkflowPermissionRequest ?? FallbackPermissionRequest case MonitorTool: - return MonitorPermissionRequest ?? FallbackPermissionRequest; + return MonitorPermissionRequest ?? FallbackPermissionRequest case GlobTool: case GrepTool: case FileReadTool: - return FilesystemPermissionRequest; + return FilesystemPermissionRequest default: - return FallbackPermissionRequest; + return FallbackPermissionRequest } } + export type PermissionRequestProps = { - toolUseConfirm: ToolUseConfirm; - toolUseContext: ToolUseContext; - onDone(): void; - onReject(): void; - verbose: boolean; - workerBadge: WorkerBadgeProps | undefined; + toolUseConfirm: ToolUseConfirm + toolUseContext: ToolUseContext + onDone(): void + onReject(): void + verbose: boolean + workerBadge: WorkerBadgeProps | undefined /** * Register JSX to render in a sticky footer below the scrollable area. * Fullscreen mode only (non-fullscreen has no sticky area — terminal @@ -98,119 +131,102 @@ export type PermissionRequestProps = { * to avoid stale closures (React reconciles the JSX, preserving Select's * internal focus/input state). */ - setStickyFooter?: (jsx: React.ReactNode | null) => void; -}; + setStickyFooter?: (jsx: React.ReactNode | null) => void +} + export type ToolUseConfirm = { - assistantMessage: AssistantMessage; - tool: Tool; - description: string; - input: z.infer; - toolUseContext: ToolUseContext; - toolUseID: string; - permissionResult: PermissionDecision; - permissionPromptStartTimeMs: number; + assistantMessage: AssistantMessage + tool: Tool + description: string + input: z.infer + toolUseContext: ToolUseContext + toolUseID: string + permissionResult: PermissionDecision + permissionPromptStartTimeMs: number /** * Called when user interacts with the permission dialog (e.g., arrow keys, tab, typing). * This prevents async auto-approval mechanisms (like the bash classifier) from * dismissing the dialog while the user is actively engaging with it. */ - classifierCheckInProgress?: boolean; - classifierAutoApproved?: boolean; - classifierMatchedRule?: string; - workerBadge?: WorkerBadgeProps; - onUserInteraction(): void; - onAbort(): void; - onDismissCheckmark?(): void; - onAllow(updatedInput: z.infer, permissionUpdates: PermissionUpdate[], feedback?: string, contentBlocks?: ContentBlockParam[]): void; - onReject(feedback?: string, contentBlocks?: ContentBlockParam[]): void; - recheckPermission(): Promise; -}; + classifierCheckInProgress?: boolean + classifierAutoApproved?: boolean + classifierMatchedRule?: string + workerBadge?: WorkerBadgeProps + onUserInteraction(): void + onAbort(): void + onDismissCheckmark?(): void + onAllow( + updatedInput: z.infer, + permissionUpdates: PermissionUpdate[], + feedback?: string, + contentBlocks?: ContentBlockParam[], + ): void + onReject(feedback?: string, contentBlocks?: ContentBlockParam[]): void + recheckPermission(): Promise +} + function getNotificationMessage(toolUseConfirm: ToolUseConfirm): string { - const toolName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); + const toolName = toolUseConfirm.tool.userFacingName( + toolUseConfirm.input as never, + ) + if (toolUseConfirm.tool === ExitPlanModeV2Tool) { - return 'Claude Code needs your approval for the plan'; + return 'Claude Code needs your approval for the plan' } + if (toolUseConfirm.tool === EnterPlanModeTool) { - return 'Claude Code wants to enter plan mode'; + return 'Claude Code wants to enter plan mode' } - if (feature('REVIEW_ARTIFACT') && toolUseConfirm.tool === ReviewArtifactTool) { - return 'Claude needs your approval for a review artifact'; + + if ( + feature('REVIEW_ARTIFACT') && + toolUseConfirm.tool === ReviewArtifactTool + ) { + return 'Claude needs your approval for a review artifact' } + if (!toolName || toolName.trim() === '') { - return 'Claude Code needs your attention'; + return 'Claude Code needs your attention' } - return `Claude needs your permission to use ${toolName}`; + + return `Claude needs your permission to use ${toolName}` } // TODO: Move this to Tool.renderPermissionRequest -export function PermissionRequest(t0) { - const $ = _c(18); - const { - toolUseConfirm, - toolUseContext, - onDone, - onReject, - verbose, - workerBadge, - setStickyFooter - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== onReject || $[2] !== toolUseConfirm) { - t1 = () => { - onDone(); - onReject(); - toolUseConfirm.onReject(); - }; - $[0] = onDone; - $[1] = onReject; - $[2] = toolUseConfirm; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[4] = t2; - } else { - t2 = $[4]; - } - useKeybinding("app:interrupt", t1, t2); - let t3; - if ($[5] !== toolUseConfirm) { - t3 = getNotificationMessage(toolUseConfirm); - $[5] = toolUseConfirm; - $[6] = t3; - } else { - t3 = $[6]; - } - const notificationMessage = t3; - useNotifyAfterTimeout(notificationMessage, "permission_prompt"); - let t4; - if ($[7] !== toolUseConfirm.tool) { - t4 = permissionComponentForTool(toolUseConfirm.tool); - $[7] = toolUseConfirm.tool; - $[8] = t4; - } else { - t4 = $[8]; - } - const PermissionComponent = t4; - let t5; - if ($[9] !== PermissionComponent || $[10] !== onDone || $[11] !== onReject || $[12] !== setStickyFooter || $[13] !== toolUseConfirm || $[14] !== toolUseContext || $[15] !== verbose || $[16] !== workerBadge) { - t5 = ; - $[9] = PermissionComponent; - $[10] = onDone; - $[11] = onReject; - $[12] = setStickyFooter; - $[13] = toolUseConfirm; - $[14] = toolUseContext; - $[15] = verbose; - $[16] = workerBadge; - $[17] = t5; - } else { - t5 = $[17]; - } - return t5; +export function PermissionRequest({ + toolUseConfirm, + toolUseContext, + onDone, + onReject, + verbose, + workerBadge, + setStickyFooter, +}: PermissionRequestProps): React.ReactNode { + // Handle Ctrl+C (app:interrupt) to reject + useKeybinding( + 'app:interrupt', + () => { + onDone() + onReject() + toolUseConfirm.onReject() + }, + { context: 'Confirmation' }, + ) + + const notificationMessage = getNotificationMessage(toolUseConfirm) + useNotifyAfterTimeout(notificationMessage, 'permission_prompt') + + const PermissionComponent = permissionComponentForTool(toolUseConfirm.tool) + + return ( + + ) } diff --git a/src/components/permissions/PermissionRequestTitle.tsx b/src/components/permissions/PermissionRequestTitle.tsx index a324d3e97..953cca22b 100644 --- a/src/components/permissions/PermissionRequestTitle.tsx +++ b/src/components/permissions/PermissionRequestTitle.tsx @@ -1,65 +1,41 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; -import type { WorkerBadgeProps } from './WorkerBadge.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' +import type { WorkerBadgeProps } from './WorkerBadge.js' + type Props = { - title: string; - subtitle?: React.ReactNode; - color?: keyof Theme; - workerBadge?: WorkerBadgeProps; -}; -export function PermissionRequestTitle(t0) { - const $ = _c(13); - const { - title, - subtitle, - color: t1, - workerBadge - } = t0; - const color = t1 === undefined ? "permission" : t1; - let t2; - if ($[0] !== color || $[1] !== title) { - t2 = {title}; - $[0] = color; - $[1] = title; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== workerBadge) { - t3 = workerBadge && {"\xB7 "}@{workerBadge.name}; - $[3] = workerBadge; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== subtitle) { - t5 = subtitle != null && (typeof subtitle === "string" ? {subtitle} : subtitle); - $[8] = subtitle; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t4 || $[11] !== t5) { - t6 = {t4}{t5}; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; + title: string + subtitle?: React.ReactNode + color?: keyof Theme + workerBadge?: WorkerBadgeProps +} + +export function PermissionRequestTitle({ + title, + subtitle, + color = 'permission', + workerBadge, +}: Props): React.ReactNode { + return ( + + + + {title} + + {workerBadge && ( + + {'· '}@{workerBadge.name} + + )} + + {subtitle != null && + (typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + ))} + + ) } diff --git a/src/components/permissions/PermissionRuleExplanation.tsx b/src/components/permissions/PermissionRuleExplanation.tsx index 32e6a944b..406f7e3b8 100644 --- a/src/components/permissions/PermissionRuleExplanation.tsx +++ b/src/components/permissions/PermissionRuleExplanation.tsx @@ -1,120 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import React from 'react'; -import { Ansi, Box, Text } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import type { PermissionDecision, PermissionDecisionReason } from '../../utils/permissions/PermissionResult.js'; -import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js'; -import type { Theme } from '../../utils/theme.js'; -import ThemedText from '../design-system/ThemedText.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import React from 'react' +import { Ansi, Box, Text } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import type { + PermissionDecision, + PermissionDecisionReason, +} from '../../utils/permissions/PermissionResult.js' +import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' +import type { Theme } from '../../utils/theme.js' +import ThemedText from '../design-system/ThemedText.js' + export type PermissionRuleExplanationProps = { - permissionResult: PermissionDecision; - toolType: 'tool' | 'command' | 'edit' | 'read'; -}; + permissionResult: PermissionDecision + toolType: 'tool' | 'command' | 'edit' | 'read' +} + type DecisionReasonStrings = { - reasonString: string; - configString?: string; + reasonString: string + configString?: string /** When set, reasonString is plain text rendered with this theme color instead of . */ - themeColor?: keyof Theme; -}; -function stringsForDecisionReason(reason: PermissionDecisionReason | undefined, toolType: 'tool' | 'command' | 'edit' | 'read'): DecisionReasonStrings | null { + themeColor?: keyof Theme +} + +function stringsForDecisionReason( + reason: PermissionDecisionReason | undefined, + toolType: 'tool' | 'command' | 'edit' | 'read', +): DecisionReasonStrings | null { if (!reason) { - return null; + return null } - if ((feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && reason.type === 'classifier') { + if ( + (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && + reason.type === 'classifier' + ) { if (reason.classifier === 'auto-mode') { return { reasonString: `Auto mode classifier requires confirmation for this ${toolType}.\n${reason.reason}`, configString: undefined, - themeColor: 'error' - }; + themeColor: 'error', + } } return { reasonString: `Classifier ${chalk.bold(reason.classifier)} requires confirmation for this ${toolType}.\n${reason.reason}`, - configString: undefined - }; + configString: undefined, + } } switch (reason.type) { case 'rule': return { - reasonString: `Permission rule ${chalk.bold(permissionRuleValueToString(reason.rule.ruleValue))} requires confirmation for this ${toolType}.`, - configString: reason.rule.source === 'policySettings' ? undefined : '/permissions to update rules' - }; - case 'hook': - { - const hookReasonString = reason.reason ? `:\n${reason.reason}` : '.'; - const sourceLabel = reason.hookSource ? ` ${chalk.dim(`[${reason.hookSource}]`)}` : ''; - return { - reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`, - configString: '/hooks to update' - }; + reasonString: `Permission rule ${chalk.bold( + permissionRuleValueToString(reason.rule.ruleValue), + )} requires confirmation for this ${toolType}.`, + configString: + reason.rule.source === 'policySettings' + ? undefined + : '/permissions to update rules', } + case 'hook': { + const hookReasonString = reason.reason ? `:\n${reason.reason}` : '.' + const sourceLabel = reason.hookSource + ? ` ${chalk.dim(`[${reason.hookSource}]`)}` + : '' + return { + reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`, + configString: '/hooks to update', + } + } case 'safetyCheck': case 'other': return { reasonString: reason.reason, - configString: undefined - }; + configString: undefined, + } case 'workingDir': return { reasonString: reason.reason, - configString: '/permissions to update rules' - }; + configString: '/permissions to update rules', + } default: - return null; + return null } } -export function PermissionRuleExplanation(t0) { - const $ = _c(11); - const { - permissionResult, - toolType - } = t0; - const permissionMode = useAppState(_temp); - const t1 = permissionResult?.decisionReason; - let t2; - if ($[0] !== t1 || $[1] !== toolType) { - t2 = stringsForDecisionReason(t1, toolType); - $[0] = t1; - $[1] = toolType; - $[2] = t2; - } else { - t2 = $[2]; - } - const strings = t2; + +export function PermissionRuleExplanation({ + permissionResult, + toolType, +}: PermissionRuleExplanationProps): React.ReactNode { + const permissionMode = useAppState(s => s.toolPermissionContext.mode) + const strings = stringsForDecisionReason( + permissionResult?.decisionReason, + toolType, + ) if (!strings) { - return null; - } - const themeColor = strings.themeColor ?? (permissionResult?.decisionReason?.type === "hook" && permissionMode === "auto" ? "warning" : undefined); - let t3; - if ($[3] !== strings.reasonString || $[4] !== themeColor) { - t3 = themeColor ? {strings.reasonString} : {strings.reasonString}; - $[3] = strings.reasonString; - $[4] = themeColor; - $[5] = t3; - } else { - t3 = $[5]; + return null } - let t4; - if ($[6] !== strings.configString) { - t4 = strings.configString && {strings.configString}; - $[6] = strings.configString; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = {t3}{t4}; - $[8] = t3; - $[9] = t4; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; -} -function _temp(s) { - return s.toolPermissionContext.mode; + + const themeColor = + strings.themeColor ?? + (permissionResult?.decisionReason?.type === 'hook' && + permissionMode === 'auto' + ? 'warning' + : undefined) + + return ( + + {themeColor ? ( + {strings.reasonString} + ) : ( + + {strings.reasonString} + + )} + {strings.configString && {strings.configString}} + + ) } diff --git a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx index 89604c4e9..5dcd0e488 100644 --- a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx @@ -1,43 +1,48 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../../services/analytics/index.js'; -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; -import { getDestructiveCommandWarning } from '../../../tools/PowerShellTool/destructiveCommandWarning.js'; -import { PowerShellTool } from '../../../tools/PowerShellTool/PowerShellTool.js'; -import { isAllowlistedCommand } from '../../../tools/PowerShellTool/readOnlyValidation.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { getCompoundCommandPrefixesStatic } from '../../../utils/powershell/staticPrefix.js'; -import { Select } from '../../CustomSelect/select.js'; -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import { PermissionExplainerContent, usePermissionExplainerUI } from '../PermissionExplanation.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js'; -import { logUnaryPermissionEvent } from '../utils.js'; -import { powershellToolUseOptions } from './powershellToolUseOptions.js'; -export function PowerShellPermissionRequest(props: PermissionRequestProps): React.ReactNode { - const { - toolUseConfirm, - toolUseContext, - onDone, - onReject, - workerBadge - } = props; - const { - command, - description - } = PowerShellTool.inputSchema.parse(toolUseConfirm.input); - const [theme] = useTheme(); +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Box, Text, useTheme } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../../services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' +import { getDestructiveCommandWarning } from '../../../tools/PowerShellTool/destructiveCommandWarning.js' +import { PowerShellTool } from '../../../tools/PowerShellTool/PowerShellTool.js' +import { isAllowlistedCommand } from '../../../tools/PowerShellTool/readOnlyValidation.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { getCompoundCommandPrefixesStatic } from '../../../utils/powershell/staticPrefix.js' +import { Select } from '../../CustomSelect/select.js' +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' +import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js' +import { PermissionDialog } from '../PermissionDialog.js' +import { + PermissionExplainerContent, + usePermissionExplainerUI, +} from '../PermissionExplanation.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js' +import { logUnaryPermissionEvent } from '../utils.js' +import { powershellToolUseOptions } from './powershellToolUseOptions.js' + +export function PowerShellPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const { toolUseConfirm, toolUseContext, onDone, onReject, workerBadge } = + props + + const { command, description } = PowerShellTool.inputSchema.parse( + toolUseConfirm.input, + ) + + const [theme] = useTheme() const explainerState = usePermissionExplainerUI({ toolName: toolUseConfirm.tool.name, toolInput: toolUseConfirm.input, toolDescription: toolUseConfirm.description, - messages: toolUseContext.messages - }); + messages: toolUseContext.messages, + }) const { yesInputMode, noInputMode, @@ -50,15 +55,21 @@ export function PowerShellPermissionRequest(props: PermissionRequestProps): Reac focusedOption, handleInputModeToggle, handleReject, - handleFocus + handleFocus, } = useShellPermissionFeedback({ toolUseConfirm, onDone, onReject, - explainerVisible: explainerState.visible - }); - const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false) ? getDestructiveCommandWarning(command) : null; - const [showPermissionDebug, setShowPermissionDebug] = useState(false); + explainerVisible: explainerState.visible, + }) + const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_destructive_command_warning', + false, + ) + ? getDestructiveCommandWarning(command) + : null + + const [showPermissionDebug, setShowPermissionDebug] = useState(false) // Editable prefix — compute static prefix locally (no LLM call). // Initialize synchronously to the raw command for single-line commands so @@ -69,166 +80,233 @@ export function PowerShellPermissionRequest(props: PermissionRequestProps): Reac // corpus shows 14 multiline rules, zero match twice). For compound commands, // computes a prefix per subcommand, excluding subcommands that are already // auto-allowed (read-only). - const [editablePrefix, setEditablePrefix] = useState(command.includes('\n') ? undefined : command); - const hasUserEditedPrefix = useRef(false); + const [editablePrefix, setEditablePrefix] = useState( + command.includes('\n') ? undefined : command, + ) + const hasUserEditedPrefix = useRef(false) useEffect(() => { - let cancelled = false; + let cancelled = false // Filter receives ParsedCommandElement — isAllowlistedCommand works from // element.name/nameType/args directly. isReadOnlyCommand(text) would need // to reparse (pwsh.exe spawn per subcommand) and returns false without the // full parsed AST, making the filter a no-op. - getCompoundCommandPrefixesStatic(command, element => isAllowlistedCommand(element, element.text)).then(prefixes => { - if (cancelled || hasUserEditedPrefix.current) return; - if (prefixes.length > 0) { - setEditablePrefix(`${prefixes[0]}:*`); - } - }).catch(() => {}); + getCompoundCommandPrefixesStatic(command, element => + isAllowlistedCommand(element, element.text), + ) + .then(prefixes => { + if (cancelled || hasUserEditedPrefix.current) return + if (prefixes.length > 0) { + setEditablePrefix(`${prefixes[0]}:*`) + } + }) + .catch(() => {}) return () => { - cancelled = true; - }; + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [command]); + }, [command]) + const onEditablePrefixChange = useCallback((value: string) => { - hasUserEditedPrefix.current = true; - setEditablePrefix(value); - }, []); - const unaryEvent = useMemo(() => ({ - completion_type: 'tool_use_single', - language_name: 'none' - }), []); - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - const options = useMemo(() => powershellToolUseOptions({ - suggestions: toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined, - onRejectFeedbackChange: setRejectFeedback, - onAcceptFeedbackChange: setAcceptFeedback, - yesInputMode, - noInputMode, - editablePrefix, - onEditablePrefixChange - }), [toolUseConfirm, yesInputMode, noInputMode, editablePrefix, onEditablePrefixChange]); + hasUserEditedPrefix.current = true + setEditablePrefix(value) + }, []) + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const options = useMemo( + () => + powershellToolUseOptions({ + suggestions: + toolUseConfirm.permissionResult.behavior === 'ask' + ? toolUseConfirm.permissionResult.suggestions + : undefined, + onRejectFeedbackChange: setRejectFeedback, + onAcceptFeedbackChange: setAcceptFeedback, + yesInputMode, + noInputMode, + editablePrefix, + onEditablePrefixChange, + }), + [ + toolUseConfirm, + yesInputMode, + noInputMode, + editablePrefix, + onEditablePrefixChange, + ], + ) // Toggle permission debug info with keybinding const handleToggleDebug = useCallback(() => { - setShowPermissionDebug(prev => !prev); - }, []); + setShowPermissionDebug(prev => !prev) + }, []) useKeybinding('permission:toggleDebug', handleToggleDebug, { - context: 'Confirmation' - }); + context: 'Confirmation', + }) + function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) const optionIndex: Record = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, - no: 3 - }; + no: 3, + } logEvent('tengu_permission_request_option_selected', { option_index: optionIndex[value], - explainer_visible: explainerState.visible - }); - const toolNameForAnalytics = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; + explainer_visible: explainerState.visible, + }) + + const toolNameForAnalytics = sanitizeToolNameForAnalytics( + toolUseConfirm.tool.name, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + if (value === 'yes-prefix-edited') { - const trimmedPrefix = (editablePrefix ?? '').trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + const trimmedPrefix = (editablePrefix ?? '').trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') if (!trimmedPrefix) { - toolUseConfirm.onAllow(toolUseConfirm.input, []); + toolUseConfirm.onAllow(toolUseConfirm.input, []) } else { - const prefixUpdates: PermissionUpdate[] = [{ - type: 'addRules', - rules: [{ - toolName: PowerShellTool.name, - ruleContent: trimmedPrefix - }], - behavior: 'allow', - destination: 'localSettings' - }]; - toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates); + const prefixUpdates: PermissionUpdate[] = [ + { + type: 'addRules', + rules: [ + { + toolName: PowerShellTool.name, + ruleContent: trimmedPrefix, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ] + toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates) } - onDone(); - return; + onDone() + return } + switch (value) { - case 'yes': - { - const trimmedFeedback = acceptFeedback.trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Log accept submission with feedback context - logEvent('tengu_accept_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: yesFeedbackModeEntered - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], trimmedFeedback || undefined); - onDone(); - break; - } - case 'yes-apply-suggestions': - { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) - const permissionUpdates = 'suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions || [] : []; - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates); - onDone(); - break; - } - case 'no': - { - const trimmedFeedback = rejectFeedback.trim(); - - // Log reject submission with feedback context - logEvent('tengu_reject_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: noFeedbackModeEntered - }); - - // Process rejection (with or without feedback) - handleReject(trimmedFeedback || undefined); - break; - } + case 'yes': { + const trimmedFeedback = acceptFeedback.trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Log accept submission with feedback context + logEvent('tengu_accept_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: yesFeedbackModeEntered, + }) + toolUseConfirm.onAllow( + toolUseConfirm.input, + [], + trimmedFeedback || undefined, + ) + onDone() + break + } + case 'yes-apply-suggestions': { + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) + const permissionUpdates = + 'suggestions' in toolUseConfirm.permissionResult + ? toolUseConfirm.permissionResult.suggestions || [] + : [] + toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates) + onDone() + break + } + case 'no': { + const trimmedFeedback = rejectFeedback.trim() + + // Log reject submission with feedback context + logEvent('tengu_reject_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: noFeedbackModeEntered, + }) + + // Process rejection (with or without feedback) + handleReject(trimmedFeedback || undefined) + break + } } } - return + + return ( + - {PowerShellTool.renderToolUseMessage({ - command, - description - }, { - theme, - verbose: true - } // always show the full command - )} + {PowerShellTool.renderToolUseMessage( + { command, description }, + { theme, verbose: true }, // always show the full command + )} - {!explainerState.visible && {toolUseConfirm.description}} - + {!explainerState.visible && ( + {toolUseConfirm.description} + )} + - {showPermissionDebug ? <> - - {toolUseContext.options.debug && + {showPermissionDebug ? ( + <> + + {toolUseContext.options.debug && ( + Ctrl-D to hide debug info - } - : <> + + )} + + ) : ( + <> - - {destructiveWarning && + + {destructiveWarning && ( + {destructiveWarning} - } + + )} Do you want to proceed? - handleReject()} + onFocus={handleFocus} + onInputModeToggle={handleInputModeToggle} + /> Esc to cancel - {(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'} - {explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} + {explainerState.enabled && + ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} - {toolUseContext.options.debug && Ctrl+d to show debug info} + {toolUseContext.options.debug && ( + Ctrl+d to show debug info + )} - } - ; + + )} + + ) } diff --git a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx index d1daa5124..2ad089efe 100644 --- a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx @@ -1,9 +1,15 @@ -import { POWERSHELL_TOOL_NAME } from '../../../tools/PowerShellTool/toolName.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js'; -export type PowerShellToolUseOption = 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited' | 'no'; +import { POWERSHELL_TOOL_NAME } from '../../../tools/PowerShellTool/toolName.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js' + +export type PowerShellToolUseOption = + | 'yes' + | 'yes-apply-suggestions' + | 'yes-prefix-edited' + | 'no' + export function powershellToolUseOptions({ suggestions = [], onRejectFeedbackChange, @@ -11,17 +17,18 @@ export function powershellToolUseOptions({ yesInputMode = false, noInputMode = false, editablePrefix, - onEditablePrefixChange + onEditablePrefixChange, }: { - suggestions?: PermissionUpdate[]; - onRejectFeedbackChange: (value: string) => void; - onAcceptFeedbackChange: (value: string) => void; - yesInputMode?: boolean; - noInputMode?: boolean; - editablePrefix?: string; - onEditablePrefixChange?: (value: string) => void; + suggestions?: PermissionUpdate[] + onRejectFeedbackChange: (value: string) => void + onAcceptFeedbackChange: (value: string) => void + yesInputMode?: boolean + noInputMode?: boolean + editablePrefix?: string + onEditablePrefixChange?: (value: string) => void }): OptionWithDescription[] { - const options: OptionWithDescription[] = []; + const options: OptionWithDescription[] = [] + if (yesInputMode) { options.push({ type: 'input', @@ -29,13 +36,13 @@ export function powershellToolUseOptions({ value: 'yes', placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'Yes', - value: 'yes' - }); + value: 'yes', + }) } // Note: No sandbox toggle for PowerShell - sandbox is not supported on Windows @@ -47,8 +54,17 @@ export function powershellToolUseOptions({ // directory permissions or Read-tool rules, so fall back to the label when // those are present. if (shouldShowAlwaysAllowOptions() && suggestions.length > 0) { - const hasNonPowerShellSuggestions = suggestions.some(s => s.type === 'addDirectories' || s.type === 'addRules' && s.rules?.some(r => r.toolName !== POWERSHELL_TOOL_NAME)); - if (editablePrefix !== undefined && onEditablePrefixChange && !hasNonPowerShellSuggestions) { + const hasNonPowerShellSuggestions = suggestions.some( + s => + s.type === 'addDirectories' || + (s.type === 'addRules' && + s.rules?.some(r => r.toolName !== POWERSHELL_TOOL_NAME)), + ) + if ( + editablePrefix !== undefined && + onEditablePrefixChange && + !hasNonPowerShellSuggestions + ) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -59,18 +75,22 @@ export function powershellToolUseOptions({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - resetCursorOnUpdate: true - }); + resetCursorOnUpdate: true, + }) } else { - const label = generateShellSuggestionsLabel(suggestions, POWERSHELL_TOOL_NAME); + const label = generateShellSuggestionsLabel( + suggestions, + POWERSHELL_TOOL_NAME, + ) if (label) { options.push({ label, - value: 'yes-apply-suggestions' - }); + value: 'yes-apply-suggestions', + }) } } } + if (noInputMode) { options.push({ type: 'input', @@ -78,13 +98,14 @@ export function powershellToolUseOptions({ value: 'no', placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'No', - value: 'no' - }); + value: 'no', + }) } - return options; + + return options } diff --git a/src/components/permissions/SandboxPermissionRequest.tsx b/src/components/permissions/SandboxPermissionRequest.tsx index affbc35fb..9dc4d6629 100644 --- a/src/components/permissions/SandboxPermissionRequest.tsx +++ b/src/components/permissions/SandboxPermissionRequest.tsx @@ -1,162 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; -import { type NetworkHostPattern, shouldAllowManagedSandboxDomainsOnly } from 'src/utils/sandbox/sandbox-adapter.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { Select } from '../CustomSelect/select.js'; -import { PermissionDialog } from './PermissionDialog.js'; +import * as React from 'react' +import { Box, Text } from 'src/ink.js' +import { + type NetworkHostPattern, + shouldAllowManagedSandboxDomainsOnly, +} from 'src/utils/sandbox/sandbox-adapter.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { Select } from '../CustomSelect/select.js' +import { PermissionDialog } from './PermissionDialog.js' + export type SandboxPermissionRequestProps = { - hostPattern: NetworkHostPattern; + hostPattern: NetworkHostPattern onUserResponse: (response: { - allow: boolean; - persistToSettings: boolean; - }) => void; -}; -export function SandboxPermissionRequest(t0) { - const $ = _c(22); - const { - hostPattern: t1, - onUserResponse - } = t0; - const { - host - } = t1; - let t2; - if ($[0] !== onUserResponse) { - t2 = function onSelect(value) { - bb4: switch (value) { - case "yes": - { - onUserResponse({ - allow: true, - persistToSettings: false - }); - break bb4; - } - case "yes-dont-ask-again": - { - onUserResponse({ - allow: true, - persistToSettings: true - }); - break bb4; - } - case "no": - { - onUserResponse({ - allow: false, - persistToSettings: false - }); - } - } - }; - $[0] = onUserResponse; - $[1] = t2; - } else { - t2 = $[1]; - } - const onSelect = t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldAllowManagedSandboxDomainsOnly(); - $[2] = t3; - } else { - t3 = $[2]; - } - const managedDomainsOnly = t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Yes", - value: "yes" - }; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== host) { - t5 = !managedDomainsOnly ? [{ - label: Yes, and don't ask again for {host}, - value: "yes-dont-ask-again" - }] : []; - $[4] = host; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: No, and tell Claude what to do differently (esc), - value: "no" - }; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] !== t5) { - t7 = [t4, ...t5, t6]; - $[7] = t5; - $[8] = t7; - } else { - t7 = $[8]; - } - const options = t7; - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Host:; - $[9] = t8; - } else { - t8 = $[9]; - } - let t9; - if ($[10] !== host) { - t9 = {t8} {host}; - $[10] = host; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Do you want to allow this connection?; - $[12] = t10; - } else { - t10 = $[12]; - } - let t11; - if ($[13] !== onUserResponse) { - t11 = () => { - onUserResponse({ - allow: false, - persistToSettings: false - }); - }; - $[13] = onUserResponse; - $[14] = t11; - } else { - t11 = $[14]; - } - let t12; - if ($[15] !== onSelect || $[16] !== options || $[17] !== t11) { - t12 = { + if (process.env.USER_TYPE === 'ant') { + logEvent('tengu_sandbox_network_dialog_result', { + host: host as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + result: + 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + } + onUserResponse({ allow: false, persistToSettings: false }) + }} + /> + + + + ) } diff --git a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx index 999a63f53..209cd08f4 100644 --- a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx +++ b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx @@ -1,229 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename, relative } from 'path'; -import React, { Suspense, use, useMemo } from 'react'; -import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { isENOENT } from 'src/utils/errors.js'; -import { detectEncodingForResolvedPath } from 'src/utils/fileRead.js'; -import { getFsImplementation } from 'src/utils/fsOperations.js'; -import { Text } from '../../../ink.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import { applySedSubstitution, type SedEditInfo } from '../../../tools/BashTool/sedEditParser.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { basename, relative } from 'path' +import React, { Suspense, use, useMemo } from 'react' +import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js' +import { getCwd } from 'src/utils/cwd.js' +import { isENOENT } from 'src/utils/errors.js' +import { detectEncodingForResolvedPath } from 'src/utils/fileRead.js' +import { getFsImplementation } from 'src/utils/fsOperations.js' +import { Text } from '../../../ink.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { + applySedSubstitution, + type SedEditInfo, +} from '../../../tools/BashTool/sedEditParser.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' + type SedEditPermissionRequestProps = PermissionRequestProps & { - sedInfo: SedEditInfo; -}; -type FileReadResult = { - oldContent: string; - fileExists: boolean; -}; -export function SedEditPermissionRequest(t0) { - const $ = _c(9); - let props; - let sedInfo; - if ($[0] !== t0) { - ({ - sedInfo, - ...props - } = t0); - $[0] = t0; - $[1] = props; - $[2] = sedInfo; - } else { - props = $[1]; - sedInfo = $[2]; - } - const { - filePath - } = sedInfo; - let t1; - if ($[3] !== filePath) { - t1 = (async () => { - const encoding = detectEncodingForResolvedPath(filePath); - const raw = await getFsImplementation().readFile(filePath, { - encoding - }); - return { - oldContent: raw.replaceAll("\r\n", "\n"), - fileExists: true - }; - })().catch(_temp); - $[3] = filePath; - $[4] = t1; - } else { - t1 = $[4]; - } - const contentPromise = t1; - let t2; - if ($[5] !== contentPromise || $[6] !== props || $[7] !== sedInfo) { - t2 = ; - $[5] = contentPromise; - $[6] = props; - $[7] = sedInfo; - $[8] = t2; - } else { - t2 = $[8]; - } - return t2; + sedInfo: SedEditInfo } -function _temp(e) { - if (!isENOENT(e)) { - throw e; - } - return { - oldContent: "", - fileExists: false - }; + +type FileReadResult = { oldContent: string; fileExists: boolean } + +export function SedEditPermissionRequest({ + sedInfo, + ...props +}: SedEditPermissionRequestProps): React.ReactNode { + const { filePath } = sedInfo + + // Read file content async so mount doesn't block React commit on disk I/O. + // Large files would otherwise hang the dialog before it renders. + // Memoized on filePath so we don't re-read on every render. + const contentPromise = useMemo( + () => + (async (): Promise => { + // Detect encoding first (sync 4KB read — negligible) so UTF-16LE BOMs + // render correctly. This matches what readFileSync did before the + // async conversion. + const encoding = detectEncodingForResolvedPath(filePath) + const raw = await getFsImplementation().readFile(filePath, { encoding }) + return { + oldContent: raw.replaceAll('\r\n', '\n'), + fileExists: true, + } + })().catch((e: unknown): FileReadResult => { + if (!isENOENT(e)) throw e + return { oldContent: '', fileExists: false } + }), + [filePath], + ) + + return ( + + + + ) } -function SedEditPermissionRequestInner(t0) { - const $ = _c(35); - let contentPromise; - let props; - let sedInfo; - if ($[0] !== t0) { - ({ - sedInfo, - contentPromise, - ...props - } = t0); - $[0] = t0; - $[1] = contentPromise; - $[2] = props; - $[3] = sedInfo; - } else { - contentPromise = $[1]; - props = $[2]; - sedInfo = $[3]; - } - const { - filePath - } = sedInfo; - const { - oldContent, - fileExists - } = use(contentPromise) as any; - let t1; - if ($[4] !== oldContent || $[5] !== sedInfo) { - t1 = applySedSubstitution(oldContent, sedInfo); - $[4] = oldContent; - $[5] = sedInfo; - $[6] = t1; - } else { - t1 = $[6]; - } - const newContent = t1; - let t2; - bb0: { + +function SedEditPermissionRequestInner({ + sedInfo, + contentPromise, + ...props +}: SedEditPermissionRequestProps & { + contentPromise: Promise +}): React.ReactNode { + const { filePath } = sedInfo + const { oldContent, fileExists } = use(contentPromise) + + // Compute the new content by applying the sed substitution + const newContent = useMemo(() => { + return applySedSubstitution(oldContent, sedInfo) + }, [oldContent, sedInfo]) + + // Create the edit representation for the diff + const edits = useMemo(() => { if (oldContent === newContent) { - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = []; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = t3; - break bb0; + return [] } - let t3; - if ($[8] !== newContent || $[9] !== oldContent) { - t3 = [{ + return [ + { old_string: oldContent, new_string: newContent, - replace_all: false - }]; - $[8] = newContent; - $[9] = oldContent; - $[10] = t3; - } else { - t3 = $[10]; - } - t2 = t3; - } - const edits = t2; - let t3; - bb1: { + replace_all: false, + }, + ] + }, [oldContent, newContent]) + + // Determine appropriate message when no changes + const noChangesMessage = useMemo(() => { if (!fileExists) { - t3 = "File does not exist"; - break bb1; + return 'File does not exist' + } + return 'Pattern did not match any content' + }, [fileExists]) + + // Parse input and add _simulatedSedEdit to ensure what user previewed + // is exactly what gets written (prevents sed/JS regex differences) + const parseInput = (input: unknown) => { + const parsed = BashTool.inputSchema.parse(input) + return { + ...parsed, + _simulatedSedEdit: { + filePath, + newContent, + }, } - t3 = "Pattern did not match any content"; - } - const noChangesMessage = t3; - let t4; - if ($[11] !== filePath || $[12] !== newContent) { - t4 = input => { - const parsed = BashTool.inputSchema.parse(input); - return { - ...parsed, - _simulatedSedEdit: { - filePath, - newContent - } - }; - }; - $[11] = filePath; - $[12] = newContent; - $[13] = t4; - } else { - t4 = $[13]; - } - const parseInput = t4; - const t5 = props.toolUseConfirm; - const t6 = props.toolUseContext; - const t7 = props.onDone; - const t8 = props.onReject; - let t9; - if ($[14] !== filePath) { - t9 = relative(getCwd(), filePath); - $[14] = filePath; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== filePath) { - t10 = basename(filePath); - $[16] = filePath; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== t10) { - t11 = Do you want to make this edit to{" "}{t10}?; - $[18] = t10; - $[19] = t11; - } else { - t11 = $[19]; - } - let t12; - if ($[20] !== edits || $[21] !== filePath || $[22] !== noChangesMessage) { - t12 = edits.length > 0 ? : {noChangesMessage}; - $[20] = edits; - $[21] = filePath; - $[22] = noChangesMessage; - $[23] = t12; - } else { - t12 = $[23]; - } - let t13; - if ($[24] !== filePath || $[25] !== parseInput || $[26] !== props.onDone || $[27] !== props.onReject || $[28] !== props.toolUseConfirm || $[29] !== props.toolUseContext || $[30] !== props.workerBadge || $[31] !== t11 || $[32] !== t12 || $[33] !== t9) { - t13 = ; - $[24] = filePath; - $[25] = parseInput; - $[26] = props.onDone; - $[27] = props.onReject; - $[28] = props.toolUseConfirm; - $[29] = props.toolUseContext; - $[30] = props.workerBadge; - $[31] = t11; - $[32] = t12; - $[33] = t9; - $[34] = t13; - } else { - t13 = $[34]; } - return t13; + + return ( + + Do you want to make this edit to{' '} + {basename(filePath)}? + + } + content={ + edits.length > 0 ? ( + + ) : ( + {noChangesMessage} + ) + } + path={filePath} + completionType="str_replace_single" + parseInput={parseInput} + workerBadge={props.workerBadge} + /> + ) } diff --git a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx index ef66df877..799c88705 100644 --- a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx +++ b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx @@ -1,368 +1,253 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useMemo } from 'react'; -import { logError } from 'src/utils/log.js'; -import { getOriginalCwd } from '../../../bootstrap/state.js'; -import { Box, Text } from '../../../ink.js'; -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; -import { SKILL_TOOL_NAME } from '../../../tools/SkillTool/constants.js'; -import { SkillTool } from '../../../tools/SkillTool/SkillTool.js'; -import { env } from '../../../utils/env.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import { logUnaryEvent } from '../../../utils/unaryLogging.js'; -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import { PermissionPrompt, type PermissionPromptOption, type ToolAnalyticsContext } from '../PermissionPrompt.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -type SkillOptionValue = 'yes' | 'yes-exact' | 'yes-prefix' | 'no'; -export function SkillPermissionRequest(props) { - const $ = _c(51); +import React, { useCallback, useMemo } from 'react' +import { logError } from 'src/utils/log.js' +import { getOriginalCwd } from '../../../bootstrap/state.js' +import { Box, Text } from '../../../ink.js' +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' +import { SKILL_TOOL_NAME } from '../../../tools/SkillTool/constants.js' +import { SkillTool } from '../../../tools/SkillTool/SkillTool.js' +import { env } from '../../../utils/env.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import { logUnaryEvent } from '../../../utils/unaryLogging.js' +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import { + PermissionPrompt, + type PermissionPromptOption, + type ToolAnalyticsContext, +} from '../PermissionPrompt.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' + +type SkillOptionValue = 'yes' | 'yes-exact' | 'yes-prefix' | 'no' + +export function SkillPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { const { toolUseConfirm, onDone, onReject, - workerBadge - } = props; - const parseInput = _temp; - let t0; - if ($[0] !== toolUseConfirm.input) { - t0 = parseInput(toolUseConfirm.input); - $[0] = toolUseConfirm.input; - $[1] = t0; - } else { - t0 = $[1]; - } - const skill = t0; - const commandObj = toolUseConfirm.permissionResult.behavior === "ask" && toolUseConfirm.permissionResult.metadata && "command" in toolUseConfirm.permissionResult.metadata ? toolUseConfirm.permissionResult.metadata.command : undefined; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[2] = t1; - } else { - t1 = $[2]; - } - const unaryEvent = t1; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getOriginalCwd(); - $[3] = t2; - } else { - t2 = $[3]; - } - const originalCwd = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldShowAlwaysAllowOptions(); - $[4] = t3; - } else { - t3 = $[4]; - } - const showAlwaysAllowOptions = t3; - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Yes", - value: "yes", - feedbackConfig: { - type: "accept" - } - }]; - $[5] = t4; - } else { - t4 = $[5]; - } - const baseOptions = t4; - let alwaysAllowOptions; - if ($[6] !== skill) { - alwaysAllowOptions = []; + verbose: _verbose, + workerBadge, + } = props + const parseInput = (input: unknown): string => { + const result = SkillTool.inputSchema.safeParse(input) + if (!result.success) { + logError( + new Error(`Failed to parse skill tool input: ${result.error.message}`), + ) + return '' + } + return result.data.skill + } + + const skill = parseInput(toolUseConfirm.input) + + // Check if this is a command using metadata from checkPermissions + const commandObj = + toolUseConfirm.permissionResult.behavior === 'ask' && + toolUseConfirm.permissionResult.metadata && + 'command' in toolUseConfirm.permissionResult.metadata + ? toolUseConfirm.permissionResult.metadata.command + : undefined + + const unaryEvent = useMemo( + () => ({ + completion_type: 'tool_use_single', + language_name: 'none', + }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const originalCwd = getOriginalCwd() + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): PermissionPromptOption[] => { + const baseOptions: PermissionPromptOption[] = [ + { + label: 'Yes', + value: 'yes', + feedbackConfig: { type: 'accept' }, + }, + ] + + // Only add "always allow" options when not restricted by allowManagedPermissionRulesOnly + const alwaysAllowOptions: PermissionPromptOption[] = [] if (showAlwaysAllowOptions) { - const t5 = {skill}; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {originalCwd}; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== t5) { - t7 = { - label: Yes, and don't ask again for {t5} in{" "}{t6}, - value: "yes-exact" - }; - $[9] = t5; - $[10] = t7; - } else { - t7 = $[10]; - } - alwaysAllowOptions.push(t7); - const spaceIndex = skill.indexOf(" "); + // Add exact match option + alwaysAllowOptions.push({ + label: ( + + Yes, and don't ask again for {skill} in{' '} + {originalCwd} + + ), + value: 'yes-exact', + }) + + // Add prefix option if the skill has arguments + const spaceIndex = skill.indexOf(' ') if (spaceIndex > 0) { - const commandPrefix = skill.substring(0, spaceIndex); - const t8 = commandPrefix + ":*"; - let t9; - if ($[11] !== t8) { - t9 = {t8}; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {originalCwd}; - $[13] = t10; - } else { - t10 = $[13]; - } - let t11; - if ($[14] !== t9) { - t11 = { - label: Yes, and don't ask again for{" "}{t9} commands in{" "}{t10}, - value: "yes-prefix" - }; - $[14] = t9; - $[15] = t11; - } else { - t11 = $[15]; - } - alwaysAllowOptions.push(t11); + const commandPrefix = skill.substring(0, spaceIndex) + alwaysAllowOptions.push({ + label: ( + + Yes, and don't ask again for{' '} + {commandPrefix + ':*'} commands in{' '} + {originalCwd} + + ), + value: 'yes-prefix', + }) } } - $[6] = skill; - $[7] = alwaysAllowOptions; - } else { - alwaysAllowOptions = $[7]; - } - let t5; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "No", - value: "no", - feedbackConfig: { - type: "reject" - } - }; - $[16] = t5; - } else { - t5 = $[16]; - } - const noOption = t5; - let t6; - if ($[17] !== alwaysAllowOptions) { - t6 = [...baseOptions, ...alwaysAllowOptions, noOption]; - $[17] = alwaysAllowOptions; - $[18] = t6; - } else { - t6 = $[18]; - } - const options = t6; - let t7; - if ($[19] !== toolUseConfirm.tool.name) { - t7 = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name); - $[19] = toolUseConfirm.tool.name; - $[20] = t7; - } else { - t7 = $[20]; - } - const t8 = toolUseConfirm.tool.isMcp ?? false; - let t9; - if ($[21] !== t7 || $[22] !== t8) { - t9 = { - toolName: t7, - isMcp: t8 - }; - $[21] = t7; - $[22] = t8; - $[23] = t9; - } else { - t9 = $[23]; - } - const toolAnalyticsContext = t9; - let t10; - if ($[24] !== onDone || $[25] !== onReject || $[26] !== skill || $[27] !== toolUseConfirm) { - t10 = (value, feedback) => { - bb33: switch (value) { - case "yes": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); - onDone(); - break bb33; - } - case "yes-exact": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: SKILL_TOOL_NAME, - ruleContent: skill - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb33; - } - case "yes-prefix": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - const spaceIndex_0 = skill.indexOf(" "); - const commandPrefix_0 = spaceIndex_0 > 0 ? skill.substring(0, spaceIndex_0) : skill; - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: SKILL_TOOL_NAME, - ruleContent: `${commandPrefix_0}:*` - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb33; - } - case "no": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onReject(feedback); - onReject(); - onDone(); - } - } - }; - $[24] = onDone; - $[25] = onReject; - $[26] = skill; - $[27] = toolUseConfirm; - $[28] = t10; - } else { - t10 = $[28]; - } - const handleSelect = t10; - let t11; - if ($[29] !== onDone || $[30] !== onReject || $[31] !== toolUseConfirm) { - t11 = () => { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform + + const noOption: PermissionPromptOption = { + label: 'No', + value: 'no', + feedbackConfig: { type: 'reject' }, + } + + return [...baseOptions, ...alwaysAllowOptions, noOption] + }, [skill, originalCwd, showAlwaysAllowOptions]) + + const toolAnalyticsContext = useMemo( + (): ToolAnalyticsContext => ({ + toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name), + isMcp: toolUseConfirm.tool.isMcp ?? false, + }), + [toolUseConfirm.tool.name, toolUseConfirm.tool.isMcp], + ) + + const handleSelect = useCallback( + (value: SkillOptionValue, feedback?: string) => { + switch (value) { + case 'yes': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) + onDone() + break + case 'yes-exact': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: SKILL_TOOL_NAME, + ruleContent: skill, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }); - toolUseConfirm.onReject(); - onReject(); - onDone(); - }; - $[29] = onDone; - $[30] = onReject; - $[31] = toolUseConfirm; - $[32] = t11; - } else { - t11 = $[32]; - } - const handleCancel = t11; - const t12 = `Use skill "${skill}"?`; - let t13; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Claude may use instructions, code, or files from this Skill.; - $[33] = t13; - } else { - t13 = $[33]; - } - const t14 = commandObj?.description; - let t15; - if ($[34] !== t14) { - t15 = {t14}; - $[34] = t14; - $[35] = t15; - } else { - t15 = $[35]; - } - let t16; - if ($[36] !== toolUseConfirm.permissionResult) { - t16 = ; - $[36] = toolUseConfirm.permissionResult; - $[37] = t16; - } else { - t16 = $[37]; - } - let t17; - if ($[38] !== handleCancel || $[39] !== handleSelect || $[40] !== options || $[41] !== toolAnalyticsContext) { - t17 = ; - $[38] = handleCancel; - $[39] = handleSelect; - $[40] = options; - $[41] = toolAnalyticsContext; - $[42] = t17; - } else { - t17 = $[42]; - } - let t18; - if ($[43] !== t16 || $[44] !== t17) { - t18 = {t16}{t17}; - $[43] = t16; - $[44] = t17; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] !== t12 || $[47] !== t15 || $[48] !== t18 || $[49] !== workerBadge) { - t19 = {t13}{t15}{t18}; - $[46] = t12; - $[47] = t15; - $[48] = t18; - $[49] = workerBadge; - $[50] = t19; - } else { - t19 = $[50]; - } - return t19; -} -function _temp(input) { - const result = SkillTool.inputSchema.safeParse(input); - if (!result.success) { - logError(new Error(`Failed to parse skill tool input: ${result.error.message}`)); - return ""; - } - return result.data.skill; + case 'yes-prefix': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + + // Extract the skill prefix (everything before the first space) + const spaceIndex = skill.indexOf(' ') + const commandPrefix = + spaceIndex > 0 ? skill.substring(0, spaceIndex) : skill + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: SKILL_TOOL_NAME, + ruleContent: `${commandPrefix}:*`, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break + } + case 'no': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject(feedback) + onReject() + onDone() + break + } + }, + [toolUseConfirm, onDone, onReject, skill], + ) + + const handleCancel = useCallback(() => { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onReject() + onDone() + }, [toolUseConfirm, onDone, onReject]) + + return ( + + Claude may use instructions, code, or files from this Skill. + + {commandObj?.description} + + + + + + + + ) } diff --git a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx index 2a93fd5c9..da2498885 100644 --- a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx +++ b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx @@ -1,257 +1,148 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import { type OptionWithDescription, Select } from '../../CustomSelect/select.js'; -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -import { logUnaryPermissionEvent } from '../utils.js'; -function inputToPermissionRuleContent(input: { - [k: string]: unknown; -}): string { +import React, { useMemo } from 'react' +import { Box, Text, useTheme } from '../../../ink.js' +import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import { + type OptionWithDescription, + Select, +} from '../../CustomSelect/select.js' +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { logUnaryPermissionEvent } from '../utils.js' + +function inputToPermissionRuleContent(input: { [k: string]: unknown }): string { try { - const parsedInput = WebFetchTool.inputSchema.safeParse(input); + const parsedInput = WebFetchTool.inputSchema.safeParse(input) if (!parsedInput.success) { - return `input:${input.toString()}`; + return `input:${input.toString()}` } - const { - url - } = parsedInput.data; - const hostname = new URL(url).hostname; - return `domain:${hostname}`; + const { url } = parsedInput.data + const hostname = new URL(url).hostname + return `domain:${hostname}` } catch { - return `input:${input.toString()}`; + return `input:${input.toString()}` } } -export function WebFetchPermissionRequest(t0) { - const $ = _c(41); - const { - toolUseConfirm, - onDone, - onReject, - verbose, - workerBadge - } = t0; - const [theme] = useTheme(); - const { - url - } = toolUseConfirm.input as { - url: string; - }; - let t1; - if ($[0] !== url) { - t1 = new URL(url); - $[0] = url; - $[1] = t1; - } else { - t1 = $[1]; - } - const hostname = t1.hostname; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - const unaryEvent = t2; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldShowAlwaysAllowOptions(); - $[3] = t3; - } else { - t3 = $[3]; - } - const showAlwaysAllowOptions = t3; - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Yes", - value: "yes" - }; - $[4] = t4; - } else { - t4 = $[4]; - } - let result; - if ($[5] !== hostname) { - result = [t4]; + +export function WebFetchPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + verbose, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [theme] = useTheme() + // url is already validated by the input schema + const { url } = toolUseConfirm.input as { url: string } + + // Extract hostname from URL + const hostname = new URL(url).hostname + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + // Generate permission options specific to domains + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): OptionWithDescription[] => { + const result: OptionWithDescription[] = [ + { + label: 'Yes', + value: 'yes', + }, + ] + if (showAlwaysAllowOptions) { - const t5 = {hostname}; - let t6; - if ($[7] !== t5) { - t6 = { - label: Yes, and don't ask again for {t5}, - value: "yes-dont-ask-again-domain" - }; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - result.push(t6); + result.push({ + label: ( + + Yes, and don't ask again for {hostname} + + ), + value: 'yes-dont-ask-again-domain', + }) } - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: No, and tell Claude what to do differently (esc), - value: "no" - }; - $[9] = t5; - } else { - t5 = $[9]; - } - result.push(t5); - $[5] = hostname; - $[6] = result; - } else { - result = $[6]; - } - const options = result; - let t5; - if ($[10] !== onDone || $[11] !== onReject || $[12] !== toolUseConfirm) { - t5 = function onChange(newValue) { - bb8: switch (newValue) { - case "yes": - { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "accept"); - toolUseConfirm.onAllow(toolUseConfirm.input, []); - onDone(); - break bb8; - } - case "yes-dont-ask-again-domain": + + result.push({ + label: ( + + No, and tell Claude what to do differently (esc) + + ), + value: 'no', + }) + + return result + }, [hostname, showAlwaysAllowOptions]) + + function onChange(newValue: string) { + switch (newValue) { + case 'yes': + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + toolUseConfirm.onAllow(toolUseConfirm.input, []) + onDone() + break + case 'yes-dont-ask-again-domain': { + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + const ruleContent = inputToPermissionRuleContent(toolUseConfirm.input) + const ruleValue = { + toolName: toolUseConfirm.tool.name, + ruleContent, + } + + // Pass permission update directly to onAllow + toolUseConfirm.onAllow(toolUseConfirm.input, [ { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "accept"); - const ruleContent = inputToPermissionRuleContent(toolUseConfirm.input); - const ruleValue = { - toolName: toolUseConfirm.tool.name, - ruleContent - }; - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [ruleValue], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb8; - } - case "no": - { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "reject"); - toolUseConfirm.onReject(); - onReject(); - onDone(); - } + type: 'addRules', + rules: [ruleValue], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }; - $[10] = onDone; - $[11] = onReject; - $[12] = toolUseConfirm; - $[13] = t5; - } else { - t5 = $[13]; - } - const onChange = t5; - let t6; - if ($[14] !== theme || $[15] !== toolUseConfirm.input || $[16] !== verbose) { - t6 = WebFetchTool.renderToolUseMessage(toolUseConfirm.input as { - url: string; - prompt: string; - }, { - theme, - verbose - }); - $[14] = theme; - $[15] = toolUseConfirm.input; - $[16] = verbose; - $[17] = t6; - } else { - t6 = $[17]; - } - let t7; - if ($[18] !== t6) { - t7 = {t6}; - $[18] = t6; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] !== toolUseConfirm.description) { - t8 = {toolUseConfirm.description}; - $[20] = toolUseConfirm.description; - $[21] = t8; - } else { - t8 = $[21]; - } - let t9; - if ($[22] !== t7 || $[23] !== t8) { - t9 = {t7}{t8}; - $[22] = t7; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - let t10; - if ($[25] !== toolUseConfirm.permissionResult) { - t10 = ; - $[25] = toolUseConfirm.permissionResult; - $[26] = t10; - } else { - t10 = $[26]; - } - let t11; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Do you want to allow Claude to fetch this content?; - $[27] = t11; - } else { - t11 = $[27]; - } - let t12; - if ($[28] !== onChange) { - t12 = () => onChange("no"); - $[28] = onChange; - $[29] = t12; - } else { - t12 = $[29]; - } - let t13; - if ($[30] !== onChange || $[31] !== options || $[32] !== t12) { - t13 = onChange('no')} + /> + + + ) } diff --git a/src/components/permissions/WorkerBadge.tsx b/src/components/permissions/WorkerBadge.tsx index bc6bb357f..61d5873ab 100644 --- a/src/components/permissions/WorkerBadge.tsx +++ b/src/components/permissions/WorkerBadge.tsx @@ -1,48 +1,27 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; -import { toInkColor } from '../../utils/ink.js'; +import * as React from 'react' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' +import { toInkColor } from '../../utils/ink.js' + export type WorkerBadgeProps = { - name: string; - color: string; -}; + name: string + color: string +} /** * Renders a colored badge showing the worker's name for permission prompts. * Used to indicate which swarm worker is requesting the permission. */ -export function WorkerBadge(t0) { - const $ = _c(7); - const { - name, - color - } = t0; - let t1; - if ($[0] !== color) { - t1 = toInkColor(color); - $[0] = color; - $[1] = t1; - } else { - t1 = $[1]; - } - const inkColor = t1; - let t2; - if ($[2] !== name) { - t2 = @{name}; - $[2] = name; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== inkColor || $[5] !== t2) { - t3 = {BLACK_CIRCLE} {t2}; - $[4] = inkColor; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; +export function WorkerBadge({ + name, + color, +}: WorkerBadgeProps): React.ReactNode { + const inkColor = toInkColor(color) + return ( + + + {BLACK_CIRCLE} @{name} + + + ) } diff --git a/src/components/permissions/WorkerPendingPermission.tsx b/src/components/permissions/WorkerPendingPermission.tsx index 7caad36c2..06aab0334 100644 --- a/src/components/permissions/WorkerPendingPermission.tsx +++ b/src/components/permissions/WorkerPendingPermission.tsx @@ -1,104 +1,70 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getAgentName, getTeammateColor, getTeamName } from '../../utils/teammate.js'; -import { Spinner } from '../Spinner.js'; -import { WorkerBadge } from './WorkerBadge.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + getAgentName, + getTeammateColor, + getTeamName, +} from '../../utils/teammate.js' +import { Spinner } from '../Spinner.js' +import { WorkerBadge } from './WorkerBadge.js' + type Props = { - toolName: string; - description: string; -}; + toolName: string + description: string +} /** * Visual indicator shown on workers while waiting for leader to approve a permission request. * Displays the pending tool with a spinner and information about what's being requested. */ -export function WorkerPendingPermission(t0) { - const $ = _c(15); - const { - toolName, - description - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTeamName(); - $[0] = t1; - } else { - t1 = $[0]; - } - const teamName = t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getAgentName(); - $[1] = t2; - } else { - t2 = $[1]; - } - const agentName = t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = getTeammateColor(); - $[2] = t3; - } else { - t3 = $[2]; - } - const agentColor = t3; - let t4; - let t5; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {" "}Waiting for team lead approval; - t5 = agentName && agentColor && ; - $[3] = t4; - $[4] = t5; - } else { - t4 = $[3]; - t5 = $[4]; - } - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Tool: ; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== toolName) { - t7 = {t6}{toolName}; - $[6] = toolName; - $[7] = t7; - } else { - t7 = $[7]; - } - let t8; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Action: ; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] !== description) { - t9 = {t8}{description}; - $[9] = description; - $[10] = t9; - } else { - t9 = $[10]; - } - let t10; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t10 = teamName && Permission request sent to team {"\""}{teamName}{"\""} leader; - $[11] = t10; - } else { - t10 = $[11]; - } - let t11; - if ($[12] !== t7 || $[13] !== t9) { - t11 = {t4}{t5}{t7}{t9}{t10}; - $[12] = t7; - $[13] = t9; - $[14] = t11; - } else { - t11 = $[14]; - } - return t11; +export function WorkerPendingPermission({ + toolName, + description, +}: Props): React.ReactNode { + const teamName = getTeamName() + const agentName = getAgentName() + const agentColor = getTeammateColor() + + return ( + + + + + {' '} + Waiting for team lead approval + + + + {agentName && agentColor && ( + + + + )} + + + Tool: + {toolName} + + + + Action: + {description} + + + {teamName && ( + + + Permission request sent to team {'"'} + {teamName} + {'"'} leader + + + )} + + ) } diff --git a/src/components/permissions/rules/AddPermissionRules.tsx b/src/components/permissions/rules/AddPermissionRules.tsx index 5de1bf288..6e48e1dcb 100644 --- a/src/components/permissions/rules/AddPermissionRules.tsx +++ b/src/components/permissions/rules/AddPermissionRules.tsx @@ -1,179 +1,165 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback } from 'react'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import type { PermissionBehavior, PermissionRule, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { applyPermissionUpdate, persistPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js'; -import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; -import { detectUnreachableRules, type UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js'; -import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js'; -import { type EditableSettingSource, SOURCES } from '../../../utils/settings/constants.js'; -import { getRelativeSettingsFilePathForSource } from '../../../utils/settings/settings.js'; -import { plural } from '../../../utils/stringUtils.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { Dialog } from '../../design-system/Dialog.js'; -import { PermissionRuleDescription } from './PermissionRuleDescription.js'; -export function optionForPermissionSaveDestination(saveDestination: EditableSettingSource): OptionWithDescription { +import * as React from 'react' +import { useCallback } from 'react' +import { Select } from '../../../components/CustomSelect/select.js' +import { Box, Text } from '../../../ink.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import type { + PermissionBehavior, + PermissionRule, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { + applyPermissionUpdate, + persistPermissionUpdate, +} from '../../../utils/permissions/PermissionUpdate.js' +import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js' +import { + detectUnreachableRules, + type UnreachableRule, +} from '../../../utils/permissions/shadowedRuleDetection.js' +import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js' +import { + type EditableSettingSource, + SOURCES, +} from '../../../utils/settings/constants.js' +import { getRelativeSettingsFilePathForSource } from '../../../utils/settings/settings.js' +import { plural } from '../../../utils/stringUtils.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { Dialog } from '../../design-system/Dialog.js' +import { PermissionRuleDescription } from './PermissionRuleDescription.js' + +export function optionForPermissionSaveDestination( + saveDestination: EditableSettingSource, +): OptionWithDescription { switch (saveDestination) { case 'localSettings': return { label: 'Project settings (local)', description: `Saved in ${getRelativeSettingsFilePathForSource('localSettings')}`, - value: saveDestination - }; + value: saveDestination, + } case 'projectSettings': return { label: 'Project settings', description: `Checked in at ${getRelativeSettingsFilePathForSource('projectSettings')}`, - value: saveDestination - }; + value: saveDestination, + } case 'userSettings': return { label: 'User settings', description: `Saved in at ~/.claude/settings.json`, - value: saveDestination - }; + value: saveDestination, + } } } + type Props = { - onAddRules: (rules: PermissionRule[], unreachable?: UnreachableRule[]) => void; - onCancel: () => void; - ruleValues: PermissionRuleValue[]; - ruleBehavior: PermissionBehavior; - initialContext: ToolPermissionContext; - setToolPermissionContext: (newContext: ToolPermissionContext) => void; -}; -export function AddPermissionRules(t0) { - const $ = _c(26); - const { - onAddRules, - onCancel, - ruleValues, - ruleBehavior, - initialContext, - setToolPermissionContext - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = SOURCES.map(optionForPermissionSaveDestination); - $[0] = t1; - } else { - t1 = $[0]; - } - const allOptions = t1; - let t2; - if ($[1] !== initialContext || $[2] !== onAddRules || $[3] !== onCancel || $[4] !== ruleBehavior || $[5] !== ruleValues || $[6] !== setToolPermissionContext) { - t2 = selectedValue => { - if (selectedValue === "cancel") { - onCancel(); - return; - } else { - if ((SOURCES as readonly string[]).includes(selectedValue)) { - const destination = selectedValue as EditableSettingSource; - const updatedContext = applyPermissionUpdate(initialContext, { - type: "addRules", - rules: ruleValues, - behavior: ruleBehavior, - destination - }); - persistPermissionUpdate({ - type: "addRules", - rules: ruleValues, - behavior: ruleBehavior, - destination - }); - setToolPermissionContext(updatedContext); - const rules = ruleValues.map(ruleValue => ({ - ruleValue, - ruleBehavior, - source: destination - })); - const sandboxAutoAllowEnabled = SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const allUnreachable = detectUnreachableRules(updatedContext, { - sandboxAutoAllowEnabled - }); - const newUnreachable = allUnreachable.filter(u => ruleValues.some(rv => rv.toolName === u.rule.ruleValue.toolName && rv.ruleContent === u.rule.ruleValue.ruleContent)); - onAddRules(rules, newUnreachable.length > 0 ? newUnreachable : undefined); - } - } - }; - $[1] = initialContext; - $[2] = onAddRules; - $[3] = onCancel; - $[4] = ruleBehavior; - $[5] = ruleValues; - $[6] = setToolPermissionContext; - $[7] = t2; - } else { - t2 = $[7]; - } - const onSelect = t2; - let t3; - if ($[8] !== ruleValues.length) { - t3 = plural(ruleValues.length, "rule"); - $[8] = ruleValues.length; - $[9] = t3; - } else { - t3 = $[9]; - } - const title = `Add ${ruleBehavior} permission ${t3}`; - let t4; - if ($[10] !== ruleValues) { - t4 = ruleValues.map(_temp); - $[10] = ruleValues; - $[11] = t4; - } else { - t4 = $[11]; - } - let t5; - if ($[12] !== t4) { - t5 = {t4}; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - const t6 = ruleValues.length === 1 ? "Where should this rule be saved?" : "Where should these rules be saved?"; - let t7; - if ($[14] !== t6) { - t7 = {t6}; - $[14] = t6; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== onSelect) { - t8 = + + + ) } diff --git a/src/components/permissions/rules/AddWorkspaceDirectory.tsx b/src/components/permissions/rules/AddWorkspaceDirectory.tsx index 3ff73d080..07d0a00ef 100644 --- a/src/components/permissions/rules/AddWorkspaceDirectory.tsx +++ b/src/components/permissions/rules/AddWorkspaceDirectory.tsx @@ -1,339 +1,292 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDebounceCallback } from 'usehooks-ts'; -import { addDirHelpMessage, validateDirectoryForWorkspace } from '../../../commands/add-dir/validation.js'; -import TextInput from '../../../components/TextInput.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { getDirectoryCompletions } from '../../../utils/suggestions/directoryCompletion.js'; -import { ConfigurableShortcutHint } from '../../ConfigurableShortcutHint.js'; -import { Select } from '../../CustomSelect/select.js'; -import { Byline } from '../../design-system/Byline.js'; -import { Dialog } from '../../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../../design-system/KeyboardShortcutHint.js'; -import { PromptInputFooterSuggestions, type SuggestionItem } from '../../PromptInput/PromptInputFooterSuggestions.js'; +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useDebounceCallback } from 'usehooks-ts' +import { + addDirHelpMessage, + validateDirectoryForWorkspace, +} from '../../../commands/add-dir/validation.js' +import TextInput from '../../../components/TextInput.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { getDirectoryCompletions } from '../../../utils/suggestions/directoryCompletion.js' +import { ConfigurableShortcutHint } from '../../ConfigurableShortcutHint.js' +import { Select } from '../../CustomSelect/select.js' +import { Byline } from '../../design-system/Byline.js' +import { Dialog } from '../../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../../design-system/KeyboardShortcutHint.js' +import { + PromptInputFooterSuggestions, + type SuggestionItem, +} from '../../PromptInput/PromptInputFooterSuggestions.js' + type Props = { - onAddDirectory: (path: string, remember?: boolean) => void; - onCancel: () => void; - permissionContext: ToolPermissionContext; - directoryPath?: string; // When directoryPath is provided, show selection options instead of input -}; -type RememberDirectoryOption = 'yes-session' | 'yes-remember' | 'no'; + onAddDirectory: (path: string, remember?: boolean) => void + onCancel: () => void + permissionContext: ToolPermissionContext + directoryPath?: string // When directoryPath is provided, show selection options instead of input +} + +type RememberDirectoryOption = 'yes-session' | 'yes-remember' | 'no' + const REMEMBER_DIRECTORY_OPTIONS: Array<{ - value: RememberDirectoryOption; - label: string; -}> = [{ - value: 'yes-session', - label: 'Yes, for this session' -}, { - value: 'yes-remember', - label: 'Yes, and remember this directory' -}, { - value: 'no', - label: 'No' -}]; -function PermissionDescription() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Claude Code will be able to read files in this directory and make edits when auto-accept edits is on.; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; + value: RememberDirectoryOption + label: string +}> = [ + { + value: 'yes-session', + label: 'Yes, for this session', + }, + { + value: 'yes-remember', + label: 'Yes, and remember this directory', + }, + { + value: 'no', + label: 'No', + }, +] + +function PermissionDescription(): React.ReactNode { + return ( + + Claude Code will be able to read files in this directory and make edits + when auto-accept edits is on. + + ) } -function DirectoryDisplay(t0) { - const $ = _c(5); - const { - path - } = t0; - let t1; - if ($[0] !== path) { - t1 = {path}; - $[0] = path; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t1) { - t3 = {t1}{t2}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + +function DirectoryDisplay({ path }: { path: string }): React.ReactNode { + return ( + + {path} + + + ) } -function DirectoryInput(t0) { - const $ = _c(14); - const { - value, - onChange, - onSubmit, - error, - suggestions, - selectedSuggestion - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Enter the path to the directory:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== onChange || $[2] !== onSubmit || $[3] !== value) { - t2 = ; - $[1] = onChange; - $[2] = onSubmit; - $[3] = value; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== selectedSuggestion || $[6] !== suggestions) { - t3 = suggestions.length > 0 && ; - $[5] = selectedSuggestion; - $[6] = suggestions; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== error) { - t4 = error && {error}; - $[8] = error; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== t2 || $[11] !== t3 || $[12] !== t4) { - t5 = {t1}{t2}{t3}{t4}; - $[10] = t2; - $[11] = t3; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - return t5; + +function DirectoryInput({ + value, + onChange, + onSubmit, + error, + suggestions, + selectedSuggestion, +}: { + value: string + onChange: (value: string) => void + onSubmit: (value: string) => void + error: string | null + suggestions: SuggestionItem[] + selectedSuggestion: number +}): React.ReactNode { + return ( + + Enter the path to the directory: + + {}} + /> + + {suggestions.length > 0 && ( + + + + )} + {error && {error}} + + ) } -function _temp() {} -export function AddWorkspaceDirectory(t0) { - const $ = _c(34); - const { - onAddDirectory, - onCancel, - permissionContext, - directoryPath - } = t0; - const [directoryInput, setDirectoryInput] = useState(""); - const [error, setError] = useState(null); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [suggestions, setSuggestions] = useState(t1); - const [selectedSuggestion, setSelectedSuggestion] = useState(0); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = async path => { - if (!path) { - setSuggestions([]); - setSelectedSuggestion(0); - return; - } - const completions = await getDirectoryCompletions(path); - setSuggestions(completions); - setSelectedSuggestion(0); - }; - $[1] = t2; - } else { - t2 = $[1]; - } - const fetchSuggestions = t2; - const debouncedFetchSuggestions = useDebounceCallback(fetchSuggestions, 100); - let t3; - let t4; - if ($[2] !== debouncedFetchSuggestions || $[3] !== directoryInput) { - t3 = () => { - debouncedFetchSuggestions(directoryInput); - }; - t4 = [directoryInput, debouncedFetchSuggestions]; - $[2] = debouncedFetchSuggestions; - $[3] = directoryInput; - $[4] = t3; - $[5] = t4; - } else { - t3 = $[4]; - t4 = $[5]; - } - useEffect(t3, t4); - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = suggestion => { - const newPath = suggestion.id + "/"; - setDirectoryInput(newPath); - setError(null); - }; - $[6] = t5; - } else { - t5 = $[6]; - } - const applySuggestion = t5; - let t6; - if ($[7] !== onAddDirectory || $[8] !== permissionContext) { - t6 = async newPath_0 => { - const result = await validateDirectoryForWorkspace(newPath_0, permissionContext); - if (result.resultType === "success") { - onAddDirectory(result.absolutePath, false); + +export function AddWorkspaceDirectory({ + onAddDirectory, + onCancel, + permissionContext, + directoryPath, +}: Props): React.ReactNode { + const [directoryInput, setDirectoryInput] = useState('') + const [error, setError] = useState(null) + const [suggestions, setSuggestions] = useState([]) + const [selectedSuggestion, setSelectedSuggestion] = useState(0) + const options = useMemo(() => REMEMBER_DIRECTORY_OPTIONS, []) + + // Fetch directory completions + const fetchSuggestions = useCallback(async (path: string) => { + if (!path) { + setSuggestions([]) + setSelectedSuggestion(0) + return + } + const completions = await getDirectoryCompletions(path) + setSuggestions(completions) + setSelectedSuggestion(0) + }, []) + + const debouncedFetchSuggestions = useDebounceCallback(fetchSuggestions, 100) + + useEffect(() => { + void debouncedFetchSuggestions(directoryInput) + }, [directoryInput, debouncedFetchSuggestions]) + + const applySuggestion = useCallback((suggestion: SuggestionItem) => { + const newPath = suggestion.id + '/' + setDirectoryInput(newPath) + setError(null) + // Suggestions will update via the useEffect + }, []) + + // Handle directory submission from input + const handleSubmit = useCallback( + async (newPath: string) => { + const result = await validateDirectoryForWorkspace( + newPath, + permissionContext, + ) + + if (result.resultType === 'success') { + onAddDirectory(result.absolutePath, false) } else { - setError(addDirHelpMessage(result)); + setError(addDirHelpMessage(result)) } - }; - $[7] = onAddDirectory; - $[8] = permissionContext; - $[9] = t6; - } else { - t6 = $[9]; - } - const handleSubmit = t6; - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - context: "Settings" - }; - $[10] = t7; - } else { - t7 = $[10]; - } - useKeybinding("confirm:no", onCancel, t7); - let t8; - if ($[11] !== handleSubmit || $[12] !== selectedSuggestion || $[13] !== suggestions) { - t8 = e => { + }, + [permissionContext, onAddDirectory], + ) + + // Handle Esc to cancel (Ctrl+C handled by global keybindings) + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { if (suggestions.length > 0) { - if (e.key === "tab") { - e.preventDefault(); - const suggestion_0 = suggestions[selectedSuggestion]; - if (suggestion_0) { - applySuggestion(suggestion_0); + // Tab: accept selected suggestion and continue (for drilling into subdirs) + if (e.key === 'tab') { + e.preventDefault() + const suggestion = suggestions[selectedSuggestion] + if (suggestion) { + applySuggestion(suggestion) } - return; + return } - if (e.key === "return") { - e.preventDefault(); - const suggestion_1 = suggestions[selectedSuggestion]; - if (suggestion_1) { - handleSubmit(suggestion_1.id + "/"); + + // Enter: apply selected suggestion and submit + if (e.key === 'return') { + e.preventDefault() + const suggestion = suggestions[selectedSuggestion] + if (suggestion) { + void handleSubmit(suggestion.id + '/') } - return; + return } - if (e.key === "up" || e.ctrl && e.key === "p") { - e.preventDefault(); - setSelectedSuggestion(prev => prev <= 0 ? suggestions.length - 1 : prev - 1); - return; + + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + setSelectedSuggestion(prev => + prev <= 0 ? suggestions.length - 1 : prev - 1, + ) + return } - if (e.key === "down" || e.ctrl && e.key === "n") { - e.preventDefault(); - setSelectedSuggestion(prev_0 => prev_0 >= suggestions.length - 1 ? 0 : prev_0 + 1); - return; + + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + setSelectedSuggestion(prev => + prev >= suggestions.length - 1 ? 0 : prev + 1, + ) + return } } - }; - $[11] = handleSubmit; - $[12] = selectedSuggestion; - $[13] = suggestions; - $[14] = t8; - } else { - t8 = $[14]; - } - const handleKeyDown = t8; - let t9; - if ($[15] !== directoryPath || $[16] !== onAddDirectory || $[17] !== onCancel) { - t9 = value => { - if (!directoryPath) { - return; - } - const selectionValue = value as RememberDirectoryOption; - bb64: switch (selectionValue) { - case "yes-session": - { - onAddDirectory(directoryPath, false); - break bb64; - } - case "yes-remember": - { - onAddDirectory(directoryPath, true); - break bb64; - } - case "no": - { - onCancel(); - } + }, + [suggestions, selectedSuggestion, applySuggestion, handleSubmit], + ) + + const handleSelect = useCallback( + (value: string) => { + if (!directoryPath) return + + const selectionValue = value as RememberDirectoryOption + + switch (selectionValue) { + case 'yes-session': + onAddDirectory(directoryPath, false) + break + case 'yes-remember': + onAddDirectory(directoryPath, true) + break + case 'no': + onCancel() + break } - }; - $[15] = directoryPath; - $[16] = onAddDirectory; - $[17] = onCancel; - $[18] = t9; - } else { - t9 = $[18]; - } - const handleSelect = t9; - const t10 = directoryPath ? undefined : _temp2; - let t11; - if ($[19] !== directoryInput || $[20] !== directoryPath || $[21] !== error || $[22] !== handleSelect || $[23] !== handleSubmit || $[24] !== selectedSuggestion || $[25] !== suggestions) { - t11 = directoryPath ? handleSelect('no')} + /> + + ) : ( + + + + + )} + + + ) } diff --git a/src/components/permissions/rules/PermissionRuleDescription.tsx b/src/components/permissions/rules/PermissionRuleDescription.tsx index 57cb34279..ac8f0cd23 100644 --- a/src/components/permissions/rules/PermissionRuleDescription.tsx +++ b/src/components/permissions/rules/PermissionRuleDescription.tsx @@ -1,75 +1,46 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../../../ink.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import type { PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; +import * as React from 'react' +import { Text } from '../../../ink.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import type { PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js' + type RuleSubtitleProps = { - ruleValue: PermissionRuleValue; -}; -export function PermissionRuleDescription(t0) { - const $ = _c(9); - const { - ruleValue - } = t0; + ruleValue: PermissionRuleValue +} + +export function PermissionRuleDescription({ + ruleValue, +}: RuleSubtitleProps): React.ReactNode { switch (ruleValue.toolName) { - case BashTool.name: - { - if (ruleValue.ruleContent) { - if (ruleValue.ruleContent.endsWith(":*")) { - let t1; - if ($[0] !== ruleValue.ruleContent) { - t1 = ruleValue.ruleContent.slice(0, -2); - $[0] = ruleValue.ruleContent; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = Any Bash command starting with{" "}{t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; - } else { - let t1; - if ($[4] !== ruleValue.ruleContent) { - t1 = The Bash command {ruleValue.ruleContent}; - $[4] = ruleValue.ruleContent; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } + case BashTool.name: { + if (ruleValue.ruleContent) { + if (ruleValue.ruleContent.endsWith(':*')) { + return ( + + Any Bash command starting with{' '} + {ruleValue.ruleContent.slice(0, -2)} + + ) } else { - let t1; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Any Bash command; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + return ( + + The Bash command {ruleValue.ruleContent} + + ) } + } else { + return Any Bash command } - default: - { - if (!ruleValue.ruleContent) { - let t1; - if ($[7] !== ruleValue.toolName) { - t1 = Any use of the {ruleValue.toolName} tool; - $[7] = ruleValue.toolName; - $[8] = t1; - } else { - t1 = $[8]; - } - return t1; - } else { - return null; - } + } + default: { + if (!ruleValue.ruleContent) { + return ( + + Any use of the {ruleValue.toolName} tool + + ) + } else { + return null } + } } } diff --git a/src/components/permissions/rules/PermissionRuleInput.tsx b/src/components/permissions/rules/PermissionRuleInput.tsx index dbfca58c2..36dfb6b63 100644 --- a/src/components/permissions/rules/PermissionRuleInput.tsx +++ b/src/components/permissions/rules/PermissionRuleInput.tsx @@ -1,137 +1,107 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useState } from 'react'; -import TextInput from '../../../components/TextInput.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { Box, Newline, Text } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js'; -import type { PermissionBehavior, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { permissionRuleValueFromString, permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; +import figures from 'figures' +import * as React from 'react' +import { useState } from 'react' +import TextInput from '../../../components/TextInput.js' +import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { Box, Newline, Text } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js' +import type { + PermissionBehavior, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { + permissionRuleValueFromString, + permissionRuleValueToString, +} from '../../../utils/permissions/permissionRuleParser.js' + export type PermissionRuleInputProps = { - onCancel: () => void; - onSubmit: (ruleValue: PermissionRuleValue, ruleBehavior: PermissionBehavior) => void; - ruleBehavior: PermissionBehavior; -}; -export function PermissionRuleInput(t0) { - const $ = _c(24); - const { - onCancel, - onSubmit, - ruleBehavior - } = t0; - const [inputValue, setInputValue] = useState(""); - const [cursorOffset, setCursorOffset] = useState(0); - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Settings" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - const { - columns - } = useTerminalSize(); - const textInputColumns = columns - 6; - let t2; - if ($[1] !== onSubmit || $[2] !== ruleBehavior) { - t2 = value => { - const trimmedValue = value.trim(); - if (trimmedValue.length === 0) { - return; - } - const ruleValue = permissionRuleValueFromString(trimmedValue); - onSubmit(ruleValue, ruleBehavior); - }; - $[1] = onSubmit; - $[2] = ruleBehavior; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleSubmit = t2; - let t3; - if ($[4] !== ruleBehavior) { - t3 = Add {ruleBehavior} permission rule; - $[4] = ruleBehavior; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {permissionRuleValueToString({ - toolName: WebFetchTool.name - })}; - t6 = or ; - $[7] = t5; - $[8] = t6; - } else { - t5 = $[7]; - t6 = $[8]; - } - let t7; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Permission rules are a tool name, optionally followed by a specifier in parentheses.{t4}e.g.,{" "}{t5}{t6}{permissionRuleValueToString({ - toolName: BashTool.name, - ruleContent: "ls:*" - })}; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] !== cursorOffset || $[11] !== handleSubmit || $[12] !== inputValue || $[13] !== textInputColumns) { - t8 = {t7}; - $[10] = cursorOffset; - $[11] = handleSubmit; - $[12] = inputValue; - $[13] = textInputColumns; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== t3 || $[16] !== t8) { - t9 = {t3}{t8}; - $[15] = t3; - $[16] = t8; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] !== exitState.keyName || $[19] !== exitState.pending) { - t10 = {exitState.pending ? Press {exitState.keyName} again to exit : Enter to submit · Esc to cancel}; - $[18] = exitState.keyName; - $[19] = exitState.pending; - $[20] = t10; - } else { - t10 = $[20]; - } - let t11; - if ($[21] !== t10 || $[22] !== t9) { - t11 = <>{t9}{t10}; - $[21] = t10; - $[22] = t9; - $[23] = t11; - } else { - t11 = $[23]; + onCancel: () => void + onSubmit: ( + ruleValue: PermissionRuleValue, + ruleBehavior: PermissionBehavior, + ) => void + ruleBehavior: PermissionBehavior +} + +export function PermissionRuleInput({ + onCancel, + onSubmit, + ruleBehavior, +}: PermissionRuleInputProps): React.ReactNode { + const [inputValue, setInputValue] = useState('') + const [cursorOffset, setCursorOffset] = useState(0) + const exitState = useExitOnCtrlCDWithKeybindings() + + // Use configurable keybinding for ESC to cancel + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + const { columns } = useTerminalSize() + const textInputColumns = columns - 6 + + const handleSubmit = (value: string) => { + const trimmedValue = value.trim() + if (trimmedValue.length === 0) { + return + } + const ruleValue = permissionRuleValueFromString(trimmedValue) + onSubmit(ruleValue, ruleBehavior) } - return t11; + + return ( + <> + + + Add {ruleBehavior} permission rule + + + + Permission rules are a tool name, optionally followed by a specifier + in parentheses. + + e.g.,{' '} + + {permissionRuleValueToString({ toolName: WebFetchTool.name })} + + or + + {permissionRuleValueToString({ + toolName: BashTool.name, + ruleContent: 'ls:*', + })} + + + + + + + + + {exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + Enter to submit · Esc to cancel + )} + + + ) } diff --git a/src/components/permissions/rules/PermissionRuleList.tsx b/src/components/permissions/rules/PermissionRuleList.tsx index d935a8cc0..129b58083 100644 --- a/src/components/permissions/rules/PermissionRuleList.tsx +++ b/src/components/permissions/rules/PermissionRuleList.tsx @@ -1,272 +1,183 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { applyPermissionUpdate, persistPermissionUpdate } from 'src/utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdateDestination } from 'src/utils/permissions/PermissionUpdateSchema.js'; -import type { CommandResultDisplay } from '../../../commands.js'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useSearchInput } from '../../../hooks/useSearchInput.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text, useTerminalFocus } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { type AutoModeDenial, getAutoModeDenials } from '../../../utils/autoModeDenials.js'; -import type { PermissionBehavior, PermissionRule, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; -import { deletePermissionRule, getAllowRules, getAskRules, getDenyRules, permissionRuleSourceDisplayString } from '../../../utils/permissions/permissions.js'; -import type { UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js'; -import { jsonStringify } from '../../../utils/slowOperations.js'; -import { Pane } from '../../design-system/Pane.js'; -import { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '../../design-system/Tabs.js'; -import { SearchBox } from '../../SearchBox.js'; -import type { Option } from '../../ui/option.js'; -import { AddPermissionRules } from './AddPermissionRules.js'; -import { AddWorkspaceDirectory } from './AddWorkspaceDirectory.js'; -import { PermissionRuleDescription } from './PermissionRuleDescription.js'; -import { PermissionRuleInput } from './PermissionRuleInput.js'; -import { RecentDenialsTab } from './RecentDenialsTab.js'; -import { RemoveWorkspaceDirectory } from './RemoveWorkspaceDirectory.js'; -import { WorkspaceTab } from './WorkspaceTab.js'; -type TabType = 'recent' | 'allow' | 'ask' | 'deny' | 'workspace'; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + applyPermissionUpdate, + persistPermissionUpdate, +} from 'src/utils/permissions/PermissionUpdate.js' +import type { PermissionUpdateDestination } from 'src/utils/permissions/PermissionUpdateSchema.js' +import type { CommandResultDisplay } from '../../../commands.js' +import { Select } from '../../../components/CustomSelect/select.js' +import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useSearchInput } from '../../../hooks/useSearchInput.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text, useTerminalFocus } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { + type AutoModeDenial, + getAutoModeDenials, +} from '../../../utils/autoModeDenials.js' +import type { + PermissionBehavior, + PermissionRule, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js' +import { + deletePermissionRule, + getAllowRules, + getAskRules, + getDenyRules, + permissionRuleSourceDisplayString, +} from '../../../utils/permissions/permissions.js' +import type { UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js' +import { jsonStringify } from '../../../utils/slowOperations.js' +import { Pane } from '../../design-system/Pane.js' +import { + Tab, + Tabs, + useTabHeaderFocus, + useTabsWidth, +} from '../../design-system/Tabs.js' +import { SearchBox } from '../../SearchBox.js' +import type { Option } from '../../ui/option.js' +import { AddPermissionRules } from './AddPermissionRules.js' +import { AddWorkspaceDirectory } from './AddWorkspaceDirectory.js' +import { PermissionRuleDescription } from './PermissionRuleDescription.js' +import { PermissionRuleInput } from './PermissionRuleInput.js' +import { RecentDenialsTab } from './RecentDenialsTab.js' +import { RemoveWorkspaceDirectory } from './RemoveWorkspaceDirectory.js' +import { WorkspaceTab } from './WorkspaceTab.js' + +type TabType = 'recent' | 'allow' | 'ask' | 'deny' | 'workspace' + type RuleSourceTextProps = { - rule: PermissionRule; -}; -function RuleSourceText(t0) { - const $ = _c(4); - const { - rule - } = t0; - let t1; - if ($[0] !== rule.source) { - t1 = permissionRuleSourceDisplayString(rule.source); - $[0] = rule.source; - $[1] = t1; - } else { - t1 = $[1]; - } - const t2 = `From ${t1}`; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; + rule: PermissionRule +} +function RuleSourceText({ rule }: RuleSourceTextProps): React.ReactNode { + return ( + {`From ${permissionRuleSourceDisplayString(rule.source)}`} + ) } // Helper function to get the appropriate label for rule behavior function getRuleBehaviorLabel(ruleBehavior: PermissionBehavior): string { switch (ruleBehavior) { case 'allow': - return 'allowed'; + return 'allowed' case 'deny': - return 'denied'; + return 'denied' case 'ask': - return 'ask'; + return 'ask' } } // Component for showing tool details and managing the interactive deletion workflow -function RuleDetails(t0) { - const $ = _c(42); - const { - rule, - onDelete, - onCancel - } = t0; - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - let t2; - if ($[1] !== rule.ruleValue) { - t2 = permissionRuleValueToString(rule.ruleValue); - $[1] = rule.ruleValue; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== rule.ruleValue) { - t4 = ; - $[5] = rule.ruleValue; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== rule) { - t5 = ; - $[7] = rule; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== t3 || $[10] !== t4 || $[11] !== t5) { - t6 = {t3}{t4}{t5}; - $[9] = t3; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - const ruleDescription = t6; - let t7; - if ($[13] !== exitState.keyName || $[14] !== exitState.pending) { - t7 = {exitState.pending ? Press {exitState.keyName} again to exit : Esc to cancel}; - $[13] = exitState.keyName; - $[14] = exitState.pending; - $[15] = t7; - } else { - t7 = $[15]; - } - const footer = t7; - if (rule.source === "policySettings") { - let t8; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Rule details; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t9 = This rule is configured by managed settings and cannot be modified.{"\n"}Contact your system administrator for more information.; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] !== ruleDescription) { - t10 = {t8}{ruleDescription}{t9}; - $[18] = ruleDescription; - $[19] = t10; - } else { - t10 = $[19]; - } - let t11; - if ($[20] !== footer || $[21] !== t10) { - t11 = <>{t10}{footer}; - $[20] = footer; - $[21] = t10; - $[22] = t11; - } else { - t11 = $[22]; - } - return t11; - } - let t8; - if ($[23] !== rule.ruleBehavior) { - t8 = getRuleBehaviorLabel(rule.ruleBehavior); - $[23] = rule.ruleBehavior; - $[24] = t8; - } else { - t8 = $[24]; - } - let t9; - if ($[25] !== t8) { - t9 = Delete {t8} tool?; - $[25] = t8; - $[26] = t9; - } else { - t9 = $[26]; - } - let t10; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Are you sure you want to delete this permission rule?; - $[27] = t10; - } else { - t10 = $[27]; - } - let t11; - if ($[28] !== onCancel || $[29] !== onDelete) { - t11 = _ => _ === "yes" ? onDelete() : onCancel(); - $[28] = onCancel; - $[29] = onDelete; - $[30] = t11; - } else { - t11 = $[30]; - } - let t12; - if ($[31] === Symbol.for("react.memo_cache_sentinel")) { - t12 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[31] = t12; - } else { - t12 = $[31]; - } - let t13; - if ($[32] !== onCancel || $[33] !== t11) { - t13 = (_ === 'yes' ? onDelete() : onCancel())} + onCancel={onCancel} + options={[ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ]} + /> + + {footer} + + ) } + type RulesTabContentProps = { - options: Option[]; - searchQuery: string; - isSearchMode: boolean; - isFocused: boolean; - onSelect: (value: string) => void; - onCancel: () => void; - lastFocusedRuleKey: string | undefined; - cursorOffset?: number; - onHeaderFocusChange?: (focused: boolean) => void; -}; + options: Option[] + searchQuery: string + isSearchMode: boolean + isFocused: boolean + onSelect: (value: string) => void + onCancel: () => void + lastFocusedRuleKey: string | undefined + cursorOffset?: number + onHeaderFocusChange?: (focused: boolean) => void +} // Component for rendering rules tab content with full width support -function RulesTabContent(props) { - const $ = _c(26); +function RulesTabContent(props: RulesTabContentProps): React.ReactNode { const { options, searchQuery, @@ -276,903 +187,613 @@ function RulesTabContent(props) { onCancel, lastFocusedRuleKey, cursorOffset, - onHeaderFocusChange - } = props; - const tabWidth = useTabsWidth(); - const { - headerFocused, - focusHeader, - blurHeader - } = useTabHeaderFocus(); - let t0; - let t1; - if ($[0] !== blurHeader || $[1] !== headerFocused || $[2] !== isSearchMode) { - t0 = () => { - if (isSearchMode && headerFocused) { - blurHeader(); - } - }; - t1 = [isSearchMode, headerFocused, blurHeader]; - $[0] = blurHeader; - $[1] = headerFocused; - $[2] = isSearchMode; - $[3] = t0; - $[4] = t1; - } else { - t0 = $[3]; - t1 = $[4]; - } - useEffect(t0, t1); - let t2; - let t3; - if ($[5] !== headerFocused || $[6] !== onHeaderFocusChange) { - t2 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t3 = [headerFocused, onHeaderFocusChange]; - $[5] = headerFocused; - $[6] = onHeaderFocusChange; - $[7] = t2; - $[8] = t3; - } else { - t2 = $[7]; - t3 = $[8]; - } - useEffect(t2, t3); - const t4 = isSearchMode && !headerFocused; - let t5; - if ($[9] !== cursorOffset || $[10] !== isFocused || $[11] !== searchQuery || $[12] !== t4 || $[13] !== tabWidth) { - t5 = ; - $[9] = cursorOffset; - $[10] = isFocused; - $[11] = searchQuery; - $[12] = t4; - $[13] = tabWidth; - $[14] = t5; - } else { - t5 = $[14]; - } - const t6 = Math.min(10, options.length); - const t7 = isSearchMode || headerFocused; - let t8; - if ($[15] !== focusHeader || $[16] !== lastFocusedRuleKey || $[17] !== onCancel || $[18] !== onSelect || $[19] !== options || $[20] !== t6 || $[21] !== t7) { - t8 = + + ) } // Composes the subtitle + search + Select for a single allow/ask/deny tab. -function PermissionRulesTab(t0) { - const $ = _c(27); - let T0; - let T1; - let handleToolSelect; - let rulesProps; - let t1; - let t2; - let t3; - let t4; - let tab; - if ($[0] !== t0) { - const { - tab: t5, - getRulesOptions, - handleToolSelect: t6, - ...t7 - } = t0; - tab = t5; - handleToolSelect = t6; - rulesProps = t7; - T1 = Box; - t2 = "column"; - t3 = tab === "allow" ? 0 : undefined; - let t8; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - allow: "Claude Code won't ask before using allowed tools.", - ask: "Claude Code will always ask for confirmation before using these tools.", - deny: "Claude Code will always reject requests to use denied tools." - }; - $[10] = t8; - } else { - t8 = $[10]; - } - const t9 = t8[tab]; - if ($[11] !== t9) { - t4 = {t9}; - $[11] = t9; - $[12] = t4; - } else { - t4 = $[12]; - } - T0 = RulesTabContent; - t1 = getRulesOptions(tab, rulesProps.searchQuery); - $[0] = t0; - $[1] = T0; - $[2] = T1; - $[3] = handleToolSelect; - $[4] = rulesProps; - $[5] = t1; - $[6] = t2; - $[7] = t3; - $[8] = t4; - $[9] = tab; - } else { - T0 = $[1]; - T1 = $[2]; - handleToolSelect = $[3]; - rulesProps = $[4]; - t1 = $[5]; - t2 = $[6]; - t3 = $[7]; - t4 = $[8]; - tab = $[9]; - } - let t5; - if ($[13] !== handleToolSelect || $[14] !== tab) { - t5 = v => handleToolSelect(v, tab); - $[13] = handleToolSelect; - $[14] = tab; - $[15] = t5; - } else { - t5 = $[15]; - } - let t6; - if ($[16] !== T0 || $[17] !== rulesProps || $[18] !== t1.options || $[19] !== t5) { - t6 = ; - $[16] = T0; - $[17] = rulesProps; - $[18] = t1.options; - $[19] = t5; - $[20] = t6; - } else { - t6 = $[20]; - } - let t7; - if ($[21] !== T1 || $[22] !== t2 || $[23] !== t3 || $[24] !== t4 || $[25] !== t6) { - t7 = {t4}{t6}; - $[21] = T1; - $[22] = t2; - $[23] = t3; - $[24] = t4; - $[25] = t6; - $[26] = t7; - } else { - t7 = $[26]; - } - return t7; +function PermissionRulesTab({ + tab, + getRulesOptions, + handleToolSelect, + ...rulesProps +}: { + tab: 'allow' | 'ask' | 'deny' + getRulesOptions: (tab: TabType, query?: string) => { options: Option[] } + handleToolSelect: (value: string, tab: TabType) => void +} & Omit): React.ReactNode { + return ( + + + { + { + allow: "Claude Code won't ask before using allowed tools.", + ask: 'Claude Code will always ask for confirmation before using these tools.', + deny: 'Claude Code will always reject requests to use denied tools.', + }[tab] + } + + handleToolSelect(v, tab)} + {...rulesProps} + /> + + ) } + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - shouldQuery?: boolean; - metaMessages?: string[]; - }) => void; - initialTab?: TabType; - onRetryDenials?: (commands: string[]) => void; -}; -export function PermissionRuleList(t0) { - const $ = _c(113); - const { - onExit, - initialTab, - onRetryDenials - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getAutoModeDenials(); - $[0] = t1; - } else { - t1 = $[0]; - } - const hasDenials = t1.length > 0; - const defaultTab = initialTab ?? (hasDenials ? "recent" : "allow"); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [changes, setChanges] = useState(t2); - const toolPermissionContext = useAppState(_temp); - const setAppState = useSetAppState(); - const isTerminalFocused = useTerminalFocus(); - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - approved: new Set(), - retry: new Set(), - denials: [] - }; - $[2] = t3; - } else { - t3 = $[2]; - } - const denialStateRef = useRef(t3); - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = s_0 => { - denialStateRef.current = s_0; - }; - $[3] = t4; - } else { - t4 = $[3]; - } - const handleDenialStateChange = t4; - const [selectedRule, setSelectedRule] = useState(); - const [lastFocusedRuleKey, setLastFocusedRuleKey] = useState(); - const [addingRuleToTab, setAddingRuleToTab] = useState(null); - const [validatedRule, setValidatedRule] = useState(null); - const [isAddingWorkspaceDirectory, setIsAddingWorkspaceDirectory] = useState(false); - const [removingDirectory, setRemovingDirectory] = useState(null); - const [isSearchMode, setIsSearchMode] = useState(false); - const [headerFocused, setHeaderFocused] = useState(true); - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = focused => { - setHeaderFocused(focused); - }; - $[4] = t5; - } else { - t5 = $[4]; - } - const handleHeaderFocusChange = t5; - let map; - if ($[5] !== toolPermissionContext) { - map = new Map(); + onExit: ( + result?: string, + options?: { + display?: CommandResultDisplay + shouldQuery?: boolean + metaMessages?: string[] + }, + ) => void + initialTab?: TabType + onRetryDenials?: (commands: string[]) => void +} + +export function PermissionRuleList({ + onExit, + initialTab, + onRetryDenials, +}: Props): React.ReactNode { + const hasDenials = getAutoModeDenials().length > 0 + const defaultTab: TabType = initialTab ?? (hasDenials ? 'recent' : 'allow') + const [changes, setChanges] = useState([]) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const setAppState = useSetAppState() + const isTerminalFocused = useTerminalFocus() + + // Ref not state: RecentDenialsTab updates don't need to trigger parent + // re-render (only read on exit), and re-renders trip the modal ScrollBox + // collapse bug from #23592 in fullscreen. + const denialStateRef = useRef<{ + approved: Set + retry: Set + denials: readonly AutoModeDenial[] + }>({ approved: new Set(), retry: new Set(), denials: [] }) + const handleDenialStateChange = useCallback( + (s: typeof denialStateRef.current) => { + denialStateRef.current = s + }, + [], + ) + + const [selectedRule, setSelectedRule] = useState() + // Track the key of the last focused rule to restore position after deletion + const [lastFocusedRuleKey, setLastFocusedRuleKey] = useState< + string | undefined + >() + const [addingRuleToTab, setAddingRuleToTab] = useState(null) + const [validatedRule, setValidatedRule] = useState<{ + ruleBehavior: PermissionBehavior + ruleValue: PermissionRuleValue + } | null>(null) + const [isAddingWorkspaceDirectory, setIsAddingWorkspaceDirectory] = + useState(false) + const [removingDirectory, setRemovingDirectory] = useState( + null, + ) + const [isSearchMode, setIsSearchMode] = useState(false) + const [headerFocused, setHeaderFocused] = useState(true) + const handleHeaderFocusChange = useCallback((focused: boolean) => { + setHeaderFocused(focused) + }, []) + + const allowRulesByKey = useMemo(() => { + const map = new Map() getAllowRules(toolPermissionContext).forEach(rule => { - map.set(jsonStringify(rule), rule); - }); - $[5] = toolPermissionContext; - $[6] = map; - } else { - map = $[6]; - } - const allowRulesByKey = map; - let map_0; - if ($[7] !== toolPermissionContext) { - map_0 = new Map(); - getDenyRules(toolPermissionContext).forEach(rule_0 => { - map_0.set(jsonStringify(rule_0), rule_0); - }); - $[7] = toolPermissionContext; - $[8] = map_0; - } else { - map_0 = $[8]; - } - const denyRulesByKey = map_0; - let map_1; - if ($[9] !== toolPermissionContext) { - map_1 = new Map(); - getAskRules(toolPermissionContext).forEach(rule_1 => { - map_1.set(jsonStringify(rule_1), rule_1); - }); - $[9] = toolPermissionContext; - $[10] = map_1; - } else { - map_1 = $[10]; - } - const askRulesByKey = map_1; - let t6; - if ($[11] !== allowRulesByKey || $[12] !== askRulesByKey || $[13] !== denyRulesByKey) { - t6 = (tab, t7) => { - const query = t7 === undefined ? "" : t7; + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const denyRulesByKey = useMemo(() => { + const map = new Map() + getDenyRules(toolPermissionContext).forEach(rule => { + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const askRulesByKey = useMemo(() => { + const map = new Map() + getAskRules(toolPermissionContext).forEach(rule => { + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const getRulesOptions = useCallback( + (tab: TabType, query: string = '') => { const rulesByKey = (() => { switch (tab) { - case "allow": - { - return allowRulesByKey; - } - case "deny": - { - return denyRulesByKey; - } - case "ask": - { - return askRulesByKey; - } - case "workspace": - case "recent": - { - return new Map(); - } + case 'allow': + return allowRulesByKey + case 'deny': + return denyRulesByKey + case 'ask': + return askRulesByKey + case 'workspace': + case 'recent': + return new Map() } - })(); - const options = []; - if (tab !== "workspace" && tab !== "recent" && !query) { + })() + + const options: Option[] = [] + + // Only show "Add a new rule" for allow and deny tabs (and not when searching) + if (tab !== 'workspace' && tab !== 'recent' && !query) { options.push({ label: `Add a new rule${figures.ellipsis}`, - value: "add-new-rule" - }); + value: 'add-new-rule', + }) } + + // Get all rule keys and sort them alphabetically based on rule's formatted value const sortedRuleKeys = Array.from(rulesByKey.keys()).sort((a, b) => { - const ruleA = rulesByKey.get(a); - const ruleB = rulesByKey.get(b); + const ruleA = rulesByKey.get(a) + const ruleB = rulesByKey.get(b) if (ruleA && ruleB) { - const ruleAString = permissionRuleValueToString(ruleA.ruleValue).toLowerCase(); - const ruleBString = permissionRuleValueToString(ruleB.ruleValue).toLowerCase(); - return ruleAString.localeCompare(ruleBString); + const ruleAString = permissionRuleValueToString( + ruleA.ruleValue, + ).toLowerCase() + const ruleBString = permissionRuleValueToString( + ruleB.ruleValue, + ).toLowerCase() + return ruleAString.localeCompare(ruleBString) } - return 0; - }); - const lowerQuery = query.toLowerCase(); + return 0 + }) + + // Build options from sorted keys, filtering by search query + const lowerQuery = query.toLowerCase() for (const ruleKey of sortedRuleKeys) { - const rule_2 = rulesByKey.get(ruleKey); - if (rule_2) { - const ruleString = permissionRuleValueToString(rule_2.ruleValue); + const rule = rulesByKey.get(ruleKey) + if (rule) { + const ruleString = permissionRuleValueToString(rule.ruleValue) + // Filter by search query if provided if (query && !ruleString.toLowerCase().includes(lowerQuery)) { - continue; + continue } options.push({ label: ruleString, - value: ruleKey - }); + value: ruleKey, + }) } } - return { - options, - rulesByKey - }; - }; - $[11] = allowRulesByKey; - $[12] = askRulesByKey; - $[13] = denyRulesByKey; - $[14] = t6; - } else { - t6 = $[14]; - } - const getRulesOptions = t6; - const exitState = useExitOnCtrlCDWithKeybindings(); - const isSearchModeActive = !selectedRule && !addingRuleToTab && !validatedRule && !isAddingWorkspaceDirectory && !removingDirectory; - const t7 = isSearchModeActive && isSearchMode; - let t8; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t8 = () => { - setIsSearchMode(false); - }; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] !== t7) { - t9 = { - isActive: t7, - onExit: t8 - }; - $[16] = t7; - $[17] = t9; - } else { - t9 = $[17]; - } + + return { options, rulesByKey } + }, + [allowRulesByKey, denyRulesByKey, askRulesByKey], + ) + + const exitState = useExitOnCtrlCDWithKeybindings() + + const isSearchModeActive = + !selectedRule && + !addingRuleToTab && + !validatedRule && + !isAddingWorkspaceDirectory && + !removingDirectory + const { query: searchQuery, setQuery: setSearchQuery, - cursorOffset: searchCursorOffset - } = useSearchInput(t9); - let t10; - if ($[18] !== isSearchMode || $[19] !== isSearchModeActive || $[20] !== setSearchQuery) { - t10 = e => { - if (!isSearchModeActive) { - return; - } - if (isSearchMode) { - return; - } - if (e.ctrl || e.meta) { - return; - } - if (e.key === "/") { - e.preventDefault(); - setIsSearchMode(true); - setSearchQuery(""); - } else { - if (e.key.length === 1 && e.key !== "j" && e.key !== "k" && e.key !== "m" && e.key !== "i" && e.key !== "r" && e.key !== " ") { - e.preventDefault(); - setIsSearchMode(true); - setSearchQuery(e.key); - } + cursorOffset: searchCursorOffset, + } = useSearchInput({ + isActive: isSearchModeActive && isSearchMode, + onExit: () => { + setIsSearchMode(false) + }, + }) + + // Handle entering search mode + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isSearchModeActive) return + if (isSearchMode) return + if (e.ctrl || e.meta) return + + // Enter search mode with '/' or any printable character. + // e.key.length === 1 filters out special keys (down, return, escape, + // etc.) — previously the raw escape sequence leaked through and + // triggered search mode with garbage on arrow-key press. + if (e.key === '/') { + e.preventDefault() + setIsSearchMode(true) + setSearchQuery('') + } else if ( + e.key.length === 1 && + // Don't enter search mode for vim-nav / space / retry key + e.key !== 'j' && + e.key !== 'k' && + e.key !== 'm' && + e.key !== 'i' && + e.key !== 'r' && + e.key !== ' ' + ) { + e.preventDefault() + setIsSearchMode(true) + setSearchQuery(e.key) } - }; - $[18] = isSearchMode; - $[19] = isSearchModeActive; - $[20] = setSearchQuery; - $[21] = t10; - } else { - t10 = $[21]; - } - const handleKeyDown = t10; - let t11; - if ($[22] !== getRulesOptions) { - t11 = (selectedValue, tab_0) => { - const { - rulesByKey: rulesByKey_0 - } = getRulesOptions(tab_0); - if (selectedValue === "add-new-rule") { - setAddingRuleToTab(tab_0); - return; + }, + [isSearchModeActive, isSearchMode, setSearchQuery], + ) + + const handleToolSelect = useCallback( + (selectedValue: string, tab: TabType) => { + const { rulesByKey } = getRulesOptions(tab) + if (selectedValue === 'add-new-rule') { + setAddingRuleToTab(tab) + return } else { - setSelectedRule(rulesByKey_0.get(selectedValue)); - return; + setSelectedRule(rulesByKey.get(selectedValue)) + return } - }; - $[22] = getRulesOptions; - $[23] = t11; - } else { - t11 = $[23]; - } - const handleToolSelect = t11; - let t12; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t12 = () => { - setAddingRuleToTab(null); - }; - $[24] = t12; - } else { - t12 = $[24]; - } - const handleRuleInputCancel = t12; - let t13; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t13 = (ruleValue, ruleBehavior) => { - setValidatedRule({ - ruleValue, - ruleBehavior - }); - setAddingRuleToTab(null); - }; - $[25] = t13; - } else { - t13 = $[25]; - } - const handleRuleInputSubmit = t13; - let t14; - if ($[26] === Symbol.for("react.memo_cache_sentinel")) { - t14 = (rules, unreachable) => { - setValidatedRule(null); - for (const rule_3 of rules) { - setChanges(prev => [...prev, `Added ${rule_3.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(rule_3.ruleValue))}`]); + }, + [getRulesOptions], + ) + + const handleRuleInputCancel = useCallback(() => { + setAddingRuleToTab(null) + }, []) + + const handleRuleInputSubmit = useCallback( + (ruleValue: PermissionRuleValue, ruleBehavior: PermissionBehavior) => { + setValidatedRule({ ruleValue, ruleBehavior }) + setAddingRuleToTab(null) + }, + [], + ) + + const handleAddRulesSuccess = useCallback( + (rules: PermissionRule[], unreachable?: UnreachableRule[]) => { + setValidatedRule(null) + for (const rule of rules) { + setChanges(prev => [ + ...prev, + `Added ${rule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(rule.ruleValue))}`, + ]) } + + // Show warnings for any unreachable rules we just added if (unreachable && unreachable.length > 0) { for (const u of unreachable) { - const severity = u.shadowType === "deny" ? "blocked" : "shadowed"; - setChanges(prev_0 => [...prev_0, chalk.yellow(`${figures.warning} Warning: ${permissionRuleValueToString(u.rule.ruleValue)} is ${severity}`), chalk.dim(` ${u.reason}`), chalk.dim(` Fix: ${u.fix}`)]); + const severity = u.shadowType === 'deny' ? 'blocked' : 'shadowed' + setChanges(prev => [ + ...prev, + chalk.yellow( + `${figures.warning} Warning: ${permissionRuleValueToString(u.rule.ruleValue)} is ${severity}`, + ), + chalk.dim(` ${u.reason}`), + chalk.dim(` Fix: ${u.fix}`), + ]) } } - }; - $[26] = t14; - } else { - t14 = $[26]; - } - const handleAddRulesSuccess = t14; - let t15; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t15 = () => { - setValidatedRule(null); - }; - $[27] = t15; - } else { - t15 = $[27]; - } - const handleAddRuleCancel = t15; - let t16; - if ($[28] === Symbol.for("react.memo_cache_sentinel")) { - t16 = () => setIsAddingWorkspaceDirectory(true); - $[28] = t16; - } else { - t16 = $[28]; - } - const handleRequestAddDirectory = t16; - let t17; - if ($[29] === Symbol.for("react.memo_cache_sentinel")) { - t17 = path => setRemovingDirectory(path); - $[29] = t17; - } else { - t17 = $[29]; - } - const handleRequestRemoveDirectory = t17; - let t18; - if ($[30] !== changes || $[31] !== onExit || $[32] !== onRetryDenials) { - t18 = () => { - const s_1 = denialStateRef.current; - const denialsFor = (set: Set) => Array.from(set).map(idx => s_1.denials[idx]).filter(_temp2); - const retryDenials = denialsFor(s_1.retry); - if (retryDenials.length > 0) { - const commands = retryDenials.map(_temp3); - onRetryDenials?.(commands); - onExit(undefined, { - shouldQuery: true, - metaMessages: [`Permission granted for: ${commands.join(", ")}. You may now retry ${commands.length === 1 ? "this command" : "these commands"} if you would like.`] - }); - return; - } - const approvedDenials = denialsFor(s_1.approved); - if (approvedDenials.length > 0 || changes.length > 0) { - const approvedMsg = approvedDenials.length > 0 ? [`Approved ${approvedDenials.map(_temp4).join(", ")}`] : []; - onExit([...approvedMsg, ...changes].join("\n")); - } else { - onExit("Permissions dialog dismissed", { - display: "system" - }); - } - }; - $[30] = changes; - $[31] = onExit; - $[32] = onRetryDenials; - $[33] = t18; - } else { - t18 = $[33]; - } - const handleRulesCancel = t18; - const t19 = isSearchModeActive && !isSearchMode; - let t20; - if ($[34] !== t19) { - t20 = { - context: "Settings", - isActive: t19 - }; - $[34] = t19; - $[35] = t20; - } else { - t20 = $[35]; - } - useKeybinding("confirm:no", handleRulesCancel, t20); - let t21; - if ($[36] !== getRulesOptions || $[37] !== selectedRule || $[38] !== setAppState || $[39] !== toolPermissionContext) { - t21 = () => { - if (!selectedRule) { - return; - } - const { - options: options_0 - } = getRulesOptions(selectedRule.ruleBehavior as TabType); - const selectedKey = jsonStringify(selectedRule); - const ruleKeys = options_0.filter(_temp5).map(_temp6); - const currentIndex = ruleKeys.indexOf(selectedKey); - let nextFocusKey; - if (currentIndex !== -1) { - if (currentIndex < ruleKeys.length - 1) { - nextFocusKey = ruleKeys[currentIndex + 1]; - } else { - if (currentIndex > 0) { - nextFocusKey = ruleKeys[currentIndex - 1]; - } - } - } - setLastFocusedRuleKey(nextFocusKey); - deletePermissionRule({ - rule: selectedRule, - initialContext: toolPermissionContext, - setToolPermissionContext(toolPermissionContext_0) { - setAppState(prev_1 => ({ - ...prev_1, - toolPermissionContext: toolPermissionContext_0 - })); - } - }); - setChanges(prev_2 => [...prev_2, `Deleted ${selectedRule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(selectedRule.ruleValue))}`]); - setSelectedRule(undefined); - }; - $[36] = getRulesOptions; - $[37] = selectedRule; - $[38] = setAppState; - $[39] = toolPermissionContext; - $[40] = t21; - } else { - t21 = $[40]; - } - const handleDeleteRule = t21; - if (selectedRule) { - let t22; - if ($[41] === Symbol.for("react.memo_cache_sentinel")) { - t22 = () => setSelectedRule(undefined); - $[41] = t22; - } else { - t22 = $[41]; + }, + [], + ) + + const handleAddRuleCancel = useCallback(() => { + setValidatedRule(null) + }, []) + + const handleRequestAddDirectory = useCallback( + () => setIsAddingWorkspaceDirectory(true), + [], + ) + const handleRequestRemoveDirectory = useCallback( + (path: string) => setRemovingDirectory(path), + [], + ) + const handleRulesCancel = useCallback(() => { + const s = denialStateRef.current + const denialsFor = (set: Set) => + Array.from(set) + .map(idx => s.denials[idx]) + .filter((d): d is AutoModeDenial => d !== undefined) + + const retryDenials = denialsFor(s.retry) + if (retryDenials.length > 0) { + const commands = retryDenials.map(d => d.display) + onRetryDenials?.(commands) + onExit(undefined, { + shouldQuery: true, + metaMessages: [ + `Permission granted for: ${commands.join(', ')}. You may now retry ${commands.length === 1 ? 'this command' : 'these commands'} if you would like.`, + ], + }) + return } - let t23; - if ($[42] !== handleDeleteRule || $[43] !== selectedRule) { - t23 = ; - $[42] = handleDeleteRule; - $[43] = selectedRule; - $[44] = t23; + + const approvedDenials = denialsFor(s.approved) + if (approvedDenials.length > 0 || changes.length > 0) { + const approvedMsg = + approvedDenials.length > 0 + ? [ + `Approved ${approvedDenials.map(d => chalk.bold(d.display)).join(', ')}`, + ] + : [] + onExit([...approvedMsg, ...changes].join('\n')) } else { - t23 = $[44]; + onExit('Permissions dialog dismissed', { + display: 'system', + }) } - return t23; - } - if (addingRuleToTab && addingRuleToTab !== "workspace" && addingRuleToTab !== "recent") { - let t22; - if ($[45] !== addingRuleToTab) { - t22 = ; - $[45] = addingRuleToTab; - $[46] = t22; - } else { - t22 = $[46]; + }, [changes, onExit, onRetryDenials]) + + // Handle Escape at the top level so it works even when header is focused + // (which disables the Select component and its select:cancel keybinding). + // Mirrors the pattern in Settings.tsx. + useKeybinding('confirm:no', handleRulesCancel, { + context: 'Settings', + isActive: isSearchModeActive && !isSearchMode, + }) + + const handleDeleteRule = () => { + if (!selectedRule) return + + // Find the adjacent rule to focus on after deletion + const { options } = getRulesOptions(selectedRule.ruleBehavior as TabType) + const selectedKey = jsonStringify(selectedRule) + const ruleKeys = options + .filter(opt => opt.value !== 'add-new-rule') + .map(opt => opt.value) + const currentIndex = ruleKeys.indexOf(selectedKey) + + // Try to focus on the next rule, or the previous if deleting the last one + let nextFocusKey: string | undefined + if (currentIndex !== -1) { + if (currentIndex < ruleKeys.length - 1) { + // Focus on the next rule + nextFocusKey = ruleKeys[currentIndex + 1] + } else if (currentIndex > 0) { + // Focus on the previous rule (we're deleting the last one) + nextFocusKey = ruleKeys[currentIndex - 1] + } } - return t22; + setLastFocusedRuleKey(nextFocusKey) + + void deletePermissionRule({ + rule: selectedRule, + initialContext: toolPermissionContext, + setToolPermissionContext(toolPermissionContext) { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }, + }) + + setChanges(prev => [ + ...prev, + `Deleted ${selectedRule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(selectedRule.ruleValue))}`, + ]) + setSelectedRule(undefined) } + + if (selectedRule) { + return ( + setSelectedRule(undefined)} + /> + ) + } + + if ( + addingRuleToTab && + addingRuleToTab !== 'workspace' && + addingRuleToTab !== 'recent' + ) { + return ( + + ) + } + if (validatedRule) { - let t22; - if ($[47] !== validatedRule.ruleValue) { - t22 = [validatedRule.ruleValue]; - $[47] = validatedRule.ruleValue; - $[48] = t22; - } else { - t22 = $[48]; - } - let t23; - if ($[49] !== setAppState) { - t23 = toolPermissionContext_1 => { - setAppState(prev_3 => ({ - ...prev_3, - toolPermissionContext: toolPermissionContext_1 - })); - }; - $[49] = setAppState; - $[50] = t23; - } else { - t23 = $[50]; - } - let t24; - if ($[51] !== t22 || $[52] !== t23 || $[53] !== toolPermissionContext || $[54] !== validatedRule.ruleBehavior) { - t24 = ; - $[51] = t22; - $[52] = t23; - $[53] = toolPermissionContext; - $[54] = validatedRule.ruleBehavior; - $[55] = t24; - } else { - t24 = $[55]; - } - return t24; + return ( + { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }} + /> + ) } + if (isAddingWorkspaceDirectory) { - let t22; - if ($[56] !== setAppState || $[57] !== toolPermissionContext) { - t22 = (path_0, remember) => { - const destination: PermissionUpdateDestination = remember ? "localSettings" : "session"; - const permissionUpdate = { - type: "addDirectories" as const, - directories: [path_0], - destination - }; - const updatedContext = applyPermissionUpdate(toolPermissionContext, permissionUpdate); - setAppState(prev_4 => ({ - ...prev_4, - toolPermissionContext: updatedContext - })); - if (remember) { - persistPermissionUpdate(permissionUpdate); - } - setChanges(prev_5 => [...prev_5, `Added directory ${chalk.bold(path_0)} to workspace${remember ? " and saved to local settings" : " for this session"}`]); - setIsAddingWorkspaceDirectory(false); - }; - $[56] = setAppState; - $[57] = toolPermissionContext; - $[58] = t22; - } else { - t22 = $[58]; - } - let t23; - if ($[59] === Symbol.for("react.memo_cache_sentinel")) { - t23 = () => setIsAddingWorkspaceDirectory(false); - $[59] = t23; - } else { - t23 = $[59]; - } - let t24; - if ($[60] !== t22 || $[61] !== toolPermissionContext) { - t24 = ; - $[60] = t22; - $[61] = toolPermissionContext; - $[62] = t24; - } else { - t24 = $[62]; - } - return t24; + return ( + { + // Apply the permission update to add the directory + const destination: PermissionUpdateDestination = remember + ? 'localSettings' + : 'session' + + const permissionUpdate = { + type: 'addDirectories' as const, + directories: [path], + destination, + } + + const updatedContext = applyPermissionUpdate( + toolPermissionContext, + permissionUpdate, + ) + setAppState(prev => ({ + ...prev, + toolPermissionContext: updatedContext, + })) + + // Persist if remember is true + if (remember) { + persistPermissionUpdate(permissionUpdate) + } + + setChanges(prev => [ + ...prev, + `Added directory ${chalk.bold(path)} to workspace${remember ? ' and saved to local settings' : ' for this session'}`, + ]) + setIsAddingWorkspaceDirectory(false) + }} + onCancel={() => setIsAddingWorkspaceDirectory(false)} + permissionContext={toolPermissionContext} + /> + ) } + if (removingDirectory) { - let t22; - if ($[63] !== removingDirectory) { - t22 = () => { - setChanges(prev_6 => [...prev_6, `Removed directory ${chalk.bold(removingDirectory)} from workspace`]); - setRemovingDirectory(null); - }; - $[63] = removingDirectory; - $[64] = t22; - } else { - t22 = $[64]; - } - let t23; - if ($[65] === Symbol.for("react.memo_cache_sentinel")) { - t23 = () => setRemovingDirectory(null); - $[65] = t23; - } else { - t23 = $[65]; - } - let t24; - if ($[66] !== setAppState) { - t24 = toolPermissionContext_2 => { - setAppState(prev_7 => ({ - ...prev_7, - toolPermissionContext: toolPermissionContext_2 - })); - }; - $[66] = setAppState; - $[67] = t24; - } else { - t24 = $[67]; - } - let t25; - if ($[68] !== removingDirectory || $[69] !== t22 || $[70] !== t24 || $[71] !== toolPermissionContext) { - t25 = ; - $[68] = removingDirectory; - $[69] = t22; - $[70] = t24; - $[71] = toolPermissionContext; - $[72] = t25; - } else { - t25 = $[72]; - } - return t25; - } - let t22; - if ($[73] !== getRulesOptions || $[74] !== handleRulesCancel || $[75] !== handleToolSelect || $[76] !== isSearchMode || $[77] !== isTerminalFocused || $[78] !== lastFocusedRuleKey || $[79] !== searchCursorOffset || $[80] !== searchQuery) { - t22 = { - searchQuery, - isSearchMode, - isFocused: isTerminalFocused, - onCancel: handleRulesCancel, - lastFocusedRuleKey, - cursorOffset: searchCursorOffset, - getRulesOptions, - handleToolSelect, - onHeaderFocusChange: handleHeaderFocusChange - }; - $[73] = getRulesOptions; - $[74] = handleRulesCancel; - $[75] = handleToolSelect; - $[76] = isSearchMode; - $[77] = isTerminalFocused; - $[78] = lastFocusedRuleKey; - $[79] = searchCursorOffset; - $[80] = searchQuery; - $[81] = t22; - } else { - t22 = $[81]; - } - const sharedRulesProps = t22; - const isHidden = !!selectedRule || !!addingRuleToTab || !!validatedRule || isAddingWorkspaceDirectory || !!removingDirectory; - const t23 = !isSearchMode; - let t24; - if ($[82] === Symbol.for("react.memo_cache_sentinel")) { - t24 = ; - $[82] = t24; - } else { - t24 = $[82]; - } - let t25; - if ($[83] !== sharedRulesProps) { - t25 = ; - $[83] = sharedRulesProps; - $[84] = t25; - } else { - t25 = $[84]; - } - let t26; - if ($[85] !== sharedRulesProps) { - t26 = ; - $[85] = sharedRulesProps; - $[86] = t26; - } else { - t26 = $[86]; - } - let t27; - if ($[87] !== sharedRulesProps) { - t27 = ; - $[87] = sharedRulesProps; - $[88] = t27; - } else { - t27 = $[88]; + return ( + { + setChanges(prev => [ + ...prev, + `Removed directory ${chalk.bold(removingDirectory)} from workspace`, + ]) + setRemovingDirectory(null) + }} + onCancel={() => setRemovingDirectory(null)} + permissionContext={toolPermissionContext} + setPermissionContext={toolPermissionContext => { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }} + /> + ) } - let t28; - if ($[89] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Claude Code can read files in the workspace, and make edits when auto-accept edits is on.; - $[89] = t28; - } else { - t28 = $[89]; - } - let t29; - if ($[90] !== onExit || $[91] !== toolPermissionContext) { - t29 = {t28}; - $[90] = onExit; - $[91] = toolPermissionContext; - $[92] = t29; - } else { - t29 = $[92]; - } - let t30; - if ($[93] !== defaultTab || $[94] !== isHidden || $[95] !== t23 || $[96] !== t25 || $[97] !== t26 || $[98] !== t27 || $[99] !== t29) { - t30 = ; - $[93] = defaultTab; - $[94] = isHidden; - $[95] = t23; - $[96] = t25; - $[97] = t26; - $[98] = t27; - $[99] = t29; - $[100] = t30; - } else { - t30 = $[100]; - } - let t31; - if ($[101] !== defaultTab || $[102] !== exitState.keyName || $[103] !== exitState.pending || $[104] !== headerFocused || $[105] !== isSearchMode) { - t31 = {exitState.pending ? <>Press {exitState.keyName} again to exit : headerFocused ? <>←/→ tab switch · ↓ return · Esc cancel : isSearchMode ? <>Type to filter · Enter/↓ select · ↑ tabs · Esc clear : hasDenials && defaultTab === "recent" ? <>Enter approve · r retry · ↑↓ navigate · ←/→ switch · Esc cancel : <>↑↓ navigate · Enter select · Type to search · ←/→ switch · Esc cancel}; - $[101] = defaultTab; - $[102] = exitState.keyName; - $[103] = exitState.pending; - $[104] = headerFocused; - $[105] = isSearchMode; - $[106] = t31; - } else { - t31 = $[106]; - } - let t32; - if ($[107] !== t30 || $[108] !== t31) { - t32 = {t30}{t31}; - $[107] = t30; - $[108] = t31; - $[109] = t32; - } else { - t32 = $[109]; - } - let t33; - if ($[110] !== handleKeyDown || $[111] !== t32) { - t33 = {t32}; - $[110] = handleKeyDown; - $[111] = t32; - $[112] = t33; - } else { - t33 = $[112]; + + const sharedRulesProps = { + searchQuery, + isSearchMode, + isFocused: isTerminalFocused, + onCancel: handleRulesCancel, + lastFocusedRuleKey, + cursorOffset: searchCursorOffset, + getRulesOptions, + handleToolSelect, + onHeaderFocusChange: handleHeaderFocusChange, } - return t33; -} -function _temp6(opt_0) { - return opt_0.value; -} -function _temp5(opt) { - return opt.value !== "add-new-rule"; -} -function _temp4(d_1) { - return chalk.bold(d_1.display); -} -function _temp3(d_0) { - return d_0.display; -} -function _temp2(d) { - return d !== undefined; -} -function _temp(s) { - return s.toolPermissionContext; + + const isHidden = + !!selectedRule || + !!addingRuleToTab || + !!validatedRule || + isAddingWorkspaceDirectory || + !!removingDirectory + + return ( + + + + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : headerFocused ? ( + <>←/→ tab switch · ↓ return · Esc cancel + ) : isSearchMode ? ( + <>Type to filter · Enter/↓ select · ↑ tabs · Esc clear + ) : hasDenials && defaultTab === 'recent' ? ( + <> + Enter approve · r retry · ↑↓ navigate · ←/→ switch · Esc cancel + + ) : ( + <> + ↑↓ navigate · Enter select · Type to search · ←/→ switch · Esc + cancel + + )} + + + + + ) } diff --git a/src/components/permissions/rules/RecentDenialsTab.tsx b/src/components/permissions/rules/RecentDenialsTab.tsx index cba81a4ea..17c13844d 100644 --- a/src/components/permissions/rules/RecentDenialsTab.tsx +++ b/src/components/permissions/rules/RecentDenialsTab.tsx @@ -1,206 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- 'r' is a view-specific key, not a global keybinding -import { Box, Text, useInput } from '../../../ink.js'; -import { type AutoModeDenial, getAutoModeDenials } from '../../../utils/autoModeDenials.js'; -import { Select } from '../../CustomSelect/select.js'; -import { StatusIcon } from '../../design-system/StatusIcon.js'; -import { useTabHeaderFocus } from '../../design-system/Tabs.js'; +import { Box, Text, useInput } from '../../../ink.js' +import { + type AutoModeDenial, + getAutoModeDenials, +} from '../../../utils/autoModeDenials.js' +import { Select } from '../../CustomSelect/select.js' +import { StatusIcon } from '../../design-system/StatusIcon.js' +import { useTabHeaderFocus } from '../../design-system/Tabs.js' + type Props = { - onHeaderFocusChange?: (focused: boolean) => void; + onHeaderFocusChange?: (focused: boolean) => void /** Called when approved/retry state changes so parent can act on exit */ onStateChange: (state: { - approved: Set; - retry: Set; - denials: readonly AutoModeDenial[]; - }) => void; -}; -export function RecentDenialsTab(t0) { - const $ = _c(30); - const { - onHeaderFocusChange, - onStateChange - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - let t2; - if ($[0] !== headerFocused || $[1] !== onHeaderFocusChange) { - t1 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t2 = [headerFocused, onHeaderFocusChange]; - $[0] = headerFocused; - $[1] = onHeaderFocusChange; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - const [denials] = useState(_temp); - const [approved, setApproved] = useState(_temp2); - const [retry, setRetry] = useState(_temp3); - const [focusedIdx, setFocusedIdx] = useState(0); - let t3; - let t4; - if ($[4] !== approved || $[5] !== denials || $[6] !== onStateChange || $[7] !== retry) { - t3 = () => { - onStateChange({ - approved, - retry, - denials - }); - }; - t4 = [approved, retry, denials, onStateChange]; - $[4] = approved; - $[5] = denials; - $[6] = onStateChange; - $[7] = retry; - $[8] = t3; - $[9] = t4; - } else { - t3 = $[8]; - t4 = $[9]; - } - useEffect(t3, t4); - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = value => { - const idx = Number(value); - setApproved(prev => { - const next = new Set(prev); - if (next.has(idx)) { - next.delete(idx); - } else { - next.add(idx); - } - return next; - }); - }; - $[10] = t5; - } else { - t5 = $[10]; - } - const handleSelect = t5; - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = value_0 => { - setFocusedIdx(Number(value_0)); - }; - $[11] = t6; - } else { - t6 = $[11]; - } - const handleFocus = t6; - let t7; - if ($[12] !== focusedIdx) { - t7 = (input, _key) => { - if (input === "r") { - setRetry(prev_0 => { - const next_0 = new Set(prev_0); - if (next_0.has(focusedIdx)) { - next_0.delete(focusedIdx); - } else { - next_0.add(focusedIdx); - } - return next_0; - }); - setApproved(prev_1 => { - if (prev_1.has(focusedIdx)) { - return prev_1; - } - const next_1 = new Set(prev_1); - next_1.add(focusedIdx); - return next_1; - }); + approved: Set + retry: Set + denials: readonly AutoModeDenial[] + }) => void +} + +export function RecentDenialsTab({ + onHeaderFocusChange, + onStateChange, +}: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + useEffect(() => { + onHeaderFocusChange?.(headerFocused) + }, [headerFocused, onHeaderFocusChange]) + + // Snapshot on mount — approved/retry Sets key by index, and the live store + // prepends. A concurrent denial would shift all indices mid-edit. + const [denials] = useState(() => getAutoModeDenials()) + + const [approved, setApproved] = useState>(() => new Set()) + const [retry, setRetry] = useState>(() => new Set()) + const [focusedIdx, setFocusedIdx] = useState(0) + + useEffect(() => { + onStateChange({ approved, retry, denials }) + }, [approved, retry, denials, onStateChange]) + + const handleSelect = useCallback((value: string) => { + const idx = Number(value) + setApproved(prev => { + const next = new Set(prev) + if (next.has(idx)) next.delete(idx) + else next.add(idx) + return next + }) + }, []) + + const handleFocus = useCallback((value: string) => { + setFocusedIdx(Number(value)) + }, []) + + useInput( + (input, _key) => { + if (input === 'r') { + setRetry(prev => { + const next = new Set(prev) + if (next.has(focusedIdx)) next.delete(focusedIdx) + else next.add(focusedIdx) + return next + }) + // Retry implies approve + setApproved(prev => { + if (prev.has(focusedIdx)) return prev + const next = new Set(prev) + next.add(focusedIdx) + return next + }) } - }; - $[12] = focusedIdx; - $[13] = t7; - } else { - t7 = $[13]; - } - const t8 = denials.length > 0; - let t9; - if ($[14] !== t8) { - t9 = { - isActive: t8 - }; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - useInput(t7, t9); + }, + { isActive: denials.length > 0 }, + ) + if (denials.length === 0) { - let t10; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t10 = No recent denials. Commands denied by the auto mode classifier will appear here.; - $[16] = t10; - } else { - t10 = $[16]; - } - return t10; + return ( + + No recent denials. Commands denied by the auto mode classifier will + appear here. + + ) } - let t10; - if ($[17] !== approved || $[18] !== denials || $[19] !== retry) { - let t11; - if ($[21] !== approved || $[22] !== retry) { - t11 = (d, idx_0) => { - const isApproved = approved.has(idx_0); - const suffix = retry.has(idx_0) ? " (retry)" : ""; - return { - label: {d.display}{suffix}, - value: String(idx_0) - }; - }; - $[21] = approved; - $[22] = retry; - $[23] = t11; - } else { - t11 = $[23]; + + const options = denials.map((d, idx) => { + const isApproved = approved.has(idx) + const suffix = retry.has(idx) ? ' (retry)' : '' + return { + label: ( + + + {d.display} + {suffix} + + ), + value: String(idx), } - t10 = denials.map(t11); - $[17] = approved; - $[18] = denials; - $[19] = retry; - $[20] = t10; - } else { - t10 = $[20]; - } - const options = t10; - let t11; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Commands recently denied by the auto mode classifier.; - $[24] = t11; - } else { - t11 = $[24]; - } - const t12 = Math.min(10, options.length); - let t13; - if ($[25] !== focusHeader || $[26] !== headerFocused || $[27] !== options || $[28] !== t12) { - t13 = {t11} + + + ) } diff --git a/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx b/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx index ffdf65799..e6eefade2 100644 --- a/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx +++ b/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx @@ -1,109 +1,68 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback } from 'react'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { applyPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js'; -import { Dialog } from '../../design-system/Dialog.js'; +import * as React from 'react' +import { useCallback } from 'react' +import { Select } from '../../../components/CustomSelect/select.js' +import { Box, Text } from '../../../ink.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { applyPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js' +import { Dialog } from '../../design-system/Dialog.js' + type Props = { - directoryPath: string; - onRemove: () => void; - onCancel: () => void; - permissionContext: ToolPermissionContext; - setPermissionContext: (context: ToolPermissionContext) => void; -}; -export function RemoveWorkspaceDirectory(t0) { - const $ = _c(19); - const { - directoryPath, - onRemove, - onCancel, - permissionContext, - setPermissionContext - } = t0; - let t1; - if ($[0] !== directoryPath || $[1] !== onRemove || $[2] !== permissionContext || $[3] !== setPermissionContext) { - t1 = () => { - const updatedContext = applyPermissionUpdate(permissionContext, { - type: "removeDirectories", - directories: [directoryPath], - destination: "session" - }); - setPermissionContext(updatedContext); - onRemove(); - }; - $[0] = directoryPath; - $[1] = onRemove; - $[2] = permissionContext; - $[3] = setPermissionContext; - $[4] = t1; - } else { - t1 = $[4]; - } - const handleRemove = t1; - let t2; - if ($[5] !== handleRemove || $[6] !== onCancel) { - t2 = value => { - if (value === "yes") { - handleRemove(); + directoryPath: string + onRemove: () => void + onCancel: () => void + permissionContext: ToolPermissionContext + setPermissionContext: (context: ToolPermissionContext) => void +} + +export function RemoveWorkspaceDirectory({ + directoryPath, + onRemove, + onCancel, + permissionContext, + setPermissionContext, +}: Props): React.ReactNode { + const handleRemove = useCallback(() => { + const updatedContext = applyPermissionUpdate(permissionContext, { + type: 'removeDirectories', + directories: [directoryPath], + destination: 'session', + }) + + setPermissionContext(updatedContext) + onRemove() + }, [directoryPath, permissionContext, setPermissionContext, onRemove]) + + const handleSelect = useCallback( + (value: string) => { + if (value === 'yes') { + handleRemove() } else { - onCancel(); + onCancel() } - }; - $[5] = handleRemove; - $[6] = onCancel; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleSelect = t2; - let t3; - if ($[8] !== directoryPath) { - t3 = {directoryPath}; - $[8] = directoryPath; - $[9] = t3; - } else { - t3 = $[9]; - } - let t4; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Claude Code will no longer have access to files in this directory.; - $[10] = t4; - } else { - t4 = $[10]; - } - let t5; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t5 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== handleSelect || $[13] !== onCancel) { - t6 = + + ) } diff --git a/src/components/permissions/rules/WorkspaceTab.tsx b/src/components/permissions/rules/WorkspaceTab.tsx index 8ed8a09c6..0dab0c7d0 100644 --- a/src/components/permissions/rules/WorkspaceTab.tsx +++ b/src/components/permissions/rules/WorkspaceTab.tsx @@ -1,149 +1,105 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect } from 'react'; -import { getOriginalCwd } from '../../../bootstrap/state.js'; -import type { CommandResultDisplay } from '../../../commands.js'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { useTabHeaderFocus } from '../../design-system/Tabs.js'; +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect } from 'react' +import { getOriginalCwd } from '../../../bootstrap/state.js' +import type { CommandResultDisplay } from '../../../commands.js' +import { Select } from '../../../components/CustomSelect/select.js' +import { Box, Text } from '../../../ink.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { useTabHeaderFocus } from '../../design-system/Tabs.js' + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - toolPermissionContext: ToolPermissionContext; - onRequestAddDirectory: () => void; - onRequestRemoveDirectory: (path: string) => void; - onHeaderFocusChange?: (focused: boolean) => void; -}; + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + toolPermissionContext: ToolPermissionContext + onRequestAddDirectory: () => void + onRequestRemoveDirectory: (path: string) => void + onHeaderFocusChange?: (focused: boolean) => void +} + type DirectoryItem = { - path: string; - isCurrent: boolean; - isDeletable: boolean; -}; -export function WorkspaceTab(t0) { - const $ = _c(23); - const { - onExit, - toolPermissionContext, - onRequestAddDirectory, - onRequestRemoveDirectory, - onHeaderFocusChange - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - let t2; - if ($[0] !== headerFocused || $[1] !== onHeaderFocusChange) { - t1 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t2 = [headerFocused, onHeaderFocusChange]; - $[0] = headerFocused; - $[1] = onHeaderFocusChange; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== toolPermissionContext.additionalWorkingDirectories) { - t3 = Array.from(toolPermissionContext.additionalWorkingDirectories.keys()).map(_temp); - $[4] = toolPermissionContext.additionalWorkingDirectories; - $[5] = t3; - } else { - t3 = $[5]; - } - const additionalDirectories = t3; - let t4; - if ($[6] !== additionalDirectories || $[7] !== onRequestAddDirectory || $[8] !== onRequestRemoveDirectory) { - t4 = selectedValue => { - if (selectedValue === "add-directory") { - onRequestAddDirectory(); - return; + path: string + isCurrent: boolean + isDeletable: boolean +} + +export function WorkspaceTab({ + onExit, + toolPermissionContext, + onRequestAddDirectory, + onRequestRemoveDirectory, + onHeaderFocusChange, +}: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + useEffect(() => { + onHeaderFocusChange?.(headerFocused) + }, [headerFocused, onHeaderFocusChange]) + // Get only additional workspace directories (not the current working directory) + const additionalDirectories = React.useMemo((): DirectoryItem[] => { + return Array.from( + toolPermissionContext.additionalWorkingDirectories.keys(), + ).map(path => ({ + path, + isCurrent: false, + isDeletable: true, + })) + }, [toolPermissionContext.additionalWorkingDirectories]) + + const handleDirectorySelect = useCallback( + (selectedValue: string) => { + if (selectedValue === 'add-directory') { + onRequestAddDirectory() + return } - const directory = additionalDirectories.find(d => d.path === selectedValue); + + const directory = additionalDirectories.find( + d => d.path === selectedValue, + ) if (directory && directory.isDeletable) { - onRequestRemoveDirectory(directory.path); + onRequestRemoveDirectory(directory.path) } - }; - $[6] = additionalDirectories; - $[7] = onRequestAddDirectory; - $[8] = onRequestRemoveDirectory; - $[9] = t4; - } else { - t4 = $[9]; - } - const handleDirectorySelect = t4; - let t5; - if ($[10] !== onExit) { - t5 = () => onExit("Workspace dialog dismissed", { - display: "system" - }); - $[10] = onExit; - $[11] = t5; - } else { - t5 = $[11]; - } - const handleCancel = t5; - let opts; - if ($[12] !== additionalDirectories) { - opts = additionalDirectories.map(_temp2); - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: `Add directory${figures.ellipsis}`, - value: "add-directory" - }; - $[14] = t6; - } else { - t6 = $[14]; - } - opts.push(t6); - $[12] = additionalDirectories; - $[13] = opts; - } else { - opts = $[13]; - } - const options = opts; - let t6; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {`- ${getOriginalCwd()}`}(Original working directory); - $[15] = t6; - } else { - t6 = $[15]; - } - const t7 = Math.min(10, options.length); - let t8; - if ($[16] !== focusHeader || $[17] !== handleCancel || $[18] !== handleDirectorySelect || $[19] !== headerFocused || $[20] !== options || $[21] !== t7) { - t8 = {t6} + + ) } diff --git a/src/components/permissions/shellPermissionHelpers.tsx b/src/components/permissions/shellPermissionHelpers.tsx index e7b5ef621..2c7a2db95 100644 --- a/src/components/permissions/shellPermissionHelpers.tsx +++ b/src/components/permissions/shellPermissionHelpers.tsx @@ -1,59 +1,73 @@ -import { basename, sep } from 'path'; -import React, { type ReactNode } from 'react'; -import { getOriginalCwd } from '../../bootstrap/state.js'; -import { Text } from '../../ink.js'; -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; -import { permissionRuleExtractPrefix } from '../../utils/permissions/shellRuleMatching.js'; +import { basename, sep } from 'path' +import React, { type ReactNode } from 'react' +import { getOriginalCwd } from '../../bootstrap/state.js' +import { Text } from '../../ink.js' +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' +import { permissionRuleExtractPrefix } from '../../utils/permissions/shellRuleMatching.js' + function commandListDisplay(commands: string[]): ReactNode { switch (commands.length) { case 0: - return ''; + return '' case 1: - return {commands[0]}; + return {commands[0]} case 2: - return + return ( + {commands[0]} and {commands[1]} - ; + + ) default: - return + return ( + {commands.slice(0, -1).join(', ')}, and{' '} {commands.slice(-1)[0]} - ; + + ) } } + function commandListDisplayTruncated(commands: string[]): ReactNode { // Check if the plain text representation would be too long - const plainText = commands.join(', '); + const plainText = commands.join(', ') if (plainText.length > 50) { - return 'similar'; + return 'similar' } - return commandListDisplay(commands); + return commandListDisplay(commands) } + function formatPathList(paths: string[]): ReactNode { - if (paths.length === 0) return ''; + if (paths.length === 0) return '' // Extract directory names from paths - const names = paths.map(p => basename(p) || p); + const names = paths.map(p => basename(p) || p) + if (names.length === 1) { - return + return ( + {names[0]} {sep} - ; + + ) } if (names.length === 2) { - return + return ( + {names[0]} {sep} and {names[1]} {sep} - ; + + ) } // For 3+, show first two with "and N more" - return + return ( + {names[0]} {sep}, {names[1]} {sep} and {paths.length - 2} more - ; + + ) } /** @@ -62,102 +76,138 @@ function formatPathList(paths: string[]): ReactNode { * and an optional command transform (e.g., Bash strips output redirections so * filenames don't show as commands). */ -export function generateShellSuggestionsLabel(suggestions: PermissionUpdate[], shellToolName: string, commandTransform?: (command: string) => string): ReactNode | null { +export function generateShellSuggestionsLabel( + suggestions: PermissionUpdate[], + shellToolName: string, + commandTransform?: (command: string) => string, +): ReactNode | null { // Collect all rules for display - const allRules = suggestions.filter(s => s.type === 'addRules').flatMap(s => s.rules || []); + const allRules = suggestions + .filter(s => s.type === 'addRules') + .flatMap(s => s.rules || []) // Separate Read rules from shell rules - const readRules = allRules.filter(r => r.toolName === 'Read'); - const shellRules = allRules.filter(r => r.toolName === shellToolName); + const readRules = allRules.filter(r => r.toolName === 'Read') + const shellRules = allRules.filter(r => r.toolName === shellToolName) // Get directory info - const directories = suggestions.filter(s => s.type === 'addDirectories').flatMap(s => s.directories || []); + const directories = suggestions + .filter(s => s.type === 'addDirectories') + .flatMap(s => s.directories || []) // Extract paths from Read rules (keep separate from directories) - const readPaths = readRules.map(r => r.ruleContent?.replace('/**', '') || '').filter(p => p); + const readPaths = readRules + .map(r => r.ruleContent?.replace('/**', '') || '') + .filter(p => p) // Extract shell command prefixes, optionally transforming for display - const shellCommands = [...new Set(shellRules.flatMap(rule => { - if (!rule.ruleContent) return []; - const command = permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent; - return commandTransform ? commandTransform(command) : command; - }))]; + const shellCommands = [ + ...new Set( + shellRules.flatMap(rule => { + if (!rule.ruleContent) return [] + const command = + permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent + return commandTransform ? commandTransform(command) : command + }), + ), + ] // Check what we have - const hasDirectories = directories.length > 0; - const hasReadPaths = readPaths.length > 0; - const hasCommands = shellCommands.length > 0; + const hasDirectories = directories.length > 0 + const hasReadPaths = readPaths.length > 0 + const hasCommands = shellCommands.length > 0 // Handle single type cases if (hasReadPaths && !hasDirectories && !hasCommands) { // Only Read rules - use "reading from" language if (readPaths.length === 1) { - const firstPath = readPaths[0]!; - const dirName = basename(firstPath) || firstPath; - return + const firstPath = readPaths[0]! + const dirName = basename(firstPath) || firstPath + return ( + Yes, allow reading from {dirName} {sep} from this project - ; + + ) } // Multiple read paths - return + return ( + Yes, allow reading from {formatPathList(readPaths)} from this project - ; + + ) } + if (hasDirectories && !hasReadPaths && !hasCommands) { // Only directory permissions - use "access to" language if (directories.length === 1) { - const firstDir = directories[0]!; - const dirName = basename(firstDir) || firstDir; - return + const firstDir = directories[0]! + const dirName = basename(firstDir) || firstDir + return ( + Yes, and always allow access to {dirName} {sep} from this project - ; + + ) } // Multiple directories - return + return ( + Yes, and always allow access to {formatPathList(directories)} from this project - ; + + ) } + if (hasCommands && !hasDirectories && !hasReadPaths) { // Only shell command permissions - return + return ( + {"Yes, and don't ask again for "} {commandListDisplayTruncated(shellCommands)} commands in{' '} {getOriginalCwd()} - ; + + ) } // Handle mixed cases if ((hasDirectories || hasReadPaths) && !hasCommands) { // Combine directories and read paths since they're both path access - const allPaths = [...directories, ...readPaths]; + const allPaths = [...directories, ...readPaths] if (hasDirectories && hasReadPaths) { // Mixed - use generic "access to" - return + return ( + Yes, and always allow access to {formatPathList(allPaths)} from this project - ; + + ) } } + if ((hasDirectories || hasReadPaths) && hasCommands) { // Build descriptive message for both types - const allPaths = [...directories, ...readPaths]; + const allPaths = [...directories, ...readPaths] // Keep it concise but informative if (allPaths.length === 1 && shellCommands.length === 1) { - return + return ( + Yes, and allow access to {formatPathList(allPaths)} and{' '} {commandListDisplayTruncated(shellCommands)} commands - ; + + ) } - return + + return ( + Yes, and allow {formatPathList(allPaths)} access and{' '} {commandListDisplayTruncated(shellCommands)} commands - ; + + ) } - return null; + + return null } diff --git a/src/components/sandbox/SandboxConfigTab.tsx b/src/components/sandbox/SandboxConfigTab.tsx index 50d77344b..58bfba688 100644 --- a/src/components/sandbox/SandboxConfigTab.tsx +++ b/src/components/sandbox/SandboxConfigTab.tsx @@ -1,44 +1,135 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { SandboxManager, shouldAllowManagedSandboxDomainsOnly } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxConfigTab() { - const $ = _c(3); - const isEnabled = SandboxManager.isSandboxingEnabled(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - const depCheck = SandboxManager.checkDependencies(); - t0 = depCheck.warnings.length > 0 ? {depCheck.warnings.map(_temp)} : null; - $[0] = t0; - } else { - t0 = $[0]; - } - const warningsNote = t0; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + SandboxManager, + shouldAllowManagedSandboxDomainsOnly, +} from '../../utils/sandbox/sandbox-adapter.js' + +export function SandboxConfigTab(): React.ReactNode { + const isEnabled = SandboxManager.isSandboxingEnabled() + + // Show warnings (e.g., seccomp not available on Linux) + const depCheck = SandboxManager.checkDependencies() + const warningsNote = + depCheck.warnings.length > 0 ? ( + + {depCheck.warnings.map((w, i) => ( + + {w} + + ))} + + ) : null + if (!isEnabled) { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sandbox is not enabled{warningsNote}; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - const fsReadConfig = SandboxManager.getFsReadConfig(); - const fsWriteConfig = SandboxManager.getFsWriteConfig(); - const networkConfig = SandboxManager.getNetworkRestrictionConfig(); - const allowUnixSockets = SandboxManager.getAllowUnixSockets(); - const excludedCommands = SandboxManager.getExcludedCommands(); - const globPatternWarnings = SandboxManager.getLinuxGlobPatternWarnings(); - t1 = Excluded Commands:{excludedCommands.length > 0 ? excludedCommands.join(", ") : "None"}{fsReadConfig.denyOnly.length > 0 && Filesystem Read Restrictions:Denied: {fsReadConfig.denyOnly.join(", ")}{fsReadConfig.allowWithinDeny && fsReadConfig.allowWithinDeny.length > 0 && Allowed within denied: {fsReadConfig.allowWithinDeny.join(", ")}}}{fsWriteConfig.allowOnly.length > 0 && Filesystem Write Restrictions:Allowed: {fsWriteConfig.allowOnly.join(", ")}{fsWriteConfig.denyWithinAllow.length > 0 && Denied within allowed: {fsWriteConfig.denyWithinAllow.join(", ")}}}{(networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0 || networkConfig.deniedHosts && networkConfig.deniedHosts.length > 0) && Network Restrictions{shouldAllowManagedSandboxDomainsOnly() ? " (Managed)" : ""}:{networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0 && Allowed: {networkConfig.allowedHosts.join(", ")}}{networkConfig.deniedHosts && networkConfig.deniedHosts.length > 0 && Denied: {networkConfig.deniedHosts.join(", ")}}}{allowUnixSockets && allowUnixSockets.length > 0 && Allowed Unix Sockets:{allowUnixSockets.join(", ")}}{globPatternWarnings.length > 0 && ⚠ Warning: Glob patterns not fully supported on LinuxThe following patterns will be ignored:{" "}{globPatternWarnings.slice(0, 3).join(", ")}{globPatternWarnings.length > 3 && ` (${globPatternWarnings.length - 3} more)`}}{warningsNote}; - $[2] = t1; - } else { - t1 = $[2]; + return ( + + Sandbox is not enabled + {warningsNote} + + ) } - return t1; -} -function _temp(w, i) { - return {w}; + + const fsReadConfig = SandboxManager.getFsReadConfig() + const fsWriteConfig = SandboxManager.getFsWriteConfig() + const networkConfig = SandboxManager.getNetworkRestrictionConfig() + const allowUnixSockets = SandboxManager.getAllowUnixSockets() + const excludedCommands = SandboxManager.getExcludedCommands() + const globPatternWarnings = SandboxManager.getLinuxGlobPatternWarnings() + + return ( + + {/* Excluded Commands */} + + + Excluded Commands: + + + {excludedCommands.length > 0 ? excludedCommands.join(', ') : 'None'} + + + + {/* Filesystem Read Restrictions */} + {fsReadConfig.denyOnly.length > 0 && ( + + + Filesystem Read Restrictions: + + Denied: {fsReadConfig.denyOnly.join(', ')} + {fsReadConfig.allowWithinDeny && + fsReadConfig.allowWithinDeny.length > 0 && ( + + Allowed within denied: {fsReadConfig.allowWithinDeny.join(', ')} + + )} + + )} + + {/* Filesystem Write Restrictions */} + {fsWriteConfig.allowOnly.length > 0 && ( + + + Filesystem Write Restrictions: + + Allowed: {fsWriteConfig.allowOnly.join(', ')} + {fsWriteConfig.denyWithinAllow.length > 0 && ( + + Denied within allowed: {fsWriteConfig.denyWithinAllow.join(', ')} + + )} + + )} + + {/* Network Restrictions */} + {((networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0) || + (networkConfig.deniedHosts && + networkConfig.deniedHosts.length > 0)) && ( + + + Network Restrictions + {shouldAllowManagedSandboxDomainsOnly() ? ' (Managed)' : ''}: + + {networkConfig.allowedHosts && + networkConfig.allowedHosts.length > 0 && ( + + Allowed: {networkConfig.allowedHosts.join(', ')} + + )} + {networkConfig.deniedHosts && + networkConfig.deniedHosts.length > 0 && ( + + Denied: {networkConfig.deniedHosts.join(', ')} + + )} + + )} + + {/* Unix Sockets */} + {allowUnixSockets && allowUnixSockets.length > 0 && ( + + + Allowed Unix Sockets: + + {allowUnixSockets.join(', ')} + + )} + + {/* Linux Glob Pattern Warning */} + {globPatternWarnings.length > 0 && ( + + + ⚠ Warning: Glob patterns not fully supported on Linux + + + The following patterns will be ignored:{' '} + {globPatternWarnings.slice(0, 3).join(', ')} + {globPatternWarnings.length > 3 && + ` (${globPatternWarnings.length - 3} more)`} + + + )} + + {warningsNote} + + ) } diff --git a/src/components/sandbox/SandboxDependenciesTab.tsx b/src/components/sandbox/SandboxDependenciesTab.tsx index 53cff39f4..75091910d 100644 --- a/src/components/sandbox/SandboxDependenciesTab.tsx +++ b/src/components/sandbox/SandboxDependenciesTab.tsx @@ -1,119 +1,124 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getPlatform } from '../../utils/platform.js'; -import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { getPlatform } from '../../utils/platform.js' +import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' + type Props = { - depCheck: SandboxDependencyCheck; -}; -export function SandboxDependenciesTab(t0) { - const $ = _c(24); - const { - depCheck - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getPlatform(); - $[0] = t1; - } else { - t1 = $[0]; - } - const platform = t1; - const isMac = platform === "macos"; - let t2; - if ($[1] !== depCheck.errors) { - t2 = depCheck.errors.some(_temp); - $[1] = depCheck.errors; - $[2] = t2; - } else { - t2 = $[2]; - } - const rgMissing = t2; - let t3; - if ($[3] !== depCheck.errors) { - t3 = depCheck.errors.some(_temp2); - $[3] = depCheck.errors; - $[4] = t3; - } else { - t3 = $[4]; - } - const bwrapMissing = t3; - let t4; - if ($[5] !== depCheck.errors) { - t4 = depCheck.errors.some(_temp3); - $[5] = depCheck.errors; - $[6] = t4; - } else { - t4 = $[6]; - } - const socatMissing = t4; - const seccompMissing = depCheck.warnings.length > 0; - let t5; - if ($[7] !== bwrapMissing || $[8] !== depCheck.errors || $[9] !== rgMissing || $[10] !== seccompMissing || $[11] !== socatMissing) { - const otherErrors = depCheck.errors.filter(_temp4); - const rgInstallHint = isMac ? "brew install ripgrep" : "apt install ripgrep"; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = isMac && seatbelt: built-in (macOS); - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - let t8; - if ($[14] !== rgMissing) { - t7 = ripgrep (rg):{" "}{rgMissing ? not found : found}; - t8 = rgMissing && {" "}· {rgInstallHint}; - $[14] = rgMissing; - $[15] = t7; - $[16] = t8; - } else { - t7 = $[15]; - t8 = $[16]; - } - let t9; - if ($[17] !== t7 || $[18] !== t8) { - t9 = {t7}{t8}; - $[17] = t7; - $[18] = t8; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== bwrapMissing || $[21] !== seccompMissing || $[22] !== socatMissing) { - t10 = !isMac && <>bubblewrap (bwrap):{" "}{bwrapMissing ? not installed : installed}{bwrapMissing && {" "}· apt install bubblewrap}socat:{" "}{socatMissing ? not installed : installed}{socatMissing && {" "}· apt install socat}seccomp filter:{" "}{seccompMissing ? not installed : installed}{seccompMissing && (required to block unix domain sockets)}{seccompMissing && {" "}· npm install -g @anthropic-ai/sandbox-runtime{" "}· or copy vendor/seccomp/* from sandbox-runtime and set{" "}sandbox.seccomp.bpfPath and applyPath in settings.json}; - $[20] = bwrapMissing; - $[21] = seccompMissing; - $[22] = socatMissing; - $[23] = t10; - } else { - t10 = $[23]; - } - t5 = {t6}{t9}{t10}{otherErrors.map(_temp5)}; - $[7] = bwrapMissing; - $[8] = depCheck.errors; - $[9] = rgMissing; - $[10] = seccompMissing; - $[11] = socatMissing; - $[12] = t5; - } else { - t5 = $[12]; - } - return t5; + depCheck: SandboxDependencyCheck } -function _temp5(err) { - return {err}; -} -function _temp4(e_2) { - return !e_2.includes("ripgrep") && !e_2.includes("bwrap") && !e_2.includes("socat"); -} -function _temp3(e_1) { - return e_1.includes("socat"); -} -function _temp2(e_0) { - return e_0.includes("bwrap"); -} -function _temp(e) { - return e.includes("ripgrep"); + +export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { + const platform = getPlatform() + const isMac = platform === 'macos' + + // ripgrep is required on all platforms (used to scan for dangerous dirs). + // On macOS, seatbelt is built into the OS — ripgrep is the only runtime dep. + // On Linux/WSL, bwrap + socat are required, seccomp is optional. + // + // #31804: previously this tab unconditionally rendered Linux deps (bwrap, + // socat, seccomp). When ripgrep was missing on macOS, users saw confusing + // Linux install instructions and no mention of the actual problem. + const rgMissing = depCheck.errors.some(e => e.includes('ripgrep')) + const bwrapMissing = depCheck.errors.some(e => e.includes('bwrap')) + const socatMissing = depCheck.errors.some(e => e.includes('socat')) + const seccompMissing = depCheck.warnings.length > 0 + + // Any errors we don't have a dedicated row for — render verbatim so they + // aren't silently swallowed (e.g. "Unsupported platform" or future deps). + const otherErrors = depCheck.errors.filter( + e => !e.includes('ripgrep') && !e.includes('bwrap') && !e.includes('socat'), + ) + + const rgInstallHint = isMac ? 'brew install ripgrep' : 'apt install ripgrep' + + return ( + + {isMac && ( + + + seatbelt: built-in (macOS) + + + )} + + + + ripgrep (rg):{' '} + {rgMissing ? ( + not found + ) : ( + found + )} + + {rgMissing && ( + + {' '}· {rgInstallHint} + + )} + + + {!isMac && ( + <> + + + bubblewrap (bwrap):{' '} + {bwrapMissing ? ( + not installed + ) : ( + installed + )} + + {bwrapMissing && ( + {' '}· apt install bubblewrap + )} + + + + + socat:{' '} + {socatMissing ? ( + not installed + ) : ( + installed + )} + + {socatMissing && {' '}· apt install socat} + + + + + seccomp filter:{' '} + {seccompMissing ? ( + not installed + ) : ( + installed + )} + {seccompMissing && ( + (required to block unix domain sockets) + )} + + {seccompMissing && ( + + + {' '}· npm install -g @anthropic-ai/sandbox-runtime + + + {' '}· or copy vendor/seccomp/* from sandbox-runtime and set + + + {' '}sandbox.seccomp.bpfPath and applyPath in settings.json + + + )} + + + )} + + {otherErrors.map(err => ( + + {err} + + ))} + + ) } diff --git a/src/components/sandbox/SandboxDoctorSection.tsx b/src/components/sandbox/SandboxDoctorSection.tsx index 747369108..5e7198c38 100644 --- a/src/components/sandbox/SandboxDoctorSection.tsx +++ b/src/components/sandbox/SandboxDoctorSection.tsx @@ -1,45 +1,48 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxDoctorSection() { - const $ = _c(2); +import React from 'react' +import { Box, Text } from '../../ink.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' + +export function SandboxDoctorSection(): React.ReactNode { if (!SandboxManager.isSupportedPlatform()) { - return null; + return null } + if (!SandboxManager.isSandboxEnabledInSettings()) { - return null; + return null } - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const depCheck = SandboxManager.checkDependencies(); - const hasErrors = depCheck.errors.length > 0; - const hasWarnings = depCheck.warnings.length > 0; - if (!hasErrors && !hasWarnings) { - t1 = null; - break bb0; - } - const statusColor = hasErrors ? "error" as const : "warning" as const; - const statusText = hasErrors ? "Missing dependencies" : "Available (with warnings)"; - t0 = Sandbox└ Status: {statusText}{depCheck.errors.map(_temp)}{depCheck.warnings.map(_temp2)}{hasErrors && └ Run /sandbox for install instructions}; - } - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; + + const depCheck = SandboxManager.checkDependencies() + const hasErrors = depCheck.errors.length > 0 + const hasWarnings = depCheck.warnings.length > 0 + + if (!hasErrors && !hasWarnings) { + return null } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - return t0; -} -function _temp2(w, i_0) { - return └ {w}; -} -function _temp(e, i) { - return └ {e}; + + const statusColor = hasErrors ? ('error' as const) : ('warning' as const) + const statusText = hasErrors + ? 'Missing dependencies' + : 'Available (with warnings)' + + return ( + + Sandbox + + └ Status: {statusText} + + {depCheck.errors.map((e, i) => ( + + └ {e} + + ))} + {depCheck.warnings.map((w, i) => ( + + └ {w} + + ))} + {hasErrors && ( + └ Run /sandbox for install instructions + )} + + ) } diff --git a/src/components/sandbox/SandboxOverridesTab.tsx b/src/components/sandbox/SandboxOverridesTab.tsx index c13eb0a8e..74c6d224b 100644 --- a/src/components/sandbox/SandboxOverridesTab.tsx +++ b/src/components/sandbox/SandboxOverridesTab.tsx @@ -1,192 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import type { CommandResultDisplay } from '../../types/command.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { Select } from '../CustomSelect/select.js'; -import { useTabHeaderFocus } from '../design-system/Tabs.js'; +import React from 'react' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import type { CommandResultDisplay } from '../../types/command.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { Select } from '../CustomSelect/select.js' +import { useTabHeaderFocus } from '../design-system/Tabs.js' + type Props = { - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -type OverrideMode = 'open' | 'closed'; -export function SandboxOverridesTab(t0) { - const $ = _c(5); - const { - onComplete - } = t0; - const isEnabled = SandboxManager.isSandboxingEnabled(); - const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy(); - const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed(); + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type OverrideMode = 'open' | 'closed' + +export function SandboxOverridesTab({ onComplete }: Props): React.ReactNode { + const isEnabled = SandboxManager.isSandboxingEnabled() + const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy() + const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed() + if (!isEnabled) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sandbox is not enabled. Enable sandbox to configure override settings.; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + return ( + + + Sandbox is not enabled. Enable sandbox to configure override settings. + + + ) } + if (isLocked) { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Override settings are managed by a higher-priority configuration and cannot be changed locally.; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {t1}Current setting:{" "}{currentAllowUnsandboxed ? "Allow unsandboxed fallback" : "Strict sandbox mode"}; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; - } - let t1; - if ($[3] !== onComplete) { - t1 = ; - $[3] = onComplete; - $[4] = t1; - } else { - t1 = $[4]; + return ( + + + Override settings are managed by a higher-priority configuration and + cannot be changed locally. + + + + Current setting:{' '} + {currentAllowUnsandboxed + ? 'Allow unsandboxed fallback' + : 'Strict sandbox mode'} + + + + ) } - return t1; + + return ( + + ) } // Split so useTabHeaderFocus() only runs when the Select renders. Calling it // above the early returns registers a down-arrow opt-in even when we return // static text — pressing ↓ then blurs the header with no way back. -function OverridesSelect(t0) { - const $ = _c(25); - const { - onComplete, - currentMode - } = t0; - const [theme] = useTheme(); - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - if ($[0] !== theme) { - t1 = color("success", theme)("(current)"); - $[0] = theme; - $[1] = t1; - } else { - t1 = $[1]; - } - const currentIndicator = t1; - const t2 = currentMode === "open" ? `Allow unsandboxed fallback ${currentIndicator}` : "Allow unsandboxed fallback"; - let t3; - if ($[2] !== t2) { - t3 = { - label: t2, - value: "open" - }; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const t4 = currentMode === "closed" ? `Strict sandbox mode ${currentIndicator}` : "Strict sandbox mode"; - let t5; - if ($[4] !== t4) { - t5 = { - label: t4, - value: "closed" - }; - $[4] = t4; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== t3 || $[7] !== t5) { - t6 = [t3, t5]; - $[6] = t3; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - const options = t6; - let t7; - if ($[9] !== onComplete) { - t7 = async function handleSelect(value) { - const mode = value as OverrideMode; - await SandboxManager.setSandboxSettings({ - allowUnsandboxedCommands: mode === "open" - }); - const message = mode === "open" ? "\u2713 Unsandboxed fallback allowed - commands can run outside sandbox when necessary" : "\u2713 Strict sandbox mode - all commands must run in sandbox or be excluded via the `excludedCommands` option"; - onComplete(message); - }; - $[9] = onComplete; - $[10] = t7; - } else { - t7 = $[10]; - } - const handleSelect = t7; - let t8; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Configure Overrides:; - $[11] = t8; - } else { - t8 = $[11]; - } - let t9; - if ($[12] !== onComplete) { - t9 = () => onComplete(undefined, { - display: "skip" - }); - $[12] = onComplete; - $[13] = t9; - } else { - t9 = $[13]; - } - let t10; - if ($[14] !== focusHeader || $[15] !== handleSelect || $[16] !== headerFocused || $[17] !== options || $[18] !== t9) { - t10 = onComplete(undefined, { display: 'skip' })} + onUpFromFirstItem={focusHeader} + isDisabled={headerFocused} + /> + + + + Allow unsandboxed fallback: + {' '} + When a command fails due to sandbox restrictions, Claude can retry + with dangerouslyDisableSandbox to run outside the sandbox (falling + back to default permissions). + + + + Strict sandbox mode: + {' '} + All bash commands invoked by the model must run in the sandbox unless + they are explicitly listed in excludedCommands. + + + Learn more:{' '} + + code.claude.com/docs/en/sandboxing#configure-sandboxing + + + + + ) } diff --git a/src/components/sandbox/SandboxSettings.tsx b/src/components/sandbox/SandboxSettings.tsx index b8c403efb..05998577b 100644 --- a/src/components/sandbox/SandboxSettings.tsx +++ b/src/components/sandbox/SandboxSettings.tsx @@ -1,295 +1,211 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { CommandResultDisplay } from '../../types/command.js'; -import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'; -import { Select } from '../CustomSelect/select.js'; -import { Pane } from '../design-system/Pane.js'; -import { Tab, Tabs, useTabHeaderFocus } from '../design-system/Tabs.js'; -import { SandboxConfigTab } from './SandboxConfigTab.js'; -import { SandboxDependenciesTab } from './SandboxDependenciesTab.js'; -import { SandboxOverridesTab } from './SandboxOverridesTab.js'; +import React from 'react' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { CommandResultDisplay } from '../../types/command.js' +import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' +import { Select } from '../CustomSelect/select.js' +import { Pane } from '../design-system/Pane.js' +import { Tab, Tabs, useTabHeaderFocus } from '../design-system/Tabs.js' +import { SandboxConfigTab } from './SandboxConfigTab.js' +import { SandboxDependenciesTab } from './SandboxDependenciesTab.js' +import { SandboxOverridesTab } from './SandboxOverridesTab.js' + type Props = { - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - depCheck: SandboxDependencyCheck; -}; -type SandboxMode = 'auto-allow' | 'regular' | 'disabled'; -export function SandboxSettings(t0) { - const $ = _c(34); - const { - onComplete, - depCheck - } = t0; - const [theme] = useTheme(); - const currentEnabled = SandboxManager.isSandboxingEnabled(); - const currentAutoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const hasWarnings = depCheck.warnings.length > 0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getSettings_DEPRECATED(); - $[0] = t1; - } else { - t1 = $[0]; - } - const settings = t1; - const allowAllUnixSockets = settings.sandbox?.network?.allowAllUnixSockets; - const showSocketWarning = hasWarnings && !allowAllUnixSockets; - const getCurrentMode = () => { - if (!currentEnabled) { - return "disabled"; - } - if (currentAutoAllow) { - return "auto-allow"; + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + depCheck: SandboxDependencyCheck +} + +type SandboxMode = 'auto-allow' | 'regular' | 'disabled' + +export function SandboxSettings({ + onComplete, + depCheck, +}: Props): React.ReactNode { + const [theme] = useTheme() + const currentEnabled = SandboxManager.isSandboxingEnabled() + const currentAutoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled() + const hasWarnings = depCheck.warnings.length > 0 + const settings = getSettings_DEPRECATED() + const allowAllUnixSockets = settings.sandbox?.network?.allowAllUnixSockets + // Show warning if seccomp missing AND user hasn't allowed all unix sockets + const showSocketWarning = hasWarnings && !allowAllUnixSockets + + // Determine current mode + const getCurrentMode = (): SandboxMode => { + if (!currentEnabled) return 'disabled' + if (currentAutoAllow) return 'auto-allow' + return 'regular' + } + + const currentMode = getCurrentMode() + const currentIndicator = color('success', theme)(`(current)`) + + const options = [ + { + label: + currentMode === 'auto-allow' + ? `Sandbox BashTool, with auto-allow ${currentIndicator}` + : 'Sandbox BashTool, with auto-allow', + value: 'auto-allow', + }, + { + label: + currentMode === 'regular' + ? `Sandbox BashTool, with regular permissions ${currentIndicator}` + : 'Sandbox BashTool, with regular permissions', + value: 'regular', + }, + { + label: + currentMode === 'disabled' + ? `No Sandbox ${currentIndicator}` + : 'No Sandbox', + value: 'disabled', + }, + ] + + async function handleSelect(value: string) { + const mode = value as SandboxMode + + switch (mode) { + case 'auto-allow': + await SandboxManager.setSandboxSettings({ + enabled: true, + autoAllowBashIfSandboxed: true, + }) + onComplete('✓ Sandbox enabled with auto-allow for bash commands') + break + case 'regular': + await SandboxManager.setSandboxSettings({ + enabled: true, + autoAllowBashIfSandboxed: false, + }) + onComplete('✓ Sandbox enabled with regular bash permissions') + break + case 'disabled': + await SandboxManager.setSandboxSettings({ + enabled: false, + autoAllowBashIfSandboxed: false, + }) + onComplete('○ Sandbox disabled') + break } - return "regular"; - }; - const currentMode = getCurrentMode(); - let t2; - if ($[1] !== theme) { - t2 = color("success", theme)("(current)"); - $[1] = theme; - $[2] = t2; - } else { - t2 = $[2]; - } - const currentIndicator = t2; - const t3 = currentMode === "auto-allow" ? `Sandbox BashTool, with auto-allow ${currentIndicator}` : "Sandbox BashTool, with auto-allow"; - let t4; - if ($[3] !== t3) { - t4 = { - label: t3, - value: "auto-allow" - }; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - const t5 = currentMode === "regular" ? `Sandbox BashTool, with regular permissions ${currentIndicator}` : "Sandbox BashTool, with regular permissions"; - let t6; - if ($[5] !== t5) { - t6 = { - label: t5, - value: "regular" - }; - $[5] = t5; - $[6] = t6; - } else { - t6 = $[6]; - } - const t7 = currentMode === "disabled" ? `No Sandbox ${currentIndicator}` : "No Sandbox"; - let t8; - if ($[7] !== t7) { - t8 = { - label: t7, - value: "disabled" - }; - $[7] = t7; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] !== t4 || $[10] !== t6 || $[11] !== t8) { - t9 = [t4, t6, t8]; - $[9] = t4; - $[10] = t6; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - const options = t9; - let t10; - if ($[13] !== onComplete) { - t10 = async function handleSelect(value) { - const mode = value as SandboxMode; - bb33: switch (mode) { - case "auto-allow": - { - await SandboxManager.setSandboxSettings({ - enabled: true, - autoAllowBashIfSandboxed: true - }); - onComplete("\u2713 Sandbox enabled with auto-allow for bash commands"); - break bb33; - } - case "regular": - { - await SandboxManager.setSandboxSettings({ - enabled: true, - autoAllowBashIfSandboxed: false - }); - onComplete("\u2713 Sandbox enabled with regular bash permissions"); - break bb33; - } - case "disabled": - { - await SandboxManager.setSandboxSettings({ - enabled: false, - autoAllowBashIfSandboxed: false - }); - onComplete("\u25CB Sandbox disabled"); - } - } - }; - $[13] = onComplete; - $[14] = t10; - } else { - t10 = $[14]; - } - const handleSelect = t10; - let t11; - if ($[15] !== onComplete) { - t11 = { - "confirm:no": () => onComplete(undefined, { - display: "skip" - }) - }; - $[15] = onComplete; - $[16] = t11; - } else { - t11 = $[16]; - } - let t12; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t12 = { - context: "Settings" - }; - $[17] = t12; - } else { - t12 = $[17]; - } - useKeybindings(t11, t12); - let t13; - if ($[18] !== handleSelect || $[19] !== onComplete || $[20] !== options || $[21] !== showSocketWarning) { - t13 = ; - $[18] = handleSelect; - $[19] = onComplete; - $[20] = options; - $[21] = showSocketWarning; - $[22] = t13; - } else { - t13 = $[22]; } - const modeTab = t13; - let t14; - if ($[23] !== onComplete) { - t14 = ; - $[23] = onComplete; - $[24] = t14; - } else { - t14 = $[24]; - } - const overridesTab = t14; - let t15; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t15 = ; - $[25] = t15; - } else { - t15 = $[25]; - } - const configTab = t15; - const hasErrors = depCheck.errors.length > 0; - let t16; - if ($[26] !== depCheck || $[27] !== hasErrors || $[28] !== hasWarnings || $[29] !== modeTab || $[30] !== overridesTab) { - t16 = hasErrors ? [] : [modeTab, ...(hasWarnings ? [] : []), overridesTab, configTab]; - $[26] = depCheck; - $[27] = hasErrors; - $[28] = hasWarnings; - $[29] = modeTab; - $[30] = overridesTab; - $[31] = t16; - } else { - t16 = $[31]; - } - const tabs = t16; - let t17; - if ($[32] !== tabs) { - t17 = {tabs}; - $[32] = tabs; - $[33] = t17; - } else { - t17 = $[33]; - } - return t17; + + useKeybindings( + { + 'confirm:no': () => onComplete(undefined, { display: 'skip' }), + }, + { context: 'Settings' }, + ) + + const modeTab = ( + + + + ) + + const overridesTab = ( + + + + ) + + const configTab = ( + + + + ) + + const hasErrors = depCheck.errors.length > 0 + + // If required deps missing, only show Dependencies tab + // If only optional deps missing, show all tabs + const tabs = hasErrors + ? [ + + + , + ] + : [ + modeTab, + ...(hasWarnings + ? [ + + + , + ] + : []), + overridesTab, + configTab, + ] + + return ( + + + {tabs} + + + ) } -function SandboxModeTab(t0) { - const $ = _c(16); - const { - showSocketWarning, - options, - onSelect, - onComplete - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - if ($[0] !== showSocketWarning) { - t1 = showSocketWarning && Cannot block unix domain sockets (see Dependencies tab); - $[0] = showSocketWarning; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Configure Mode:; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onComplete) { - t3 = () => onComplete(undefined, { - display: "skip" - }); - $[3] = onComplete; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== focusHeader || $[6] !== headerFocused || $[7] !== onSelect || $[8] !== options || $[9] !== t3) { - t4 = onComplete(undefined, { display: 'skip' })} + onUpFromFirstItem={focusHeader} + isDisabled={headerFocused} + /> + + + + Auto-allow mode: + {' '} + Commands will try to run in the sandbox automatically, and attempts to + run outside of the sandbox fallback to regular permissions. Explicit + ask/deny rules are always respected. + + + Learn more:{' '} + + code.claude.com/docs/en/sandboxing + + + + + ) } diff --git a/src/components/shell/ExpandShellOutputContext.tsx b/src/components/shell/ExpandShellOutputContext.tsx index 271d9f313..cc6628b64 100644 --- a/src/components/shell/ExpandShellOutputContext.tsx +++ b/src/components/shell/ExpandShellOutputContext.tsx @@ -1,6 +1,5 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useContext } from 'react'; +import * as React from 'react' +import { useContext } from 'react' /** * Context to indicate that shell output should be shown in full (not truncated). @@ -9,27 +8,24 @@ import { useContext } from 'react'; * This follows the same pattern as MessageResponseContext and SubAgentContext - * a boolean context that child components can check to modify their behavior. */ -const ExpandShellOutputContext = React.createContext(false); -export function ExpandShellOutputProvider(t0) { - const $ = _c(2); - const { - children - } = t0; - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const ExpandShellOutputContext = React.createContext(false) + +export function ExpandShellOutputProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + + {children} + + ) } /** * Returns true if this component is rendered inside an ExpandShellOutputProvider, * indicating the shell output should be shown in full rather than truncated. */ -export function useExpandShellOutput() { - return useContext(ExpandShellOutputContext); +export function useExpandShellOutput(): boolean { + return useContext(ExpandShellOutputContext) } diff --git a/src/components/shell/OutputLine.tsx b/src/components/shell/OutputLine.tsx index 16832239d..cf72760db 100644 --- a/src/components/shell/OutputLine.tsx +++ b/src/components/shell/OutputLine.tsx @@ -1,106 +1,98 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useMemo } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Ansi, Text } from '../../ink.js'; -import { createHyperlink } from '../../utils/hyperlink.js'; -import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'; -import { renderTruncatedContent } from '../../utils/terminal.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { InVirtualListContext } from '../messageActions.js'; -import { useExpandShellOutput } from './ExpandShellOutputContext.js'; +import * as React from 'react' +import { useMemo } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Ansi, Text } from '../../ink.js' +import { createHyperlink } from '../../utils/hyperlink.js' +import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' +import { renderTruncatedContent } from '../../utils/terminal.js' +import { MessageResponse } from '../MessageResponse.js' +import { InVirtualListContext } from '../messageActions.js' +import { useExpandShellOutput } from './ExpandShellOutputContext.js' + export function tryFormatJson(line: string): string { try { - const parsed = jsonParse(line); - const stringified = jsonStringify(parsed); + const parsed = jsonParse(line) + const stringified = jsonStringify(parsed) // Check if precision was lost during JSON round-trip // This happens when large integers exceed Number.MAX_SAFE_INTEGER // We normalize both strings by removing whitespace and unnecessary // escapes (\/ is valid but optional in JSON) for comparison - const normalizedOriginal = line.replace(/\\\//g, '/').replace(/\s+/g, ''); - const normalizedStringified = stringified.replace(/\s+/g, ''); + const normalizedOriginal = line.replace(/\\\//g, '/').replace(/\s+/g, '') + const normalizedStringified = stringified.replace(/\s+/g, '') + if (normalizedOriginal !== normalizedStringified) { // Precision loss detected - return original line unformatted - return line; + return line } - return jsonStringify(parsed, null, 2); + + return jsonStringify(parsed, null, 2) } catch { - return line; + return line } } -const MAX_JSON_FORMAT_LENGTH = 10_000; + +const MAX_JSON_FORMAT_LENGTH = 10_000 + export function tryJsonFormatContent(content: string): string { if (content.length > MAX_JSON_FORMAT_LENGTH) { - return content; + return content } - const allLines = content.split('\n'); - return allLines.map(tryFormatJson).join('\n'); + const allLines = content.split('\n') + return allLines.map(tryFormatJson).join('\n') } // Match http(s) URLs inside JSON string values. Conservative: no quotes, // no whitespace, no trailing comma/brace that'd be JSON structure. -const URL_IN_JSON = /https?:\/\/[^\s"'<>\\]+/g; +const URL_IN_JSON = /https?:\/\/[^\s"'<>\\]+/g + export function linkifyUrlsInText(content: string): string { - return content.replace(URL_IN_JSON, url => createHyperlink(url)); + return content.replace(URL_IN_JSON, url => createHyperlink(url)) } -export function OutputLine(t0) { - const $ = _c(11); - const { - content, - verbose, - isError, - isWarning, - linkifyUrls - } = t0; - const { - columns - } = useTerminalSize(); - const expandShellOutput = useExpandShellOutput(); - const inVirtualList = React.useContext(InVirtualListContext); - const shouldShowFull = verbose || expandShellOutput; - let t1; - if ($[0] !== columns || $[1] !== content || $[2] !== inVirtualList || $[3] !== linkifyUrls || $[4] !== shouldShowFull) { - bb0: { - let formatted = tryJsonFormatContent(content); - if (linkifyUrls) { - formatted = linkifyUrlsInText(formatted); - } - if (shouldShowFull) { - t1 = stripUnderlineAnsi(formatted); - break bb0; - } - t1 = stripUnderlineAnsi(renderTruncatedContent(formatted, columns, inVirtualList)); + +export function OutputLine({ + content, + verbose, + isError, + isWarning, + linkifyUrls, +}: { + content: string + verbose: boolean + isError?: boolean + isWarning?: boolean + linkifyUrls?: boolean +}): React.ReactNode { + const { columns } = useTerminalSize() + // Context-based expansion for latest user shell output (from ! commands) + const expandShellOutput = useExpandShellOutput() + const inVirtualList = React.useContext(InVirtualListContext) + + // Show full output if verbose mode OR if this is the latest user shell output + const shouldShowFull = verbose || expandShellOutput + + const formattedContent = useMemo(() => { + let formatted = tryJsonFormatContent(content) + if (linkifyUrls) { + formatted = linkifyUrlsInText(formatted) } - $[0] = columns; - $[1] = content; - $[2] = inVirtualList; - $[3] = linkifyUrls; - $[4] = shouldShowFull; - $[5] = t1; - } else { - t1 = $[5]; - } - const formattedContent = t1; - const color = isError ? "error" : isWarning ? "warning" : undefined; - let t2; - if ($[6] !== formattedContent) { - t2 = {formattedContent}; - $[6] = formattedContent; - $[7] = t2; - } else { - t2 = $[7]; - } - let t3; - if ($[8] !== color || $[9] !== t2) { - t3 = {t2}; - $[8] = color; - $[9] = t2; - $[10] = t3; - } else { - t3 = $[10]; - } - return t3; + if (shouldShowFull) { + return stripUnderlineAnsi(formatted) + } + return stripUnderlineAnsi( + renderTruncatedContent(formatted, columns, inVirtualList), + ) + }, [content, shouldShowFull, columns, linkifyUrls, inVirtualList]) + + const color = isError ? 'error' : isWarning ? 'warning' : undefined + + return ( + + + {formattedContent} + + + ) } /** @@ -112,6 +104,8 @@ export function OutputLine(t0) { */ export function stripUnderlineAnsi(content: string): string { return content.replace( - // eslint-disable-next-line no-control-regex - /\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g, ''); + // eslint-disable-next-line no-control-regex + /\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g, + '', + ) } diff --git a/src/components/shell/ShellProgressMessage.tsx b/src/components/shell/ShellProgressMessage.tsx index 45d07ff56..99da5ac3b 100644 --- a/src/components/shell/ShellProgressMessage.tsx +++ b/src/components/shell/ShellProgressMessage.tsx @@ -1,149 +1,87 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import stripAnsi from 'strip-ansi'; -import { Box, Text } from '../../ink.js'; -import { formatFileSize } from '../../utils/format.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { OffscreenFreeze } from '../OffscreenFreeze.js'; -import { ShellTimeDisplay } from './ShellTimeDisplay.js'; +import React from 'react' +import stripAnsi from 'strip-ansi' +import { Box, Text } from '../../ink.js' +import { formatFileSize } from '../../utils/format.js' +import { MessageResponse } from '../MessageResponse.js' +import { OffscreenFreeze } from '../OffscreenFreeze.js' +import { ShellTimeDisplay } from './ShellTimeDisplay.js' + type Props = { - output: string; - fullOutput: string; - elapsedTimeSeconds?: number; - totalLines?: number; - totalBytes?: number; - timeoutMs?: number; - taskId?: string; - verbose: boolean; -}; -export function ShellProgressMessage(t0) { - const $ = _c(30); - const { - output, - fullOutput, - elapsedTimeSeconds, - totalLines, - totalBytes, - timeoutMs, - verbose - } = t0; - let t1; - if ($[0] !== fullOutput) { - t1 = stripAnsi(fullOutput.trim()); - $[0] = fullOutput; - $[1] = t1; - } else { - t1 = $[1]; - } - const strippedFullOutput = t1; - let lines; - let t2; - if ($[2] !== output || $[3] !== strippedFullOutput || $[4] !== verbose) { - const strippedOutput = stripAnsi(output.trim()); - lines = strippedOutput.split("\n").filter(_temp); - t2 = verbose ? strippedFullOutput : lines.slice(-5).join("\n"); - $[2] = output; - $[3] = strippedFullOutput; - $[4] = verbose; - $[5] = lines; - $[6] = t2; - } else { - lines = $[5]; - t2 = $[6]; - } - const displayLines = t2; + output: string + fullOutput: string + elapsedTimeSeconds?: number + totalLines?: number + totalBytes?: number + timeoutMs?: number + taskId?: string + verbose: boolean +} + +export function ShellProgressMessage({ + output, + fullOutput, + elapsedTimeSeconds, + totalLines, + totalBytes, + timeoutMs, + verbose, +}: Props): React.ReactNode { + const strippedFullOutput = stripAnsi(fullOutput.trim()) + const strippedOutput = stripAnsi(output.trim()) + const lines = strippedOutput.split('\n').filter(line => line) + const displayLines = verbose ? strippedFullOutput : lines.slice(-5).join('\n') + + // OffscreenFreeze: BashTool yields progress (elapsedTimeSeconds) every second. + // If this line scrolls into scrollback, each tick forces a full terminal reset. + // A foreground `sleep 600` on a 29-row terminal with 4000 rows of history + // produced 507 resets over 10 minutes (go/ccshare/maxk-20260226-190348). if (!lines.length) { - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Running… ; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== elapsedTimeSeconds || $[9] !== timeoutMs) { - t4 = {t3}; - $[8] = elapsedTimeSeconds; - $[9] = timeoutMs; - $[10] = t4; - } else { - t4 = $[10]; - } - return t4; + return ( + + + Running… + + + + ) } - const extraLines = totalLines ? Math.max(0, totalLines - 5) : 0; - let lineStatus = ""; + + // Not truncated: "+2 lines" (total exceeds displayed 5) + // Truncated: "~2000 lines" (extrapolated estimate from tail sample) + const extraLines = totalLines ? Math.max(0, totalLines - 5) : 0 + let lineStatus = '' if (!verbose && totalBytes && totalLines) { - lineStatus = `~${totalLines} lines`; - } else { - if (!verbose && extraLines > 0) { - lineStatus = `+${extraLines} lines`; - } - } - const t3 = verbose ? undefined : Math.min(5, lines.length); - let t4; - if ($[11] !== displayLines) { - t4 = {displayLines}; - $[11] = displayLines; - $[12] = t4; - } else { - t4 = $[12]; - } - let t5; - if ($[13] !== t3 || $[14] !== t4) { - t5 = {t4}; - $[13] = t3; - $[14] = t4; - $[15] = t5; - } else { - t5 = $[15]; - } - let t6; - if ($[16] !== lineStatus) { - t6 = lineStatus ? {lineStatus} : null; - $[16] = lineStatus; - $[17] = t6; - } else { - t6 = $[17]; + lineStatus = `~${totalLines} lines` + } else if (!verbose && extraLines > 0) { + lineStatus = `+${extraLines} lines` } - let t7; - if ($[18] !== elapsedTimeSeconds || $[19] !== timeoutMs) { - t7 = ; - $[18] = elapsedTimeSeconds; - $[19] = timeoutMs; - $[20] = t7; - } else { - t7 = $[20]; - } - let t8; - if ($[21] !== totalBytes) { - t8 = totalBytes ? {formatFileSize(totalBytes)} : null; - $[21] = totalBytes; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== t6 || $[24] !== t7 || $[25] !== t8) { - t9 = {t6}{t7}{t8}; - $[23] = t6; - $[24] = t7; - $[25] = t8; - $[26] = t9; - } else { - t9 = $[26]; - } - let t10; - if ($[27] !== t5 || $[28] !== t9) { - t10 = {t5}{t9}; - $[27] = t5; - $[28] = t9; - $[29] = t10; - } else { - t10 = $[29]; - } - return t10; -} -function _temp(line) { - return line; + + return ( + + + + + {displayLines} + + + {lineStatus ? {lineStatus} : null} + + {totalBytes ? ( + {formatFileSize(totalBytes)} + ) : null} + + + + + ) } diff --git a/src/components/shell/ShellTimeDisplay.tsx b/src/components/shell/ShellTimeDisplay.tsx index 6830a3af7..7e619dfba 100644 --- a/src/components/shell/ShellTimeDisplay.tsx +++ b/src/components/shell/ShellTimeDisplay.tsx @@ -1,73 +1,28 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import { formatDuration } from '../../utils/format.js'; +import React from 'react' +import { Text } from '../../ink.js' +import { formatDuration } from '../../utils/format.js' + type Props = { - elapsedTimeSeconds?: number; - timeoutMs?: number; -}; -export function ShellTimeDisplay(t0) { - const $ = _c(10); - const { - elapsedTimeSeconds, - timeoutMs - } = t0; + elapsedTimeSeconds?: number + timeoutMs?: number +} + +export function ShellTimeDisplay({ + elapsedTimeSeconds, + timeoutMs, +}: Props): React.ReactNode { if (elapsedTimeSeconds === undefined && !timeoutMs) { - return null; - } - let t1; - if ($[0] !== timeoutMs) { - t1 = timeoutMs ? formatDuration(timeoutMs, { - hideTrailingZeros: true - }) : undefined; - $[0] = timeoutMs; - $[1] = t1; - } else { - t1 = $[1]; + return null } - const timeout = t1; + const timeout = timeoutMs + ? formatDuration(timeoutMs, { hideTrailingZeros: true }) + : undefined if (elapsedTimeSeconds === undefined) { - const t2 = `(timeout ${timeout})`; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; + return {`(timeout ${timeout})`} } - const t2 = elapsedTimeSeconds * 1000; - let t3; - if ($[4] !== t2) { - t3 = formatDuration(t2); - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const elapsed = t3; + const elapsed = formatDuration(elapsedTimeSeconds * 1000) if (timeout) { - const t4 = `(${elapsed} · timeout ${timeout})`; - let t5; - if ($[6] !== t4) { - t5 = {t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; - } - const t4 = `(${elapsed})`; - let t5; - if ($[8] !== t4) { - t5 = {t4}; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; + return {`(${elapsed} · timeout ${timeout})`} } - return t5; + return {`(${elapsed})`} } diff --git a/src/components/skills/SkillsMenu.tsx b/src/components/skills/SkillsMenu.tsx index df78c1af4..f8f2896a6 100644 --- a/src/components/skills/SkillsMenu.tsx +++ b/src/components/skills/SkillsMenu.tsx @@ -1,236 +1,205 @@ -import { c as _c } from "react/compiler-runtime"; -import capitalize from 'lodash-es/capitalize.js'; -import * as React from 'react'; -import { useMemo } from 'react'; -import { type Command, type CommandBase, type CommandResultDisplay, getCommandName, type PromptCommand } from '../../commands.js'; -import { Box, Text } from '../../ink.js'; -import { estimateSkillFrontmatterTokens, getSkillsPath } from '../../skills/loadSkillsDir.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatTokens } from '../../utils/format.js'; -import { getSettingSourceName, type SettingSource } from '../../utils/settings/constants.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Dialog } from '../design-system/Dialog.js'; +import capitalize from 'lodash-es/capitalize.js' +import * as React from 'react' +import { useMemo } from 'react' +import { + type Command, + type CommandBase, + type CommandResultDisplay, + getCommandName, + type PromptCommand, +} from '../../commands.js' +import { Box, Text } from '../../ink.js' +import { + estimateSkillFrontmatterTokens, + getSkillsPath, +} from '../../skills/loadSkillsDir.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatTokens } from '../../utils/format.js' +import { + getSettingSourceName, + type SettingSource, +} from '../../utils/settings/constants.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Dialog } from '../design-system/Dialog.js' // Skills are always PromptCommands with CommandBase properties -type SkillCommand = CommandBase & PromptCommand; -type SkillSource = SettingSource | 'plugin' | 'mcp'; +type SkillCommand = CommandBase & PromptCommand + +type SkillSource = SettingSource | 'plugin' | 'mcp' + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - commands: Command[]; -}; + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + commands: Command[] +} + function getSourceTitle(source: SkillSource): string { if (source === 'plugin') { - return 'Plugin skills'; + return 'Plugin skills' } if (source === 'mcp') { - return 'MCP skills'; + return 'MCP skills' } - return `${capitalize(getSettingSourceName(source))} skills`; + return `${capitalize(getSettingSourceName(source))} skills` } -function getSourceSubtitle(source: SkillSource, skills: SkillCommand[]): string | undefined { + +function getSourceSubtitle( + source: SkillSource, + skills: SkillCommand[], +): string | undefined { // MCP skills show server names; file-based skills show filesystem paths. // Skill names are `:`, not `mcp____…`. if (source === 'mcp') { - const servers = [...new Set(skills.map(s => { - const idx = s.name.indexOf(':'); - return idx > 0 ? s.name.slice(0, idx) : null; - }).filter((n): n is string => n != null))]; - return servers.length > 0 ? servers.join(', ') : undefined; - } - const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')); - const hasCommandsSkills = skills.some(s => s.loadedFrom === 'commands_DEPRECATED'); - return hasCommandsSkills ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` : skillsPath; + const servers = [ + ...new Set( + skills + .map(s => { + const idx = s.name.indexOf(':') + return idx > 0 ? s.name.slice(0, idx) : null + }) + .filter((n): n is string => n != null), + ), + ] + return servers.length > 0 ? servers.join(', ') : undefined + } + const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')) + const hasCommandsSkills = skills.some( + s => s.loadedFrom === 'commands_DEPRECATED', + ) + return hasCommandsSkills + ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` + : skillsPath } -export function SkillsMenu(t0) { - const $ = _c(35); - const { - onExit, - commands - } = t0; - let t1; - if ($[0] !== commands) { - t1 = commands.filter(_temp); - $[0] = commands; - $[1] = t1; - } else { - t1 = $[1]; - } - const skills = t1; - let groups; - if ($[2] !== skills) { - groups = { + +export function SkillsMenu({ onExit, commands }: Props): React.ReactNode { + // Filter commands for skills and cast to SkillCommand + const skills = useMemo(() => { + return commands.filter( + (cmd): cmd is SkillCommand => + cmd.type === 'prompt' && + (cmd.loadedFrom === 'skills' || + cmd.loadedFrom === 'commands_DEPRECATED' || + cmd.loadedFrom === 'plugin' || + cmd.loadedFrom === 'mcp'), + ) + }, [commands]) + + const skillsBySource = useMemo((): Record => { + const groups: Record = { policySettings: [], userSettings: [], projectSettings: [], localSettings: [], flagSettings: [], plugin: [], - mcp: [] - }; + mcp: [], + } + for (const skill of skills) { - const source = skill.source as SkillSource; + const source = skill.source as SkillSource if (source in groups) { - groups[source].push(skill); + groups[source].push(skill) } } + for (const group of Object.values(groups)) { - (group as Array<{ name: string }>).sort(_temp2); + group.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b))) } - $[2] = skills; - $[3] = groups; - } else { - groups = $[3]; - } - const skillsBySource = groups; - let t2; - if ($[4] !== onExit) { - t2 = () => { - onExit("Skills dialog dismissed", { - display: "system" - }); - }; - $[4] = onExit; - $[5] = t2; - } else { - t2 = $[5]; + + return groups + }, [skills]) + + const handleCancel = (): void => { + onExit('Skills dialog dismissed', { display: 'system' }) } - const handleCancel = t2; + if (skills.length === 0) { - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Create skills in .claude/skills/ or ~/.claude/skills/; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== handleCancel) { - t5 = {t3}{t4}; - $[8] = handleCancel; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + return ( + + + Create skills in .claude/skills/ or ~/.claude/skills/ + + + + + + ) } - const renderSkill = _temp3; - let t3; - if ($[10] !== skillsBySource) { - t3 = source_0 => { - const groupSkills = skillsBySource[source_0]; - if (groupSkills.length === 0) { - return null; - } - const title = getSourceTitle(source_0); - const subtitle = getSourceSubtitle(source_0, groupSkills); - return {title}{subtitle && ({subtitle})}{groupSkills.map(skill_1 => renderSkill(skill_1))}; - }; - $[10] = skillsBySource; - $[11] = t3; - } else { - t3 = $[11]; - } - const renderSkillGroup = t3; - const t4 = skills.length; - let t5; - if ($[12] !== skills.length) { - t5 = plural(skills.length, "skill"); - $[12] = skills.length; - $[13] = t5; - } else { - t5 = $[13]; - } - const t6 = `${t4} ${t5}`; - let t7; - if ($[14] !== renderSkillGroup) { - t7 = renderSkillGroup("projectSettings"); - $[14] = renderSkillGroup; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== renderSkillGroup) { - t8 = renderSkillGroup("userSettings"); - $[16] = renderSkillGroup; - $[17] = t8; - } else { - t8 = $[17]; - } - let t9; - if ($[18] !== renderSkillGroup) { - t9 = renderSkillGroup("policySettings"); - $[18] = renderSkillGroup; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== renderSkillGroup) { - t10 = renderSkillGroup("plugin"); - $[20] = renderSkillGroup; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== renderSkillGroup) { - t11 = renderSkillGroup("mcp"); - $[22] = renderSkillGroup; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== t10 || $[25] !== t11 || $[26] !== t7 || $[27] !== t8 || $[28] !== t9) { - t12 = {t7}{t8}{t9}{t10}{t11}; - $[24] = t10; - $[25] = t11; - $[26] = t7; - $[27] = t8; - $[28] = t9; - $[29] = t12; - } else { - t12 = $[29]; - } - let t13; - if ($[30] === Symbol.for("react.memo_cache_sentinel")) { - t13 = ; - $[30] = t13; - } else { - t13 = $[30]; + + const renderSkill = (skill: SkillCommand) => { + const estimatedTokens = estimateSkillFrontmatterTokens(skill) + const tokenDisplay = `~${formatTokens(estimatedTokens)}` + const pluginName = + skill.source === 'plugin' + ? skill.pluginInfo?.pluginManifest.name + : undefined + + return ( + + {getCommandName(skill)} + + {pluginName ? ` · ${pluginName}` : ''} · {tokenDisplay} description + tokens + + + ) } - let t14; - if ($[31] !== handleCancel || $[32] !== t12 || $[33] !== t6) { - t14 = {t12}{t13}; - $[31] = handleCancel; - $[32] = t12; - $[33] = t6; - $[34] = t14; - } else { - t14 = $[34]; + + const renderSkillGroup = (source: SkillSource) => { + const groupSkills = skillsBySource[source] + if (groupSkills.length === 0) return null + + const title = getSourceTitle(source) + const subtitle = getSourceSubtitle(source, groupSkills) + + return ( + + + + {title} + + {subtitle && ({subtitle})} + + {groupSkills.map(skill => renderSkill(skill))} + + ) } - return t14; -} -function _temp3(skill_0) { - const estimatedTokens = estimateSkillFrontmatterTokens(skill_0); - const tokenDisplay = `~${formatTokens(estimatedTokens)}`; - const pluginName = skill_0.source === "plugin" ? skill_0.pluginInfo?.pluginManifest.name : undefined; - return {getCommandName(skill_0)}{pluginName ? ` · ${pluginName}` : ""} · {tokenDisplay} description tokens; -} -function _temp2(a, b) { - return getCommandName(a).localeCompare(getCommandName(b)); -} -function _temp(cmd) { - return cmd.type === "prompt" && (cmd.loadedFrom === "skills" || cmd.loadedFrom === "commands_DEPRECATED" || cmd.loadedFrom === "plugin" || cmd.loadedFrom === "mcp"); + + return ( + + + {renderSkillGroup('projectSettings')} + {renderSkillGroup('userSettings')} + {renderSkillGroup('policySettings')} + {renderSkillGroup('plugin')} + {renderSkillGroup('mcp')} + + + + + + ) } diff --git a/src/components/tasks/AsyncAgentDetailDialog.tsx b/src/components/tasks/AsyncAgentDetailDialog.tsx index a942c105e..4174d4fa5 100644 --- a/src/components/tasks/AsyncAgentDetailDialog.tsx +++ b/src/components/tasks/AsyncAgentDetailDialog.tsx @@ -1,228 +1,200 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { getEmptyToolPermissionContext } from '../../Tool.js'; -import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { getTools } from '../../tools.js'; -import { formatNumber } from '../../utils/format.js'; -import { extractTag } from '../../utils/messages.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { UserPlanMessage } from '../messages/UserPlanMessage.js'; -import { renderToolActivity } from './renderToolActivity.js'; -import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js'; +import React, { useMemo } from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { getTools } from '../../tools.js' +import { formatNumber } from '../../utils/format.js' +import { extractTag } from '../../utils/messages.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { UserPlanMessage } from '../messages/UserPlanMessage.js' +import { renderToolActivity } from './renderToolActivity.js' +import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js' + type Props = { - agent: DeepImmutable; - onDone: () => void; - onKillAgent?: () => void; - onBack?: () => void; -}; -export function AsyncAgentDetailDialog(t0) { - const $ = _c(54); - const { - agent, - onDone, - onKillAgent, - onBack - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTools(getEmptyToolPermissionContext()); - $[0] = t1; - } else { - t1 = $[0]; - } - const tools = t1; - const elapsedTime = useElapsedTime(agent.startTime, agent.status === "running", 1000, agent.totalPausedMs ?? 0); - let t2; - if ($[1] !== onDone) { - t2 = { - "confirm:yes": onDone - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybindings(t2, t3); - let t4; - if ($[4] !== agent.status || $[5] !== onBack || $[6] !== onDone || $[7] !== onKillAgent) { - t4 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && agent.status === "running" && onKillAgent) { - e.preventDefault(); - onKillAgent(); - } + agent: DeepImmutable + onDone: () => void + onKillAgent?: () => void + onBack?: () => void +} + +export function AsyncAgentDetailDialog({ + agent, + onDone, + onKillAgent, + onBack, +}: Props): React.ReactNode { + const [theme] = useTheme() + + // Get tools for rendering activity messages + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + + const elapsedTime = useElapsedTime( + agent.startTime, + agent.status === 'running', + 1000, + agent.totalPausedMs ?? 0, + ) + + // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) + // internally but does NOT auto-wire confirm:yes. + useKeybindings( + { + 'confirm:yes': onDone, + }, + { context: 'Confirmation' }, + ) + + // Component-specific shortcuts shown in UI hints (x=stop) and + // navigation keys (space=dismiss, left=back). These are context-dependent + // actions tied to agent state, not standard dialog keybindings. + // Note: Dialog component already handles ESC via confirm:no keybinding; + // confirm:yes (Enter/y) is handled by useKeybindings above. + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && agent.status === 'running' && onKillAgent) { + e.preventDefault() + onKillAgent() + } + } + + // Extract plan from prompt - if present, we show the plan instead of the prompt + const planContent = extractTag(agent.prompt, 'plan') + + const displayPrompt = + agent.prompt.length > 300 + ? agent.prompt.substring(0, 297) + '…' + : agent.prompt + + // Get tokens and tool uses (from result if completed, otherwise from progress) + const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount + const toolUseCount = + agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount + + const title = ( + + {agent.selectedAgent?.agentType ?? 'agent'} ›{' '} + {agent.description || 'Async agent'} + + ) + + // Build subtitle with status and stats + const subtitle = ( + + {agent.status !== 'running' && ( + + {getTaskStatusIcon(agent.status)}{' '} + {agent.status === 'completed' + ? 'Completed' + : agent.status === 'failed' + ? 'Failed' + : 'Stopped'} + {' · '} + + )} + + {elapsedTime} + {tokenCount !== undefined && tokenCount > 0 && ( + <> · {formatNumber(tokenCount)} tokens + )} + {toolUseCount !== undefined && toolUseCount > 0 && ( + <> + {' '} + · {toolUseCount} {toolUseCount === 1 ? 'tool' : 'tools'} + + )} + + + ) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {agent.status === 'running' && onKillAgent && ( + + )} + + ) } - } - }; - $[4] = agent.status; - $[5] = onBack; - $[6] = onDone; - $[7] = onKillAgent; - $[8] = t4; - } else { - t4 = $[8]; - } - const handleKeyDown = t4; - let t5; - if ($[9] !== agent.prompt) { - t5 = extractTag(agent.prompt, "plan"); - $[9] = agent.prompt; - $[10] = t5; - } else { - t5 = $[10]; - } - const planContent = t5; - const displayPrompt = agent.prompt.length > 300 ? agent.prompt.substring(0, 297) + "\u2026" : agent.prompt; - const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount; - const toolUseCount = agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount; - const t6 = agent.selectedAgent?.agentType ?? "agent"; - const t7 = agent.description || "Async agent"; - let t8; - if ($[11] !== t6 || $[12] !== t7) { - t8 = {t6} ›{" "}{t7}; - $[11] = t6; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - const title = t8; - let t9; - if ($[14] !== agent.status) { - t9 = agent.status !== "running" && {getTaskStatusIcon(agent.status)}{" "}{agent.status === "completed" ? "Completed" : agent.status === "failed" ? "Failed" : "Stopped"}{" \xB7 "}; - $[14] = agent.status; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== tokenCount) { - t10 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[16] = tokenCount; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== toolUseCount) { - t11 = toolUseCount !== undefined && toolUseCount > 0 && <>{" "}· {toolUseCount} {toolUseCount === 1 ? "tool" : "tools"}; - $[18] = toolUseCount; - $[19] = t11; - } else { - t11 = $[19]; - } - let t12; - if ($[20] !== elapsedTime || $[21] !== t10 || $[22] !== t11) { - t12 = {elapsedTime}{t10}{t11}; - $[20] = elapsedTime; - $[21] = t10; - $[22] = t11; - $[23] = t12; - } else { - t12 = $[23]; - } - let t13; - if ($[24] !== t12 || $[25] !== t9) { - t13 = {t9}{t12}; - $[24] = t12; - $[25] = t9; - $[26] = t13; - } else { - t13 = $[26]; - } - const subtitle = t13; - let t14; - if ($[27] !== agent.status || $[28] !== onBack || $[29] !== onKillAgent) { - t14 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{agent.status === "running" && onKillAgent && }; - $[27] = agent.status; - $[28] = onBack; - $[29] = onKillAgent; - $[30] = t14; - } else { - t14 = $[30]; - } - let t15; - if ($[31] !== agent.progress || $[32] !== agent.status || $[33] !== theme) { - t15 = agent.status === "running" && agent.progress?.recentActivities && agent.progress.recentActivities.length > 0 && Progress{agent.progress.recentActivities.map((activity, i) => {i === agent.progress.recentActivities.length - 1 ? "\u203A " : " "}{renderToolActivity(activity, tools, theme)})}; - $[31] = agent.progress; - $[32] = agent.status; - $[33] = theme; - $[34] = t15; - } else { - t15 = $[34]; - } - let t16; - if ($[35] !== displayPrompt || $[36] !== planContent) { - t16 = planContent ? : Prompt{displayPrompt}; - $[35] = displayPrompt; - $[36] = planContent; - $[37] = t16; - } else { - t16 = $[37]; - } - let t17; - if ($[38] !== agent.error || $[39] !== agent.status) { - t17 = agent.status === "failed" && agent.error && Error{agent.error}; - $[38] = agent.error; - $[39] = agent.status; - $[40] = t17; - } else { - t17 = $[40]; - } - let t18; - if ($[41] !== t15 || $[42] !== t16 || $[43] !== t17) { - t18 = {t15}{t16}{t17}; - $[41] = t15; - $[42] = t16; - $[43] = t17; - $[44] = t18; - } else { - t18 = $[44]; - } - let t19; - if ($[45] !== onDone || $[46] !== subtitle || $[47] !== t14 || $[48] !== t18 || $[49] !== title) { - t19 = {t18}; - $[45] = onDone; - $[46] = subtitle; - $[47] = t14; - $[48] = t18; - $[49] = title; - $[50] = t19; - } else { - t19 = $[50]; - } - let t20; - if ($[51] !== handleKeyDown || $[52] !== t19) { - t20 = {t19}; - $[51] = handleKeyDown; - $[52] = t19; - $[53] = t20; - } else { - t20 = $[53]; - } - return t20; + > + + {/* Recent activities for running agents */} + {agent.status === 'running' && + agent.progress?.recentActivities && + agent.progress.recentActivities.length > 0 && ( + + + Progress + + {agent.progress.recentActivities.map((activity, i) => ( + + {i === agent.progress!.recentActivities!.length - 1 + ? '› ' + : ' '} + {renderToolActivity(activity, tools, theme)} + + ))} + + )} + + {/* Plan section (if present) - shown instead of prompt */} + {planContent ? ( + + + + ) : ( + /* Prompt section - only shown when no plan */ + + + Prompt + + {displayPrompt} + + )} + + {/* Error details if failed */} + {agent.status === 'failed' && agent.error && ( + + + Error + + + {agent.error} + + + )} + + + + ) } diff --git a/src/components/tasks/BackgroundTask.tsx b/src/components/tasks/BackgroundTask.tsx index a6923b1da..fd48d09e7 100644 --- a/src/components/tasks/BackgroundTask.tsx +++ b/src/components/tasks/BackgroundTask.tsx @@ -1,344 +1,146 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from 'src/ink.js'; -import type { BackgroundTaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { truncate } from 'src/utils/format.js'; -import { toInkColor } from 'src/utils/ink.js'; -import { plural } from 'src/utils/stringUtils.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { RemoteSessionProgress } from './RemoteSessionProgress.js'; -import { ShellProgress, TaskStatusText } from './ShellProgress.js'; -import { describeTeammateActivity } from './taskStatusUtils.js'; +import * as React from 'react' +import { Text } from 'src/ink.js' +import type { BackgroundTaskState } from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { truncate } from 'src/utils/format.js' +import { toInkColor } from 'src/utils/ink.js' +import { plural } from 'src/utils/stringUtils.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { RemoteSessionProgress } from './RemoteSessionProgress.js' +import { ShellProgress, TaskStatusText } from './ShellProgress.js' +import { describeTeammateActivity } from './taskStatusUtils.js' + type Props = { - task: DeepImmutable; - maxActivityWidth?: number; -}; -export function BackgroundTask(t0) { - const $ = _c(92); - const { - task, - maxActivityWidth - } = t0; - const activityLimit = maxActivityWidth ?? 40; + task: DeepImmutable + maxActivityWidth?: number +} + +export function BackgroundTask({ + task, + maxActivityWidth, +}: Props): React.ReactNode { + const activityLimit = maxActivityWidth ?? 40 switch (task.type) { - case "local_bash": - { - const t1 = task.kind === "monitor" ? task.description : task.command; - let t2; - if ($[0] !== activityLimit || $[1] !== t1) { - t2 = truncate(t1, activityLimit, true); - $[0] = activityLimit; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== task) { - t3 = ; - $[3] = task; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{" "}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; - } - case "remote_agent": - { - if (task.isRemoteReview) { - let t1; - if ($[8] !== task) { - t1 = ; - $[8] = task; - $[9] = t1; - } else { - t1 = $[9]; - } - return t1; - } - const running = task.status === "running" || task.status === "pending"; - const t1 = running ? DIAMOND_OPEN : DIAMOND_FILLED; - let t2; - if ($[10] !== t1) { - t2 = {t1} ; - $[10] = t1; - $[11] = t2; - } else { - t2 = $[11]; - } - let t3; - if ($[12] !== activityLimit || $[13] !== task.title) { - t3 = truncate(task.title, activityLimit, true); - $[12] = activityLimit; - $[13] = task.title; - $[14] = t3; - } else { - t3 = $[14]; - } - let t4; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t4 = · ; - $[15] = t4; - } else { - t4 = $[15]; - } - let t5; - if ($[16] !== task) { - t5 = ; - $[16] = task; - $[17] = t5; - } else { - t5 = $[17]; - } - let t6; - if ($[18] !== t2 || $[19] !== t3 || $[20] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[18] = t2; - $[19] = t3; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - return t6; - } - case "local_agent": - { - let t1; - if ($[22] !== activityLimit || $[23] !== task.description) { - t1 = truncate(task.description, activityLimit, true); - $[22] = activityLimit; - $[23] = task.description; - $[24] = t1; - } else { - t1 = $[24]; - } - const t2 = task.status === "completed" ? "done" : undefined; - const t3 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t4; - if ($[25] !== t2 || $[26] !== t3 || $[27] !== task.status) { - t4 = ; - $[25] = t2; - $[26] = t3; - $[27] = task.status; - $[28] = t4; - } else { - t4 = $[28]; - } - let t5; - if ($[29] !== t1 || $[30] !== t4) { - t5 = {t1}{" "}{t4}; - $[29] = t1; - $[30] = t4; - $[31] = t5; - } else { - t5 = $[31]; - } - return t5; - } - case "in_process_teammate": - { - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - if ($[32] !== activityLimit || $[33] !== task) { - const activity = describeTeammateActivity(task); - T1 = Text; - let t5; - if ($[40] !== task.identity.color) { - t5 = toInkColor(task.identity.color); - $[40] = task.identity.color; - $[41] = t5; - } else { - t5 = $[41]; - } - if ($[42] !== t5 || $[43] !== task.identity.agentName) { - t4 = @{task.identity.agentName}; - $[42] = t5; - $[43] = task.identity.agentName; - $[44] = t4; - } else { - t4 = $[44]; - } - T0 = Text; - t1 = true; - t2 = ": "; - t3 = truncate(activity, activityLimit, true); - $[32] = activityLimit; - $[33] = task; - $[34] = T0; - $[35] = T1; - $[36] = t1; - $[37] = t2; - $[38] = t3; - $[39] = t4; - } else { - T0 = $[34]; - T1 = $[35]; - t1 = $[36]; - t2 = $[37]; - t3 = $[38]; - t4 = $[39]; - } - let t5; - if ($[45] !== T0 || $[46] !== t1 || $[47] !== t2 || $[48] !== t3) { - t5 = {t2}{t3}; - $[45] = T0; - $[46] = t1; - $[47] = t2; - $[48] = t3; - $[49] = t5; - } else { - t5 = $[49]; - } - let t6; - if ($[50] !== T1 || $[51] !== t4 || $[52] !== t5) { - t6 = {t4}{t5}; - $[50] = T1; - $[51] = t4; - $[52] = t5; - $[53] = t6; - } else { - t6 = $[53]; - } - return t6; - } - case "local_workflow": - { - const t1 = task.workflowName ?? task.summary ?? task.description; - let t2; - if ($[54] !== activityLimit || $[55] !== t1) { - t2 = truncate(t1, activityLimit, true); - $[54] = activityLimit; - $[55] = t1; - $[56] = t2; - } else { - t2 = $[56]; - } - let t3; - if ($[57] !== task.agentCount || $[58] !== task.status) { - t3 = task.status === "running" ? `${task.agentCount} ${plural(task.agentCount, "agent")}` : task.status === "completed" ? "done" : undefined; - $[57] = task.agentCount; - $[58] = task.status; - $[59] = t3; - } else { - t3 = $[59]; - } - const t4 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t5; - if ($[60] !== t3 || $[61] !== t4 || $[62] !== task.status) { - t5 = ; - $[60] = t3; - $[61] = t4; - $[62] = task.status; - $[63] = t5; - } else { - t5 = $[63]; - } - let t6; - if ($[64] !== t2 || $[65] !== t5) { - t6 = {t2}{" "}{t5}; - $[64] = t2; - $[65] = t5; - $[66] = t6; - } else { - t6 = $[66]; - } - return t6; - } - case "monitor_mcp": - { - let t1; - if ($[67] !== activityLimit || $[68] !== task.description) { - t1 = truncate(task.description, activityLimit, true); - $[67] = activityLimit; - $[68] = task.description; - $[69] = t1; - } else { - t1 = $[69]; - } - const t2 = task.status === "completed" ? "done" : undefined; - const t3 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t4; - if ($[70] !== t2 || $[71] !== t3 || $[72] !== task.status) { - t4 = ; - $[70] = t2; - $[71] = t3; - $[72] = task.status; - $[73] = t4; - } else { - t4 = $[73]; - } - let t5; - if ($[74] !== t1 || $[75] !== t4) { - t5 = {t1}{" "}{t4}; - $[74] = t1; - $[75] = t4; - $[76] = t5; - } else { - t5 = $[76]; - } - return t5; - } - case "dream": - { - const n = task.filesTouched.length; - let t1; - if ($[77] !== n || $[78] !== task.phase || $[79] !== task.sessionsReviewing) { - t1 = task.phase === "updating" && n > 0 ? `${n} ${plural(n, "file")}` : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, "session")}`; - $[77] = n; - $[78] = task.phase; - $[79] = task.sessionsReviewing; - $[80] = t1; - } else { - t1 = $[80]; - } - const detail = t1; - let t2; - if ($[81] !== detail || $[82] !== task.phase) { - t2 = · {task.phase} · {detail}; - $[81] = detail; - $[82] = task.phase; - $[83] = t2; - } else { - t2 = $[83]; - } - const t3 = task.status === "completed" ? "done" : undefined; - const t4 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t5; - if ($[84] !== t3 || $[85] !== t4 || $[86] !== task.status) { - t5 = ; - $[84] = t3; - $[85] = t4; - $[86] = task.status; - $[87] = t5; - } else { - t5 = $[87]; - } - let t6; - if ($[88] !== t2 || $[89] !== t5 || $[90] !== task.description) { - t6 = {task.description}{" "}{t2}{" "}{t5}; - $[88] = t2; - $[89] = t5; - $[90] = task.description; - $[91] = t6; - } else { - t6 = $[91]; - } - return t6; + case 'local_bash': + return ( + + {truncate( + task.kind === 'monitor' ? task.description : task.command, + activityLimit, + true, + )}{' '} + + + ) + case 'remote_agent': { + // Lite-review renders its own rainbow line (title + live counts), + // so we don't prefix the title — the rainbow already includes it. + if (task.isRemoteReview) { + return ( + + + + ) } + const running = task.status === 'running' || task.status === 'pending' + return ( + + {running ? DIAMOND_OPEN : DIAMOND_FILLED} + {truncate(task.title, activityLimit, true)} + · + + + ) + } + case 'local_agent': + return ( + + {truncate(task.description, activityLimit, true)}{' '} + + + ) + case 'in_process_teammate': { + const activity = describeTeammateActivity(task) + return ( + + + @{task.identity.agentName} + + : {truncate(activity, activityLimit, true)} + + ) + } + case 'local_workflow': + return ( + + {truncate( + task.workflowName ?? task.summary ?? task.description, + activityLimit, + true, + )}{' '} + + + ) + case 'monitor_mcp': + return ( + + {truncate(task.description, activityLimit, true)}{' '} + + + ) + case 'dream': { + const n = task.filesTouched.length + const detail = + task.phase === 'updating' && n > 0 + ? `${n} ${plural(n, 'file')}` + : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, 'session')}` + return ( + + {task.description}{' '} + + · {task.phase} · {detail} + {' '} + + + ) + } } } diff --git a/src/components/tasks/BackgroundTaskStatus.tsx b/src/components/tasks/BackgroundTaskStatus.tsx index 37bfd8009..26d46cf98 100644 --- a/src/components/tasks/BackgroundTaskStatus.tsx +++ b/src/components/tasks/BackgroundTaskStatus.tsx @@ -1,428 +1,310 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useMemo, useState } from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { stringWidth } from 'src/ink/stringWidth.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; -import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js'; -import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js'; -import { Box, Text } from '../../ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import type { Theme } from '../../utils/theme.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { shouldHideTasksFooter } from './taskStatusUtils.js'; +import figures from 'figures' +import * as React from 'react' +import { useMemo, useState } from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { stringWidth } from 'src/ink/stringWidth.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from 'src/state/teammateViewHelpers.js' +import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js' +import { + type BackgroundTaskState, + isBackgroundTask, + type TaskState, +} from 'src/tasks/types.js' +import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js' +import { Box, Text } from '../../ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import type { Theme } from '../../utils/theme.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { shouldHideTasksFooter } from './taskStatusUtils.js' + type Props = { - tasksSelected: boolean; - isViewingTeammate?: boolean; - teammateFooterIndex?: number; - isLeaderIdle?: boolean; - onOpenDialog?: (taskId?: string) => void; -}; -export function BackgroundTaskStatus(t0) { - const $ = _c(48); - const { - tasksSelected, - isViewingTeammate, - teammateFooterIndex: t1, - isLeaderIdle: t2, - onOpenDialog - } = t0; - const teammateFooterIndex = t1 === undefined ? 0 : t1; - const isLeaderIdle = t2 === undefined ? false : t2; - const setAppState = useSetAppState(); - const { - columns - } = useTerminalSize(); - const tasks = useAppState(_temp); - const viewingAgentTaskId = useAppState(_temp2); - let t3; - if ($[0] !== tasks) { - t3 = (Object.values(tasks ?? {}) as TaskState[]).filter(_temp3); - $[0] = tasks; - $[1] = t3; - } else { - t3 = $[1]; - } - const runningTasks = t3; - const expandedView = useAppState(_temp4); - const showSpinnerTree = expandedView === "teammates"; - const allTeammates = !showSpinnerTree && runningTasks.length > 0 && runningTasks.every(_temp5); - let t4; - if ($[2] !== runningTasks) { - t4 = runningTasks.filter(_temp6).sort(_temp7); - $[2] = runningTasks; - $[3] = t4; - } else { - t4 = $[3]; - } - const teammateEntries = t4; - let t5; - if ($[4] !== isLeaderIdle) { - t5 = { - name: "main", + tasksSelected: boolean + isViewingTeammate?: boolean + teammateFooterIndex?: number + isLeaderIdle?: boolean + onOpenDialog?: (taskId?: string) => void +} + +export function BackgroundTaskStatus({ + tasksSelected, + isViewingTeammate, + teammateFooterIndex = 0, + isLeaderIdle = false, + onOpenDialog, +}: Props): React.ReactNode { + const setAppState = useSetAppState() + const { columns } = useTerminalSize() + const tasks = useAppState(s => s.tasks) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + + const runningTasks = useMemo( + () => + (Object.values(tasks ?? {}) as TaskState[]).filter( + t => + isBackgroundTask(t) && + !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + ), + [tasks], + ) + + // Check if all tasks are in-process teammates (team mode) + // In spinner-tree mode, don't show teammate pills (teammates appear in the spinner tree) + const expandedView = useAppState(s => s.expandedView) + const showSpinnerTree = expandedView === 'teammates' + const allTeammates = + !showSpinnerTree && + runningTasks.length > 0 && + runningTasks.every(t => t.type === 'in_process_teammate') + + // Memoize teammate-related computations at the top level (rules of hooks) + const teammateEntries = useMemo( + () => + runningTasks + .filter( + (t): t is BackgroundTaskState & { type: 'in_process_teammate' } => + t.type === 'in_process_teammate', + ) + .sort((a, b) => + a.identity.agentName.localeCompare(b.identity.agentName), + ), + [runningTasks], + ) + + // Build array of all pills with their activity state + // Each pill is "@{name}" and separator is " " (1 char) + // Sort idle agents to the end, but only when not in selection mode + // to avoid reordering while user is arrowing through the list + // "main" always stays first regardless of idle state + const allPills = useMemo(() => { + const mainPill = { + name: 'main', color: undefined as keyof Theme | undefined, isIdle: isLeaderIdle, - taskId: undefined as string | undefined - }; - $[4] = isLeaderIdle; - $[5] = t5; - } else { - t5 = $[5]; - } - const mainPill = t5; - let t6; - if ($[6] !== mainPill || $[7] !== tasksSelected || $[8] !== teammateEntries) { - const teammatePills = teammateEntries.map(_temp8); - if (!tasksSelected) { - teammatePills.sort(_temp9); - } - const pills = [mainPill, ...teammatePills]; - t6 = pills.map(_temp0); - $[6] = mainPill; - $[7] = tasksSelected; - $[8] = teammateEntries; - $[9] = t6; - } else { - t6 = $[9]; - } - const allPills = t6; - let t7; - if ($[10] !== allPills) { - t7 = allPills.map(_temp1); - $[10] = allPills; - $[11] = t7; - } else { - t7 = $[11]; - } - const pillWidths = t7; - if (allTeammates || !showSpinnerTree && isViewingTeammate) { - const selectedIdx = tasksSelected ? teammateFooterIndex : -1; - let t8; - if ($[12] !== teammateEntries || $[13] !== viewingAgentTaskId) { - t8 = viewingAgentTaskId ? teammateEntries.findIndex(t_3 => t_3.id === viewingAgentTaskId) + 1 : 0; - $[12] = teammateEntries; - $[13] = viewingAgentTaskId; - $[14] = t8; - } else { - t8 = $[14]; - } - const viewedIdx = t8; - const availableWidth = Math.max(20, columns - 20 - 4); - const t9 = selectedIdx >= 0 ? selectedIdx : 0; - let t10; - if ($[15] !== availableWidth || $[16] !== pillWidths || $[17] !== t9) { - t10 = calculateHorizontalScrollWindow(pillWidths, availableWidth, 2, t9); - $[15] = availableWidth; - $[16] = pillWidths; - $[17] = t9; - $[18] = t10; - } else { - t10 = $[18]; + taskId: undefined as string | undefined, } - const { - startIndex, - endIndex, - showLeftArrow, - showRightArrow - } = t10; - let t11; - if ($[19] !== allPills || $[20] !== endIndex || $[21] !== startIndex) { - t11 = allPills.slice(startIndex, endIndex); - $[19] = allPills; - $[20] = endIndex; - $[21] = startIndex; - $[22] = t11; - } else { - t11 = $[22]; - } - const visiblePills = t11; - let t12; - if ($[23] !== showLeftArrow) { - t12 = showLeftArrow && {figures.arrowLeft} ; - $[23] = showLeftArrow; - $[24] = t12; - } else { - t12 = $[24]; - } - let t13; - if ($[25] !== selectedIdx || $[26] !== setAppState || $[27] !== viewedIdx || $[28] !== visiblePills) { - t13 = visiblePills.map((pill_1, i_1) => { - const needsSeparator = i_1 > 0; - return {needsSeparator && } pill_1.taskId ? enterTeammateView(pill_1.taskId, setAppState) : exitTeammateView(setAppState)} />; - }); - $[25] = selectedIdx; - $[26] = setAppState; - $[27] = viewedIdx; - $[28] = visiblePills; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== showRightArrow) { - t14 = showRightArrow && {figures.arrowRight}; - $[30] = showRightArrow; - $[31] = t14; - } else { - t14 = $[31]; - } - let t15; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {" \xB7 "}; - $[32] = t15; - } else { - t15 = $[32]; - } - let t16; - if ($[33] !== t12 || $[34] !== t13 || $[35] !== t14) { - t16 = <>{t12}{t13}{t14}{t15}; - $[33] = t12; - $[34] = t13; - $[35] = t14; - $[36] = t16; - } else { - t16 = $[36]; + + const teammatePills = teammateEntries.map(t => ({ + name: t.identity.agentName, + color: getAgentThemeColor(t.identity.color), + isIdle: t.isIdle, + taskId: t.id, + })) + + // Only sort teammates when not selecting to avoid reordering during navigation + if (!tasksSelected) { + teammatePills.sort((a, b) => { + // Active agents first, idle agents last + if (a.isIdle !== b.isIdle) return a.isIdle ? 1 : -1 + return 0 // Keep original order within each group + }) } - return t16; + + // main always first, then sorted teammates + const pills = [mainPill, ...teammatePills] + + // Add idx after sorting + return pills.map((pill, i) => ({ ...pill, idx: i })) + }, [teammateEntries, isLeaderIdle, tasksSelected]) + + // Calculate pill widths (including separator space, except first) + const pillWidths = useMemo( + () => + allPills.map((pill, i) => { + const pillText = `@${pill.name}` + // First pill has no leading space, others have 1 space separator + return stringWidth(pillText) + (i > 0 ? 1 : 0) + }), + [allPills], + ) + + if (allTeammates || (!showSpinnerTree && isViewingTeammate)) { + const selectedIdx = tasksSelected ? teammateFooterIndex : -1 + // Which agent is currently foregrounded (bold) + const viewedIdx = viewingAgentTaskId + ? teammateEntries.findIndex(t => t.id === viewingAgentTaskId) + 1 + : 0 // 0 = main/leader + + // Calculate available width for pills + // Reserve space for: arrows, hint, and minimal padding + // Pills are rendered on their own line when in team mode + const ARROW_WIDTH = 2 // arrow char + space + const HINT_WIDTH = 20 // shift+↓ to expand + const PADDING = 4 // minimal safety margin + const availableWidth = Math.max(20, columns - HINT_WIDTH - PADDING) + + // Calculate visible window of pills + const { startIndex, endIndex, showLeftArrow, showRightArrow } = + calculateHorizontalScrollWindow( + pillWidths, + availableWidth, + ARROW_WIDTH, + selectedIdx >= 0 ? selectedIdx : 0, + ) + + const visiblePills = allPills.slice(startIndex, endIndex) + + return ( + <> + {showLeftArrow && {figures.arrowLeft} } + {visiblePills.map((pill, i) => { + // First visible pill has no leading separator + // (left arrow already provides spacing if present) + const needsSeparator = i > 0 + return ( + + {needsSeparator && } + + pill.taskId + ? enterTeammateView(pill.taskId, setAppState) + : exitTeammateView(setAppState) + } + /> + + ) + })} + {showRightArrow && {figures.arrowRight}} + + {' · '} + + + + ) } + + // In spinner-tree mode, don't show any footer status for teammates + // (they appear in the spinner tree above) if (shouldHideTasksFooter(tasks ?? {}, showSpinnerTree)) { - return null; + return null } + if (runningTasks.length === 0) { - return null; - } - let t8; - if ($[37] !== runningTasks) { - t8 = getPillLabel(runningTasks); - $[37] = runningTasks; - $[38] = t8; - } else { - t8 = $[38]; - } - let t9; - if ($[39] !== onOpenDialog || $[40] !== t8 || $[41] !== tasksSelected) { - t9 = {t8}; - $[39] = onOpenDialog; - $[40] = t8; - $[41] = tasksSelected; - $[42] = t9; - } else { - t9 = $[42]; - } - let t10; - if ($[43] !== runningTasks) { - t10 = pillNeedsCta(runningTasks) && · {figures.arrowDown} to view; - $[43] = runningTasks; - $[44] = t10; - } else { - t10 = $[44]; - } - let t11; - if ($[45] !== t10 || $[46] !== t9) { - t11 = <>{t9}{t10}; - $[45] = t10; - $[46] = t9; - $[47] = t11; - } else { - t11 = $[47]; + return null } - return t11; -} -function _temp1(pill_0, i_0) { - const pillText = `@${pill_0.name}`; - return stringWidth(pillText) + (i_0 > 0 ? 1 : 0); -} -function _temp0(pill, i) { - return { - ...pill, - idx: i - }; -} -function _temp9(a_0, b_0) { - if (a_0.isIdle !== b_0.isIdle) { - return a_0.isIdle ? 1 : -1; - } - return 0; -} -function _temp8(t_2) { - return { - name: t_2.identity.agentName, - color: getAgentThemeColor(t_2.identity.color), - isIdle: t_2.isIdle, - taskId: t_2.id - }; -} -function _temp7(a, b) { - return a.identity.agentName.localeCompare(b.identity.agentName); -} -function _temp6(t_1) { - return t_1.type === "in_process_teammate"; -} -function _temp5(t_0) { - return t_0.type === "in_process_teammate"; -} -function _temp4(s_1) { - return s_1.expandedView; -} -function _temp3(t) { - return isBackgroundTask(t) && !(false && isPanelAgentTask(t)); -} -function _temp2(s_0) { - return s_0.viewingAgentTaskId; -} -function _temp(s) { - return s.tasks; + + return ( + <> + + {getPillLabel(runningTasks)} + + {pillNeedsCta(runningTasks) && ( + · {figures.arrowDown} to view + )} + + ) } + type AgentPillProps = { - name: string; - color?: keyof Theme; - isSelected: boolean; - isViewed: boolean; - isIdle: boolean; - onClick?: () => void; -}; -function AgentPill(t0) { - const $ = _c(19); - const { - name, - color, - isSelected, - isViewed, - isIdle, - onClick - } = t0; - const [hover, setHover] = useState(false); - const highlighted = isSelected || hover; - let label; + name: string + color?: keyof Theme + isSelected: boolean + isViewed: boolean + isIdle: boolean + onClick?: () => void +} + +function AgentPill({ + name, + color, + isSelected, + isViewed, + isIdle, + onClick, +}: AgentPillProps): React.ReactNode { + const [hover, setHover] = useState(false) + // Hover mirrors the keyboard-selected look so the affordance is familiar. + const highlighted = isSelected || hover + + let label: React.ReactNode if (highlighted) { - let t1; - if ($[0] !== color || $[1] !== isViewed || $[2] !== name) { - t1 = color ? @{name} : @{name}; - $[0] = color; - $[1] = isViewed; - $[2] = name; - $[3] = t1; - } else { - t1 = $[3]; - } - label = t1; + label = color ? ( + + @{name} + + ) : ( + + @{name} + + ) + } else if (isIdle) { + label = ( + + @{name} + + ) + } else if (isViewed) { + label = ( + + @{name} + + ) } else { - if (isIdle) { - let t1; - if ($[4] !== isViewed || $[5] !== name) { - t1 = @{name}; - $[4] = isViewed; - $[5] = name; - $[6] = t1; - } else { - t1 = $[6]; - } - label = t1; - } else { - if (isViewed) { - let t1; - if ($[7] !== color || $[8] !== name) { - t1 = @{name}; - $[7] = color; - $[8] = name; - $[9] = t1; - } else { - t1 = $[9]; - } - label = t1; - } else { - const t1 = !color; - let t2; - if ($[10] !== color || $[11] !== name || $[12] !== t1) { - t2 = @{name}; - $[10] = color; - $[11] = name; - $[12] = t1; - $[13] = t2; - } else { - t2 = $[13]; - } - label = t2; - } - } - } - if (!onClick) { - return label; - } - let t1; - let t2; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setHover(true); - t2 = () => setHover(false); - $[14] = t1; - $[15] = t2; - } else { - t1 = $[14]; - t2 = $[15]; + label = ( + + @{name} + + ) } - let t3; - if ($[16] !== label || $[17] !== onClick) { - t3 = {label}; - $[16] = label; - $[17] = onClick; - $[18] = t3; - } else { - t3 = $[18]; - } - return t3; + + if (!onClick) return label + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {label} + + ) } -function SummaryPill(t0) { - const $ = _c(8); - const { - selected, - onClick, - children - } = t0; - const [hover, setHover] = useState(false); - const t1 = selected || hover; - let t2; - if ($[0] !== children || $[1] !== t1) { - t2 = {children}; - $[0] = children; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - const label = t2; - if (!onClick) { - return label; - } - let t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setHover(true); - t4 = () => setHover(false); - $[3] = t3; - $[4] = t4; - } else { - t3 = $[3]; - t4 = $[4]; - } - let t5; - if ($[5] !== label || $[6] !== onClick) { - t5 = {label}; - $[5] = label; - $[6] = onClick; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; + +function SummaryPill({ + selected, + onClick, + children, +}: { + selected: boolean + onClick?: () => void + children: React.ReactNode +}): React.ReactNode { + const [hover, setHover] = useState(false) + const label = ( + + {children} + + ) + if (!onClick) return label + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {label} + + ) } -function getAgentThemeColor(colorName: string | undefined): keyof Theme | undefined { - if (!colorName) return undefined; + +function getAgentThemeColor( + colorName: string | undefined, +): keyof Theme | undefined { + if (!colorName) return undefined if (AGENT_COLORS.includes(colorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]; + return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] } - return undefined; + return undefined } diff --git a/src/components/tasks/BackgroundTasksDialog.tsx b/src/components/tasks/BackgroundTasksDialog.tsx index c7bbd9b60..d9f119cf1 100644 --- a/src/components/tasks/BackgroundTasksDialog.tsx +++ b/src/components/tasks/BackgroundTasksDialog.tsx @@ -1,171 +1,214 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import React, { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; -import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; -import type { ToolUseContext } from 'src/Tool.js'; -import { DreamTask, type DreamTaskState } from 'src/tasks/DreamTask/DreamTask.js'; -import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js'; -import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js'; -import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js'; +import { feature } from 'bun:bundle' +import figures from 'figures' +import React, { + type ReactNode, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from 'react' +import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from 'src/state/teammateViewHelpers.js' +import type { ToolUseContext } from 'src/Tool.js' +import { + DreamTask, + type DreamTaskState, +} from 'src/tasks/DreamTask/DreamTask.js' +import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' +import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' +import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js' // Type import is erased at build time — safe even though module is ant-gated. -import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js'; -import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js'; -import { RemoteAgentTask, type RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { intersperse } from 'src/utils/array.js'; -import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js'; -import { stopUltraplan } from '../../commands/ultraplan.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { count } from '../../utils/array.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js'; -import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js'; -import { DreamDetailDialog } from './DreamDetailDialog.js'; -import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js'; -import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js'; -import { ShellDetailDialog } from './ShellDetailDialog.js'; -type ViewState = { - mode: 'list'; -} | { - mode: 'detail'; - itemId: string; -}; +import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js' +import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js' +import { + RemoteAgentTask, + type RemoteAgentTaskState, +} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' +import { + type BackgroundTaskState, + isBackgroundTask, + type TaskState, +} from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { intersperse } from 'src/utils/array.js' +import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js' +import { stopUltraplan } from '../../commands/ultraplan.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { count } from '../../utils/array.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js' +import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js' +import { DreamDetailDialog } from './DreamDetailDialog.js' +import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js' +import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js' +import { ShellDetailDialog } from './ShellDetailDialog.js' + +type ViewState = { mode: 'list' } | { mode: 'detail'; itemId: string } + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - toolUseContext: ToolUseContext; - initialDetailTaskId?: string; -}; -type ListItem = { - id: string; - type: 'local_bash'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'remote_agent'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'local_agent'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'in_process_teammate'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'local_workflow'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'monitor_mcp'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'dream'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'leader'; - label: string; - status: 'running'; -}; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + toolUseContext: ToolUseContext + initialDetailTaskId?: string +} + +type ListItem = + | { + id: string + type: 'local_bash' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'remote_agent' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'local_agent' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'in_process_teammate' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'local_workflow' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'monitor_mcp' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'dream' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'leader' + label: string + status: 'running' + } // WORKFLOW_SCRIPTS is ant-only (build_flags.yaml). Static imports would leak // ~1.3K lines into external builds. Gate with feature() + require so the // bundler can dead-code-eliminate the branch. /* eslint-disable @typescript-eslint/no-require-imports */ -const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') ? (require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js')).WorkflowDetailDialog : null; -const workflowTaskModule = feature('WORKFLOW_SCRIPTS') ? require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') : null; -const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null; -const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null; -const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null; +const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') + ? ( + require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js') + ).WorkflowDetailDialog + : null +const workflowTaskModule = feature('WORKFLOW_SCRIPTS') + ? (require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js')) + : null +const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null +const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null +const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null // Relative path, not `src/...` path-mapping — Bun's DCE can statically // resolve + eliminate `./` requires, but path-mapped strings stay opaque // and survive as dead literals in the bundle. Matches tasks.ts pattern. -const monitorMcpModule = feature('MONITOR_TOOL') ? require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js') : null; -const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null; -const MonitorMcpDetailDialog = feature('MONITOR_TOOL') ? (require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js')).MonitorMcpDetailDialog : null; +const monitorMcpModule = feature('MONITOR_TOOL') + ? (require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js')) + : null +const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null +const MonitorMcpDetailDialog = feature('MONITOR_TOOL') + ? ( + require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js') + ).MonitorMcpDetailDialog + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Helper to get filtered background tasks (excludes foregrounded local_agent) -function getSelectableBackgroundTasks(tasks: Record | undefined, foregroundedTaskId: string | undefined): TaskState[] { - const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask); - return backgroundTasks.filter(task => !(task.type === 'local_agent' && task.id === foregroundedTaskId)); +function getSelectableBackgroundTasks( + tasks: Record | undefined, + foregroundedTaskId: string | undefined, +): TaskState[] { + const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask) + return backgroundTasks.filter( + task => !(task.type === 'local_agent' && task.id === foregroundedTaskId), + ) } + export function BackgroundTasksDialog({ onDone, toolUseContext, - initialDetailTaskId + initialDetailTaskId, }: Props): React.ReactNode { - const tasks = useAppState(s => s.tasks); - const foregroundedTaskId = useAppState(s_0 => s_0.foregroundedTaskId); - const showSpinnerTree = useAppState(s_1 => s_1.expandedView) === 'teammates'; - const setAppState = useSetAppState(); - const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k'); - const typedTasks = tasks as Record | undefined; + const tasks = useAppState(s => s.tasks) + const foregroundedTaskId = useAppState(s => s.foregroundedTaskId) + const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' + const setAppState = useSetAppState() + const killAgentsShortcut = useShortcutDisplay( + 'chat:killAgents', + 'Chat', + 'ctrl+x ctrl+k', + ) + const typedTasks = tasks as Record | undefined // Track if we skipped list view on mount (for back button behavior) - const skippedListOnMount = useRef(false); + const skippedListOnMount = useRef(false) // Compute initial view state - skip list if caller provided a specific task, // or if there's exactly one task const [viewState, setViewState] = useState(() => { if (initialDetailTaskId) { - skippedListOnMount.current = true; - return { - mode: 'detail', - itemId: initialDetailTaskId - }; + skippedListOnMount.current = true + return { mode: 'detail', itemId: initialDetailTaskId } } - const allItems = getSelectableBackgroundTasks(typedTasks, foregroundedTaskId); + const allItems = getSelectableBackgroundTasks( + typedTasks, + foregroundedTaskId, + ) if (allItems.length === 1) { - skippedListOnMount.current = true; - return { - mode: 'detail', - itemId: allItems[0]!.id - }; + skippedListOnMount.current = true + return { mode: 'detail', itemId: allItems[0]!.id } } - return { - mode: 'list' - }; - }); - const [selectedIndex, setSelectedIndex] = useState(0); + return { mode: 'list' } + }) + const [selectedIndex, setSelectedIndex] = useState(0) // Register as modal overlay so parent Chat keybindings (up/down for history) // are deactivated while this dialog is open - useRegisterOverlay('background-tasks-dialog', undefined); + useRegisterOverlay('background-tasks-dialog') // Memoize the sorted and categorized items together to ensure stable references const { @@ -175,37 +218,48 @@ export function BackgroundTasksDialog({ teammateTasks, workflowTasks, mcpMonitors, - dreamTasks: dreamTasks_0, - allSelectableItems + dreamTasks, + allSelectableItems, } = useMemo(() => { // Filter to only show running/pending background tasks, matching the status bar count - const backgroundTasks = Object.values(typedTasks ?? {}).filter(isBackgroundTask); - const allItems_0 = backgroundTasks.map(toListItem); - const sorted = allItems_0.sort((a, b) => { - const aStatus = a.status; - const bStatus = b.status; - if (aStatus === 'running' && bStatus !== 'running') return -1; - if (aStatus !== 'running' && bStatus === 'running') return 1; - const aTime = 'task' in a ? a.task.startTime : 0; - const bTime = 'task' in b ? b.task.startTime : 0; - return bTime - aTime; - }); - const bash = sorted.filter(item => item.type === 'local_bash'); - const remote = sorted.filter(item_0 => item_0.type === 'remote_agent'); + const backgroundTasks = Object.values(typedTasks ?? {}).filter( + isBackgroundTask, + ) + const allItems = backgroundTasks.map(toListItem) + const sorted = allItems.sort((a, b) => { + const aStatus = a.status + const bStatus = b.status + if (aStatus === 'running' && bStatus !== 'running') return -1 + if (aStatus !== 'running' && bStatus === 'running') return 1 + const aTime = 'task' in a ? a.task.startTime : 0 + const bTime = 'task' in b ? b.task.startTime : 0 + return bTime - aTime + }) + const bash = sorted.filter(item => item.type === 'local_bash') + const remote = sorted.filter(item => item.type === 'remote_agent') // Exclude foregrounded task - it's being viewed in the main UI, not a background task - const agent = sorted.filter(item_1 => item_1.type === 'local_agent' && item_1.id !== foregroundedTaskId); - const workflows = sorted.filter(item_2 => item_2.type === 'local_workflow'); - const monitorMcp = sorted.filter(item_3 => item_3.type === 'monitor_mcp'); - const dreamTasks = sorted.filter(item_4 => item_4.type === 'dream'); + const agent = sorted.filter( + item => item.type === 'local_agent' && item.id !== foregroundedTaskId, + ) + const workflows = sorted.filter(item => item.type === 'local_workflow') + const monitorMcp = sorted.filter(item => item.type === 'monitor_mcp') + const dreamTasks = sorted.filter(item => item.type === 'dream') // In spinner-tree mode, exclude teammates from the dialog (they appear in the tree) - const teammates = showSpinnerTree ? [] : sorted.filter(item_5 => item_5.type === 'in_process_teammate'); + const teammates = showSpinnerTree + ? [] + : sorted.filter(item => item.type === 'in_process_teammate') // Add leader entry when there are teammates, so users can foreground back to leader - const leaderItem: ListItem[] = teammates.length > 0 ? [{ - id: '__leader__', - type: 'leader', - label: `@${TEAM_LEAD_NAME}`, - status: 'running' - }] : []; + const leaderItem: ListItem[] = + teammates.length > 0 + ? [ + { + id: '__leader__', + type: 'leader', + label: `@${TEAM_LEAD_NAME}`, + status: 'running', + }, + ] + : [] return { bashTasks: bash, remoteSessions: remote, @@ -217,135 +271,177 @@ export function BackgroundTasksDialog({ // Order MUST match JSX render order (teammates \u2192 bash \u2192 monitorMcp \u2192 // remote \u2192 agent \u2192 workflows \u2192 dream) so \u2193/\u2191 navigation moves the cursor // visually downward. - allSelectableItems: [...leaderItem, ...teammates, ...bash, ...monitorMcp, ...remote, ...agent, ...workflows, ...dreamTasks] - }; - }, [typedTasks, foregroundedTaskId, showSpinnerTree]); - const currentSelection = allSelectableItems[selectedIndex] ?? null; + allSelectableItems: [ + ...leaderItem, + ...teammates, + ...bash, + ...monitorMcp, + ...remote, + ...agent, + ...workflows, + ...dreamTasks, + ], + } + }, [typedTasks, foregroundedTaskId, showSpinnerTree]) + + const currentSelection = allSelectableItems[selectedIndex] ?? null // Use configurable keybindings for standard navigation and confirm/cancel. // confirm:no is handled by Dialog's onCancel prop. - useKeybindings({ - 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), - 'confirm:next': () => setSelectedIndex(prev_0 => Math.min(allSelectableItems.length - 1, prev_0 + 1)), - 'confirm:yes': () => { - const current = allSelectableItems[selectedIndex]; - if (current) { - if (current.type === 'leader') { - exitTeammateView(setAppState); - onDone('Viewing leader', { - display: 'system' - }); - } else { - setViewState({ - mode: 'detail', - itemId: current.id - }); + useKeybindings( + { + 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), + 'confirm:next': () => + setSelectedIndex(prev => + Math.min(allSelectableItems.length - 1, prev + 1), + ), + 'confirm:yes': () => { + const current = allSelectableItems[selectedIndex] + if (current) { + if (current.type === 'leader') { + exitTeammateView(setAppState) + onDone('Viewing leader', { display: 'system' }) + } else { + setViewState({ mode: 'detail', itemId: current.id }) + } } - } - } - }, { - context: 'Confirmation', - isActive: viewState.mode === 'list' - }); + }, + }, + { context: 'Confirmation', isActive: viewState.mode === 'list' }, + ) // Component-specific shortcuts (x=stop, f=foreground, right=zoom) shown in UI. // These are task-type and status dependent, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { // Only handle input when in list mode - if (viewState.mode !== 'list') return; + if (viewState.mode !== 'list') return + if (e.key === 'left') { - e.preventDefault(); - onDone('Background tasks dialog dismissed', { - display: 'system' - }); - return; + e.preventDefault() + onDone('Background tasks dialog dismissed', { display: 'system' }) + return } // Compute current selection at the time of the key press - const currentSelection_0 = allSelectableItems[selectedIndex]; - if (!currentSelection_0) return; // everything below requires a selection + const currentSelection = allSelectableItems[selectedIndex] + if (!currentSelection) return // everything below requires a selection if (e.key === 'x') { - e.preventDefault(); - if (currentSelection_0.type === 'local_bash' && currentSelection_0.status === 'running') { - void killShellTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'local_agent' && currentSelection_0.status === 'running') { - void killAgentTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'in_process_teammate' && currentSelection_0.status === 'running') { - void killTeammateTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'local_workflow' && currentSelection_0.status === 'running' && killWorkflowTask) { - killWorkflowTask(currentSelection_0.id, setAppState); - } else if (currentSelection_0.type === 'monitor_mcp' && currentSelection_0.status === 'running' && killMonitorMcp) { - killMonitorMcp(currentSelection_0.id, setAppState); - } else if (currentSelection_0.type === 'dream' && currentSelection_0.status === 'running') { - void killDreamTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'remote_agent' && currentSelection_0.status === 'running') { - if (currentSelection_0.task.isUltraplan) { - void stopUltraplan(currentSelection_0.id, currentSelection_0.task.sessionId, setAppState); + e.preventDefault() + if ( + currentSelection.type === 'local_bash' && + currentSelection.status === 'running' + ) { + void killShellTask(currentSelection.id) + } else if ( + currentSelection.type === 'local_agent' && + currentSelection.status === 'running' + ) { + void killAgentTask(currentSelection.id) + } else if ( + currentSelection.type === 'in_process_teammate' && + currentSelection.status === 'running' + ) { + void killTeammateTask(currentSelection.id) + } else if ( + currentSelection.type === 'local_workflow' && + currentSelection.status === 'running' && + killWorkflowTask + ) { + killWorkflowTask(currentSelection.id, setAppState) + } else if ( + currentSelection.type === 'monitor_mcp' && + currentSelection.status === 'running' && + killMonitorMcp + ) { + killMonitorMcp(currentSelection.id, setAppState) + } else if ( + currentSelection.type === 'dream' && + currentSelection.status === 'running' + ) { + void killDreamTask(currentSelection.id) + } else if ( + currentSelection.type === 'remote_agent' && + currentSelection.status === 'running' + ) { + if (currentSelection.task.isUltraplan) { + void stopUltraplan( + currentSelection.id, + currentSelection.task.sessionId, + setAppState, + ) } else { - void killRemoteAgentTask(currentSelection_0.id); + void killRemoteAgentTask(currentSelection.id) } } } + if (e.key === 'f') { - if (currentSelection_0.type === 'in_process_teammate' && currentSelection_0.status === 'running') { - e.preventDefault(); - enterTeammateView(currentSelection_0.id, setAppState); - onDone('Viewing teammate', { - display: 'system' - }); - } else if (currentSelection_0.type === 'leader') { - e.preventDefault(); - exitTeammateView(setAppState); - onDone('Viewing leader', { - display: 'system' - }); + if ( + currentSelection.type === 'in_process_teammate' && + currentSelection.status === 'running' + ) { + e.preventDefault() + enterTeammateView(currentSelection.id, setAppState) + onDone('Viewing teammate', { display: 'system' }) + } else if (currentSelection.type === 'leader') { + e.preventDefault() + exitTeammateView(setAppState) + onDone('Viewing leader', { display: 'system' }) } } - }; + } + async function killShellTask(taskId: string): Promise { - await LocalShellTask.kill(taskId, setAppState); + await LocalShellTask.kill(taskId, setAppState) } - async function killAgentTask(taskId_0: string): Promise { - await LocalAgentTask.kill(taskId_0, setAppState); + + async function killAgentTask(taskId: string): Promise { + await LocalAgentTask.kill(taskId, setAppState) } - async function killTeammateTask(taskId_1: string): Promise { - await InProcessTeammateTask.kill(taskId_1, setAppState); + + async function killTeammateTask(taskId: string): Promise { + await InProcessTeammateTask.kill(taskId, setAppState) } - async function killDreamTask(taskId_2: string): Promise { - await DreamTask.kill(taskId_2, setAppState); + + async function killDreamTask(taskId: string): Promise { + await DreamTask.kill(taskId, setAppState) } - async function killRemoteAgentTask(taskId_3: string): Promise { - await RemoteAgentTask.kill(taskId_3, setAppState); + + async function killRemoteAgentTask(taskId: string): Promise { + await RemoteAgentTask.kill(taskId, setAppState) } // Wrap onDone in useEffectEvent to get a stable reference that always calls // the current onDone callback without causing the effect to re-fire. - const onDoneEvent = useEffectEvent(onDone); + const onDoneEvent = useEffectEvent(onDone) + useEffect(() => { if (viewState.mode !== 'list') { - const task = (typedTasks ?? {})[viewState.itemId]; + const task = (typedTasks ?? {})[viewState.itemId] // Workflow tasks get a grace: their detail view stays open through // completion so the user sees the final state before eviction. - if (!task || task.type !== 'local_workflow' && !isBackgroundTask(task)) { + if ( + !task || + (task.type !== 'local_workflow' && !isBackgroundTask(task)) + ) { // Task was removed or is no longer a background task (e.g. killed). // If we skipped the list on mount, close the dialog entirely. if (skippedListOnMount.current) { onDoneEvent('Background tasks dialog dismissed', { - display: 'system' - }); + display: 'system', + }) } else { - setViewState({ - mode: 'list' - }); + setViewState({ mode: 'list' }) } } } - const totalItems = allSelectableItems.length; + + const totalItems = allSelectableItems.length if (selectedIndex >= totalItems && totalItems > 0) { - setSelectedIndex(totalItems - 1); + setSelectedIndex(totalItems - 1) } - }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]); + }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]) // Helper to go back to list view (or close dialog if we skipped list on // mount AND there's still only ≤1 item). Checking current count prevents @@ -353,142 +449,421 @@ export function BackgroundTasksDialog({ // then a second task started, 'back' should show the list — not close. const goBackToList = () => { if (skippedListOnMount.current && allSelectableItems.length <= 1) { - onDone('Background tasks dialog dismissed', { - display: 'system' - }); + onDone('Background tasks dialog dismissed', { display: 'system' }) } else { - skippedListOnMount.current = false; - setViewState({ - mode: 'list' - }); + skippedListOnMount.current = false + setViewState({ mode: 'list' }) } - }; + } // If an item is selected, show the appropriate view if (viewState.mode !== 'list' && typedTasks) { - const task_0 = typedTasks[viewState.itemId]; - if (!task_0) { - return null; + const task = typedTasks[viewState.itemId] + if (!task) { + return null } // Detail mode - show appropriate detail dialog - switch (task_0.type) { + switch (task.type) { case 'local_bash': - return void killShellTask(task_0.id)} onBack={goBackToList} key={`shell-${task_0.id}`} />; + return ( + void killShellTask(task.id)} + onBack={goBackToList} + key={`shell-${task.id}`} + /> + ) case 'local_agent': - return void killAgentTask(task_0.id)} onBack={goBackToList} key={`agent-${task_0.id}`} />; + return ( + void killAgentTask(task.id)} + onBack={goBackToList} + key={`agent-${task.id}`} + /> + ) case 'remote_agent': - return void stopUltraplan(task_0.id, task_0.sessionId, setAppState) : () => void killRemoteAgentTask(task_0.id)} key={`session-${task_0.id}`} />; + return ( + + void stopUltraplan(task.id, task.sessionId, setAppState) + : () => void killRemoteAgentTask(task.id) + } + key={`session-${task.id}`} + /> + ) case 'in_process_teammate': - return void killTeammateTask(task_0.id) : undefined} onBack={goBackToList} onForeground={task_0.status === 'running' ? () => { - enterTeammateView(task_0.id, setAppState); - onDone('Viewing teammate', { - display: 'system' - }); - } : undefined} key={`teammate-${task_0.id}`} />; + return ( + void killTeammateTask(task.id) + : undefined + } + onBack={goBackToList} + onForeground={ + task.status === 'running' + ? () => { + enterTeammateView(task.id, setAppState) + onDone('Viewing teammate', { display: 'system' }) + } + : undefined + } + key={`teammate-${task.id}`} + /> + ) case 'local_workflow': - if (!WorkflowDetailDialog) return null; - return killWorkflowTask(task_0.id, setAppState) : undefined} onSkipAgent={task_0.status === 'running' && skipWorkflowAgent ? agentId => skipWorkflowAgent(task_0.id, agentId, setAppState) : undefined} onRetryAgent={task_0.status === 'running' && retryWorkflowAgent ? agentId_0 => retryWorkflowAgent(task_0.id, agentId_0, setAppState) : undefined} onBack={goBackToList} key={`workflow-${task_0.id}`} />; + if (!WorkflowDetailDialog) return null + return ( + killWorkflowTask(task.id, setAppState) + : undefined + } + onSkipAgent={ + task.status === 'running' && skipWorkflowAgent + ? agentId => skipWorkflowAgent(task.id, agentId, setAppState) + : undefined + } + onRetryAgent={ + task.status === 'running' && retryWorkflowAgent + ? agentId => retryWorkflowAgent(task.id, agentId, setAppState) + : undefined + } + onBack={goBackToList} + key={`workflow-${task.id}`} + /> + ) case 'monitor_mcp': - if (!MonitorMcpDetailDialog) return null; - return killMonitorMcp(task_0.id, setAppState) : undefined} onBack={goBackToList} key={`monitor-mcp-${task_0.id}`} />; + if (!MonitorMcpDetailDialog) return null + return ( + killMonitorMcp(task.id, setAppState) + : undefined + } + onBack={goBackToList} + key={`monitor-mcp-${task.id}`} + /> + ) case 'dream': - return onDone('Background tasks dialog dismissed', { - display: 'system' - })} onBack={goBackToList} onKill={task_0.status === 'running' ? () => void killDreamTask(task_0.id) : undefined} key={`dream-${task_0.id}`} />; + return ( + + onDone('Background tasks dialog dismissed', { + display: 'system', + }) + } + onBack={goBackToList} + onKill={ + task.status === 'running' + ? () => void killDreamTask(task.id) + : undefined + } + key={`dream-${task.id}`} + /> + ) } } - const runningBashCount = count(bashTasks, _ => _.status === 'running'); - const runningAgentCount = count(remoteSessions, __0 => __0.status === 'running' || __0.status === 'pending') + count(agentTasks, __1 => __1.status === 'running'); - const runningTeammateCount = count(teammateTasks, __2 => __2.status === 'running'); - const subtitle = intersperse([...(runningTeammateCount > 0 ? [ + + const runningBashCount = count(bashTasks, _ => _.status === 'running') + const runningAgentCount = + count( + remoteSessions, + _ => _.status === 'running' || _.status === 'pending', + ) + count(agentTasks, _ => _.status === 'running') + const runningTeammateCount = count(teammateTasks, _ => _.status === 'running') + const subtitle = intersperse( + [ + ...(runningTeammateCount > 0 + ? [ + {runningTeammateCount}{' '} {runningTeammateCount !== 1 ? 'agents' : 'agent'} - ] : []), ...(runningBashCount > 0 ? [ + , + ] + : []), + ...(runningBashCount > 0 + ? [ + {runningBashCount}{' '} {runningBashCount !== 1 ? 'active shells' : 'active shell'} - ] : []), ...(runningAgentCount > 0 ? [ + , + ] + : []), + ...(runningAgentCount > 0 + ? [ + {runningAgentCount}{' '} {runningAgentCount !== 1 ? 'active agents' : 'active agent'} - ] : [])], index => · ); - const actions = [, , ...(currentSelection?.type === 'in_process_teammate' && currentSelection.status === 'running' ? [] : []), ...((currentSelection?.type === 'local_bash' || currentSelection?.type === 'local_agent' || currentSelection?.type === 'in_process_teammate' || currentSelection?.type === 'local_workflow' || currentSelection?.type === 'monitor_mcp' || currentSelection?.type === 'dream' || currentSelection?.type === 'remote_agent') && currentSelection.status === 'running' ? [] : []), ...(agentTasks.some(t => t.status === 'running') ? [] : []), ]; - const handleCancel = () => onDone('Background tasks dialog dismissed', { - display: 'system' - }); + , + ] + : []), + ], + index => · , + ) + + const actions = [ + , + , + ...(currentSelection?.type === 'in_process_teammate' && + currentSelection.status === 'running' + ? [ + , + ] + : []), + ...((currentSelection?.type === 'local_bash' || + currentSelection?.type === 'local_agent' || + currentSelection?.type === 'in_process_teammate' || + currentSelection?.type === 'local_workflow' || + currentSelection?.type === 'monitor_mcp' || + currentSelection?.type === 'dream' || + currentSelection?.type === 'remote_agent') && + currentSelection.status === 'running' + ? [] + : []), + ...(agentTasks.some(t => t.status === 'running') + ? [ + , + ] + : []), + , + ] + + const handleCancel = () => + onDone('Background tasks dialog dismissed', { display: 'system' }) + function renderInputGuide(exitState: ExitState): React.ReactNode { if (exitState.pending) { - return Press {exitState.keyName} again to exit; + return Press {exitState.keyName} again to exit } - return {actions}; + return {actions} } - return - {subtitle}} onCancel={handleCancel} color="background" inputGuide={renderInputGuide}> - {allSelectableItems.length === 0 ? No tasks currently running : - {teammateTasks.length > 0 && - {(bashTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && + + return ( + + {subtitle}} + onCancel={handleCancel} + color="background" + inputGuide={renderInputGuide} + > + {allSelectableItems.length === 0 ? ( + No tasks currently running + ) : ( + + {teammateTasks.length > 0 && ( + + {(bashTasks.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0) && ( + {' '}Agents ( {count(teammateTasks, i => i.type !== 'leader')}) - } + + )} - + - } + + )} - {bashTasks.length > 0 && 0 ? 1 : 0}> - {(teammateTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && + {bashTasks.length > 0 && ( + 0 ? 1 : 0} + > + {(teammateTasks.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0) && ( + {' '}Shells ({bashTasks.length}) - } + + )} - {bashTasks.map(item_6 => )} + {bashTasks.map(item => ( + + ))} - } + + )} - {mcpMonitors.length > 0 && 0 || bashTasks.length > 0 ? 1 : 0}> + {mcpMonitors.length > 0 && ( + 0 || bashTasks.length > 0 ? 1 : 0 + } + > {' '}Monitors ({mcpMonitors.length}) - {mcpMonitors.map(item_7 => )} + {mcpMonitors.map(item => ( + + ))} - } + + )} - {remoteSessions.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 ? 1 : 0}> + {remoteSessions.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 + ? 1 + : 0 + } + > {' '}Remote agents ({remoteSessions.length} ) - {remoteSessions.map(item_8 => )} + {remoteSessions.map(item => ( + + ))} - } + + )} - {agentTasks.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 ? 1 : 0}> + {agentTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 + ? 1 + : 0 + } + > {' '}Local agents ({agentTasks.length}) - {agentTasks.map(item_9 => )} + {agentTasks.map(item => ( + + ))} - } + + )} - {workflowTasks.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 ? 1 : 0}> + {workflowTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0 + ? 1 + : 0 + } + > {' '}Workflows ({workflowTasks.length}) - {workflowTasks.map(item_10 => )} + {workflowTasks.map(item => ( + + ))} - } + + )} - {dreamTasks_0.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 || workflowTasks.length > 0 ? 1 : 0}> + {dreamTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0 || + workflowTasks.length > 0 + ? 1 + : 0 + } + > - {dreamTasks_0.map(item_11 => )} + {dreamTasks.map(item => ( + + ))} - } - } + + )} + + )} - ; + + ) } + function toListItem(task: BackgroundTaskState): ListItem { switch (task.type) { case 'local_bash': @@ -497,155 +872,141 @@ function toListItem(task: BackgroundTaskState): ListItem { type: 'local_bash', label: task.kind === 'monitor' ? task.description : task.command, status: task.status, - task - }; + task, + } case 'remote_agent': return { id: task.id, type: 'remote_agent', label: task.title, status: task.status, - task - }; + task, + } case 'local_agent': return { id: task.id, type: 'local_agent', label: task.description, status: task.status, - task - }; + task, + } case 'in_process_teammate': return { id: task.id, type: 'in_process_teammate', label: `@${task.identity.agentName}`, status: task.status, - task - }; + task, + } case 'local_workflow': return { id: task.id, type: 'local_workflow', label: task.summary ?? task.description, status: task.status, - task - }; + task, + } case 'monitor_mcp': return { id: task.id, type: 'monitor_mcp', label: task.description, status: task.status, - task - }; + task, + } case 'dream': return { id: task.id, type: 'dream', label: task.description, status: task.status, - task - }; + task, + } } } -function Item(t0) { - const $ = _c(14); - const { - item, - isSelected - } = t0; - const { - columns - } = useTerminalSize(); - const maxActivityWidth = Math.max(30, columns - 26); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = isCoordinatorMode(); - $[0] = t1; - } else { - t1 = $[0]; - } - const useGreyPointer = t1; - const t2 = useGreyPointer && isSelected; - const t3 = isSelected ? figures.pointer + " " : " "; - let t4; - if ($[1] !== t2 || $[2] !== t3) { - t4 = {t3}; - $[1] = t2; - $[2] = t3; - $[3] = t4; - } else { - t4 = $[3]; - } - const t5 = isSelected && !useGreyPointer ? "suggestion" : undefined; - let t6; - if ($[4] !== item.task || $[5] !== item.type || $[6] !== maxActivityWidth) { - t6 = item.type === "leader" ? @{TEAM_LEAD_NAME} : ; - $[4] = item.task; - $[5] = item.type; - $[6] = maxActivityWidth; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== t5 || $[9] !== t6) { - t7 = {t6}; - $[8] = t5; - $[9] = t6; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t4 || $[12] !== t7) { - t8 = {t4}{t7}; - $[11] = t4; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; + +function Item({ + item, + isSelected, +}: { + item: ListItem + isSelected: boolean +}): ReactNode { + const { columns } = useTerminalSize() + // Dialog border (2) + padding (2) + pointer prefix (2) + name/status overhead (~20) + const maxActivityWidth = Math.max(30, columns - 26) + // In coordinator mode, use grey pointer instead of blue + const useGreyPointer = isCoordinatorMode() + + return ( + + + {isSelected ? figures.pointer + ' ' : ' '} + + + {item.type === 'leader' ? ( + @{TEAM_LEAD_NAME} + ) : ( + + )} + + + ) } -function TeammateTaskGroups(t0) { - const $ = _c(3); - const { - teammateTasks, - currentSelectionId - } = t0; - let t1; - if ($[0] !== currentSelectionId || $[1] !== teammateTasks) { - const leaderItems = teammateTasks.filter(_temp); - const teammateItems = teammateTasks.filter(_temp2); - const teams = new Map(); - for (const item of teammateItems) { - const teamName = item.task.identity.teamName; - const group = teams.get(teamName); - if (group) { - group.push(item); - } else { - teams.set(teamName, [item]); - } + +function TeammateTaskGroups({ + teammateTasks, + currentSelectionId, +}: { + teammateTasks: ListItem[] + currentSelectionId: string | undefined +}): ReactNode { + // Separate leader from teammates, group teammates by team + const leaderItems = teammateTasks.filter(i => i.type === 'leader') + const teammateItems = teammateTasks.filter( + i => i.type === 'in_process_teammate', + ) + const teams = new Map() + for (const item of teammateItems) { + const teamName = item.task.identity.teamName + const group = teams.get(teamName) + if (group) { + group.push(item) + } else { + teams.set(teamName, [item]) } - const teamEntries = [...teams.entries()]; - t1 = <>{teamEntries.map(t2 => { - const [teamName_0, items] = t2; - const memberCount = items.length + leaderItems.length; - return {" "}Team: {teamName_0} ({memberCount}){leaderItems.map(item_0 => )}{items.map(item_1 => )}; - })}; - $[0] = currentSelectionId; - $[1] = teammateTasks; - $[2] = t1; - } else { - t1 = $[2]; } - return t1; -} -function _temp2(i_0) { - return i_0.type === "in_process_teammate"; -} -function _temp(i) { - return i.type === "leader"; + const teamEntries = [...teams.entries()] + return ( + <> + {teamEntries.map(([teamName, items]) => { + const memberCount = items.length + leaderItems.length + return ( + + + {' '}Team: {teamName} ({memberCount}) + + {/* Render leader first within each team */} + {leaderItems.map(item => ( + + ))} + {items.map(item => ( + + ))} + + ) + })} + + ) } diff --git a/src/components/tasks/DreamDetailDialog.tsx b/src/components/tasks/DreamDetailDialog.tsx index 46470c000..bea310946 100644 --- a/src/components/tasks/DreamDetailDialog.tsx +++ b/src/components/tasks/DreamDetailDialog.tsx @@ -1,250 +1,136 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js' +import { plural } from '../../utils/stringUtils.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - task: DeepImmutable; - onDone: () => void; - onBack?: () => void; - onKill?: () => void; -}; + task: DeepImmutable + onDone: () => void + onBack?: () => void + onKill?: () => void +} // How many recent turns to render. Earlier turns collapse to a count. -const VISIBLE_TURNS = 6; -export function DreamDetailDialog(t0) { - const $ = _c(70); - const { - task, - onDone, - onBack, - onKill - } = t0; - const elapsedTime = useElapsedTime(task.startTime, task.status === "running", 1000, 0); - let t1; - if ($[0] !== onDone) { - t1 = { - "confirm:yes": onDone - }; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useKeybindings(t1, t2); - let t3; - if ($[3] !== onBack || $[4] !== onDone || $[5] !== onKill || $[6] !== task.status) { - t3 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && task.status === "running" && onKill) { - e.preventDefault(); - onKill(); - } - } - } - }; - $[3] = onBack; - $[4] = onDone; - $[5] = onKill; - $[6] = task.status; - $[7] = t3; - } else { - t3 = $[7]; - } - const handleKeyDown = t3; - let T0; - let T1; - let T2; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[8] !== elapsedTime || $[9] !== handleKeyDown || $[10] !== onBack || $[11] !== onDone || $[12] !== onKill || $[13] !== task.filesTouched.length || $[14] !== task.sessionsReviewing || $[15] !== task.status || $[16] !== task.turns) { - const visibleTurns = task.turns.filter(_temp); - const shown = visibleTurns.slice(-VISIBLE_TURNS); - const hidden = visibleTurns.length - shown.length; - T2 = Box; - t13 = "column"; - t14 = 0; - t15 = true; - t16 = handleKeyDown; - T1 = Dialog; - t8 = "Memory consolidation"; - const t17 = task.sessionsReviewing; - let t18; - if ($[33] !== task.sessionsReviewing) { - t18 = plural(task.sessionsReviewing, "session"); - $[33] = task.sessionsReviewing; - $[34] = t18; - } else { - t18 = $[34]; - } - let t19; - if ($[35] !== task.filesTouched.length) { - t19 = task.filesTouched.length > 0 && <>{" "}· {task.filesTouched.length}{" "}{plural(task.filesTouched.length, "file")} touched; - $[35] = task.filesTouched.length; - $[36] = t19; - } else { - t19 = $[36]; - } - if ($[37] !== elapsedTime || $[38] !== t18 || $[39] !== t19 || $[40] !== task.sessionsReviewing) { - t9 = {elapsedTime} · reviewing {t17}{" "}{t18}{t19}; - $[37] = elapsedTime; - $[38] = t18; - $[39] = t19; - $[40] = task.sessionsReviewing; - $[41] = t9; - } else { - t9 = $[41]; - } - t10 = onDone; - t11 = "background"; - if ($[42] !== onBack || $[43] !== onKill || $[44] !== task.status) { - t12 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{task.status === "running" && onKill && }; - $[42] = onBack; - $[43] = onKill; - $[44] = task.status; - $[45] = t12; - } else { - t12 = $[45]; - } - T0 = Box; - t4 = "column"; - t5 = 1; - let t20; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t20 = Status:; - $[46] = t20; - } else { - t20 = $[46]; - } - if ($[47] !== task.status) { - t6 = {t20}{" "}{task.status === "running" ? running : task.status === "completed" ? {task.status} : {task.status}}; - $[47] = task.status; - $[48] = t6; - } else { - t6 = $[48]; +const VISIBLE_TURNS = 6 + +export function DreamDetailDialog({ + task, + onDone, + onBack, + onKill, +}: Props): React.ReactNode { + const elapsedTime = useElapsedTime( + task.startTime, + task.status === 'running', + 1000, + 0, + ) + + // Dialog handles confirm:no (Esc) → onCancel. Wire confirm:yes (Enter/y) too. + useKeybindings({ 'confirm:yes': onDone }, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && task.status === 'running' && onKill) { + e.preventDefault() + onKill() } - t7 = shown.length === 0 ? {task.status === "running" ? "Starting\u2026" : "(no text output)"} : <>{hidden > 0 && ({hidden} earlier {plural(hidden, "turn")})}{shown.map(_temp2)}; - $[8] = elapsedTime; - $[9] = handleKeyDown; - $[10] = onBack; - $[11] = onDone; - $[12] = onKill; - $[13] = task.filesTouched.length; - $[14] = task.sessionsReviewing; - $[15] = task.status; - $[16] = task.turns; - $[17] = T0; - $[18] = T1; - $[19] = T2; - $[20] = t10; - $[21] = t11; - $[22] = t12; - $[23] = t13; - $[24] = t14; - $[25] = t15; - $[26] = t16; - $[27] = t4; - $[28] = t5; - $[29] = t6; - $[30] = t7; - $[31] = t8; - $[32] = t9; - } else { - T0 = $[17]; - T1 = $[18]; - T2 = $[19]; - t10 = $[20]; - t11 = $[21]; - t12 = $[22]; - t13 = $[23]; - t14 = $[24]; - t15 = $[25]; - t16 = $[26]; - t4 = $[27]; - t5 = $[28]; - t6 = $[29]; - t7 = $[30]; - t8 = $[31]; - t9 = $[32]; - } - let t17; - if ($[49] !== T0 || $[50] !== t4 || $[51] !== t5 || $[52] !== t6 || $[53] !== t7) { - t17 = {t6}{t7}; - $[49] = T0; - $[50] = t4; - $[51] = t5; - $[52] = t6; - $[53] = t7; - $[54] = t17; - } else { - t17 = $[54]; } - let t18; - if ($[55] !== T1 || $[56] !== t10 || $[57] !== t11 || $[58] !== t12 || $[59] !== t17 || $[60] !== t8 || $[61] !== t9) { - t18 = {t17}; - $[55] = T1; - $[56] = t10; - $[57] = t11; - $[58] = t12; - $[59] = t17; - $[60] = t8; - $[61] = t9; - $[62] = t18; - } else { - t18 = $[62]; - } - let t19; - if ($[63] !== T2 || $[64] !== t13 || $[65] !== t14 || $[66] !== t15 || $[67] !== t16 || $[68] !== t18) { - t19 = {t18}; - $[63] = T2; - $[64] = t13; - $[65] = t14; - $[66] = t15; - $[67] = t16; - $[68] = t18; - $[69] = t19; - } else { - t19 = $[69]; - } - return t19; -} -function _temp2(turn, i) { - return {turn.text}{turn.toolUseCount > 0 && {" "}({turn.toolUseCount}{" "}{plural(turn.toolUseCount, "tool")})}; -} -function _temp(t) { - return t.text !== ""; + + // Turns with text to show. Tool-only turns (text='') are dropped entirely — + // the per-turn toolUseCount already captures that work. + const visibleTurns = task.turns.filter(t => t.text !== '') + const shown = visibleTurns.slice(-VISIBLE_TURNS) + const hidden = visibleTurns.length - shown.length + + return ( + + + {elapsedTime} · reviewing {task.sessionsReviewing}{' '} + {plural(task.sessionsReviewing, 'session')} + {task.filesTouched.length > 0 && ( + <> + {' '} + · {task.filesTouched.length}{' '} + {plural(task.filesTouched.length, 'file')} touched + + )} + + } + onCancel={onDone} + color="background" + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {task.status === 'running' && onKill && ( + + )} + + ) + } + > + + + Status:{' '} + {task.status === 'running' ? ( + running + ) : task.status === 'completed' ? ( + {task.status} + ) : ( + {task.status} + )} + + + {shown.length === 0 ? ( + + {task.status === 'running' ? 'Starting…' : '(no text output)'} + + ) : ( + <> + {hidden > 0 && ( + + ({hidden} earlier {plural(hidden, 'turn')}) + + )} + {shown.map((turn, i) => ( + + {turn.text} + {turn.toolUseCount > 0 && ( + + {' '}({turn.toolUseCount}{' '} + {plural(turn.toolUseCount, 'tool')}) + + )} + + ))} + + )} + + + + ) } diff --git a/src/components/tasks/InProcessTeammateDetailDialog.tsx b/src/components/tasks/InProcessTeammateDetailDialog.tsx index c8b25f80f..b59bbbd5e 100644 --- a/src/components/tasks/InProcessTeammateDetailDialog.tsx +++ b/src/components/tasks/InProcessTeammateDetailDialog.tsx @@ -1,265 +1,193 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { getEmptyToolPermissionContext } from '../../Tool.js'; -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { getTools } from '../../tools.js'; -import { formatNumber, truncateToWidth } from '../../utils/format.js'; -import { toInkColor } from '../../utils/ink.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { renderToolActivity } from './renderToolActivity.js'; -import { describeTeammateActivity } from './taskStatusUtils.js'; +import React, { useMemo } from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' +import { getTools } from '../../tools.js' +import { formatNumber, truncateToWidth } from '../../utils/format.js' +import { toInkColor } from '../../utils/ink.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { renderToolActivity } from './renderToolActivity.js' +import { describeTeammateActivity } from './taskStatusUtils.js' + type Props = { - teammate: DeepImmutable; - onDone: () => void; - onKill?: () => void; - onBack?: () => void; - onForeground?: () => void; -}; -export function InProcessTeammateDetailDialog(t0) { - const $ = _c(63); - const { - teammate, - onDone, - onKill, - onBack, - onForeground - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTools(getEmptyToolPermissionContext()); - $[0] = t1; - } else { - t1 = $[0]; - } - const tools = t1; - const elapsedTime = useElapsedTime(teammate.startTime, teammate.status === "running", 1000, teammate.totalPausedMs ?? 0); - let t2; - if ($[1] !== onDone) { - t2 = { - "confirm:yes": onDone - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybindings(t2, t3); - let t4; - if ($[4] !== onBack || $[5] !== onDone || $[6] !== onForeground || $[7] !== onKill || $[8] !== teammate.status) { - t4 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && teammate.status === "running" && onKill) { - e.preventDefault(); - onKill(); - } else { - if (e.key === "f" && teammate.status === "running" && onForeground) { - e.preventDefault(); - onForeground(); - } + teammate: DeepImmutable + onDone: () => void + onKill?: () => void + onBack?: () => void + onForeground?: () => void +} +export function InProcessTeammateDetailDialog({ + teammate, + onDone, + onKill, + onBack, + onForeground, +}: Props): React.ReactNode { + const [theme] = useTheme() + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + + const elapsedTime = useElapsedTime( + teammate.startTime, + teammate.status === 'running', + 1000, + teammate.totalPausedMs ?? 0, + ) + + // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) + useKeybindings( + { + 'confirm:yes': onDone, + }, + { context: 'Confirmation' }, + ) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && teammate.status === 'running' && onKill) { + e.preventDefault() + onKill() + } else if (e.key === 'f' && teammate.status === 'running' && onForeground) { + e.preventDefault() + onForeground() + } + } + + const activity = describeTeammateActivity(teammate) + + const tokenCount = + teammate.result?.totalTokens ?? teammate.progress?.tokenCount + const toolUseCount = + teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount + + const displayPrompt = truncateToWidth(teammate.prompt, 300) + + const title = ( + + + @{teammate.identity.agentName} + + {activity && ({activity})} + + ) + + const subtitle = ( + + {teammate.status !== 'running' && ( + + {teammate.status === 'completed' + ? 'Completed' + : teammate.status === 'failed' + ? 'Failed' + : 'Stopped'} + {' · '} + + )} + + {elapsedTime} + {tokenCount !== undefined && tokenCount > 0 && ( + <> · {formatNumber(tokenCount)} tokens + )} + {toolUseCount !== undefined && toolUseCount > 0 && ( + <> + {' '} + · {toolUseCount} {toolUseCount === 1 ? 'tool' : 'tools'} + + )} + + + ) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {teammate.status === 'running' && onKill && ( + + )} + {teammate.status === 'running' && onForeground && ( + + )} + + ) } - } - }; - $[4] = onBack; - $[5] = onDone; - $[6] = onForeground; - $[7] = onKill; - $[8] = teammate.status; - $[9] = t4; - } else { - t4 = $[9]; - } - const handleKeyDown = t4; - let t5; - if ($[10] !== teammate) { - t5 = describeTeammateActivity(teammate); - $[10] = teammate; - $[11] = t5; - } else { - t5 = $[11]; - } - const activity = t5; - const tokenCount = teammate.result?.totalTokens ?? teammate.progress?.tokenCount; - const toolUseCount = teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount; - let t6; - if ($[12] !== teammate.prompt) { - t6 = truncateToWidth(teammate.prompt, 300); - $[12] = teammate.prompt; - $[13] = t6; - } else { - t6 = $[13]; - } - const displayPrompt = t6; - let t7; - if ($[14] !== teammate.identity.color) { - t7 = toInkColor(teammate.identity.color); - $[14] = teammate.identity.color; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t7 || $[17] !== teammate.identity.agentName) { - t8 = @{teammate.identity.agentName}; - $[16] = t7; - $[17] = teammate.identity.agentName; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== activity) { - t9 = activity && ({activity}); - $[19] = activity; - $[20] = t9; - } else { - t9 = $[20]; - } - let t10; - if ($[21] !== t8 || $[22] !== t9) { - t10 = {t8}{t9}; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const title = t10; - let t11; - if ($[24] !== teammate.status) { - t11 = teammate.status !== "running" && {teammate.status === "completed" ? "Completed" : teammate.status === "failed" ? "Failed" : "Stopped"}{" \xB7 "}; - $[24] = teammate.status; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== tokenCount) { - t12 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[26] = tokenCount; - $[27] = t12; - } else { - t12 = $[27]; - } - let t13; - if ($[28] !== toolUseCount) { - t13 = toolUseCount !== undefined && toolUseCount > 0 && <>{" "}· {toolUseCount} {toolUseCount === 1 ? "tool" : "tools"}; - $[28] = toolUseCount; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== elapsedTime || $[31] !== t12 || $[32] !== t13) { - t14 = {elapsedTime}{t12}{t13}; - $[30] = elapsedTime; - $[31] = t12; - $[32] = t13; - $[33] = t14; - } else { - t14 = $[33]; - } - let t15; - if ($[34] !== t11 || $[35] !== t14) { - t15 = {t11}{t14}; - $[34] = t11; - $[35] = t14; - $[36] = t15; - } else { - t15 = $[36]; - } - const subtitle = t15; - let t16; - if ($[37] !== onBack || $[38] !== onForeground || $[39] !== onKill || $[40] !== teammate.status) { - t16 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{teammate.status === "running" && onKill && }{teammate.status === "running" && onForeground && }; - $[37] = onBack; - $[38] = onForeground; - $[39] = onKill; - $[40] = teammate.status; - $[41] = t16; - } else { - t16 = $[41]; - } - let t17; - if ($[42] !== teammate.progress || $[43] !== teammate.status || $[44] !== theme) { - t17 = teammate.status === "running" && teammate.progress?.recentActivities && teammate.progress.recentActivities.length > 0 && Progress{teammate.progress.recentActivities.map((activity_0, i) => {i === teammate.progress.recentActivities.length - 1 ? "\u203A " : " "}{renderToolActivity(activity_0, tools, theme)})}; - $[42] = teammate.progress; - $[43] = teammate.status; - $[44] = theme; - $[45] = t17; - } else { - t17 = $[45]; - } - let t18; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t18 = Prompt; - $[46] = t18; - } else { - t18 = $[46]; - } - let t19; - if ($[47] !== displayPrompt) { - t19 = {t18}{displayPrompt}; - $[47] = displayPrompt; - $[48] = t19; - } else { - t19 = $[48]; - } - let t20; - if ($[49] !== teammate.error || $[50] !== teammate.status) { - t20 = teammate.status === "failed" && teammate.error && Error{teammate.error}; - $[49] = teammate.error; - $[50] = teammate.status; - $[51] = t20; - } else { - t20 = $[51]; - } - let t21; - if ($[52] !== onDone || $[53] !== subtitle || $[54] !== t16 || $[55] !== t17 || $[56] !== t19 || $[57] !== t20 || $[58] !== title) { - t21 = {t17}{t19}{t20}; - $[52] = onDone; - $[53] = subtitle; - $[54] = t16; - $[55] = t17; - $[56] = t19; - $[57] = t20; - $[58] = title; - $[59] = t21; - } else { - t21 = $[59]; - } - let t22; - if ($[60] !== handleKeyDown || $[61] !== t21) { - t22 = {t21}; - $[60] = handleKeyDown; - $[61] = t21; - $[62] = t22; - } else { - t22 = $[62]; - } - return t22; + > + {/* Recent activities for running teammates */} + {teammate.status === 'running' && + teammate.progress?.recentActivities && + teammate.progress.recentActivities.length > 0 && ( + + + Progress + + {teammate.progress.recentActivities.map((activity, i) => ( + + {i === teammate.progress!.recentActivities!.length - 1 + ? '› ' + : ' '} + {renderToolActivity(activity, tools, theme)} + + ))} + + )} + + {/* Prompt section */} + + + Prompt + + {displayPrompt} + + + {/* Error details if failed */} + {teammate.status === 'failed' && teammate.error && ( + + + Error + + + {teammate.error} + + + )} + + + ) } diff --git a/src/components/tasks/RemoteSessionDetailDialog.tsx b/src/components/tasks/RemoteSessionDetailDialog.tsx index f5435ead7..55c897fd9 100644 --- a/src/components/tasks/RemoteSessionDetailDialog.tsx +++ b/src/components/tasks/RemoteSessionDetailDialog.tsx @@ -1,41 +1,48 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useMemo, useState } from 'react'; -import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'; -import type { ToolUseContext } from 'src/Tool.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Link, Text } from '../../ink.js'; -import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'; -import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js'; -import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js'; -import { openBrowser } from '../../utils/browser.js'; -import { errorMessage } from '../../utils/errors.js'; -import { formatDuration, truncateToWidth } from '../../utils/format.js'; -import { toInternalMessages } from '../../utils/messages/mappers.js'; -import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; -import { plural } from '../../utils/stringUtils.js'; -import { teleportResumeCodeSession } from '../../utils/teleport.js'; -import { Select } from '../CustomSelect/select.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Message } from '../Message.js'; -import { formatReviewStageCounts, RemoteSessionProgress } from './RemoteSessionProgress.js'; +import figures from 'figures' +import React, { useMemo, useState } from 'react' +import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js' +import type { ToolUseContext } from 'src/Tool.js' +import type { DeepImmutable } from 'src/types/utils.js' +import type { CommandResultDisplay } from '../../commands.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Link, Text } from '../../ink.js' +import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { + AGENT_TOOL_NAME, + LEGACY_AGENT_TOOL_NAME, +} from '../../tools/AgentTool/constants.js' +import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js' +import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js' +import { openBrowser } from '../../utils/browser.js' +import { errorMessage } from '../../utils/errors.js' +import { formatDuration, truncateToWidth } from '../../utils/format.js' +import { toInternalMessages } from '../../utils/messages/mappers.js' +import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js' +import { plural } from '../../utils/stringUtils.js' +import { teleportResumeCodeSession } from '../../utils/teleport.js' +import { Select } from '../CustomSelect/select.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Message } from '../Message.js' +import { + formatReviewStageCounts, + RemoteSessionProgress, +} from './RemoteSessionProgress.js' + type Props = { - session: DeepImmutable; - toolUseContext: ToolUseContext; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onBack?: () => void; - onKill?: () => void; -}; + session: DeepImmutable + toolUseContext: ToolUseContext + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onBack?: () => void + onKill?: () => void +} // Compact one-line summary: tool name + first meaningful string arg. // Lighter than tool.renderToolUseMessage (no registry lookup / schema parse). @@ -44,746 +51,423 @@ type Props = { export function formatToolUseSummary(name: string, input: unknown): string { // plan_ready phase is only reached via ExitPlanMode tool if (name === EXIT_PLAN_MODE_V2_TOOL_NAME) { - return 'Review the plan in Claude Code on the web'; + return 'Review the plan in Claude Code on the web' } - if (!input || typeof input !== 'object') return name; + if (!input || typeof input !== 'object') return name // AskUserQuestion: show the question text as a CTA, not the tool name. // Input shape is {questions: [{question, header, options}]}. if (name === ASK_USER_QUESTION_TOOL_NAME && 'questions' in input) { - const qs = input.questions; + const qs = input.questions if (Array.isArray(qs) && qs[0] && typeof qs[0] === 'object') { // Prefer question (full text) over header (max-12-char tag). header // is a required schema field so checking it first would make the // question fallback dead code. - const q = 'question' in qs[0] && typeof qs[0].question === 'string' && qs[0].question ? qs[0].question : 'header' in qs[0] && typeof qs[0].header === 'string' ? qs[0].header : null; + const q = + 'question' in qs[0] && + typeof qs[0].question === 'string' && + qs[0].question + ? qs[0].question + : 'header' in qs[0] && typeof qs[0].header === 'string' + ? qs[0].header + : null if (q) { - const oneLine = q.replace(/\s+/g, ' ').trim(); - return `Answer in browser: ${truncateToWidth(oneLine, 50)}`; + const oneLine = q.replace(/\s+/g, ' ').trim() + return `Answer in browser: ${truncateToWidth(oneLine, 50)}` } } } for (const v of Object.values(input)) { if (typeof v === 'string' && v.trim()) { - const oneLine = v.replace(/\s+/g, ' ').trim(); - return `${name} ${truncateToWidth(oneLine, 60)}`; + const oneLine = v.replace(/\s+/g, ' ').trim() + return `${name} ${truncateToWidth(oneLine, 60)}` } } - return name; + return name } + const PHASE_LABEL = { needs_input: 'input required', - plan_ready: 'ready' -} as const; + plan_ready: 'ready', +} as const + const AGENT_VERB = { needs_input: 'waiting', - plan_ready: 'done' -} as const; -function UltraplanSessionDetail(t0) { - const $ = _c(70); - const { - session, - onDone, - onBack, - onKill - } = t0; - const running = session.status === "running" || session.status === "pending"; - const phase = session.ultraplanPhase; - const statusText = running ? phase ? PHASE_LABEL[phase] : "running" : session.status; - const elapsedTime = useElapsedTime(session.startTime, running, 1000, 0, session.endTime); - let spawns = 0; - let calls = 0; - let lastBlock = null; - for (const msg of session.log) { - if (msg.type !== "assistant") { - continue; - } - for (const block of msg.message.content) { - if (block.type !== "tool_use") { - continue; - } - calls++; - lastBlock = block; - if (block.name === AGENT_TOOL_NAME || block.name === LEGACY_AGENT_TOOL_NAME) { - spawns++; + plan_ready: 'done', +} as const + +function UltraplanSessionDetail({ + session, + onDone, + onBack, + onKill, +}: Omit): React.ReactNode { + const running = session.status === 'running' || session.status === 'pending' + const phase = session.ultraplanPhase + const statusText = running + ? phase + ? PHASE_LABEL[phase] + : 'running' + : session.status + const elapsedTime = useElapsedTime( + session.startTime, + running, + 1000, + 0, + session.endTime, + ) + + // Counts are eventually correct (lag ≤ poll interval). agentsWorking starts + // at 1 (the main session agent) and increments per subagent spawn. toolCalls + // is main-session only — subagent calls may not surface in this stream. + const { agentsWorking, toolCalls, lastToolCall } = useMemo(() => { + let spawns = 0 + let calls = 0 + let lastBlock: { name: string; input: unknown } | null = null + for (const msg of session.log) { + if (msg.type !== 'assistant') continue + for (const block of msg.message.content) { + if (block.type !== 'tool_use') continue + calls++ + lastBlock = block + if ( + block.name === AGENT_TOOL_NAME || + block.name === LEGACY_AGENT_TOOL_NAME + ) { + spawns++ + } } } - } - const t1 = 1 + spawns; - let t2; - if ($[0] !== lastBlock) { - t2 = lastBlock ? formatToolUseSummary(lastBlock.name, lastBlock.input) : null; - $[0] = lastBlock; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== calls || $[3] !== t1 || $[4] !== t2) { - t3 = { - agentsWorking: t1, + return { + agentsWorking: 1 + spawns, toolCalls: calls, - lastToolCall: t2 - }; - $[2] = calls; - $[3] = t1; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const { - agentsWorking, - toolCalls, - lastToolCall - } = t3; - let t4; - if ($[6] !== session.sessionId) { - t4 = getRemoteTaskSessionUrl(session.sessionId); - $[6] = session.sessionId; - $[7] = t4; - } else { - t4 = $[7]; - } - const sessionUrl = t4; - let t5; - if ($[8] !== onBack || $[9] !== onDone) { - t5 = onBack ?? (() => onDone("Remote session details dismissed", { - display: "system" - })); - $[8] = onBack; - $[9] = onDone; - $[10] = t5; - } else { - t5 = $[10]; - } - const goBackOrClose = t5; - const [confirmingStop, setConfirmingStop] = useState(false); - if (confirmingStop) { - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => setConfirmingStop(false); - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t7 = This will terminate the Claude Code on the web session.; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - label: "Terminate session", - value: "stop" as const - }; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [t8, { - label: "Back", - value: "back" as const - }]; - $[14] = t9; - } else { - t9 = $[14]; - } - let t10; - if ($[15] !== goBackOrClose || $[16] !== onKill) { - t10 = {t7} { + if (v === 'stop') { + onKill?.() + goBackOrClose() + } else { + setConfirmingStop(false) + } + }} + /> + + + ) } - let t23; - if ($[54] !== goBackOrClose || $[55] !== onDone || $[56] !== sessionUrl) { - t23 = v_0 => { - switch (v_0) { - case "open": - { - openBrowser(sessionUrl); - onDone(); - return; - } - case "stop": - { - setConfirmingStop(true); - return; - } - case "back": - { - goBackOrClose(); - return; - } + + return ( + + + {phase === 'plan_ready' ? DIAMOND_FILLED : DIAMOND_OPEN}{' '} + + ultraplan + + {' · '} + {elapsedTime} + {' · '} + {statusText} + + } - }; - $[54] = goBackOrClose; - $[55] = onDone; - $[56] = sessionUrl; - $[57] = t23; - } else { - t23 = $[57]; - } - let t24; - if ($[58] !== t22 || $[59] !== t23) { - t24 = { + switch (v) { + case 'open': + void openBrowser(sessionUrl) + // Close the dialog so the user lands back at the prompt with + // any half-written input intact (inputValue persists across + // the showBashesDialog toggle). + onDone() + return + case 'stop': + setConfirmingStop(true) + return + case 'back': + goBackOrClose() + return + } + }} + /> + + + ) } -const STAGES = ['finding', 'verifying', 'synthesizing'] as const; + +const STAGES = ['finding', 'verifying', 'synthesizing'] as const const STAGE_LABELS: Record<(typeof STAGES)[number], string> = { finding: 'Find', verifying: 'Verify', - synthesizing: 'Dedupe' -}; + synthesizing: 'Dedupe', +} // Setup → Find → Verify → Dedupe pipeline. Current stage in cloud teal, // rest dim. When completed, all stages dim with a trailing green ✓. The // "Setup" label shows before the orchestrator writes its first progress // snapshot (container boot + repo clone), so the 0-found display doesn't // look like a hung finder. -function StagePipeline(t0) { - const $ = _c(15); - const { - stage, - completed, - hasProgress - } = t0; - let t1; - if ($[0] !== stage) { - t1 = stage ? STAGES.indexOf(stage) : -1; - $[0] = stage; - $[1] = t1; - } else { - t1 = $[1]; - } - const currentIdx = t1; - const inSetup = !completed && !hasProgress; - let t2; - if ($[2] !== inSetup) { - t2 = inSetup ? Setup : Setup; - $[2] = inSetup; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== completed || $[6] !== currentIdx || $[7] !== inSetup) { - t4 = STAGES.map((s, i) => { - const isCurrent = !completed && !inSetup && i === currentIdx; - return {i > 0 && }{isCurrent ? {STAGE_LABELS[s]} : {STAGE_LABELS[s]}}; - }); - $[5] = completed; - $[6] = currentIdx; - $[7] = inSetup; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== completed) { - t5 = completed && ; - $[9] = completed; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t2 || $[12] !== t4 || $[13] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[11] = t2; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; +function StagePipeline({ + stage, + completed, + hasProgress, +}: { + stage: 'finding' | 'verifying' | 'synthesizing' | undefined + completed: boolean + hasProgress: boolean +}): React.ReactNode { + const currentIdx = stage ? STAGES.indexOf(stage) : -1 + const inSetup = !completed && !hasProgress + return ( + + {inSetup ? ( + Setup + ) : ( + Setup + )} + + {STAGES.map((s, i) => { + const isCurrent = !completed && !inSetup && i === currentIdx + return ( + + {i > 0 && } + {isCurrent ? ( + {STAGE_LABELS[s]} + ) : ( + {STAGE_LABELS[s]} + )} + + ) + })} + {completed && } + + ) } // Stage-appropriate counts line. Running-state formatting delegates to // formatReviewStageCounts (shared with the pill) so the two views can't // drift; completed state is dialog-specific (findings summary). -function reviewCountsLine(session: DeepImmutable): string { - const p = session.reviewProgress; +function reviewCountsLine( + session: DeepImmutable, +): string { + const p = session.reviewProgress // No progress data — the orchestrator never wrote a snapshot. Don't // claim "0 findings" when completed; we just don't know. - if (!p) return session.status === 'completed' ? 'done' : 'setting up'; - const verified = p.bugsVerified; - const refuted = p.bugsRefuted ?? 0; + if (!p) return session.status === 'completed' ? 'done' : 'setting up' + const verified = p.bugsVerified + const refuted = p.bugsRefuted ?? 0 if (session.status === 'completed') { - const parts = [`${verified} ${plural(verified, 'finding')}`]; - if (refuted > 0) parts.push(`${refuted} refuted`); - return parts.join(' · '); + const parts = [`${verified} ${plural(verified, 'finding')}`] + if (refuted > 0) parts.push(`${refuted} refuted`) + return parts.join(' · ') } - return formatReviewStageCounts(p.stage, p.bugsFound, verified, refuted); + return formatReviewStageCounts(p.stage, p.bugsFound, verified, refuted) } -type MenuAction = 'open' | 'stop' | 'back' | 'dismiss'; -function ReviewSessionDetail(t0) { - const $ = _c(56); - const { - session, - onDone, - onBack, - onKill - } = t0; - const completed = session.status === "completed"; - const running = session.status === "running" || session.status === "pending"; - const [confirmingStop, setConfirmingStop] = useState(false); - const elapsedTime = useElapsedTime(session.startTime, running, 1000, 0, session.endTime); - let t1; - if ($[0] !== onDone) { - t1 = () => onDone("Remote session details dismissed", { - display: "system" - }); - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleClose = t1; - const goBackOrClose = onBack ?? handleClose; - let t2; - if ($[2] !== session.sessionId) { - t2 = getRemoteTaskSessionUrl(session.sessionId); - $[2] = session.sessionId; - $[3] = t2; - } else { - t2 = $[3]; - } - const sessionUrl = t2; - const statusLabel = completed ? "ready" : running ? "running" : session.status; + +type MenuAction = 'open' | 'stop' | 'back' | 'dismiss' + +function ReviewSessionDetail({ + session, + onDone, + onBack, + onKill, +}: Omit): React.ReactNode { + const completed = session.status === 'completed' + const running = session.status === 'running' || session.status === 'pending' + const [confirmingStop, setConfirmingStop] = useState(false) + + // useElapsedTime drives the 1Hz tick so the timer advances while the + // dialog is open — the previous inline elapsed-time calculation only + // re-rendered on session state changes (poll interval), which looked + // like the clock was stuck. + const elapsedTime = useElapsedTime( + session.startTime, + running, + 1000, + 0, + session.endTime, + ) + + const handleClose = () => + onDone('Remote session details dismissed', { display: 'system' }) + const goBackOrClose = onBack ?? handleClose + + const sessionUrl = getRemoteTaskSessionUrl(session.sessionId) + const statusLabel = completed ? 'ready' : running ? 'running' : session.status + if (confirmingStop) { - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setConfirmingStop(false); - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = This archives the remote session and stops local tracking. The review will not complete and any findings so far are discarded.; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Stop ultrareview", - value: "stop" as const - }; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [t5, { - label: "Back", - value: "back" as const - }]; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== goBackOrClose || $[9] !== onKill) { - t7 = {t4} { + if (v === 'stop') { + onKill?.() + goBackOrClose() + } else { + setConfirmingStop(false) + } + }} + /> + + + ) } - let t3; - if ($[11] !== completed || $[12] !== onKill || $[13] !== running) { - t3 = completed ? [{ - label: "Open in Claude Code on the web", - value: "open" - }, { - label: "Dismiss", - value: "dismiss" - }] : [{ - label: "Open in Claude Code on the web", - value: "open" - }, ...(onKill && running ? [{ - label: "Stop ultrareview", - value: "stop" as const - }] : []), { - label: "Back", - value: "back" - }]; - $[11] = completed; - $[12] = onKill; - $[13] = running; - $[14] = t3; - } else { - t3 = $[14]; + + const options: { label: string; value: MenuAction }[] = completed + ? [ + { label: 'Open in Claude Code on the web', value: 'open' }, + { label: 'Dismiss', value: 'dismiss' }, + ] + : [ + { label: 'Open in Claude Code on the web', value: 'open' }, + ...(onKill && running + ? [{ label: 'Stop ultrareview', value: 'stop' as const }] + : []), + { label: 'Back', value: 'back' }, + ] + + const handleSelect = (action: MenuAction) => { + switch (action) { + case 'open': + void openBrowser(sessionUrl) + onDone() + break + case 'stop': + setConfirmingStop(true) + break + case 'back': + goBackOrClose() + break + case 'dismiss': + handleClose() + break + } } - const options = t3; - let t4; - if ($[15] !== goBackOrClose || $[16] !== handleClose || $[17] !== onDone || $[18] !== sessionUrl) { - t4 = action => { - bb45: switch (action) { - case "open": - { - openBrowser(sessionUrl); - onDone(); - break bb45; - } - case "stop": - { - setConfirmingStop(true); - break bb45; - } - case "back": - { - goBackOrClose(); - break bb45; - } - case "dismiss": - { - handleClose(); - } + + return ( + + + {completed ? DIAMOND_FILLED : DIAMOND_OPEN}{' '} + + ultrareview + + {' · '} + {elapsedTime} + {' · '} + {statusLabel} + + } - }; - $[15] = goBackOrClose; - $[16] = handleClose; - $[17] = onDone; - $[18] = sessionUrl; - $[19] = t4; - } else { - t4 = $[19]; - } - const handleSelect = t4; - const t5 = completed ? DIAMOND_FILLED : DIAMOND_OPEN; - let t6; - if ($[20] !== t5) { - t6 = {t5}{" "}; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - let t7; - if ($[22] === Symbol.for("react.memo_cache_sentinel")) { - t7 = ultrareview; - $[22] = t7; - } else { - t7 = $[22]; - } - let t8; - if ($[23] !== elapsedTime || $[24] !== statusLabel) { - t8 = {" \xB7 "}{elapsedTime}{" \xB7 "}{statusLabel}; - $[23] = elapsedTime; - $[24] = statusLabel; - $[25] = t8; - } else { - t8 = $[25]; - } - let t9; - if ($[26] !== t6 || $[27] !== t8) { - t9 = {t6}{t7}{t8}; - $[26] = t6; - $[27] = t8; - $[28] = t9; - } else { - t9 = $[28]; - } - const t10 = session.reviewProgress?.stage; - const t11 = !!session.reviewProgress; - let t12; - if ($[29] !== completed || $[30] !== t10 || $[31] !== t11) { - t12 = ; - $[29] = completed; - $[30] = t10; - $[31] = t11; - $[32] = t12; - } else { - t12 = $[32]; - } - let t13; - if ($[33] !== session) { - t13 = reviewCountsLine(session); - $[33] = session; - $[34] = t13; - } else { - t13 = $[34]; - } - let t14; - if ($[35] !== t13) { - t14 = {t13}; - $[35] = t13; - $[36] = t14; - } else { - t14 = $[36]; - } - let t15; - if ($[37] !== sessionUrl) { - t15 = {sessionUrl}; - $[37] = sessionUrl; - $[38] = t15; - } else { - t15 = $[38]; - } - let t16; - if ($[39] !== sessionUrl || $[40] !== t15) { - t16 = {t15}; - $[39] = sessionUrl; - $[40] = t15; - $[41] = t16; - } else { - t16 = $[41]; - } - let t17; - if ($[42] !== t14 || $[43] !== t16) { - t17 = {t14}{t16}; - $[42] = t14; - $[43] = t16; - $[44] = t17; - } else { - t17 = $[44]; - } - let t18; - if ($[45] !== handleSelect || $[46] !== options) { - t18 = + + + ) } + export function RemoteSessionDetailDialog({ session, toolUseContext, onDone, onBack, - onKill + onKill, }: Props): React.ReactNode { - const [isTeleporting, setIsTeleporting] = useState(false); - const [teleportError, setTeleportError] = useState(null); + const [isTeleporting, setIsTeleporting] = useState(false) + const [teleportError, setTeleportError] = useState(null) // Get last few messages from remote session for display. // Scan all messages (not just the last 3 raw entries) because the tail of @@ -791,74 +475,119 @@ export function RemoteSessionDetailDialog({ // Placed before the early returns so hook call order is stable (Rules of Hooks). // Ultraplan/review sessions never read this — skip the normalize work for them. const lastMessages = useMemo(() => { - if (session.isUltraplan || session.isRemoteReview) return []; - return normalizeMessages(toInternalMessages(session.log as SDKMessage[])).filter(_ => _.type !== 'progress').slice(-3); - }, [session]); + if (session.isUltraplan || session.isRemoteReview) return [] + return normalizeMessages(toInternalMessages(session.log as SDKMessage[])) + .filter(_ => _.type !== 'progress') + .slice(-3) + }, [session]) + if (session.isUltraplan) { - return ; + return ( + + ) } // Review sessions get the stage-pipeline view; everything else keeps the // generic label/value + recent-messages dialog below. if (session.isRemoteReview) { - return ; + return ( + + ) } - const handleClose = () => onDone('Remote session details dismissed', { - display: 'system' - }); + + const handleClose = () => + onDone('Remote session details dismissed', { display: 'system' }) // Component-specific shortcuts shown in UI hints (t=teleport, space=dismiss, // left=back). These are state-dependent actions, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { if (e.key === ' ') { - e.preventDefault(); - onDone('Remote session details dismissed', { - display: 'system' - }); + e.preventDefault() + onDone('Remote session details dismissed', { display: 'system' }) } else if (e.key === 'left' && onBack) { - e.preventDefault(); - onBack(); + e.preventDefault() + onBack() } else if (e.key === 't' && !isTeleporting) { - e.preventDefault(); - void handleTeleport(); + e.preventDefault() + void handleTeleport() } else if (e.key === 'return') { - e.preventDefault(); - handleClose(); + e.preventDefault() + handleClose() } - }; + } // Handle teleporting to remote session async function handleTeleport(): Promise { - setIsTeleporting(true); - setTeleportError(null); + setIsTeleporting(true) + setTeleportError(null) + try { - await teleportResumeCodeSession(session.sessionId); + await teleportResumeCodeSession(session.sessionId) } catch (err) { - setTeleportError(errorMessage(err)); + setTeleportError(errorMessage(err)) } finally { - setIsTeleporting(false); + setIsTeleporting(false) } } // Truncate title if too long (for display purposes) - const displayTitle = truncateToWidth(session.title, 50); + const displayTitle = truncateToWidth(session.title, 50) // Map TaskStatus to display status (handle 'pending') - const displayStatus = session.status === 'pending' ? 'starting' : session.status; - return - exitState.pending ? Press {exitState.keyName} again to exit : + const displayStatus = + session.status === 'pending' ? 'starting' : session.status + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + {onBack && } - {!isTeleporting && } - }> + {!isTeleporting && ( + + )} + + ) + } + > Status:{' '} - {displayStatus === 'running' || displayStatus === 'starting' ? {displayStatus} : displayStatus === 'completed' ? {displayStatus} : {displayStatus}} + {displayStatus === 'running' || displayStatus === 'starting' ? ( + {displayStatus} + ) : displayStatus === 'completed' ? ( + {displayStatus} + ) : ( + {displayStatus} + )} Runtime:{' '} - {formatDuration((session.endTime ?? Date.now()) - session.startTime)} + {formatDuration( + (session.endTime ?? Date.now()) - session.startTime, + )} Title: {displayTitle} @@ -876,12 +605,30 @@ export function RemoteSessionDetailDialog({ {/* Remote session messages section */} - {session.log.length > 0 && + {session.log.length > 0 && ( + Recent messages: - {lastMessages.map((msg, i) => 0} tools={toolUseContext.options.tools} commands={toolUseContext.options.commands} verbose={toolUseContext.options.verbose} inProgressToolUseIDs={new Set()} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} />)} + {lastMessages.map((msg, i) => ( + 0} + tools={toolUseContext.options.tools} + commands={toolUseContext.options.commands} + verbose={toolUseContext.options.verbose} + inProgressToolUseIDs={new Set()} + progressMessagesForMessage={[]} + shouldAnimate={false} + shouldShowDot={false} + style="condensed" + isTranscriptMode={false} + isStatic={true} + /> + ))} @@ -889,15 +636,21 @@ export function RemoteSessionDetailDialog({ messages - } + + )} {/* Teleport error message */} - {teleportError && + {teleportError && ( + Teleport failed: {teleportError} - } + + )} {/* Teleporting status */} - {isTeleporting && Teleporting to session…} + {isTeleporting && ( + Teleporting to session… + )} - ; + + ) } diff --git a/src/components/tasks/RemoteSessionProgress.tsx b/src/components/tasks/RemoteSessionProgress.tsx index 2da0140a5..c1711cd8a 100644 --- a/src/components/tasks/RemoteSessionProgress.tsx +++ b/src/components/tasks/RemoteSessionProgress.tsx @@ -1,14 +1,17 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useRef } from 'react'; -import type { RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { useSettings } from '../../hooks/useSettings.js'; -import { Text, useAnimationFrame } from '../../ink.js'; -import { count } from '../../utils/array.js'; -import { getRainbowColor } from '../../utils/thinking.js'; -const TICK_MS = 80; -type ReviewStage = NonNullable['stage']>; +import React, { useRef } from 'react' +import type { RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { useSettings } from '../../hooks/useSettings.js' +import { Text, useAnimationFrame } from '../../ink.js' +import { count } from '../../utils/array.js' +import { getRainbowColor } from '../../utils/thinking.js' + +const TICK_MS = 80 + +type ReviewStage = NonNullable< + NonNullable['stage'] +> /** * Stage-appropriate counts line for a running review. Shared between the @@ -19,52 +22,48 @@ type ReviewStage = NonNullable 0) parts.push(`${refuted} refuted`); - parts.push('deduping'); - return parts.join(' · '); + const parts = [`${verified} verified`] + if (refuted > 0) parts.push(`${refuted} refuted`) + parts.push('deduping') + return parts.join(' · ') } if (stage === 'verifying') { - const parts = [`${found} found`, `${verified} verified`]; - if (refuted > 0) parts.push(`${refuted} refuted`); - return parts.join(' · '); + const parts = [`${found} found`, `${verified} verified`] + if (refuted > 0) parts.push(`${refuted} refuted`) + return parts.join(' · ') } // stage === 'finding' - return found > 0 ? `${found} found` : 'finding'; + return found > 0 ? `${found} found` : 'finding' } // Per-character rainbow gradient, same treatment as the ultraplan keyword. // The phase offset lets the gradient cycle — so the colors sweep along the // text on each animation frame instead of being static. -function RainbowText(t0) { - const $ = _c(5); - const { - text, - phase: t1 - } = t0; - const phase = t1 === undefined ? 0 : t1; - let t2; - if ($[0] !== text) { - t2 = [...text]; - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== phase || $[3] !== t2) { - t3 = <>{t2.map((ch, i) => {ch})}; - $[2] = phase; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; +function RainbowText({ + text, + phase = 0, +}: { + text: string + phase?: number +}): React.ReactNode { + return ( + <> + {[...text].map((ch, i) => ( + + {ch} + + ))} + + ) } // Smooth-tick a count toward target, +1 per frame. Same pattern as the @@ -74,169 +73,129 @@ function RainbowText(t0) { // the clock is frozen), bypass the tick and jump straight to target — // otherwise a frozen `time` would leave the ref stuck at its init value. function useSmoothCount(target: number, time: number, snap: boolean): number { - const displayed = useRef(target); - const lastTick = useRef(time); + const displayed = useRef(target) + const lastTick = useRef(time) if (snap || target < displayed.current) { - displayed.current = target; + displayed.current = target } else if (target > displayed.current && time !== lastTick.current) { - displayed.current += 1; - lastTick.current = time; + displayed.current += 1 + lastTick.current = time } - return displayed.current; + return displayed.current } -function ReviewRainbowLine(t0) { - const $ = _c(15); - const { - session - } = t0; - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const p = session.reviewProgress; - const running = session.status === "running"; - const [, time] = useAnimationFrame(running && !reducedMotion ? TICK_MS : null); - const targetFound = p?.bugsFound ?? 0; - const targetVerified = p?.bugsVerified ?? 0; - const targetRefuted = p?.bugsRefuted ?? 0; - const snap = reducedMotion || !running; - const found = useSmoothCount(targetFound, time, snap); - const verified = useSmoothCount(targetVerified, time, snap); - const refuted = useSmoothCount(targetRefuted, time, snap); - const phase = Math.floor(time / (TICK_MS * 3)) % 7; - if (session.status === "completed") { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = <>{DIAMOND_FILLED} ready · shift+↓ to view; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - if (session.status === "failed") { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = <>{DIAMOND_FILLED} {" \xB7 "}error; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - let t1; - if ($[2] !== found || $[3] !== p || $[4] !== refuted || $[5] !== verified) { - t1 = !p ? "setting up" : formatReviewStageCounts(p.stage, found, verified, refuted); - $[2] = found; - $[3] = p; - $[4] = refuted; - $[5] = verified; - $[6] = t1; - } else { - t1 = $[6]; - } - const tail = t1; - let t2; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {DIAMOND_OPEN} ; - $[7] = t2; - } else { - t2 = $[7]; - } - const t3 = running ? phase : 0; - let t4; - if ($[8] !== t3) { - t4 = ; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== tail) { - t5 = · {tail}; - $[10] = tail; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t4 || $[13] !== t5) { - t6 = <>{t2}{t4}{t5}; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; + +function ReviewRainbowLine({ + session, +}: { + session: DeepImmutable +}): React.ReactNode { + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + const p = session.reviewProgress + const running = session.status === 'running' + // Animation clock runs only while running — completed/failed are static. + // Disabled entirely when the user prefers reduced motion. + // + // The ref is intentionally discarded: this component is rendered inside + // wrappers (BackgroundTasksDialog, RemoteSessionDetailDialog), and + // Ink can't nest inside . Dropping the ref means + // useTerminalViewport's isVisible stays true, so the clock ticks even when + // scrolled off-screen — acceptable for a single 30-char line. + const [, time] = useAnimationFrame(running && !reducedMotion ? TICK_MS : null) + + const targetFound = p?.bugsFound ?? 0 + const targetVerified = p?.bugsVerified ?? 0 + const targetRefuted = p?.bugsRefuted ?? 0 + // snap when the clock isn't advancing (reduced motion, or not running) — + // useAnimationFrame(null) freezes `time` at its mount value, which would + // leave the tick-gate permanently false. + const snap = reducedMotion || !running + const found = useSmoothCount(targetFound, time, snap) + const verified = useSmoothCount(targetVerified, time, snap) + const refuted = useSmoothCount(targetRefuted, time, snap) + + // Phase advances every 3 ticks so the gradient sweep is visible but + // not frantic. Modulo keeps it in the 7-color cycle. + const phase = Math.floor(time / (TICK_MS * 3)) % 7 + + // ◇ open diamond while running (teal, matches cloud-session accent), ◆ + // filled when terminal. Rainbow is scoped to the word `ultrareview` only — + // per design feedback, "there is a limit to the glittering rainbow". + // Counts stay dimColor. + if (session.status === 'completed') { + return ( + <> + {DIAMOND_FILLED} + + ready · shift+↓ to view + + ) + } + if (session.status === 'failed') { + return ( + <> + {DIAMOND_FILLED} + + + {' · '} + error + + + ) } - return t6; + + // The !p branch ("setting up") covers the window before the orchestrator + // writes its first progress snapshot — container boot + repo clone can + // take 1-3 min, during which "0 found" looked hung. + const tail = !p + ? 'setting up' + : formatReviewStageCounts(p.stage, found, verified, refuted) + return ( + <> + {DIAMOND_OPEN} + + · {tail} + + ) } -export function RemoteSessionProgress(t0) { - const $ = _c(11); - const { - session - } = t0; + +export function RemoteSessionProgress({ + session, +}: { + session: DeepImmutable +}): React.ReactNode { + // Lite-review: rainbow gradient over the full line, ultraplan-style. + // BackgroundTask.tsx delegates the whole wrapper here so the + // gradient spans the title, not just the trailing status. if (session.isRemoteReview) { - let t1; - if ($[0] !== session) { - t1 = ; - $[0] = session; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + return } - if (session.status === "completed") { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = done; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + + if (session.status === 'completed') { + return ( + + done + + ) } - if (session.status === "failed") { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = error; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + + if (session.status === 'failed') { + return ( + + error + + ) } + if (!session.todoList.length) { - let t1; - if ($[4] !== session.status) { - t1 = {session.status}…; - $[4] = session.status; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } - let t1; - if ($[6] !== session.todoList) { - t1 = count(session.todoList, _temp); - $[6] = session.todoList; - $[7] = t1; - } else { - t1 = $[7]; - } - const completed = t1; - const total = session.todoList.length; - let t2; - if ($[8] !== completed || $[9] !== total) { - t2 = {completed}/{total}; - $[8] = completed; - $[9] = total; - $[10] = t2; - } else { - t2 = $[10]; + return {session.status}… } - return t2; -} -function _temp(_) { - return _.status === "completed"; + + const completed = count(session.todoList, _ => _.status === 'completed') + const total = session.todoList.length + return ( + + {completed}/{total} + + ) } diff --git a/src/components/tasks/ShellDetailDialog.tsx b/src/components/tasks/ShellDetailDialog.tsx index d42472c31..a81bafc8b 100644 --- a/src/components/tasks/ShellDetailDialog.tsx +++ b/src/components/tasks/ShellDetailDialog.tsx @@ -1,403 +1,247 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Suspense, use, useDeferredValue, useEffect, useState } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { LocalShellTaskState } from '../../tasks/LocalShellTask/guards.js'; -import { formatDuration, formatFileSize, truncateToWidth } from '../../utils/format.js'; -import { tailFile } from '../../utils/fsOperations.js'; -import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React, { + Suspense, + use, + useDeferredValue, + useEffect, + useState, +} from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { LocalShellTaskState } from '../../tasks/LocalShellTask/guards.js' +import { + formatDuration, + formatFileSize, + truncateToWidth, +} from '../../utils/format.js' +import { tailFile } from '../../utils/fsOperations.js' +import { getTaskOutputPath } from '../../utils/task/diskOutput.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - shell: DeepImmutable; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onKillShell?: () => void; - onBack?: () => void; -}; -const SHELL_DETAIL_TAIL_BYTES = 8192; + shell: DeepImmutable + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onKillShell?: () => void + onBack?: () => void +} + +const SHELL_DETAIL_TAIL_BYTES = 8192 + type TaskOutputResult = { - content: string; - bytesTotal: number; -}; + content: string + bytesTotal: number +} /** * Read the tail of the task output file. Only reads the last few KB, * not the entire file. */ -async function getTaskOutput(shell: DeepImmutable): Promise { - const path = getTaskOutputPath(shell.id); +async function getTaskOutput( + shell: DeepImmutable, +): Promise { + const path = getTaskOutputPath(shell.id) try { - const result = await tailFile(path, SHELL_DETAIL_TAIL_BYTES); - return { - content: result.content, - bytesTotal: result.bytesTotal - }; + const result = await tailFile(path, SHELL_DETAIL_TAIL_BYTES) + return { content: result.content, bytesTotal: result.bytesTotal } } catch { - return { - content: '', - bytesTotal: 0 - }; + return { content: '', bytesTotal: 0 } } } -export function ShellDetailDialog(t0) { - const $ = _c(57); - const { - shell, - onDone, - onKillShell, - onBack - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== shell) { - t1 = () => getTaskOutput(shell); - $[0] = shell; - $[1] = t1; - } else { - t1 = $[1]; - } - const [outputPromise, setOutputPromise] = useState(t1); - const deferredOutputPromise = useDeferredValue(outputPromise); - let t2; - if ($[2] !== shell) { - t2 = () => { - if (shell.status !== "running") { - return; - } - const timer = setInterval(_temp, 1000, setOutputPromise, shell); - return () => clearInterval(timer); - }; - $[2] = shell; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== shell.id || $[5] !== shell.status) { - t3 = [shell.id, shell.status]; - $[4] = shell.id; - $[5] = shell.status; - $[6] = t3; - } else { - t3 = $[6]; - } - useEffect(t2, t3); - let t4; - if ($[7] !== onDone) { - t4 = () => onDone("Shell details dismissed", { - display: "system" - }); - $[7] = onDone; - $[8] = t4; - } else { - t4 = $[8]; - } - const handleClose = t4; - let t5; - if ($[9] !== handleClose) { - t5 = { - "confirm:yes": handleClose - }; - $[9] = handleClose; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - context: "Confirmation" - }; - $[11] = t6; - } else { - t6 = $[11]; + +export function ShellDetailDialog({ + shell, + onDone, + onKillShell, + onBack, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + + // Promise created in initializer (not during render). For running shells, + // the effect timer replaces it periodically to pick up new output. + // useDeferredValue keeps showing the previous output while the new promise + // resolves, preventing the Suspense fallback from flickering. + const [outputPromise, setOutputPromise] = useState>( + () => getTaskOutput(shell), + ) + const deferredOutputPromise = useDeferredValue(outputPromise) + + useEffect(() => { + if (shell.status !== 'running') { + return + } + const timer = setInterval( + (setOutputPromise, shell) => setOutputPromise(getTaskOutput(shell)), + 1000, + setOutputPromise, + shell, + ) + return () => clearInterval(timer) + }, [shell.id, shell.status]) + + // Handle standard close action + const handleClose = () => + onDone('Shell details dismissed', { display: 'system' }) + + // Handle additional close actions beyond Dialog's built-in Esc handler + useKeybindings( + { + 'confirm:yes': handleClose, + }, + { context: 'Confirmation' }, + ) + + // Handle dialog-specific keys + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone('Shell details dismissed', { display: 'system' }) + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && shell.status === 'running' && onKillShell) { + e.preventDefault() + onKillShell() + } } - useKeybindings(t5, t6); - let t7; - if ($[12] !== onBack || $[13] !== onDone || $[14] !== onKillShell || $[15] !== shell.status) { - t7 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone("Shell details dismissed", { - display: "system" - }); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && shell.status === "running" && onKillShell) { - e.preventDefault(); - onKillShell(); - } + + // Truncate command if too long (for display purposes) + const isMonitor = shell.kind === 'monitor' + const displayCommand = truncateToWidth(shell.command, 280) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {shell.status === 'running' && onKillShell && ( + + )} + + ) } - } - }; - $[12] = onBack; - $[13] = onDone; - $[14] = onKillShell; - $[15] = shell.status; - $[16] = t7; - } else { - t7 = $[16]; - } - const handleKeyDown = t7; - const isMonitor = shell.kind === "monitor"; - let t8; - if ($[17] !== shell.command) { - t8 = truncateToWidth(shell.command, 280); - $[17] = shell.command; - $[18] = t8; - } else { - t8 = $[18]; - } - const displayCommand = t8; - const t9 = isMonitor ? "Monitor details" : "Shell details"; - let t10; - if ($[19] !== onBack || $[20] !== onKillShell || $[21] !== shell.status) { - t10 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{shell.status === "running" && onKillShell && }; - $[19] = onBack; - $[20] = onKillShell; - $[21] = shell.status; - $[22] = t10; - } else { - t10 = $[22]; - } - let t11; - if ($[23] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Status:; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== shell.result || $[25] !== shell.status) { - t12 = {t11}{" "}{shell.status === "running" ? {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`} : shell.status === "completed" ? {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`} : {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`}}; - $[24] = shell.result; - $[25] = shell.status; - $[26] = t12; - } else { - t12 = $[26]; - } - let t13; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Runtime:; - $[27] = t13; - } else { - t13 = $[27]; - } - let t14; - if ($[28] !== shell.endTime) { - t14 = shell.endTime ?? Date.now(); - $[28] = shell.endTime; - $[29] = t14; - } else { - t14 = $[29]; - } - const t15 = t14 - shell.startTime; - let t16; - if ($[30] !== t15) { - t16 = formatDuration(t15); - $[30] = t15; - $[31] = t16; - } else { - t16 = $[31]; - } - let t17; - if ($[32] !== t16) { - t17 = {t13}{" "}{t16}; - $[32] = t16; - $[33] = t17; - } else { - t17 = $[33]; - } - const t18 = isMonitor ? "Script:" : "Command:"; - let t19; - if ($[34] !== t18) { - t19 = {t18}; - $[34] = t18; - $[35] = t19; - } else { - t19 = $[35]; - } - let t20; - if ($[36] !== displayCommand || $[37] !== t19) { - t20 = {t19}{" "}{displayCommand}; - $[36] = displayCommand; - $[37] = t19; - $[38] = t20; - } else { - t20 = $[38]; - } - let t21; - if ($[39] !== t12 || $[40] !== t17 || $[41] !== t20) { - t21 = {t12}{t17}{t20}; - $[39] = t12; - $[40] = t17; - $[41] = t20; - $[42] = t21; - } else { - t21 = $[42]; - } - let t22; - if ($[43] === Symbol.for("react.memo_cache_sentinel")) { - t22 = Output:; - $[43] = t22; - } else { - t22 = $[43]; - } - let t23; - if ($[44] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Loading output…; - $[44] = t23; - } else { - t23 = $[44]; - } - let t24; - if ($[45] !== columns || $[46] !== deferredOutputPromise) { - t24 = {t22}; - $[45] = columns; - $[46] = deferredOutputPromise; - $[47] = t24; - } else { - t24 = $[47]; - } - let t25; - if ($[48] !== handleClose || $[49] !== t10 || $[50] !== t21 || $[51] !== t24 || $[52] !== t9) { - t25 = {t21}{t24}; - $[48] = handleClose; - $[49] = t10; - $[50] = t21; - $[51] = t24; - $[52] = t9; - $[53] = t25; - } else { - t25 = $[53]; - } - let t26; - if ($[54] !== handleKeyDown || $[55] !== t25) { - t26 = {t25}; - $[54] = handleKeyDown; - $[55] = t25; - $[56] = t26; - } else { - t26 = $[56]; - } - return t26; -} -function _temp(setOutputPromise_0, shell_0) { - return setOutputPromise_0(getTaskOutput(shell_0)); + > + + + Status:{' '} + {shell.status === 'running' ? ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + ) : shell.status === 'completed' ? ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + ) : ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + )} + + + Runtime:{' '} + {formatDuration((shell.endTime ?? Date.now()) - shell.startTime)} + + + {isMonitor ? 'Script:' : 'Command:'}{' '} + {displayCommand} + + + + + Output: + Loading output…}> + + + + + + ) } + type ShellOutputContentProps = { - outputPromise: Promise; - columns: number; -}; -function ShellOutputContent(t0) { - const $ = _c(19); - const { - outputPromise, - columns - } = t0; - const { - content, - bytesTotal - } = use(outputPromise) as any; + outputPromise: Promise + columns: number +} + +function ShellOutputContent({ + outputPromise, + columns, +}: ShellOutputContentProps): React.ReactNode { + const { content, bytesTotal } = use(outputPromise) + if (!content) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = No output available; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - let isIncomplete; - let rendered; - if ($[1] !== bytesTotal || $[2] !== content) { - const starts = []; - let pos = content.length; - for (let i = 0; i < 10 && pos > 0; i++) { - const prev = content.lastIndexOf("\n", pos - 1); - starts.push(prev + 1); - pos = prev; - } - starts.reverse(); - isIncomplete = bytesTotal > content.length; - rendered = []; - for (let i_0 = 0; i_0 < starts.length; i_0++) { - const start = starts[i_0]; - const end = i_0 < starts.length - 1 ? starts[i_0 + 1] - 1 : content.length; - const line = content.slice(start, end); - if (line) { - rendered.push(line); - } - } - $[1] = bytesTotal; - $[2] = content; - $[3] = isIncomplete; - $[4] = rendered; - } else { - isIncomplete = $[3]; - rendered = $[4]; - } - const t1 = columns - 6; - let t2; - if ($[5] !== rendered) { - t2 = rendered.map(_temp2); - $[5] = rendered; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== t1 || $[8] !== t2) { - t3 = {t2}; - $[7] = t1; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; + return No output available } - const t4 = `Showing ${rendered.length} lines`; - let t5; - if ($[10] !== bytesTotal || $[11] !== isIncomplete) { - t5 = isIncomplete ? ` of ${formatFileSize(bytesTotal)}` : ""; - $[10] = bytesTotal; - $[11] = isIncomplete; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - let t7; - if ($[16] !== t3 || $[17] !== t6) { - t7 = <>{t3}{t6}; - $[16] = t3; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; + + // Find last 10 line boundaries via lastIndexOf + const starts: number[] = [] + let pos = content.length + for (let i = 0; i < 10 && pos > 0; i++) { + const prev = content.lastIndexOf('\n', pos - 1) + starts.push(prev + 1) + pos = prev + } + starts.reverse() + const isIncomplete = bytesTotal > content.length + + // Build lines, skip empty trailing/leading segments + const rendered: string[] = [] + for (let i = 0; i < starts.length; i++) { + const start = starts[i]! + const end = i < starts.length - 1 ? starts[i + 1]! - 1 : content.length + const line = content.slice(start, end) + if (line) rendered.push(line) } - return t7; -} -function _temp2(line_0, i_1) { - return {line_0}; + + return ( + <> + + {rendered.map((line, i) => ( + + {line} + + ))} + + + {`Showing ${rendered.length} lines`} + {isIncomplete ? ` of ${formatFileSize(bytesTotal)}` : ''} + + + ) } diff --git a/src/components/tasks/ShellProgress.tsx b/src/components/tasks/ShellProgress.tsx index 6e9a671c0..b70494c16 100644 --- a/src/components/tasks/ShellProgress.tsx +++ b/src/components/tasks/ShellProgress.tsx @@ -1,86 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React from 'react'; -import { Text } from 'src/ink.js'; -import type { TaskStatus } from 'src/Task.js'; -import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js'; -import type { DeepImmutable } from 'src/types/utils.js'; +import type { ReactNode } from 'react' +import React from 'react' +import { Text } from 'src/ink.js' +import type { TaskStatus } from 'src/Task.js' +import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' +import type { DeepImmutable } from 'src/types/utils.js' + type TaskStatusTextProps = { - status: TaskStatus; - label?: string; - suffix?: string; -}; -export function TaskStatusText(t0) { - const $ = _c(4); - const { - status, - label, - suffix - } = t0; - const displayLabel = label ?? status; - const color = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : undefined; - let t1; - if ($[0] !== color || $[1] !== displayLabel || $[2] !== suffix) { - t1 = ({displayLabel}{suffix}); - $[0] = color; - $[1] = displayLabel; - $[2] = suffix; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + status: TaskStatus + label?: string + suffix?: string +} + +export function TaskStatusText({ + status, + label, + suffix, +}: TaskStatusTextProps): ReactNode { + const displayLabel = label ?? status + const color = + status === 'completed' + ? 'success' + : status === 'failed' + ? 'error' + : status === 'killed' + ? 'warning' + : undefined + return ( + + ({displayLabel} + {suffix}) + + ) } -export function ShellProgress(t0) { - const $ = _c(4); - const { - shell - } = t0; + +export function ShellProgress({ + shell, +}: { + shell: DeepImmutable +}): ReactNode { switch (shell.status) { - case "completed": - { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - case "failed": - { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - case "killed": - { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - case "running": - case "pending": - { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; - } + case 'completed': + return + case 'failed': + return + case 'killed': + return + case 'running': + case 'pending': + return } } diff --git a/src/components/tasks/renderToolActivity.tsx b/src/components/tasks/renderToolActivity.tsx index e2e4ebae7..a6e1c60a2 100644 --- a/src/components/tasks/renderToolActivity.tsx +++ b/src/components/tasks/renderToolActivity.tsx @@ -1,32 +1,39 @@ -import React from 'react'; -import { Text } from '../../ink.js'; -import type { Tools } from '../../Tool.js'; -import { findToolByName } from '../../Tool.js'; -import type { ToolActivity } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import type { ThemeName } from '../../utils/theme.js'; -export function renderToolActivity(activity: ToolActivity, tools: Tools, theme: ThemeName): React.ReactNode { - const tool = findToolByName(tools, activity.toolName); +import React from 'react' +import { Text } from '../../ink.js' +import type { Tools } from '../../Tool.js' +import { findToolByName } from '../../Tool.js' +import type { ToolActivity } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import type { ThemeName } from '../../utils/theme.js' + +export function renderToolActivity( + activity: ToolActivity, + tools: Tools, + theme: ThemeName, +): React.ReactNode { + const tool = findToolByName(tools, activity.toolName) if (!tool) { - return activity.toolName; + return activity.toolName } try { - const parsed = tool.inputSchema.safeParse(activity.input); - const parsedInput = parsed.success ? parsed.data : {}; - const userFacingName = tool.userFacingName(parsedInput); + const parsed = tool.inputSchema.safeParse(activity.input) + const parsedInput = parsed.success ? parsed.data : {} + const userFacingName = tool.userFacingName(parsedInput) if (!userFacingName) { - return activity.toolName; + return activity.toolName } const toolArgs = tool.renderToolUseMessage(parsedInput, { theme, - verbose: false - }); + verbose: false, + }) if (toolArgs) { - return + return ( + {userFacingName}({toolArgs}) - ; + + ) } - return userFacingName; + return userFacingName } catch { - return activity.toolName; + return activity.toolName } } diff --git a/src/components/tasks/taskStatusUtils.tsx b/src/components/tasks/taskStatusUtils.tsx index a70cbd6ca..91cb14cbf 100644 --- a/src/components/tasks/taskStatusUtils.tsx +++ b/src/components/tasks/taskStatusUtils.tsx @@ -2,71 +2,73 @@ * Shared utilities for displaying task status across different task types. */ -import figures from 'figures'; -import type { TaskStatus } from 'src/Task.js'; -import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js'; -import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { summarizeRecentActivities } from 'src/utils/collapseReadSearch.js'; +import figures from 'figures' +import type { TaskStatus } from 'src/Task.js' +import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' +import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { isBackgroundTask, type TaskState } from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { summarizeRecentActivities } from 'src/utils/collapseReadSearch.js' /** * Returns true if the given task status represents a terminal (finished) state. */ export function isTerminalStatus(status: TaskStatus): boolean { - return status === 'completed' || status === 'failed' || status === 'killed'; + return status === 'completed' || status === 'failed' || status === 'killed' } /** * Returns the appropriate icon for a task based on status and state flags. */ -export function getTaskStatusIcon(status: TaskStatus, options?: { - isIdle?: boolean; - awaitingApproval?: boolean; - hasError?: boolean; - shutdownRequested?: boolean; -}): string { - const { - isIdle, - awaitingApproval, - hasError, - shutdownRequested - } = options ?? {}; - if (hasError) return figures.cross; - if (awaitingApproval) return figures.questionMarkPrefix; - if (shutdownRequested) return figures.warning; +export function getTaskStatusIcon( + status: TaskStatus, + options?: { + isIdle?: boolean + awaitingApproval?: boolean + hasError?: boolean + shutdownRequested?: boolean + }, +): string { + const { isIdle, awaitingApproval, hasError, shutdownRequested } = + options ?? {} + + if (hasError) return figures.cross + if (awaitingApproval) return figures.questionMarkPrefix + if (shutdownRequested) return figures.warning + if (status === 'running') { - if (isIdle) return figures.ellipsis; - return figures.play; + if (isIdle) return figures.ellipsis + return figures.play } - if (status === 'completed') return figures.tick; - if (status === 'failed' || status === 'killed') return figures.cross; - return figures.bullet; + if (status === 'completed') return figures.tick + if (status === 'failed' || status === 'killed') return figures.cross + return figures.bullet } /** * Returns the appropriate semantic color for a task based on status and state flags. */ -export function getTaskStatusColor(status: TaskStatus, options?: { - isIdle?: boolean; - awaitingApproval?: boolean; - hasError?: boolean; - shutdownRequested?: boolean; -}): 'success' | 'error' | 'warning' | 'background' { - const { - isIdle, - awaitingApproval, - hasError, - shutdownRequested - } = options ?? {}; - if (hasError) return 'error'; - if (awaitingApproval) return 'warning'; - if (shutdownRequested) return 'warning'; - if (isIdle) return 'background'; - if (status === 'completed') return 'success'; - if (status === 'failed') return 'error'; - if (status === 'killed') return 'warning'; - return 'background'; +export function getTaskStatusColor( + status: TaskStatus, + options?: { + isIdle?: boolean + awaitingApproval?: boolean + hasError?: boolean + shutdownRequested?: boolean + }, +): 'success' | 'error' | 'warning' | 'background' { + const { isIdle, awaitingApproval, hasError, shutdownRequested } = + options ?? {} + + if (hasError) return 'error' + if (awaitingApproval) return 'warning' + if (shutdownRequested) return 'warning' + if (isIdle) return 'background' + + if (status === 'completed') return 'success' + if (status === 'failed') return 'error' + if (status === 'killed') return 'warning' + return 'background' } /** @@ -74,11 +76,18 @@ export function getTaskStatusColor(status: TaskStatus, options?: { * accounting for shutdown/approval/idle states and falling back through * recent-activity summary → last activity description → 'working'. */ -export function describeTeammateActivity(t: DeepImmutable): string { - if (t.shutdownRequested) return 'stopping'; - if (t.awaitingPlanApproval) return 'awaiting approval'; - if (t.isIdle) return 'idle'; - return (t.progress?.recentActivities && summarizeRecentActivities(t.progress.recentActivities)) ?? t.progress?.lastActivity?.activityDescription ?? 'working'; +export function describeTeammateActivity( + t: DeepImmutable, +): string { + if (t.shutdownRequested) return 'stopping' + if (t.awaitingPlanApproval) return 'awaiting approval' + if (t.isIdle) return 'idle' + return ( + (t.progress?.recentActivities && + summarizeRecentActivities(t.progress.recentActivities)) ?? + t.progress?.lastActivity?.activityDescription ?? + 'working' + ) } /** @@ -90,17 +99,21 @@ export function describeTeammateActivity(t: DeepImmutable s.teamContext) + + // Derive teammate count from teamContext (no filesystem I/O needed) + const totalTeammates = teamContext + ? Object.values(teamContext.teammates).filter(t => t.name !== 'team-lead') + .length + : 0 + if (totalTeammates === 0) { - return null; - } - let t2; - if ($[2] !== showHint || $[3] !== teamsSelected) { - t2 = showHint && teamsSelected ? <>· Enter to view : null; - $[2] = showHint; - $[3] = teamsSelected; - $[4] = t2; - } else { - t2 = $[4]; - } - const hint = t2; - const statusText = `${totalTeammates} ${totalTeammates === 1 ? "teammate" : "teammates"}`; - const t3 = teamsSelected ? "selected" : "normal"; - let t4; - if ($[5] !== statusText || $[6] !== t3 || $[7] !== teamsSelected) { - t4 = {statusText}; - $[5] = statusText; - $[6] = t3; - $[7] = teamsSelected; - $[8] = t4; - } else { - t4 = $[8]; + return null } - let t5; - if ($[9] !== hint) { - t5 = hint ? {hint} : null; - $[9] = hint; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t4 || $[12] !== t5) { - t6 = <>{t4}{t5}; - $[11] = t4; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; -} -function _temp2(t) { - return t.name !== "team-lead"; -} -function _temp(s) { - return s.teamContext; + + const hint = + showHint && teamsSelected ? ( + <> + · + Enter to view + + ) : null + + const statusText = `${totalTeammates} ${totalTeammates === 1 ? 'teammate' : 'teammates'}` + + return ( + <> + + {statusText} + + {hint ? {hint} : null} + + ) } diff --git a/src/components/teams/TeamsDialog.tsx b/src/components/teams/TeamsDialog.tsx index 5cfbf95ff..872212115 100644 --- a/src/components/teams/TeamsDialog.tsx +++ b/src/components/teams/TeamsDialog.tsx @@ -1,550 +1,600 @@ -import { c as _c } from "react/compiler-runtime"; -import { randomUUID } from 'crypto'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useInterval } from 'usehooks-ts'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import { stringWidth } from '../../ink/stringWidth.js'; +import { randomUUID } from 'crypto' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useInterval } from 'usehooks-ts' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import { stringWidth } from '../../ink/stringWidth.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow dialog navigation -import { Box, Text, useInput } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js'; -import { getEmptyToolPermissionContext } from '../../Tool.js'; -import { AGENT_COLOR_TO_THEME_COLOR } from '../../tools/AgentTool/agentColorManager.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; -import { truncateToWidth } from '../../utils/format.js'; -import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js'; -import { getModeColor, type PermissionMode, permissionModeFromString, permissionModeSymbol } from '../../utils/permissions/PermissionMode.js'; -import { jsonStringify } from '../../utils/slowOperations.js'; -import { IT2_COMMAND, isInsideTmuxSync } from '../../utils/swarm/backends/detection.js'; -import { ensureBackendsRegistered, getBackendByType, getCachedBackend } from '../../utils/swarm/backends/registry.js'; -import type { PaneBackendType } from '../../utils/swarm/backends/types.js'; -import { getSwarmSocketName, TMUX_COMMAND } from '../../utils/swarm/constants.js'; -import { addHiddenPaneId, removeHiddenPaneId, removeMemberFromTeam, setMemberMode, setMultipleMemberModes } from '../../utils/swarm/teamHelpers.js'; -import { listTasks, type Task, unassignTeammateTasks } from '../../utils/tasks.js'; -import { getTeammateStatuses, type TeammateStatus, type TeamSummary } from '../../utils/teamDiscovery.js'; -import { createModeSetRequestMessage, sendShutdownRequestToMailbox, writeToMailbox } from '../../utils/teammateMailbox.js'; -import { Dialog } from '../design-system/Dialog.js'; -import ThemedText from '../design-system/ThemedText.js'; +import { Box, Text, useInput } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { + type AppState, + useAppState, + useSetAppState, +} from '../../state/AppState.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import { AGENT_COLOR_TO_THEME_COLOR } from '../../tools/AgentTool/agentColorManager.js' +import { logForDebugging } from '../../utils/debug.js' +import { execFileNoThrow } from '../../utils/execFileNoThrow.js' +import { truncateToWidth } from '../../utils/format.js' +import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js' +import { + getModeColor, + type PermissionMode, + permissionModeFromString, + permissionModeSymbol, +} from '../../utils/permissions/PermissionMode.js' +import { jsonStringify } from '../../utils/slowOperations.js' +import { + IT2_COMMAND, + isInsideTmuxSync, +} from '../../utils/swarm/backends/detection.js' +import { + ensureBackendsRegistered, + getBackendByType, + getCachedBackend, +} from '../../utils/swarm/backends/registry.js' +import type { PaneBackendType } from '../../utils/swarm/backends/types.js' +import { + getSwarmSocketName, + TMUX_COMMAND, +} from '../../utils/swarm/constants.js' +import { + addHiddenPaneId, + removeHiddenPaneId, + removeMemberFromTeam, + setMemberMode, + setMultipleMemberModes, +} from '../../utils/swarm/teamHelpers.js' +import { + listTasks, + type Task, + unassignTeammateTasks, +} from '../../utils/tasks.js' +import { + getTeammateStatuses, + type TeammateStatus, + type TeamSummary, +} from '../../utils/teamDiscovery.js' +import { + createModeSetRequestMessage, + sendShutdownRequestToMailbox, + writeToMailbox, +} from '../../utils/teammateMailbox.js' +import { Dialog } from '../design-system/Dialog.js' +import ThemedText from '../design-system/ThemedText.js' + type Props = { - initialTeams?: TeamSummary[]; - onDone: () => void; -}; -type DialogLevel = { - type: 'teammateList'; - teamName: string; -} | { - type: 'teammateDetail'; - teamName: string; - memberName: string; -}; + initialTeams?: TeamSummary[] + onDone: () => void +} + +type DialogLevel = + | { type: 'teammateList'; teamName: string } + | { type: 'teammateDetail'; teamName: string; memberName: string } /** * Dialog for viewing teammates in the current team */ -export function TeamsDialog({ - initialTeams, - onDone -}: Props): React.ReactNode { +export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode { // Register as overlay so CancelRequestHandler doesn't intercept escape - useRegisterOverlay('teams-dialog', undefined); + useRegisterOverlay('teams-dialog') // initialTeams is derived from teamContext in PromptInput (no filesystem I/O) - const setAppState = useSetAppState(); + const setAppState = useSetAppState() // Initialize dialogLevel with first team name if available - const firstTeamName = initialTeams?.[0]?.name ?? ''; + const firstTeamName = initialTeams?.[0]?.name ?? '' const [dialogLevel, setDialogLevel] = useState({ type: 'teammateList', - teamName: firstTeamName - }); - const [selectedIndex, setSelectedIndex] = useState(0); - const [refreshKey, setRefreshKey] = useState(0); + teamName: firstTeamName, + }) + const [selectedIndex, setSelectedIndex] = useState(0) + const [refreshKey, setRefreshKey] = useState(0) // initialTeams is now always provided from PromptInput (derived from teamContext) // No filesystem I/O needed here const teammateStatuses = useMemo(() => { - return getTeammateStatuses(dialogLevel.teamName); + return getTeammateStatuses(dialogLevel.teamName) // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, [dialogLevel.teamName, refreshKey]); + }, [dialogLevel.teamName, refreshKey]) // Periodically refresh to pick up mode changes from teammates useInterval(() => { - setRefreshKey(k => k + 1); - }, 1000); + setRefreshKey(k => k + 1) + }, 1000) + const currentTeammate = useMemo(() => { - if (dialogLevel.type !== 'teammateDetail') return null; - return teammateStatuses.find(t => t.name === dialogLevel.memberName) ?? null; - }, [dialogLevel, teammateStatuses]); + if (dialogLevel.type !== 'teammateDetail') return null + return teammateStatuses.find(t => t.name === dialogLevel.memberName) ?? null + }, [dialogLevel, teammateStatuses]) // Get isBypassPermissionsModeAvailable from AppState - const isBypassAvailable = useAppState(s => s.toolPermissionContext.isBypassPermissionsModeAvailable); + const isBypassAvailable = useAppState( + s => s.toolPermissionContext.isBypassPermissionsModeAvailable, + ) + const goBackToList = (): void => { - setDialogLevel({ - type: 'teammateList', - teamName: dialogLevel.teamName - }); - setSelectedIndex(0); - }; + setDialogLevel({ type: 'teammateList', teamName: dialogLevel.teamName }) + setSelectedIndex(0) + } // Handler for confirm:cycleMode - cycle teammate permission modes const handleCycleMode = useCallback(() => { if (dialogLevel.type === 'teammateDetail' && currentTeammate) { // Detail view: cycle just this teammate - cycleTeammateMode(currentTeammate, dialogLevel.teamName, isBypassAvailable); - setRefreshKey(k => k + 1); - } else if (dialogLevel.type === 'teammateList' && teammateStatuses.length > 0) { + cycleTeammateMode( + currentTeammate, + dialogLevel.teamName, + isBypassAvailable, + ) + setRefreshKey(k => k + 1) + } else if ( + dialogLevel.type === 'teammateList' && + teammateStatuses.length > 0 + ) { // List view: cycle all teammates in tandem - cycleAllTeammateModes(teammateStatuses, dialogLevel.teamName, isBypassAvailable); - setRefreshKey(k => k + 1); + cycleAllTeammateModes( + teammateStatuses, + dialogLevel.teamName, + isBypassAvailable, + ) + setRefreshKey(k => k + 1) } - }, [dialogLevel, currentTeammate, teammateStatuses, isBypassAvailable]); + }, [dialogLevel, currentTeammate, teammateStatuses, isBypassAvailable]) // Use keybindings for mode cycling - useKeybindings({ - 'confirm:cycleMode': handleCycleMode - }, { - context: 'Confirmation' - }); + useKeybindings( + { 'confirm:cycleMode': handleCycleMode }, + { context: 'Confirmation' }, + ) + useInput((input, key) => { // Handle left arrow to go back if (key.leftArrow) { if (dialogLevel.type === 'teammateDetail') { - goBackToList(); + goBackToList() } - return; + return } // Handle up/down navigation if (key.upArrow || key.downArrow) { - const maxIndex = getMaxIndex(); + const maxIndex = getMaxIndex() if (key.upArrow) { - setSelectedIndex(prev => Math.max(0, prev - 1)); + setSelectedIndex(prev => Math.max(0, prev - 1)) } else { - setSelectedIndex(prev => Math.min(maxIndex, prev + 1)); + setSelectedIndex(prev => Math.min(maxIndex, prev + 1)) } - return; + return } // Handle Enter to drill down or view output if (key.return) { - if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) { + if ( + dialogLevel.type === 'teammateList' && + teammateStatuses[selectedIndex] + ) { setDialogLevel({ type: 'teammateDetail', teamName: dialogLevel.teamName, - memberName: teammateStatuses[selectedIndex].name - }); + memberName: teammateStatuses[selectedIndex].name, + }) } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { // View output - switch to tmux pane - void viewTeammateOutput(currentTeammate.tmuxPaneId, currentTeammate.backendType); - onDone(); + void viewTeammateOutput( + currentTeammate.tmuxPaneId, + currentTeammate.backendType, + ) + onDone() } - return; + return } // Handle 'k' to kill teammate if (input === 'k') { - if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) { - void killTeammate(teammateStatuses[selectedIndex].tmuxPaneId, teammateStatuses[selectedIndex].backendType, dialogLevel.teamName, teammateStatuses[selectedIndex].agentId, teammateStatuses[selectedIndex].name, setAppState).then(() => { - setRefreshKey(k => k + 1); + if ( + dialogLevel.type === 'teammateList' && + teammateStatuses[selectedIndex] + ) { + void killTeammate( + teammateStatuses[selectedIndex].tmuxPaneId, + teammateStatuses[selectedIndex].backendType, + dialogLevel.teamName, + teammateStatuses[selectedIndex].agentId, + teammateStatuses[selectedIndex].name, + setAppState, + ).then(() => { + setRefreshKey(k => k + 1) // Adjust selection if needed - setSelectedIndex(prev => Math.max(0, Math.min(prev, teammateStatuses.length - 2))); - }); + setSelectedIndex(prev => + Math.max(0, Math.min(prev, teammateStatuses.length - 2)), + ) + }) } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { - void killTeammate(currentTeammate.tmuxPaneId, currentTeammate.backendType, dialogLevel.teamName, currentTeammate.agentId, currentTeammate.name, setAppState); - goBackToList(); + void killTeammate( + currentTeammate.tmuxPaneId, + currentTeammate.backendType, + dialogLevel.teamName, + currentTeammate.agentId, + currentTeammate.name, + setAppState, + ) + goBackToList() } - return; + return } // Handle 's' for shutdown of selected teammate if (input === 's') { - if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) { - const teammate = teammateStatuses[selectedIndex]; - void sendShutdownRequestToMailbox(teammate.name, dialogLevel.teamName, 'Graceful shutdown requested by team lead'); + if ( + dialogLevel.type === 'teammateList' && + teammateStatuses[selectedIndex] + ) { + const teammate = teammateStatuses[selectedIndex] + void sendShutdownRequestToMailbox( + teammate.name, + dialogLevel.teamName, + 'Graceful shutdown requested by team lead', + ) } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { - void sendShutdownRequestToMailbox(currentTeammate.name, dialogLevel.teamName, 'Graceful shutdown requested by team lead'); - goBackToList(); + void sendShutdownRequestToMailbox( + currentTeammate.name, + dialogLevel.teamName, + 'Graceful shutdown requested by team lead', + ) + goBackToList() } - return; + return } // Handle 'h' to hide/show individual teammate (only for backends that support it) if (input === 'h') { - const backend = getCachedBackend(); - const teammate = dialogLevel.type === 'teammateList' ? teammateStatuses[selectedIndex] : dialogLevel.type === 'teammateDetail' ? currentTeammate : null; + const backend = getCachedBackend() + const teammate = + dialogLevel.type === 'teammateList' + ? teammateStatuses[selectedIndex] + : dialogLevel.type === 'teammateDetail' + ? currentTeammate + : null + if (teammate && backend?.supportsHideShow) { - void toggleTeammateVisibility(teammate, dialogLevel.teamName).then(() => { - // Force refresh of teammate statuses - setRefreshKey(k => k + 1); - }); + void toggleTeammateVisibility(teammate, dialogLevel.teamName).then( + () => { + // Force refresh of teammate statuses + setRefreshKey(k => k + 1) + }, + ) if (dialogLevel.type === 'teammateDetail') { - goBackToList(); + goBackToList() } } - return; + return } // Handle 'H' to hide/show all teammates (only for backends that support it) if (input === 'H' && dialogLevel.type === 'teammateList') { - const backend = getCachedBackend(); + const backend = getCachedBackend() if (backend?.supportsHideShow && teammateStatuses.length > 0) { // If any are visible, hide all. Otherwise, show all. - const anyVisible = teammateStatuses.some(t => !t.isHidden); - void Promise.all(teammateStatuses.map(t => anyVisible ? hideTeammate(t, dialogLevel.teamName) : showTeammate(t, dialogLevel.teamName))).then(() => { + const anyVisible = teammateStatuses.some(t => !t.isHidden) + void Promise.all( + teammateStatuses.map(t => + anyVisible + ? hideTeammate(t, dialogLevel.teamName) + : showTeammate(t, dialogLevel.teamName), + ), + ).then(() => { // Force refresh of teammate statuses - setRefreshKey(k => k + 1); - }); + setRefreshKey(k => k + 1) + }) } - return; + return } // Handle 'p' to prune (kill) all idle teammates if (input === 'p' && dialogLevel.type === 'teammateList') { - const idleTeammates = teammateStatuses.filter(t => t.status === 'idle'); + const idleTeammates = teammateStatuses.filter(t => t.status === 'idle') if (idleTeammates.length > 0) { - void Promise.all(idleTeammates.map(t => killTeammate(t.tmuxPaneId, t.backendType, dialogLevel.teamName, t.agentId, t.name, setAppState))).then(() => { - setRefreshKey(k => k + 1); - setSelectedIndex(prev => Math.max(0, Math.min(prev, teammateStatuses.length - idleTeammates.length - 1))); - }); + void Promise.all( + idleTeammates.map(t => + killTeammate( + t.tmuxPaneId, + t.backendType, + dialogLevel.teamName, + t.agentId, + t.name, + setAppState, + ), + ), + ).then(() => { + setRefreshKey(k => k + 1) + setSelectedIndex(prev => + Math.max( + 0, + Math.min( + prev, + teammateStatuses.length - idleTeammates.length - 1, + ), + ), + ) + }) } - return; + return } // Note: Mode cycling (shift+tab) is handled via useKeybindings with confirm:cycleMode action - }); + }) + function getMaxIndex(): number { if (dialogLevel.type === 'teammateList') { - return Math.max(0, teammateStatuses.length - 1); + return Math.max(0, teammateStatuses.length - 1) } - return 0; + return 0 } // Render based on dialog level if (dialogLevel.type === 'teammateList') { - return ; + return ( + + ) } + if (dialogLevel.type === 'teammateDetail' && currentTeammate) { - return ; + return ( + + ) } - return null; + + return null } + type TeamDetailViewProps = { - teamName: string; - teammates: TeammateStatus[]; - selectedIndex: number; - onCancel: () => void; -}; -function TeamDetailView(t0) { - const $ = _c(13); - const { - teamName, - teammates, - selectedIndex, - onCancel - } = t0; - const subtitle = `${teammates.length} ${teammates.length === 1 ? "teammate" : "teammates"}`; - const supportsHideShow = getCachedBackend()?.supportsHideShow ?? false; - const cycleModeShortcut = useShortcutDisplay("confirm:cycleMode", "Confirmation", "shift+tab"); - const t1 = `Team ${teamName}`; - let t2; - if ($[0] !== selectedIndex || $[1] !== teammates) { - t2 = teammates.length === 0 ? No teammates : {teammates.map((teammate, index) => )}; - $[0] = selectedIndex; - $[1] = teammates; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onCancel || $[4] !== subtitle || $[5] !== t1 || $[6] !== t2) { - t3 = {t2}; - $[3] = onCancel; - $[4] = subtitle; - $[5] = t1; - $[6] = t2; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== cycleModeShortcut) { - t4 = {figures.arrowUp}/{figures.arrowDown} select · Enter view · k kill · s shutdown · p prune idle{supportsHideShow && " \xB7 h hide/show \xB7 H hide/show all"}{" \xB7 "}{cycleModeShortcut} sync cycle modes for all · Esc close; - $[8] = cycleModeShortcut; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== t3 || $[11] !== t4) { - t5 = <>{t3}{t4}; - $[10] = t3; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - return t5; + teamName: string + teammates: TeammateStatus[] + selectedIndex: number + onCancel: () => void +} + +function TeamDetailView({ + teamName, + teammates, + selectedIndex, + onCancel, +}: TeamDetailViewProps): React.ReactNode { + const subtitle = `${teammates.length} ${teammates.length === 1 ? 'teammate' : 'teammates'}` + // Check if the backend supports hide/show + const supportsHideShow = getCachedBackend()?.supportsHideShow ?? false + // Get the display text for the cycle mode shortcut + const cycleModeShortcut = useShortcutDisplay( + 'confirm:cycleMode', + 'Confirmation', + 'shift+tab', + ) + + return ( + <> + + {teammates.length === 0 ? ( + No teammates + ) : ( + + {teammates.map((teammate, index) => ( + + ))} + + )} + + + + {figures.arrowUp}/{figures.arrowDown} select · Enter view · k kill · s + shutdown · p prune idle + {supportsHideShow && ' · h hide/show · H hide/show all'} + {' · '} + {cycleModeShortcut} sync cycle modes for all · Esc close + + + + ) } + type TeammateListItemProps = { - teammate: TeammateStatus; - isSelected: boolean; -}; -function TeammateListItem(t0) { - const $ = _c(21); - const { - teammate, - isSelected - } = t0; - const isIdle = teammate.status === "idle"; - const shouldDim = isIdle && !isSelected; - let modeSymbol; - let t1; - if ($[0] !== teammate.mode) { - const mode = teammate.mode ? permissionModeFromString(teammate.mode) : "default"; - modeSymbol = permissionModeSymbol(mode); - t1 = getModeColor(mode); - $[0] = teammate.mode; - $[1] = modeSymbol; - $[2] = t1; - } else { - modeSymbol = $[1]; - t1 = $[2]; - } - const modeColor = t1; - const t2 = isSelected ? "suggestion" : undefined; - const t3 = isSelected ? figures.pointer + " " : " "; - let t4; - if ($[3] !== teammate.isHidden) { - t4 = teammate.isHidden && [hidden] ; - $[3] = teammate.isHidden; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== isIdle) { - t5 = isIdle && [idle] ; - $[5] = isIdle; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== modeColor || $[8] !== modeSymbol) { - t6 = modeSymbol && {modeSymbol} ; - $[7] = modeColor; - $[8] = modeSymbol; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== teammate.model) { - t7 = teammate.model && ({teammate.model}); - $[10] = teammate.model; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== shouldDim || $[13] !== t2 || $[14] !== t3 || $[15] !== t4 || $[16] !== t5 || $[17] !== t6 || $[18] !== t7 || $[19] !== teammate.name) { - t8 = {t3}{t4}{t5}{t6}@{teammate.name}{t7}; - $[12] = shouldDim; - $[13] = t2; - $[14] = t3; - $[15] = t4; - $[16] = t5; - $[17] = t6; - $[18] = t7; - $[19] = teammate.name; - $[20] = t8; - } else { - t8 = $[20]; - } - return t8; + teammate: TeammateStatus + isSelected: boolean } + +function TeammateListItem({ + teammate, + isSelected, +}: TeammateListItemProps): React.ReactNode { + const isIdle = teammate.status === 'idle' + // Only dim if idle AND not selected - selection highlighting takes precedence + const shouldDim = isIdle && !isSelected + + // Get mode display + const mode = teammate.mode + ? permissionModeFromString(teammate.mode) + : 'default' + const modeSymbol = permissionModeSymbol(mode) + const modeColor = getModeColor(mode) + + return ( + + {isSelected ? figures.pointer + ' ' : ' '} + {teammate.isHidden && [hidden] } + {isIdle && [idle] } + {modeSymbol && {modeSymbol} }@ + {teammate.name} + {teammate.model && ({teammate.model})} + + ) +} + type TeammateDetailViewProps = { - teammate: TeammateStatus; - teamName: string; - onCancel: () => void; -}; -function TeammateDetailView(t0) { - const $ = _c(39); - const { - teammate, - teamName, - onCancel - } = t0; - const [promptExpanded, setPromptExpanded] = useState(false); - const cycleModeShortcut = useShortcutDisplay("confirm:cycleMode", "Confirmation", "shift+tab"); - const themeColor = teammate.color ? AGENT_COLOR_TO_THEME_COLOR[teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR] : undefined; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [teammateTasks, setTeammateTasks] = useState(t1); - let t2; - let t3; - if ($[1] !== teamName || $[2] !== teammate.agentId || $[3] !== teammate.name) { - t2 = () => { - let cancelled = false; - listTasks(teamName).then(allTasks => { - if (cancelled) { - return; - } - setTeammateTasks(allTasks.filter(task => task.owner === teammate.agentId || task.owner === teammate.name)); - }); - return () => { - cancelled = true; - }; - }; - t3 = [teamName, teammate.agentId, teammate.name]; - $[1] = teamName; - $[2] = teammate.agentId; - $[3] = teammate.name; - $[4] = t2; - $[5] = t3; - } else { - t2 = $[4]; - t3 = $[5]; - } - useEffect(t2, t3); - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = input => { - if (input === "p") { - setPromptExpanded(_temp); - } - }; - $[6] = t4; - } else { - t4 = $[6]; - } - useInput(t4); - const workingPath = teammate.worktreePath || teammate.cwd; - let subtitleParts; - if ($[7] !== teammate.model || $[8] !== teammate.worktreePath || $[9] !== workingPath) { - subtitleParts = []; - if (teammate.model) { - subtitleParts.push(teammate.model); + teammate: TeammateStatus + teamName: string + onCancel: () => void +} + +function TeammateDetailView({ + teammate, + teamName, + onCancel, +}: TeammateDetailViewProps): React.ReactNode { + const [promptExpanded, setPromptExpanded] = useState(false) + // Get the display text for the cycle mode shortcut + const cycleModeShortcut = useShortcutDisplay( + 'confirm:cycleMode', + 'Confirmation', + 'shift+tab', + ) + const themeColor = teammate.color + ? AGENT_COLOR_TO_THEME_COLOR[ + teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR + ] + : undefined + + // Get tasks assigned to this teammate + const [teammateTasks, setTeammateTasks] = useState([]) + useEffect(() => { + let cancelled = false + void listTasks(teamName).then(allTasks => { + if (cancelled) return + // Filter tasks owned by this teammate (by agentId or name) + setTeammateTasks( + allTasks.filter( + task => + task.owner === teammate.agentId || task.owner === teammate.name, + ), + ) + }) + return () => { + cancelled = true } - if (workingPath) { - subtitleParts.push(teammate.worktreePath ? `worktree: ${workingPath}` : workingPath); + }, [teamName, teammate.agentId, teammate.name]) + + useInput(input => { + // Handle 'p' to expand/collapse prompt + if (input === 'p') { + setPromptExpanded(prev => !prev) } - $[7] = teammate.model; - $[8] = teammate.worktreePath; - $[9] = workingPath; - $[10] = subtitleParts; - } else { - subtitleParts = $[10]; - } - const subtitle = subtitleParts.join(" \xB7 ") || undefined; - let modeSymbol; - let t5; - if ($[11] !== teammate.mode) { - const mode = teammate.mode ? permissionModeFromString(teammate.mode) : "default"; - modeSymbol = permissionModeSymbol(mode); - t5 = getModeColor(mode); - $[11] = teammate.mode; - $[12] = modeSymbol; - $[13] = t5; - } else { - modeSymbol = $[12]; - t5 = $[13]; - } - const modeColor = t5; - let t6; - if ($[14] !== modeColor || $[15] !== modeSymbol) { - t6 = modeSymbol && {modeSymbol} ; - $[14] = modeColor; - $[15] = modeSymbol; - $[16] = t6; - } else { - t6 = $[16]; - } - let t7; - if ($[17] !== teammate.name || $[18] !== themeColor) { - t7 = themeColor ? {`@${teammate.name}`} : `@${teammate.name}`; - $[17] = teammate.name; - $[18] = themeColor; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] !== t6 || $[21] !== t7) { - t8 = <>{t6}{t7}; - $[20] = t6; - $[21] = t7; - $[22] = t8; - } else { - t8 = $[22]; - } - const title = t8; - let t9; - if ($[23] !== teammateTasks) { - t9 = teammateTasks.length > 0 && Tasks{teammateTasks.map(_temp2)}; - $[23] = teammateTasks; - $[24] = t9; - } else { - t9 = $[24]; - } - let t10; - if ($[25] !== promptExpanded || $[26] !== teammate.prompt) { - t10 = teammate.prompt && Prompt{promptExpanded ? teammate.prompt : truncateToWidth(teammate.prompt, 80)}{stringWidth(teammate.prompt) > 80 && !promptExpanded && (p to expand)}; - $[25] = promptExpanded; - $[26] = teammate.prompt; - $[27] = t10; - } else { - t10 = $[27]; - } - let t11; - if ($[28] !== onCancel || $[29] !== subtitle || $[30] !== t10 || $[31] !== t9 || $[32] !== title) { - t11 = {t9}{t10}; - $[28] = onCancel; - $[29] = subtitle; - $[30] = t10; - $[31] = t9; - $[32] = title; - $[33] = t11; - } else { - t11 = $[33]; - } - let t12; - if ($[34] !== cycleModeShortcut) { - t12 = {figures.arrowLeft} back · Esc close · k kill · s shutdown{getCachedBackend()?.supportsHideShow && " \xB7 h hide/show"}{" \xB7 "}{cycleModeShortcut} cycle mode; - $[34] = cycleModeShortcut; - $[35] = t12; - } else { - t12 = $[35]; - } - let t13; - if ($[36] !== t11 || $[37] !== t12) { - t13 = <>{t11}{t12}; - $[36] = t11; - $[37] = t12; - $[38] = t13; - } else { - t13 = $[38]; + }) + + // Determine working directory display + const workingPath = teammate.worktreePath || teammate.cwd + + // Build subtitle with metadata + const subtitleParts: string[] = [] + if (teammate.model) subtitleParts.push(teammate.model) + if (workingPath) { + subtitleParts.push( + teammate.worktreePath ? `worktree: ${workingPath}` : workingPath, + ) } - return t13; -} -function _temp2(task_0) { - return {task_0.status === "completed" ? figures.tick : "\u25FC"}{" "}{task_0.subject}; -} -function _temp(prev) { - return !prev; + const subtitle = subtitleParts.join(' · ') || undefined + + // Get mode display for title + const mode = teammate.mode + ? permissionModeFromString(teammate.mode) + : 'default' + const modeSymbol = permissionModeSymbol(mode) + const modeColor = getModeColor(mode) + + // Build title with mode symbol and colored name if applicable + const title = ( + <> + {modeSymbol && {modeSymbol} } + {themeColor ? ( + {`@${teammate.name}`} + ) : ( + `@${teammate.name}` + )} + + ) + + return ( + <> + + {/* Tasks section */} + {teammateTasks.length > 0 && ( + + Tasks + {teammateTasks.map(task => ( + + {task.status === 'completed' ? figures.tick : '◼'}{' '} + {task.subject} + + ))} + + )} + + {/* Prompt section */} + {teammate.prompt && ( + + Prompt + + {promptExpanded + ? teammate.prompt + : truncateToWidth(teammate.prompt, 80)} + {stringWidth(teammate.prompt) > 80 && !promptExpanded && ( + (p to expand) + )} + + + )} + + + + {figures.arrowLeft} back · Esc close · k kill · s shutdown + {getCachedBackend()?.supportsHideShow && ' · h hide/show'} + {' · '} + {cycleModeShortcut} cycle mode + + + + ) } -async function killTeammate(paneId: string, backendType: PaneBackendType | undefined, teamName: string, teammateId: string, teammateName: string, setAppState: (f: (prev: AppState) => AppState) => void): Promise { + +async function killTeammate( + paneId: string, + backendType: PaneBackendType | undefined, + teamName: string, + teammateId: string, + teammateName: string, + setAppState: (f: (prev: AppState) => AppState) => void, +): Promise { // Kill the pane using the backend that created it (handles -s / -L flags correctly). // Wrapped in try/catch so cleanup (removeMemberFromTeam, unassignTeammateTasks, // setAppState) always runs — matches useInboxPoller.ts error isolation. @@ -553,76 +603,91 @@ async function killTeammate(paneId: string, backendType: PaneBackendType | undef // Use ensureBackendsRegistered (not detectAndGetBackend) — this process may // be a teammate that never ran detection, but we only need class imports // here, not subprocess probes that could throw in a different environment. - await ensureBackendsRegistered(); - await getBackendByType(backendType).killPane(paneId, !isInsideTmuxSync()); + await ensureBackendsRegistered() + await getBackendByType(backendType).killPane(paneId, !isInsideTmuxSync()) } catch (error) { - logForDebugging(`[TeamsDialog] Failed to kill pane ${paneId}: ${error}`); + logForDebugging(`[TeamsDialog] Failed to kill pane ${paneId}: ${error}`) } } else { // backendType undefined: old team files predating this field, or in-process. // Old tmux-file case is a migration gap — the pane is orphaned. In-process // teammates have no pane to kill, so this is correct for them. - logForDebugging(`[TeamsDialog] Skipping pane kill for ${paneId}: no backendType recorded`); + logForDebugging( + `[TeamsDialog] Skipping pane kill for ${paneId}: no backendType recorded`, + ) } // Remove from team config file - removeMemberFromTeam(teamName, paneId); + removeMemberFromTeam(teamName, paneId) // Unassign tasks and build notification message - const { - notificationMessage - } = await unassignTeammateTasks(teamName, teammateId, teammateName, 'terminated'); + const { notificationMessage } = await unassignTeammateTasks( + teamName, + teammateId, + teammateName, + 'terminated', + ) // Update AppState to keep status line in sync and notify the lead setAppState(prev => { - if (!prev.teamContext?.teammates) return prev; - if (!(teammateId in prev.teamContext.teammates)) return prev; - const { - [teammateId]: _, - ...remainingTeammates - } = prev.teamContext.teammates; + if (!prev.teamContext?.teammates) return prev + if (!(teammateId in prev.teamContext.teammates)) return prev + const { [teammateId]: _, ...remainingTeammates } = + prev.teamContext.teammates return { ...prev, teamContext: { ...prev.teamContext, - teammates: remainingTeammates + teammates: remainingTeammates, }, inbox: { - messages: [...prev.inbox.messages, { - id: randomUUID(), - from: 'system', - text: jsonStringify({ - type: 'teammate_terminated', - message: notificationMessage - }), - timestamp: new Date().toISOString(), - status: 'pending' as const - }] - } - }; - }); - logForDebugging(`[TeamsDialog] Removed ${teammateId} from teamContext`); + messages: [ + ...prev.inbox.messages, + { + id: randomUUID(), + from: 'system', + text: jsonStringify({ + type: 'teammate_terminated', + message: notificationMessage, + }), + timestamp: new Date().toISOString(), + status: 'pending' as const, + }, + ], + }, + } + }) + logForDebugging(`[TeamsDialog] Removed ${teammateId} from teamContext`) } -async function viewTeammateOutput(paneId: string, backendType: PaneBackendType | undefined): Promise { + +async function viewTeammateOutput( + paneId: string, + backendType: PaneBackendType | undefined, +): Promise { if (backendType === 'iterm2') { // -s is required to target a specific session (ITermBackend.ts:216-217) - await execFileNoThrow(IT2_COMMAND, ['session', 'focus', '-s', paneId]); + await execFileNoThrow(IT2_COMMAND, ['session', 'focus', '-s', paneId]) } else { // External-tmux teammates live on the swarm socket — without -L, this // targets the default server and silently no-ops. Mirrors runTmuxInSwarm // in TmuxBackend.ts:85-89. - const args = isInsideTmuxSync() ? ['select-pane', '-t', paneId] : ['-L', getSwarmSocketName(), 'select-pane', '-t', paneId]; - await execFileNoThrow(TMUX_COMMAND, args); + const args = isInsideTmuxSync() + ? ['select-pane', '-t', paneId] + : ['-L', getSwarmSocketName(), 'select-pane', '-t', paneId] + await execFileNoThrow(TMUX_COMMAND, args) } } /** * Toggle visibility of a teammate pane (hide if visible, show if hidden) */ -async function toggleTeammateVisibility(teammate: TeammateStatus, teamName: string): Promise { +async function toggleTeammateVisibility( + teammate: TeammateStatus, + teamName: string, +): Promise { if (teammate.isHidden) { - await showTeammate(teammate, teamName); + await showTeammate(teammate, teamName) } else { - await hideTeammate(teammate, teamName); + await hideTeammate(teammate, teamName) } } @@ -630,47 +695,71 @@ async function toggleTeammateVisibility(teammate: TeammateStatus, teamName: stri * Hide a teammate pane using the backend abstraction. * Only available for ant users (gated for dead code elimination in external builds) */ -async function hideTeammate(teammate: TeammateStatus, teamName: string): Promise {} +async function hideTeammate( + teammate: TeammateStatus, + teamName: string, +): Promise { +} /** * Show a previously hidden teammate pane using the backend abstraction. * Only available for ant users (gated for dead code elimination in external builds) */ -async function showTeammate(teammate: TeammateStatus, teamName: string): Promise {} +async function showTeammate( + teammate: TeammateStatus, + teamName: string, +): Promise { +} /** * Send a mode change message to a single teammate * Also updates config.json directly so the UI reflects the change immediately */ -function sendModeChangeToTeammate(teammateName: string, teamName: string, targetMode: PermissionMode): void { +function sendModeChangeToTeammate( + teammateName: string, + teamName: string, + targetMode: PermissionMode, +): void { // Update config.json directly so UI shows the change immediately - setMemberMode(teamName, teammateName, targetMode); + setMemberMode(teamName, teammateName, targetMode) // Also send message so teammate updates their local permission context const message = createModeSetRequestMessage({ mode: targetMode, - from: 'team-lead' - }); - void writeToMailbox(teammateName, { from: 'team-lead', - text: jsonStringify(message), - timestamp: new Date().toISOString() - }, teamName); - logForDebugging(`[TeamsDialog] Sent mode change to ${teammateName}: ${targetMode}`); + }) + void writeToMailbox( + teammateName, + { + from: 'team-lead', + text: jsonStringify(message), + timestamp: new Date().toISOString(), + }, + teamName, + ) + logForDebugging( + `[TeamsDialog] Sent mode change to ${teammateName}: ${targetMode}`, + ) } /** * Cycle a single teammate's mode */ -function cycleTeammateMode(teammate: TeammateStatus, teamName: string, isBypassAvailable: boolean): void { - const currentMode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default'; +function cycleTeammateMode( + teammate: TeammateStatus, + teamName: string, + isBypassAvailable: boolean, +): void { + const currentMode = teammate.mode + ? permissionModeFromString(teammate.mode) + : 'default' const context = { ...getEmptyToolPermissionContext(), mode: currentMode, - isBypassPermissionsModeAvailable: isBypassAvailable - }; - const nextMode = getNextPermissionMode(context); - sendModeChangeToTeammate(teammate.name, teamName, nextMode); + isBypassPermissionsModeAvailable: isBypassAvailable, + } + const nextMode = getNextPermissionMode(context) + sendModeChangeToTeammate(teammate.name, teamName, nextMode) } /** @@ -679,36 +768,51 @@ function cycleTeammateMode(teammate: TeammateStatus, teamName: string, isBypassA * If same, cycle all to next mode * Uses batch update to avoid race conditions */ -function cycleAllTeammateModes(teammates: TeammateStatus[], teamName: string, isBypassAvailable: boolean): void { - if (teammates.length === 0) return; - const modes = teammates.map(t => t.mode ? permissionModeFromString(t.mode) : 'default'); - const allSame = modes.every(m => m === modes[0]); +function cycleAllTeammateModes( + teammates: TeammateStatus[], + teamName: string, + isBypassAvailable: boolean, +): void { + if (teammates.length === 0) return + + const modes = teammates.map(t => + t.mode ? permissionModeFromString(t.mode) : 'default', + ) + const allSame = modes.every(m => m === modes[0]) // Determine target mode for all teammates - const targetMode = !allSame ? 'default' : getNextPermissionMode({ - ...getEmptyToolPermissionContext(), - mode: modes[0] ?? 'default', - isBypassPermissionsModeAvailable: isBypassAvailable - }); + const targetMode = !allSame + ? 'default' + : getNextPermissionMode({ + ...getEmptyToolPermissionContext(), + mode: modes[0] ?? 'default', + isBypassPermissionsModeAvailable: isBypassAvailable, + }) // Batch update config.json in a single atomic operation const modeUpdates = teammates.map(t => ({ memberName: t.name, - mode: targetMode - })); - setMultipleMemberModes(teamName, modeUpdates); + mode: targetMode, + })) + setMultipleMemberModes(teamName, modeUpdates) // Send mailbox messages to each teammate for (const teammate of teammates) { const message = createModeSetRequestMessage({ mode: targetMode, - from: 'team-lead' - }); - void writeToMailbox(teammate.name, { from: 'team-lead', - text: jsonStringify(message), - timestamp: new Date().toISOString() - }, teamName); + }) + void writeToMailbox( + teammate.name, + { + from: 'team-lead', + text: jsonStringify(message), + timestamp: new Date().toISOString(), + }, + teamName, + ) } - logForDebugging(`[TeamsDialog] Sent mode change to all ${teammates.length} teammates: ${targetMode}`); + logForDebugging( + `[TeamsDialog] Sent mode change to all ${teammates.length} teammates: ${targetMode}`, + ) } diff --git a/src/components/ui/OrderedList.tsx b/src/components/ui/OrderedList.tsx index a7f53f52e..ef468156f 100644 --- a/src/components/ui/OrderedList.tsx +++ b/src/components/ui/OrderedList.tsx @@ -1,70 +1,54 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, isValidElement, type ReactNode, useContext } from 'react'; -import { Box } from '../../ink.js'; -import { OrderedListItem, OrderedListItemContext } from './OrderedListItem.js'; -const OrderedListContext = createContext({ - marker: '' -}); +import React, { + createContext, + isValidElement, + type ReactNode, + useContext, +} from 'react' +import { Box } from '../../ink.js' +import { OrderedListItem, OrderedListItemContext } from './OrderedListItem.js' + +const OrderedListContext = createContext({ marker: '' }) + type OrderedListProps = { - children: ReactNode; -}; -function OrderedListComponent(t0) { - const $ = _c(9); - const { - children - } = t0; - const { - marker: parentMarker - } = useContext(OrderedListContext); - let numberOfItems = 0; + children: ReactNode +} + +function OrderedListComponent({ children }: OrderedListProps): React.ReactNode { + const { marker: parentMarker } = useContext(OrderedListContext) + + let numberOfItems = 0 for (const child of React.Children.toArray(children)) { if (!isValidElement(child) || child.type !== OrderedListItem) { - continue; + continue } - numberOfItems++; + numberOfItems++ } - const maxMarkerWidth = String(numberOfItems).length; - let t1; - if ($[0] !== children || $[1] !== maxMarkerWidth || $[2] !== parentMarker) { - let t2; - if ($[4] !== maxMarkerWidth || $[5] !== parentMarker) { - t2 = (child_0, index) => { - if (!isValidElement(child_0) || child_0.type !== OrderedListItem) { - return child_0; + + const maxMarkerWidth = String(numberOfItems).length + + return ( + + {React.Children.map(children, (child, index) => { + if (!isValidElement(child) || child.type !== OrderedListItem) { + return child } - const paddedMarker = `${String(index + 1).padStart(maxMarkerWidth)}.`; - const marker = `${parentMarker}${paddedMarker}`; - return {child_0}; - }; - $[4] = maxMarkerWidth; - $[5] = parentMarker; - $[6] = t2; - } else { - t2 = $[6]; - } - t1 = React.Children.map(children, t2); - $[0] = children; - $[1] = maxMarkerWidth; - $[2] = parentMarker; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[7] !== t1) { - t2 = {t1}; - $[7] = t1; - $[8] = t2; - } else { - t2 = $[8]; - } - return t2; + + const paddedMarker = `${String(index + 1).padStart(maxMarkerWidth)}.` + const marker = `${parentMarker}${paddedMarker}` + + return ( + + + {child} + + + ) + })} + + ) } // eslint-disable-next-line custom-rules/no-top-level-side-effects -OrderedListComponent.Item = OrderedListItem; -export const OrderedList = OrderedListComponent; +OrderedListComponent.Item = OrderedListItem + +export const OrderedList = OrderedListComponent diff --git a/src/components/ui/OrderedListItem.tsx b/src/components/ui/OrderedListItem.tsx index 5f09b3213..f217f0eaf 100644 --- a/src/components/ui/OrderedListItem.tsx +++ b/src/components/ui/OrderedListItem.tsx @@ -1,44 +1,21 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, type ReactNode, useContext } from 'react'; -import { Box, Text } from '../../ink.js'; -export const OrderedListItemContext = createContext({ - marker: '' -}); +import React, { createContext, type ReactNode, useContext } from 'react' +import { Box, Text } from '../../ink.js' + +export const OrderedListItemContext = createContext({ marker: '' }) + type OrderedListItemProps = { - children: ReactNode; -}; -export function OrderedListItem(t0) { - const $ = _c(7); - const { - children - } = t0; - const { - marker - } = useContext(OrderedListItemContext); - let t1; - if ($[0] !== marker) { - t1 = {marker}; - $[0] = marker; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== children) { - t2 = {children}; - $[2] = children; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1 || $[5] !== t2) { - t3 = {t1}{t2}; - $[4] = t1; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; + children: ReactNode +} + +export function OrderedListItem({ + children, +}: OrderedListItemProps): React.ReactNode { + const { marker } = useContext(OrderedListItemContext) + + return ( + + {marker} + {children} + + ) } diff --git a/src/components/ui/TreeSelect.tsx b/src/components/ui/TreeSelect.tsx index 94f00de0c..55ca33c88 100644 --- a/src/components/ui/TreeSelect.tsx +++ b/src/components/ui/TreeSelect.tsx @@ -1,396 +1,356 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box } from '../../ink.js'; -import { type OptionWithDescription, Select } from '../CustomSelect/select.js'; +import React from 'react' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box } from '../../ink.js' +import { type OptionWithDescription, Select } from '../CustomSelect/select.js' + export type TreeNode = { - id: string | number; - value: T; - label: string; - description?: string; - dimDescription?: boolean; - children?: TreeNode[]; - metadata?: Record; -}; + id: string | number + value: T + label: string + description?: string + dimDescription?: boolean + children?: TreeNode[] + metadata?: Record +} + type FlattenedNode = { - node: TreeNode; - depth: number; - isExpanded: boolean; - hasChildren: boolean; - parentId?: string | number; -}; + node: TreeNode + depth: number + isExpanded: boolean + hasChildren: boolean + parentId?: string | number +} + export type TreeSelectProps = { /** * Tree nodes to display. */ - readonly nodes: TreeNode[]; + readonly nodes: TreeNode[] /** * Callback when a node is selected. */ - readonly onSelect: (node: TreeNode) => void; + readonly onSelect: (node: TreeNode) => void /** * Callback when cancel is pressed. */ - readonly onCancel?: () => void; + readonly onCancel?: () => void /** * Callback when focused node changes. */ - readonly onFocus?: (node: TreeNode) => void; + readonly onFocus?: (node: TreeNode) => void /** * Node to focus by ID. */ - readonly focusNodeId?: string | number; + readonly focusNodeId?: string | number /** * Number of visible options. */ - readonly visibleOptionCount?: number; + readonly visibleOptionCount?: number /** * Layout of the options. */ - readonly layout?: 'compact' | 'expanded' | 'compact-vertical'; + readonly layout?: 'compact' | 'expanded' | 'compact-vertical' /** * When disabled, user input is ignored. */ - readonly isDisabled?: boolean; + readonly isDisabled?: boolean /** * When true, hides the numeric indexes next to each option. */ - readonly hideIndexes?: boolean; + readonly hideIndexes?: boolean /** * Function to determine if a node should be initially expanded. * If not provided, all nodes start collapsed. */ - readonly isNodeExpanded?: (nodeId: string | number) => boolean; + readonly isNodeExpanded?: (nodeId: string | number) => boolean /** * Callback when a node is expanded. */ - readonly onExpand?: (nodeId: string | number) => void; + readonly onExpand?: (nodeId: string | number) => void /** * Callback when a node is collapsed. */ - readonly onCollapse?: (nodeId: string | number) => void; + readonly onCollapse?: (nodeId: string | number) => void /** * Custom prefix function for parent nodes * @param isExpanded - Whether the parent node is currently expanded * @returns The prefix string to display (default: '▼ ' when expanded, '▶ ' when collapsed) */ - readonly getParentPrefix?: (isExpanded: boolean) => string; + readonly getParentPrefix?: (isExpanded: boolean) => string /** * Custom prefix function for child nodes * @param depth - The depth of the child node in the tree (0-indexed from parent) * @returns The prefix string to display (default: ' ▸ ') */ - readonly getChildPrefix?: (depth: number) => string; + readonly getChildPrefix?: (depth: number) => string /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ - readonly onUpFromFirstItem?: () => void; -}; + readonly onUpFromFirstItem?: () => void +} /** * TreeSelect is a generic component for selecting items from a hierarchical tree structure. * It handles expand/collapse state, keyboard navigation, and renders the tree as a flat list * using the Select component. */ -export function TreeSelect(t0) { - const $ = _c(48); - const { - nodes, - onSelect, - onCancel, - onFocus, - focusNodeId, - visibleOptionCount, - layout: t1, - isDisabled: t2, - hideIndexes: t3, - isNodeExpanded, - onExpand, - onCollapse, - getParentPrefix, - getChildPrefix, - onUpFromFirstItem - } = t0; - const layout = t1 === undefined ? "expanded" : t1; - const isDisabled = t2 === undefined ? false : t2; - const hideIndexes = t3 === undefined ? false : t3; - let t4; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t4 = new Set(); - $[0] = t4; - } else { - t4 = $[0]; - } - const [internalExpandedIds, setInternalExpandedIds] = React.useState(t4); - const isProgrammaticFocusRef = React.useRef(false); - const lastFocusedIdRef = React.useRef(null); - let t5; - if ($[1] !== internalExpandedIds || $[2] !== isNodeExpanded) { - t5 = nodeId => { +export function TreeSelect({ + nodes, + onSelect, + onCancel, + onFocus, + focusNodeId, + visibleOptionCount, + layout = 'expanded', + isDisabled = false, + hideIndexes = false, + isNodeExpanded, + onExpand, + onCollapse, + getParentPrefix, + getChildPrefix, + onUpFromFirstItem, +}: TreeSelectProps): React.ReactNode { + // Track which nodes are expanded (internal state if not controlled externally) + const [internalExpandedIds, setInternalExpandedIds] = React.useState< + Set + >(new Set()) + + // Track if we're programmatically setting focus to avoid infinite loops + const isProgrammaticFocusRef = React.useRef(false) + + // Track last focused ID to prevent duplicate focus calls + const lastFocusedIdRef = React.useRef(null) + + // Determine if a node is expanded (use external function if provided, otherwise use internal state) + const isExpanded = React.useCallback( + (nodeId: string | number): boolean => { if (isNodeExpanded) { - return isNodeExpanded(nodeId); + return isNodeExpanded(nodeId) } - return internalExpandedIds.has(nodeId); - }; - $[1] = internalExpandedIds; - $[2] = isNodeExpanded; - $[3] = t5; - } else { - t5 = $[3]; - } - const isExpanded = t5; - let result; - if ($[4] !== isExpanded || $[5] !== nodes) { - result = []; - function traverse(node, depth, parentId) { - const hasChildren = !!node.children && node.children.length > 0; - const nodeIsExpanded = isExpanded(node.id); + return internalExpandedIds.has(nodeId) + }, + [isNodeExpanded, internalExpandedIds], + ) + + // Flatten the tree into a linear list for the Select component + const flattenedNodes = React.useMemo((): FlattenedNode[] => { + const result: FlattenedNode[] = [] + + function traverse( + node: TreeNode, + depth: number, + parentId?: string | number, + ): void { + const hasChildren = !!node.children && node.children.length > 0 + const nodeIsExpanded = isExpanded(node.id) + result.push({ node, depth, isExpanded: nodeIsExpanded, hasChildren, - parentId - }); + parentId, + }) + + // Only traverse children if this node is expanded if (hasChildren && nodeIsExpanded && node.children) { for (const child of node.children) { - traverse(child, depth + 1, node.id); + traverse(child, depth + 1, node.id) } } } - for (const node_0 of nodes) { - traverse(node_0, 0, undefined); + + for (const node of nodes) { + traverse(node, 0) } - $[4] = isExpanded; - $[5] = nodes; - $[6] = result; - } else { - result = $[6]; - } - const flattenedNodes = result; - const defaultGetParentPrefix = _temp; - const defaultGetChildPrefix = _temp2; - const parentPrefixFn = getParentPrefix ?? defaultGetParentPrefix; - const childPrefixFn = getChildPrefix ?? defaultGetChildPrefix; - let t6; - if ($[7] !== childPrefixFn || $[8] !== parentPrefixFn) { - t6 = flatNode => { - let prefix = ""; + + return result + }, [nodes, isExpanded]) + + // Default prefix functions + const defaultGetParentPrefix = React.useCallback( + (isExpanded: boolean): string => (isExpanded ? '▼ ' : '▶ '), + [], + ) + const defaultGetChildPrefix = React.useCallback( + (_depth: number): string => ' ▸ ', + [], + ) + + const parentPrefixFn = getParentPrefix ?? defaultGetParentPrefix + const childPrefixFn = getChildPrefix ?? defaultGetChildPrefix + + // Build the label with appropriate prefixes based on tree position + const buildLabel = React.useCallback( + (flatNode: FlattenedNode): string => { + let prefix = '' + if (flatNode.hasChildren) { - prefix = parentPrefixFn(flatNode.isExpanded); - } else { - if (flatNode.depth > 0) { - prefix = childPrefixFn(flatNode.depth); - } - } - return prefix + flatNode.node.label; - }; - $[7] = childPrefixFn; - $[8] = parentPrefixFn; - $[9] = t6; - } else { - t6 = $[9]; - } - const buildLabel = t6; - let t7; - if ($[10] !== buildLabel || $[11] !== flattenedNodes) { - t7 = flattenedNodes.map(flatNode_0 => ({ - label: buildLabel(flatNode_0), - description: flatNode_0.node.description, - dimDescription: flatNode_0.node.dimDescription ?? true, - value: flatNode_0.node.id - })); - $[10] = buildLabel; - $[11] = flattenedNodes; - $[12] = t7; - } else { - t7 = $[12]; - } - const options = t7; - let map; - if ($[13] !== flattenedNodes) { - map = new Map(); - flattenedNodes.forEach(fn => map.set(fn.node.id, fn.node)); - $[13] = flattenedNodes; - $[14] = map; - } else { - map = $[14]; - } - const nodeMap = map; - let t8; - if ($[15] !== flattenedNodes) { - t8 = nodeId_0 => flattenedNodes.find(fn_0 => fn_0.node.id === nodeId_0); - $[15] = flattenedNodes; - $[16] = t8; - } else { - t8 = $[16]; - } - const findFlattenedNode = t8; - let t9; - if ($[17] !== findFlattenedNode || $[18] !== onCollapse || $[19] !== onExpand) { - t9 = (nodeId_1, shouldExpand) => { - const flatNode_1 = findFlattenedNode(nodeId_1); - if (!flatNode_1 || !flatNode_1.hasChildren) { - return; + // Parent node with children + prefix = parentPrefixFn(flatNode.isExpanded) + } else if (flatNode.depth > 0) { + // Child node + prefix = childPrefixFn(flatNode.depth) } + + return prefix + flatNode.node.label + }, + [parentPrefixFn, childPrefixFn], + ) + + // Convert flattened nodes to Select options + const options = React.useMemo((): OptionWithDescription< + string | number + >[] => { + return flattenedNodes.map(flatNode => ({ + label: buildLabel(flatNode), + description: flatNode.node.description, + dimDescription: flatNode.node.dimDescription ?? true, + value: flatNode.node.id, + })) + }, [flattenedNodes, buildLabel]) + + // Map from node ID to the actual node for quick lookup + const nodeMap = React.useMemo(() => { + const map = new Map>() + flattenedNodes.forEach(fn => map.set(fn.node.id, fn.node)) + return map + }, [flattenedNodes]) + + // Find the flattened node by ID + const findFlattenedNode = React.useCallback( + (nodeId: string | number): FlattenedNode | undefined => { + return flattenedNodes.find(fn => fn.node.id === nodeId) + }, + [flattenedNodes], + ) + + // Handle expand/collapse + const toggleExpand = React.useCallback( + (nodeId: string | number, shouldExpand: boolean) => { + const flatNode = findFlattenedNode(nodeId) + if (!flatNode || !flatNode.hasChildren) return + if (shouldExpand) { if (onExpand) { - onExpand(nodeId_1); + onExpand(nodeId) } else { - setInternalExpandedIds(prev => new Set(prev).add(nodeId_1)); + setInternalExpandedIds(prev => new Set(prev).add(nodeId)) } } else { if (onCollapse) { - onCollapse(nodeId_1); + onCollapse(nodeId) } else { - setInternalExpandedIds(prev_0 => { - const newSet = new Set(prev_0); - newSet.delete(nodeId_1); - return newSet; - }); + setInternalExpandedIds(prev => { + const newSet = new Set(prev) + newSet.delete(nodeId) + return newSet + }) } } - }; - $[17] = findFlattenedNode; - $[18] = onCollapse; - $[19] = onExpand; - $[20] = t9; - } else { - t9 = $[20]; - } - const toggleExpand = t9; - let t10; - if ($[21] !== findFlattenedNode || $[22] !== focusNodeId || $[23] !== isDisabled || $[24] !== nodeMap || $[25] !== onFocus || $[26] !== toggleExpand) { - t10 = e => { - if (!focusNodeId || isDisabled) { - return; - } - const flatNode_2 = findFlattenedNode(focusNodeId); - if (!flatNode_2) { - return; - } - if (e.key === "right" && flatNode_2.hasChildren) { - e.preventDefault(); - toggleExpand(focusNodeId, true); - } else { - if (e.key === "left") { - if (flatNode_2.hasChildren && flatNode_2.isExpanded) { - e.preventDefault(); - toggleExpand(focusNodeId, false); - } else { - if (flatNode_2.parentId !== undefined) { - e.preventDefault(); - isProgrammaticFocusRef.current = true; - toggleExpand(flatNode_2.parentId, false); - if (onFocus) { - const parentNode = nodeMap.get(flatNode_2.parentId); - if (parentNode) { - onFocus(parentNode); - } - } - } + }, + [findFlattenedNode, onExpand, onCollapse], + ) + + // Handle left/right arrow keys for expand/collapse + const handleKeyDown = (e: KeyboardEvent) => { + if (!focusNodeId || isDisabled) return + + const flatNode = findFlattenedNode(focusNodeId) + if (!flatNode) return + + if (e.key === 'right' && flatNode.hasChildren) { + // Expand the focused node (only if it has children) + e.preventDefault() + toggleExpand(focusNodeId, true) + } else if (e.key === 'left') { + if (flatNode.hasChildren && flatNode.isExpanded) { + // Collapse the focused parent node + e.preventDefault() + toggleExpand(focusNodeId, false) + } else if (flatNode.parentId !== undefined) { + // If this is a child node OR a collapsed parent with a parent, + // collapse the parent and focus it + e.preventDefault() + isProgrammaticFocusRef.current = true + toggleExpand(flatNode.parentId, false) + if (onFocus) { + const parentNode = nodeMap.get(flatNode.parentId) + if (parentNode) { + onFocus(parentNode) } } } - }; - $[21] = findFlattenedNode; - $[22] = focusNodeId; - $[23] = isDisabled; - $[24] = nodeMap; - $[25] = onFocus; - $[26] = toggleExpand; - $[27] = t10; - } else { - t10 = $[27]; - } - const handleKeyDown = t10; - let t11; - if ($[28] !== nodeMap || $[29] !== onSelect) { - t11 = nodeId_2 => { - const node_1 = nodeMap.get(nodeId_2); - if (!node_1) { - return; - } - onSelect(node_1); - }; - $[28] = nodeMap; - $[29] = onSelect; - $[30] = t11; - } else { - t11 = $[30]; + } } - const handleChange = t11; - let t12; - if ($[31] !== nodeMap || $[32] !== onFocus) { - t12 = nodeId_3 => { + + // Handle selection + const handleChange = React.useCallback( + (nodeId: string | number) => { + const node = nodeMap.get(nodeId) + if (!node) return + + // Always select the node - expand/collapse is handled by arrow keys + onSelect(node) + }, + [nodeMap, onSelect], + ) + + // Handle focus changes + const handleFocus = React.useCallback( + (nodeId: string | number) => { + // Skip if this is a programmatic focus change if (isProgrammaticFocusRef.current) { - isProgrammaticFocusRef.current = false; - return; + isProgrammaticFocusRef.current = false + return } - if (lastFocusedIdRef.current === nodeId_3) { - return; + + // Skip if same node already focused + if (lastFocusedIdRef.current === nodeId) { + return } - lastFocusedIdRef.current = nodeId_3; + lastFocusedIdRef.current = nodeId + if (onFocus) { - const node_2 = nodeMap.get(nodeId_3); - if (node_2) { - onFocus(node_2); + const node = nodeMap.get(nodeId) + if (node) { + onFocus(node) } } - }; - $[31] = nodeMap; - $[32] = onFocus; - $[33] = t12; - } else { - t12 = $[33]; - } - const handleFocus = t12; - let t13; - if ($[34] !== focusNodeId || $[35] !== handleChange || $[36] !== handleFocus || $[37] !== hideIndexes || $[38] !== isDisabled || $[39] !== layout || $[40] !== onCancel || $[41] !== onUpFromFirstItem || $[42] !== options || $[43] !== visibleOptionCount) { - t13 = + + ) } diff --git a/src/components/wizard/WizardDialogLayout.tsx b/src/components/wizard/WizardDialogLayout.tsx index d3781650b..34f20a261 100644 --- a/src/components/wizard/WizardDialogLayout.tsx +++ b/src/components/wizard/WizardDialogLayout.tsx @@ -1,64 +1,48 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import type { Theme } from '../../utils/theme.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { useWizard } from './useWizard.js'; -import { WizardNavigationFooter } from './WizardNavigationFooter.js'; +import React, { type ReactNode } from 'react' +import type { Theme } from '../../utils/theme.js' +import { Dialog } from '../design-system/Dialog.js' +import { useWizard } from './useWizard.js' +import { WizardNavigationFooter } from './WizardNavigationFooter.js' + type Props = { - title?: string; - color?: keyof Theme; - children: ReactNode; - subtitle?: string; - footerText?: ReactNode; -}; -export function WizardDialogLayout(t0) { - const $ = _c(11); - const { - title: titleOverride, - color: t1, - children, - subtitle, - footerText - } = t0; - const color = t1 === undefined ? "suggestion" : t1; + title?: string + color?: keyof Theme + children: ReactNode + subtitle?: string + footerText?: ReactNode +} + +export function WizardDialogLayout({ + title: titleOverride, + color = 'suggestion', + children, + subtitle, + footerText, +}: Props): ReactNode { const { currentStepIndex, totalSteps, title: providerTitle, showStepCounter, - goBack - } = useWizard(); - const title = titleOverride || providerTitle || "Wizard"; - const stepSuffix = showStepCounter !== false ? ` (${currentStepIndex + 1}/${totalSteps})` : ""; - const t2 = `${title}${stepSuffix}`; - let t3; - if ($[0] !== children || $[1] !== color || $[2] !== goBack || $[3] !== subtitle || $[4] !== t2) { - t3 = {children}; - $[0] = children; - $[1] = color; - $[2] = goBack; - $[3] = subtitle; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== footerText) { - t4 = ; - $[6] = footerText; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = <>{t3}{t4}; - $[8] = t3; - $[9] = t4; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; + goBack, + } = useWizard() + const title = titleOverride || providerTitle || 'Wizard' + const stepSuffix = + showStepCounter !== false ? ` (${currentStepIndex + 1}/${totalSteps})` : '' + + return ( + <> + + {children} + + + + ) } diff --git a/src/components/wizard/WizardNavigationFooter.tsx b/src/components/wizard/WizardNavigationFooter.tsx index 183334a91..35a03ee81 100644 --- a/src/components/wizard/WizardNavigationFooter.tsx +++ b/src/components/wizard/WizardNavigationFooter.tsx @@ -1,23 +1,37 @@ -import React, { type ReactNode } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React, { type ReactNode } from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - instructions?: ReactNode; -}; + instructions?: ReactNode +} + export function WizardNavigationFooter({ - instructions = + instructions = ( + - + + ), }: Props): ReactNode { - const exitState = useExitOnCtrlCDWithKeybindings(); - return + const exitState = useExitOnCtrlCDWithKeybindings() + + return ( + - {exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions} + {exitState.pending + ? `Press ${exitState.keyName} again to exit` + : instructions} - ; + + ) } diff --git a/src/components/wizard/WizardProvider.tsx b/src/components/wizard/WizardProvider.tsx index 3160ea610..6707cb95a 100644 --- a/src/components/wizard/WizardProvider.tsx +++ b/src/components/wizard/WizardProvider.tsx @@ -1,156 +1,96 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import type { WizardContextValue, WizardProviderProps } from './types.js'; +import React, { + createContext, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import type { WizardContextValue, WizardProviderProps } from './types.js' // Use any here for the context since it will be cast properly when used // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const WizardContext = createContext | null>(null); -export function WizardProvider(t0) { - const $ = _c(38); - const { - steps, - initialData: t1, - onComplete, - onCancel, - children, - title, - showStepCounter: t2 - } = t0; - let t3; - if ($[0] !== t1) { - t3 = t1 === undefined ? {} as T : t1; - $[0] = t1; - $[1] = t3; - } else { - t3 = $[1]; - } - const initialData = t3; - const showStepCounter = t2 === undefined ? true : t2; - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [wizardData, setWizardData] = useState(initialData); - const [isCompleted, setIsCompleted] = useState(false); - let t4; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t4 = []; - $[2] = t4; - } else { - t4 = $[2]; - } - const [navigationHistory, setNavigationHistory] = useState(t4); - useExitOnCtrlCDWithKeybindings(); - let t5; - let t6; - if ($[3] !== isCompleted || $[4] !== onComplete || $[5] !== wizardData) { - t5 = () => { - if (isCompleted) { - setNavigationHistory([]); - onComplete(wizardData); - } - }; - t6 = [isCompleted, wizardData, onComplete]; - $[3] = isCompleted; - $[4] = onComplete; - $[5] = wizardData; - $[6] = t5; - $[7] = t6; - } else { - t5 = $[6]; - t6 = $[7]; - } - useEffect(t5, t6); - let t7; - if ($[8] !== currentStepIndex || $[9] !== navigationHistory || $[10] !== steps.length) { - t7 = () => { - if (currentStepIndex < steps.length - 1) { - if (navigationHistory.length > 0) { - setNavigationHistory(prev => [...prev, currentStepIndex]); - } - setCurrentStepIndex(_temp); - } else { - setIsCompleted(true); - } - }; - $[8] = currentStepIndex; - $[9] = navigationHistory; - $[10] = steps.length; - $[11] = t7; - } else { - t7 = $[11]; - } - const goNext = t7; - let t8; - if ($[12] !== currentStepIndex || $[13] !== navigationHistory || $[14] !== onCancel) { - t8 = () => { +export const WizardContext = createContext | null>(null) + +export function WizardProvider>({ + steps, + initialData = {} as T, + onComplete, + onCancel, + children, + title, + showStepCounter = true, +}: WizardProviderProps): ReactNode { + const [currentStepIndex, setCurrentStepIndex] = useState(0) + const [wizardData, setWizardData] = useState(initialData) + const [isCompleted, setIsCompleted] = useState(false) + const [navigationHistory, setNavigationHistory] = useState([]) + + useExitOnCtrlCDWithKeybindings() + + // Handle completion in useEffect to avoid updating parent during render + useEffect(() => { + if (isCompleted) { + setNavigationHistory([]) + void onComplete(wizardData) + } + }, [isCompleted, wizardData, onComplete]) + + const goNext = useCallback(() => { + if (currentStepIndex < steps.length - 1) { + // If we have history (non-linear flow), add current step to it if (navigationHistory.length > 0) { - const previousStep = navigationHistory[navigationHistory.length - 1]; - if (previousStep !== undefined) { - setNavigationHistory(_temp2); - setCurrentStepIndex(previousStep); - } - } else { - if (currentStepIndex > 0) { - setCurrentStepIndex(_temp3); - } else { - if (onCancel) { - onCancel(); - } - } + setNavigationHistory(prev => [...prev, currentStepIndex]) } - }; - $[12] = currentStepIndex; - $[13] = navigationHistory; - $[14] = onCancel; - $[15] = t8; - } else { - t8 = $[15]; - } - const goBack = t8; - let t9; - if ($[16] !== currentStepIndex || $[17] !== steps.length) { - t9 = index => { - if (index >= 0 && index < steps.length) { - setNavigationHistory(prev_3 => [...prev_3, currentStepIndex]); - setCurrentStepIndex(index); + + setCurrentStepIndex(prev => prev + 1) + } else { + // Mark as completed, which will trigger useEffect + setIsCompleted(true) + } + }, [currentStepIndex, steps.length, navigationHistory]) + + const goBack = useCallback(() => { + // Check if we have navigation history to use + if (navigationHistory.length > 0) { + const previousStep = navigationHistory[navigationHistory.length - 1] + if (previousStep !== undefined) { + setNavigationHistory(prev => prev.slice(0, -1)) + setCurrentStepIndex(previousStep) } - }; - $[16] = currentStepIndex; - $[17] = steps.length; - $[18] = t9; - } else { - t9 = $[18]; - } - const goToStep = t9; - let t10; - if ($[19] !== onCancel) { - t10 = () => { - setNavigationHistory([]); - if (onCancel) { - onCancel(); + } else if (currentStepIndex > 0) { + // Fallback to simple decrement if no history + setCurrentStepIndex(prev => prev - 1) + } else if (onCancel) { + onCancel() + } + }, [currentStepIndex, navigationHistory, onCancel]) + + const goToStep = useCallback( + (index: number) => { + if (index >= 0 && index < steps.length) { + // Push current step to history before jumping + setNavigationHistory(prev => [...prev, currentStepIndex]) + setCurrentStepIndex(index) } - }; - $[19] = onCancel; - $[20] = t10; - } else { - t10 = $[20]; - } - const cancel = t10; - let t11; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t11 = updates => { - setWizardData(prev_4 => ({ - ...prev_4, - ...updates - })); - }; - $[21] = t11; - } else { - t11 = $[21]; - } - const updateWizardData = t11; - let t12; - if ($[22] !== cancel || $[23] !== currentStepIndex || $[24] !== goBack || $[25] !== goNext || $[26] !== goToStep || $[27] !== showStepCounter || $[28] !== steps.length || $[29] !== title || $[30] !== wizardData) { - t12 = { + }, + [currentStepIndex, steps.length], + ) + + const cancel = useCallback(() => { + setNavigationHistory([]) + if (onCancel) { + onCancel() + } + }, [onCancel]) + + const updateWizardData = useCallback((updates: Partial) => { + setWizardData(prev => ({ ...prev, ...updates })) + }, []) + + const contextValue = useMemo>( + () => ({ currentStepIndex, totalSteps: steps.length, wizardData, @@ -161,52 +101,31 @@ export function WizardProvider(t0) { goToStep, cancel, title, - showStepCounter - }; - $[22] = cancel; - $[23] = currentStepIndex; - $[24] = goBack; - $[25] = goNext; - $[26] = goToStep; - $[27] = showStepCounter; - $[28] = steps.length; - $[29] = title; - $[30] = wizardData; - $[31] = t12; - } else { - t12 = $[31]; - } - const contextValue = t12; - const CurrentStepComponent = steps[currentStepIndex]; + showStepCounter, + }), + [ + currentStepIndex, + steps.length, + wizardData, + updateWizardData, + goNext, + goBack, + goToStep, + cancel, + title, + showStepCounter, + ], + ) + + const CurrentStepComponent = steps[currentStepIndex] + if (!CurrentStepComponent || isCompleted) { - return null; - } - let t13; - if ($[32] !== CurrentStepComponent || $[33] !== children) { - t13 = children || ; - $[32] = CurrentStepComponent; - $[33] = children; - $[34] = t13; - } else { - t13 = $[34]; - } - let t14; - if ($[35] !== contextValue || $[36] !== t13) { - t14 = {t13}; - $[35] = contextValue; - $[36] = t13; - $[37] = t14; - } else { - t14 = $[37]; + return null } - return t14; -} -function _temp3(prev_2) { - return prev_2 - 1; -} -function _temp2(prev_1) { - return prev_1.slice(0, -1); -} -function _temp(prev_0) { - return prev_0 + 1; + + return ( + + {children || } + + ) } diff --git a/src/context/QueuedMessageContext.tsx b/src/context/QueuedMessageContext.tsx index 670f6afb3..575fc8619 100644 --- a/src/context/QueuedMessageContext.tsx +++ b/src/context/QueuedMessageContext.tsx @@ -1,62 +1,45 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box } from '../ink.js'; +import * as React from 'react' +import { Box } from '../ink.js' + type QueuedMessageContextValue = { - isQueued: boolean; - isFirst: boolean; + isQueued: boolean + isFirst: boolean /** Width reduction for container padding (e.g., 4 for paddingX={2}) */ - paddingWidth: number; -}; -const QueuedMessageContext = React.createContext(undefined); -export function useQueuedMessage() { - return React.useContext(QueuedMessageContext); + paddingWidth: number } -const PADDING_X = 2; + +const QueuedMessageContext = React.createContext< + QueuedMessageContextValue | undefined +>(undefined) + +export function useQueuedMessage(): QueuedMessageContextValue | undefined { + return React.useContext(QueuedMessageContext) +} + +const PADDING_X = 2 + type Props = { - isFirst: boolean; - useBriefLayout?: boolean; - children: React.ReactNode; -}; -export function QueuedMessageProvider(t0) { - const $ = _c(9); - const { - isFirst, - useBriefLayout, - children - } = t0; - const padding = useBriefLayout ? 0 : PADDING_X; - const t1 = padding * 2; - let t2; - if ($[0] !== isFirst || $[1] !== t1) { - t2 = { - isQueued: true, - isFirst, - paddingWidth: t1 - }; - $[0] = isFirst; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - const value = t2; - let t3; - if ($[3] !== children || $[4] !== padding) { - t3 = {children}; - $[3] = children; - $[4] = padding; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== t3 || $[7] !== value) { - t4 = {t3}; - $[6] = t3; - $[7] = value; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + isFirst: boolean + useBriefLayout?: boolean + children: React.ReactNode +} + +export function QueuedMessageProvider({ + isFirst, + useBriefLayout, + children, +}: Props): React.ReactNode { + // Brief mode already indents via paddingLeft in HighlightedThinkingText / + // BriefTool UI — adding paddingX here would double-indent the queue. + const padding = useBriefLayout ? 0 : PADDING_X + const value = React.useMemo( + () => ({ isQueued: true, isFirst, paddingWidth: padding * 2 }), + [isFirst, padding], + ) + + return ( + + {children} + + ) } diff --git a/src/context/fpsMetrics.tsx b/src/context/fpsMetrics.tsx index a1c281005..b23a411ec 100644 --- a/src/context/fpsMetrics.tsx +++ b/src/context/fpsMetrics.tsx @@ -1,29 +1,26 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useContext } from 'react'; -import type { FpsMetrics } from '../utils/fpsTracker.js'; -type FpsMetricsGetter = () => FpsMetrics | undefined; -const FpsMetricsContext = createContext(undefined); +import React, { createContext, useContext } from 'react' +import type { FpsMetrics } from '../utils/fpsTracker.js' + +type FpsMetricsGetter = () => FpsMetrics | undefined + +const FpsMetricsContext = createContext(undefined) + type Props = { - getFpsMetrics: FpsMetricsGetter; - children: React.ReactNode; -}; -export function FpsMetricsProvider(t0) { - const $ = _c(3); - const { - getFpsMetrics, - children - } = t0; - let t1; - if ($[0] !== children || $[1] !== getFpsMetrics) { - t1 = {children}; - $[0] = children; - $[1] = getFpsMetrics; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + getFpsMetrics: FpsMetricsGetter + children: React.ReactNode } -export function useFpsMetrics() { - return useContext(FpsMetricsContext); + +export function FpsMetricsProvider({ + getFpsMetrics, + children, +}: Props): React.ReactNode { + return ( + + {children} + + ) +} + +export function useFpsMetrics(): FpsMetricsGetter | undefined { + return useContext(FpsMetricsContext) } diff --git a/src/context/mailbox.tsx b/src/context/mailbox.tsx index ac9d46b79..a02e2cb46 100644 --- a/src/context/mailbox.tsx +++ b/src/context/mailbox.tsx @@ -1,37 +1,25 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useContext, useMemo } from 'react'; -import { Mailbox } from '../utils/mailbox.js'; -const MailboxContext = createContext(undefined); +import React, { createContext, useContext, useMemo } from 'react' +import { Mailbox } from '../utils/mailbox.js' + +const MailboxContext = createContext(undefined) + type Props = { - children: React.ReactNode; -}; -export function MailboxProvider(t0) { - const $ = _c(3); - const { - children - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = new Mailbox(); - $[0] = t1; - } else { - t1 = $[0]; - } - const mailbox = t1; - let t2; - if ($[1] !== children) { - t2 = {children}; - $[1] = children; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + children: React.ReactNode +} + +export function MailboxProvider({ children }: Props): React.ReactNode { + const mailbox = useMemo(() => new Mailbox(), []) + return ( + + {children} + + ) } -export function useMailbox() { - const mailbox = useContext(MailboxContext); + +export function useMailbox(): Mailbox { + const mailbox = useContext(MailboxContext) if (!mailbox) { - throw new Error("useMailbox must be used within a MailboxProvider"); + throw new Error('useMailbox must be used within a MailboxProvider') } - return mailbox; + return mailbox } diff --git a/src/context/modalContext.tsx b/src/context/modalContext.tsx index b9b2f0d63..b2263a071 100644 --- a/src/context/modalContext.tsx +++ b/src/context/modalContext.tsx @@ -1,6 +1,5 @@ -import { c as _c } from "react/compiler-runtime"; -import { createContext, type RefObject, useContext } from 'react'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; +import { createContext, type RefObject, useContext } from 'react' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' /** * Set by FullscreenLayout when rendering content in its `modal` slot — @@ -20,13 +19,14 @@ import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; * null = not inside the modal slot. */ type ModalCtx = { - rows: number; - columns: number; - scrollRef: RefObject | null; -}; -export const ModalContext = createContext(null); -export function useIsInsideModal() { - return useContext(ModalContext) !== null; + rows: number + columns: number + scrollRef: RefObject | null +} +export const ModalContext = createContext(null) + +export function useIsInsideModal(): boolean { + return useContext(ModalContext) !== null } /** @@ -35,23 +35,14 @@ export function useIsInsideModal() { * component caps its visible content height — the modal's inner area is * smaller than the terminal. */ -export function useModalOrTerminalSize(fallback) { - const $ = _c(3); - const ctx = useContext(ModalContext); - let t0; - if ($[0] !== ctx || $[1] !== fallback) { - t0 = ctx ? { - rows: ctx.rows, - columns: ctx.columns - } : fallback; - $[0] = ctx; - $[1] = fallback; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; +export function useModalOrTerminalSize(fallback: { + rows: number + columns: number +}): { rows: number; columns: number } { + const ctx = useContext(ModalContext) + return ctx ? { rows: ctx.rows, columns: ctx.columns } : fallback } -export function useModalScrollRef() { - return useContext(ModalContext)?.scrollRef ?? null; + +export function useModalScrollRef(): RefObject | null { + return useContext(ModalContext)?.scrollRef ?? null } diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index d6281d5a3..a19d908f9 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -1,216 +1,288 @@ -import type * as React from 'react'; -import { useCallback, useEffect } from 'react'; -import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'; -import type { Theme } from '../utils/theme.js'; -type Priority = 'low' | 'medium' | 'high' | 'immediate'; +import type * as React from 'react' +import { useCallback, useEffect } from 'react' +import { useAppStateStore, useSetAppState } from 'src/state/AppState.js' +import type { Theme } from '../utils/theme.js' + +type Priority = 'low' | 'medium' | 'high' | 'immediate' + type BaseNotification = { - key: string; + key: string /** * Keys of notifications that this notification invalidates. * If a notification is invalidated, it will be removed from the queue * and, if currently displayed, cleared immediately. */ - invalidates?: string[]; - priority: Priority; - timeoutMs?: number; + invalidates?: string[] + priority: Priority + timeoutMs?: number /** * Combine notifications with the same key, like Array.reduce(). * Called as fold(accumulator, incoming) when a notification with a matching * key already exists in the queue or is currently displayed. * Returns the merged notification (should carry fold forward for future merges). */ - fold?: (accumulator: Notification, incoming: Notification) => Notification; -}; + fold?: (accumulator: Notification, incoming: Notification) => Notification +} + type TextNotification = BaseNotification & { - text: string; - color?: keyof Theme; -}; + text: string + color?: keyof Theme +} + type JSXNotification = BaseNotification & { - jsx: React.ReactNode; -}; -type AddNotificationFn = (content: Notification) => void; -type RemoveNotificationFn = (key: string) => void; -export type Notification = TextNotification | JSXNotification; -const DEFAULT_TIMEOUT_MS = 8000; + jsx: React.ReactNode +} + +type AddNotificationFn = (content: Notification) => void +type RemoveNotificationFn = (key: string) => void + +export type Notification = TextNotification | JSXNotification + +const DEFAULT_TIMEOUT_MS = 8000 // Track current timeout to clear it when immediate notifications arrive -let currentTimeoutId: NodeJS.Timeout | null = null; +let currentTimeoutId: NodeJS.Timeout | null = null + export function useNotifications(): { - addNotification: AddNotificationFn; - removeNotification: RemoveNotificationFn; + addNotification: AddNotificationFn + removeNotification: RemoveNotificationFn } { - const store = useAppStateStore(); - const setAppState = useSetAppState(); + const store = useAppStateStore() + const setAppState = useSetAppState() // Process queue when current notification finishes or queue changes const processQueue = useCallback(() => { setAppState(prev => { - const next = getNext(prev.notifications.queue); + const next = getNext(prev.notifications.queue) if (prev.notifications.current !== null || !next) { - return prev; + return prev } - currentTimeoutId = setTimeout((setAppState, nextKey, processQueue) => { - currentTimeoutId = null; - setAppState(prev => { - // Compare by key instead of reference to handle re-created notifications - if (prev.notifications.current?.key !== nextKey) { - return prev; - } - return { - ...prev, - notifications: { - queue: prev.notifications.queue, - current: null + + currentTimeoutId = setTimeout( + (setAppState, nextKey, processQueue) => { + currentTimeoutId = null + setAppState(prev => { + // Compare by key instead of reference to handle re-created notifications + if (prev.notifications.current?.key !== nextKey) { + return prev + } + return { + ...prev, + notifications: { + queue: prev.notifications.queue, + current: null, + }, } - }; - }); - processQueue(); - }, next.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, next.key, processQueue); + }) + processQueue() + }, + next.timeoutMs ?? DEFAULT_TIMEOUT_MS, + setAppState, + next.key, + processQueue, + ) + return { ...prev, notifications: { queue: prev.notifications.queue.filter(_ => _ !== next), - current: next - } - }; - }); - }, [setAppState]); - const addNotification = useCallback((notif: Notification) => { - // Handle immediate priority notifications - if (notif.priority === 'immediate') { - // Clear any existing timeout since we're showing a new immediate notification - if (currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; + current: next, + }, } + }) + }, [setAppState]) - // Set up timeout for the immediate notification - currentTimeoutId = setTimeout((setAppState, notif, processQueue) => { - currentTimeoutId = null; - setAppState(prev => { - // Compare by key instead of reference to handle re-created notifications - if (prev.notifications.current?.key !== notif.key) { - return prev; - } - return { - ...prev, - notifications: { - queue: prev.notifications.queue.filter(_ => !notif.invalidates?.includes(_.key)), - current: null - } - }; - }); - processQueue(); - }, notif.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, notif, processQueue); - - // Show the immediate notification right away - setAppState(prev => ({ - ...prev, - notifications: { - current: notif, - queue: - // Only re-queue the current notification if it's not immediate - [...(prev.notifications.current ? [prev.notifications.current] : []), ...prev.notifications.queue].filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key)) + const addNotification = useCallback( + (notif: Notification) => { + // Handle immediate priority notifications + if (notif.priority === 'immediate') { + // Clear any existing timeout since we're showing a new immediate notification + if (currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null } - })); - return; // IMPORTANT: Exit addNotification for immediate notifications - } - // Handle non-immediate notifications - setAppState(prev => { - // Check if we can fold into an existing notification with the same key - if (notif.fold) { - // Fold into current notification if keys match - if (prev.notifications.current?.key === notif.key) { - const folded = notif.fold(prev.notifications.current, notif); - // Reset timeout for the folded notification - if (currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; - } - currentTimeoutId = setTimeout((setAppState, foldedKey, processQueue) => { - currentTimeoutId = null; - setAppState(p => { - if (p.notifications.current?.key !== foldedKey) { - return p; + // Set up timeout for the immediate notification + currentTimeoutId = setTimeout( + (setAppState, notif, processQueue) => { + currentTimeoutId = null + setAppState(prev => { + // Compare by key instead of reference to handle re-created notifications + if (prev.notifications.current?.key !== notif.key) { + return prev } return { - ...p, + ...prev, notifications: { - queue: p.notifications.queue, - current: null - } - }; - }); - processQueue(); - }, folded.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, folded.key, processQueue); - return { - ...prev, - notifications: { - current: folded, - queue: prev.notifications.queue + queue: prev.notifications.queue.filter( + _ => !notif.invalidates?.includes(_.key), + ), + current: null, + }, + } + }) + processQueue() + }, + notif.timeoutMs ?? DEFAULT_TIMEOUT_MS, + setAppState, + notif, + processQueue, + ) + + // Show the immediate notification right away + setAppState(prev => ({ + ...prev, + notifications: { + current: notif, + queue: + // Only re-queue the current notification if it's not immediate + [ + ...(prev.notifications.current + ? [prev.notifications.current] + : []), + ...prev.notifications.queue, + ].filter( + _ => + _.priority !== 'immediate' && + !notif.invalidates?.includes(_.key), + ), + }, + })) + return // IMPORTANT: Exit addNotification for immediate notifications + } + + // Handle non-immediate notifications + setAppState(prev => { + // Check if we can fold into an existing notification with the same key + if (notif.fold) { + // Fold into current notification if keys match + if (prev.notifications.current?.key === notif.key) { + const folded = notif.fold(prev.notifications.current, notif) + // Reset timeout for the folded notification + if (currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null } - }; - } + currentTimeoutId = setTimeout( + (setAppState, foldedKey, processQueue) => { + currentTimeoutId = null + setAppState(p => { + if (p.notifications.current?.key !== foldedKey) { + return p + } + return { + ...p, + notifications: { + queue: p.notifications.queue, + current: null, + }, + } + }) + processQueue() + }, + folded.timeoutMs ?? DEFAULT_TIMEOUT_MS, + setAppState, + folded.key, + processQueue, + ) - // Fold into queued notification if keys match - const queueIdx = prev.notifications.queue.findIndex(_ => _.key === notif.key); - if (queueIdx !== -1) { - const folded = notif.fold(prev.notifications.queue[queueIdx]!, notif); - const newQueue = [...prev.notifications.queue]; - newQueue[queueIdx] = folded; - return { - ...prev, - notifications: { - current: prev.notifications.current, - queue: newQueue + return { + ...prev, + notifications: { + current: folded, + queue: prev.notifications.queue, + }, } - }; + } + + // Fold into queued notification if keys match + const queueIdx = prev.notifications.queue.findIndex( + _ => _.key === notif.key, + ) + if (queueIdx !== -1) { + const folded = notif.fold( + prev.notifications.queue[queueIdx]!, + notif, + ) + const newQueue = [...prev.notifications.queue] + newQueue[queueIdx] = folded + return { + ...prev, + notifications: { + current: prev.notifications.current, + queue: newQueue, + }, + } + } } - } - // Only add to queue if not already present (prevent duplicates) - const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key)); - const shouldAdd = !queuedKeys.has(notif.key) && prev.notifications.current?.key !== notif.key; - if (!shouldAdd) return prev; - const invalidatesCurrent = prev.notifications.current !== null && notif.invalidates?.includes(prev.notifications.current.key); - if (invalidatesCurrent && currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; - } - return { - ...prev, - notifications: { - current: invalidatesCurrent ? null : prev.notifications.current, - queue: [...prev.notifications.queue.filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key)), notif] + // Only add to queue if not already present (prevent duplicates) + const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key)) + const shouldAdd = + !queuedKeys.has(notif.key) && + prev.notifications.current?.key !== notif.key + + if (!shouldAdd) return prev + + const invalidatesCurrent = + prev.notifications.current !== null && + notif.invalidates?.includes(prev.notifications.current.key) + + if (invalidatesCurrent && currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null } - }; - }); - // Process queue after adding the notification - processQueue(); - }, [setAppState, processQueue]); - const removeNotification = useCallback((key: string) => { - setAppState(prev => { - const isCurrent = prev.notifications.current?.key === key; - const inQueue = prev.notifications.queue.some(n => n.key === key); - if (!isCurrent && !inQueue) { - return prev; - } - if (isCurrent && currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; - } - return { - ...prev, - notifications: { - current: isCurrent ? null : prev.notifications.current, - queue: prev.notifications.queue.filter(n => n.key !== key) + return { + ...prev, + notifications: { + current: invalidatesCurrent ? null : prev.notifications.current, + queue: [ + ...prev.notifications.queue.filter( + _ => + _.priority !== 'immediate' && + !notif.invalidates?.includes(_.key), + ), + notif, + ], + }, + } + }) + + // Process queue after adding the notification + processQueue() + }, + [setAppState, processQueue], + ) + + const removeNotification = useCallback( + (key: string) => { + setAppState(prev => { + const isCurrent = prev.notifications.current?.key === key + const inQueue = prev.notifications.queue.some(n => n.key === key) + + if (!isCurrent && !inQueue) { + return prev + } + + if (isCurrent && currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null + } + + return { + ...prev, + notifications: { + current: isCurrent ? null : prev.notifications.current, + queue: prev.notifications.queue.filter(n => n.key !== key), + }, } - }; - }); - processQueue(); - }, [setAppState, processQueue]); + }) + + processQueue() + }, + [setAppState, processQueue], + ) // Process queue on mount if there are notifications in the initial state. // Imperative read (not useAppState) — a subscription in a mount-only @@ -219,21 +291,22 @@ export function useNotifications(): { // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref useEffect(() => { if (store.getState().notifications.queue.length > 0) { - processQueue(); + processQueue() } - }, []); - return { - addNotification, - removeNotification - }; + }, []) + + return { addNotification, removeNotification } } + const PRIORITIES: Record = { immediate: 0, high: 1, medium: 2, - low: 3 -}; + low: 3, +} export function getNext(queue: Notification[]): Notification | undefined { - if (queue.length === 0) return undefined; - return queue.reduce((min, n) => PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min); + if (queue.length === 0) return undefined + return queue.reduce((min, n) => + PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min, + ) } diff --git a/src/context/overlayContext.tsx b/src/context/overlayContext.tsx index 45da45425..602c1268d 100644 --- a/src/context/overlayContext.tsx +++ b/src/context/overlayContext.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Overlay tracking for Escape key coordination. * @@ -13,12 +12,12 @@ import { c as _c } from "react/compiler-runtime"; * The hook automatically registers on mount and unregisters on unmount, * so no manual cleanup or state management is needed. */ -import { useContext, useEffect, useLayoutEffect } from 'react'; -import instances from '../ink/instances.js'; -import { AppStoreContext, useAppState } from '../state/AppState.js'; +import { useContext, useEffect, useLayoutEffect } from 'react' +import instances from '../ink/instances.js' +import { AppStoreContext, useAppState } from '../state/AppState.js' // Non-modal overlays that shouldn't disable TextInput focus -const NON_MODAL_OVERLAYS = new Set(['autocomplete']); +const NON_MODAL_OVERLAYS = new Set(['autocomplete']) /** * Hook to register a component as an active overlay. @@ -35,72 +34,41 @@ const NON_MODAL_OVERLAYS = new Set(['autocomplete']); * // ... * } */ -export function useRegisterOverlay(id, t0) { - const $ = _c(8); - const enabled = t0 === undefined ? true : t0; - const store = useContext(AppStoreContext); - const setAppState = store?.setState; - let t1; - let t2; - if ($[0] !== enabled || $[1] !== id || $[2] !== setAppState) { - t1 = () => { - if (!enabled || !setAppState) { - return; - } +export function useRegisterOverlay(id: string, enabled = true): void { + // Use context directly so this is a no-op when rendered outside AppStateProvider + // (e.g., in isolated component tests that don't need the full app state tree). + const store = useContext(AppStoreContext) + const setAppState = store?.setState + useEffect(() => { + if (!enabled || !setAppState) return + setAppState(prev => { + if (prev.activeOverlays.has(id)) return prev + const next = new Set(prev.activeOverlays) + next.add(id) + return { ...prev, activeOverlays: next } + }) + return () => { setAppState(prev => { - if (prev.activeOverlays.has(id)) { - return prev; - } - const next = new Set(prev.activeOverlays); - next.add(id); - return { - ...prev, - activeOverlays: next - }; - }); - return () => { - setAppState(prev_0 => { - if (!prev_0.activeOverlays.has(id)) { - return prev_0; - } - const next_0 = new Set(prev_0.activeOverlays); - next_0.delete(id); - return { - ...prev_0, - activeOverlays: next_0 - }; - }); - }; - }; - t2 = [id, enabled, setAppState]; - $[0] = enabled; - $[1] = id; - $[2] = setAppState; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - useEffect(t1, t2); - let t3; - let t4; - if ($[5] !== enabled) { - t3 = () => { - if (!enabled) { - return; - } - return _temp; - }; - t4 = [enabled]; - $[5] = enabled; - $[6] = t3; - $[7] = t4; - } else { - t3 = $[6]; - t4 = $[7]; - } - useLayoutEffect(t3, t4); + if (!prev.activeOverlays.has(id)) return prev + const next = new Set(prev.activeOverlays) + next.delete(id) + return { ...prev, activeOverlays: next } + }) + } + }, [id, enabled, setAppState]) + + // On overlay close, force the next render to full-damage diff instead + // of blit. A tall overlay (e.g. FuzzyPicker with a 20-line preview) + // shrinks the Ink-managed region on unmount; the blit fast path can + // copy stale cells from the overlay's previous frame into rows the + // shorter layout no longer reaches, leaving a ghost title/divider. + // useLayoutEffect so cleanup runs synchronously before the microtask- + // deferred onRender (scheduleRender queues a microtask from + // resetAfterCommit; passive-effect cleanup would land after it). + useLayoutEffect(() => { + if (!enabled) return + return () => instances.get(process.stdout)?.invalidatePrevFrame() + }, [enabled]) } /** @@ -116,11 +84,8 @@ export function useRegisterOverlay(id, t0) { * useKeybinding('chat:cancel', handleCancel, { isActive }) * } */ -function _temp() { - return instances.get(process.stdout)?.invalidatePrevFrame(); -} -export function useIsOverlayActive() { - return useAppState(_temp2); +export function useIsOverlayActive(): boolean { + return useAppState(s => s.activeOverlays.size > 0) } /** @@ -134,17 +99,11 @@ export function useIsOverlayActive() { * // Use for TextInput focus - allows typing during autocomplete * focus: !isSearchingHistory && !isModalOverlayActive */ -function _temp2(s) { - return s.activeOverlays.size > 0; -} -export function useIsModalOverlayActive() { - return useAppState(_temp3); -} -function _temp3(s) { - for (const id of s.activeOverlays) { - if (!NON_MODAL_OVERLAYS.has(id)) { - return true; +export function useIsModalOverlayActive(): boolean { + return useAppState(s => { + for (const id of s.activeOverlays) { + if (!NON_MODAL_OVERLAYS.has(id)) return true } - } - return false; + return false + }) } diff --git a/src/context/promptOverlayContext.tsx b/src/context/promptOverlayContext.tsx index e68c17f73..87c97d559 100644 --- a/src/context/promptOverlayContext.tsx +++ b/src/context/promptOverlayContext.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Portal for content that floats above the prompt so it escapes * FullscreenLayout's bottom-slot `overflowY:hidden` clip. @@ -19,106 +18,78 @@ import { c as _c } from "react/compiler-runtime"; * Split into data/setter context pairs so writers never re-render on * their own writes — the setter contexts are stable. */ -import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react'; -import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js'; +import React, { + createContext, + type ReactNode, + useContext, + useEffect, + useState, +} from 'react' +import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js' + export type PromptOverlayData = { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - maxColumnWidth?: number; -}; -type Setter = (d: T | null) => void; -const DataContext = createContext(null); -const SetContext = createContext | null>(null); -const DialogContext = createContext(null); -const SetDialogContext = createContext | null>(null); -export function PromptOverlayProvider(t0) { - const $ = _c(6); - const { - children - } = t0; - const [data, setData] = useState(null); - const [dialog, setDialog] = useState(null); - let t1; - if ($[0] !== children || $[1] !== dialog) { - t1 = {children}; - $[0] = children; - $[1] = dialog; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== data || $[4] !== t1) { - t2 = {t1}; - $[3] = data; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; + suggestions: SuggestionItem[] + selectedSuggestion: number + maxColumnWidth?: number } -export function usePromptOverlay() { - return useContext(DataContext); + +type Setter = (d: T | null) => void + +const DataContext = createContext(null) +const SetContext = createContext | null>(null) +const DialogContext = createContext(null) +const SetDialogContext = createContext | null>(null) + +export function PromptOverlayProvider({ + children, +}: { + children: ReactNode +}): ReactNode { + const [data, setData] = useState(null) + const [dialog, setDialog] = useState(null) + return ( + + + + + {children} + + + + + ) } -export function usePromptOverlayDialog() { - return useContext(DialogContext); + +export function usePromptOverlay(): PromptOverlayData | null { + return useContext(DataContext) +} + +export function usePromptOverlayDialog(): ReactNode { + return useContext(DialogContext) } /** * Register suggestion data for the floating overlay. Clears on unmount. * No-op outside the provider (non-fullscreen renders inline instead). */ -export function useSetPromptOverlay(data) { - const $ = _c(4); - const set = useContext(SetContext); - let t0; - let t1; - if ($[0] !== data || $[1] !== set) { - t0 = () => { - if (!set) { - return; - } - set(data); - return () => set(null); - }; - t1 = [set, data]; - $[0] = data; - $[1] = set; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - useEffect(t0, t1); +export function useSetPromptOverlay(data: PromptOverlayData | null): void { + const set = useContext(SetContext) + useEffect(() => { + if (!set) return + set(data) + return () => set(null) + }, [set, data]) } /** * Register a dialog node to float above the prompt. Clears on unmount. * No-op outside the provider (non-fullscreen renders inline instead). */ -export function useSetPromptOverlayDialog(node) { - const $ = _c(4); - const set = useContext(SetDialogContext); - let t0; - let t1; - if ($[0] !== node || $[1] !== set) { - t0 = () => { - if (!set) { - return; - } - set(node); - return () => set(null); - }; - t1 = [set, node]; - $[0] = node; - $[1] = set; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - useEffect(t0, t1); +export function useSetPromptOverlayDialog(node: ReactNode): void { + const set = useContext(SetDialogContext) + useEffect(() => { + if (!set) return + set(node) + return () => set(null) + }, [set, node]) } diff --git a/src/context/stats.tsx b/src/context/stats.tsx index eec550768..14a21f171 100644 --- a/src/context/stats.tsx +++ b/src/context/stats.tsx @@ -1,219 +1,173 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; -import { saveCurrentProjectConfig } from '../utils/config.js'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, +} from 'react' +import { saveCurrentProjectConfig } from '../utils/config.js' + export type StatsStore = { - increment(name: string, value?: number): void; - set(name: string, value: number): void; - observe(name: string, value: number): void; - add(name: string, value: string): void; - getAll(): Record; -}; + increment(name: string, value?: number): void + set(name: string, value: number): void + observe(name: string, value: number): void + add(name: string, value: string): void + getAll(): Record +} + function percentile(sorted: number[], p: number): number { - const index = p / 100 * (sorted.length - 1); - const lower = Math.floor(index); - const upper = Math.ceil(index); + const index = (p / 100) * (sorted.length - 1) + const lower = Math.floor(index) + const upper = Math.ceil(index) if (lower === upper) { - return sorted[lower]!; + return sorted[lower]! } - return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower); + return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower) } -const RESERVOIR_SIZE = 1024; + +const RESERVOIR_SIZE = 1024 + type Histogram = { - reservoir: number[]; - count: number; - sum: number; - min: number; - max: number; -}; + reservoir: number[] + count: number + sum: number + min: number + max: number +} + export function createStatsStore(): StatsStore { - const metrics = new Map(); - const histograms = new Map(); - const sets = new Map>(); + const metrics = new Map() + const histograms = new Map() + const sets = new Map>() + return { increment(name: string, value = 1) { - metrics.set(name, (metrics.get(name) ?? 0) + value); + metrics.set(name, (metrics.get(name) ?? 0) + value) }, set(name: string, value: number) { - metrics.set(name, value); + metrics.set(name, value) }, observe(name: string, value: number) { - let h = histograms.get(name); + let h = histograms.get(name) if (!h) { - h = { - reservoir: [], - count: 0, - sum: 0, - min: value, - max: value - }; - histograms.set(name, h); + h = { reservoir: [], count: 0, sum: 0, min: value, max: value } + histograms.set(name, h) } - h.count++; - h.sum += value; + h.count++ + h.sum += value if (value < h.min) { - h.min = value; + h.min = value } if (value > h.max) { - h.max = value; + h.max = value } // Reservoir sampling (Algorithm R) if (h.reservoir.length < RESERVOIR_SIZE) { - h.reservoir.push(value); + h.reservoir.push(value) } else { - const j = Math.floor(Math.random() * h.count); + const j = Math.floor(Math.random() * h.count) if (j < RESERVOIR_SIZE) { - h.reservoir[j] = value; + h.reservoir[j] = value } } }, add(name: string, value: string) { - let s = sets.get(name); + let s = sets.get(name) if (!s) { - s = new Set(); - sets.set(name, s); + s = new Set() + sets.set(name, s) } - s.add(value); + s.add(value) }, getAll() { - const result: Record = Object.fromEntries(metrics); + const result: Record = Object.fromEntries(metrics) + for (const [name, h] of histograms) { if (h.count === 0) { - continue; + continue } - result[`${name}_count`] = h.count; - result[`${name}_min`] = h.min; - result[`${name}_max`] = h.max; - result[`${name}_avg`] = h.sum / h.count; - const sorted = [...h.reservoir].sort((a, b) => a - b); - result[`${name}_p50`] = percentile(sorted, 50); - result[`${name}_p95`] = percentile(sorted, 95); - result[`${name}_p99`] = percentile(sorted, 99); + result[`${name}_count`] = h.count + result[`${name}_min`] = h.min + result[`${name}_max`] = h.max + result[`${name}_avg`] = h.sum / h.count + const sorted = [...h.reservoir].sort((a, b) => a - b) + result[`${name}_p50`] = percentile(sorted, 50) + result[`${name}_p95`] = percentile(sorted, 95) + result[`${name}_p99`] = percentile(sorted, 99) } + for (const [name, s] of sets) { - result[name] = s.size; + result[name] = s.size } - return result; - } - }; + + return result + }, + } } -export const StatsContext = createContext(null); + +export const StatsContext = createContext(null) + type Props = { - store?: StatsStore; - children: React.ReactNode; -}; -export function StatsProvider(t0) { - const $ = _c(7); - const { - store: externalStore, - children - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = createStatsStore(); - $[0] = t1; - } else { - t1 = $[0]; - } - const internalStore = t1; - const store = externalStore ?? internalStore; - let t2; - let t3; - if ($[1] !== store) { - t2 = () => { - const flush = () => { - const metrics = store.getAll(); - if (Object.keys(metrics).length > 0) { - saveCurrentProjectConfig(current => ({ - ...current, - lastSessionMetrics: metrics - })); - } - }; - process.on("exit", flush); - return () => { - process.off("exit", flush); - }; - }; - t3 = [store]; - $[1] = store; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - useEffect(t2, t3); - let t4; - if ($[4] !== children || $[5] !== store) { - t4 = {children}; - $[4] = children; - $[5] = store; - $[6] = t4; - } else { - t4 = $[6]; - } - return t4; + store?: StatsStore + children: React.ReactNode } -export function useStats() { - const store = useContext(StatsContext); + +export function StatsProvider({ + store: externalStore, + children, +}: Props): React.ReactNode { + const internalStore = useMemo(() => createStatsStore(), []) + const store = externalStore ?? internalStore + + useEffect(() => { + const flush = () => { + const metrics = store.getAll() + if (Object.keys(metrics).length > 0) { + saveCurrentProjectConfig(current => ({ + ...current, + lastSessionMetrics: metrics, + })) + } + } + process.on('exit', flush) + return () => { + process.off('exit', flush) + } + }, [store]) + + return {children} +} + +export function useStats(): StatsStore { + const store = useContext(StatsContext) if (!store) { - throw new Error("useStats must be used within a StatsProvider"); + throw new Error('useStats must be used within a StatsProvider') } - return store; + return store } -export function useCounter(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.increment(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useCounter(name: string): (value?: number) => void { + const store = useStats() + return useCallback( + (value?: number) => store.increment(name, value), + [store, name], + ) } -export function useGauge(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.set(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useGauge(name: string): (value: number) => void { + const store = useStats() + return useCallback((value: number) => store.set(name, value), [store, name]) } -export function useTimer(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.observe(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useTimer(name: string): (value: number) => void { + const store = useStats() + return useCallback( + (value: number) => store.observe(name, value), + [store, name], + ) } -export function useSet(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.add(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useSet(name: string): (value: string) => void { + const store = useStats() + return useCallback((value: string) => store.add(name, value), [store, name]) } diff --git a/src/context/voice.tsx b/src/context/voice.tsx index 33c172c9a..3adcc4d27 100644 --- a/src/context/voice.tsx +++ b/src/context/voice.tsx @@ -1,71 +1,58 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useContext, useState, useSyncExternalStore } from 'react'; -import { createStore, type Store } from '../state/store.js'; +import React, { + createContext, + useContext, + useState, + useSyncExternalStore, +} from 'react' +import { createStore, type Store } from '../state/store.js' + export type VoiceState = { - voiceState: 'idle' | 'recording' | 'processing'; - voiceError: string | null; - voiceInterimTranscript: string; - voiceAudioLevels: number[]; - voiceWarmingUp: boolean; -}; + voiceState: 'idle' | 'recording' | 'processing' + voiceError: string | null + voiceInterimTranscript: string + voiceAudioLevels: number[] + voiceWarmingUp: boolean +} + const DEFAULT_STATE: VoiceState = { voiceState: 'idle', voiceError: null, voiceInterimTranscript: '', voiceAudioLevels: [], - voiceWarmingUp: false -}; -type VoiceStore = Store; -const VoiceContext = createContext(null); + voiceWarmingUp: false, +} + +type VoiceStore = Store + +const VoiceContext = createContext(null) + type Props = { - children: React.ReactNode; -}; -export function VoiceProvider(t0) { - const $ = _c(3); - const { - children - } = t0; - const [store] = useState(_temp); - let t1; - if ($[0] !== children || $[1] !== store) { - t1 = {children}; - $[0] = children; - $[1] = store; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + children: React.ReactNode } -function _temp() { - return createStore(DEFAULT_STATE); + +export function VoiceProvider({ children }: Props): React.ReactNode { + // Store is created once — stable context value means the provider never + // triggers re-renders. Consumers subscribe to slices via useVoiceState. + const [store] = useState(() => createStore(DEFAULT_STATE)) + return {children} } -function useVoiceStore() { - const store = useContext(VoiceContext); + +function useVoiceStore(): VoiceStore { + const store = useContext(VoiceContext) if (!store) { - throw new Error("useVoiceState must be used within a VoiceProvider"); + throw new Error('useVoiceState must be used within a VoiceProvider') } - return store; + return store } /** * Subscribe to a slice of voice state. Only re-renders when the selected * value changes (compared via Object.is). */ -export function useVoiceState(selector) { - const $ = _c(3); - const store = useVoiceStore(); - let t0; - if ($[0] !== selector || $[1] !== store) { - t0 = () => selector(store.getState()); - $[0] = selector; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - const get = t0; - return useSyncExternalStore(store.subscribe, get, get); +export function useVoiceState(selector: (state: VoiceState) => T): T { + const store = useVoiceStore() + const get = () => selector(store.getState()) + return useSyncExternalStore(store.subscribe, get, get) } /** @@ -73,8 +60,10 @@ export function useVoiceState(selector) { * store.setState is synchronous: callers can read getVoiceState() immediately * after to observe the new value (VoiceKeybindingHandler relies on this). */ -export function useSetVoiceState() { - return useVoiceStore().setState; +export function useSetVoiceState(): ( + updater: (prev: VoiceState) => VoiceState, +) => void { + return useVoiceStore().setState } /** @@ -82,6 +71,6 @@ export function useSetVoiceState() { * useVoiceState (which subscribes), this doesn't cause re-renders — use * inside event handlers that need to read state set earlier in the same tick. */ -export function useGetVoiceState() { - return useVoiceStore().getState; +export function useGetVoiceState(): () => VoiceState { + return useVoiceStore().getState } diff --git a/src/dialogLaunchers.tsx b/src/dialogLaunchers.tsx index 0c1befb7c..3d2e01e14 100644 --- a/src/dialogLaunchers.tsx +++ b/src/dialogLaunchers.tsx @@ -6,62 +6,91 @@ * Part of the main.tsx React/JSX extraction effort. See sibling PRs * perf/extract-interactive-helpers and perf/launch-repl. */ -import React from 'react'; -import type { AssistantSession } from './assistant/sessionDiscovery.js'; -import type { StatsStore } from './context/stats.js'; -import type { Root } from './ink.js'; -import { renderAndRun, showSetupDialog } from './interactiveHelpers.js'; -import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; -import type { AppState } from './state/AppStateStore.js'; -import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js'; -import type { TeleportRemoteResponse } from './utils/conversationRecovery.js'; -import type { FpsMetrics } from './utils/fpsTracker.js'; -import type { ValidationError } from './utils/settings/validation.js'; +import React from 'react' +import type { AssistantSession } from './assistant/sessionDiscovery.js' +import type { StatsStore } from './context/stats.js' +import type { Root } from './ink.js' +import { renderAndRun, showSetupDialog } from './interactiveHelpers.js' +import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js' +import type { AppState } from './state/AppStateStore.js' +import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js' +import type { TeleportRemoteResponse } from './utils/conversationRecovery.js' +import type { FpsMetrics } from './utils/fpsTracker.js' +import type { ValidationError } from './utils/settings/validation.js' // Type-only access to ResumeConversation's Props via the module type. // No runtime cost - erased at compile time. -type ResumeConversationProps = React.ComponentProps; +type ResumeConversationProps = React.ComponentProps< + typeof import('./screens/ResumeConversation.js').ResumeConversation +> /** * Site ~3173: SnapshotUpdateDialog (agent memory snapshot update prompt). * Original callback wiring: onComplete={done}, onCancel={() => done('keep')}. */ -export async function launchSnapshotUpdateDialog(root: Root, props: { - agentType: string; - scope: AgentMemoryScope; - snapshotTimestamp: string; -}): Promise<'merge' | 'keep' | 'replace'> { - const { - SnapshotUpdateDialog - } = await import('./components/agents/SnapshotUpdateDialog.js'); - return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => done('keep')} />); +export async function launchSnapshotUpdateDialog( + root: Root, + props: { + agentType: string + scope: AgentMemoryScope + snapshotTimestamp: string + }, +): Promise<'merge' | 'keep' | 'replace'> { + const { SnapshotUpdateDialog } = await import( + './components/agents/SnapshotUpdateDialog.js' + ) + return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => ( + done('keep')} + /> + )) } /** * Site ~3250: InvalidSettingsDialog (settings validation errors). * Original callback wiring: onContinue={done}, onExit passed through from caller. */ -export async function launchInvalidSettingsDialog(root: Root, props: { - settingsErrors: ValidationError[]; - onExit: () => void; -}): Promise { - const { - InvalidSettingsDialog - } = await import('./components/InvalidSettingsDialog.js'); - return showSetupDialog(root, done => ); +export async function launchInvalidSettingsDialog( + root: Root, + props: { + settingsErrors: ValidationError[] + onExit: () => void + }, +): Promise { + const { InvalidSettingsDialog } = await import( + './components/InvalidSettingsDialog.js' + ) + return showSetupDialog(root, done => ( + + )) } /** * Site ~4229: AssistantSessionChooser (pick a bridge session to attach to). * Original callback wiring: onSelect={id => done(id)}, onCancel={() => done(null)}. */ -export async function launchAssistantSessionChooser(root: Root, props: { - sessions: AssistantSession[]; -}): Promise { - const { - AssistantSessionChooser - } = await import('./assistant/AssistantSessionChooser.js'); - return showSetupDialog(root, done => done(id)} onCancel={() => done(null)} />); +export async function launchAssistantSessionChooser( + root: Root, + props: { sessions: AssistantSession[] }, +): Promise { + const { AssistantSessionChooser } = await import( + './assistant/AssistantSessionChooser.js' + ) + return showSetupDialog(root, done => ( + done(id)} + onCancel={() => done(null)} + /> + )) } /** @@ -70,43 +99,71 @@ export async function launchAssistantSessionChooser(root: Root, props: { * success, null on cancel. Rejects on install failure so the caller can * distinguish errors from user cancellation. */ -export async function launchAssistantInstallWizard(root: Root): Promise { - const { - NewInstallWizard, - computeDefaultInstallDir - } = await import('./commands/assistant/assistant.js'); - const defaultDir = await computeDefaultInstallDir(); - let rejectWithError: (reason: Error) => void; +export async function launchAssistantInstallWizard( + root: Root, +): Promise { + const { NewInstallWizard, computeDefaultInstallDir } = await import( + './commands/assistant/assistant.js' + ) + const defaultDir = await computeDefaultInstallDir() + let rejectWithError: (reason: Error) => void const errorPromise = new Promise((_, reject) => { - rejectWithError = reject; - }); - const resultPromise = showSetupDialog(root, done => done(dir)} onCancel={() => done(null)} onError={message => rejectWithError(new Error(`Installation failed: ${message}`))} />); - return Promise.race([resultPromise, errorPromise]); + rejectWithError = reject + }) + const resultPromise = showSetupDialog(root, done => ( + done(dir)} + onCancel={() => done(null)} + onError={message => + rejectWithError(new Error(`Installation failed: ${message}`)) + } + /> + )) + return Promise.race([resultPromise, errorPromise]) } /** * Site ~4549: TeleportResumeWrapper (interactive teleport session picker). * Original callback wiring: onComplete={done}, onCancel={() => done(null)}, source="cliArg". */ -export async function launchTeleportResumeWrapper(root: Root): Promise { - const { - TeleportResumeWrapper - } = await import('./components/TeleportResumeWrapper.js'); - return showSetupDialog(root, done => done(null)} source="cliArg" />); +export async function launchTeleportResumeWrapper( + root: Root, +): Promise { + const { TeleportResumeWrapper } = await import( + './components/TeleportResumeWrapper.js' + ) + return showSetupDialog(root, done => ( + done(null)} + source="cliArg" + /> + )) } /** * Site ~4597: TeleportRepoMismatchDialog (pick a local checkout of the target repo). * Original callback wiring: onSelectPath={done}, onCancel={() => done(null)}. */ -export async function launchTeleportRepoMismatchDialog(root: Root, props: { - targetRepo: string; - initialPaths: string[]; -}): Promise { - const { - TeleportRepoMismatchDialog - } = await import('./components/TeleportRepoMismatchDialog.js'); - return showSetupDialog(root, done => done(null)} />); +export async function launchTeleportRepoMismatchDialog( + root: Root, + props: { + targetRepo: string + initialPaths: string[] + }, +): Promise { + const { TeleportRepoMismatchDialog } = await import( + './components/TeleportRepoMismatchDialog.js' + ) + return showSetupDialog(root, done => ( + done(null)} + /> + )) } /** @@ -114,19 +171,31 @@ export async function launchTeleportRepoMismatchDialog(root: Root, props: { * Uses renderAndRun, NOT showSetupDialog. Wraps in . * Preserves original Promise.all parallelism between getWorktreePaths and imports. */ -export async function launchResumeChooser(root: Root, appProps: { - getFpsMetrics: () => FpsMetrics | undefined; - stats: StatsStore; - initialState: AppState; -}, worktreePathsPromise: Promise, resumeProps: Omit): Promise { - const [worktreePaths, { - ResumeConversation - }, { - App - }] = await Promise.all([worktreePathsPromise, import('./screens/ResumeConversation.js'), import('./components/App.js')]); - await renderAndRun(root, +export async function launchResumeChooser( + root: Root, + appProps: { + getFpsMetrics: () => FpsMetrics | undefined + stats: StatsStore + initialState: AppState + }, + worktreePathsPromise: Promise, + resumeProps: Omit, +): Promise { + const [worktreePaths, { ResumeConversation }, { App }] = await Promise.all([ + worktreePathsPromise, + import('./screens/ResumeConversation.js'), + import('./components/App.js'), + ]) + await renderAndRun( + root, + - ); + , + ) } diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index fa377cbc3..a1f173e6c 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,17 +1,19 @@ #!/usr/bin/env bun -import { feature } from 'bun:bundle'; +import { feature } from 'bun:bundle' // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons // eslint-disable-next-line custom-rules/no-top-level-side-effects -process.env.COREPACK_ENABLE_AUTO_PIN = '0'; +process.env.COREPACK_ENABLE_AUTO_PIN = '0' // Set max heap size for child processes in CCR environments (containers have 16GB) // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check if (process.env.CLAUDE_CODE_REMOTE === 'true') { // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level - const existing = process.env.NODE_OPTIONS || ''; + const existing = process.env.NODE_OPTIONS || '' // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level - process.env.NODE_OPTIONS = existing ? `${existing} --max-old-space-size=8192` : '--max-old-space-size=8192'; + process.env.NODE_OPTIONS = existing + ? `${existing} --max-old-space-size=8192` + : '--max-old-space-size=8192' } // Harness-science L0 ablation baseline. Inlined here (not init.ts) because @@ -30,7 +32,7 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) { 'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS', ]) { // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level - process.env[k] ??= '1'; + process.env[k] ??= '1' } } @@ -40,51 +42,64 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) { * Fast-path for --version has zero imports beyond this file. */ async function main(): Promise { - const args = process.argv.slice(2); + const args = process.argv.slice(2) // Fast-path for --version/-v: zero module loading needed - if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) { + if ( + args.length === 1 && + (args[0] === '--version' || args[0] === '-v' || args[0] === '-V') + ) { // MACRO.VERSION is inlined at build time // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${MACRO.VERSION} (Claude Code)`); - return; + console.log(`${MACRO.VERSION} (Claude Code)`) + return } // For all other paths, load the startup profiler - const { profileCheckpoint } = await import('../utils/startupProfiler.js'); - profileCheckpoint('cli_entry'); + const { profileCheckpoint } = await import('../utils/startupProfiler.js') + profileCheckpoint('cli_entry') // Fast-path for --dump-system-prompt: output the rendered system prompt and exit. // Used by prompt sensitivity evals to extract the system prompt at a specific commit. // Ant-only: eliminated from external builds via feature flag. if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') { - profileCheckpoint('cli_dump_system_prompt_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { getMainLoopModel } = await import('../utils/model/model.js'); - const modelIdx = args.indexOf('--model'); - const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel(); - const { getSystemPrompt } = await import('../constants/prompts.js'); - const prompt = await getSystemPrompt([], model); + profileCheckpoint('cli_dump_system_prompt_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const { getMainLoopModel } = await import('../utils/model/model.js') + const modelIdx = args.indexOf('--model') + const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel() + const { getSystemPrompt } = await import('../constants/prompts.js') + const prompt = await getSystemPrompt([], model) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(prompt.join('\n')); - return; + console.log(prompt.join('\n')) + return } + if (process.argv[2] === '--claude-in-chrome-mcp') { - profileCheckpoint('cli_claude_in_chrome_mcp_path'); - const { runClaudeInChromeMcpServer } = await import('../utils/claudeInChrome/mcpServer.js'); - await runClaudeInChromeMcpServer(); - return; + profileCheckpoint('cli_claude_in_chrome_mcp_path') + const { runClaudeInChromeMcpServer } = await import( + '../utils/claudeInChrome/mcpServer.js' + ) + await runClaudeInChromeMcpServer() + return } else if (process.argv[2] === '--chrome-native-host') { - profileCheckpoint('cli_chrome_native_host_path'); - const { runChromeNativeHost } = await import('../utils/claudeInChrome/chromeNativeHost.js'); - await runChromeNativeHost(); - return; - } else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') { - profileCheckpoint('cli_computer_use_mcp_path'); - const { runComputerUseMcpServer } = await import('../utils/computerUse/mcpServer.js'); - await runComputerUseMcpServer(); - return; + profileCheckpoint('cli_chrome_native_host_path') + const { runChromeNativeHost } = await import( + '../utils/claudeInChrome/chromeNativeHost.js' + ) + await runChromeNativeHost() + return + } else if ( + feature('CHICAGO_MCP') && + process.argv[2] === '--computer-use-mcp' + ) { + profileCheckpoint('cli_computer_use_mcp_path') + const { runComputerUseMcpServer } = await import( + '../utils/computerUse/mcpServer.js' + ) + await runComputerUseMcpServer() + return } // Fast-path for `--daemon-worker=` (internal — supervisor spawns this). @@ -93,9 +108,9 @@ async function main(): Promise { // workers are lean. If a worker kind needs configs/auth (assistant will), // it calls them inside its run() fn. if (feature('DAEMON') && args[0] === '--daemon-worker') { - const { runDaemonWorker } = await import('../daemon/workerRegistry.js'); - await runDaemonWorker(args[1]); - return; + const { runDaemonWorker } = await import('../daemon/workerRegistry.js') + await runDaemonWorker(args[1]) + return } // Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`): @@ -110,51 +125,59 @@ async function main(): Promise { args[0] === 'sync' || args[0] === 'bridge') ) { - profileCheckpoint('cli_bridge_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { getBridgeDisabledReason, checkBridgeMinVersion } = await import('../bridge/bridgeEnabled.js'); - const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js'); - const { bridgeMain } = await import('../bridge/bridgeMain.js'); - const { exitWithError } = await import('../utils/process.js'); + profileCheckpoint('cli_bridge_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + + const { getBridgeDisabledReason, checkBridgeMinVersion } = await import( + '../bridge/bridgeEnabled.js' + ) + const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js') + const { bridgeMain } = await import('../bridge/bridgeMain.js') + const { exitWithError } = await import('../utils/process.js') // Auth check must come before the GrowthBook gate check — without auth, // GrowthBook has no user context and would return a stale/default false. // getBridgeDisabledReason awaits GB init, so the returned value is fresh // (not the stale disk cache), but init still needs auth headers to work. - const { getClaudeAIOAuthTokens } = await import('../utils/auth.js'); + const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') if (!getClaudeAIOAuthTokens()?.accessToken) { - exitWithError(BRIDGE_LOGIN_ERROR); + exitWithError(BRIDGE_LOGIN_ERROR) } - const disabledReason = await getBridgeDisabledReason(); + const disabledReason = await getBridgeDisabledReason() if (disabledReason) { - exitWithError(`Error: ${disabledReason}`); + exitWithError(`Error: ${disabledReason}`) } - const versionError = checkBridgeMinVersion(); + const versionError = checkBridgeMinVersion() if (versionError) { - exitWithError(versionError); + exitWithError(versionError) } // Bridge is a remote control feature - check policy limits - const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../services/policyLimits/index.js'); - await waitForPolicyLimitsToLoad(); + const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import( + '../services/policyLimits/index.js' + ) + await waitForPolicyLimitsToLoad() if (!isPolicyAllowed('allow_remote_control')) { - exitWithError("Error: Remote Control is disabled by your organization's policy."); + exitWithError( + "Error: Remote Control is disabled by your organization's policy.", + ) } - await bridgeMain(args.slice(1)); - return; + + await bridgeMain(args.slice(1)) + return } // Fast-path for `claude daemon [subcommand]`: long-running supervisor. if (feature('DAEMON') && args[0] === 'daemon') { - profileCheckpoint('cli_daemon_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { initSinks } = await import('../utils/sinks.js'); - initSinks(); - const { daemonMain } = await import('../daemon/main.js'); - await daemonMain(args.slice(1)); - return; + profileCheckpoint('cli_daemon_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const { initSinks } = await import('../utils/sinks.js') + initSinks() + const { daemonMain } = await import('../daemon/main.js') + await daemonMain(args.slice(1)) + return } // Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`. @@ -169,103 +192,117 @@ async function main(): Promise { args.includes('--bg') || args.includes('--background')) ) { - profileCheckpoint('cli_bg_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const bg = await import('../cli/bg.js'); + profileCheckpoint('cli_bg_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const bg = await import('../cli/bg.js') switch (args[0]) { case 'ps': - await bg.psHandler(args.slice(1)); - break; + await bg.psHandler(args.slice(1)) + break case 'logs': - await bg.logsHandler(args[1]); - break; + await bg.logsHandler(args[1]) + break case 'attach': - await bg.attachHandler(args[1]); - break; + await bg.attachHandler(args[1]) + break case 'kill': - await bg.killHandler(args[1]); - break; + await bg.killHandler(args[1]) + break default: - await bg.handleBgFlag(args); + await bg.handleBgFlag(args) } - return; + return } // Fast-path for template job commands. - if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) { - profileCheckpoint('cli_templates_path'); - const { templatesMain } = await import('../cli/handlers/templateJobs.js'); - await templatesMain(args); + if ( + feature('TEMPLATES') && + (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply') + ) { + profileCheckpoint('cli_templates_path') + const { templatesMain } = await import('../cli/handlers/templateJobs.js') + await templatesMain(args) // process.exit (not return) — mountFleetView's Ink TUI can leave event // loop handles that prevent natural exit. // eslint-disable-next-line custom-rules/no-process-exit - process.exit(0); + process.exit(0) } // Fast-path for `claude environment-runner`: headless BYOC runner. // feature() must stay inline for build-time dead code elimination. if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') { - profileCheckpoint('cli_environment_runner_path'); - const { environmentRunnerMain } = await import('../environment-runner/main.js'); - await environmentRunnerMain(args.slice(1)); - return; + profileCheckpoint('cli_environment_runner_path') + const { environmentRunnerMain } = await import( + '../environment-runner/main.js' + ) + await environmentRunnerMain(args.slice(1)) + return } // Fast-path for `claude self-hosted-runner`: headless self-hosted-runner // targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS // heartbeat). feature() must stay inline for build-time dead code elimination. if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') { - profileCheckpoint('cli_self_hosted_runner_path'); - const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js'); - await selfHostedRunnerMain(args.slice(1)); - return; + profileCheckpoint('cli_self_hosted_runner_path') + const { selfHostedRunnerMain } = await import( + '../self-hosted-runner/main.js' + ) + await selfHostedRunnerMain(args.slice(1)) + return } // Fast-path for --worktree --tmux: exec into tmux before loading full CLI - const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic'); + const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic') if ( hasTmuxFlag && - (args.includes('-w') || args.includes('--worktree') || args.some(a => a.startsWith('--worktree='))) + (args.includes('-w') || + args.includes('--worktree') || + args.some(a => a.startsWith('--worktree='))) ) { - profileCheckpoint('cli_tmux_worktree_fast_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { isWorktreeModeEnabled } = await import('../utils/worktreeModeEnabled.js'); + profileCheckpoint('cli_tmux_worktree_fast_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const { isWorktreeModeEnabled } = await import( + '../utils/worktreeModeEnabled.js' + ) if (isWorktreeModeEnabled()) { - const { execIntoTmuxWorktree } = await import('../utils/worktree.js'); - const result = await execIntoTmuxWorktree(args); + const { execIntoTmuxWorktree } = await import('../utils/worktree.js') + const result = await execIntoTmuxWorktree(args) if (result.handled) { - return; + return } // If not handled (e.g., error), fall through to normal CLI if (result.error) { - const { exitWithError } = await import('../utils/process.js'); - exitWithError(result.error); + const { exitWithError } = await import('../utils/process.js') + exitWithError(result.error) } } } // Redirect common update flag mistakes to the update subcommand - if (args.length === 1 && (args[0] === '--update' || args[0] === '--upgrade')) { - process.argv = [process.argv[0]!, process.argv[1]!, 'update']; + if ( + args.length === 1 && + (args[0] === '--update' || args[0] === '--upgrade') + ) { + process.argv = [process.argv[0]!, process.argv[1]!, 'update'] } // --bare: set SIMPLE early so gates fire during module eval / commander // option building (not just inside the action handler). if (args.includes('--bare')) { - process.env.CLAUDE_CODE_SIMPLE = '1'; + process.env.CLAUDE_CODE_SIMPLE = '1' } // No special flags detected, load and run the full CLI - const { startCapturingEarlyInput } = await import('../utils/earlyInput.js'); - startCapturingEarlyInput(); - profileCheckpoint('cli_before_main_import'); - const { main: cliMain } = await import('../main.jsx'); - profileCheckpoint('cli_after_main_import'); - await cliMain(); - profileCheckpoint('cli_after_main_complete'); + const { startCapturingEarlyInput } = await import('../utils/earlyInput.js') + startCapturingEarlyInput() + profileCheckpoint('cli_before_main_import') + const { main: cliMain } = await import('../main.jsx') + profileCheckpoint('cli_after_main_import') + await cliMain() + profileCheckpoint('cli_after_main_complete') } // eslint-disable-next-line custom-rules/no-top-level-side-effects -void main(); +void main() diff --git a/src/hooks/notifs/useCanSwitchToExistingSubscription.tsx b/src/hooks/notifs/useCanSwitchToExistingSubscription.tsx index 4fc3c5d9e..0d70d5a2b 100644 --- a/src/hooks/notifs/useCanSwitchToExistingSubscription.tsx +++ b/src/hooks/notifs/useCanSwitchToExistingSubscription.tsx @@ -1,59 +1,67 @@ -import * as React from 'react'; -import { getOauthProfileFromApiKey } from 'src/services/oauth/getOauthProfile.js'; -import { isClaudeAISubscriber } from 'src/utils/auth.js'; -import { Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { useStartupNotification } from './useStartupNotification.js'; -const MAX_SHOW_COUNT = 3; +import * as React from 'react' +import { getOauthProfileFromApiKey } from 'src/services/oauth/getOauthProfile.js' +import { isClaudeAISubscriber } from 'src/utils/auth.js' +import { Text } from '../../ink.js' +import { logEvent } from '../../services/analytics/index.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { useStartupNotification } from './useStartupNotification.js' + +const MAX_SHOW_COUNT = 3 /** * Hook to check if the user has a subscription on Console but isn't logged into it. */ -export function useCanSwitchToExistingSubscription() { - useStartupNotification(_temp2); +export function useCanSwitchToExistingSubscription(): void { + useStartupNotification(async () => { + if ((getGlobalConfig().subscriptionNoticeCount ?? 0) >= MAX_SHOW_COUNT) { + return null + } + const subscriptionType = await getExistingClaudeSubscription() + if (subscriptionType === null) return null + + saveGlobalConfig(current => ({ + ...current, + subscriptionNoticeCount: (current.subscriptionNoticeCount ?? 0) + 1, + })) + logEvent('tengu_switch_to_subscription_notice_shown', {}) + + return { + key: 'switch-to-subscription', + jsx: ( + + Use your existing Claude {subscriptionType} plan with Claude Code + + {' '} + · /login to activate + + + ), + priority: 'low', + } + }) } /** * Checks if the user has a subscription but is not currently logged into it. * This helps inform users they should run /login to access their subscription. */ -async function _temp2() { - if ((getGlobalConfig().subscriptionNoticeCount ?? 0) >= MAX_SHOW_COUNT) { - return null; - } - const subscriptionType = await getExistingClaudeSubscription(); - if (subscriptionType === null) { - return null; - } - saveGlobalConfig(_temp); - logEvent("tengu_switch_to_subscription_notice_shown", {}); - return { - key: "switch-to-subscription", - jsx: Use your existing Claude {subscriptionType} plan with Claude Code{" "}· /login to activate, - priority: "low" - }; -} -function _temp(current) { - return { - ...current, - subscriptionNoticeCount: (current.subscriptionNoticeCount ?? 0) + 1 - }; -} async function getExistingClaudeSubscription(): Promise<'Max' | 'Pro' | null> { // If already using subscription auth, there is nothing to switch to if (isClaudeAISubscriber()) { - return null; + return null } - const profile = await getOauthProfileFromApiKey(); + const profile = await getOauthProfileFromApiKey() if (!profile) { - return null; + return null } + if (profile.account.has_claude_max) { - return 'Max'; + return 'Max' } + if (profile.account.has_claude_pro) { - return 'Pro'; + return 'Pro' } - return null; + + return null } diff --git a/src/hooks/notifs/useDeprecationWarningNotification.tsx b/src/hooks/notifs/useDeprecationWarningNotification.tsx index c9699554f..7c40ffc47 100644 --- a/src/hooks/notifs/useDeprecationWarningNotification.tsx +++ b/src/hooks/notifs/useDeprecationWarningNotification.tsx @@ -1,43 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import { useEffect, useRef } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { getModelDeprecationWarning } from 'src/utils/model/deprecation.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -export function useDeprecationWarningNotification(model) { - const $ = _c(4); - const { - addNotification - } = useNotifications(); - const lastWarningRef = useRef(null); - let t0; - let t1; - if ($[0] !== addNotification || $[1] !== model) { - t0 = () => { - if (getIsRemoteMode()) { - return; - } - const deprecationWarning = getModelDeprecationWarning(model); - if (deprecationWarning && deprecationWarning !== lastWarningRef.current) { - lastWarningRef.current = deprecationWarning; - addNotification({ - key: "model-deprecation-warning", - text: deprecationWarning, - color: "warning", - priority: "high" - }); - } - if (!deprecationWarning) { - lastWarningRef.current = null; - } - }; - t1 = [model, addNotification]; - $[0] = addNotification; - $[1] = model; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - useEffect(t0, t1); +import { useEffect, useRef } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { getModelDeprecationWarning } from 'src/utils/model/deprecation.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' + +export function useDeprecationWarningNotification(model: string): void { + const { addNotification } = useNotifications() + const lastWarningRef = useRef(null) + + useEffect(() => { + if (getIsRemoteMode()) return + const deprecationWarning = getModelDeprecationWarning(model) + + // Show warning if model is deprecated and we haven't shown this exact warning yet + if (deprecationWarning && deprecationWarning !== lastWarningRef.current) { + lastWarningRef.current = deprecationWarning + addNotification({ + key: 'model-deprecation-warning', + text: deprecationWarning, + color: 'warning', + priority: 'high', + }) + } + + // Reset tracking if model changes to non-deprecated + if (!deprecationWarning) { + lastWarningRef.current = null + } + }, [model, addNotification]) } diff --git a/src/hooks/notifs/useFastModeNotification.tsx b/src/hooks/notifs/useFastModeNotification.tsx index b112b9544..fd8728954 100644 --- a/src/hooks/notifs/useFastModeNotification.tsx +++ b/src/hooks/notifs/useFastModeNotification.tsx @@ -1,161 +1,111 @@ -import { c as _c } from "react/compiler-runtime"; -import { useEffect } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { type CooldownReason, isFastModeEnabled, onCooldownExpired, onCooldownTriggered, onFastModeOverageRejection, onOrgFastModeChanged } from 'src/utils/fastMode.js'; -import { formatDuration } from 'src/utils/format.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -const COOLDOWN_STARTED_KEY = 'fast-mode-cooldown-started'; -const COOLDOWN_EXPIRED_KEY = 'fast-mode-cooldown-expired'; -const ORG_CHANGED_KEY = 'fast-mode-org-changed'; -const OVERAGE_REJECTED_KEY = 'fast-mode-overage-rejected'; -export function useFastModeNotification() { - const $ = _c(13); - const { - addNotification - } = useNotifications(); - const isFastMode = useAppState(_temp); - const setAppState = useSetAppState(); - let t0; - let t1; - if ($[0] !== addNotification || $[1] !== isFastMode || $[2] !== setAppState) { - t0 = () => { - if (getIsRemoteMode()) { - return; - } - if (!isFastModeEnabled()) { - return; - } - return onOrgFastModeChanged(orgEnabled => { - if (orgEnabled) { - addNotification({ - key: ORG_CHANGED_KEY, - color: "fastMode", - priority: "immediate", - text: "Fast mode is now available \xB7 /fast to turn on" - }); - } else { - if (isFastMode) { - setAppState(_temp2); - addNotification({ - key: ORG_CHANGED_KEY, - color: "warning", - priority: "immediate", - text: "Fast mode has been disabled by your organization" - }); - } - } - }); - }; - t1 = [addNotification, isFastMode, setAppState]; - $[0] = addNotification; - $[1] = isFastMode; - $[2] = setAppState; - $[3] = t0; - $[4] = t1; - } else { - t0 = $[3]; - t1 = $[4]; - } - useEffect(t0, t1); - let t2; - let t3; - if ($[5] !== addNotification || $[6] !== setAppState) { - t2 = () => { - if (getIsRemoteMode()) { - return; - } - if (!isFastModeEnabled()) { - return; - } - return onFastModeOverageRejection(message => { - setAppState(_temp3); - addNotification({ - key: OVERAGE_REJECTED_KEY, - color: "warning", - priority: "immediate", - text: message - }); - }); - }; - t3 = [addNotification, setAppState]; - $[5] = addNotification; - $[6] = setAppState; - $[7] = t2; - $[8] = t3; - } else { - t2 = $[7]; - t3 = $[8]; - } - useEffect(t2, t3); - let t4; - let t5; - if ($[9] !== addNotification || $[10] !== isFastMode) { - t4 = () => { - if (getIsRemoteMode()) { - return; - } - if (!isFastMode) { - return; - } - const unsubTriggered = onCooldownTriggered((resetAt, reason) => { - const resetIn = formatDuration(resetAt - Date.now(), { - hideTrailingZeros: true - }); - const message_0 = getCooldownMessage(reason, resetIn); +import { useEffect } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + type CooldownReason, + isFastModeEnabled, + onCooldownExpired, + onCooldownTriggered, + onFastModeOverageRejection, + onOrgFastModeChanged, +} from 'src/utils/fastMode.js' +import { formatDuration } from 'src/utils/format.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' + +const COOLDOWN_STARTED_KEY = 'fast-mode-cooldown-started' +const COOLDOWN_EXPIRED_KEY = 'fast-mode-cooldown-expired' +const ORG_CHANGED_KEY = 'fast-mode-org-changed' +const OVERAGE_REJECTED_KEY = 'fast-mode-overage-rejected' + +export function useFastModeNotification(): void { + const { addNotification } = useNotifications() + const isFastMode = useAppState(s => s.fastMode) + const setAppState = useSetAppState() + + // Notify when org fast mode status changes + useEffect(() => { + if (getIsRemoteMode()) return + if (!isFastModeEnabled()) { + return + } + + return onOrgFastModeChanged(orgEnabled => { + if (orgEnabled) { addNotification({ - key: COOLDOWN_STARTED_KEY, - invalidates: [COOLDOWN_EXPIRED_KEY], - text: message_0, - color: "warning", - priority: "immediate" - }); - }); - const unsubExpired = onCooldownExpired(() => { + key: ORG_CHANGED_KEY, + color: 'fastMode', + priority: 'immediate', + text: 'Fast mode is now available · /fast to turn on', + }) + } else if (isFastMode) { + // Org disabled fast mode — permanently turn off fast mode + setAppState(prev => ({ ...prev, fastMode: false })) addNotification({ - key: COOLDOWN_EXPIRED_KEY, - invalidates: [COOLDOWN_STARTED_KEY], - color: "fastMode", - text: "Fast limit reset \xB7 now using fast mode", - priority: "immediate" - }); - }); - return () => { - unsubTriggered(); - unsubExpired(); - }; - }; - t5 = [addNotification, isFastMode]; - $[9] = addNotification; - $[10] = isFastMode; - $[11] = t4; - $[12] = t5; - } else { - t4 = $[11]; - t5 = $[12]; - } - useEffect(t4, t5); -} -function _temp3(prev_0) { - return { - ...prev_0, - fastMode: false - }; -} -function _temp2(prev) { - return { - ...prev, - fastMode: false - }; -} -function _temp(s) { - return s.fastMode; + key: ORG_CHANGED_KEY, + color: 'warning', + priority: 'immediate', + text: 'Fast mode has been disabled by your organization', + }) + } + }) + }, [addNotification, isFastMode, setAppState]) + + // Notify when fast mode is rejected due to overage/extra usage issues + useEffect(() => { + if (getIsRemoteMode()) return + if (!isFastModeEnabled()) return + + return onFastModeOverageRejection(message => { + setAppState(prev => ({ ...prev, fastMode: false })) + addNotification({ + key: OVERAGE_REJECTED_KEY, + color: 'warning', + priority: 'immediate', + text: message, + }) + }) + }, [addNotification, setAppState]) + + useEffect(() => { + if (getIsRemoteMode()) return + if (!isFastMode) { + return + } + + const unsubTriggered = onCooldownTriggered((resetAt, reason) => { + const resetIn = formatDuration(resetAt - Date.now(), { + hideTrailingZeros: true, + }) + const message = getCooldownMessage(reason, resetIn) + addNotification({ + key: COOLDOWN_STARTED_KEY, + invalidates: [COOLDOWN_EXPIRED_KEY], + text: message, + color: 'warning', + priority: 'immediate', + }) + }) + const unsubExpired = onCooldownExpired(() => { + addNotification({ + key: COOLDOWN_EXPIRED_KEY, + invalidates: [COOLDOWN_STARTED_KEY], + color: 'fastMode', + text: `Fast limit reset · now using fast mode`, + priority: 'immediate', + }) + }) + return () => { + unsubTriggered() + unsubExpired() + } + }, [addNotification, isFastMode]) } + function getCooldownMessage(reason: CooldownReason, resetIn: string): string { switch (reason) { case 'overloaded': - return `Fast mode overloaded and is temporarily unavailable · resets in ${resetIn}`; + return `Fast mode overloaded and is temporarily unavailable · resets in ${resetIn}` case 'rate_limit': - return `Fast limit reached and temporarily disabled · resets in ${resetIn}`; + return `Fast limit reached and temporarily disabled · resets in ${resetIn}` } } diff --git a/src/hooks/notifs/useIDEStatusIndicator.tsx b/src/hooks/notifs/useIDEStatusIndicator.tsx index 213555180..4be07f551 100644 --- a/src/hooks/notifs/useIDEStatusIndicator.tsx +++ b/src/hooks/notifs/useIDEStatusIndicator.tsx @@ -1,185 +1,159 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useRef } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { Text } from 'src/ink.js'; -import type { MCPServerConnection } from 'src/services/mcp/types.js'; -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; -import { detectIDEs, type IDEExtensionInstallationStatus, isJetBrainsIde, isSupportedTerminal } from 'src/utils/ide.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -import { useIdeConnectionStatus } from '../useIdeConnectionStatus.js'; -import type { IDESelection } from '../useIdeSelection.js'; -const MAX_IDE_HINT_SHOW_COUNT = 5; +import React, { useEffect, useRef } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { Text } from 'src/ink.js' +import type { MCPServerConnection } from 'src/services/mcp/types.js' +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' +import { + detectIDEs, + type IDEExtensionInstallationStatus, + isJetBrainsIde, + isSupportedTerminal, +} from 'src/utils/ide.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' +import { useIdeConnectionStatus } from '../useIdeConnectionStatus.js' +import type { IDESelection } from '../useIdeSelection.js' + +const MAX_IDE_HINT_SHOW_COUNT = 5 + type Props = { - ideInstallationStatus: IDEExtensionInstallationStatus | null; - ideSelection: IDESelection | undefined; - mcpClients: MCPServerConnection[]; -}; -export function useIDEStatusIndicator(t0) { - const $ = _c(26); - const { - ideSelection, - mcpClients, - ideInstallationStatus - } = t0; - const { - addNotification, - removeNotification - } = useNotifications(); - const { - status: ideStatus, - ideName - } = useIdeConnectionStatus(mcpClients); - const hasShownHintRef = useRef(false); - let t1; - if ($[0] !== ideInstallationStatus) { - t1 = ideInstallationStatus ? isJetBrainsIde(ideInstallationStatus?.ideType) : false; - $[0] = ideInstallationStatus; - $[1] = t1; - } else { - t1 = $[1]; - } - const isJetBrains = t1; - const showIDEInstallErrorOrJetBrainsInfo = ideInstallationStatus?.error || isJetBrains; - const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0); - const shouldShowConnected = ideStatus === "connected" && !shouldShowIdeSelection; - const showIDEInstallError = showIDEInstallErrorOrJetBrainsInfo && !isJetBrains && !shouldShowConnected && !shouldShowIdeSelection; - const showJetBrainsInfo = showIDEInstallErrorOrJetBrainsInfo && isJetBrains && !shouldShowConnected && !shouldShowIdeSelection; - let t2; - let t3; - if ($[2] !== addNotification || $[3] !== ideStatus || $[4] !== removeNotification || $[5] !== showJetBrainsInfo) { - t2 = () => { - if (getIsRemoteMode()) { - return; - } - if (isSupportedTerminal() || ideStatus !== null || showJetBrainsInfo) { - removeNotification("ide-status-hint"); - return; - } - if (hasShownHintRef.current || (getGlobalConfig().ideHintShownCount ?? 0) >= MAX_IDE_HINT_SHOW_COUNT) { - return; - } - const timeoutId = setTimeout(_temp2, 3000, hasShownHintRef, addNotification); - return () => clearTimeout(timeoutId); - }; - t3 = [addNotification, removeNotification, ideStatus, showJetBrainsInfo]; - $[2] = addNotification; - $[3] = ideStatus; - $[4] = removeNotification; - $[5] = showJetBrainsInfo; - $[6] = t2; - $[7] = t3; - } else { - t2 = $[6]; - t3 = $[7]; - } - useEffect(t2, t3); - let t4; - let t5; - if ($[8] !== addNotification || $[9] !== ideName || $[10] !== ideStatus || $[11] !== removeNotification || $[12] !== showIDEInstallError || $[13] !== showJetBrainsInfo) { - t4 = () => { - if (getIsRemoteMode()) { - return; - } - if (showIDEInstallError || showJetBrainsInfo || ideStatus !== "disconnected" || !ideName) { - removeNotification("ide-status-disconnected"); - return; - } - addNotification({ - key: "ide-status-disconnected", - text: `${ideName} disconnected`, - color: "error", - priority: "medium" - }); - }; - t5 = [addNotification, removeNotification, ideStatus, ideName, showIDEInstallError, showJetBrainsInfo]; - $[8] = addNotification; - $[9] = ideName; - $[10] = ideStatus; - $[11] = removeNotification; - $[12] = showIDEInstallError; - $[13] = showJetBrainsInfo; - $[14] = t4; - $[15] = t5; - } else { - t4 = $[14]; - t5 = $[15]; - } - useEffect(t4, t5); - let t6; - let t7; - if ($[16] !== addNotification || $[17] !== removeNotification || $[18] !== showJetBrainsInfo) { - t6 = () => { - if (getIsRemoteMode()) { - return; - } - if (!showJetBrainsInfo) { - removeNotification("ide-status-jetbrains-disconnected"); - return; - } - addNotification({ - key: "ide-status-jetbrains-disconnected", - text: "IDE plugin not connected \xB7 /status for info", - priority: "medium" - }); - }; - t7 = [addNotification, removeNotification, showJetBrainsInfo]; - $[16] = addNotification; - $[17] = removeNotification; - $[18] = showJetBrainsInfo; - $[19] = t6; - $[20] = t7; - } else { - t6 = $[19]; - t7 = $[20]; - } - useEffect(t6, t7); - let t8; - let t9; - if ($[21] !== addNotification || $[22] !== removeNotification || $[23] !== showIDEInstallError) { - t8 = () => { - if (getIsRemoteMode()) { - return; - } - if (!showIDEInstallError) { - removeNotification("ide-status-install-error"); - return; - } - addNotification({ - key: "ide-status-install-error", - text: "IDE extension install failed (see /status for info)", - color: "error", - priority: "medium" - }); - }; - t9 = [addNotification, removeNotification, showIDEInstallError]; - $[21] = addNotification; - $[22] = removeNotification; - $[23] = showIDEInstallError; - $[24] = t8; - $[25] = t9; - } else { - t8 = $[24]; - t9 = $[25]; - } - useEffect(t8, t9); + ideInstallationStatus: IDEExtensionInstallationStatus | null + ideSelection: IDESelection | undefined + mcpClients: MCPServerConnection[] } -function _temp2(hasShownHintRef_0, addNotification_0) { - detectIDEs(true).then(infos => { - const ideName_0 = infos[0]?.name; - if (ideName_0 && !hasShownHintRef_0.current) { - hasShownHintRef_0.current = true; - saveGlobalConfig(_temp); - addNotification_0({ - key: "ide-status-hint", - jsx: /ide for {ideName_0}, - priority: "low" - }); + +export function useIDEStatusIndicator({ + ideSelection, + mcpClients, + ideInstallationStatus, +}: Props): void { + const { addNotification, removeNotification } = useNotifications() + const { status: ideStatus, ideName } = useIdeConnectionStatus(mcpClients) + const hasShownHintRef = useRef(false) + + const isJetBrains = ideInstallationStatus + ? isJetBrainsIde(ideInstallationStatus?.ideType) + : false + const showIDEInstallErrorOrJetBrainsInfo = + ideInstallationStatus?.error || isJetBrains + + const shouldShowIdeSelection = + ideStatus === 'connected' && + (ideSelection?.filePath || + (ideSelection?.text && ideSelection.lineCount > 0)) + + // Only show the connected if not showing context + const shouldShowConnected = + ideStatus === 'connected' && !shouldShowIdeSelection + + const showIDEInstallError = + showIDEInstallErrorOrJetBrainsInfo && + !isJetBrains && + !shouldShowConnected && + !shouldShowIdeSelection + + const showJetBrainsInfo = + showIDEInstallErrorOrJetBrainsInfo && + isJetBrains && + !shouldShowConnected && + !shouldShowIdeSelection + + // Show the /ide command hint if running from an external terminal and found running IDE(s) + // Delay showing hint to avoid brief flash during auto-connect startup + useEffect(() => { + if (getIsRemoteMode()) return + if (isSupportedTerminal() || ideStatus !== null || showJetBrainsInfo) { + removeNotification('ide-status-hint') + return } - }); -} -function _temp(current) { - return { - ...current, - ideHintShownCount: (current.ideHintShownCount ?? 0) + 1 - }; + // Wait a bit to let auto-connect happen first, avoiding brief hint flash + if ( + hasShownHintRef.current || + (getGlobalConfig().ideHintShownCount ?? 0) >= MAX_IDE_HINT_SHOW_COUNT + ) { + return + } + const timeoutId = setTimeout( + (hasShownHintRef, addNotification) => { + void detectIDEs(true).then(infos => { + const ideName = infos[0]?.name + if (ideName && !hasShownHintRef.current) { + hasShownHintRef.current = true + saveGlobalConfig(current => ({ + ...current, + ideHintShownCount: (current.ideHintShownCount ?? 0) + 1, + })) + addNotification({ + key: 'ide-status-hint', + jsx: ( + + /ide for {ideName} + + ), + priority: 'low', + }) + } + }) + }, + 3000, + hasShownHintRef, + addNotification, + ) + return () => clearTimeout(timeoutId) + }, [addNotification, removeNotification, ideStatus, showJetBrainsInfo]) + + // Show IDE disconnected/failed notification when status is disconnected + useEffect(() => { + if (getIsRemoteMode()) return + if ( + showIDEInstallError || + showJetBrainsInfo || + ideStatus !== 'disconnected' || + !ideName + ) { + removeNotification('ide-status-disconnected') + return + } + addNotification({ + key: 'ide-status-disconnected', + text: `${ideName} disconnected`, + color: 'error', + priority: 'medium', + }) + }, [ + addNotification, + removeNotification, + ideStatus, + ideName, + showIDEInstallError, + showJetBrainsInfo, + ]) + + // Show JetBrains plugin not connected hint + useEffect(() => { + if (getIsRemoteMode()) return + if (!showJetBrainsInfo) { + removeNotification('ide-status-jetbrains-disconnected') + return + } + addNotification({ + key: 'ide-status-jetbrains-disconnected', + text: 'IDE plugin not connected · /status for info', + priority: 'medium', + }) + }, [addNotification, removeNotification, showJetBrainsInfo]) + + // Show IDE install error + useEffect(() => { + if (getIsRemoteMode()) return + if (!showIDEInstallError) { + removeNotification('ide-status-install-error') + return + } + addNotification({ + key: 'ide-status-install-error', + text: 'IDE extension install failed (see /status for info)', + color: 'error', + priority: 'medium', + }) + }, [addNotification, removeNotification, showIDEInstallError]) } diff --git a/src/hooks/notifs/useInstallMessages.tsx b/src/hooks/notifs/useInstallMessages.tsx index af8eb118d..47d4dc73d 100644 --- a/src/hooks/notifs/useInstallMessages.tsx +++ b/src/hooks/notifs/useInstallMessages.tsx @@ -1,25 +1,22 @@ -import { checkInstall } from 'src/utils/nativeInstaller/index.js'; -import { useStartupNotification } from './useStartupNotification.js'; -export function useInstallMessages() { - useStartupNotification(_temp2); -} -async function _temp2() { - const messages = await checkInstall(); - return messages.map(_temp); -} -function _temp(message, index) { - let priority = "low"; - if (message.type === "error" || message.userActionRequired) { - priority = "high"; - } else { - if (message.type === "path" || message.type === "alias") { - priority = "medium"; - } - } - return { - key: `install-message-${index}-${message.type}`, - text: message.message, - priority, - color: message.type === "error" ? "error" : "warning" - }; +import { checkInstall } from 'src/utils/nativeInstaller/index.js' +import { useStartupNotification } from './useStartupNotification.js' + +export function useInstallMessages(): void { + useStartupNotification(async () => { + const messages = await checkInstall() + return messages.map((message, index) => { + let priority: 'low' | 'medium' | 'high' | 'immediate' = 'low' + if (message.type === 'error' || message.userActionRequired) { + priority = 'high' + } else if (message.type === 'path' || message.type === 'alias') { + priority = 'medium' + } + return { + key: `install-message-${index}-${message.type}`, + text: message.message, + priority, + color: message.type === 'error' ? 'error' : 'warning', + } + }) + }) } diff --git a/src/hooks/notifs/useLspInitializationNotification.tsx b/src/hooks/notifs/useLspInitializationNotification.tsx index ffd9e489e..f86243a51 100644 --- a/src/hooks/notifs/useLspInitializationNotification.tsx +++ b/src/hooks/notifs/useLspInitializationNotification.tsx @@ -1,14 +1,17 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useInterval } from 'usehooks-ts'; -import { getIsRemoteMode, getIsScrollDraining } from '../../bootstrap/state.js'; -import { useNotifications } from '../../context/notifications.js'; -import { Text } from '../../ink.js'; -import { getInitializationStatus, getLspServerManager } from '../../services/lsp/manager.js'; -import { useSetAppState } from '../../state/AppState.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -const LSP_POLL_INTERVAL_MS = 5000; +import * as React from 'react' +import { useInterval } from 'usehooks-ts' +import { getIsRemoteMode, getIsScrollDraining } from '../../bootstrap/state.js' +import { useNotifications } from '../../context/notifications.js' +import { Text } from '../../ink.js' +import { + getInitializationStatus, + getLspServerManager, +} from '../../services/lsp/manager.js' +import { useSetAppState } from '../../state/AppState.js' +import { logForDebugging } from '../../utils/debug.js' +import { isEnvTruthy } from '../../utils/envUtils.js' + +const LSP_POLL_INTERVAL_MS = 5000 /** * Hook that polls LSP status and shows a notification when: @@ -19,124 +22,120 @@ const LSP_POLL_INTERVAL_MS = 5000; * * Only active when ENABLE_LSP_TOOL is set. */ -export function useLspInitializationNotification() { - const $ = _c(10); - const { - addNotification - } = useNotifications(); - const setAppState = useSetAppState(); - const [shouldPoll, setShouldPoll] = React.useState(_temp); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = new Set(); - $[0] = t0; - } else { - t0 = $[0]; - } - const notifiedErrorsRef = React.useRef(t0); - let t1; - if ($[1] !== addNotification || $[2] !== setAppState) { - t1 = (source, errorMessage) => { - const errorKey = `${source}:${errorMessage}`; +export function useLspInitializationNotification(): void { + const { addNotification } = useNotifications() + const setAppState = useSetAppState() + // Lazy initializer — eager form re-evaluates isEnvTruthy on every REPL + // render (the arg expression runs even though useState ignores it after + // mount). Showed up as 7.2s isEnvTruthy self-time during PageUp spam + // after #24498 swapped cheap !!process.env.X for isEnvTruthy(). + const [shouldPoll, setShouldPoll] = React.useState(() => + isEnvTruthy("true"), + ) + // Track which errors we've already notified about to avoid duplicates + const notifiedErrorsRef = React.useRef>(new Set()) + + const addError = React.useCallback( + (source: string, errorMessage: string) => { + const errorKey = `${source}:${errorMessage}` if (notifiedErrorsRef.current.has(errorKey)) { - return; + return // Already notified } - notifiedErrorsRef.current.add(errorKey); - logForDebugging(`LSP error: ${source} - ${errorMessage}`); + notifiedErrorsRef.current.add(errorKey) + + logForDebugging(`LSP error: ${source} - ${errorMessage}`) + + // Add error to appState.plugins.errors setAppState(prev => { - const existingKeys = new Set(prev.plugins.errors.map(_temp2)); - const stateErrorKey = `generic-error:${source}:${errorMessage}`; + // Check if this error already exists to avoid duplicates + const existingKeys = new Set( + prev.plugins.errors.map(e => { + if (e.type === 'generic-error') { + return `generic-error:${e.source}:${e.error}` + } + return `${e.type}:${e.source}` + }), + ) + + const stateErrorKey = `generic-error:${source}:${errorMessage}` if (existingKeys.has(stateErrorKey)) { - return prev; + return prev } + return { ...prev, plugins: { ...prev.plugins, - errors: [...prev.plugins.errors, { - type: "generic-error" as const, - source, - error: errorMessage - }] - } - }; - }); - const displayName = source.startsWith("plugin:") ? source.split(":")[1] ?? source : source; + errors: [ + ...prev.plugins.errors, + { + type: 'generic-error' as const, + source, + error: errorMessage, + }, + ], + }, + } + }) + + // Show notification - extract plugin name from source like "plugin:typescript-lsp:typescript" + const displayName = source.startsWith('plugin:') + ? (source.split(':')[1] ?? source) + : source + addNotification({ key: `lsp-error-${source}`, - jsx: <>LSP for {displayName} failed · /plugin for details, - priority: "medium", - timeoutMs: 8000 - }); - }; - $[1] = addNotification; - $[2] = setAppState; - $[3] = t1; - } else { - t1 = $[3]; - } - const addError = t1; - let t2; - if ($[4] !== addError) { - t2 = () => { - if (getIsRemoteMode()) { - return; - } - if (getIsScrollDraining()) { - return; - } - const status = getInitializationStatus(); - if (status.status === "failed") { - addError("lsp-manager", status.error.message); - setShouldPoll(false); - return; - } - if (status.status === "pending" || status.status === "not-started") { - return; - } - const manager = getLspServerManager(); - if (manager) { - const servers = manager.getAllServers(); - for (const [serverName, server] of servers) { - if (server.state === "error" && server.lastError) { - addError(serverName, server.lastError.message); - } + jsx: ( + <> + LSP for {displayName} failed + · /plugin for details + + ), + priority: 'medium', + timeoutMs: 8000, + }) + }, + [addNotification, setAppState], + ) + + const poll = React.useCallback(() => { + if (getIsRemoteMode()) return + // Skip during scroll drain — iterating all LSP servers + setAppState + // competes for the event loop with scroll frames. Next interval picks up. + if (getIsScrollDraining()) return + + const status = getInitializationStatus() + + // Check manager initialization status + if (status.status === 'failed') { + addError('lsp-manager', status.error.message) + setShouldPoll(false) + return + } + + if (status.status === 'pending' || status.status === 'not-started') { + // Still initializing, continue polling + return + } + + // Manager initialized successfully - check for server errors + const manager = getLspServerManager() + if (manager) { + const servers = manager.getAllServers() + for (const [serverName, server] of servers) { + if (server.state === 'error' && server.lastError) { + addError(serverName, server.lastError.message) } } - }; - $[4] = addError; - $[5] = t2; - } else { - t2 = $[5]; - } - const poll = t2; - useInterval(poll, shouldPoll ? LSP_POLL_INTERVAL_MS : null); - let t3; - let t4; - if ($[6] !== poll || $[7] !== shouldPoll) { - t3 = () => { - if (getIsRemoteMode() || !shouldPoll) { - return; - } - poll(); - }; - t4 = [poll, shouldPoll]; - $[6] = poll; - $[7] = shouldPoll; - $[8] = t3; - $[9] = t4; - } else { - t3 = $[8]; - t4 = $[9]; - } - React.useEffect(t3, t4); -} -function _temp2(e) { - if (e.type === "generic-error") { - return `generic-error:${e.source}:${e.error}`; - } - return `${e.type}:${e.source}`; -} -function _temp() { - return isEnvTruthy("true"); + } + // Continue polling to detect future server errors + }, [addError]) + + useInterval(poll, shouldPoll ? LSP_POLL_INTERVAL_MS : null) + + // Initial poll on mount + React.useEffect(() => { + if (getIsRemoteMode() || !shouldPoll) return + poll() + }, [poll, shouldPoll]) } diff --git a/src/hooks/notifs/useMcpConnectivityStatus.tsx b/src/hooks/notifs/useMcpConnectivityStatus.tsx index 95627dc3b..83072ba0f 100644 --- a/src/hooks/notifs/useMcpConnectivityStatus.tsx +++ b/src/hooks/notifs/useMcpConnectivityStatus.tsx @@ -1,87 +1,126 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -import { Text } from '../../ink.js'; -import { hasClaudeAiMcpEverConnected } from '../../services/mcp/claudeai.js'; -import type { MCPServerConnection } from '../../services/mcp/types.js'; +import * as React from 'react' +import { useEffect } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' +import { Text } from '../../ink.js' +import { hasClaudeAiMcpEverConnected } from '../../services/mcp/claudeai.js' +import type { MCPServerConnection } from '../../services/mcp/types.js' + type Props = { - mcpClients?: MCPServerConnection[]; -}; -const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; -export function useMcpConnectivityStatus(t0) { - const $ = _c(4); - const { - mcpClients: t1 - } = t0; - const mcpClients = t1 === undefined ? EMPTY_MCP_CLIENTS : t1; - const { - addNotification - } = useNotifications(); - let t2; - let t3; - if ($[0] !== addNotification || $[1] !== mcpClients) { - t2 = () => { - if (getIsRemoteMode()) { - return; - } - const failedLocalClients = mcpClients.filter(_temp); - const failedClaudeAiClients = mcpClients.filter(_temp2); - const needsAuthLocalServers = mcpClients.filter(_temp3); - const needsAuthClaudeAiServers = mcpClients.filter(_temp4); - if (failedLocalClients.length === 0 && failedClaudeAiClients.length === 0 && needsAuthLocalServers.length === 0 && needsAuthClaudeAiServers.length === 0) { - return; - } - if (failedLocalClients.length > 0) { - addNotification({ - key: "mcp-failed", - jsx: <>{failedLocalClients.length} MCP{" "}{failedLocalClients.length === 1 ? "server" : "servers"} failed · /mcp, - priority: "medium" - }); - } - if (failedClaudeAiClients.length > 0) { - addNotification({ - key: "mcp-claudeai-failed", - jsx: <>{failedClaudeAiClients.length} claude.ai{" "}{failedClaudeAiClients.length === 1 ? "connector" : "connectors"}{" "}unavailable · /mcp, - priority: "medium" - }); - } - if (needsAuthLocalServers.length > 0) { - addNotification({ - key: "mcp-needs-auth", - jsx: <>{needsAuthLocalServers.length} MCP{" "}{needsAuthLocalServers.length === 1 ? "server needs" : "servers need"}{" "}auth · /mcp, - priority: "medium" - }); - } - if (needsAuthClaudeAiServers.length > 0) { - addNotification({ - key: "mcp-claudeai-needs-auth", - jsx: <>{needsAuthClaudeAiServers.length} claude.ai{" "}{needsAuthClaudeAiServers.length === 1 ? "connector needs" : "connectors need"}{" "}auth · /mcp, - priority: "medium" - }); - } - }; - t3 = [addNotification, mcpClients]; - $[0] = addNotification; - $[1] = mcpClients; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - useEffect(t2, t3); + mcpClients?: MCPServerConnection[] } -function _temp4(client_2) { - return client_2.type === "needs-auth" && client_2.config.type === "claudeai-proxy" && hasClaudeAiMcpEverConnected(client_2.name); -} -function _temp3(client_1) { - return client_1.type === "needs-auth" && client_1.config.type !== "claudeai-proxy"; -} -function _temp2(client_0) { - return client_0.type === "failed" && client_0.config.type === "claudeai-proxy" && hasClaudeAiMcpEverConnected(client_0.name); -} -function _temp(client) { - return client.type === "failed" && client.config.type !== "sse-ide" && client.config.type !== "ws-ide" && client.config.type !== "claudeai-proxy"; + +const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [] + +export function useMcpConnectivityStatus({ + mcpClients = EMPTY_MCP_CLIENTS, +}: Props): void { + const { addNotification } = useNotifications() + useEffect(() => { + if (getIsRemoteMode()) return + const failedLocalClients = mcpClients.filter( + client => + client.type === 'failed' && + client.config.type !== 'sse-ide' && + client.config.type !== 'ws-ide' && + client.config.type !== 'claudeai-proxy', + ) + // claude.ai failures get a separate notification: they almost always indicate + // a toolbox-service outage (shared auth backend), not a local config issue. + // Only flag connectors that have previously connected successfully — an + // org-configured connector that's been needs-auth since it appeared is one + // the user has ignored and shouldn't nag about; one that was working + // yesterday and is now failed is a state change worth surfacing. + const failedClaudeAiClients = mcpClients.filter( + client => + client.type === 'failed' && + client.config.type === 'claudeai-proxy' && + hasClaudeAiMcpEverConnected(client.name), + ) + const needsAuthLocalServers = mcpClients.filter( + client => + client.type === 'needs-auth' && client.config.type !== 'claudeai-proxy', + ) + const needsAuthClaudeAiServers = mcpClients.filter( + client => + client.type === 'needs-auth' && + client.config.type === 'claudeai-proxy' && + hasClaudeAiMcpEverConnected(client.name), + ) + if ( + failedLocalClients.length === 0 && + failedClaudeAiClients.length === 0 && + needsAuthLocalServers.length === 0 && + needsAuthClaudeAiServers.length === 0 + ) { + return + } + if (failedLocalClients.length > 0) { + addNotification({ + key: 'mcp-failed', + jsx: ( + <> + + {failedLocalClients.length} MCP{' '} + {failedLocalClients.length === 1 ? 'server' : 'servers'} failed + + · /mcp + + ), + priority: 'medium', + }) + } + if (failedClaudeAiClients.length > 0) { + addNotification({ + key: 'mcp-claudeai-failed', + jsx: ( + <> + + {failedClaudeAiClients.length} claude.ai{' '} + {failedClaudeAiClients.length === 1 ? 'connector' : 'connectors'}{' '} + unavailable + + · /mcp + + ), + priority: 'medium', + }) + } + if (needsAuthLocalServers.length > 0) { + addNotification({ + key: 'mcp-needs-auth', + jsx: ( + <> + + {needsAuthLocalServers.length} MCP{' '} + {needsAuthLocalServers.length === 1 + ? 'server needs' + : 'servers need'}{' '} + auth + + · /mcp + + ), + priority: 'medium', + }) + } + if (needsAuthClaudeAiServers.length > 0) { + addNotification({ + key: 'mcp-claudeai-needs-auth', + jsx: ( + <> + + {needsAuthClaudeAiServers.length} claude.ai{' '} + {needsAuthClaudeAiServers.length === 1 + ? 'connector needs' + : 'connectors need'}{' '} + auth + + · /mcp + + ), + priority: 'medium', + }) + } + }, [addNotification, mcpClients]) } diff --git a/src/hooks/notifs/useModelMigrationNotifications.tsx b/src/hooks/notifs/useModelMigrationNotifications.tsx index f23e02707..b2bdc52fb 100644 --- a/src/hooks/notifs/useModelMigrationNotifications.tsx +++ b/src/hooks/notifs/useModelMigrationNotifications.tsx @@ -1,51 +1,53 @@ -import type { Notification } from 'src/context/notifications.js'; -import { type GlobalConfig, getGlobalConfig } from 'src/utils/config.js'; -import { useStartupNotification } from './useStartupNotification.js'; +import type { Notification } from 'src/context/notifications.js' +import { type GlobalConfig, getGlobalConfig } from 'src/utils/config.js' +import { useStartupNotification } from './useStartupNotification.js' // Shows a one-time notification right after a model migration writes its // timestamp to config. Each entry reads its own timestamp field(s) and emits // a notification if the write happened within the last 3s (i.e. this launch). // Future model migrations: add an entry to MIGRATIONS below. const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [ -// Sonnet 4.5 → 4.6 (pro/max/team premium) -c => { - if (!recent(c.sonnet45To46MigrationTimestamp)) return; - return { - key: 'sonnet-46-update', - text: 'Model updated to Sonnet 4.6', - color: 'suggestion', - priority: 'high', - timeoutMs: 3000 - }; -}, -// Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the -// current Opus default (4.6 for 1P). -c => { - const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp); - const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp; - if (!recent(ts)) return; - return { - key: 'opus-pro-update', - text: isLegacyRemap ? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' : 'Model updated to Opus 4.6', - color: 'suggestion', - priority: 'high', - timeoutMs: isLegacyRemap ? 8000 : 3000 - }; -}]; -export function useModelMigrationNotifications() { - useStartupNotification(_temp); -} -function _temp() { - const config = getGlobalConfig(); - const notifs = []; - for (const migration of MIGRATIONS) { - const notif = migration(config); - if (notif) { - notifs.push(notif); + // Sonnet 4.5 → 4.6 (pro/max/team premium) + c => { + if (!recent(c.sonnet45To46MigrationTimestamp)) return + return { + key: 'sonnet-46-update', + text: 'Model updated to Sonnet 4.6', + color: 'suggestion', + priority: 'high', + timeoutMs: 3000, + } + }, + // Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the + // current Opus default (4.6 for 1P). + c => { + const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp) + const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp + if (!recent(ts)) return + return { + key: 'opus-pro-update', + text: isLegacyRemap + ? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' + : 'Model updated to Opus 4.6', + color: 'suggestion', + priority: 'high', + timeoutMs: isLegacyRemap ? 8000 : 3000, } - } - return notifs.length > 0 ? notifs : null; + }, +] + +export function useModelMigrationNotifications(): void { + useStartupNotification(() => { + const config = getGlobalConfig() + const notifs: Notification[] = [] + for (const migration of MIGRATIONS) { + const notif = migration(config) + if (notif) notifs.push(notif) + } + return notifs.length > 0 ? notifs : null + }) } + function recent(ts: number | undefined): boolean { - return ts !== undefined && Date.now() - ts < 3000; + return ts !== undefined && Date.now() - ts < 3000 } diff --git a/src/hooks/notifs/useNpmDeprecationNotification.tsx b/src/hooks/notifs/useNpmDeprecationNotification.tsx index 3ef4467b5..ddcb68d82 100644 --- a/src/hooks/notifs/useNpmDeprecationNotification.tsx +++ b/src/hooks/notifs/useNpmDeprecationNotification.tsx @@ -1,25 +1,27 @@ -import { isInBundledMode } from 'src/utils/bundledMode.js'; -import { getCurrentInstallationType } from 'src/utils/doctorDiagnostic.js'; -import { isEnvTruthy } from 'src/utils/envUtils.js'; -import { useStartupNotification } from './useStartupNotification.js'; -const NPM_DEPRECATION_MESSAGE = ''; -// const NPM_DEPRECATION_MESSAGE = 'Claude Code has switched from npm to native installer. Run `claude install` or see https://docs.anthropic.com/en/docs/claude-code/getting-started for more options.'; -export function useNpmDeprecationNotification() { - useStartupNotification(_temp); -} -async function _temp() { - if (isInBundledMode() || isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) { - return null; - } - const installationType = await getCurrentInstallationType(); - if (installationType === "development") { - return null; - } - return { - timeoutMs: 15000, - key: "npm-deprecation-warning", - text: NPM_DEPRECATION_MESSAGE, - color: "warning", - priority: "high" - }; +import { isInBundledMode } from 'src/utils/bundledMode.js' +import { getCurrentInstallationType } from 'src/utils/doctorDiagnostic.js' +import { isEnvTruthy } from 'src/utils/envUtils.js' +import { useStartupNotification } from './useStartupNotification.js' + +const NPM_DEPRECATION_MESSAGE = + 'Claude Code has switched from npm to native installer. Run `claude install` or see https://docs.anthropic.com/en/docs/claude-code/getting-started for more options.' + +export function useNpmDeprecationNotification(): void { + useStartupNotification(async () => { + if ( + isInBundledMode() || + isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS) + ) { + return null + } + const installationType = await getCurrentInstallationType() + if (installationType === 'development') return null + return { + timeoutMs: 15000, + key: 'npm-deprecation-warning', + text: NPM_DEPRECATION_MESSAGE, + color: 'warning', + priority: 'high', + } + }) } diff --git a/src/hooks/notifs/usePluginAutoupdateNotification.tsx b/src/hooks/notifs/usePluginAutoupdateNotification.tsx index 8621af5a2..bec229328 100644 --- a/src/hooks/notifs/usePluginAutoupdateNotification.tsx +++ b/src/hooks/notifs/usePluginAutoupdateNotification.tsx @@ -1,82 +1,67 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -import { useNotifications } from '../../context/notifications.js'; -import { Text } from '../../ink.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { onPluginsAutoUpdated } from '../../utils/plugins/pluginAutoupdate.js'; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { getIsRemoteMode } from '../../bootstrap/state.js' +import { useNotifications } from '../../context/notifications.js' +import { Text } from '../../ink.js' +import { logForDebugging } from '../../utils/debug.js' +import { onPluginsAutoUpdated } from '../../utils/plugins/pluginAutoupdate.js' /** * Hook that displays a notification when plugins have been auto-updated. * The notification tells the user to run /reload-plugins to apply the updates. */ -export function usePluginAutoupdateNotification() { - const $ = _c(7); - const { - addNotification - } = useNotifications(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = []; - $[0] = t0; - } else { - t0 = $[0]; - } - const [updatedPlugins, setUpdatedPlugins] = useState(t0); - let t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - if (getIsRemoteMode()) { - return; - } - const unsubscribe = onPluginsAutoUpdated(plugins => { - logForDebugging(`Plugin autoupdate notification: ${plugins.length} plugin(s) updated`); - setUpdatedPlugins(plugins); - }); - return unsubscribe; - }; - t2 = []; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - let t3; - let t4; - if ($[3] !== addNotification || $[4] !== updatedPlugins) { - t3 = () => { - if (getIsRemoteMode()) { - return; - } - if (updatedPlugins.length === 0) { - return; - } - const pluginNames = updatedPlugins.map(_temp); - const displayNames = pluginNames.length <= 2 ? pluginNames.join(" and ") : `${pluginNames.length} plugins`; - addNotification({ - key: "plugin-autoupdate-restart", - jsx: <>{pluginNames.length === 1 ? "Plugin" : "Plugins"} updated:{" "}{displayNames} · Run /reload-plugins to apply, - priority: "low", - timeoutMs: 10000 - }); - logForDebugging(`Showing plugin autoupdate notification for: ${pluginNames.join(", ")}`); - }; - t4 = [updatedPlugins, addNotification]; - $[3] = addNotification; - $[4] = updatedPlugins; - $[5] = t3; - $[6] = t4; - } else { - t3 = $[5]; - t4 = $[6]; - } - useEffect(t3, t4); -} -function _temp(id) { - const atIndex = id.indexOf("@"); - return atIndex > 0 ? id.substring(0, atIndex) : id; +export function usePluginAutoupdateNotification(): void { + const { addNotification } = useNotifications() + const [updatedPlugins, setUpdatedPlugins] = useState([]) + + // Register for autoupdate notifications + useEffect(() => { + if (getIsRemoteMode()) return + const unsubscribe = onPluginsAutoUpdated(plugins => { + logForDebugging( + `Plugin autoupdate notification: ${plugins.length} plugin(s) updated`, + ) + setUpdatedPlugins(plugins) + }) + + return unsubscribe + }, []) + + // Show notification when plugins are updated + useEffect(() => { + if (getIsRemoteMode()) return + if (updatedPlugins.length === 0) { + return + } + + // Extract plugin names from plugin IDs (format: "name@marketplace") + const pluginNames = updatedPlugins.map(id => { + const atIndex = id.indexOf('@') + return atIndex > 0 ? id.substring(0, atIndex) : id + }) + + const displayNames = + pluginNames.length <= 2 + ? pluginNames.join(' and ') + : `${pluginNames.length} plugins` + + addNotification({ + key: 'plugin-autoupdate-restart', + jsx: ( + <> + + {pluginNames.length === 1 ? 'Plugin' : 'Plugins'} updated:{' '} + {displayNames} + + · Run /reload-plugins to apply + + ), + priority: 'low', + timeoutMs: 10000, + }) + + logForDebugging( + `Showing plugin autoupdate notification for: ${pluginNames.join(', ')}`, + ) + }, [updatedPlugins, addNotification]) } diff --git a/src/hooks/notifs/usePluginInstallationStatus.tsx b/src/hooks/notifs/usePluginInstallationStatus.tsx index 82754ec09..20055403d 100644 --- a/src/hooks/notifs/usePluginInstallationStatus.tsx +++ b/src/hooks/notifs/usePluginInstallationStatus.tsx @@ -1,127 +1,80 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useMemo } from 'react'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -import { useNotifications } from '../../context/notifications.js'; -import { Text } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { plural } from '../../utils/stringUtils.js'; -export function usePluginInstallationStatus() { - const $ = _c(20); - const { - addNotification - } = useNotifications(); - const installationStatus = useAppState(_temp); - let t0; - bb0: { - if (!installationStatus) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { +import * as React from 'react' +import { useEffect, useMemo } from 'react' +import { getIsRemoteMode } from '../../bootstrap/state.js' +import { useNotifications } from '../../context/notifications.js' +import { Text } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import { logForDebugging } from '../../utils/debug.js' +import { plural } from '../../utils/stringUtils.js' + +export function usePluginInstallationStatus(): void { + const { addNotification } = useNotifications() + const installationStatus = useAppState(s => s.plugins.installationStatus) + + // Memoize the failed counts to prevent unnecessary effect triggers + const { totalFailed, failedMarketplacesCount, failedPluginsCount } = + useMemo(() => { + if (!installationStatus) { + return { totalFailed: 0, failedMarketplacesCount: 0, - failedPluginsCount: 0 - }; - $[0] = t1; - } else { - t1 = $[0]; + failedPluginsCount: 0, + } } - t0 = t1; - break bb0; - } - let t1; - if ($[1] !== installationStatus.marketplaces) { - t1 = installationStatus.marketplaces.filter(_temp2); - $[1] = installationStatus.marketplaces; - $[2] = t1; - } else { - t1 = $[2]; + + const failedMarketplaces = installationStatus.marketplaces.filter( + m => m.status === 'failed', + ) + const failedPlugins = installationStatus.plugins.filter( + p => p.status === 'failed', + ) + + return { + totalFailed: failedMarketplaces.length + failedPlugins.length, + failedMarketplacesCount: failedMarketplaces.length, + failedPluginsCount: failedPlugins.length, + } + }, [installationStatus]) + + useEffect(() => { + if (getIsRemoteMode()) return + if (!installationStatus) { + logForDebugging('No installation status to monitor') + return } - const failedMarketplaces = t1; - let t2; - if ($[3] !== installationStatus.plugins) { - t2 = installationStatus.plugins.filter(_temp3); - $[3] = installationStatus.plugins; - $[4] = t2; - } else { - t2 = $[4]; + + if (totalFailed === 0) { + return } - const failedPlugins = t2; - const t3 = failedMarketplaces.length + failedPlugins.length; - let t4; - if ($[5] !== failedMarketplaces.length || $[6] !== failedPlugins.length || $[7] !== t3) { - t4 = { - totalFailed: t3, - failedMarketplacesCount: failedMarketplaces.length, - failedPluginsCount: failedPlugins.length - }; - $[5] = failedMarketplaces.length; - $[6] = failedPlugins.length; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; + + logForDebugging( + `Plugin installation status: ${failedMarketplacesCount} failed marketplaces, ${failedPluginsCount} failed plugins`, + ) + + if (totalFailed === 0) { + return } - t0 = t4; - } - const { + + // Add notification for failures + logForDebugging( + `Adding notification for ${totalFailed} failed installations`, + ) + addNotification({ + key: 'plugin-install-failed', + jsx: ( + <> + + {totalFailed} {plural(totalFailed, 'plugin')} failed to install + + · /plugin for details + + ), + priority: 'medium', + }) + }, [ + addNotification, totalFailed, failedMarketplacesCount, - failedPluginsCount - } = t0; - let t1; - if ($[9] !== addNotification || $[10] !== failedMarketplacesCount || $[11] !== failedPluginsCount || $[12] !== installationStatus || $[13] !== totalFailed) { - t1 = () => { - if (getIsRemoteMode()) { - return; - } - if (!installationStatus) { - logForDebugging("No installation status to monitor"); - return; - } - if (totalFailed === 0) { - return; - } - logForDebugging(`Plugin installation status: ${failedMarketplacesCount} failed marketplaces, ${failedPluginsCount} failed plugins`); - if (totalFailed === 0) { - return; - } - logForDebugging(`Adding notification for ${totalFailed} failed installations`); - addNotification({ - key: "plugin-install-failed", - jsx: <>{totalFailed} {plural(totalFailed, "plugin")} failed to install · /plugin for details, - priority: "medium" - }); - }; - $[9] = addNotification; - $[10] = failedMarketplacesCount; - $[11] = failedPluginsCount; - $[12] = installationStatus; - $[13] = totalFailed; - $[14] = t1; - } else { - t1 = $[14]; - } - let t2; - if ($[15] !== addNotification || $[16] !== failedMarketplacesCount || $[17] !== failedPluginsCount || $[18] !== totalFailed) { - t2 = [addNotification, totalFailed, failedMarketplacesCount, failedPluginsCount]; - $[15] = addNotification; - $[16] = failedMarketplacesCount; - $[17] = failedPluginsCount; - $[18] = totalFailed; - $[19] = t2; - } else { - t2 = $[19]; - } - useEffect(t1, t2); -} -function _temp3(p) { - return p.status === "failed"; -} -function _temp2(m) { - return m.status === "failed"; -} -function _temp(s) { - return s.plugins.installationStatus; + failedPluginsCount, + ]) } diff --git a/src/hooks/notifs/useRateLimitWarningNotification.tsx b/src/hooks/notifs/useRateLimitWarningNotification.tsx index 38a51ee27..bfd3f193f 100644 --- a/src/hooks/notifs/useRateLimitWarningNotification.tsx +++ b/src/hooks/notifs/useRateLimitWarningNotification.tsx @@ -1,113 +1,80 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { Text } from 'src/ink.js'; -import { getRateLimitWarning, getUsingOverageText } from 'src/services/claudeAiLimits.js'; -import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js'; -import { getSubscriptionType } from 'src/utils/auth.js'; -import { hasClaudeAiBillingAccess } from 'src/utils/billing.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -export function useRateLimitWarningNotification(model) { - const $ = _c(17); - const { - addNotification - } = useNotifications(); - const claudeAiLimits = useClaudeAiLimits(); - let t0; - if ($[0] !== claudeAiLimits || $[1] !== model) { - t0 = getRateLimitWarning(claudeAiLimits, model); - $[0] = claudeAiLimits; - $[1] = model; - $[2] = t0; - } else { - t0 = $[2]; - } - const rateLimitWarning = t0; - let t1; - if ($[3] !== claudeAiLimits) { - t1 = getUsingOverageText(claudeAiLimits); - $[3] = claudeAiLimits; - $[4] = t1; - } else { - t1 = $[4]; - } - const usingOverageText = t1; - const shownWarningRef = useRef(null); - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getSubscriptionType(); - $[5] = t2; - } else { - t2 = $[5]; - } - const subscriptionType = t2; - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = hasClaudeAiBillingAccess(); - $[6] = t3; - } else { - t3 = $[6]; - } - const hasBillingAccess = t3; - const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise"; - const [hasShownOverageNotification, setHasShownOverageNotification] = useState(false); - let t4; - let t5; - if ($[7] !== addNotification || $[8] !== claudeAiLimits.isUsingOverage || $[9] !== hasShownOverageNotification || $[10] !== usingOverageText) { - t4 = () => { - if (getIsRemoteMode()) { - return; - } - if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification && (!isTeamOrEnterprise || hasBillingAccess)) { - addNotification({ - key: "limit-reached", - text: usingOverageText, - priority: "immediate" - }); - setHasShownOverageNotification(true); - } else { - if (!claudeAiLimits.isUsingOverage && hasShownOverageNotification) { - setHasShownOverageNotification(false); - } - } - }; - t5 = [claudeAiLimits.isUsingOverage, usingOverageText, hasShownOverageNotification, addNotification, hasBillingAccess, isTeamOrEnterprise]; - $[7] = addNotification; - $[8] = claudeAiLimits.isUsingOverage; - $[9] = hasShownOverageNotification; - $[10] = usingOverageText; - $[11] = t4; - $[12] = t5; - } else { - t4 = $[11]; - t5 = $[12]; - } - useEffect(t4, t5); - let t6; - let t7; - if ($[13] !== addNotification || $[14] !== rateLimitWarning) { - t6 = () => { - if (getIsRemoteMode()) { - return; - } - if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) { - shownWarningRef.current = rateLimitWarning; - addNotification({ - key: "rate-limit-warning", - jsx: {rateLimitWarning}, - priority: "high" - }); - } - }; - t7 = [rateLimitWarning, addNotification]; - $[13] = addNotification; - $[14] = rateLimitWarning; - $[15] = t6; - $[16] = t7; - } else { - t6 = $[15]; - t7 = $[16]; - } - useEffect(t6, t7); +import * as React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { Text } from 'src/ink.js' +import { + getRateLimitWarning, + getUsingOverageText, +} from 'src/services/claudeAiLimits.js' +import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js' +import { getSubscriptionType } from 'src/utils/auth.js' +import { hasClaudeAiBillingAccess } from 'src/utils/billing.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' + +export function useRateLimitWarningNotification(model: string): void { + const { addNotification } = useNotifications() + const claudeAiLimits = useClaudeAiLimits() + // claudeAiLimits reference is stable until statusListeners fire (API + // response), so these skip the Intl formatting work on most REPL renders. + const rateLimitWarning = useMemo( + () => getRateLimitWarning(claudeAiLimits, model), + [claudeAiLimits, model], + ) + const usingOverageText = useMemo( + () => getUsingOverageText(claudeAiLimits), + [claudeAiLimits], + ) + const shownWarningRef = useRef(null) + const subscriptionType = getSubscriptionType() + const hasBillingAccess = hasClaudeAiBillingAccess() + const isTeamOrEnterprise = + subscriptionType === 'team' || subscriptionType === 'enterprise' + + // Track overage mode transitions + const [hasShownOverageNotification, setHasShownOverageNotification] = + useState(false) + + // Show immediate notification when entering overage mode + useEffect(() => { + if (getIsRemoteMode()) return + if ( + claudeAiLimits.isUsingOverage && + !hasShownOverageNotification && + (!isTeamOrEnterprise || hasBillingAccess) + ) { + addNotification({ + key: 'limit-reached', + text: usingOverageText, + priority: 'immediate', + }) + setHasShownOverageNotification(true) + } else if (!claudeAiLimits.isUsingOverage && hasShownOverageNotification) { + // Reset when no longer in overage mode + setHasShownOverageNotification(false) + } + }, [ + claudeAiLimits.isUsingOverage, + usingOverageText, + hasShownOverageNotification, + addNotification, + hasBillingAccess, + isTeamOrEnterprise, + ]) + + // Show warning notification for approaching limits + useEffect(() => { + if (getIsRemoteMode()) return + if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) { + shownWarningRef.current = rateLimitWarning + addNotification({ + key: 'rate-limit-warning', + jsx: ( + + {rateLimitWarning} + + ), + priority: 'high', + }) + } + }, [rateLimitWarning, addNotification]) } diff --git a/src/hooks/notifs/useSettingsErrors.tsx b/src/hooks/notifs/useSettingsErrors.tsx index 58b7ed938..86c762cec 100644 --- a/src/hooks/notifs/useSettingsErrors.tsx +++ b/src/hooks/notifs/useSettingsErrors.tsx @@ -1,68 +1,41 @@ -import { c as _c } from "react/compiler-runtime"; -import { useCallback, useEffect, useState } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -import { getSettingsWithAllErrors } from '../../utils/settings/allErrors.js'; -import type { ValidationError } from '../../utils/settings/validation.js'; -import { useSettingsChange } from '../useSettingsChange.js'; -const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors'; -export function useSettingsErrors() { - const $ = _c(6); - const { - addNotification, - removeNotification - } = useNotifications(); - const [errors_0, setErrors] = useState(_temp); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - const { - errors: errors_1 - } = getSettingsWithAllErrors(); - setErrors(errors_1); - }; - $[0] = t0; - } else { - t0 = $[0]; - } - const handleSettingsChange = t0; - useSettingsChange(handleSettingsChange); - let t1; - let t2; - if ($[1] !== addNotification || $[2] !== errors_0 || $[3] !== removeNotification) { - t1 = () => { - if (getIsRemoteMode()) { - return; - } - if (errors_0.length > 0) { - const message = `Found ${errors_0.length} settings ${errors_0.length === 1 ? "issue" : "issues"} · /doctor for details`; - addNotification({ - key: SETTINGS_ERRORS_NOTIFICATION_KEY, - text: message, - color: "warning", - priority: "high", - timeoutMs: 60000 - }); - } else { - removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY); - } - }; - t2 = [errors_0, addNotification, removeNotification]; - $[1] = addNotification; - $[2] = errors_0; - $[3] = removeNotification; - $[4] = t1; - $[5] = t2; - } else { - t1 = $[4]; - t2 = $[5]; - } - useEffect(t1, t2); - return errors_0; -} -function _temp() { - const { - errors - } = getSettingsWithAllErrors(); - return errors; +import { useCallback, useEffect, useState } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' +import { getSettingsWithAllErrors } from '../../utils/settings/allErrors.js' +import type { ValidationError } from '../../utils/settings/validation.js' +import { useSettingsChange } from '../useSettingsChange.js' + +const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors' + +export function useSettingsErrors(): ValidationError[] { + const { addNotification, removeNotification } = useNotifications() + const [errors, setErrors] = useState(() => { + const { errors } = getSettingsWithAllErrors() + return errors + }) + + const handleSettingsChange = useCallback(() => { + const { errors } = getSettingsWithAllErrors() + setErrors(errors) + }, []) + + useSettingsChange(handleSettingsChange) + + useEffect(() => { + if (getIsRemoteMode()) return + if (errors.length > 0) { + const message = `Found ${errors.length} settings ${errors.length === 1 ? 'issue' : 'issues'} · /doctor for details` + addNotification({ + key: SETTINGS_ERRORS_NOTIFICATION_KEY, + text: message, + color: 'warning', + priority: 'high', + timeoutMs: 60000, + }) + } else { + removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY) + } + }, [errors, addNotification, removeNotification]) + + return errors } diff --git a/src/hooks/useArrowKeyHistory.tsx b/src/hooks/useArrowKeyHistory.tsx index 3dcf1514e..69e7d6460 100644 --- a/src/hooks/useArrowKeyHistory.tsx +++ b/src/hooks/useArrowKeyHistory.tsx @@ -1,228 +1,288 @@ -import React, { useCallback, useRef, useState } from 'react'; -import { getModeFromInput } from 'src/components/PromptInput/inputModes.js'; -import { useNotifications } from 'src/context/notifications.js'; -import { ConfigurableShortcutHint } from '../components/ConfigurableShortcutHint.js'; -import { FOOTER_TEMPORARY_STATUS_TIMEOUT } from '../components/PromptInput/Notifications.js'; -import { getHistory } from '../history.js'; -import { Text } from '../ink.js'; -import type { PromptInputMode } from '../types/textInputTypes.js'; -import type { HistoryEntry, PastedContent } from '../utils/config.js'; -export type HistoryMode = PromptInputMode; +import React, { useCallback, useRef, useState } from 'react' +import { getModeFromInput } from 'src/components/PromptInput/inputModes.js' +import { useNotifications } from 'src/context/notifications.js' +import { ConfigurableShortcutHint } from '../components/ConfigurableShortcutHint.js' +import { FOOTER_TEMPORARY_STATUS_TIMEOUT } from '../components/PromptInput/Notifications.js' +import { getHistory } from '../history.js' +import { Text } from '../ink.js' +import type { PromptInputMode } from '../types/textInputTypes.js' +import type { HistoryEntry, PastedContent } from '../utils/config.js' + +export type HistoryMode = PromptInputMode // Load history entries in chunks to reduce disk reads on rapid keypresses -const HISTORY_CHUNK_SIZE = 10; +const HISTORY_CHUNK_SIZE = 10 // Shared state for batching concurrent load requests into a single disk read // Mode filter is included to ensure we don't mix filtered and unfiltered caches -let pendingLoad: Promise | null = null; -let pendingLoadTarget = 0; -let pendingLoadModeFilter: HistoryMode | undefined = undefined; -async function loadHistoryEntries(minCount: number, modeFilter?: HistoryMode): Promise { +let pendingLoad: Promise | null = null +let pendingLoadTarget = 0 +let pendingLoadModeFilter: HistoryMode | undefined = undefined + +async function loadHistoryEntries( + minCount: number, + modeFilter?: HistoryMode, +): Promise { // Round up to next chunk to avoid repeated small reads - const target = Math.ceil(minCount / HISTORY_CHUNK_SIZE) * HISTORY_CHUNK_SIZE; + const target = Math.ceil(minCount / HISTORY_CHUNK_SIZE) * HISTORY_CHUNK_SIZE // If a load is already pending with the same mode filter and will satisfy our needs, wait for it - if (pendingLoad && pendingLoadTarget >= target && pendingLoadModeFilter === modeFilter) { - return pendingLoad; + if ( + pendingLoad && + pendingLoadTarget >= target && + pendingLoadModeFilter === modeFilter + ) { + return pendingLoad } // If a load is pending but won't satisfy our needs or has different filter, we need to wait for it // to complete first, then start a new one (can't interrupt an ongoing read) if (pendingLoad) { - await pendingLoad; + await pendingLoad } // Start a new load - pendingLoadTarget = target; - pendingLoadModeFilter = modeFilter; + pendingLoadTarget = target + pendingLoadModeFilter = modeFilter pendingLoad = (async () => { - const entries: HistoryEntry[] = []; - let loaded = 0; + const entries: HistoryEntry[] = [] + let loaded = 0 for await (const entry of getHistory()) { // If mode filter is specified, only include entries that match the mode if (modeFilter) { - const entryMode = getModeFromInput(entry.display); + const entryMode = getModeFromInput(entry.display) if (entryMode !== modeFilter) { - continue; + continue } } - entries.push(entry); - loaded++; - if (loaded >= pendingLoadTarget) break; + entries.push(entry) + loaded++ + if (loaded >= pendingLoadTarget) break } - return entries; - })(); + return entries + })() + try { - return await pendingLoad; + return await pendingLoad } finally { - pendingLoad = null; - pendingLoadTarget = 0; - pendingLoadModeFilter = undefined; + pendingLoad = null + pendingLoadTarget = 0 + pendingLoadModeFilter = undefined } } -export function useArrowKeyHistory(onSetInput: (value: string, mode: HistoryMode, pastedContents: Record) => void, currentInput: string, pastedContents: Record, setCursorOffset?: (offset: number) => void, currentMode?: HistoryMode): { - historyIndex: number; - setHistoryIndex: (index: number) => void; - onHistoryUp: () => void; - onHistoryDown: () => boolean; - resetHistory: () => void; - dismissSearchHint: () => void; + +export function useArrowKeyHistory( + onSetInput: ( + value: string, + mode: HistoryMode, + pastedContents: Record, + ) => void, + currentInput: string, + pastedContents: Record, + setCursorOffset?: (offset: number) => void, + currentMode?: HistoryMode, +): { + historyIndex: number + setHistoryIndex: (index: number) => void + onHistoryUp: () => void + onHistoryDown: () => boolean + resetHistory: () => void + dismissSearchHint: () => void } { - const [historyIndex, setHistoryIndex] = useState(0); - const [lastShownHistoryEntry, setLastShownHistoryEntry] = useState<(HistoryEntry & { - mode?: HistoryMode; - }) | undefined>(undefined); - const hasShownSearchHintRef = useRef(false); - const { - addNotification, - removeNotification - } = useNotifications(); + const [historyIndex, setHistoryIndex] = useState(0) + const [lastShownHistoryEntry, setLastShownHistoryEntry] = useState< + (HistoryEntry & { mode?: HistoryMode }) | undefined + >(undefined) + const hasShownSearchHintRef = useRef(false) + const { addNotification, removeNotification } = useNotifications() // Cache loaded history entries - const historyCache = useRef([]); + const historyCache = useRef([]) // Track which mode filter the cache was loaded with - const historyCacheModeFilter = useRef(undefined); + const historyCacheModeFilter = useRef(undefined) // Synchronous tracker for history index to avoid stale closure issues // React state updates are async, so rapid keypresses can see stale values - const historyIndexRef = useRef(0); + const historyIndexRef = useRef(0) // Track the mode filter that was active when history navigation started // This is set on the first arrow press and stays fixed until reset - const initialModeFilterRef = useRef(undefined); + const initialModeFilterRef = useRef(undefined) // Refs to track current input values for draft preservation // These ensure we capture the draft with the latest values, not stale closure values - const currentInputRef = useRef(currentInput); - const pastedContentsRef = useRef(pastedContents); - const currentModeRef = useRef(currentMode); + const currentInputRef = useRef(currentInput) + const pastedContentsRef = useRef(pastedContents) + const currentModeRef = useRef(currentMode) // Keep refs in sync with props (synchronous update on each render) - currentInputRef.current = currentInput; - pastedContentsRef.current = pastedContents; - currentModeRef.current = currentMode; - const setInputWithCursor = useCallback((value: string, mode: HistoryMode, contents: Record, cursorToStart = false): void => { - onSetInput(value, mode, contents); - setCursorOffset?.(cursorToStart ? 0 : value.length); - }, [onSetInput, setCursorOffset]); - const updateInput = useCallback((input: HistoryEntry | undefined, cursorToStart_0 = false): void => { - if (!input || !input.display) return; - const mode_0 = getModeFromInput(input.display); - const value_0 = mode_0 === 'bash' ? input.display.slice(1) : input.display; - setInputWithCursor(value_0, mode_0, input.pastedContents ?? {}, cursorToStart_0); - }, [setInputWithCursor]); + currentInputRef.current = currentInput + pastedContentsRef.current = pastedContents + currentModeRef.current = currentMode + + const setInputWithCursor = useCallback( + ( + value: string, + mode: HistoryMode, + contents: Record, + cursorToStart = false, + ): void => { + onSetInput(value, mode, contents) + setCursorOffset?.(cursorToStart ? 0 : value.length) + }, + [onSetInput, setCursorOffset], + ) + + const updateInput = useCallback( + (input: HistoryEntry | undefined, cursorToStart = false): void => { + if (!input || !input.display) return + + const mode = getModeFromInput(input.display) + const value = mode === 'bash' ? input.display.slice(1) : input.display + + setInputWithCursor(value, mode, input.pastedContents ?? {}, cursorToStart) + }, + [setInputWithCursor], + ) + const showSearchHint = useCallback((): void => { addNotification({ key: 'search-history-hint', - jsx: - - , + jsx: ( + + + + ), priority: 'immediate', - timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT - }); - }, [addNotification]); + timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT, + }) + }, [addNotification]) + const onHistoryUp = useCallback((): void => { // Capture and increment synchronously to handle rapid keypresses - const targetIndex = historyIndexRef.current; - historyIndexRef.current++; - const inputAtPress = currentInputRef.current; - const pastedContentsAtPress = pastedContentsRef.current; - const modeAtPress = currentModeRef.current; + const targetIndex = historyIndexRef.current + historyIndexRef.current++ + + const inputAtPress = currentInputRef.current + const pastedContentsAtPress = pastedContentsRef.current + const modeAtPress = currentModeRef.current + if (targetIndex === 0) { - initialModeFilterRef.current = modeAtPress === 'bash' ? modeAtPress : undefined; + initialModeFilterRef.current = + modeAtPress === 'bash' ? modeAtPress : undefined // Save draft synchronously using refs for the latest values // This ensures we capture the draft before any async operations or re-renders - const hasInput = inputAtPress.trim() !== ''; - setLastShownHistoryEntry(hasInput ? { - display: inputAtPress, - pastedContents: pastedContentsAtPress, - mode: modeAtPress - } : undefined); + const hasInput = inputAtPress.trim() !== '' + setLastShownHistoryEntry( + hasInput + ? { + display: inputAtPress, + pastedContents: pastedContentsAtPress, + mode: modeAtPress, + } + : undefined, + ) } - const modeFilter = initialModeFilterRef.current; + + const modeFilter = initialModeFilterRef.current + void (async () => { - const neededCount = targetIndex + 1; // How many entries we need + const neededCount = targetIndex + 1 // How many entries we need // If mode filter changed, invalidate cache if (historyCacheModeFilter.current !== modeFilter) { - historyCache.current = []; - historyCacheModeFilter.current = modeFilter; - historyIndexRef.current = 0; + historyCache.current = [] + historyCacheModeFilter.current = modeFilter + historyIndexRef.current = 0 } // Load more entries if needed if (historyCache.current.length < neededCount) { // Batches concurrent requests - rapid keypresses share a single disk read - const entries = await loadHistoryEntries(neededCount, modeFilter); + const entries = await loadHistoryEntries(neededCount, modeFilter) // Only update cache if we loaded more than currently cached // (handles race condition where multiple loads complete out of order) if (entries.length > historyCache.current.length) { - historyCache.current = entries; + historyCache.current = entries } } // Check if we can navigate if (targetIndex >= historyCache.current.length) { // Rollback the ref since we can't navigate - historyIndexRef.current--; + historyIndexRef.current-- // Keep the draft intact - user stays on their current input - return; + return } - const newIndex = targetIndex + 1; - setHistoryIndex(newIndex); - updateInput(historyCache.current[targetIndex], true); + + const newIndex = targetIndex + 1 + setHistoryIndex(newIndex) + updateInput(historyCache.current[targetIndex], true) // Show hint once per session after navigating through 2 history entries if (newIndex >= 2 && !hasShownSearchHintRef.current) { - hasShownSearchHintRef.current = true; - showSearchHint(); + hasShownSearchHintRef.current = true + showSearchHint() } - })(); - }, [updateInput, showSearchHint]); + })() + }, [updateInput, showSearchHint]) + const onHistoryDown = useCallback((): boolean => { // Use the ref for consistent reads - const currentIndex = historyIndexRef.current; + const currentIndex = historyIndexRef.current if (currentIndex > 1) { - historyIndexRef.current--; - setHistoryIndex(currentIndex - 1); - updateInput(historyCache.current[currentIndex - 2]); + historyIndexRef.current-- + setHistoryIndex(currentIndex - 1) + updateInput(historyCache.current[currentIndex - 2]) } else if (currentIndex === 1) { - historyIndexRef.current = 0; - setHistoryIndex(0); + historyIndexRef.current = 0 + setHistoryIndex(0) if (lastShownHistoryEntry) { // Restore the draft with its saved mode if available - const savedMode = lastShownHistoryEntry.mode; + const savedMode = lastShownHistoryEntry.mode if (savedMode) { - setInputWithCursor(lastShownHistoryEntry.display, savedMode, lastShownHistoryEntry.pastedContents ?? {}); + setInputWithCursor( + lastShownHistoryEntry.display, + savedMode, + lastShownHistoryEntry.pastedContents ?? {}, + ) } else { - updateInput(lastShownHistoryEntry); + updateInput(lastShownHistoryEntry) } } else { // When in filtered mode, stay in that mode when clearing input - setInputWithCursor('', initialModeFilterRef.current ?? 'prompt', {}); + setInputWithCursor('', initialModeFilterRef.current ?? 'prompt', {}) } } - return currentIndex <= 0; - }, [lastShownHistoryEntry, updateInput, setInputWithCursor]); + return currentIndex <= 0 + }, [lastShownHistoryEntry, updateInput, setInputWithCursor]) + const resetHistory = useCallback((): void => { - setLastShownHistoryEntry(undefined); - setHistoryIndex(0); - historyIndexRef.current = 0; - initialModeFilterRef.current = undefined; - removeNotification('search-history-hint'); - historyCache.current = []; - historyCacheModeFilter.current = undefined; - }, [removeNotification]); + setLastShownHistoryEntry(undefined) + setHistoryIndex(0) + historyIndexRef.current = 0 + initialModeFilterRef.current = undefined + removeNotification('search-history-hint') + historyCache.current = [] + historyCacheModeFilter.current = undefined + }, [removeNotification]) + const dismissSearchHint = useCallback((): void => { - removeNotification('search-history-hint'); - }, [removeNotification]); + removeNotification('search-history-hint') + }, [removeNotification]) + return { historyIndex, setHistoryIndex, onHistoryUp, onHistoryDown, resetHistory, - dismissSearchHint - }; + dismissSearchHint, + } } diff --git a/src/hooks/useCanUseTool.tsx b/src/hooks/useCanUseTool.tsx index 1ce77d843..78b9397c0 100644 --- a/src/hooks/useCanUseTool.tsx +++ b/src/hooks/useCanUseTool.tsx @@ -1,203 +1,354 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import { APIUserAbortError } from '@anthropic-ai/sdk'; -import * as React from 'react'; -import { useCallback } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'; -import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'; -import { Text } from '../ink.js'; -import type { ToolPermissionContext, Tool as ToolType, ToolUseContext } from '../Tool.js'; -import { consumeSpeculativeClassifierCheck, peekSpeculativeClassifierCheck } from '../tools/BashTool/bashPermissions.js'; -import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'; -import type { AssistantMessage } from '../types/message.js'; -import { recordAutoModeDenial } from '../utils/autoModeDenials.js'; -import { clearClassifierChecking, setClassifierApproval, setYoloClassifierApproval } from '../utils/classifierApprovals.js'; -import { logForDebugging } from '../utils/debug.js'; -import { AbortError } from '../utils/errors.js'; -import { logError } from '../utils/log.js'; -import type { PermissionDecision } from '../utils/permissions/PermissionResult.js'; -import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js'; -import { jsonStringify } from '../utils/slowOperations.js'; -import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js'; -import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js'; -import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js'; -import { createPermissionContext, createPermissionQueueOps } from './toolPermission/PermissionContext.js'; -import { logPermissionDecision } from './toolPermission/permissionLogging.js'; -export type CanUseToolFn = Record> = (tool: ToolType, input: Input, toolUseContext: ToolUseContext, assistantMessage: AssistantMessage, toolUseID: string, forceDecision?: PermissionDecision) => Promise>; -function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) { - const $ = _c(3); - let t0; - if ($[0] !== setToolPermissionContext || $[1] !== setToolUseConfirmQueue) { - t0 = async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => new Promise(resolve => { - const ctx = createPermissionContext(tool, input, toolUseContext, assistantMessage, toolUseID, setToolPermissionContext, createPermissionQueueOps(setToolUseConfirmQueue)); - if (ctx.resolveIfAborted(resolve)) { - return; - } - const decisionPromise = forceDecision !== undefined ? Promise.resolve(forceDecision) : hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID); - return decisionPromise.then(async result => { - if (result.behavior === "allow") { - if (ctx.resolveIfAborted(resolve)) { - return; - } - if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") { - setYoloClassifierApproval(toolUseID, result.decisionReason.reason); - } - ctx.logDecision({ - decision: "accept", - source: "config" - }); - resolve(ctx.buildAllow(result.updatedInput ?? input, { - decisionReason: result.decisionReason - })); - return; - } - const appState = toolUseContext.getAppState(); - const description = await tool.description(input as never, { - isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession, - toolPermissionContext: appState.toolPermissionContext, - tools: toolUseContext.options.tools - }); - if (ctx.resolveIfAborted(resolve)) { - return; - } - switch (result.behavior) { - case "deny": - { - logPermissionDecision({ +import { feature } from 'bun:bundle' +import { APIUserAbortError } from '@anthropic-ai/sdk' +import * as React from 'react' +import { useCallback } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' +import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' +import { Text } from '../ink.js' +import type { + ToolPermissionContext, + Tool as ToolType, + ToolUseContext, +} from '../Tool.js' +import { + consumeSpeculativeClassifierCheck, + peekSpeculativeClassifierCheck, +} from '../tools/BashTool/bashPermissions.js' +import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' +import type { AssistantMessage } from '../types/message.js' +import { recordAutoModeDenial } from '../utils/autoModeDenials.js' +import { + clearClassifierChecking, + setClassifierApproval, + setYoloClassifierApproval, +} from '../utils/classifierApprovals.js' +import { logForDebugging } from '../utils/debug.js' +import { AbortError } from '../utils/errors.js' +import { logError } from '../utils/log.js' +import type { PermissionDecision } from '../utils/permissions/PermissionResult.js' +import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js' +import { jsonStringify } from '../utils/slowOperations.js' +import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js' +import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js' +import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js' +import { + createPermissionContext, + createPermissionQueueOps, +} from './toolPermission/PermissionContext.js' +import { logPermissionDecision } from './toolPermission/permissionLogging.js' + +export type CanUseToolFn< + Input extends Record = Record, +> = ( + tool: ToolType, + input: Input, + toolUseContext: ToolUseContext, + assistantMessage: AssistantMessage, + toolUseID: string, + forceDecision?: PermissionDecision, +) => Promise> + +function useCanUseTool( + setToolUseConfirmQueue: React.Dispatch< + React.SetStateAction + >, + setToolPermissionContext: (context: ToolPermissionContext) => void, +): CanUseToolFn { + return useCallback( + async ( + tool, + input, + toolUseContext, + assistantMessage, + toolUseID, + forceDecision, + ) => { + return new Promise(resolve => { + const ctx = createPermissionContext( + tool, + input, + toolUseContext, + assistantMessage, + toolUseID, + setToolPermissionContext, + createPermissionQueueOps(setToolUseConfirmQueue), + ) + + if (ctx.resolveIfAborted(resolve)) return + + const decisionPromise = + forceDecision !== undefined + ? Promise.resolve(forceDecision) + : hasPermissionsToUseTool( tool, input, toolUseContext, - messageId: ctx.messageId, - toolUseID - }, { - decision: "reject", - source: "config" - }); - if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") { - recordAutoModeDenial({ - toolName: tool.name, - display: description, - reason: result.decisionReason.reason ?? "", - timestamp: Date.now() - }); - toolUseContext.addNotification?.({ - key: "auto-mode-denied", - priority: "immediate", - jsx: <>{tool.userFacingName(input).toLowerCase()} denied by auto mode · /permissions - }); + assistantMessage, + toolUseID, + ) + + return decisionPromise + .then(async result => { + // [ANT-ONLY] Log all tool permission decisions with tool name and args + if (process.env.USER_TYPE === 'ant') { + logEvent('tengu_internal_tool_permission_decision', { + toolName: sanitizeToolNameForAnalytics(tool.name), + behavior: + result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + // Note: input contains code/filepaths, only log for ants + input: jsonStringify( + input, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + messageID: + ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + isMcp: tool.isMcp ?? false, + }) + } + + // Has permissions to use tool, granted in config + if (result.behavior === 'allow') { + if (ctx.resolveIfAborted(resolve)) return + // Track auto mode classifier approvals for UI display + if ( + feature('TRANSCRIPT_CLASSIFIER') && + result.decisionReason?.type === 'classifier' && + result.decisionReason.classifier === 'auto-mode' + ) { + setYoloClassifierApproval( + toolUseID, + result.decisionReason.reason, + ) } - resolve(result); - return; + + ctx.logDecision({ decision: 'accept', source: 'config' }) + + resolve( + ctx.buildAllow(result.updatedInput ?? input, { + decisionReason: result.decisionReason, + }), + ) + return } - case "ask": - { - if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) { - const coordinatorDecision = await handleCoordinatorPermission({ - ctx, - ...(feature("BASH_CLASSIFIER") ? { - pendingClassifierCheck: result.pendingClassifierCheck - } : {}), - updatedInput: result.updatedInput, - suggestions: result.suggestions, - permissionMode: appState.toolPermissionContext.mode - }); - if (coordinatorDecision) { - resolve(coordinatorDecision); - return; + + const appState = toolUseContext.getAppState() + const description = await tool.description(input as never, { + isNonInteractiveSession: + toolUseContext.options.isNonInteractiveSession, + toolPermissionContext: appState.toolPermissionContext, + tools: toolUseContext.options.tools, + }) + + if (ctx.resolveIfAborted(resolve)) return + + // Does not have permissions to use tool, check the behavior + switch (result.behavior) { + case 'deny': { + logPermissionDecision( + { + tool, + input, + toolUseContext, + messageId: ctx.messageId, + toolUseID, + }, + { decision: 'reject', source: 'config' }, + ) + if ( + feature('TRANSCRIPT_CLASSIFIER') && + result.decisionReason?.type === 'classifier' && + result.decisionReason.classifier === 'auto-mode' + ) { + recordAutoModeDenial({ + toolName: tool.name, + display: description, + reason: result.decisionReason.reason ?? '', + timestamp: Date.now(), + }) + toolUseContext.addNotification?.({ + key: 'auto-mode-denied', + priority: 'immediate', + jsx: ( + <> + + {tool.userFacingName(input).toLowerCase()} denied by + auto mode + + · /permissions + + ), + }) } + resolve(result) + return } - if (ctx.resolveIfAborted(resolve)) { - return; - } - const swarmDecision = await handleSwarmWorkerPermission({ - ctx, - description, - ...(feature("BASH_CLASSIFIER") ? { - pendingClassifierCheck: result.pendingClassifierCheck - } : {}), - updatedInput: result.updatedInput, - suggestions: result.suggestions - }); - if (swarmDecision) { - resolve(swarmDecision); - return; - } - if (feature("BASH_CLASSIFIER") && result.pendingClassifierCheck && tool.name === BASH_TOOL_NAME && !appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) { - const speculativePromise = peekSpeculativeClassifierCheck((input as { - command: string; - }).command); - if (speculativePromise) { - const raceResult = await Promise.race([speculativePromise.then(_temp), new Promise(_temp2)]); - if (ctx.resolveIfAborted(resolve)) { - return; + + case 'ask': { + // For coordinator workers, await automated checks before showing dialog. + // Background workers should only interrupt the user when automated checks can't decide. + if ( + appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog + ) { + const coordinatorDecision = await handleCoordinatorPermission( + { + ctx, + ...(feature('BASH_CLASSIFIER') + ? { + pendingClassifierCheck: + result.pendingClassifierCheck, + } + : {}), + updatedInput: result.updatedInput, + suggestions: result.suggestions, + permissionMode: appState.toolPermissionContext.mode, + }, + ) + if (coordinatorDecision) { + resolve(coordinatorDecision) + return } - if ((raceResult as any).type === "result" && (raceResult as any).result.matches && (raceResult as any).result.confidence === "high" && feature("BASH_CLASSIFIER")) { - consumeSpeculativeClassifierCheck((input as { - command: string; - }).command); - const matchedRule = (raceResult as any).result.matchedDescription ?? undefined; - if (matchedRule) { - setClassifierApproval(toolUseID, matchedRule); - } - ctx.logDecision({ - decision: "accept", - source: { - type: "classifier" + // null means neither automated check resolved -- fall through to dialog below. + // Hooks already ran, classifier already consumed. + } + + // After awaiting automated checks, verify the request wasn't aborted + // while we were waiting. Without this check, a stale dialog could appear. + if (ctx.resolveIfAborted(resolve)) return + + // For swarm workers, try classifier auto-approval then + // forward permission requests to the leader via mailbox. + const swarmDecision = await handleSwarmWorkerPermission({ + ctx, + description, + ...(feature('BASH_CLASSIFIER') + ? { + pendingClassifierCheck: result.pendingClassifierCheck, } - }); - resolve(ctx.buildAllow(result.updatedInput ?? input as Record, { - decisionReason: { - type: "classifier" as const, - classifier: "bash_allow" as const, - reason: `Allowed by prompt rule: "${(raceResult as any).result.matchedDescription}"` + : {}), + updatedInput: result.updatedInput, + suggestions: result.suggestions, + }) + if (swarmDecision) { + resolve(swarmDecision) + return + } + + // Grace period: wait up to 2s for speculative classifier + // to resolve before showing the dialog (main agent only) + if ( + feature('BASH_CLASSIFIER') && + result.pendingClassifierCheck && + tool.name === BASH_TOOL_NAME && + !appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog + ) { + const speculativePromise = peekSpeculativeClassifierCheck( + (input as { command: string }).command, + ) + if (speculativePromise) { + const raceResult = await Promise.race([ + speculativePromise.then(r => ({ + type: 'result' as const, + result: r, + })), + new Promise<{ type: 'timeout' }>(res => + // eslint-disable-next-line no-restricted-syntax -- resolves with a value, not void + setTimeout(res, 2000, { type: 'timeout' as const }), + ), + ]) + + if (ctx.resolveIfAborted(resolve)) return + + if ( + raceResult.type === 'result' && + raceResult.result.matches && + raceResult.result.confidence === 'high' && + feature('BASH_CLASSIFIER') + ) { + // Classifier approved within grace period — skip dialog + void consumeSpeculativeClassifierCheck( + (input as { command: string }).command, + ) + + const matchedRule = + raceResult.result.matchedDescription ?? undefined + if (matchedRule) { + setClassifierApproval(toolUseID, matchedRule) } - })); - return; + + ctx.logDecision({ + decision: 'accept', + source: { type: 'classifier' }, + }) + resolve( + ctx.buildAllow( + result.updatedInput ?? + (input as Record), + { + decisionReason: { + type: 'classifier' as const, + classifier: 'bash_allow' as const, + reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`, + }, + }, + ), + ) + return + } + // Timeout or no match — fall through to show dialog } } + + // Show dialog and start hooks/classifier in background + handleInteractivePermission( + { + ctx, + description, + result, + awaitAutomatedChecksBeforeDialog: + appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog, + bridgeCallbacks: feature('BRIDGE_MODE') + ? appState.replBridgePermissionCallbacks + : undefined, + channelCallbacks: + feature('KAIROS') || feature('KAIROS_CHANNELS') + ? appState.channelPermissionCallbacks + : undefined, + }, + resolve, + ) + + return } - handleInteractivePermission({ - ctx, - description, - result, - awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog, - bridgeCallbacks: feature("BRIDGE_MODE") ? appState.replBridgePermissionCallbacks : undefined, - channelCallbacks: feature("KAIROS") || feature("KAIROS_CHANNELS") ? appState.channelPermissionCallbacks : undefined - }, resolve); - return; } - } - }).catch(error => { - if (error instanceof AbortError || error instanceof APIUserAbortError) { - logForDebugging(`Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`); - ctx.logCancelled(); - resolve(ctx.cancelAndAbort(undefined, true)); - } else { - logError(error); - resolve(ctx.cancelAndAbort(undefined, true)); - } - }).finally(() => { - clearClassifierChecking(toolUseID); - }); - }); - $[0] = setToolPermissionContext; - $[1] = setToolUseConfirmQueue; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; -} -function _temp2(res) { - return setTimeout(res, 2000, { - type: "timeout" as const - }); -} -function _temp(r) { - return { - type: "result" as const, - result: r - }; + }) + .catch(error => { + if ( + error instanceof AbortError || + error instanceof APIUserAbortError + ) { + logForDebugging( + `Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`, + ) + ctx.logCancelled() + resolve(ctx.cancelAndAbort(undefined, true)) + } else { + logError(error) + resolve(ctx.cancelAndAbort(undefined, true)) + } + }) + .finally(() => { + clearClassifierChecking(toolUseID) + }) + }) + }, + [setToolUseConfirmQueue, setToolPermissionContext], + ) } -export default useCanUseTool; + +export default useCanUseTool diff --git a/src/hooks/useChromeExtensionNotification.tsx b/src/hooks/useChromeExtensionNotification.tsx index a32aac968..dc058df0e 100644 --- a/src/hooks/useChromeExtensionNotification.tsx +++ b/src/hooks/useChromeExtensionNotification.tsx @@ -1,42 +1,66 @@ -import * as React from 'react'; -import { Text } from '../ink.js'; -import { isClaudeAISubscriber } from '../utils/auth.js'; -import { isChromeExtensionInstalled, shouldEnableClaudeInChrome } from '../utils/claudeInChrome/setup.js'; -import { isRunningOnHomespace } from '../utils/envUtils.js'; -import { useStartupNotification } from './notifs/useStartupNotification.js'; +import * as React from 'react' +import { Text } from '../ink.js' +import { isClaudeAISubscriber } from '../utils/auth.js' +import { + isChromeExtensionInstalled, + shouldEnableClaudeInChrome, +} from '../utils/claudeInChrome/setup.js' +import { isRunningOnHomespace } from '../utils/envUtils.js' +import { useStartupNotification } from './notifs/useStartupNotification.js' + function getChromeFlag(): boolean | undefined { if (process.argv.includes('--chrome')) { - return true; + return true } if (process.argv.includes('--no-chrome')) { - return false; + return false } - return undefined; + return undefined } -export function useChromeExtensionNotification() { - useStartupNotification(_temp); -} -async function _temp() { - const chromeFlag = getChromeFlag(); - if (!shouldEnableClaudeInChrome(chromeFlag)) { - return null; - } - // Subscription check bypassed - const installed = await isChromeExtensionInstalled(); - if (!installed && !isRunningOnHomespace()) { - return { - key: "chrome-extension-not-detected", - jsx: Chrome extension not detected · https://claude.ai/chrome to install, - priority: "immediate", - timeoutMs: 3000 - }; - } - if (chromeFlag === undefined) { - return { - key: "claude-in-chrome-default-enabled", - text: "Claude in Chrome enabled \xB7 /chrome", - priority: "low" - }; - } - return null; + +export function useChromeExtensionNotification(): void { + useStartupNotification(async () => { + const chromeFlag = getChromeFlag() + if (!shouldEnableClaudeInChrome(chromeFlag)) return null + + // Claude in Chrome is only supported for claude.ai subscribers (unless user is ant) + if ("external" !== 'ant' && !isClaudeAISubscriber()) { + return { + key: 'chrome-requires-subscription', + jsx: ( + + Claude in Chrome requires a claude.ai subscription + + ), + priority: 'immediate', + timeoutMs: 5000, + } + } + + const installed = await isChromeExtensionInstalled() + if (!installed && !isRunningOnHomespace()) { + // Skip notification on Homespace since Chrome setup requires different steps (see go/hsproxy) + return { + key: 'chrome-extension-not-detected', + jsx: ( + + Chrome extension not detected · https://claude.ai/chrome to install + + ), + // TODO(hackyon): Lower the priority if the claude-in-chrome integration is no longer opt-in + priority: 'immediate', + timeoutMs: 3000, + } + } + if (chromeFlag === undefined) { + // Show low priority notification only when Chrome is enabled by default + // (not explicitly enabled with --chrome or disabled with --no-chrome) + return { + key: 'claude-in-chrome-default-enabled', + text: `Claude in Chrome enabled · /chrome`, + priority: 'low', + } + } + return null + }) } diff --git a/src/hooks/useClaudeCodeHintRecommendation.tsx b/src/hooks/useClaudeCodeHintRecommendation.tsx index 0b2291167..9e9aa1cf3 100644 --- a/src/hooks/useClaudeCodeHintRecommendation.tsx +++ b/src/hooks/useClaudeCodeHintRecommendation.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Surfaces plugin-install prompts driven by `` tags * that CLIs/SDKs emit to stderr. See docs/claude-code-hints.md. @@ -9,120 +8,117 @@ import { c as _c } from "react/compiler-runtime"; * anything that reaches this hook is worth resolving. */ -import * as React from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent } from '../services/analytics/index.js'; -import { clearPendingHint, getPendingHintSnapshot, markShownThisSession, subscribeToPendingHint } from '../utils/claudeCodeHints.js'; -import { logForDebugging } from '../utils/debug.js'; -import { disableHintRecommendations, markHintPluginShown, type PluginHintRecommendation, resolvePluginHint } from '../utils/plugins/hintRecommendation.js'; -import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js'; -import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; +import * as React from 'react' +import { useNotifications } from '../context/notifications.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + logEvent, +} from '../services/analytics/index.js' +import { + clearPendingHint, + getPendingHintSnapshot, + markShownThisSession, + subscribeToPendingHint, +} from '../utils/claudeCodeHints.js' +import { logForDebugging } from '../utils/debug.js' +import { + disableHintRecommendations, + markHintPluginShown, + type PluginHintRecommendation, + resolvePluginHint, +} from '../utils/plugins/hintRecommendation.js' +import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js' +import { + installPluginAndNotify, + usePluginRecommendationBase, +} from './usePluginRecommendationBase.js' + type UseClaudeCodeHintRecommendationResult = { - recommendation: PluginHintRecommendation | null; - handleResponse: (response: 'yes' | 'no' | 'disable') => void; -}; -export function useClaudeCodeHintRecommendation() { - const $ = _c(11); - const pendingHint = React.useSyncExternalStore(subscribeToPendingHint, getPendingHintSnapshot); - const { - addNotification - } = useNotifications(); - const { - recommendation, - clearRecommendation, - tryResolve - } = usePluginRecommendationBase(); - let t0; - let t1; - if ($[0] !== pendingHint || $[1] !== tryResolve) { - t0 = () => { - if (!pendingHint) { - return; + recommendation: PluginHintRecommendation | null + handleResponse: (response: 'yes' | 'no' | 'disable') => void +} + +export function useClaudeCodeHintRecommendation(): UseClaudeCodeHintRecommendationResult { + const pendingHint = React.useSyncExternalStore( + subscribeToPendingHint, + getPendingHintSnapshot, + ) + const { addNotification } = useNotifications() + const { recommendation, clearRecommendation, tryResolve } = + usePluginRecommendationBase() + + React.useEffect(() => { + if (!pendingHint) return + tryResolve(async () => { + const resolved = await resolvePluginHint(pendingHint) + if (resolved) { + logForDebugging( + `[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`, + ) + markShownThisSession() } - tryResolve(async () => { - const resolved = await resolvePluginHint(pendingHint); - if (resolved) { - logForDebugging(`[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`); - markShownThisSession(); - } - if (getPendingHintSnapshot() === pendingHint) { - clearPendingHint(); - } - return resolved; - }); - }; - t1 = [pendingHint, tryResolve]; - $[0] = pendingHint; - $[1] = tryResolve; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - React.useEffect(t0, t1); - let t2; - if ($[4] !== addNotification || $[5] !== clearRecommendation || $[6] !== recommendation) { - t2 = response => { - if (!recommendation) { - return; + // Drop the slot — but only if it still holds the hint we just + // resolved. A newer hint may have overwritten it during the async + // lookup; don't clobber that. + if (getPendingHintSnapshot() === pendingHint) { + clearPendingHint() } - markHintPluginShown(recommendation.pluginId); - logEvent("tengu_plugin_hint_response", { - _PROTO_plugin_name: recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - _PROTO_marketplace_name: recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - response: response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - bb15: switch (response) { - case "yes": - { - const { - pluginId, - pluginName, - marketplaceName - } = recommendation; - installPluginAndNotify(pluginId, pluginName, "hint-plugin", addNotification, async pluginData => { + return resolved + }) + }, [pendingHint, tryResolve]) + + const handleResponse = React.useCallback( + (response: 'yes' | 'no' | 'disable') => { + if (!recommendation) return + + // Record show-once here, not at resolution-time — the dialog may have + // been blocked by a higher-priority focusedInputDialog and never + // rendered. Auto-dismiss reaches this via onResponse('no'). + markHintPluginShown(recommendation.pluginId) + logEvent('tengu_plugin_hint_response', { + _PROTO_plugin_name: + recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + _PROTO_marketplace_name: + recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + response: + response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + switch (response) { + case 'yes': { + const { pluginId, pluginName, marketplaceName } = recommendation + void installPluginAndNotify( + pluginId, + pluginName, + 'hint-plugin', + addNotification, + async pluginData => { const result = await installPluginFromMarketplace({ pluginId, entry: pluginData.entry, marketplaceName, - scope: "user", - trigger: "hint" - }); + scope: 'user', + trigger: 'hint', + }) if (!result.success) { - throw new Error((result as any).error); + throw new Error(result.error) } - }); - break bb15; - } - case "disable": - { - disableHintRecommendations(); - break bb15; - } - case "no": + }, + ) + break + } + case 'disable': + disableHintRecommendations() + break + case 'no': + break } - clearRecommendation(); - }; - $[4] = addNotification; - $[5] = clearRecommendation; - $[6] = recommendation; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleResponse = t2; - let t3; - if ($[8] !== handleResponse || $[9] !== recommendation) { - t3 = { - recommendation, - handleResponse - }; - $[8] = handleResponse; - $[9] = recommendation; - $[10] = t3; - } else { - t3 = $[10]; - } - return t3; + + clearRecommendation() + }, + [recommendation, addNotification, clearRecommendation], + ) + + return { recommendation, handleResponse } } diff --git a/src/hooks/useCommandKeybindings.tsx b/src/hooks/useCommandKeybindings.tsx index 581e43796..416a07ce7 100644 --- a/src/hooks/useCommandKeybindings.tsx +++ b/src/hooks/useCommandKeybindings.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Component that registers keybinding handlers for command bindings. * @@ -9,99 +8,75 @@ import { c as _c } from "react/compiler-runtime"; * Commands triggered via keybinding are treated as "immediate" - they execute right * away and preserve the user's existing input text (the prompt is not cleared). */ -import { useMemo } from 'react'; -import { useIsModalOverlayActive } from '../context/overlayContext.js'; -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js'; +import { useMemo } from 'react' +import { useIsModalOverlayActive } from '../context/overlayContext.js' +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js' + type Props = { // onSubmit accepts additional parameters beyond what we pass here, // so we use a rest parameter to allow any additional args - onSubmit: (input: string, helpers: PromptInputHelpers, ...rest: [speculationAccept?: undefined, options?: { - fromKeybinding?: boolean; - }]) => void; + onSubmit: ( + input: string, + helpers: PromptInputHelpers, + ...rest: [ + speculationAccept?: undefined, + options?: { fromKeybinding?: boolean }, + ] + ) => void /** Set to false to disable command keybindings (e.g., when a dialog is open) */ - isActive?: boolean; -}; + isActive?: boolean +} + const NOOP_HELPERS: PromptInputHelpers = { setCursorOffset: () => {}, clearBuffer: () => {}, - resetHistory: () => {} -}; + resetHistory: () => {}, +} /** * Registers keybinding handlers for all "command:*" actions found in the * user's keybinding configuration. When triggered, each handler submits * the corresponding slash command (e.g., "command:commit" submits "/commit"). */ -export function CommandKeybindingHandlers(t0) { - const $ = _c(8); - const { - onSubmit, - isActive: t1 - } = t0; - const isActive = t1 === undefined ? true : t1; - const keybindingContext = useOptionalKeybindingContext(); - const isModalOverlayActive = useIsModalOverlayActive(); - let t2; - bb0: { - if (!keybindingContext) { - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = new Set(); - $[0] = t3; - } else { - t3 = $[0]; - } - t2 = t3; - break bb0; - } - let actions; - if ($[1] !== keybindingContext.bindings) { - actions = new Set(); - for (const binding of keybindingContext.bindings) { - if (binding.action?.startsWith("command:")) { - actions.add(binding.action); - } +export function CommandKeybindingHandlers({ + onSubmit, + isActive = true, +}: Props): null { + const keybindingContext = useOptionalKeybindingContext() + const isModalOverlayActive = useIsModalOverlayActive() + + // Extract command actions from parsed bindings + const commandActions = useMemo(() => { + if (!keybindingContext) return new Set() + const actions = new Set() + for (const binding of keybindingContext.bindings) { + if (binding.action?.startsWith('command:')) { + actions.add(binding.action) } - $[1] = keybindingContext.bindings; - $[2] = actions; - } else { - actions = $[2]; } - t2 = actions; - } - const commandActions = t2; - let map; - if ($[3] !== commandActions || $[4] !== onSubmit) { - map = {}; + return actions + }, [keybindingContext]) + + // Build handler map for all command actions + const handlers = useMemo(() => { + const map: Record void> = {} for (const action of commandActions) { - const commandName = action.slice(8); + const commandName = action.slice('command:'.length) map[action] = () => { onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, { - fromKeybinding: true - }); - }; + fromKeybinding: true, + }) + } } - $[3] = commandActions; - $[4] = onSubmit; - $[5] = map; - } else { - map = $[5]; - } - const handlers = map; - const t3 = isActive && !isModalOverlayActive; - let t4; - if ($[6] !== t3) { - t4 = { - context: "Chat", - isActive: t3 - }; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - useKeybindings(handlers, t4); - return null; + return map + }, [commandActions, onSubmit]) + + useKeybindings(handlers, { + context: 'Chat', + isActive: isActive && !isModalOverlayActive, + }) + + return null } diff --git a/src/hooks/useGlobalKeybindings.tsx b/src/hooks/useGlobalKeybindings.tsx index faaf1fe82..a41b1b6a5 100644 --- a/src/hooks/useGlobalKeybindings.tsx +++ b/src/hooks/useGlobalKeybindings.tsx @@ -4,27 +4,31 @@ * Must be rendered inside KeybindingSetup to have access to the keybinding context. * This component renders nothing - it just registers the keybinding handlers. */ -import { feature } from 'bun:bundle'; -import { useCallback } from 'react'; -import instances from '../ink/instances.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import type { Screen } from '../screens/REPL.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { count } from '../utils/array.js'; -import { getTerminalPanel } from '../utils/terminalPanel.js'; +import { feature } from 'bun:bundle' +import { useCallback } from 'react' +import instances from '../ink/instances.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import type { Screen } from '../screens/REPL.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { count } from '../utils/array.js' +import { getTerminalPanel } from '../utils/terminalPanel.js' + type Props = { - screen: Screen; - setScreen: React.Dispatch>; - showAllInTranscript: boolean; - setShowAllInTranscript: React.Dispatch>; - messageCount: number; - onEnterTranscript?: () => void; - onExitTranscript?: () => void; - virtualScrollActive?: boolean; - searchBarOpen?: boolean; -}; + screen: Screen + setScreen: React.Dispatch> + showAllInTranscript: boolean + setShowAllInTranscript: React.Dispatch> + messageCount: number + onEnterTranscript?: () => void + onExitTranscript?: () => void + virtualScrollActive?: boolean + searchBarOpen?: boolean +} /** * Registers global keybinding handlers for: @@ -42,56 +46,55 @@ export function GlobalKeybindingHandlers({ onEnterTranscript, onExitTranscript, virtualScrollActive, - searchBarOpen = false + searchBarOpen = false, }: Props): null { - const expandedView = useAppState(s => s.expandedView); - const setAppState = useSetAppState(); + const expandedView = useAppState(s => s.expandedView) + const setAppState = useSetAppState() // Toggle todo list (ctrl+t) - cycles through views const handleToggleTodos = useCallback(() => { logEvent('tengu_toggle_todos', { - is_expanded: expandedView === 'tasks' - }); + is_expanded: expandedView === 'tasks', + }) setAppState(prev => { - const { - getAllInProcessTeammateTasks - } = - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js'); - const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0; + const { getAllInProcessTeammateTasks } = + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') + const hasTeammates = + count( + getAllInProcessTeammateTasks(prev.tasks), + t => t.status === 'running', + ) > 0 + if (hasTeammates) { // Both exist: none → tasks → teammates → none switch (prev.expandedView) { case 'none': - return { - ...prev, - expandedView: 'tasks' as const - }; + return { ...prev, expandedView: 'tasks' as const } case 'tasks': - return { - ...prev, - expandedView: 'teammates' as const - }; + return { ...prev, expandedView: 'teammates' as const } case 'teammates': - return { - ...prev, - expandedView: 'none' as const - }; + return { ...prev, expandedView: 'none' as const } } } // Only tasks: none ↔ tasks return { ...prev, - expandedView: prev.expandedView === 'tasks' ? 'none' as const : 'tasks' as const - }; - }); - }, [expandedView, setAppState]); + expandedView: + prev.expandedView === 'tasks' + ? ('none' as const) + : ('tasks' as const), + } + }) + }, [expandedView, setAppState]) // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript. // Brief view has its own dedicated toggle on ctrl+shift+b. - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.isBriefOnly) : false; + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false const handleToggleTranscript = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // Escape hatch: GB kill-switch while defaultView=chat was persisted @@ -100,58 +103,71 @@ export function GlobalKeybindingHandlers({ // Only needed in the prompt screen — transcript mode already ignores // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode). /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEnabled - } = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js'); + const { isBriefEnabled } = + require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') { - setAppState(prev_0 => { - if (!prev_0.isBriefOnly) return prev_0; - return { - ...prev_0, - isBriefOnly: false - }; - }); - return; + setAppState(prev => { + if (!prev.isBriefOnly) return prev + return { ...prev, isBriefOnly: false } + }) + return } } - const isEnteringTranscript = screen !== 'transcript'; + + const isEnteringTranscript = screen !== 'transcript' logEvent('tengu_toggle_transcript', { is_entering: isEnteringTranscript, show_all: showAllInTranscript, - message_count: messageCount - }); - setScreen(s_1 => s_1 === 'transcript' ? 'prompt' : 'transcript'); - setShowAllInTranscript(false); + message_count: messageCount, + }) + setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript')) + setShowAllInTranscript(false) if (isEnteringTranscript && onEnterTranscript) { - onEnterTranscript(); + onEnterTranscript() } if (!isEnteringTranscript && onExitTranscript) { - onExitTranscript(); + onExitTranscript() } - }, [screen, setScreen, isBriefOnly, showAllInTranscript, setShowAllInTranscript, messageCount, setAppState, onEnterTranscript, onExitTranscript]); + }, [ + screen, + setScreen, + isBriefOnly, + showAllInTranscript, + setShowAllInTranscript, + messageCount, + setAppState, + onEnterTranscript, + onExitTranscript, + ]) // Toggle showing all messages in transcript mode (ctrl+e) const handleToggleShowAll = useCallback(() => { logEvent('tengu_transcript_toggle_show_all', { is_expanding: !showAllInTranscript, - message_count: messageCount - }); - setShowAllInTranscript(prev_1 => !prev_1); - }, [showAllInTranscript, setShowAllInTranscript, messageCount]); + message_count: messageCount, + }) + setShowAllInTranscript(prev => !prev) + }, [showAllInTranscript, setShowAllInTranscript, messageCount]) // Exit transcript mode (ctrl+c or escape) const handleExitTranscript = useCallback(() => { logEvent('tengu_transcript_exit', { show_all: showAllInTranscript, - message_count: messageCount - }); - setScreen('prompt'); - setShowAllInTranscript(false); + message_count: messageCount, + }) + setScreen('prompt') + setShowAllInTranscript(false) if (onExitTranscript) { - onExitTranscript(); + onExitTranscript() } - }, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]); + }, [ + setScreen, + showAllInTranscript, + setShowAllInTranscript, + messageCount, + onExitTranscript, + ]) // Toggle brief-only view (ctrl+shift+b). Pure display filter toggle — // does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF @@ -160,81 +176,80 @@ export function GlobalKeybindingHandlers({ const handleToggleBrief = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEnabled: isBriefEnabled_0 - } = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js'); + const { isBriefEnabled } = + require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ - if (!isBriefEnabled_0() && !isBriefOnly) return; - const next = !isBriefOnly; + if (!isBriefEnabled() && !isBriefOnly) return + const next = !isBriefOnly logEvent('tengu_brief_mode_toggled', { enabled: next, gated: false, - source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - setAppState(prev_2 => { - if (prev_2.isBriefOnly === next) return prev_2; - return { - ...prev_2, - isBriefOnly: next - }; - }); + source: + 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + setAppState(prev => { + if (prev.isBriefOnly === next) return prev + return { ...prev, isBriefOnly: next } + }) } - }, [isBriefOnly, setAppState]); + }, [isBriefOnly, setAppState]) // Register keybinding handlers useKeybinding('app:toggleTodos', handleToggleTodos, { - context: 'Global' - }); + context: 'Global', + }) useKeybinding('app:toggleTranscript', handleToggleTranscript, { - context: 'Global' - }); + context: 'Global', + }) if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useKeybinding('app:toggleBrief', handleToggleBrief, { - context: 'Global' - }); + context: 'Global', + }) } // Register teammate keybinding - useKeybinding('app:toggleTeammatePreview', () => { - setAppState(prev_3 => ({ - ...prev_3, - showTeammateMessagePreview: !prev_3.showTeammateMessagePreview - })); - }, { - context: 'Global' - }); + useKeybinding( + 'app:toggleTeammatePreview', + () => { + setAppState(prev => ({ + ...prev, + showTeammateMessagePreview: !prev.showTeammateMessagePreview, + })) + }, + { + context: 'Global', + }, + ) // Toggle built-in terminal panel (meta+j). // toggle() blocks in spawnSync until the user detaches from tmux. const handleToggleTerminal = useCallback(() => { if (feature('TERMINAL_PANEL')) { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) { - return; + return } - getTerminalPanel().toggle(); + getTerminalPanel().toggle() } - }, []); + }, []) useKeybinding('app:toggleTerminal', handleToggleTerminal, { - context: 'Global' - }); + context: 'Global', + }) // Clear screen and force full redraw (ctrl+l). Recovery path when the // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine // thinks unchanged cells don't need repainting. const handleRedraw = useCallback(() => { - instances.get(process.stdout)?.forceRedraw(); - }, []); - useKeybinding('app:redraw', handleRedraw, { - context: 'Global' - }); + instances.get(process.stdout)?.forceRedraw() + }, []) + useKeybinding('app:redraw', handleRedraw, { context: 'Global' }) // Transcript-specific bindings (only active when in transcript mode) - const isInTranscript = screen === 'transcript'; + const isInTranscript = screen === 'transcript' useKeybinding('transcript:toggleShowAll', handleToggleShowAll, { context: 'Transcript', - isActive: isInTranscript && !virtualScrollActive - }); + isActive: isInTranscript && !virtualScrollActive, + }) useKeybinding('transcript:exit', handleExitTranscript, { context: 'Transcript', // Bar-open is a mode (owns keystrokes). Navigating (highlights @@ -242,7 +257,8 @@ export function GlobalKeybindingHandlers({ // directly, same as less q. useSearchInput doesn't stopPropagation, // so without this gate its onCancel AND this handler would both // fire on one Esc (child registers first, fires first, bubbles). - isActive: isInTranscript && !searchBarOpen - }); - return null; + isActive: isInTranscript && !searchBarOpen, + }) + + return null } diff --git a/src/hooks/useIDEIntegration.tsx b/src/hooks/useIDEIntegration.tsx index 29c50b7c6..786146ee7 100644 --- a/src/hooks/useIDEIntegration.tsx +++ b/src/hooks/useIDEIntegration.tsx @@ -1,69 +1,88 @@ -import { c as _c } from "react/compiler-runtime"; -import { useEffect } from 'react'; -import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js'; -import type { DetectedIDEInfo } from '../utils/ide.js'; -import { type IDEExtensionInstallationStatus, type IdeType, initializeIdeIntegration, isSupportedTerminal } from '../utils/ide.js'; +import { useEffect } from 'react' +import type { ScopedMcpServerConfig } from '../services/mcp/types.js' +import { getGlobalConfig } from '../utils/config.js' +import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js' +import type { DetectedIDEInfo } from '../utils/ide.js' +import { + type IDEExtensionInstallationStatus, + type IdeType, + initializeIdeIntegration, + isSupportedTerminal, +} from '../utils/ide.js' + type UseIDEIntegrationProps = { - autoConnectIdeFlag?: boolean; - ideToInstallExtension: IdeType | null; - setDynamicMcpConfig: React.Dispatch | undefined>>; - setShowIdeOnboarding: React.Dispatch>; - setIDEInstallationState: React.Dispatch>; -}; -export function useIDEIntegration(t0) { - const $ = _c(7); - const { + autoConnectIdeFlag?: boolean + ideToInstallExtension: IdeType | null + setDynamicMcpConfig: React.Dispatch< + React.SetStateAction | undefined> + > + setShowIdeOnboarding: React.Dispatch> + setIDEInstallationState: React.Dispatch< + React.SetStateAction + > +} + +export function useIDEIntegration({ + autoConnectIdeFlag, + ideToInstallExtension, + setDynamicMcpConfig, + setShowIdeOnboarding, + setIDEInstallationState, +}: UseIDEIntegrationProps): void { + useEffect(() => { + function addIde(ide: DetectedIDEInfo | null) { + if (!ide) { + return + } + + // Check if auto-connect is enabled + const globalConfig = getGlobalConfig() + const autoConnectEnabled = + (globalConfig.autoConnectIde || + autoConnectIdeFlag || + isSupportedTerminal() || + // tmux/screen overwrite TERM_PROGRAM, breaking terminal detection, but the + // IDE extension's port env var is inherited. If set, auto-connect anyway. + process.env.CLAUDE_CODE_SSE_PORT || + ideToInstallExtension || + isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && + !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE) + + if (!autoConnectEnabled) { + return + } + + setDynamicMcpConfig(prev => { + // Only add the IDE if we don't already have one + if (prev?.ide) { + return prev + } + return { + ...prev, + ide: { + type: ide.url.startsWith('ws:') ? 'ws-ide' : 'sse-ide', + url: ide.url, + ideName: ide.name, + authToken: ide.authToken, + ideRunningInWindows: ide.ideRunningInWindows, + scope: 'dynamic' as const, + }, + } + }) + } + + // Use the new utility function + void initializeIdeIntegration( + addIde, + ideToInstallExtension, + () => setShowIdeOnboarding(true), + status => setIDEInstallationState(status), + ) + }, [ autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, - setIDEInstallationState - } = t0; - let t1; - let t2; - if ($[0] !== autoConnectIdeFlag || $[1] !== ideToInstallExtension || $[2] !== setDynamicMcpConfig || $[3] !== setIDEInstallationState || $[4] !== setShowIdeOnboarding) { - t1 = () => { - const addIde = function addIde(ide) { - if (!ide) { - return; - } - const globalConfig = getGlobalConfig(); - const autoConnectEnabled = (globalConfig.autoConnectIde || autoConnectIdeFlag || isSupportedTerminal() || process.env.CLAUDE_CODE_SSE_PORT || ideToInstallExtension || isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE); - if (!autoConnectEnabled) { - return; - } - setDynamicMcpConfig(prev => { - if (prev?.ide) { - return prev; - } - return { - ...prev, - ide: { - type: ide.url.startsWith("ws:") ? "ws-ide" : "sse-ide", - url: ide.url, - ideName: ide.name, - authToken: ide.authToken, - ideRunningInWindows: ide.ideRunningInWindows, - scope: "dynamic" as const - } - }; - }); - }; - initializeIdeIntegration(addIde, ideToInstallExtension, () => setShowIdeOnboarding(true), status => setIDEInstallationState(status)); - }; - t2 = [autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState]; - $[0] = autoConnectIdeFlag; - $[1] = ideToInstallExtension; - $[2] = setDynamicMcpConfig; - $[3] = setIDEInstallationState; - $[4] = setShowIdeOnboarding; - $[5] = t1; - $[6] = t2; - } else { - t1 = $[5]; - t2 = $[6]; - } - useEffect(t1, t2); + setIDEInstallationState, + ]) } diff --git a/src/hooks/useLspPluginRecommendation.tsx b/src/hooks/useLspPluginRecommendation.tsx index aaffb43a2..610431a63 100644 --- a/src/hooks/useLspPluginRecommendation.tsx +++ b/src/hooks/useLspPluginRecommendation.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Hook for LSP plugin recommendations * @@ -11,183 +10,170 @@ import { c as _c } from "react/compiler-runtime"; * Only shows one recommendation per session. */ -import { extname, join } from 'path'; -import * as React from 'react'; -import { hasShownLspRecommendationThisSession, setLspRecommendationShownThisSession } from '../bootstrap/state.js'; -import { useNotifications } from '../context/notifications.js'; -import { useAppState } from '../state/AppState.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { logError } from '../utils/log.js'; -import { addToNeverSuggest, getMatchingLspPlugins, incrementIgnoredCount } from '../utils/plugins/lspRecommendation.js'; -import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js'; -import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; -import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; +import { extname, join } from 'path' +import * as React from 'react' +import { + hasShownLspRecommendationThisSession, + setLspRecommendationShownThisSession, +} from '../bootstrap/state.js' +import { useNotifications } from '../context/notifications.js' +import { useAppState } from '../state/AppState.js' +import { saveGlobalConfig } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { logError } from '../utils/log.js' +import { + addToNeverSuggest, + getMatchingLspPlugins, + incrementIgnoredCount, +} from '../utils/plugins/lspRecommendation.js' +import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js' +import { + getSettingsForSource, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { + installPluginAndNotify, + usePluginRecommendationBase, +} from './usePluginRecommendationBase.js' // Threshold for detecting timeout vs explicit dismiss (ms) // Menu auto-dismisses at 30s, so anything over 28s is likely timeout -const TIMEOUT_THRESHOLD_MS = 28_000; +const TIMEOUT_THRESHOLD_MS = 28_000 + export type LspRecommendationState = { - pluginId: string; - pluginName: string; - pluginDescription?: string; - fileExtension: string; - shownAt: number; // Timestamp for timeout detection -} | null; + pluginId: string + pluginName: string + pluginDescription?: string + fileExtension: string + shownAt: number // Timestamp for timeout detection +} | null + type UseLspPluginRecommendationResult = { - recommendation: LspRecommendationState; - handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void; -}; -export function useLspPluginRecommendation() { - const $ = _c(12); - const trackedFiles = useAppState(_temp); - const { - addNotification - } = useNotifications(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = new Set(); - $[0] = t0; - } else { - t0 = $[0]; - } - const checkedFilesRef = React.useRef(t0); - const { - recommendation, - clearRecommendation, - tryResolve - } = usePluginRecommendationBase(); - let t1; - let t2; - if ($[1] !== trackedFiles || $[2] !== tryResolve) { - t1 = () => { - tryResolve(async () => { - if (hasShownLspRecommendationThisSession()) { - return null; - } - const newFiles = []; - for (const file of trackedFiles) { - if (!checkedFilesRef.current.has(file)) { - checkedFilesRef.current.add(file); - newFiles.push(file); - } + recommendation: LspRecommendationState + handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void +} + +export function useLspPluginRecommendation(): UseLspPluginRecommendationResult { + const trackedFiles = useAppState(s => s.fileHistory.trackedFiles) + const { addNotification } = useNotifications() + const checkedFilesRef = React.useRef>(new Set()) + const { recommendation, clearRecommendation, tryResolve } = + usePluginRecommendationBase>() + + React.useEffect(() => { + tryResolve(async () => { + if (hasShownLspRecommendationThisSession()) return null + + const newFiles: string[] = [] + for (const file of trackedFiles) { + if (!checkedFilesRef.current.has(file)) { + checkedFilesRef.current.add(file) + newFiles.push(file) } - for (const filePath of newFiles) { - ; - try { - const matches = await getMatchingLspPlugins(filePath); - const match = matches[0]; - if (match) { - logForDebugging(`[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`); - setLspRecommendationShownThisSession(true); - return { - pluginId: match.pluginId, - pluginName: match.pluginName, - pluginDescription: match.description, - fileExtension: extname(filePath), - shownAt: Date.now() - }; + } + + for (const filePath of newFiles) { + try { + const matches = await getMatchingLspPlugins(filePath) + const match = matches[0] // official plugins prioritized + if (match) { + logForDebugging( + `[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`, + ) + setLspRecommendationShownThisSession(true) + return { + pluginId: match.pluginId, + pluginName: match.pluginName, + pluginDescription: match.description, + fileExtension: extname(filePath), + shownAt: Date.now(), } - } catch (t3) { - const error = t3; - logError(error); } + } catch (error) { + logError(error) } - return null; - }); - }; - t2 = [trackedFiles, tryResolve]; - $[1] = trackedFiles; - $[2] = tryResolve; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - React.useEffect(t1, t2); - let t3; - if ($[5] !== addNotification || $[6] !== clearRecommendation || $[7] !== recommendation) { - t3 = response => { - if (!recommendation) { - return; } - const { - pluginId, - pluginName, - shownAt - } = recommendation; - logForDebugging(`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`); - bb60: switch (response) { - case "yes": - { - installPluginAndNotify(pluginId, pluginName, "lsp-plugin", addNotification, async pluginData => { - logForDebugging(`[useLspPluginRecommendation] Installing plugin: ${pluginId}`); - const localSourcePath = typeof pluginData.entry.source === "string" ? join(pluginData.marketplaceInstallLocation, pluginData.entry.source) : undefined; - await cacheAndRegisterPlugin(pluginId, pluginData.entry, "user", undefined, localSourcePath); - const settings = getSettingsForSource("userSettings"); - updateSettingsForSource("userSettings", { + return null + }) + }, [trackedFiles, tryResolve]) + + const handleResponse = React.useCallback( + (response: 'yes' | 'no' | 'never' | 'disable') => { + if (!recommendation) return + + const { pluginId, pluginName, shownAt } = recommendation + + logForDebugging( + `[useLspPluginRecommendation] User response: ${response} for ${pluginName}`, + ) + + switch (response) { + case 'yes': + void installPluginAndNotify( + pluginId, + pluginName, + 'lsp-plugin', + addNotification, + async pluginData => { + logForDebugging( + `[useLspPluginRecommendation] Installing plugin: ${pluginId}`, + ) + const localSourcePath = + typeof pluginData.entry.source === 'string' + ? join( + pluginData.marketplaceInstallLocation, + pluginData.entry.source, + ) + : undefined + await cacheAndRegisterPlugin( + pluginId, + pluginData.entry, + 'user', + undefined, // projectPath - not needed for user scope + localSourcePath, + ) + // Enable in user settings so it loads on restart + const settings = getSettingsForSource('userSettings') + updateSettingsForSource('userSettings', { enabledPlugins: { ...settings?.enabledPlugins, - [pluginId]: true - } - }); - logForDebugging(`[useLspPluginRecommendation] Plugin installed: ${pluginId}`); - }); - break bb60; - } - case "no": - { - const elapsed = Date.now() - shownAt; - if (elapsed >= TIMEOUT_THRESHOLD_MS) { - logForDebugging(`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`); - incrementIgnoredCount(); - } - break bb60; - } - case "never": - { - addToNeverSuggest(pluginId); - break bb60; - } - case "disable": - { - saveGlobalConfig(_temp2); + [pluginId]: true, + }, + }) + logForDebugging( + `[useLspPluginRecommendation] Plugin installed: ${pluginId}`, + ) + }, + ) + break + + case 'no': { + const elapsed = Date.now() - shownAt + if (elapsed >= TIMEOUT_THRESHOLD_MS) { + logForDebugging( + `[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`, + ) + incrementIgnoredCount() } + break + } + + case 'never': + addToNeverSuggest(pluginId) + break + + case 'disable': + saveGlobalConfig(current => { + if (current.lspRecommendationDisabled) return current + return { ...current, lspRecommendationDisabled: true } + }) + break } - clearRecommendation(); - }; - $[5] = addNotification; - $[6] = clearRecommendation; - $[7] = recommendation; - $[8] = t3; - } else { - t3 = $[8]; - } - const handleResponse = t3; - let t4; - if ($[9] !== handleResponse || $[10] !== recommendation) { - t4 = { - recommendation, - handleResponse - }; - $[9] = handleResponse; - $[10] = recommendation; - $[11] = t4; - } else { - t4 = $[11]; - } - return t4; -} -function _temp2(current) { - if (current.lspRecommendationDisabled) { - return current; - } - return { - ...current, - lspRecommendationDisabled: true - }; -} -function _temp(s) { - return s.fileHistory.trackedFiles; + + clearRecommendation() + }, + [recommendation, addNotification, clearRecommendation], + ) + + return { recommendation, handleResponse } } diff --git a/src/hooks/useOfficialMarketplaceNotification.tsx b/src/hooks/useOfficialMarketplaceNotification.tsx index 7784c23d6..25cf62254 100644 --- a/src/hooks/useOfficialMarketplaceNotification.tsx +++ b/src/hooks/useOfficialMarketplaceNotification.tsx @@ -1,47 +1,67 @@ -import * as React from 'react'; -import type { Notification } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { logForDebugging } from '../utils/debug.js'; -import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js'; -import { useStartupNotification } from './notifs/useStartupNotification.js'; +import * as React from 'react' +import type { Notification } from '../context/notifications.js' +import { Text } from '../ink.js' +import { logForDebugging } from '../utils/debug.js' +import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js' +import { useStartupNotification } from './notifs/useStartupNotification.js' /** * Hook that handles official marketplace auto-installation and shows * notifications for success/failure in the bottom right of the REPL. */ -export function useOfficialMarketplaceNotification() { - useStartupNotification(_temp); -} -async function _temp() { - const result = await checkAndInstallOfficialMarketplace(); - const notifs = []; - if (result.configSaveFailed) { - logForDebugging("Showing marketplace config save failure notification"); - notifs.push({ - key: "marketplace-config-save-failed", - jsx: Failed to save marketplace retry info · Check ~/.claude.json permissions, - priority: "immediate", - timeoutMs: 10000 - }); - } - if (result.installed) { - logForDebugging("Showing marketplace installation success notification"); - notifs.push({ - key: "marketplace-installed", - jsx: ✓ Anthropic marketplace installed · /plugin to see available plugins, - priority: "immediate", - timeoutMs: 7000 - }); - } else { - if (result.skipped && result.reason === "unknown") { - logForDebugging("Showing marketplace installation failure notification"); +export function useOfficialMarketplaceNotification(): void { + useStartupNotification(async () => { + const result = await checkAndInstallOfficialMarketplace() + const notifs: Notification[] = [] + + // Check for config save failure first - this is critical + if (result.configSaveFailed) { + logForDebugging('Showing marketplace config save failure notification') + notifs.push({ + key: 'marketplace-config-save-failed', + jsx: ( + + Failed to save marketplace retry info · Check ~/.claude.json + permissions + + ), + priority: 'immediate', + timeoutMs: 10000, + }) + } + + if (result.installed) { + logForDebugging('Showing marketplace installation success notification') + notifs.push({ + key: 'marketplace-installed', + jsx: ( + + ✓ Anthropic marketplace installed · /plugin to see available plugins + + ), + priority: 'immediate', + timeoutMs: 7000, + }) + } else if (result.skipped && result.reason === 'unknown') { + logForDebugging('Showing marketplace installation failure notification') notifs.push({ - key: "marketplace-install-failed", - jsx: Failed to install Anthropic marketplace · Will retry on next startup, - priority: "immediate", - timeoutMs: 8000 - }); + key: 'marketplace-install-failed', + jsx: ( + + Failed to install Anthropic marketplace · Will retry on next startup + + ), + priority: 'immediate', + timeoutMs: 8000, + }) } - } - return notifs; + // Don't show notifications for: + // - already_installed (user already has it) + // - policy_blocked (enterprise policy, don't nag) + // - already_attempted (handled by retry logic now) + // - git_unavailable (marketplace is a nice-to-have; if git is missing + // or is a non-functional macOS xcrun shim, retry silently on backoff + // rather than nagging — the user will sort git out for other reasons) + return notifs + }) } diff --git a/src/hooks/usePluginRecommendationBase.tsx b/src/hooks/usePluginRecommendationBase.tsx index db0167ccf..23930fba4 100644 --- a/src/hooks/usePluginRecommendationBase.tsx +++ b/src/hooks/usePluginRecommendationBase.tsx @@ -1,19 +1,19 @@ -import { c as _c } from "react/compiler-runtime"; /** * Shared state machine + install helper for plugin-recommendation hooks * (LSP, claude-code-hint). Centralizes the gate chain, async-guard, * and success/failure notification JSX so new sources stay small. */ -import figures from 'figures'; -import * as React from 'react'; -import { getIsRemoteMode } from '../bootstrap/state.js'; -import type { useNotifications } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { logError } from '../utils/log.js'; -import { getPluginById } from '../utils/plugins/marketplaceManager.js'; -type AddNotification = ReturnType['addNotification']; -type PluginData = NonNullable>>; +import figures from 'figures' +import * as React from 'react' +import { getIsRemoteMode } from '../bootstrap/state.js' +import type { useNotifications } from '../context/notifications.js' +import { Text } from '../ink.js' +import { logError } from '../utils/log.js' +import { getPluginById } from '../utils/plugins/marketplaceManager.js' + +type AddNotification = ReturnType['addNotification'] +type PluginData = NonNullable>> /** * Call tryResolve inside a useEffect; it applies standard gates (remote @@ -21,84 +21,72 @@ type PluginData = NonNullable>>; * becomes the recommendation. Include tryResolve in effect deps — its * identity tracks recommendation, so clearing re-triggers resolution. */ -export function usePluginRecommendationBase() { - const $ = _c(6); - const [recommendation, setRecommendation] = React.useState(null); - const isCheckingRef = React.useRef(false); - let t0; - if ($[0] !== recommendation) { - t0 = resolve => { - if (getIsRemoteMode()) { - return; - } - if (recommendation) { - return; - } - if (isCheckingRef.current) { - return; - } - isCheckingRef.current = true; - resolve().then(rec => { - if (rec) { - setRecommendation(rec); - } - }).catch(logError).finally(() => { - isCheckingRef.current = false; - }); - }; - $[0] = recommendation; - $[1] = t0; - } else { - t0 = $[1]; - } - const tryResolve = t0; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setRecommendation(null); - $[2] = t1; - } else { - t1 = $[2]; - } - const clearRecommendation = t1; - let t2; - if ($[3] !== recommendation || $[4] !== tryResolve) { - t2 = { - recommendation, - clearRecommendation, - tryResolve - }; - $[3] = recommendation; - $[4] = tryResolve; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; +export function usePluginRecommendationBase(): { + recommendation: T | null + clearRecommendation: () => void + tryResolve: (resolve: () => Promise) => void +} { + const [recommendation, setRecommendation] = React.useState(null) + const isCheckingRef = React.useRef(false) + + const tryResolve = React.useCallback( + (resolve: () => Promise) => { + if (getIsRemoteMode()) return + if (recommendation) return + if (isCheckingRef.current) return + + isCheckingRef.current = true + void resolve() + .then(rec => { + if (rec) setRecommendation(rec) + }) + .catch(logError) + .finally(() => { + isCheckingRef.current = false + }) + }, + [recommendation], + ) + + const clearRecommendation = React.useCallback( + () => setRecommendation(null), + [], + ) + + return { recommendation, clearRecommendation, tryResolve } } /** Look up plugin, run install(), emit standard success/failure notification. */ -export async function installPluginAndNotify(pluginId: string, pluginName: string, keyPrefix: string, addNotification: AddNotification, install: (pluginData: PluginData) => Promise): Promise { +export async function installPluginAndNotify( + pluginId: string, + pluginName: string, + keyPrefix: string, + addNotification: AddNotification, + install: (pluginData: PluginData) => Promise, +): Promise { try { - const pluginData = await getPluginById(pluginId); + const pluginData = await getPluginById(pluginId) if (!pluginData) { - throw new Error(`Plugin ${pluginId} not found in marketplace`); + throw new Error(`Plugin ${pluginId} not found in marketplace`) } - await install(pluginData); + await install(pluginData) addNotification({ key: `${keyPrefix}-installed`, - jsx: + jsx: ( + {figures.tick} {pluginName} installed · restart to apply - , + + ), priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } catch (error) { - logError(error); + logError(error) addNotification({ key: `${keyPrefix}-install-failed`, jsx: Failed to install {pluginName}, priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } } diff --git a/src/hooks/usePromptsFromClaudeInChrome.tsx b/src/hooks/usePromptsFromClaudeInChrome.tsx index d71f08353..be7fa8363 100644 --- a/src/hooks/usePromptsFromClaudeInChrome.tsx +++ b/src/hooks/usePromptsFromClaudeInChrome.tsx @@ -1,70 +1,129 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; -import { useEffect, useRef } from 'react'; -import { logError } from 'src/utils/log.js'; -import { z } from 'zod/v4'; -import { callIdeRpc } from '../services/mcp/client.js'; -import type { ConnectedMCPServer, MCPServerConnection } from '../services/mcp/types.js'; -import type { PermissionMode } from '../types/permissions.js'; -import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isTrackedClaudeInChromeTabId } from '../utils/claudeInChrome/common.js'; -import { lazySchema } from '../utils/lazySchema.js'; -import { enqueuePendingNotification } from '../utils/messageQueueManager.js'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' +import { useEffect, useRef } from 'react' +import { logError } from 'src/utils/log.js' +import { z } from 'zod/v4' +import { callIdeRpc } from '../services/mcp/client.js' +import type { + ConnectedMCPServer, + MCPServerConnection, +} from '../services/mcp/types.js' +import type { PermissionMode } from '../types/permissions.js' +import { + CLAUDE_IN_CHROME_MCP_SERVER_NAME, + isTrackedClaudeInChromeTabId, +} from '../utils/claudeInChrome/common.js' +import { lazySchema } from '../utils/lazySchema.js' +import { enqueuePendingNotification } from '../utils/messageQueueManager.js' // Schema for the prompt notification from Chrome extension (JSON-RPC 2.0 format) -const ClaudeInChromePromptNotificationSchema = lazySchema(() => z.object({ - method: z.literal('notifications/message'), - params: z.object({ - prompt: z.string(), - image: z.object({ - type: z.literal('base64'), - media_type: z.enum(['image/jpeg', 'image/png', 'image/gif', 'image/webp']), - data: z.string() - }).optional(), - tabId: z.number().optional() - }) -})); +const ClaudeInChromePromptNotificationSchema = lazySchema(() => + z.object({ + method: z.literal('notifications/message'), + params: z.object({ + prompt: z.string(), + image: z + .object({ + type: z.literal('base64'), + media_type: z.enum([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]), + data: z.string(), + }) + .optional(), + tabId: z.number().optional(), + }), + }), +) /** * A hook that listens for prompt notifications from the Claude for Chrome extension, * enqueues them as user prompts, and syncs permission mode changes to the extension. */ -export function usePromptsFromClaudeInChrome(mcpClients, toolPermissionMode) { - const $ = _c(6); - useRef(undefined); - let t0; - if ($[0] !== mcpClients) { - t0 = [mcpClients]; - $[0] = mcpClients; - $[1] = t0; - } else { - t0 = $[1]; - } - useEffect(_temp, t0); - let t1; - let t2; - if ($[2] !== mcpClients || $[3] !== toolPermissionMode) { - t1 = () => { - const chromeClient = findChromeClient(mcpClients); - if (!chromeClient) { - return; - } - const chromeMode = toolPermissionMode === "bypassPermissions" ? "skip_all_permission_checks" : "ask"; - callIdeRpc("set_permission_mode", { - mode: chromeMode - }, chromeClient); - }; - t2 = [mcpClients, toolPermissionMode]; - $[2] = mcpClients; - $[3] = toolPermissionMode; - $[4] = t1; - $[5] = t2; - } else { - t1 = $[4]; - t2 = $[5]; - } - useEffect(t1, t2); +export function usePromptsFromClaudeInChrome( + mcpClients: MCPServerConnection[], + toolPermissionMode: PermissionMode, +): void { + const mcpClientRef = useRef(undefined) + + useEffect(() => { + if ("external" !== 'ant') { + return + } + + const mcpClient = findChromeClient(mcpClients) + if (mcpClientRef.current !== mcpClient) { + mcpClientRef.current = mcpClient + } + + if (mcpClient) { + mcpClient.client.setNotificationHandler( + ClaudeInChromePromptNotificationSchema(), + notification => { + if (mcpClientRef.current !== mcpClient) { + return + } + const { tabId, prompt, image } = notification.params + + // Process notifications from tabs we're tracking since notifications are broadcasted + if ( + typeof tabId !== 'number' || + !isTrackedClaudeInChromeTabId(tabId) + ) { + return + } + + try { + // Build content blocks if there's an image, otherwise just use the prompt string + if (image) { + const contentBlocks: ContentBlockParam[] = [ + { type: 'text', text: prompt }, + { + type: 'image', + source: { + type: image.type, + media_type: image.media_type, + data: image.data, + }, + }, + ] + enqueuePendingNotification({ + value: contentBlocks, + mode: 'prompt', + }) + } else { + enqueuePendingNotification({ value: prompt, mode: 'prompt' }) + } + } catch (error) { + logError(error as Error) + } + }, + ) + } + }, [mcpClients]) + + // Sync permission mode with Chrome extension whenever it changes + useEffect(() => { + const chromeClient = findChromeClient(mcpClients) + if (!chromeClient) return + + const chromeMode = + toolPermissionMode === 'bypassPermissions' + ? 'skip_all_permission_checks' + : 'ask' + + void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient) + }, [mcpClients, toolPermissionMode]) } -function _temp() {} -function findChromeClient(clients: MCPServerConnection[]): ConnectedMCPServer | undefined { - return clients.find((client): client is ConnectedMCPServer => client.type === 'connected' && client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME); + +function findChromeClient( + clients: MCPServerConnection[], +): ConnectedMCPServer | undefined { + return clients.find( + (client): client is ConnectedMCPServer => + client.type === 'connected' && + client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, + ) } diff --git a/src/hooks/useReplBridge.tsx b/src/hooks/useReplBridge.tsx index 6a767a1cd..522202891 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -1,32 +1,52 @@ -import { feature } from 'bun:bundle'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { setMainLoopModelOverride } from '../bootstrap/state.js'; -import { type BridgePermissionCallbacks, type BridgePermissionResponse, isBridgePermissionResponse } from '../bridge/bridgePermissionCallbacks.js'; -import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js'; -import { extractInboundMessageFields } from '../bridge/inboundMessages.js'; -import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js'; -import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js'; -import type { Command } from '../commands.js'; -import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js'; -import { getRemoteSessionUrl } from '../constants/product.js'; -import { useNotifications } from '../context/notifications.js'; -import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js'; -import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'; -import { Text } from '../ink.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js'; -import type { Message } from '../types/message.js'; -import { getCwd } from '../utils/cwd.js'; -import { logForDebugging } from '../utils/debug.js'; -import { errorMessage } from '../utils/errors.js'; -import { enqueue } from '../utils/messageQueueManager.js'; -import { buildSystemInitMessage } from '../utils/messages/systemInit.js'; -import { createBridgeStatusMessage, createSystemMessage } from '../utils/messages.js'; -import { getAutoModeUnavailableNotification, getAutoModeUnavailableReason, isAutoModeGateEnabled, isBypassPermissionsModeDisabled, transitionPermissionMode } from '../utils/permissions/permissionSetup.js'; -import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'; +import { feature } from 'bun:bundle' +import React, { useCallback, useEffect, useRef } from 'react' +import { setMainLoopModelOverride } from '../bootstrap/state.js' +import { + type BridgePermissionCallbacks, + type BridgePermissionResponse, + isBridgePermissionResponse, +} from '../bridge/bridgePermissionCallbacks.js' +import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js' +import { extractInboundMessageFields } from '../bridge/inboundMessages.js' +import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js' +import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js' +import type { Command } from '../commands.js' +import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js' +import { getRemoteSessionUrl } from '../constants/product.js' +import { useNotifications } from '../context/notifications.js' +import type { + PermissionMode, + SDKMessage, +} from '../entrypoints/agentSdkTypes.js' +import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js' +import { Text } from '../ink.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { + useAppState, + useAppStateStore, + useSetAppState, +} from '../state/AppState.js' +import type { Message } from '../types/message.js' +import { getCwd } from '../utils/cwd.js' +import { logForDebugging } from '../utils/debug.js' +import { errorMessage } from '../utils/errors.js' +import { enqueue } from '../utils/messageQueueManager.js' +import { buildSystemInitMessage } from '../utils/messages/systemInit.js' +import { + createBridgeStatusMessage, + createSystemMessage, +} from '../utils/messages.js' +import { + getAutoModeUnavailableNotification, + getAutoModeUnavailableReason, + isAutoModeGateEnabled, + isBypassPermissionsModeDisabled, + transitionPermissionMode, +} from '../utils/permissions/permissionSetup.js' +import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js' /** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */ -export const BRIDGE_FAILURE_DISMISS_MS = 10_000; +export const BRIDGE_FAILURE_DISMISS_MS = 10_000 /** * Max consecutive initReplBridge failures before the hook stops re-attempting @@ -37,7 +57,7 @@ export const BRIDGE_FAILURE_DISMISS_MS = 10_000; * top stuck client generated 2,879 × 401/day alone (17% of all 401s on the * route). */ -const MAX_CONSECUTIVE_INIT_FAILURES = 3; +const MAX_CONSECUTIVE_INIT_FAILURES = 3 /** * Hook that initializes an always-on bridge connection in the background @@ -50,44 +70,52 @@ const MAX_CONSECUTIVE_INIT_FAILURES = 3; * * Inbound messages from claude.ai are injected into the REPL via queuedCommands. */ -export function useReplBridge(messages: Message[], setMessages: (action: React.SetStateAction) => void, abortControllerRef: React.RefObject, commands: readonly Command[], mainLoopModel: string): { - sendBridgeResult: () => void; -} { - const handleRef = useRef(null); - const teardownPromiseRef = useRef | undefined>(undefined); - const lastWrittenIndexRef = useRef(0); +export function useReplBridge( + messages: Message[], + setMessages: (action: React.SetStateAction) => void, + abortControllerRef: React.RefObject, + commands: readonly Command[], + mainLoopModel: string, +): { sendBridgeResult: () => void } { + const handleRef = useRef(null) + const teardownPromiseRef = useRef | undefined>(undefined) + const lastWrittenIndexRef = useRef(0) // Tracks UUIDs already flushed as initial messages. Persists across // bridge reconnections so Bridge #2+ only sends new messages — sending // duplicate UUIDs causes the server to kill the WebSocket. - const flushedUUIDsRef = useRef(new Set()); - const failureTimeoutRef = useRef | undefined>(undefined); + const flushedUUIDsRef = useRef(new Set()) + const failureTimeoutRef = useRef | undefined>( + undefined, + ) // Persists across effect re-runs (unlike the effect's local state). Reset // only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown // for the session, regardless of replBridgeEnabled re-toggling. - const consecutiveFailuresRef = useRef(0); - const setAppState = useSetAppState(); - const commandsRef = useRef(commands); - commandsRef.current = commands; - const mainLoopModelRef = useRef(mainLoopModel); - mainLoopModelRef.current = mainLoopModel; - const messagesRef = useRef(messages); - messagesRef.current = messages; - const store = useAppStateStore(); - const { - addNotification - } = useNotifications(); - const replBridgeEnabled = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.replBridgeEnabled) : false; - const replBridgeConnected = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.replBridgeConnected) : false; - const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_1 => s_1.replBridgeOutboundOnly) : false; - const replBridgeInitialName = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_2 => s_2.replBridgeInitialName) : undefined; + const consecutiveFailuresRef = useRef(0) + const setAppState = useSetAppState() + const commandsRef = useRef(commands) + commandsRef.current = commands + const mainLoopModelRef = useRef(mainLoopModel) + mainLoopModelRef.current = mainLoopModel + const messagesRef = useRef(messages) + messagesRef.current = messages + const store = useAppStateStore() + const { addNotification } = useNotifications() + const replBridgeEnabled = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeEnabled) + : false + const replBridgeConnected = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeConnected) + : false + const replBridgeOutboundOnly = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeOutboundOnly) + : false + const replBridgeInitialName = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeInitialName) + : undefined // Initialize/teardown bridge when enabled state changes. // Passes current messages as initialMessages so the remote session @@ -97,39 +125,48 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // negative pattern (if (!feature(...)) return) does NOT eliminate // dynamic imports below. if (feature('BRIDGE_MODE')) { - if (!replBridgeEnabled) return; - const outboundOnly = replBridgeOutboundOnly; + if (!replBridgeEnabled) return + + const outboundOnly = replBridgeOutboundOnly function notifyBridgeFailed(detail?: string): void { - if (outboundOnly) return; + if (outboundOnly) return addNotification({ key: 'bridge-failed', - jsx: <> + jsx: ( + <> Remote Control failed {detail && · {detail}} - , - priority: 'immediate' - }); + + ), + priority: 'immediate', + }) } + if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_INIT_FAILURES) { - logForDebugging(`[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`); + logForDebugging( + `[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`, + ) // Clear replBridgeEnabled so /remote-control doesn't mistakenly show // BridgeDisconnectDialog for a bridge that never connected. - const fuseHint = 'disabled after repeated failures · restart to retry'; - notifyBridgeFailed(fuseHint); + const fuseHint = 'disabled after repeated failures · restart to retry' + notifyBridgeFailed(fuseHint) setAppState(prev => { - if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) return prev; + if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) + return prev return { ...prev, replBridgeError: fuseHint, - replBridgeEnabled: false - }; - }); - return; + replBridgeEnabled: false, + } + }) + return } - let cancelled = false; + + let cancelled = false // Capture messages.length now so we don't re-send initial messages // through writeMessages after the bridge connects. - const initialMessageCount = messages.length; + const initialMessageCount = messages.length + void (async () => { try { // Wait for any in-progress teardown to complete before registering @@ -137,20 +174,22 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // the previous teardown races with the new register call, and the // server may tear down the freshly-created environment. if (teardownPromiseRef.current) { - logForDebugging('[bridge:repl] Hook: waiting for previous teardown to complete before re-init'); - await teardownPromiseRef.current; - teardownPromiseRef.current = undefined; - logForDebugging('[bridge:repl] Hook: previous teardown complete, proceeding with re-init'); + logForDebugging( + '[bridge:repl] Hook: waiting for previous teardown to complete before re-init', + ) + await teardownPromiseRef.current + teardownPromiseRef.current = undefined + logForDebugging( + '[bridge:repl] Hook: previous teardown complete, proceeding with re-init', + ) } - if (cancelled) return; + if (cancelled) return // Dynamic import so the module is tree-shaken in external builds - const { - initReplBridge - } = await import('../bridge/initReplBridge.js'); - const { - shouldShowAppUpgradeMessage - } = await import('../bridge/envLessBridgeConfig.js'); + const { initReplBridge } = await import('../bridge/initReplBridge.js') + const { shouldShowAppUpgradeMessage } = await import( + '../bridge/envLessBridgeConfig.js' + ) // Assistant mode: perpetual bridge session — claude.ai shows one // continuous conversation across CLI restarts instead of a new @@ -161,12 +200,10 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // pointer-clear so the session survives clean exits, not just // crashes. Non-assistant bridges clear the pointer on teardown // (crash-recovery only). - let perpetual = false; + let perpetual = false if (feature('KAIROS')) { - const { - isAssistantMode - } = await import('../assistant/index.js'); - perpetual = isAssistantMode(); + const { isAssistantMode } = await import('../assistant/index.js') + perpetual = isAssistantMode() } // When a user message arrives from claude.ai, inject it into the REPL. @@ -179,30 +216,32 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // later, which is fine (web messages aren't rapid-fire). async function handleInboundMessage(msg: SDKMessage): Promise { try { - const fields = extractInboundMessageFields(msg); - if (!fields) return; - const { - uuid - } = fields; + const fields = extractInboundMessageFields(msg) + if (!fields) return + + const { uuid } = fields // Dynamic import keeps the bridge code out of non-BRIDGE_MODE builds. - const { - resolveAndPrepend - } = await import('../bridge/inboundAttachments.js'); - let sanitized = fields.content; + const { resolveAndPrepend } = await import( + '../bridge/inboundAttachments.js' + ) + let sanitized = fields.content if (feature('KAIROS_GITHUB_WEBHOOKS')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - sanitizeInboundWebhookContent - } = require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js'); + const { sanitizeInboundWebhookContent } = + require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js') /* eslint-enable @typescript-eslint/no-require-imports */ - if (typeof fields.content === 'string') { - sanitized = sanitizeInboundWebhookContent(fields.content); - } + sanitized = sanitizeInboundWebhookContent(fields.content) } - const content = await resolveAndPrepend(msg, sanitized); - const preview = typeof content === 'string' ? content.slice(0, 80) : `[${content.length} content blocks]`; - logForDebugging(`[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`); + const content = await resolveAndPrepend(msg, sanitized) + + const preview = + typeof content === 'string' + ? content.slice(0, 80) + : `[${content.length} content blocks]` + logForDebugging( + `[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`, + ) enqueue({ value: content, mode: 'prompt' as const, @@ -213,54 +252,73 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // This keeps exit-word suppression and immediate-command blocks // intact for any code path that checks skipSlashCommands directly. skipSlashCommands: true, - bridgeOrigin: true - }); + bridgeOrigin: true, + }) } catch (e) { - logForDebugging(`[bridge:repl] handleInboundMessage failed: ${e}`, { - level: 'error' - }); + logForDebugging( + `[bridge:repl] handleInboundMessage failed: ${e}`, + { level: 'error' }, + ) } } // State change callback — maps bridge lifecycle events to AppState. - function handleStateChange(state: BridgeState, detail_0?: string): void { - if (cancelled) return; + function handleStateChange( + state: BridgeState, + detail?: string, + ): void { + if (cancelled) return if (outboundOnly) { - logForDebugging(`[bridge:repl] Mirror state=${state}${detail_0 ? ` detail=${detail_0}` : ''}`); + logForDebugging( + `[bridge:repl] Mirror state=${state}${detail ? ` detail=${detail}` : ''}`, + ) // Sync replBridgeConnected so the forwarding effect starts/stops // writing as the transport comes up or dies. if (state === 'failed') { - setAppState(prev_3 => { - if (!prev_3.replBridgeConnected) return prev_3; - return { - ...prev_3, - replBridgeConnected: false - }; - }); + setAppState(prev => { + if (!prev.replBridgeConnected) return prev + return { ...prev, replBridgeConnected: false } + }) } else if (state === 'ready' || state === 'connected') { - setAppState(prev_4 => { - if (prev_4.replBridgeConnected) return prev_4; - return { - ...prev_4, - replBridgeConnected: true - }; - }); + setAppState(prev => { + if (prev.replBridgeConnected) return prev + return { ...prev, replBridgeConnected: true } + }) } - return; + return } - const handle = handleRef.current; + const handle = handleRef.current switch (state) { case 'ready': - setAppState(prev_9 => { - const connectUrl = handle && handle.environmentId !== '' ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) : prev_9.replBridgeConnectUrl; - const sessionUrl = handle ? getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl) : prev_9.replBridgeSessionUrl; - const envId = handle?.environmentId; - const sessionId = handle?.bridgeSessionId; - if (prev_9.replBridgeConnected && !prev_9.replBridgeSessionActive && !prev_9.replBridgeReconnecting && prev_9.replBridgeConnectUrl === connectUrl && prev_9.replBridgeSessionUrl === sessionUrl && prev_9.replBridgeEnvironmentId === envId && prev_9.replBridgeSessionId === sessionId) { - return prev_9; + setAppState(prev => { + const connectUrl = + handle && handle.environmentId !== '' + ? buildBridgeConnectUrl( + handle.environmentId, + handle.sessionIngressUrl, + ) + : prev.replBridgeConnectUrl + const sessionUrl = handle + ? getRemoteSessionUrl( + handle.bridgeSessionId, + handle.sessionIngressUrl, + ) + : prev.replBridgeSessionUrl + const envId = handle?.environmentId + const sessionId = handle?.bridgeSessionId + if ( + prev.replBridgeConnected && + !prev.replBridgeSessionActive && + !prev.replBridgeReconnecting && + prev.replBridgeConnectUrl === connectUrl && + prev.replBridgeSessionUrl === sessionUrl && + prev.replBridgeEnvironmentId === envId && + prev.replBridgeSessionId === sessionId + ) { + return prev } return { - ...prev_9, + ...prev, replBridgeConnected: true, replBridgeSessionActive: false, replBridgeReconnecting: false, @@ -268,35 +326,40 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S replBridgeSessionUrl: sessionUrl, replBridgeEnvironmentId: envId, replBridgeSessionId: sessionId, - replBridgeError: undefined - }; - }); - break; - case 'connected': - { - setAppState(prev_8 => { - if (prev_8.replBridgeSessionActive) return prev_8; - return { - ...prev_8, - replBridgeConnected: true, - replBridgeSessionActive: true, - replBridgeReconnecting: false, - replBridgeError: undefined - }; - }); - // Send system/init so remote clients (web/iOS/Android) get - // session metadata. REPL uses query() directly — never hits - // QueryEngine's SDKMessage layer — so this is the only path - // to put system/init on the REPL-bridge wire. Skills load is - // async (memoized, cheap after REPL startup); fire-and-forget - // so the connected-state transition isn't blocked. - if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', true)) { - void (async () => { - try { - const skills = await getSlashCommandToolSkills(getCwd()); - if (cancelled) return; - const state_0 = store.getState(); - handleRef.current?.writeSdkMessages([buildSystemInitMessage({ + replBridgeError: undefined, + } + }) + break + case 'connected': { + setAppState(prev => { + if (prev.replBridgeSessionActive) return prev + return { + ...prev, + replBridgeConnected: true, + replBridgeSessionActive: true, + replBridgeReconnecting: false, + replBridgeError: undefined, + } + }) + // Send system/init so remote clients (web/iOS/Android) get + // session metadata. REPL uses query() directly — never hits + // QueryEngine's SDKMessage layer — so this is the only path + // to put system/init on the REPL-bridge wire. Skills load is + // async (memoized, cheap after REPL startup); fire-and-forget + // so the connected-state transition isn't blocked. + if ( + getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_bridge_system_init', + false, + ) + ) { + void (async () => { + try { + const skills = await getSlashCommandToolSkills(getCwd()) + if (cancelled) return + const state = store.getState() + handleRef.current?.writeSdkMessages([ + buildSystemInitMessage({ // tools/mcpClients/plugins redacted for REPL-bridge: // MCP-prefixed tool names and server names leak which // integrations the user has wired up; plugin paths leak @@ -308,112 +371,119 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S tools: [], mcpClients: [], model: mainLoopModelRef.current, - permissionMode: state_0.toolPermissionContext.mode as PermissionMode, - // TODO: avoid the cast + permissionMode: state.toolPermissionContext + .mode as PermissionMode, // TODO: avoid the cast // Remote clients can only invoke bridge-safe commands — // advertising unsafe ones (local-jsx, unallowed local) // would let mobile/web attempt them and hit errors. - commands: commandsRef.current.filter(isBridgeSafeCommand), - agents: state_0.agentDefinitions.activeAgents, + commands: + commandsRef.current.filter(isBridgeSafeCommand), + agents: state.agentDefinitions.activeAgents, skills, plugins: [], - fastMode: state_0.fastMode - })]); - } catch (err_0) { - logForDebugging(`[bridge:repl] Failed to send system/init: ${errorMessage(err_0)}`, { - level: 'error' - }); - } - })(); - } - break; + fastMode: state.fastMode, + }), + ]) + } catch (err) { + logForDebugging( + `[bridge:repl] Failed to send system/init: ${errorMessage(err)}`, + { level: 'error' }, + ) + } + })() } + break + } case 'reconnecting': - setAppState(prev_7 => { - if (prev_7.replBridgeReconnecting) return prev_7; + setAppState(prev => { + if (prev.replBridgeReconnecting) return prev return { - ...prev_7, + ...prev, replBridgeReconnecting: true, - replBridgeSessionActive: false - }; - }); - break; + replBridgeSessionActive: false, + } + }) + break case 'failed': // Clear any previous failure dismiss timer - clearTimeout(failureTimeoutRef.current); - notifyBridgeFailed(detail_0); - setAppState(prev_5 => ({ - ...prev_5, - replBridgeError: detail_0, + clearTimeout(failureTimeoutRef.current) + notifyBridgeFailed(detail) + setAppState(prev => ({ + ...prev, + replBridgeError: detail, replBridgeReconnecting: false, replBridgeSessionActive: false, - replBridgeConnected: false - })); + replBridgeConnected: false, + })) // Auto-disable after timeout so the hook stops retrying. failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_6 => { - if (!prev_6.replBridgeError) return prev_6; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_6, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); - break; + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) + break } } // Map of pending bridge permission response handlers, keyed by request_id. // Each entry is an onResponse handler waiting for CCR to reply. - const pendingPermissionHandlers = new Map void>(); + const pendingPermissionHandlers = new Map< + string, + (response: BridgePermissionResponse) => void + >() // Dispatch incoming control_response messages to registered handlers - function handlePermissionResponse(msg_0: SDKControlResponse): void { - const requestId = (msg_0 as any).response?.request_id; - if (!requestId) return; - const handler = pendingPermissionHandlers.get(requestId); + function handlePermissionResponse(msg: SDKControlResponse): void { + const requestId = msg.response?.request_id + if (!requestId) return + const handler = pendingPermissionHandlers.get(requestId) if (!handler) { - logForDebugging(`[bridge:repl] No handler for control_response request_id=${requestId}`); - return; + logForDebugging( + `[bridge:repl] No handler for control_response request_id=${requestId}`, + ) + return } - pendingPermissionHandlers.delete(requestId); + pendingPermissionHandlers.delete(requestId) // Extract the permission decision from the control_response payload - const inner = (msg_0 as any).response; - if (inner.subtype === 'success' && inner.response && isBridgePermissionResponse(inner.response)) { - handler(inner.response); + const inner = msg.response + if ( + inner.subtype === 'success' && + inner.response && + isBridgePermissionResponse(inner.response) + ) { + handler(inner.response) } } - const handle_0 = await initReplBridge({ + + const handle = await initReplBridge({ outboundOnly, tags: outboundOnly ? ['ccr-mirror'] : undefined, onInboundMessage: handleInboundMessage, onPermissionResponse: handlePermissionResponse, onInterrupt() { - abortControllerRef.current?.abort(); + abortControllerRef.current?.abort() }, onSetModel(model) { - const resolved = model === 'default' ? null : model ?? null; - setMainLoopModelOverride(resolved); - setAppState(prev_10 => { - if (prev_10.mainLoopModelForSession === resolved) return prev_10; - return { - ...prev_10, - mainLoopModelForSession: resolved - }; - }); + const resolved = model === 'default' ? null : (model ?? null) + setMainLoopModelOverride(resolved) + setAppState(prev => { + if (prev.mainLoopModelForSession === resolved) return prev + return { ...prev, mainLoopModelForSession: resolved } + }) }, onSetMaxThinkingTokens(maxTokens) { - const enabled = maxTokens !== null; - setAppState(prev_11 => { - if (prev_11.thinkingEnabled === enabled) return prev_11; - return { - ...prev_11, - thinkingEnabled: enabled - }; - }); + const enabled = maxTokens !== null + setAppState(prev => { + if (prev.thinkingEnabled === enabled) return prev + return { ...prev, thinkingEnabled: enabled } + }) }, onSetPermissionMode(mode) { // Policy guards MUST fire before transitionPermissionMode — @@ -430,190 +500,240 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S if (isBypassPermissionsModeDisabled()) { return { ok: false, - error: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration' - }; + error: + 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration', + } } - if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) { + if ( + !store.getState().toolPermissionContext + .isBypassPermissionsModeAvailable + ) { return { ok: false, - error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions' - }; + error: + 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions', + } } } - if (feature('TRANSCRIPT_CLASSIFIER') && mode === 'auto' && !isAutoModeGateEnabled()) { - const reason = getAutoModeUnavailableReason(); + if ( + feature('TRANSCRIPT_CLASSIFIER') && + mode === 'auto' && + !isAutoModeGateEnabled() + ) { + const reason = getAutoModeUnavailableReason() return { ok: false, - error: reason ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` : 'Cannot set permission mode to auto' - }; + error: reason + ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` + : 'Cannot set permission mode to auto', + } } // Guards passed — apply via the centralized transition so // prePlanMode stashing and auto-mode state sync all fire. - setAppState(prev_12 => { - const current = prev_12.toolPermissionContext.mode; - if (current === mode) return prev_12; - const next = transitionPermissionMode(current, mode, prev_12.toolPermissionContext); + setAppState(prev => { + const current = prev.toolPermissionContext.mode + if (current === mode) return prev + const next = transitionPermissionMode( + current, + mode, + prev.toolPermissionContext, + ) return { - ...prev_12, - toolPermissionContext: { - ...next, - mode - } - }; - }); + ...prev, + toolPermissionContext: { ...next, mode }, + } + }) // Recheck queued permission prompts now that mode changed. setImmediate(() => { getLeaderToolUseConfirmQueue()?.(currentQueue => { currentQueue.forEach(item => { - void item.recheckPermission(); - }); - return currentQueue; - }); - }); - return { - ok: true - }; + void item.recheckPermission() + }) + return currentQueue + }) + }) + return { ok: true } }, onStateChange: handleStateChange, initialMessages: messages.length > 0 ? messages : undefined, getMessages: () => messagesRef.current, previouslyFlushedUUIDs: flushedUUIDsRef.current, initialName: replBridgeInitialName, - perpetual - }); + perpetual, + }) if (cancelled) { // Effect was cancelled while initReplBridge was in flight. // Tear down the handle to avoid leaking resources (poll loop, // WebSocket, registered environment, cleanup callback). - logForDebugging(`[bridge:repl] Hook: init cancelled during flight, tearing down${handle_0 ? ` env=${handle_0.environmentId}` : ''}`); - if (handle_0) { - void handle_0.teardown(); + logForDebugging( + `[bridge:repl] Hook: init cancelled during flight, tearing down${handle ? ` env=${handle.environmentId}` : ''}`, + ) + if (handle) { + void handle.teardown() } - return; + return } - if (!handle_0) { + if (!handle) { // initReplBridge returned null — a precondition failed. For most // cases (no_oauth, policy_denied, etc.) onStateChange('failed') // already fired with a specific hint. The GrowthBook-gate-off case // is intentionally silent — not a failure, just not rolled out. - consecutiveFailuresRef.current++; - logForDebugging(`[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`); - clearTimeout(failureTimeoutRef.current); - setAppState(prev_13 => ({ - ...prev_13, - replBridgeError: prev_13.replBridgeError ?? 'check debug logs for details' - })); + consecutiveFailuresRef.current++ + logForDebugging( + `[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`, + ) + clearTimeout(failureTimeoutRef.current) + setAppState(prev => ({ + ...prev, + replBridgeError: + prev.replBridgeError ?? 'check debug logs for details', + })) failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_14 => { - if (!prev_14.replBridgeError) return prev_14; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_14, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); - return; + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) + return } - handleRef.current = handle_0; - setReplBridgeHandle(handle_0); - consecutiveFailuresRef.current = 0; + handleRef.current = handle + setReplBridgeHandle(handle) + consecutiveFailuresRef.current = 0 // Skip initial messages in the forwarding effect — they were // already loaded as session events during creation. - lastWrittenIndexRef.current = initialMessageCount; + lastWrittenIndexRef.current = initialMessageCount + if (outboundOnly) { - setAppState(prev_15 => { - if (prev_15.replBridgeConnected && prev_15.replBridgeSessionId === handle_0.bridgeSessionId) return prev_15; + setAppState(prev => { + if ( + prev.replBridgeConnected && + prev.replBridgeSessionId === handle.bridgeSessionId + ) + return prev return { - ...prev_15, + ...prev, replBridgeConnected: true, - replBridgeSessionId: handle_0.bridgeSessionId, + replBridgeSessionId: handle.bridgeSessionId, replBridgeSessionUrl: undefined, replBridgeConnectUrl: undefined, - replBridgeError: undefined - }; - }); - logForDebugging(`[bridge:repl] Mirror initialized, session=${handle_0.bridgeSessionId}`); + replBridgeError: undefined, + } + }) + logForDebugging( + `[bridge:repl] Mirror initialized, session=${handle.bridgeSessionId}`, + ) } else { // Build bridge permission callbacks so the interactive permission // handler can race bridge responses against local user interaction. const permissionCallbacks: BridgePermissionCallbacks = { - sendRequest(requestId_0, toolName, input, toolUseId, description, permissionSuggestions, blockedPath) { - handle_0.sendControlRequest({ + sendRequest( + requestId, + toolName, + input, + toolUseId, + description, + permissionSuggestions, + blockedPath, + ) { + handle.sendControlRequest({ type: 'control_request', - request_id: requestId_0, + request_id: requestId, request: { subtype: 'can_use_tool', tool_name: toolName, input, tool_use_id: toolUseId, description, - ...(permissionSuggestions ? { - permission_suggestions: permissionSuggestions - } : {}), - ...(blockedPath ? { - blocked_path: blockedPath - } : {}) - } - }); + ...(permissionSuggestions + ? { permission_suggestions: permissionSuggestions } + : {}), + ...(blockedPath ? { blocked_path: blockedPath } : {}), + }, + }) }, - sendResponse(requestId_1, response) { - const payload: Record = { - ...response - }; - handle_0.sendControlResponse({ + sendResponse(requestId, response) { + const payload: Record = { ...response } + handle.sendControlResponse({ type: 'control_response', response: { subtype: 'success', - request_id: requestId_1, - response: payload - } - }); + request_id: requestId, + response: payload, + }, + }) }, - cancelRequest(requestId_2) { - handle_0.sendControlCancelRequest(requestId_2); + cancelRequest(requestId) { + handle.sendControlCancelRequest(requestId) }, - onResponse(requestId_3, handler_0) { - pendingPermissionHandlers.set(requestId_3, handler_0); + onResponse(requestId, handler) { + pendingPermissionHandlers.set(requestId, handler) return () => { - pendingPermissionHandlers.delete(requestId_3); - }; - } - }; - setAppState(prev_16 => ({ - ...prev_16, - replBridgePermissionCallbacks: permissionCallbacks - })); - const url = getRemoteSessionUrl(handle_0.bridgeSessionId, handle_0.sessionIngressUrl); + pendingPermissionHandlers.delete(requestId) + } + }, + } + setAppState(prev => ({ + ...prev, + replBridgePermissionCallbacks: permissionCallbacks, + })) + const url = getRemoteSessionUrl( + handle.bridgeSessionId, + handle.sessionIngressUrl, + ) // environmentId === '' signals the v2 env-less path. buildBridgeConnectUrl // builds an env-specific connect URL, which doesn't exist without an env. - const hasEnv = handle_0.environmentId !== ''; - const connectUrl_0 = hasEnv ? buildBridgeConnectUrl(handle_0.environmentId, handle_0.sessionIngressUrl) : undefined; - setAppState(prev_17 => { - if (prev_17.replBridgeConnected && prev_17.replBridgeSessionUrl === url) { - return prev_17; + const hasEnv = handle.environmentId !== '' + const connectUrl = hasEnv + ? buildBridgeConnectUrl( + handle.environmentId, + handle.sessionIngressUrl, + ) + : undefined + setAppState(prev => { + if ( + prev.replBridgeConnected && + prev.replBridgeSessionUrl === url + ) { + return prev } return { - ...prev_17, + ...prev, replBridgeConnected: true, replBridgeSessionUrl: url, - replBridgeConnectUrl: connectUrl_0 ?? prev_17.replBridgeConnectUrl, - replBridgeEnvironmentId: handle_0.environmentId, - replBridgeSessionId: handle_0.bridgeSessionId, - replBridgeError: undefined - }; - }); + replBridgeConnectUrl: connectUrl ?? prev.replBridgeConnectUrl, + replBridgeEnvironmentId: handle.environmentId, + replBridgeSessionId: handle.bridgeSessionId, + replBridgeError: undefined, + } + }) // Show bridge status with URL in the transcript. perpetual (KAIROS // assistant mode) falls back to v1 at initReplBridge.ts — skip the // v2-only upgrade nudge for them. Own try/catch so a cosmetic // GrowthBook hiccup doesn't hit the outer init-failure handler. - const upgradeNudge = !perpetual ? await shouldShowAppUpgradeMessage().catch(() => false) : false; - if (cancelled) return; - setMessages(prev_18 => [...prev_18, createBridgeStatusMessage(url, upgradeNudge ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' : undefined)]); - logForDebugging(`[bridge:repl] Hook initialized, session=${handle_0.bridgeSessionId}`); + const upgradeNudge = !perpetual + ? await shouldShowAppUpgradeMessage().catch(() => false) + : false + if (cancelled) return + setMessages(prev => [ + ...prev, + createBridgeStatusMessage( + url, + upgradeNudge + ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' + : undefined, + ), + ]) + + logForDebugging( + `[bridge:repl] Hook initialized, session=${handle.bridgeSessionId}`, + ) } } catch (err) { // Never crash the REPL — surface the error in the UI. @@ -622,49 +742,64 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // error), don't count that toward the fuse or spam a stale error // into the UI. Also fixes pre-existing spurious setAppState/ // setMessages on cancelled throws. - if (cancelled) return; - consecutiveFailuresRef.current++; - const errMsg = errorMessage(err); - logForDebugging(`[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`); - clearTimeout(failureTimeoutRef.current); - notifyBridgeFailed(errMsg); - setAppState(prev_0 => ({ - ...prev_0, - replBridgeError: errMsg - })); + if (cancelled) return + consecutiveFailuresRef.current++ + const errMsg = errorMessage(err) + logForDebugging( + `[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`, + ) + clearTimeout(failureTimeoutRef.current) + notifyBridgeFailed(errMsg) + setAppState(prev => ({ + ...prev, + replBridgeError: errMsg, + })) failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_1 => { - if (!prev_1.replBridgeError) return prev_1; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_1, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) if (!outboundOnly) { - setMessages(prev_2 => [...prev_2, createSystemMessage(`Remote Control failed to connect: ${errMsg}`, 'warning')]); + setMessages(prev => [ + ...prev, + createSystemMessage( + `Remote Control failed to connect: ${errMsg}`, + 'warning', + ), + ]) } } - })(); + })() + return () => { - cancelled = true; - clearTimeout(failureTimeoutRef.current); - failureTimeoutRef.current = undefined; + cancelled = true + clearTimeout(failureTimeoutRef.current) + failureTimeoutRef.current = undefined if (handleRef.current) { - logForDebugging(`[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`); - teardownPromiseRef.current = handleRef.current.teardown(); - handleRef.current = null; - setReplBridgeHandle(null); + logForDebugging( + `[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`, + ) + teardownPromiseRef.current = handleRef.current.teardown() + handleRef.current = null + setReplBridgeHandle(null) } - setAppState(prev_19 => { - if (!prev_19.replBridgeConnected && !prev_19.replBridgeSessionActive && !prev_19.replBridgeError) { - return prev_19; + setAppState(prev => { + if ( + !prev.replBridgeConnected && + !prev.replBridgeSessionActive && + !prev.replBridgeError + ) { + return prev } return { - ...prev_19, + ...prev, replBridgeConnected: false, replBridgeSessionActive: false, replBridgeReconnecting: false, @@ -673,13 +808,19 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S replBridgeEnvironmentId: undefined, replBridgeSessionId: undefined, replBridgeError: undefined, - replBridgePermissionCallbacks: undefined - }; - }); - lastWrittenIndexRef.current = 0; - }; + replBridgePermissionCallbacks: undefined, + } + }) + lastWrittenIndexRef.current = 0 + } } - }, [replBridgeEnabled, replBridgeOutboundOnly, setAppState, setMessages, addNotification]); + }, [ + replBridgeEnabled, + replBridgeOutboundOnly, + setAppState, + setMessages, + addNotification, + ]) // Write new messages as they appear. // Also re-runs when replBridgeConnected changes (bridge finishes init), @@ -687,38 +828,47 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S useEffect(() => { // Positive feature() guard — see first useEffect comment if (feature('BRIDGE_MODE')) { - if (!replBridgeConnected) return; - const handle_1 = handleRef.current; - if (!handle_1) return; + if (!replBridgeConnected) return + + const handle = handleRef.current + if (!handle) return // Clamp the index in case messages were compacted (array shortened). // After compaction the ref could exceed messages.length, and without // clamping no new messages would be forwarded. if (lastWrittenIndexRef.current > messages.length) { - logForDebugging(`[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`); + logForDebugging( + `[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`, + ) } - const startIndex = Math.min(lastWrittenIndexRef.current, messages.length); + const startIndex = Math.min(lastWrittenIndexRef.current, messages.length) // Collect new messages since last write - const newMessages: Message[] = []; + const newMessages: Message[] = [] for (let i = startIndex; i < messages.length; i++) { - const msg_1 = messages[i]; - if (msg_1 && (msg_1.type === 'user' || msg_1.type === 'assistant' || msg_1.type === 'system' && msg_1.subtype === 'local_command')) { - newMessages.push(msg_1); + const msg = messages[i] + if ( + msg && + (msg.type === 'user' || + msg.type === 'assistant' || + (msg.type === 'system' && msg.subtype === 'local_command')) + ) { + newMessages.push(msg) } } - lastWrittenIndexRef.current = messages.length; + lastWrittenIndexRef.current = messages.length + if (newMessages.length > 0) { - handle_1.writeMessages(newMessages); + handle.writeMessages(newMessages) } } - }, [messages, replBridgeConnected]); + }, [messages, replBridgeConnected]) + const sendBridgeResult = useCallback(() => { if (feature('BRIDGE_MODE')) { - handleRef.current?.sendResult(); + handleRef.current?.sendResult() } - }, []); - return { - sendBridgeResult - }; + }, []) + + return { sendBridgeResult } } diff --git a/src/hooks/useTeleportResume.tsx b/src/hooks/useTeleportResume.tsx index 24265fbc8..bc0e1fb0e 100644 --- a/src/hooks/useTeleportResume.tsx +++ b/src/hooks/useTeleportResume.tsx @@ -1,84 +1,78 @@ -import { c as _c } from "react/compiler-runtime"; -import { useCallback, useState } from 'react'; -import { setTeleportedSessionInfo } from 'src/bootstrap/state.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js'; -import type { CodeSession } from 'src/utils/teleport/api.js'; -import { errorMessage, TeleportOperationError } from '../utils/errors.js'; -import { teleportResumeCodeSession } from '../utils/teleport.js'; +import { useCallback, useState } from 'react' +import { setTeleportedSessionInfo } from 'src/bootstrap/state.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js' +import type { CodeSession } from 'src/utils/teleport/api.js' +import { errorMessage, TeleportOperationError } from '../utils/errors.js' +import { teleportResumeCodeSession } from '../utils/teleport.js' + export type TeleportResumeError = { - message: string; - formattedMessage?: string; - isOperationError: boolean; -}; -export type TeleportSource = 'cliArg' | 'localCommand'; -export function useTeleportResume(source) { - const $ = _c(8); - const [isResuming, setIsResuming] = useState(false); - const [error, setError] = useState(null); - const [selectedSession, setSelectedSession] = useState(null); - let t0; - if ($[0] !== source) { - t0 = async session => { - setIsResuming(true); - setError(null); - setSelectedSession(session); - logEvent("tengu_teleport_resume_session", { - source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_id: session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - ; + message: string + formattedMessage?: string + isOperationError: boolean +} + +export type TeleportSource = 'cliArg' | 'localCommand' + +export function useTeleportResume(source: TeleportSource) { + const [isResuming, setIsResuming] = useState(false) + const [error, setError] = useState(null) + const [selectedSession, setSelectedSession] = useState( + null, + ) + + const resumeSession = useCallback( + async (session: CodeSession): Promise => { + setIsResuming(true) + setError(null) + setSelectedSession(session) + + // Log teleport session selection + logEvent('tengu_teleport_resume_session', { + source: + source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_id: + session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + try { - const result = await teleportResumeCodeSession(session.id); - setTeleportedSessionInfo({ - sessionId: session.id - }); - setIsResuming(false); - return result; - } catch (t1) { - const err = t1; - const teleportError = { - message: err instanceof TeleportOperationError ? err.message : errorMessage(err), - formattedMessage: err instanceof TeleportOperationError ? err.formattedMessage : undefined, - isOperationError: err instanceof TeleportOperationError - }; - setError(teleportError); - setIsResuming(false); - return null; + const result = await teleportResumeCodeSession(session.id) + // Track teleported session for reliability logging + setTeleportedSessionInfo({ sessionId: session.id }) + setIsResuming(false) + return result + } catch (err) { + const teleportError: TeleportResumeError = { + message: + err instanceof TeleportOperationError + ? err.message + : errorMessage(err), + formattedMessage: + err instanceof TeleportOperationError + ? err.formattedMessage + : undefined, + isOperationError: err instanceof TeleportOperationError, + } + setError(teleportError) + setIsResuming(false) + return null } - }; - $[0] = source; - $[1] = t0; - } else { - t0 = $[1]; - } - const resumeSession = t0; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - setError(null); - }; - $[2] = t1; - } else { - t1 = $[2]; - } - const clearError = t1; - let t2; - if ($[3] !== error || $[4] !== isResuming || $[5] !== resumeSession || $[6] !== selectedSession) { - t2 = { - resumeSession, - isResuming, - error, - selectedSession, - clearError - }; - $[3] = error; - $[4] = isResuming; - $[5] = resumeSession; - $[6] = selectedSession; - $[7] = t2; - } else { - t2 = $[7]; + }, + [source], + ) + + const clearError = useCallback(() => { + setError(null) + }, []) + + return { + resumeSession, + isResuming, + error, + selectedSession, + clearError, } - return t2; } diff --git a/src/hooks/useTypeahead.tsx b/src/hooks/useTypeahead.tsx index 13333171e..3e2dbd220 100644 --- a/src/hooks/useTypeahead.tsx +++ b/src/hooks/useTypeahead.tsx @@ -1,119 +1,178 @@ -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { Text } from 'src/ink.js'; -import { logEvent } from 'src/services/analytics/index.js'; -import { useDebounceCallback } from 'usehooks-ts'; -import { type Command, getCommandName } from '../commands.js'; -import { getModeFromInput, getValueFromInput } from '../components/PromptInput/inputModes.js'; -import type { SuggestionItem, SuggestionType } from '../components/PromptInput/PromptInputFooterSuggestions.js'; -import { useIsModalOverlayActive, useRegisterOverlay } from '../context/overlayContext.js'; -import { KeyboardEvent } from '../ink/events/keyboard-event.js'; +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { Text } from 'src/ink.js' +import { logEvent } from 'src/services/analytics/index.js' +import { useDebounceCallback } from 'usehooks-ts' +import { type Command, getCommandName } from '../commands.js' +import { + getModeFromInput, + getValueFromInput, +} from '../components/PromptInput/inputModes.js' +import type { + SuggestionItem, + SuggestionType, +} from '../components/PromptInput/PromptInputFooterSuggestions.js' +import { + useIsModalOverlayActive, + useRegisterOverlay, +} from '../context/overlayContext.js' +import { KeyboardEvent } from '../ink/events/keyboard-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to -import { useInput } from '../ink.js'; -import { useOptionalKeybindingContext, useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { useAppState, useAppStateStore } from '../state/AppState.js'; -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; -import type { InlineGhostText, PromptInputMode } from '../types/textInputTypes.js'; -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; -import { generateProgressiveArgumentHint, parseArguments } from '../utils/argumentSubstitution.js'; -import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js'; -import { formatLogMetadata } from '../utils/format.js'; -import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js'; -import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, isCommandInput } from '../utils/suggestions/commandSuggestions.js'; -import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '../utils/suggestions/directoryCompletion.js'; -import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js'; -import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js'; -import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'; -import { applyFileSuggestion, findLongestCommonPrefix, onIndexBuildComplete, startBackgroundCacheRefresh } from './fileSuggestions.js'; -import { generateUnifiedSuggestions } from './unifiedSuggestions.js'; +import { useInput } from '../ink.js' +import { + useOptionalKeybindingContext, + useRegisterKeybindingContext, +} from '../keybindings/KeybindingContext.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { useAppState, useAppStateStore } from '../state/AppState.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import type { + InlineGhostText, + PromptInputMode, +} from '../types/textInputTypes.js' +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' +import { + generateProgressiveArgumentHint, + parseArguments, +} from '../utils/argumentSubstitution.js' +import { + getShellCompletions, + type ShellCompletionType, +} from '../utils/bash/shellCompletion.js' +import { formatLogMetadata } from '../utils/format.js' +import { + getSessionIdFromLog, + searchSessionsByCustomTitle, +} from '../utils/sessionStorage.js' +import { + applyCommandSuggestion, + findMidInputSlashCommand, + generateCommandSuggestions, + getBestCommandMatch, + isCommandInput, +} from '../utils/suggestions/commandSuggestions.js' +import { + getDirectoryCompletions, + getPathCompletions, + isPathLikeToken, +} from '../utils/suggestions/directoryCompletion.js' +import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js' +import { + getSlackChannelSuggestions, + hasSlackMcpServer, +} from '../utils/suggestions/slackChannelSuggestions.js' +import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' +import { + applyFileSuggestion, + findLongestCommonPrefix, + onIndexBuildComplete, + startBackgroundCacheRefresh, +} from './fileSuggestions.js' +import { generateUnifiedSuggestions } from './unifiedSuggestions.js' // Unicode-aware character class for file path tokens: // \p{L} = letters (CJK, Latin, Cyrillic, etc.) // \p{N} = numbers (incl. fullwidth) // \p{M} = combining marks (macOS NFD accents, Devanagari vowel signs) -const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u; -const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u; -const TOKEN_WITH_AT_RE = /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u; -const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u; -const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u; -const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/; +const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u +const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u +const TOKEN_WITH_AT_RE = + /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u +const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u +const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u +const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/ // Type guard for path completion metadata -function isPathMetadata(metadata: unknown): metadata is { - type: 'directory' | 'file'; -} { - return typeof metadata === 'object' && metadata !== null && 'type' in metadata && (metadata.type === 'directory' || metadata.type === 'file'); +function isPathMetadata( + metadata: unknown, +): metadata is { type: 'directory' | 'file' } { + return ( + typeof metadata === 'object' && + metadata !== null && + 'type' in metadata && + (metadata.type === 'directory' || metadata.type === 'file') + ) } // Helper to determine selectedSuggestion when updating suggestions -function getPreservedSelection(prevSuggestions: SuggestionItem[], prevSelection: number, newSuggestions: SuggestionItem[]): number { +function getPreservedSelection( + prevSuggestions: SuggestionItem[], + prevSelection: number, + newSuggestions: SuggestionItem[], +): number { // No new suggestions if (newSuggestions.length === 0) { - return -1; + return -1 } // No previous selection if (prevSelection < 0) { - return 0; + return 0 } // Get the previously selected item - const prevSelectedItem = prevSuggestions[prevSelection]; + const prevSelectedItem = prevSuggestions[prevSelection] if (!prevSelectedItem) { - return 0; + return 0 } // Try to find the same item in the new list by ID - const newIndex = newSuggestions.findIndex(item => item.id === prevSelectedItem.id); + const newIndex = newSuggestions.findIndex( + item => item.id === prevSelectedItem.id, + ) // Return the new index if found, otherwise default to 0 - return newIndex >= 0 ? newIndex : 0; + return newIndex >= 0 ? newIndex : 0 } + function buildResumeInputFromSuggestion(suggestion: SuggestionItem): string { - const metadata = suggestion.metadata as { - sessionId: string; - } | undefined; - return metadata?.sessionId ? `/resume ${metadata.sessionId}` : `/resume ${suggestion.displayText}`; + const metadata = suggestion.metadata as { sessionId: string } | undefined + return metadata?.sessionId + ? `/resume ${metadata.sessionId}` + : `/resume ${suggestion.displayText}` } + type Props = { - onInputChange: (value: string) => void; - onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void; - setCursorOffset: (offset: number) => void; - input: string; - cursorOffset: number; - commands: Command[]; - mode: string; - agents: AgentDefinition[]; - setSuggestionsState: (f: (previousSuggestionsState: { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }) => { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }) => void; + onInputChange: (value: string) => void + onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void + setCursorOffset: (offset: number) => void + input: string + cursorOffset: number + commands: Command[] + mode: string + agents: AgentDefinition[] + setSuggestionsState: ( + f: (previousSuggestionsState: { + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + }) => { + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + }, + ) => void suggestionsState: { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }; - suppressSuggestions?: boolean; - markAccepted: () => void; - onModeChange?: (mode: PromptInputMode) => void; -}; + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + } + suppressSuggestions?: boolean + markAccepted: () => void + onModeChange?: (mode: PromptInputMode) => void +} + type UseTypeaheadResult = { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - suggestionType: SuggestionType; - maxColumnWidth?: number; - commandArgumentHint?: string; - inlineGhostText?: InlineGhostText; - handleKeyDown: (e: KeyboardEvent) => void; -}; + suggestions: SuggestionItem[] + selectedSuggestion: number + suggestionType: SuggestionType + maxColumnWidth?: number + commandArgumentHint?: string + inlineGhostText?: InlineGhostText + handleKeyDown: (e: KeyboardEvent) => void +} /** * Extract search token from a completion token by removing @ prefix and quotes @@ -121,16 +180,16 @@ type UseTypeaheadResult = { * @returns The search token with @ and quotes removed */ export function extractSearchToken(completionToken: { - token: string; - isQuoted?: boolean; + token: string + isQuoted?: boolean }): string { if (completionToken.isQuoted) { // Remove @" prefix and optional closing " - return completionToken.token.slice(2).replace(/"$/, ''); + return completionToken.token.slice(2).replace(/"$/, '') } else if (completionToken.token.startsWith('@')) { - return completionToken.token.substring(1); + return completionToken.token.substring(1) } else { - return completionToken.token; + return completionToken.token } } @@ -146,80 +205,109 @@ export function extractSearchToken(completionToken: { * @returns The formatted replacement value */ export function formatReplacementValue(options: { - displayText: string; - mode: string; - hasAtPrefix: boolean; - needsQuotes: boolean; - isQuoted?: boolean; - isComplete: boolean; + displayText: string + mode: string + hasAtPrefix: boolean + needsQuotes: boolean + isQuoted?: boolean + isComplete: boolean }): string { - const { - displayText, - mode, - hasAtPrefix, - needsQuotes, - isQuoted, - isComplete - } = options; - const space = isComplete ? ' ' : ''; + const { displayText, mode, hasAtPrefix, needsQuotes, isQuoted, isComplete } = + options + const space = isComplete ? ' ' : '' + if (isQuoted || needsQuotes) { // Use quoted format - return mode === 'bash' ? `"${displayText}"${space}` : `@"${displayText}"${space}`; + return mode === 'bash' + ? `"${displayText}"${space}` + : `@"${displayText}"${space}` } else if (hasAtPrefix) { - return mode === 'bash' ? `${displayText}${space}` : `@${displayText}${space}`; + return mode === 'bash' + ? `${displayText}${space}` + : `@${displayText}${space}` } else { - return displayText; + return displayText } } /** * Apply a shell completion suggestion by replacing the current word */ -export function applyShellSuggestion(suggestion: SuggestionItem, input: string, cursorOffset: number, onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void, completionType: ShellCompletionType | undefined): void { - const beforeCursor = input.slice(0, cursorOffset); - const lastSpaceIndex = beforeCursor.lastIndexOf(' '); - const wordStart = lastSpaceIndex + 1; +export function applyShellSuggestion( + suggestion: SuggestionItem, + input: string, + cursorOffset: number, + onInputChange: (value: string) => void, + setCursorOffset: (offset: number) => void, + completionType: ShellCompletionType | undefined, +): void { + const beforeCursor = input.slice(0, cursorOffset) + const lastSpaceIndex = beforeCursor.lastIndexOf(' ') + const wordStart = lastSpaceIndex + 1 // Prepare the replacement text based on completion type - let replacementText: string; + let replacementText: string if (completionType === 'variable') { - replacementText = '$' + suggestion.displayText + ' '; + replacementText = '$' + suggestion.displayText + ' ' } else if (completionType === 'command') { - replacementText = suggestion.displayText + ' '; + replacementText = suggestion.displayText + ' ' } else { - replacementText = suggestion.displayText; + replacementText = suggestion.displayText } - const newInput = input.slice(0, wordStart) + replacementText + input.slice(cursorOffset); - onInputChange(newInput); - setCursorOffset(wordStart + replacementText.length); + + const newInput = + input.slice(0, wordStart) + replacementText + input.slice(cursorOffset) + + onInputChange(newInput) + setCursorOffset(wordStart + replacementText.length) } -const DM_MEMBER_RE = /(^|\s)@[\w-]*$/; -function applyTriggerSuggestion(suggestion: SuggestionItem, input: string, cursorOffset: number, triggerRe: RegExp, onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void): void { - const m = input.slice(0, cursorOffset).match(triggerRe); - if (!m || m.index === undefined) return; - const prefixStart = m.index + (m[1]?.length ?? 0); - const before = input.slice(0, prefixStart); - const newInput = before + suggestion.displayText + ' ' + input.slice(cursorOffset); - onInputChange(newInput); - setCursorOffset(before.length + suggestion.displayText.length + 1); + +const DM_MEMBER_RE = /(^|\s)@[\w-]*$/ + +function applyTriggerSuggestion( + suggestion: SuggestionItem, + input: string, + cursorOffset: number, + triggerRe: RegExp, + onInputChange: (value: string) => void, + setCursorOffset: (offset: number) => void, +): void { + const m = input.slice(0, cursorOffset).match(triggerRe) + if (!m || m.index === undefined) return + const prefixStart = m.index + (m[1]?.length ?? 0) + const before = input.slice(0, prefixStart) + const newInput = + before + suggestion.displayText + ' ' + input.slice(cursorOffset) + onInputChange(newInput) + setCursorOffset(before.length + suggestion.displayText.length + 1) } -let currentShellCompletionAbortController: AbortController | null = null; + +let currentShellCompletionAbortController: AbortController | null = null /** * Generate bash shell completion suggestions */ -async function generateBashSuggestions(input: string, cursorOffset: number): Promise { +async function generateBashSuggestions( + input: string, + cursorOffset: number, +): Promise { try { if (currentShellCompletionAbortController) { - currentShellCompletionAbortController.abort(); + currentShellCompletionAbortController.abort() } - currentShellCompletionAbortController = new AbortController(); - const suggestions = await getShellCompletions(input, cursorOffset, currentShellCompletionAbortController.signal); - return suggestions; + + currentShellCompletionAbortController = new AbortController() + const suggestions = await getShellCompletions( + input, + cursorOffset, + currentShellCompletionAbortController.signal, + ) + + return suggestions } catch { // Silent failure - don't break UX - logEvent('tengu_shell_completion_failed', {}); - return []; + logEvent('tengu_shell_completion_failed', {}) + return [] } } @@ -234,21 +322,25 @@ async function generateBashSuggestions(input: string, cursorOffset: number): Pro * @param isDirectory Whether the suggestion is a directory (adds / suffix) or file (adds space) * @returns Object with the new input text and cursor position */ -export function applyDirectorySuggestion(input: string, suggestionId: string, tokenStartPos: number, tokenLength: number, isDirectory: boolean): { - newInput: string; - cursorPos: number; -} { - const suffix = isDirectory ? '/' : ' '; - const before = input.slice(0, tokenStartPos); - const after = input.slice(tokenStartPos + tokenLength); +export function applyDirectorySuggestion( + input: string, + suggestionId: string, + tokenStartPos: number, + tokenLength: number, + isDirectory: boolean, +): { newInput: string; cursorPos: number } { + const suffix = isDirectory ? '/' : ' ' + const before = input.slice(0, tokenStartPos) + const after = input.slice(tokenStartPos + tokenLength) // Always add @ prefix - if token already has it, we're replacing // the whole token (including @) with @suggestion.id - const replacement = '@' + suggestionId + suffix; - const newInput = before + replacement + after; + const replacement = '@' + suggestionId + suffix + const newInput = before + replacement + after + return { newInput, - cursorPos: before.length + replacement.length - }; + cursorPos: before.length + replacement.length, + } } /** @@ -258,93 +350,104 @@ export function applyDirectorySuggestion(input: string, suggestionId: string, to * @param includeAtSymbol Whether to consider @ symbol as part of the token * @returns The completable token and its start position, or null if not found */ -export function extractCompletionToken(text: string, cursorPos: number, includeAtSymbol = false): { - token: string; - startPos: number; - isQuoted?: boolean; -} | null { +export function extractCompletionToken( + text: string, + cursorPos: number, + includeAtSymbol = false, +): { token: string; startPos: number; isQuoted?: boolean } | null { // Empty input check - if (!text) return null; + if (!text) return null // Get text up to cursor - const textBeforeCursor = text.substring(0, cursorPos); + const textBeforeCursor = text.substring(0, cursorPos) // Check for quoted @ mention first (e.g., @"my file with spaces") if (includeAtSymbol) { - const quotedAtRegex = /@"([^"]*)"?$/; - const quotedMatch = textBeforeCursor.match(quotedAtRegex); + const quotedAtRegex = /@"([^"]*)"?$/ + const quotedMatch = textBeforeCursor.match(quotedAtRegex) if (quotedMatch && quotedMatch.index !== undefined) { // Include any remaining quoted content after cursor until closing quote or end - const textAfterCursor = text.substring(cursorPos); - const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/); - const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/) + const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : '' + return { token: quotedMatch[0] + quotedSuffix, startPos: quotedMatch.index, - isQuoted: true - }; + isQuoted: true, + } } } // Fast path for @ tokens: use lastIndexOf to avoid expensive $ anchor scan if (includeAtSymbol) { - const atIdx = textBeforeCursor.lastIndexOf('@'); - if (atIdx >= 0 && (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!))) { - const fromAt = textBeforeCursor.substring(atIdx); - const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE); + const atIdx = textBeforeCursor.lastIndexOf('@') + if ( + atIdx >= 0 && + (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!)) + ) { + const fromAt = textBeforeCursor.substring(atIdx) + const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE) if (atHeadMatch && atHeadMatch[0].length === fromAt.length) { - const textAfterCursor = text.substring(cursorPos); - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); - const tokenSuffix = afterMatch ? afterMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) + const tokenSuffix = afterMatch ? afterMatch[0] : '' return { token: atHeadMatch[0] + tokenSuffix, startPos: atIdx, - isQuoted: false - }; + isQuoted: false, + } } } } // Non-@ token or cursor outside @ token — use $ anchor on (short) tail - const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE; - const match = textBeforeCursor.match(tokenRegex); + const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE + const match = textBeforeCursor.match(tokenRegex) if (!match || match.index === undefined) { - return null; + return null } // Check if cursor is in the MIDDLE of a token (more word characters after cursor) // If so, extend the token to include all characters until whitespace or end of string - const textAfterCursor = text.substring(cursorPos); - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); - const tokenSuffix = afterMatch ? afterMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) + const tokenSuffix = afterMatch ? afterMatch[0] : '' + return { token: match[0] + tokenSuffix, startPos: match.index, - isQuoted: false - }; + isQuoted: false, + } } + function extractCommandNameAndArgs(value: string): { - commandName: string; - args: string; + commandName: string + args: string } | null { if (isCommandInput(value)) { - const spaceIndex = value.indexOf(' '); - if (spaceIndex === -1) return { - commandName: value.slice(1), - args: '' - }; + const spaceIndex = value.indexOf(' ') + if (spaceIndex === -1) + return { + commandName: value.slice(1), + args: '', + } return { commandName: value.slice(1, spaceIndex), - args: value.slice(spaceIndex + 1) - }; + args: value.slice(spaceIndex + 1), + } } - return null; + return null } -function hasCommandWithArguments(isAtEndWithWhitespace: boolean, value: string) { + +function hasCommandWithArguments( + isAtEndWithWhitespace: boolean, + value: string, +) { // If value.endsWith(' ') but the user is not at the end, then the user has // potentially gone back to the command in an effort to edit the command name // (but preserve the arguments). - return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' '); + return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' ') } /** @@ -360,122 +463,150 @@ export function useTypeahead({ mode, agents, setSuggestionsState, - suggestionsState: { - suggestions, - selectedSuggestion, - commandArgumentHint - }, + suggestionsState: { suggestions, selectedSuggestion, commandArgumentHint }, suppressSuggestions = false, markAccepted, - onModeChange + onModeChange, }: Props): UseTypeaheadResult { - const { - addNotification - } = useNotifications(); - const thinkingToggleShortcut = useShortcutDisplay('chat:thinkingToggle', 'Chat', 'alt+t'); - const [suggestionType, setSuggestionType] = useState('none'); + const { addNotification } = useNotifications() + const thinkingToggleShortcut = useShortcutDisplay( + 'chat:thinkingToggle', + 'Chat', + 'alt+t', + ) + const [suggestionType, setSuggestionType] = useState('none') // Compute max column width from ALL commands once (not filtered results) // This prevents layout shift when filtering const allCommandsMaxWidth = useMemo(() => { - const visibleCommands = commands.filter(cmd => !cmd.isHidden); - if (visibleCommands.length === 0) return undefined; - const maxLen = Math.max(...visibleCommands.map(cmd => getCommandName(cmd).length)); - return maxLen + 6; // +1 for "/" prefix, +5 for padding - }, [commands]); - const [maxColumnWidth, setMaxColumnWidth] = useState(undefined); - const mcpResources = useAppState(s => s.mcp.resources); - const store = useAppStateStore(); - const promptSuggestion = useAppState(s => s.promptSuggestion); + const visibleCommands = commands.filter(cmd => !cmd.isHidden) + if (visibleCommands.length === 0) return undefined + const maxLen = Math.max( + ...visibleCommands.map(cmd => getCommandName(cmd).length), + ) + return maxLen + 6 // +1 for "/" prefix, +5 for padding + }, [commands]) + + const [maxColumnWidth, setMaxColumnWidth] = useState( + undefined, + ) + const mcpResources = useAppState(s => s.mcp.resources) + const store = useAppStateStore() + const promptSuggestion = useAppState(s => s.promptSuggestion) // PromptInput hides suggestion ghost text in teammate view — mirror that // gate here so Tab/rightArrow can't accept what isn't displayed. - const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId); + const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId) // Access keybinding context to check for pending chord sequences - const keybindingContext = useOptionalKeybindingContext(); + const keybindingContext = useOptionalKeybindingContext() // State for inline ghost text (bash history completion - async) - const [inlineGhostText, setInlineGhostText] = useState(undefined); + const [inlineGhostText, setInlineGhostText] = useState< + InlineGhostText | undefined + >(undefined) // Synchronous ghost text for prompt mode mid-input slash commands. // Computed during render via useMemo to eliminate the one-frame flicker // that occurs when using useState + useEffect (effect runs after render). const syncPromptGhostText = useMemo((): InlineGhostText | undefined => { - if (mode !== 'prompt' || suppressSuggestions) return undefined; - const midInputCommand = findMidInputSlashCommand(input, cursorOffset); - if (!midInputCommand) return undefined; - const match = getBestCommandMatch(midInputCommand.partialCommand, commands); - if (!match) return undefined; + if (mode !== 'prompt' || suppressSuggestions) return undefined + const midInputCommand = findMidInputSlashCommand(input, cursorOffset) + if (!midInputCommand) return undefined + const match = getBestCommandMatch(midInputCommand.partialCommand, commands) + if (!match) return undefined return { text: match.suffix, fullCommand: match.fullCommand, - insertPosition: midInputCommand.startPos + 1 + midInputCommand.partialCommand.length - }; - }, [input, cursorOffset, mode, commands, suppressSuggestions]); + insertPosition: + midInputCommand.startPos + 1 + midInputCommand.partialCommand.length, + } + }, [input, cursorOffset, mode, commands, suppressSuggestions]) // Merged ghost text: prompt mode uses synchronous useMemo, bash mode uses async useState - const effectiveGhostText = suppressSuggestions ? undefined : mode === 'prompt' ? syncPromptGhostText : inlineGhostText; + const effectiveGhostText = suppressSuggestions + ? undefined + : mode === 'prompt' + ? syncPromptGhostText + : inlineGhostText // Use a ref for cursorOffset to avoid re-triggering suggestions on cursor movement alone // We only want to re-fetch suggestions when the actual search token changes - const cursorOffsetRef = useRef(cursorOffset); - cursorOffsetRef.current = cursorOffset; + const cursorOffsetRef = useRef(cursorOffset) + cursorOffsetRef.current = cursorOffset // Track the latest search token to discard stale results from slow async operations - const latestSearchTokenRef = useRef(null); + const latestSearchTokenRef = useRef(null) // Track previous input to detect actual text changes vs. callback recreations - const prevInputRef = useRef(''); + const prevInputRef = useRef('') // Track the latest path token to discard stale results from path completion - const latestPathTokenRef = useRef(''); + const latestPathTokenRef = useRef('') // Track the latest bash input to discard stale results from history completion - const latestBashInputRef = useRef(''); + const latestBashInputRef = useRef('') // Track the latest slack channel token to discard stale results from MCP - const latestSlackTokenRef = useRef(''); + const latestSlackTokenRef = useRef('') // Track suggestions via ref to avoid updateSuggestions being recreated on selection changes - const suggestionsRef = useRef(suggestions); - suggestionsRef.current = suggestions; + const suggestionsRef = useRef(suggestions) + suggestionsRef.current = suggestions // Track the input value when suggestions were manually dismissed to prevent re-triggering - const dismissedForInputRef = useRef(null); + const dismissedForInputRef = useRef(null) // Clear all suggestions const clearSuggestions = useCallback(() => { setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - setInlineGhostText(undefined); - }, [setSuggestionsState]); + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + setInlineGhostText(undefined) + }, [setSuggestionsState]) // Expensive async operation to fetch file/resource suggestions - const fetchFileSuggestions = useCallback(async (searchToken: string, isAtSymbol = false): Promise => { - latestSearchTokenRef.current = searchToken; - const combinedItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); - // Discard stale results if a newer query was initiated while waiting - if (latestSearchTokenRef.current !== searchToken) { - return; - } - if (combinedItems.length === 0) { - // Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions - setSuggestionsState(() => ({ + const fetchFileSuggestions = useCallback( + async (searchToken: string, isAtSymbol = false): Promise => { + latestSearchTokenRef.current = searchToken + const combinedItems = await generateUnifiedSuggestions( + searchToken, + mcpResources, + agents, + isAtSymbol, + ) + // Discard stale results if a newer query was initiated while waiting + if (latestSearchTokenRef.current !== searchToken) { + return + } + if (combinedItems.length === 0) { + // Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions + setSuggestionsState(() => ({ + commandArgumentHint: undefined, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } + setSuggestionsState(prev => ({ commandArgumentHint: undefined, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; - } - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: combinedItems, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, combinedItems) - })); - setSuggestionType(combinedItems.length > 0 ? 'file' : 'none'); - setMaxColumnWidth(undefined); // No fixed width for file suggestions - }, [mcpResources, setSuggestionsState, setSuggestionType, setMaxColumnWidth, agents]); + suggestions: combinedItems, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + combinedItems, + ), + })) + setSuggestionType(combinedItems.length > 0 ? 'file' : 'none') + setMaxColumnWidth(undefined) // No fixed width for file suggestions + }, + [ + mcpResources, + setSuggestionsState, + setSuggestionType, + setMaxColumnWidth, + agents, + ], + ) // Pre-warm the file index on mount so the first @-mention doesn't block. // The build runs in background with ~4ms event-loop yields, so it doesn't @@ -492,399 +623,515 @@ export function useTypeahead({ // subsequent tests in the shard. The subscriber still registers so // fileSuggestions tests that trigger a refresh directly work correctly. useEffect(() => { - if (("production" as string) !== 'test') { - startBackgroundCacheRefresh(); + if ("production" !== 'test') { + startBackgroundCacheRefresh() } return onIndexBuildComplete(() => { - const token = latestSearchTokenRef.current; + const token = latestSearchTokenRef.current if (token !== null) { - latestSearchTokenRef.current = null; - void fetchFileSuggestions(token, token === ''); + latestSearchTokenRef.current = null + void fetchFileSuggestions(token, token === '') } - }); - }, [fetchFileSuggestions]); + }) + }, [fetchFileSuggestions]) // Debounce the file fetch operation. 50ms sits just above macOS default // key-repeat (~33ms) so held-delete/backspace coalesces into one search // instead of stuttering on each repeated key. The search itself is ~8–15ms // on a 270k-file index. - const debouncedFetchFileSuggestions = useDebounceCallback(fetchFileSuggestions, 50); - const fetchSlackChannels = useCallback(async (partial: string): Promise => { - latestSlackTokenRef.current = partial; - const channels = await getSlackChannelSuggestions(store.getState().mcp.clients, partial); - if (latestSlackTokenRef.current !== partial) return; - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: channels, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, channels) - })); - setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none'); - setMaxColumnWidth(undefined); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref - [setSuggestionsState]); + const debouncedFetchFileSuggestions = useDebounceCallback( + fetchFileSuggestions, + 50, + ) + + const fetchSlackChannels = useCallback( + async (partial: string): Promise => { + latestSlackTokenRef.current = partial + const channels = await getSlackChannelSuggestions( + store.getState().mcp.clients, + partial, + ) + if (latestSlackTokenRef.current !== partial) return + setSuggestionsState(prev => ({ + commandArgumentHint: undefined, + suggestions: channels, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + channels, + ), + })) + setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none') + setMaxColumnWidth(undefined) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref + [setSuggestionsState], + ) // First keystroke after # needs the MCP round-trip; subsequent keystrokes // that share the same first-word segment hit the cache synchronously. - const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150); + const debouncedFetchSlackChannels = useDebounceCallback( + fetchSlackChannels, + 150, + ) // Handle immediate suggestion logic (cheap operations) // biome-ignore lint/correctness/useExhaustiveDependencies: store is a stable context ref, read imperatively at call-time - const updateSuggestions = useCallback(async (value: string, inputCursorOffset?: number): Promise => { - // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) - const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current; - if (suppressSuggestions) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; - } + const updateSuggestions = useCallback( + async (value: string, inputCursorOffset?: number): Promise => { + // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) + const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current + if (suppressSuggestions) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return + } - // Check for mid-input slash command (e.g., "help me /com") - // Only in prompt mode, not when input starts with "/" (handled separately) - // Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo. - // We only need to clear dropdown suggestions here when ghost text is active. - if (mode === 'prompt') { - const midInputCommand = findMidInputSlashCommand(value, effectiveCursorOffset); - if (midInputCommand) { - const match = getBestCommandMatch(midInputCommand.partialCommand, commands); - if (match) { + // Check for mid-input slash command (e.g., "help me /com") + // Only in prompt mode, not when input starts with "/" (handled separately) + // Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo. + // We only need to clear dropdown suggestions here when ghost text is active. + if (mode === 'prompt') { + const midInputCommand = findMidInputSlashCommand( + value, + effectiveCursorOffset, + ) + if (midInputCommand) { + const match = getBestCommandMatch( + midInputCommand.partialCommand, + commands, + ) + if (match) { + // Clear dropdown suggestions when showing ghost text + setSuggestionsState(() => ({ + commandArgumentHint: undefined, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } + } + } + + // Bash mode: check for history-based ghost text completion + if (mode === 'bash' && value.trim()) { + latestBashInputRef.current = value + const historyMatch = await getShellHistoryCompletion(value) + // Discard stale results if input changed while waiting + if (latestBashInputRef.current !== value) { + return + } + if (historyMatch) { + setInlineGhostText({ + text: historyMatch.suffix, + fullCommand: historyMatch.fullCommand, + insertPosition: value.length, + }) // Clear dropdown suggestions when showing ghost text setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } else { + // No history match, clear ghost text + setInlineGhostText(undefined) } } - } - // Bash mode: check for history-based ghost text completion - if (mode === 'bash' && value.trim()) { - latestBashInputRef.current = value; - const historyMatch = await getShellHistoryCompletion(value); - // Discard stale results if input changed while waiting - if (latestBashInputRef.current !== value) { - return; - } - if (historyMatch) { - setInlineGhostText({ - text: historyMatch.suffix, - fullCommand: historyMatch.fullCommand, - insertPosition: value.length - }); - // Clear dropdown suggestions when showing ghost text - setSuggestionsState(() => ({ - commandArgumentHint: undefined, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; - } else { - // No history match, clear ghost text - setInlineGhostText(undefined); - } - } + // Check for @ to trigger team member / named subagent suggestions + // Must check before @ file symbol to prevent conflict + // Skip in bash mode - @ has no special meaning in shell commands + const atMatch = + mode !== 'bash' + ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) + : null + if (atMatch) { + const partialName = (atMatch[2] ?? '').toLowerCase() + // Imperative read — reading at call-time fixes staleness for + // teammates/subagents added mid-session. + const state = store.getState() + const members: SuggestionItem[] = [] + const seen = new Set() + + if (isAgentSwarmsEnabled() && state.teamContext) { + for (const t of Object.values(state.teamContext.teammates ?? {})) { + if (t.name === TEAM_LEAD_NAME) continue + if (!t.name.toLowerCase().startsWith(partialName)) continue + seen.add(t.name) + members.push({ + id: `dm-${t.name}`, + displayText: `@${t.name}`, + description: 'send message', + }) + } + } - // Check for @ to trigger team member / named subagent suggestions - // Must check before @ file symbol to prevent conflict - // Skip in bash mode - @ has no special meaning in shell commands - const atMatch = mode !== 'bash' ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) : null; - if (atMatch) { - const partialName = (atMatch[2] ?? '').toLowerCase(); - // Imperative read — reading at call-time fixes staleness for - // teammates/subagents added mid-session. - const state = store.getState(); - const members: SuggestionItem[] = []; - const seen = new Set(); - if (isAgentSwarmsEnabled() && state.teamContext) { - for (const t of Object.values(state.teamContext.teammates ?? {})) { - if (t.name === TEAM_LEAD_NAME) continue; - if (!t.name.toLowerCase().startsWith(partialName)) continue; - seen.add(t.name); + for (const [name, agentId] of state.agentNameRegistry) { + if (seen.has(name)) continue + if (!name.toLowerCase().startsWith(partialName)) continue + const status = state.tasks[agentId]?.status members.push({ - id: `dm-${t.name}`, - displayText: `@${t.name}`, - description: 'send message' - }); + id: `dm-${name}`, + displayText: `@${name}`, + description: status ? `send message · ${status}` : 'send message', + }) } - } - for (const [name, agentId] of state.agentNameRegistry) { - if (seen.has(name)) continue; - if (!name.toLowerCase().startsWith(partialName)) continue; - const status = state.tasks[agentId]?.status; - members.push({ - id: `dm-${name}`, - displayText: `@${name}`, - description: status ? `send message · ${status}` : 'send message' - }); - } - if (members.length > 0) { - debouncedFetchFileSuggestions.cancel(); - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: members, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, members) - })); - setSuggestionType('agent'); - setMaxColumnWidth(undefined); - return; - } - } - // Check for # to trigger Slack channel suggestions (requires Slack MCP server) - if (mode === 'prompt') { - const hashMatch = value.substring(0, effectiveCursorOffset).match(HASH_CHANNEL_RE); - if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) { - debouncedFetchSlackChannels(hashMatch[2]!); - return; - } else if (suggestionType === 'slack-channel') { - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); - } - } - - // Check for @ symbol to trigger file suggestions (including quoted paths) - // Includes colon for MCP resources (e.g., server:resource/path) - const hasAtSymbol = value.substring(0, effectiveCursorOffset).match(HAS_AT_SYMBOL_RE); - - // First, check for slash command suggestions (higher priority than @ symbol) - // Only show slash command selector if cursor is not on the "/" character itself - // Also don't show if cursor is at end of line with whitespace before it - // Don't show slash commands in bash mode - const isAtEndWithWhitespace = effectiveCursorOffset === value.length && effectiveCursorOffset > 0 && value.length > 0 && value[effectiveCursorOffset - 1] === ' '; - - // Handle directory completion for commands - if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0) { - const parsedCommand = extractCommandNameAndArgs(value); - if (parsedCommand && parsedCommand.commandName === 'add-dir' && parsedCommand.args) { - const { - args - } = parsedCommand; - - // Clear suggestions if args end with whitespace (user is done with path) - if (args.match(/\s+$/)) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; - } - const dirSuggestions = await getDirectoryCompletions(args); - if (dirSuggestions.length > 0) { + if (members.length > 0) { + debouncedFetchFileSuggestions.cancel() setSuggestionsState(prev => ({ - suggestions: dirSuggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, dirSuggestions), - commandArgumentHint: undefined - })); - setSuggestionType('directory'); - return; + commandArgumentHint: undefined, + suggestions: members, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + members, + ), + })) + setSuggestionType('agent') + setMaxColumnWidth(undefined) + return } + } - // No suggestions found - clear and return - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; + // Check for # to trigger Slack channel suggestions (requires Slack MCP server) + if (mode === 'prompt') { + const hashMatch = value + .substring(0, effectiveCursorOffset) + .match(HASH_CHANNEL_RE) + if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) { + debouncedFetchSlackChannels(hashMatch[2]!) + return + } else if (suggestionType === 'slack-channel') { + debouncedFetchSlackChannels.cancel() + clearSuggestions() + } } - // Handle custom title completion for /resume command - if (parsedCommand && parsedCommand.commandName === 'resume' && parsedCommand.args !== undefined && value.includes(' ')) { - const { - args - } = parsedCommand; - - // Get custom title suggestions using partial match - const matches = await searchSessionsByCustomTitle(args, { - limit: 10 - }); - const suggestions = matches.map(log => { - const sessionId = getSessionIdFromLog(log); - return { - id: `resume-title-${sessionId}`, - displayText: log.customTitle!, - description: formatLogMetadata(log), - metadata: { - sessionId - } - }; - }); - if (suggestions.length > 0) { - setSuggestionsState(prev => ({ - suggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestions), - commandArgumentHint: undefined - })); - setSuggestionType('custom-title'); - return; + // Check for @ symbol to trigger file suggestions (including quoted paths) + // Includes colon for MCP resources (e.g., server:resource/path) + const hasAtSymbol = value + .substring(0, effectiveCursorOffset) + .match(HAS_AT_SYMBOL_RE) + + // First, check for slash command suggestions (higher priority than @ symbol) + // Only show slash command selector if cursor is not on the "/" character itself + // Also don't show if cursor is at end of line with whitespace before it + // Don't show slash commands in bash mode + const isAtEndWithWhitespace = + effectiveCursorOffset === value.length && + effectiveCursorOffset > 0 && + value.length > 0 && + value[effectiveCursorOffset - 1] === ' ' + + // Handle directory completion for commands + if ( + mode === 'prompt' && + isCommandInput(value) && + effectiveCursorOffset > 0 + ) { + const parsedCommand = extractCommandNameAndArgs(value) + + if ( + parsedCommand && + parsedCommand.commandName === 'add-dir' && + parsedCommand.args + ) { + const { args } = parsedCommand + + // Clear suggestions if args end with whitespace (user is done with path) + if (args.match(/\s+$/)) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return + } + + const dirSuggestions = await getDirectoryCompletions(args) + if (dirSuggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions: dirSuggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + dirSuggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('directory') + return + } + + // No suggestions found - clear and return + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return } - // No suggestions found - clear and return - clearSuggestions(); - return; + // Handle custom title completion for /resume command + if ( + parsedCommand && + parsedCommand.commandName === 'resume' && + parsedCommand.args !== undefined && + value.includes(' ') + ) { + const { args } = parsedCommand + + // Get custom title suggestions using partial match + const matches = await searchSessionsByCustomTitle(args, { + limit: 10, + }) + + const suggestions = matches.map(log => { + const sessionId = getSessionIdFromLog(log) + return { + id: `resume-title-${sessionId}`, + displayText: log.customTitle!, + description: formatLogMetadata(log), + metadata: { sessionId }, + } + }) + + if (suggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + suggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('custom-title') + return + } + + // No suggestions found - clear and return + clearSuggestions() + return + } } - } - // Determine whether to display the argument hint and command suggestions. - if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0 && !hasCommandWithArguments(isAtEndWithWhitespace, value)) { - let commandArgumentHint: string | undefined = undefined; - if (value.length > 1) { - // We have a partial or complete command without arguments - // Check if it matches a command exactly and has an argument hint - - // Extract command name: everything after / until the first space (or end) - const spaceIndex = value.indexOf(' '); - const commandName = spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex); - - // Check if there are real arguments (non-whitespace after the command) - const hasRealArguments = spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0; - - // Check if input is exactly "command + single space" (ready for arguments) - const hasExactlyOneTrailingSpace = spaceIndex !== -1 && value.length === spaceIndex + 1; - - // If input has a space after the command, don't show suggestions - // This prevents Enter from selecting a different command after Tab completion - if (spaceIndex !== -1) { - const exactMatch = commands.find(cmd => getCommandName(cmd) === commandName); - if (exactMatch || hasRealArguments) { - // Priority 1: Static argumentHint (only on first trailing space for backwards compat) - if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) { - commandArgumentHint = exactMatch.argumentHint; - } - // Priority 2: Progressive hint from argNames (show when trailing space) - else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && value.endsWith(' ')) { - const argsText = value.slice(spaceIndex + 1); - const typedArgs = parseArguments(argsText); - commandArgumentHint = generateProgressiveArgumentHint(exactMatch.argNames, typedArgs); + // Determine whether to display the argument hint and command suggestions. + if ( + mode === 'prompt' && + isCommandInput(value) && + effectiveCursorOffset > 0 && + !hasCommandWithArguments(isAtEndWithWhitespace, value) + ) { + let commandArgumentHint: string | undefined = undefined + if (value.length > 1) { + // We have a partial or complete command without arguments + // Check if it matches a command exactly and has an argument hint + + // Extract command name: everything after / until the first space (or end) + const spaceIndex = value.indexOf(' ') + const commandName = + spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex) + + // Check if there are real arguments (non-whitespace after the command) + const hasRealArguments = + spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0 + + // Check if input is exactly "command + single space" (ready for arguments) + const hasExactlyOneTrailingSpace = + spaceIndex !== -1 && value.length === spaceIndex + 1 + + // If input has a space after the command, don't show suggestions + // This prevents Enter from selecting a different command after Tab completion + if (spaceIndex !== -1) { + const exactMatch = commands.find( + cmd => getCommandName(cmd) === commandName, + ) + if (exactMatch || hasRealArguments) { + // Priority 1: Static argumentHint (only on first trailing space for backwards compat) + if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) { + commandArgumentHint = exactMatch.argumentHint + } + // Priority 2: Progressive hint from argNames (show when trailing space) + else if ( + exactMatch?.type === 'prompt' && + exactMatch.argNames?.length && + value.endsWith(' ') + ) { + const argsText = value.slice(spaceIndex + 1) + const typedArgs = parseArguments(argsText) + commandArgumentHint = generateProgressiveArgumentHint( + exactMatch.argNames, + typedArgs, + ) + } + setSuggestionsState(() => ({ + commandArgumentHint, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return } - setSuggestionsState(() => ({ - commandArgumentHint, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; } + + // Note: argument hint is only shown when there's exactly one trailing space + // (set above when hasExactlyOneTrailingSpace is true) } - // Note: argument hint is only shown when there's exactly one trailing space - // (set above when hasExactlyOneTrailingSpace is true) + const commandItems = generateCommandSuggestions(value, commands) + setSuggestionsState(() => ({ + commandArgumentHint, + suggestions: commandItems, + selectedSuggestion: commandItems.length > 0 ? 0 : -1, + })) + setSuggestionType(commandItems.length > 0 ? 'command' : 'none') + + // Use stable width from all commands (prevents layout shift when filtering) + if (commandItems.length > 0) { + setMaxColumnWidth(allCommandsMaxWidth) + } + return } - const commandItems = generateCommandSuggestions(value, commands); - setSuggestionsState(() => ({ - commandArgumentHint, - suggestions: commandItems, - selectedSuggestion: commandItems.length > 0 ? 0 : -1 - })); - setSuggestionType(commandItems.length > 0 ? 'command' : 'none'); - - // Use stable width from all commands (prevents layout shift when filtering) - if (commandItems.length > 0) { - setMaxColumnWidth(allCommandsMaxWidth); + + if (suggestionType === 'command') { + // If we had command suggestions but the input no longer starts with '/' + // we need to clear the suggestions. However, we should not return + // because there may be relevant @ symbol and file suggestions. + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } else if ( + isCommandInput(value) && + hasCommandWithArguments(isAtEndWithWhitespace, value) + ) { + // If we have a command with arguments (no trailing space), clear any stale hint + // This prevents the hint from flashing when transitioning between states + setSuggestionsState(prev => + prev.commandArgumentHint + ? { ...prev, commandArgumentHint: undefined } + : prev, + ) } - return; - } - if (suggestionType === 'command') { - // If we had command suggestions but the input no longer starts with '/' - // we need to clear the suggestions. However, we should not return - // because there may be relevant @ symbol and file suggestions. - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - } else if (isCommandInput(value) && hasCommandWithArguments(isAtEndWithWhitespace, value)) { - // If we have a command with arguments (no trailing space), clear any stale hint - // This prevents the hint from flashing when transitioning between states - setSuggestionsState(prev => prev.commandArgumentHint ? { - ...prev, - commandArgumentHint: undefined - } : prev); - } - if (suggestionType === 'custom-title') { - // If we had custom-title suggestions but the input is no longer /resume - // we need to clear the suggestions. - clearSuggestions(); - } - if (suggestionType === 'agent' && suggestionsRef.current.some((s: SuggestionItem) => s.id?.startsWith('dm-'))) { - // If we had team member suggestions but the input no longer has @ - // we need to clear the suggestions. - const hasAt = value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/); - if (!hasAt) { - clearSuggestions(); + + if (suggestionType === 'custom-title') { + // If we had custom-title suggestions but the input is no longer /resume + // we need to clear the suggestions. + clearSuggestions() } - } - // Check for @ symbol to trigger file and MCP resource suggestions - // Skip @ autocomplete in bash mode - @ has no special meaning in shell commands - if (hasAtSymbol && mode !== 'bash') { - // Get the @ token (including the @ symbol) - const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); - if (completionToken && completionToken.token.startsWith('@')) { - const searchToken = extractSearchToken(completionToken); - - // If the token after @ is path-like, use path completion instead of fuzzy search - // This handles cases like @~/path, @./path, @/path for directory traversal - if (isPathLikeToken(searchToken)) { - latestPathTokenRef.current = searchToken; - const pathSuggestions = await getPathCompletions(searchToken, { - maxResults: 10 - }); - // Discard stale results if a newer query was initiated while waiting - if (latestPathTokenRef.current !== searchToken) { - return; - } - if (pathSuggestions.length > 0) { - setSuggestionsState(prev => ({ - suggestions: pathSuggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, pathSuggestions), - commandArgumentHint: undefined - })); - setSuggestionType('directory'); - return; - } + if ( + suggestionType === 'agent' && + suggestionsRef.current.some((s: SuggestionItem) => + s.id?.startsWith('dm-'), + ) + ) { + // If we had team member suggestions but the input no longer has @ + // we need to clear the suggestions. + const hasAt = value + .substring(0, effectiveCursorOffset) + .match(/(^|\s)@([\w-]*)$/) + if (!hasAt) { + clearSuggestions() } + } - // Skip if we already fetched for this exact token (prevents loop from - // suggestions dependency causing updateSuggestions to be recreated) - if (latestSearchTokenRef.current === searchToken) { - return; + // Check for @ symbol to trigger file and MCP resource suggestions + // Skip @ autocomplete in bash mode - @ has no special meaning in shell commands + if (hasAtSymbol && mode !== 'bash') { + // Get the @ token (including the @ symbol) + const completionToken = extractCompletionToken( + value, + effectiveCursorOffset, + true, + ) + if (completionToken && completionToken.token.startsWith('@')) { + const searchToken = extractSearchToken(completionToken) + + // If the token after @ is path-like, use path completion instead of fuzzy search + // This handles cases like @~/path, @./path, @/path for directory traversal + if (isPathLikeToken(searchToken)) { + latestPathTokenRef.current = searchToken + const pathSuggestions = await getPathCompletions(searchToken, { + maxResults: 10, + }) + // Discard stale results if a newer query was initiated while waiting + if (latestPathTokenRef.current !== searchToken) { + return + } + if (pathSuggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions: pathSuggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + pathSuggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('directory') + return + } + } + + // Skip if we already fetched for this exact token (prevents loop from + // suggestions dependency causing updateSuggestions to be recreated) + if (latestSearchTokenRef.current === searchToken) { + return + } + void debouncedFetchFileSuggestions(searchToken, true) + return } - void debouncedFetchFileSuggestions(searchToken, true); - return; } - } - // If we have active file suggestions or the input changed, check for file suggestions - if (suggestionType === 'file') { - const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); - if (completionToken) { - const searchToken = extractSearchToken(completionToken); - // Skip if we already fetched for this exact token - if (latestSearchTokenRef.current === searchToken) { - return; + // If we have active file suggestions or the input changed, check for file suggestions + if (suggestionType === 'file') { + const completionToken = extractCompletionToken( + value, + effectiveCursorOffset, + true, + ) + if (completionToken) { + const searchToken = extractSearchToken(completionToken) + // Skip if we already fetched for this exact token + if (latestSearchTokenRef.current === searchToken) { + return + } + void debouncedFetchFileSuggestions(searchToken, false) + } else { + // If we had file suggestions but now there's no completion token + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - void debouncedFetchFileSuggestions(searchToken, false); - } else { - // If we had file suggestions but now there's no completion token - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); } - } - // Clear shell suggestions if not in bash mode OR if input has changed - if (suggestionType === 'shell') { - const inputSnapshot = (suggestionsRef.current[0]?.metadata as { - inputSnapshot?: string; - })?.inputSnapshot; - if (mode !== 'bash' || value !== inputSnapshot) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + // Clear shell suggestions if not in bash mode OR if input has changed + if (suggestionType === 'shell') { + const inputSnapshot = ( + suggestionsRef.current[0]?.metadata as { inputSnapshot?: string } + )?.inputSnapshot + + if (mode !== 'bash' || value !== inputSnapshot) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } } - } - }, [suggestionType, commands, setSuggestionsState, clearSuggestions, debouncedFetchFileSuggestions, debouncedFetchSlackChannels, mode, suppressSuggestions, - // Note: using suggestionsRef instead of suggestions to avoid recreating - // this callback when only selectedSuggestion changes (not the suggestions list) - allCommandsMaxWidth]); + }, + [ + suggestionType, + commands, + setSuggestionsState, + clearSuggestions, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + mode, + suppressSuggestions, + // Note: using suggestionsRef instead of suggestions to avoid recreating + // this callback when only selectedSuggestion changes (not the suggestions list) + allCommandsMaxWidth, + ], + ) // Update suggestions when input changes // Note: We intentionally don't depend on cursorOffset here - cursor movement alone @@ -893,19 +1140,19 @@ export function useTypeahead({ useEffect(() => { // If suggestions were dismissed for this exact input, don't re-trigger if (dismissedForInputRef.current === input) { - return; + return } // When the actual input text changes (not just updateSuggestions being recreated), // reset the search token ref so the same query can be re-fetched. // This fixes: type @readme.md, clear, retype @readme.md → no suggestions. if (prevInputRef.current !== input) { - prevInputRef.current = input; - latestSearchTokenRef.current = null; + prevInputRef.current = input + latestSearchTokenRef.current = null } // Clear the dismissed state when input changes - dismissedForInputRef.current = null; - void updateSuggestions(input); - }, [input, updateSuggestions]); + dismissedForInputRef.current = null + void updateSuggestions(input) + }, [input, updateSuggestions]) // Handle tab key press - complete suggestions or trigger file suggestions const handleTab = useCallback(async () => { @@ -914,143 +1161,216 @@ export function useTypeahead({ // Check for bash mode history completion first if (mode === 'bash') { // Replace the input with the full command from history - onInputChange(effectiveGhostText.fullCommand); - setCursorOffset(effectiveGhostText.fullCommand.length); - setInlineGhostText(undefined); - return; + onInputChange(effectiveGhostText.fullCommand) + setCursorOffset(effectiveGhostText.fullCommand.length) + setInlineGhostText(undefined) + return } // Find the mid-input command to get its position (for prompt mode) - const midInputCommand = findMidInputSlashCommand(input, cursorOffset); + const midInputCommand = findMidInputSlashCommand(input, cursorOffset) if (midInputCommand) { // Replace the partial command with the full command + space - const before = input.slice(0, midInputCommand.startPos); - const after = input.slice(midInputCommand.startPos + midInputCommand.token.length); - const newInput = before + '/' + effectiveGhostText.fullCommand + ' ' + after; - const newCursorOffset = midInputCommand.startPos + 1 + effectiveGhostText.fullCommand.length + 1; - onInputChange(newInput); - setCursorOffset(newCursorOffset); - return; + const before = input.slice(0, midInputCommand.startPos) + const after = input.slice( + midInputCommand.startPos + midInputCommand.token.length, + ) + const newInput = + before + '/' + effectiveGhostText.fullCommand + ' ' + after + const newCursorOffset = + midInputCommand.startPos + + 1 + + effectiveGhostText.fullCommand.length + + 1 + + onInputChange(newInput) + setCursorOffset(newCursorOffset) + return } } // If we have active suggestions, select one if (suggestions.length > 0) { // Cancel any pending debounced fetches to prevent flicker when accepting - debouncedFetchFileSuggestions.cancel(); - debouncedFetchSlackChannels.cancel(); - const index = selectedSuggestion === -1 ? 0 : selectedSuggestion; - const suggestion = suggestions[index]; + debouncedFetchFileSuggestions.cancel() + debouncedFetchSlackChannels.cancel() + + const index = selectedSuggestion === -1 ? 0 : selectedSuggestion + const suggestion = suggestions[index] + if (suggestionType === 'command' && index < suggestions.length) { if (suggestion) { - applyCommandSuggestion(suggestion, false, - // don't execute on tab - commands, onInputChange, setCursorOffset, onSubmit); - clearSuggestions(); + applyCommandSuggestion( + suggestion, + false, // don't execute on tab + commands, + onInputChange, + setCursorOffset, + onSubmit, + ) + clearSuggestions() } } else if (suggestionType === 'custom-title' && suggestions.length > 0) { // Apply custom title to /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion); - onInputChange(newInput); - setCursorOffset(newInput.length); - clearSuggestions(); + const newInput = buildResumeInputFromSuggestion(suggestion) + onInputChange(newInput) + setCursorOffset(newInput.length) + clearSuggestions() } } else if (suggestionType === 'directory' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { // Check if this is a command context (e.g., /add-dir) or general path completion - const isInCommandContext = isCommandInput(input); - let newInput: string; + const isInCommandContext = isCommandInput(input) + + let newInput: string if (isInCommandContext) { // Command context: replace just the argument portion - const spaceIndex = input.indexOf(' '); - const commandPart = input.slice(0, spaceIndex + 1); // Include the space - const cmdSuffix = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory' ? '/' : ' '; - newInput = commandPart + suggestion.id + cmdSuffix; - onInputChange(newInput); - setCursorOffset(newInput.length); - if (isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory') { + const spaceIndex = input.indexOf(' ') + const commandPart = input.slice(0, spaceIndex + 1) // Include the space + const cmdSuffix = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + ? '/' + : ' ' + newInput = commandPart + suggestion.id + cmdSuffix + + onInputChange(newInput) + setCursorOffset(newInput.length) + + if ( + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + ) { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, - commandArgumentHint: undefined - })); - void updateSuggestions(newInput, newInput.length); + commandArgumentHint: undefined, + })) + void updateSuggestions(newInput, newInput.length) } else { - clearSuggestions(); + clearSuggestions() } } else { // General path completion: replace the path token in input with @-prefixed path // Try to get token with @ prefix first to check if already prefixed - const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); - const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); + const completionTokenWithAt = extractCompletionToken( + input, + cursorOffset, + true, + ) + const completionToken = + completionTokenWithAt ?? + extractCompletionToken(input, cursorOffset, false) + if (completionToken) { - const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; - const result = applyDirectorySuggestion(input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir); - newInput = result.newInput; - onInputChange(newInput); - setCursorOffset(result.cursorPos); + const isDir = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + const result = applyDirectorySuggestion( + input, + suggestion.id, + completionToken.startPos, + completionToken.token.length, + isDir, + ) + newInput = result.newInput + + onInputChange(newInput) + setCursorOffset(result.cursorPos) + if (isDir) { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, - commandArgumentHint: undefined - })); - void updateSuggestions(newInput, result.cursorPos); + commandArgumentHint: undefined, + })) + void updateSuggestions(newInput, result.cursorPos) } else { // For files, clear suggestions - clearSuggestions(); + clearSuggestions() } } else { // No completion token found (e.g., cursor after space) - just clear suggestions // without modifying input to avoid data loss - clearSuggestions(); + clearSuggestions() } } } } else if (suggestionType === 'shell' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); - clearSuggestions(); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) + clearSuggestions() } - } else if (suggestionType === 'agent' && suggestions.length > 0 && suggestions[index]?.id?.startsWith('dm-')) { - const suggestion = suggestions[index]; + } else if ( + suggestionType === 'agent' && + suggestions.length > 0 && + suggestions[index]?.id?.startsWith('dm-') + ) { + const suggestion = suggestions[index] if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + DM_MEMBER_RE, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } else if (suggestionType === 'slack-channel' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + HASH_CHANNEL_RE, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } else if (suggestionType === 'file' && suggestions.length > 0) { - const completionToken = extractCompletionToken(input, cursorOffset, true); + const completionToken = extractCompletionToken( + input, + cursorOffset, + true, + ) if (!completionToken) { - clearSuggestions(); - return; + clearSuggestions() + return } // Check if all suggestions share a common prefix longer than the current input - const commonPrefix = findLongestCommonPrefix(suggestions); + const commonPrefix = findLongestCommonPrefix(suggestions) // Determine if token starts with @ to preserve it during replacement - const hasAtPrefix = completionToken.token.startsWith('@'); + const hasAtPrefix = completionToken.token.startsWith('@') // The effective token length excludes the @ and quotes if present - let effectiveTokenLength: number; + let effectiveTokenLength: number if (completionToken.isQuoted) { // Remove @" prefix and optional closing " to get effective length - effectiveTokenLength = completionToken.token.slice(2).replace(/"$/, '').length; + effectiveTokenLength = completionToken.token + .slice(2) + .replace(/"$/, '').length } else if (hasAtPrefix) { - effectiveTokenLength = completionToken.token.length - 1; + effectiveTokenLength = completionToken.token.length - 1 } else { - effectiveTokenLength = completionToken.token.length; + effectiveTokenLength = completionToken.token.length } // If there's a common prefix longer than what the user has typed, @@ -1060,233 +1380,401 @@ export function useTypeahead({ displayText: commonPrefix, mode, hasAtPrefix, - needsQuotes: false, - // common prefix doesn't need quotes unless already quoted + needsQuotes: false, // common prefix doesn't need quotes unless already quoted isQuoted: completionToken.isQuoted, - isComplete: false // partial completion - }); - applyFileSuggestion(replacementValue, input, completionToken.token, completionToken.startPos, onInputChange, setCursorOffset); + isComplete: false, // partial completion + }) + + applyFileSuggestion( + replacementValue, + input, + completionToken.token, + completionToken.startPos, + onInputChange, + setCursorOffset, + ) // Don't clear suggestions so user can continue typing or select a specific option // Instead, update for the new prefix - void updateSuggestions(input.replace(completionToken.token, replacementValue), cursorOffset); + void updateSuggestions( + input.replace(completionToken.token, replacementValue), + cursorOffset, + ) } else if (index < suggestions.length) { // Otherwise, apply the selected suggestion - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - const needsQuotes = suggestion.displayText.includes(' '); + const needsQuotes = suggestion.displayText.includes(' ') const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, hasAtPrefix, needsQuotes, isQuoted: completionToken.isQuoted, - isComplete: true // complete suggestion - }); - applyFileSuggestion(replacementValue, input, completionToken.token, completionToken.startPos, onInputChange, setCursorOffset); - clearSuggestions(); + isComplete: true, // complete suggestion + }) + + applyFileSuggestion( + replacementValue, + input, + completionToken.token, + completionToken.startPos, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } } } else if (input.trim() !== '') { - let suggestionType: SuggestionType; - let suggestionItems: SuggestionItem[]; + let suggestionType: SuggestionType + let suggestionItems: SuggestionItem[] + if (mode === 'bash') { - suggestionType = 'shell'; + suggestionType = 'shell' // This should be very fast, taking <10ms - const bashSuggestions = await generateBashSuggestions(input, cursorOffset); + const bashSuggestions = await generateBashSuggestions( + input, + cursorOffset, + ) if (bashSuggestions.length === 1) { // If single suggestion, apply it immediately - const suggestion = bashSuggestions[0]; + const suggestion = bashSuggestions[0] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) } - suggestionItems = []; + suggestionItems = [] } else { - suggestionItems = bashSuggestions; + suggestionItems = bashSuggestions } } else { - suggestionType = 'file'; + suggestionType = 'file' // If no suggestions, fetch file and MCP resource suggestions - const completionInfo = extractCompletionToken(input, cursorOffset, true); + const completionInfo = extractCompletionToken(input, cursorOffset, true) if (completionInfo) { // If token starts with @, search without the @ prefix - const isAtSymbol = completionInfo.token.startsWith('@'); - const searchToken = isAtSymbol ? completionInfo.token.substring(1) : completionInfo.token; - suggestionItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); + const isAtSymbol = completionInfo.token.startsWith('@') + const searchToken = isAtSymbol + ? completionInfo.token.substring(1) + : completionInfo.token + + suggestionItems = await generateUnifiedSuggestions( + searchToken, + mcpResources, + agents, + isAtSymbol, + ) } else { - suggestionItems = []; + suggestionItems = [] } } + if (suggestionItems.length > 0) { // Multiple suggestions or not bash mode: show list setSuggestionsState(prev => ({ commandArgumentHint: undefined, suggestions: suggestionItems, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestionItems) - })); - setSuggestionType(suggestionType); - setMaxColumnWidth(undefined); + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + suggestionItems, + ), + })) + setSuggestionType(suggestionType) + setMaxColumnWidth(undefined) } } - }, [suggestions, selectedSuggestion, input, suggestionType, commands, mode, onInputChange, setCursorOffset, onSubmit, clearSuggestions, cursorOffset, updateSuggestions, mcpResources, setSuggestionsState, agents, debouncedFetchFileSuggestions, debouncedFetchSlackChannels, effectiveGhostText]); + }, [ + suggestions, + selectedSuggestion, + input, + suggestionType, + commands, + mode, + onInputChange, + setCursorOffset, + onSubmit, + clearSuggestions, + cursorOffset, + updateSuggestions, + mcpResources, + setSuggestionsState, + agents, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + effectiveGhostText, + ]) // Handle enter key press - apply and execute suggestions const handleEnter = useCallback(() => { - if (selectedSuggestion < 0 || suggestions.length === 0) return; - const suggestion = suggestions[selectedSuggestion]; - if (suggestionType === 'command' && selectedSuggestion < suggestions.length) { + if (selectedSuggestion < 0 || suggestions.length === 0) return + + const suggestion = suggestions[selectedSuggestion] + + if ( + suggestionType === 'command' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { - applyCommandSuggestion(suggestion, true, - // execute on return - commands, onInputChange, setCursorOffset, onSubmit); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + applyCommandSuggestion( + suggestion, + true, // execute on return + commands, + onInputChange, + setCursorOffset, + onSubmit, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'custom-title' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'custom-title' && + selectedSuggestion < suggestions.length + ) { // Apply custom title and execute /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion); - onInputChange(newInput); - setCursorOffset(newInput.length); - onSubmit(newInput, /* isSubmittingSlashCommand */true); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + const newInput = buildResumeInputFromSuggestion(suggestion) + onInputChange(newInput) + setCursorOffset(newInput.length) + onSubmit(newInput, /* isSubmittingSlashCommand */ true) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'shell' && selectedSuggestion < suggestions.length) { - const suggestion = suggestions[selectedSuggestion]; + } else if ( + suggestionType === 'shell' && + selectedSuggestion < suggestions.length + ) { + const suggestion = suggestions[selectedSuggestion] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'agent' && selectedSuggestion < suggestions.length && suggestion?.id?.startsWith('dm-')) { - applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - } else if (suggestionType === 'slack-channel' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'agent' && + selectedSuggestion < suggestions.length && + suggestion?.id?.startsWith('dm-') + ) { + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + DM_MEMBER_RE, + onInputChange, + setCursorOffset, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } else if ( + suggestionType === 'slack-channel' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + HASH_CHANNEL_RE, + onInputChange, + setCursorOffset, + ) + debouncedFetchSlackChannels.cancel() + clearSuggestions() } - } else if (suggestionType === 'file' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'file' && + selectedSuggestion < suggestions.length + ) { // Extract completion token directly when needed - const completionInfo = extractCompletionToken(input, cursorOffset, true); + const completionInfo = extractCompletionToken(input, cursorOffset, true) if (completionInfo) { if (suggestion) { - const hasAtPrefix = completionInfo.token.startsWith('@'); - const needsQuotes = suggestion.displayText.includes(' '); + const hasAtPrefix = completionInfo.token.startsWith('@') + const needsQuotes = suggestion.displayText.includes(' ') const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, hasAtPrefix, needsQuotes, isQuoted: completionInfo.isQuoted, - isComplete: true // complete suggestion - }); - applyFileSuggestion(replacementValue, input, completionInfo.token, completionInfo.startPos, onInputChange, setCursorOffset); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + isComplete: true, // complete suggestion + }) + + applyFileSuggestion( + replacementValue, + input, + completionInfo.token, + completionInfo.startPos, + onInputChange, + setCursorOffset, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } } - } else if (suggestionType === 'directory' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'directory' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { // In command context (e.g., /add-dir), Enter submits the command // rather than applying the directory suggestion. Just clear // suggestions and let the submit handler process the current input. if (isCommandInput(input)) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return } // General path completion: replace the path token - const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); - const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); + const completionTokenWithAt = extractCompletionToken( + input, + cursorOffset, + true, + ) + const completionToken = + completionTokenWithAt ?? + extractCompletionToken(input, cursorOffset, false) + if (completionToken) { - const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; - const result = applyDirectorySuggestion(input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir); - onInputChange(result.newInput); - setCursorOffset(result.cursorPos); + const isDir = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + const result = applyDirectorySuggestion( + input, + suggestion.id, + completionToken.startPos, + completionToken.token.length, + isDir, + ) + onInputChange(result.newInput) + setCursorOffset(result.cursorPos) } // If no completion token found (e.g., cursor after space), don't modify input // to avoid data loss - just clear suggestions - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } } - }, [suggestions, selectedSuggestion, suggestionType, commands, input, cursorOffset, mode, onInputChange, setCursorOffset, onSubmit, clearSuggestions, debouncedFetchFileSuggestions, debouncedFetchSlackChannels]); + }, [ + suggestions, + selectedSuggestion, + suggestionType, + commands, + input, + cursorOffset, + mode, + onInputChange, + setCursorOffset, + onSubmit, + clearSuggestions, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + ]) // Handler for autocomplete:accept - accepts current suggestion via Tab or Right Arrow const handleAutocompleteAccept = useCallback(() => { - void handleTab(); - }, [handleTab]); + void handleTab() + }, [handleTab]) // Handler for autocomplete:dismiss - clears suggestions and prevents re-triggering const handleAutocompleteDismiss = useCallback(() => { - debouncedFetchFileSuggestions.cancel(); - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); + debouncedFetchFileSuggestions.cancel() + debouncedFetchSlackChannels.cancel() + clearSuggestions() // Remember the input when dismissed to prevent immediate re-triggering - dismissedForInputRef.current = input; - }, [debouncedFetchFileSuggestions, debouncedFetchSlackChannels, clearSuggestions, input]); + dismissedForInputRef.current = input + }, [ + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + clearSuggestions, + input, + ]) // Handler for autocomplete:previous - selects previous suggestion const handleAutocompletePrevious = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: prev.selectedSuggestion <= 0 ? suggestions.length - 1 : prev.selectedSuggestion - 1 - })); - }, [suggestions.length, setSuggestionsState]); + selectedSuggestion: + prev.selectedSuggestion <= 0 + ? suggestions.length - 1 + : prev.selectedSuggestion - 1, + })) + }, [suggestions.length, setSuggestionsState]) // Handler for autocomplete:next - selects next suggestion const handleAutocompleteNext = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: prev.selectedSuggestion >= suggestions.length - 1 ? 0 : prev.selectedSuggestion + 1 - })); - }, [suggestions.length, setSuggestionsState]); + selectedSuggestion: + prev.selectedSuggestion >= suggestions.length - 1 + ? 0 + : prev.selectedSuggestion + 1, + })) + }, [suggestions.length, setSuggestionsState]) // Autocomplete context keybindings - only active when suggestions are visible - const autocompleteHandlers = useMemo(() => ({ - 'autocomplete:accept': handleAutocompleteAccept, - 'autocomplete:dismiss': handleAutocompleteDismiss, - 'autocomplete:previous': handleAutocompletePrevious, - 'autocomplete:next': handleAutocompleteNext - }), [handleAutocompleteAccept, handleAutocompleteDismiss, handleAutocompletePrevious, handleAutocompleteNext]); + const autocompleteHandlers = useMemo( + () => ({ + 'autocomplete:accept': handleAutocompleteAccept, + 'autocomplete:dismiss': handleAutocompleteDismiss, + 'autocomplete:previous': handleAutocompletePrevious, + 'autocomplete:next': handleAutocompleteNext, + }), + [ + handleAutocompleteAccept, + handleAutocompleteDismiss, + handleAutocompletePrevious, + handleAutocompleteNext, + ], + ) // Register autocomplete as an overlay so CancelRequestHandler defers ESC handling // This ensures ESC dismisses autocomplete before canceling running tasks - const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText; - const isModalOverlayActive = useIsModalOverlayActive(); - useRegisterOverlay('autocomplete', isAutocompleteActive); + const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText + const isModalOverlayActive = useIsModalOverlayActive() + useRegisterOverlay('autocomplete', isAutocompleteActive) // Register Autocomplete context so it appears in activeContexts for other handlers. // This allows Chat's resolver to see Autocomplete and defer to its bindings for up/down. - useRegisterKeybindingContext('Autocomplete', isAutocompleteActive); + useRegisterKeybindingContext('Autocomplete', isAutocompleteActive) // Disable autocomplete keybindings when a modal overlay (e.g., DiffDialog) is active, // so escape reaches the overlay's handler instead of dismissing autocomplete useKeybindings(autocompleteHandlers, { context: 'Autocomplete', - isActive: isAutocompleteActive && !isModalOverlayActive - }); + isActive: isAutocompleteActive && !isModalOverlayActive, + }) + function acceptSuggestionText(text: string): void { - const detectedMode = getModeFromInput(text); + const detectedMode = getModeFromInput(text) if (detectedMode !== 'prompt' && onModeChange) { - onModeChange(detectedMode); - const stripped = getValueFromInput(text); - onInputChange(stripped); - setCursorOffset(stripped.length); + onModeChange(detectedMode) + const stripped = getValueFromInput(text) + onInputChange(stripped) + setCursorOffset(stripped.length) } else { - onInputChange(text); - setCursorOffset(text.length); + onInputChange(text) + setCursorOffset(text.length) } } @@ -1294,13 +1782,13 @@ export function useTypeahead({ const handleKeyDown = (e: KeyboardEvent): void => { // Handle right arrow to accept prompt suggestion ghost text if (e.key === 'right' && !isViewingTeammate) { - const suggestionText = promptSuggestion.text; - const suggestionShownAt = promptSuggestion.shownAt; + const suggestionText = promptSuggestion.text + const suggestionShownAt = promptSuggestion.shownAt if (suggestionText && suggestionShownAt > 0 && input === '') { - markAccepted(); - acceptSuggestionText(suggestionText); - e.stopImmediatePropagation(); - return; + markAccepted() + acceptSuggestionText(suggestionText) + e.stopImmediatePropagation() + return } } @@ -1309,69 +1797,78 @@ export function useTypeahead({ if (e.key === 'tab' && !e.shift) { // Skip if autocomplete is handling this (suggestions or ghost text exist) if (suggestions.length > 0 || effectiveGhostText) { - return; + return } // Accept prompt suggestion if it exists in AppState - const suggestionText = promptSuggestion.text; - const suggestionShownAt = promptSuggestion.shownAt; - if (suggestionText && suggestionShownAt > 0 && input === '' && !isViewingTeammate) { - e.preventDefault(); - markAccepted(); - acceptSuggestionText(suggestionText); - return; + const suggestionText = promptSuggestion.text + const suggestionShownAt = promptSuggestion.shownAt + if ( + suggestionText && + suggestionShownAt > 0 && + input === '' && + !isViewingTeammate + ) { + e.preventDefault() + markAccepted() + acceptSuggestionText(suggestionText) + return } // Remind user about thinking toggle shortcut if empty input if (input.trim() === '') { - e.preventDefault(); + e.preventDefault() addNotification({ key: 'thinking-toggle-hint', - jsx: + jsx: ( + Use {thinkingToggleShortcut} to toggle thinking - , + + ), priority: 'immediate', - timeoutMs: 3000 - }); + timeoutMs: 3000, + }) } - return; + return } // Only continue with navigation if we have suggestions - if (suggestions.length === 0) return; + if (suggestions.length === 0) return // Handle Ctrl-N/P for navigation (arrows handled by keybindings) // Skip if we're in the middle of a chord sequence to allow chords like ctrl+f n - const hasPendingChord = keybindingContext?.pendingChord != null; + const hasPendingChord = keybindingContext?.pendingChord != null if (e.ctrl && e.key === 'n' && !hasPendingChord) { - e.preventDefault(); - handleAutocompleteNext(); - return; + e.preventDefault() + handleAutocompleteNext() + return } + if (e.ctrl && e.key === 'p' && !hasPendingChord) { - e.preventDefault(); - handleAutocompletePrevious(); - return; + e.preventDefault() + handleAutocompletePrevious() + return } // Handle selection and execution via return/enter // Shift+Enter and Meta+Enter insert newlines (handled by useTextInput), // so don't accept the suggestion for those. if (e.key === 'return' && !e.shift && !e.meta) { - e.preventDefault(); - handleEnter(); + e.preventDefault() + handleEnter() } - }; + } // Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown. useInput((_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress); - handleKeyDown(kbEvent); + const kbEvent = new KeyboardEvent(event.keypress) + handleKeyDown(kbEvent) if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation(); + event.stopImmediatePropagation() } - }); + }) + return { suggestions, selectedSuggestion, @@ -1379,6 +1876,6 @@ export function useTypeahead({ maxColumnWidth, commandArgumentHint, inlineGhostText: effectiveGhostText, - handleKeyDown - }; + handleKeyDown, + } } diff --git a/src/hooks/useVoiceIntegration.tsx b/src/hooks/useVoiceIntegration.tsx index 47de37c93..7cedb1c0f 100644 --- a/src/hooks/useVoiceIntegration.tsx +++ b/src/hooks/useVoiceIntegration.tsx @@ -1,75 +1,85 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { useIsModalOverlayActive } from '../context/overlayContext.js'; -import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js'; -import { KeyboardEvent } from '../ink/events/keyboard-event.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useNotifications } from '../context/notifications.js' +import { useIsModalOverlayActive } from '../context/overlayContext.js' +import { + useGetVoiceState, + useSetVoiceState, + useVoiceState, +} from '../context/voice.js' +import { KeyboardEvent } from '../ink/events/keyboard-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until REPL wires handleKeyDown to -import { useInput } from '../ink.js'; -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { keystrokesEqual } from '../keybindings/resolver.js'; -import type { ParsedKeystroke } from '../keybindings/types.js'; -import { normalizeFullWidthSpace } from '../utils/stringUtils.js'; -import { useVoiceEnabled } from './useVoiceEnabled.js'; +import { useInput } from '../ink.js' +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' +import { keystrokesEqual } from '../keybindings/resolver.js' +import type { ParsedKeystroke } from '../keybindings/types.js' +import { normalizeFullWidthSpace } from '../utils/stringUtils.js' +import { useVoiceEnabled } from './useVoiceEnabled.js' // Dead code elimination: conditional import for voice input hook. /* eslint-disable @typescript-eslint/no-require-imports */ // Capture the module namespace, not the function: spyOn() mutates the module // object, so `voiceNs.useVoice(...)` resolves to the spy even if this module // was loaded before the spy was installed (test ordering independence). -const voiceNs: { - useVoice: typeof import('./useVoice.js').useVoice; -} = feature('VOICE_MODE') ? require('./useVoice.js') : { - useVoice: ({ - enabled: _e - }: { - onTranscript: (t: string) => void; - enabled: boolean; - }) => ({ - state: 'idle' as const, - handleKeyEvent: (_fallbackMs?: number) => {} - }) -}; +const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature( + 'VOICE_MODE', +) + ? require('./useVoice.js') + : { + useVoice: ({ + enabled: _e, + }: { + onTranscript: (t: string) => void + enabled: boolean + }) => ({ + state: 'idle' as const, + handleKeyEvent: (_fallbackMs?: number) => {}, + }), + } /* eslint-enable @typescript-eslint/no-require-imports */ // Maximum gap (ms) between key presses to count as held (auto-repeat). // Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while // excluding normal typing speed (100-300ms between keystrokes). -const RAPID_KEY_GAP_MS = 120; +const RAPID_KEY_GAP_MS = 120 // Fallback (ms) for modifier-combo first-press activation. Must match // FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial // key-repeat delay (~2s on macOS with slider at "Long") so holding a // modifier combo doesn't fragment into two sessions when the first // auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS. -const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000; +const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000 // Number of rapid consecutive key events required to activate voice. // Only applies to bare-char bindings (space, v, etc.) where a single press // could be normal typing. Modifier combos activate on the first press. -const HOLD_THRESHOLD = 5; +const HOLD_THRESHOLD = 5 // Number of rapid key events to start showing warmup feedback. -const WARMUP_THRESHOLD = 2; +const WARMUP_THRESHOLD = 2 // Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy // matchesKeystroke(input, Key, ...) path which assumed useInput's raw // `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space', // 'f9') that getKeyName() didn't handle, so modifier combos and f-keys // silently failed to match after the onKeyDown migration (#23524). -function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean { +function matchesKeyboardEvent( + e: KeyboardEvent, + target: ParsedKeystroke, +): boolean { // KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space // and 'enter' for return (see parser.ts case 'space'/'return'). - const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase(); - if (key !== target.key) return false; - if (e.ctrl !== target.ctrl) return false; - if (e.shift !== target.shift) return false; + const key = + e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase() + if (key !== target.key) return false + if (e.ctrl !== target.ctrl) return false + if (e.shift !== target.shift) return false // KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix); // ParsedKeystroke has both alt and meta as aliases for the same thing. - if (e.meta !== (target.alt || target.meta)) return false; - if (e.superKey !== target.super) return false; - return true; + if (e.meta !== (target.alt || target.meta)) return false + if (e.superKey !== target.super) return false + return true } // Hardcoded default for when there's no KeybindingProvider at all (e.g. @@ -82,60 +92,61 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = { alt: false, shift: false, meta: false, - super: false -}; + super: false, +} + type InsertTextHandle = { - insert: (text: string) => void; - setInputWithCursor: (value: string, cursor: number) => void; - cursorOffset: number; -}; + insert: (text: string) => void + setInputWithCursor: (value: string, cursor: number) => void + cursorOffset: number +} + type UseVoiceIntegrationArgs = { - setInputValueRaw: React.Dispatch>; - inputValueRef: React.RefObject; - insertTextRef: React.RefObject; -}; -type InterimRange = { - start: number; - end: number; -}; + setInputValueRaw: React.Dispatch> + inputValueRef: React.RefObject + insertTextRef: React.RefObject +} + +type InterimRange = { start: number; end: number } + type StripOpts = { // Which char to strip (the configured hold key). Defaults to space. - char?: string; + char?: string // Capture the voice prefix/suffix anchor at the stripped position. - anchor?: boolean; + anchor?: boolean // Minimum trailing count to leave behind — prevents stripping the // intentional warmup chars when defensively cleaning up leaks. - floor?: number; -}; + floor?: number +} + type UseVoiceIntegrationResult = { // Returns the number of trailing chars remaining after stripping. - stripTrailing: (maxStrip: number, opts?: StripOpts) => number; + stripTrailing: (maxStrip: number, opts?: StripOpts) => number // Undo the gap space and reset anchor refs after a failed voice activation. - resetAnchor: () => void; - handleKeyEvent: (fallbackMs?: number) => void; - interimRange: InterimRange | null; -}; + resetAnchor: () => void + handleKeyEvent: (fallbackMs?: number) => void + interimRange: InterimRange | null +} + export function useVoiceIntegration({ setInputValueRaw, inputValueRef, - insertTextRef + insertTextRef, }: UseVoiceIntegrationArgs): UseVoiceIntegrationResult { - const { - addNotification - } = useNotifications(); + const { addNotification } = useNotifications() // Tracks the input content before/after the cursor when voice starts, // so interim transcripts can be inserted at the cursor position without // clobbering surrounding user text. - const voicePrefixRef = useRef(null); - const voiceSuffixRef = useRef(''); + const voicePrefixRef = useRef(null) + const voiceSuffixRef = useRef('') // Tracks the last input value this hook wrote (via anchor, interim effect, // or handleVoiceTranscript). If inputValueRef.current diverges, the user // submitted or edited — both write paths bail to avoid clobbering. This is // the only guard that correctly handles empty-prefix-empty-suffix: a // startsWith('')/endsWith('') check vacuously passes, and a length check // can't distinguish a cleared input from a never-set one. - const lastSetInputRef = useRef(null); + const lastSetInputRef = useRef(null) // Strip trailing hold-key chars (and optionally capture the voice // anchor). Called during warmup (to clean up chars that leaked past @@ -149,53 +160,59 @@ export function useVoiceIntegration({ // defensive cleanup only removes leaks). Returns the number of // trailing chars remaining after stripping. When nothing changes, no // state update is performed. - const stripTrailing = useCallback((maxStrip: number, { - char = ' ', - anchor = false, - floor = 0 - }: StripOpts = {}) => { - const prev = inputValueRef.current; - const offset = insertTextRef.current?.cursorOffset ?? prev.length; - const beforeCursor = prev.slice(0, offset); - const afterCursor = prev.slice(offset); - // When the hold key is space, also count full-width spaces (U+3000) - // that a CJK IME may have inserted for the same physical key. - // U+3000 is BMP single-code-unit so indices align with beforeCursor. - const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor; - let trailing = 0; - while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) { - trailing++; - } - const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)); - const remaining = trailing - stripCount; - const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount); - // When anchoring with a non-space suffix, insert a gap space so the - // waveform cursor sits on the gap instead of covering the first - // suffix letter. The interim transcript effect maintains this same - // structure (prefix + leading + interim + trailing + suffix), so - // the gap is seamless once transcript text arrives. - // Always overwrite on anchor — if a prior activation failed to start - // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and - // the old anchor is stale. anchor=true is only passed on the single - // activation call, never during recording, so overwrite is safe. - let gap = ''; - if (anchor) { - voicePrefixRef.current = stripped; - voiceSuffixRef.current = afterCursor; - if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { - gap = ' '; + const stripTrailing = useCallback( + ( + maxStrip: number, + { char = ' ', anchor = false, floor = 0 }: StripOpts = {}, + ) => { + const prev = inputValueRef.current + const offset = insertTextRef.current?.cursorOffset ?? prev.length + const beforeCursor = prev.slice(0, offset) + const afterCursor = prev.slice(offset) + // When the hold key is space, also count full-width spaces (U+3000) + // that a CJK IME may have inserted for the same physical key. + // U+3000 is BMP single-code-unit so indices align with beforeCursor. + const scan = + char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor + let trailing = 0 + while ( + trailing < scan.length && + scan[scan.length - 1 - trailing] === char + ) { + trailing++ } - } - const newValue = stripped + gap + afterCursor; - if (anchor) lastSetInputRef.current = newValue; - if (newValue === prev && stripCount === 0) return remaining; - if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue, stripped.length); - } else { - setInputValueRaw(newValue); - } - return remaining; - }, [setInputValueRaw, inputValueRef, insertTextRef]); + const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)) + const remaining = trailing - stripCount + const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount) + // When anchoring with a non-space suffix, insert a gap space so the + // waveform cursor sits on the gap instead of covering the first + // suffix letter. The interim transcript effect maintains this same + // structure (prefix + leading + interim + trailing + suffix), so + // the gap is seamless once transcript text arrives. + // Always overwrite on anchor — if a prior activation failed to start + // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and + // the old anchor is stale. anchor=true is only passed on the single + // activation call, never during recording, so overwrite is safe. + let gap = '' + if (anchor) { + voicePrefixRef.current = stripped + voiceSuffixRef.current = afterCursor + if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { + gap = ' ' + } + } + const newValue = stripped + gap + afterCursor + if (anchor) lastSetInputRef.current = newValue + if (newValue === prev && stripCount === 0) return remaining + if (insertTextRef.current) { + insertTextRef.current.setInputWithCursor(newValue, stripped.length) + } else { + setInputValueRaw(newValue) + } + return remaining + }, + [setInputValueRaw, inputValueRef, insertTextRef], + ) // Undo the gap space inserted by stripTrailing(..., {anchor:true}) and // reset the voice prefix/suffix refs. Called when voice activation fails @@ -204,110 +221,124 @@ export function useVoiceIntegration({ // reach the stale anchor. Without this, the gap space and stale refs // persist in the input. const resetAnchor = useCallback(() => { - const prefix = voicePrefixRef.current; - if (prefix === null) return; - const suffix = voiceSuffixRef.current; - voicePrefixRef.current = null; - voiceSuffixRef.current = ''; - const restored = prefix + suffix; + const prefix = voicePrefixRef.current + if (prefix === null) return + const suffix = voiceSuffixRef.current + voicePrefixRef.current = null + voiceSuffixRef.current = '' + const restored = prefix + suffix if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(restored, prefix.length); + insertTextRef.current.setInputWithCursor(restored, prefix.length) } else { - setInputValueRaw(restored); + setInputValueRaw(restored) } - }, [setInputValueRaw, insertTextRef]); + }, [setInputValueRaw, insertTextRef]) // Voice state selectors. useVoiceEnabled = user intent (settings) + // auth + GB kill-switch, with the auth half memoized on authVersion so // render loops never hit a cold keychain spawn. // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle' as const; - const voiceInterimTranscript: string = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_0 => s_0.voiceInterimTranscript) as string : ''; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) + const voiceInterimTranscript = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceInterimTranscript) + : '' // Set the voice anchor for focus mode (where recording starts via terminal // focus, not key hold). Key-hold sets the anchor in stripTrailing. useEffect(() => { - if (!feature('VOICE_MODE')) return; + if (!feature('VOICE_MODE')) return if (voiceState === 'recording' && voicePrefixRef.current === null) { - const input = inputValueRef.current; - const offset_0 = insertTextRef.current?.cursorOffset ?? input.length; - voicePrefixRef.current = input.slice(0, offset_0); - voiceSuffixRef.current = input.slice(offset_0); - lastSetInputRef.current = input; + const input = inputValueRef.current + const offset = insertTextRef.current?.cursorOffset ?? input.length + voicePrefixRef.current = input.slice(0, offset) + voiceSuffixRef.current = input.slice(offset) + lastSetInputRef.current = input } if (voiceState === 'idle') { - voicePrefixRef.current = null; - voiceSuffixRef.current = ''; - lastSetInputRef.current = null; + voicePrefixRef.current = null + voiceSuffixRef.current = '' + lastSetInputRef.current = null } - }, [voiceState, inputValueRef, insertTextRef]); + }, [voiceState, inputValueRef, insertTextRef]) // Live-update the prompt input with the interim transcript as voice // transcribes speech. The prefix (user-typed text before the cursor) is // preserved and the transcript is inserted between prefix and suffix. useEffect(() => { - if (!feature('VOICE_MODE')) return; - if (voicePrefixRef.current === null) return; - const prefix_0 = voicePrefixRef.current; - const suffix_0 = voiceSuffixRef.current; + if (!feature('VOICE_MODE')) return + if (voicePrefixRef.current === null) return + const prefix = voicePrefixRef.current + const suffix = voiceSuffixRef.current // Submit race: if the input isn't what this hook last set it to, the // user submitted (clearing it) or edited it. voicePrefixRef is only // cleared on voiceState→idle, so it's still set during the 'processing' // window between CloseStream and WS close — this catches refined // TranscriptText arriving then and re-filling a cleared input. - if (inputValueRef.current !== lastSetInputRef.current) return; - const needsSpace = prefix_0.length > 0 && !/\s$/.test(prefix_0) && voiceInterimTranscript.length > 0; + if (inputValueRef.current !== lastSetInputRef.current) return + const needsSpace = + prefix.length > 0 && + !/\s$/.test(prefix) && + voiceInterimTranscript.length > 0 // Don't gate on voiceInterimTranscript.length -- when interim clears to '' // after handleVoiceTranscript sets the final text, the trailing space // between prefix and suffix must still be preserved. - const needsTrailingSpace = suffix_0.length > 0 && !/^\s/.test(suffix_0); - const leadingSpace = needsSpace ? ' ' : ''; - const trailingSpace = needsTrailingSpace ? ' ' : ''; - const newValue_0 = prefix_0 + leadingSpace + voiceInterimTranscript + trailingSpace + suffix_0; + const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) + const leadingSpace = needsSpace ? ' ' : '' + const trailingSpace = needsTrailingSpace ? ' ' : '' + const newValue = + prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix // Position cursor after the transcribed text (before suffix) - const cursorPos = prefix_0.length + leadingSpace.length + voiceInterimTranscript.length; + const cursorPos = + prefix.length + leadingSpace.length + voiceInterimTranscript.length if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue_0, cursorPos); + insertTextRef.current.setInputWithCursor(newValue, cursorPos) } else { - setInputValueRaw(newValue_0); + setInputValueRaw(newValue) } - lastSetInputRef.current = newValue_0; - }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]); - const handleVoiceTranscript = useCallback((text: string) => { - if (!feature('VOICE_MODE')) return; - const prefix_1 = voicePrefixRef.current; - // No voice anchor — voice was reset (or never started). Nothing to do. - if (prefix_1 === null) return; - const suffix_1 = voiceSuffixRef.current; - // Submit race: finishRecording() → user presses Enter (input cleared) - // → WebSocket close → this callback fires with stale prefix/suffix. - // If the input isn't what this hook last set (via the interim effect - // or anchor), the user submitted or edited — don't re-fill. Comparing - // against `text.length` would false-positive when the final is longer - // than the interim (ASR routinely adds punctuation/corrections). - if (inputValueRef.current !== lastSetInputRef.current) return; - const needsSpace_0 = prefix_1.length > 0 && !/\s$/.test(prefix_1) && text.length > 0; - const needsTrailingSpace_0 = suffix_1.length > 0 && !/^\s/.test(suffix_1) && text.length > 0; - const leadingSpace_0 = needsSpace_0 ? ' ' : ''; - const trailingSpace_0 = needsTrailingSpace_0 ? ' ' : ''; - const newInput = prefix_1 + leadingSpace_0 + text + trailingSpace_0 + suffix_1; - // Position cursor after the transcribed text (before suffix) - const cursorPos_0 = prefix_1.length + leadingSpace_0.length + text.length; - if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newInput, cursorPos_0); - } else { - setInputValueRaw(newInput); - } - lastSetInputRef.current = newInput; - // Update the prefix to include this chunk so focus mode can continue - // appending subsequent transcripts after it. - voicePrefixRef.current = prefix_1 + leadingSpace_0 + text; - }, [setInputValueRaw, inputValueRef, insertTextRef]); + lastSetInputRef.current = newValue + }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]) + + const handleVoiceTranscript = useCallback( + (text: string) => { + if (!feature('VOICE_MODE')) return + const prefix = voicePrefixRef.current + // No voice anchor — voice was reset (or never started). Nothing to do. + if (prefix === null) return + const suffix = voiceSuffixRef.current + // Submit race: finishRecording() → user presses Enter (input cleared) + // → WebSocket close → this callback fires with stale prefix/suffix. + // If the input isn't what this hook last set (via the interim effect + // or anchor), the user submitted or edited — don't re-fill. Comparing + // against `text.length` would false-positive when the final is longer + // than the interim (ASR routinely adds punctuation/corrections). + if (inputValueRef.current !== lastSetInputRef.current) return + const needsSpace = + prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0 + const needsTrailingSpace = + suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0 + const leadingSpace = needsSpace ? ' ' : '' + const trailingSpace = needsTrailingSpace ? ' ' : '' + const newInput = prefix + leadingSpace + text + trailingSpace + suffix + // Position cursor after the transcribed text (before suffix) + const cursorPos = prefix.length + leadingSpace.length + text.length + if (insertTextRef.current) { + insertTextRef.current.setInputWithCursor(newInput, cursorPos) + } else { + setInputValueRaw(newInput) + } + lastSetInputRef.current = newInput + // Update the prefix to include this chunk so focus mode can continue + // appending subsequent transcripts after it. + voicePrefixRef.current = prefix + leadingSpace + text + }, + [setInputValueRaw, inputValueRef, insertTextRef], + ) + const voice = voiceNs.useVoice({ onTranscript: handleVoiceTranscript, onError: (message: string) => { @@ -316,34 +347,35 @@ export function useVoiceIntegration({ text: message, color: 'error', priority: 'immediate', - timeoutMs: 10_000 - }); + timeoutMs: 10_000, + }) }, enabled: voiceEnabled, - focusMode: false - }); + focusMode: false, + }) // Compute the character range of interim (not-yet-finalized) transcript // text in the input value, so the UI can dim it. const interimRange = useMemo((): InterimRange | null => { - if (!feature('VOICE_MODE')) return null; - if (voicePrefixRef.current === null) return null; - if (voiceInterimTranscript.length === 0) return null; - const prefix_2 = voicePrefixRef.current; - const needsSpace_1 = prefix_2.length > 0 && !/\s$/.test(prefix_2) && voiceInterimTranscript.length > 0; - const start = prefix_2.length + (needsSpace_1 ? 1 : 0); - const end = start + voiceInterimTranscript.length; - return { - start, - end - }; - }, [voiceInterimTranscript]); + if (!feature('VOICE_MODE')) return null + if (voicePrefixRef.current === null) return null + if (voiceInterimTranscript.length === 0) return null + const prefix = voicePrefixRef.current + const needsSpace = + prefix.length > 0 && + !/\s$/.test(prefix) && + voiceInterimTranscript.length > 0 + const start = prefix.length + (needsSpace ? 1 : 0) + const end = start + voiceInterimTranscript.length + return { start, end } + }, [voiceInterimTranscript]) + return { stripTrailing, resetAnchor, handleKeyEvent: voice.handleKeyEvent, - interimRange - }; + interimRange, + } } /** @@ -374,24 +406,23 @@ export function useVoiceKeybindingHandler({ voiceHandleKeyEvent, stripTrailing, resetAnchor, - isActive + isActive, }: { - voiceHandleKeyEvent: (fallbackMs?: number) => void; - stripTrailing: (maxStrip: number, opts?: StripOpts) => number; - resetAnchor: () => void; - isActive: boolean; -}): { - handleKeyDown: (e: KeyboardEvent) => void; -} { - const getVoiceState = useGetVoiceState(); - const setVoiceState = useSetVoiceState(); - const keybindingContext = useOptionalKeybindingContext(); - const isModalOverlayActive = useIsModalOverlayActive(); - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? + voiceHandleKeyEvent: (fallbackMs?: number) => void + stripTrailing: (maxStrip: number, opts?: StripOpts) => number + resetAnchor: () => void + isActive: boolean +}): { handleKeyDown: (e: KeyboardEvent) => void } { + const getVoiceState = useGetVoiceState() + const setVoiceState = useSetVoiceState() + const keybindingContext = useOptionalKeybindingContext() + const isModalOverlayActive = useIsModalOverlayActive() // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle'; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : 'idle' // Find the configured key for voice:pushToTalk from keybinding context. // Forward iteration with last-wins (matching the resolver): if a later @@ -403,22 +434,22 @@ export function useVoiceKeybindingHandler({ // is also bound in Settings/Confirmation/Plugin (select:accept etc.); // without the filter those would null out the default. const voiceKeystroke = useMemo((): ParsedKeystroke | null => { - if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE; - let result: ParsedKeystroke | null = null; + if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE + let result: ParsedKeystroke | null = null for (const binding of keybindingContext.bindings) { - if (binding.context !== 'Chat') continue; - if (binding.chord.length !== 1) continue; - const ks = binding.chord[0]; - if (!ks) continue; + if (binding.context !== 'Chat') continue + if (binding.chord.length !== 1) continue + const ks = binding.chord[0] + if (!ks) continue if (binding.action === 'voice:pushToTalk') { - result = ks; + result = ks } else if (result !== null && keystrokesEqual(ks, result)) { // A later binding overrides this chord (null unbind or reassignment) - result = null; + result = null } } - return result; - }, [keybindingContext]); + return result + }, [keybindingContext]) // If the binding is a bare (unmodified) single printable char, terminal // auto-repeat may batch N keystrokes into one input event (e.g. "vvv"), @@ -426,8 +457,18 @@ export function useVoiceKeybindingHandler({ // Modifier combos (meta+k, ctrl+x) also auto-repeat (the letter part // repeats) but don't insert text, so they're swallowed from the first // press with no stripping needed. matchesKeyboardEvent handles those. - const bareChar = voiceKeystroke !== null && voiceKeystroke.key.length === 1 && !voiceKeystroke.ctrl && !voiceKeystroke.alt && !voiceKeystroke.shift && !voiceKeystroke.meta && !voiceKeystroke.super ? voiceKeystroke.key : null; - const rapidCountRef = useRef(0); + const bareChar = + voiceKeystroke !== null && + voiceKeystroke.key.length === 1 && + !voiceKeystroke.ctrl && + !voiceKeystroke.alt && + !voiceKeystroke.shift && + !voiceKeystroke.meta && + !voiceKeystroke.super + ? voiceKeystroke.key + : null + + const rapidCountRef = useRef(0) // How many rapid chars we intentionally let through to the text // input (the first WARMUP_THRESHOLD). The activation strip removes // up to this many + the activation event's potential leak. For the @@ -436,15 +477,15 @@ export function useVoiceKeybindingHandler({ // one pre-existing char if the input already ended in the bound // letter (e.g. "hav" + hold "v" → "ha"). We don't track that // boundary — it's best-effort and the warning says so. - const charsInInputRef = useRef(0); + const charsInInputRef = useRef(0) // Trailing-char count remaining after the activation strip — these // belong to the user's anchored prefix and must be preserved during // recording's defensive leak cleanup. - const recordingFloorRef = useRef(0); + const recordingFloorRef = useRef(0) // True when the current recording was started by key-hold (not focus). // Used to avoid swallowing keypresses during focus-mode recording. - const isHoldActiveRef = useRef(false); - const resetTimerRef = useRef | null>(null); + const isHoldActiveRef = useRef(false) + const resetTimerRef = useRef | null>(null) // Reset hold state as soon as we leave 'recording'. The physical hold // ends when key-repeat stops (state → 'processing'); keeping the ref @@ -452,21 +493,19 @@ export function useVoiceKeybindingHandler({ // while the transcript finalizes. useEffect(() => { if (voiceState !== 'recording') { - isHoldActiveRef.current = false; - rapidCountRef.current = 0; - charsInInputRef.current = 0; - recordingFloorRef.current = 0; + isHoldActiveRef.current = false + rapidCountRef.current = 0 + charsInInputRef.current = 0 + recordingFloorRef.current = 0 setVoiceState(prev => { - if (!prev.voiceWarmingUp) return prev; - return { - ...prev, - voiceWarmingUp: false - }; - }); + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) } - }, [voiceState, setVoiceState]); + }, [voiceState, setVoiceState]) + const handleKeyDown = (e: KeyboardEvent): void => { - if (!voiceEnabled) return; + if (!voiceEnabled) return // PromptInput is not a valid transcript target — let the hold key // flow through instead of swallowing it into stale refs (#33556). @@ -476,32 +515,37 @@ export function useVoiceKeybindingHandler({ // /plugin. Mirrors CommandKeybindingHandlers' isActive gate. // - isModalOverlayActive: overlay (permission dialog, Select with // onCancel) has focus; PromptInput is mounted but focus=false. - if (!isActive || isModalOverlayActive) return; + if (!isActive || isModalOverlayActive) return // null means the user overrode the default (null-unbind/reassign) — // hold-to-talk is disabled via binding. To toggle the feature // itself, use /voice. - if (voiceKeystroke === null) return; + if (voiceKeystroke === null) return // Match the configured key. Bare chars match by content (handles // batched auto-repeat like "vvv") with a modifier reject so e.g. // ctrl+v doesn't trip a "v" binding. Modifier combos go through // matchesKeyboardEvent (one event per repeat, no batching). - let repeatCount: number; + let repeatCount: number if (bareChar !== null) { - if (e.ctrl || e.meta || e.shift) return; + if (e.ctrl || e.meta || e.shift) return // When bound to space, also accept U+3000 (full-width space) — // CJK IMEs emit it for the same physical key. - const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key; + const normalized = + bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key // Fast-path: normal typing (any char that isn't the bound one) // bails here without allocating. The repeat() check only matters // for batched auto-repeat (input.length > 1) which is rare. - if (normalized[0] !== bareChar) return; - if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return; - repeatCount = normalized.length; + if (normalized[0] !== bareChar) return + if ( + normalized.length > 1 && + normalized !== bareChar.repeat(normalized.length) + ) + return + repeatCount = normalized.length } else { - if (!matchesKeyboardEvent(e, voiceKeystroke)) return; - repeatCount = 1; + if (!matchesKeyboardEvent(e, voiceKeystroke)) return + repeatCount = 1 } // Guard: only swallow keypresses when recording was triggered by @@ -511,22 +555,22 @@ export function useVoiceKeybindingHandler({ // from the store so that if voiceHandleKeyEvent() fails to transition // state (module not loaded, stream unavailable) we don't permanently // swallow keypresses. - const currentVoiceState = getVoiceState().voiceState; + const currentVoiceState = getVoiceState().voiceState if (isHoldActiveRef.current && currentVoiceState !== 'idle') { // Already recording — swallow continued keypresses and forward // to voice for release detection. For bare chars, defensively // strip in case the text input handler fired before this one // (listener order is not guaranteed). Modifier combos don't // insert text, so nothing to strip. - e.stopImmediatePropagation(); + e.stopImmediatePropagation() if (bareChar !== null) { stripTrailing(repeatCount, { char: bareChar, - floor: recordingFloorRef.current - }); + floor: recordingFloorRef.current, + }) } - voiceHandleKeyEvent(); - return; + voiceHandleKeyEvent() + return } // Non-hold recording (focus-mode) or processing is active. @@ -536,11 +580,12 @@ export function useVoiceKeybindingHandler({ // hit the warmup else-branch (swallow only). Bare chars flow through // unconditionally — user may be typing during focus-recording. if (currentVoiceState !== 'idle') { - if (bareChar === null) e.stopImmediatePropagation(); - return; + if (bareChar === null) e.stopImmediatePropagation() + return } - const countBefore = rapidCountRef.current; - rapidCountRef.current += repeatCount; + + const countBefore = rapidCountRef.current + rapidCountRef.current += repeatCount // ── Activation ──────────────────────────────────────────── // Handled first so the warmup branch below does NOT also run @@ -550,42 +595,37 @@ export function useVoiceKeybindingHandler({ // typed accidentally, so the hold threshold (which exists to // distinguish typing a space from holding space) doesn't apply. if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) { - e.stopImmediatePropagation(); + e.stopImmediatePropagation() if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current); - resetTimerRef.current = null; + clearTimeout(resetTimerRef.current) + resetTimerRef.current = null } - rapidCountRef.current = 0; - isHoldActiveRef.current = true; - setVoiceState(prev_0 => { - if (!prev_0.voiceWarmingUp) return prev_0; - return { - ...prev_0, - voiceWarmingUp: false - }; - }); + rapidCountRef.current = 0 + isHoldActiveRef.current = true + setVoiceState(prev => { + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) if (bareChar !== null) { // Strip the intentional warmup chars plus this event's leak // (if text input fired first). Cap covers both; min(trailing) // handles the no-leak case. Anchor the voice prefix here. // The return value (remaining) becomes the floor for // recording-time leak cleanup. - recordingFloorRef.current = stripTrailing(charsInInputRef.current + repeatCount, { - char: bareChar, - anchor: true - }); - charsInInputRef.current = 0; - voiceHandleKeyEvent(); + recordingFloorRef.current = stripTrailing( + charsInInputRef.current + repeatCount, + { char: bareChar, anchor: true }, + ) + charsInInputRef.current = 0 + voiceHandleKeyEvent() } else { // Modifier combo: nothing inserted, nothing to strip. Just // anchor the voice prefix at the current cursor position. // Longer fallback: this call is at t=0 (before auto-repeat), // so the gap to the next keypress is the OS initial repeat // *delay* (up to ~2s), not the repeat *rate* (~30-80ms). - stripTrailing(0, { - anchor: true - }); - voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS); + stripTrailing(0, { anchor: true }) + voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS) } // If voice failed to transition (module not loaded, stream // unavailable, stale enabled), clear the ref so a later @@ -594,10 +634,10 @@ export function useVoiceKeybindingHandler({ // immediate. The anchor set by stripTrailing above will // be overwritten on retry (anchor always overwrites now). if (getVoiceState().voiceState === 'idle') { - isHoldActiveRef.current = false; - resetAnchor(); + isHoldActiveRef.current = false + resetAnchor() } - return; + return } // ── Warmup (bare-char only; modifier combos activated above) ── @@ -610,67 +650,74 @@ export function useVoiceKeybindingHandler({ // no-op when nothing leaked. Check countBefore so the event that // crosses the threshold still flows through (terminal batching). if (countBefore >= WARMUP_THRESHOLD) { - e.stopImmediatePropagation(); + e.stopImmediatePropagation() stripTrailing(repeatCount, { char: bareChar, - floor: charsInInputRef.current - }); + floor: charsInInputRef.current, + }) } else { - charsInInputRef.current += repeatCount; + charsInInputRef.current += repeatCount } // Show warmup feedback once we detect a hold pattern if (rapidCountRef.current >= WARMUP_THRESHOLD) { - setVoiceState(prev_1 => { - if (prev_1.voiceWarmingUp) return prev_1; - return { - ...prev_1, - voiceWarmingUp: true - }; - }); + setVoiceState(prev => { + if (prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: true } + }) } + if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current); + clearTimeout(resetTimerRef.current) } - resetTimerRef.current = setTimeout((resetTimerRef_0, rapidCountRef_0, charsInInputRef_0, setVoiceState_0) => { - resetTimerRef_0.current = null; - rapidCountRef_0.current = 0; - charsInInputRef_0.current = 0; - setVoiceState_0(prev_2 => { - if (!prev_2.voiceWarmingUp) return prev_2; - return { - ...prev_2, - voiceWarmingUp: false - }; - }); - }, RAPID_KEY_GAP_MS, resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState); - }; + resetTimerRef.current = setTimeout( + (resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => { + resetTimerRef.current = null + rapidCountRef.current = 0 + charsInInputRef.current = 0 + setVoiceState(prev => { + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) + }, + RAPID_KEY_GAP_MS, + resetTimerRef, + rapidCountRef, + charsInInputRef, + setVoiceState, + ) + } // Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once REPL passes handleKeyDown. - useInput((_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress); - handleKeyDown(kbEvent); - // handleKeyDown stopped the adapter event, not the InputEvent the - // emitter actually checks — forward it so the text input's useInput - // listener is skipped and held spaces don't leak into the prompt. - if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation(); - } - }, { - isActive - }); - return { - handleKeyDown - }; + useInput( + (_input, _key, event) => { + const kbEvent = new KeyboardEvent(event.keypress) + handleKeyDown(kbEvent) + // handleKeyDown stopped the adapter event, not the InputEvent the + // emitter actually checks — forward it so the text input's useInput + // listener is skipped and held spaces don't leak into the prompt. + if (kbEvent.didStopImmediatePropagation()) { + event.stopImmediatePropagation() + } + }, + { isActive }, + ) + + return { handleKeyDown } } // TODO(onKeyDown-migration): temporary shim so existing JSX callers // () keep compiling. Remove once REPL.tsx // wires handleKeyDown directly. -export function VoiceKeybindingHandler(props) { - useVoiceKeybindingHandler(props); - return null; +export function VoiceKeybindingHandler(props: { + voiceHandleKeyEvent: (fallbackMs?: number) => void + stripTrailing: (maxStrip: number, opts?: StripOpts) => number + resetAnchor: () => void + isActive: boolean +}): null { + useVoiceKeybindingHandler(props) + return null } diff --git a/src/ink/Ansi.tsx b/src/ink/Ansi.tsx index 5e51a7c02..f6ff7f7de 100644 --- a/src/ink/Ansi.tsx +++ b/src/ink/Ansi.tsx @@ -1,25 +1,31 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import Link from './components/Link.js'; -import Text from './components/Text.js'; -import type { Color } from './styles.js'; -import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js'; +import React from 'react' +import Link from './components/Link.js' +import Text from './components/Text.js' +import type { Color } from './styles.js' +import { + type NamedColor, + Parser, + type Color as TermioColor, + type TextStyle, +} from './termio.js' + type Props = { - children: string; + children: string /** When true, force all text to be rendered with dim styling */ - dimColor?: boolean; -}; + dimColor?: boolean +} + type SpanProps = { - color?: Color; - backgroundColor?: Color; - dim?: boolean; - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - inverse?: boolean; - hyperlink?: string; -}; + color?: Color + backgroundColor?: Color + dim?: boolean + bold?: boolean + italic?: boolean + underline?: boolean + strikethrough?: boolean + inverse?: boolean + hyperlink?: string +} /** * Component that parses ANSI escape codes and renders them using Text components. @@ -29,145 +35,156 @@ type SpanProps = { * * Memoized to prevent re-renders when parent changes but children string is the same. */ -export const Ansi = React.memo(function Ansi(t0: { children: React.ReactNode; dimColor?: boolean }) { - const $ = _c(12); - const { - children, - dimColor - } = t0; - if (typeof children !== "string") { - let t1; - if ($[0] !== children || $[1] !== dimColor) { - t1 = dimColor ? {String(children)} : {String(children)}; - $[0] = children; - $[1] = dimColor; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; +export const Ansi = React.memo(function Ansi({ + children, + dimColor, +}: Props): React.ReactNode { + if (typeof children !== 'string') { + return dimColor ? ( + {String(children)} + ) : ( + {String(children)} + ) } - if (children === "") { - return null; - } - let t1; - let t2; - if ($[3] !== children || $[4] !== dimColor) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const spans = parseToSpans(children); - if (spans.length === 0) { - t2 = null; - break bb0; - } - if (spans.length === 1 && !hasAnyProps(spans[0].props)) { - t2 = dimColor ? {spans[0].text} : {spans[0].text}; - break bb0; - } - let t3; - if ($[7] !== dimColor) { - t3 = (span, i) => { - const hyperlink = span.props.hyperlink; - if (dimColor) { - span.props.dim = true; - } - const hasTextProps = hasAnyTextProps(span.props); - if (hyperlink) { - return hasTextProps ? {span.text} : {span.text}; - } - return hasTextProps ? {span.text} : span.text; - }; - $[7] = dimColor; - $[8] = t3; - } else { - t3 = $[8]; - } - t1 = spans.map(t3); - } - $[3] = children; - $[4] = dimColor; - $[5] = t1; - $[6] = t2; - } else { - t1 = $[5]; - t2 = $[6]; + + if (children === '') { + return null } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; + + const spans = parseToSpans(children) + + if (spans.length === 0) { + return null } - const content = t1; - let t3; - if ($[9] !== content || $[10] !== dimColor) { - t3 = dimColor ? {content} : {content}; - $[9] = content; - $[10] = dimColor; - $[11] = t3; - } else { - t3 = $[11]; + + if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) { + return dimColor ? ( + {spans[0]!.text} + ) : ( + {spans[0]!.text} + ) } - return t3; -}); + + const content = spans.map((span, i) => { + const hyperlink = span.props.hyperlink + // When dimColor is forced, override the span's dim prop + if (dimColor) { + span.props.dim = true + } + const hasTextProps = hasAnyTextProps(span.props) + + if (hyperlink) { + return hasTextProps ? ( + + + {span.text} + + + ) : ( + + {span.text} + + ) + } + + return hasTextProps ? ( + + {span.text} + + ) : ( + span.text + ) + }) + + return dimColor ? {content} : {content} +}) + type Span = { - text: string; - props: SpanProps; -}; + text: string + props: SpanProps +} /** * Parse an ANSI string into spans using the termio parser. */ function parseToSpans(input: string): Span[] { - const parser = new Parser(); - const actions = parser.feed(input); - const spans: Span[] = []; - let currentHyperlink: string | undefined; + const parser = new Parser() + const actions = parser.feed(input) + const spans: Span[] = [] + + let currentHyperlink: string | undefined + for (const action of actions) { if (action.type === 'link') { if (action.action.type === 'start') { - currentHyperlink = action.action.url; + currentHyperlink = action.action.url } else { - currentHyperlink = undefined; + currentHyperlink = undefined } - continue; + continue } + if (action.type === 'text') { - const text = action.graphemes.map(g => g.value).join(''); - if (!text) continue; - const props = textStyleToSpanProps(action.style); + const text = action.graphemes.map(g => g.value).join('') + if (!text) continue + + const props = textStyleToSpanProps(action.style) if (currentHyperlink) { - props.hyperlink = currentHyperlink; + props.hyperlink = currentHyperlink } // Try to merge with previous span if props match - const lastSpan = spans[spans.length - 1]; + const lastSpan = spans[spans.length - 1] if (lastSpan && propsEqual(lastSpan.props, props)) { - lastSpan.text += text; + lastSpan.text += text } else { - spans.push({ - text, - props - }); + spans.push({ text, props }) } } } - return spans; + + return spans } /** * Convert termio's TextStyle to SpanProps. */ function textStyleToSpanProps(style: TextStyle): SpanProps { - const props: SpanProps = {}; - if (style.bold) props.bold = true; - if (style.dim) props.dim = true; - if (style.italic) props.italic = true; - if (style.underline !== 'none') props.underline = true; - if (style.strikethrough) props.strikethrough = true; - if (style.inverse) props.inverse = true; - const fgColor = colorToString(style.fg); - if (fgColor) props.color = fgColor; - const bgColor = colorToString(style.bg); - if (bgColor) props.backgroundColor = bgColor; - return props; + const props: SpanProps = {} + + if (style.bold) props.bold = true + if (style.dim) props.dim = true + if (style.italic) props.italic = true + if (style.underline !== 'none') props.underline = true + if (style.strikethrough) props.strikethrough = true + if (style.inverse) props.inverse = true + + const fgColor = colorToString(style.fg) + if (fgColor) props.color = fgColor + + const bgColor = colorToString(style.bg) + if (bgColor) props.backgroundColor = bgColor + + return props } // Map termio named colors to the ansi: format @@ -187,8 +204,8 @@ const NAMED_COLOR_MAP: Record = { brightBlue: 'ansi:blueBright', brightMagenta: 'ansi:magentaBright', brightCyan: 'ansi:cyanBright', - brightWhite: 'ansi:whiteBright' -}; + brightWhite: 'ansi:whiteBright', +} /** * Convert termio's Color to the string format used by Ink. @@ -196,13 +213,13 @@ const NAMED_COLOR_MAP: Record = { function colorToString(color: TermioColor): Color | undefined { switch (color.type) { case 'named': - return NAMED_COLOR_MAP[color.name] as Color; + return NAMED_COLOR_MAP[color.name] as Color case 'indexed': - return `ansi256(${color.index})` as Color; + return `ansi256(${color.index})` as Color case 'rgb': - return `rgb(${color.r},${color.g},${color.b})` as Color; + return `rgb(${color.r},${color.g},${color.b})` as Color case 'default': - return undefined; + return undefined } } @@ -210,82 +227,81 @@ function colorToString(color: TermioColor): Color | undefined { * Check if two SpanProps are equal for merging. */ function propsEqual(a: SpanProps, b: SpanProps): boolean { - return a.color === b.color && a.backgroundColor === b.backgroundColor && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline && a.strikethrough === b.strikethrough && a.inverse === b.inverse && a.hyperlink === b.hyperlink; + return ( + a.color === b.color && + a.backgroundColor === b.backgroundColor && + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.strikethrough === b.strikethrough && + a.inverse === b.inverse && + a.hyperlink === b.hyperlink + ) } + function hasAnyProps(props: SpanProps): boolean { - return props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true || props.hyperlink !== undefined; + return ( + props.color !== undefined || + props.backgroundColor !== undefined || + props.dim === true || + props.bold === true || + props.italic === true || + props.underline === true || + props.strikethrough === true || + props.inverse === true || + props.hyperlink !== undefined + ) } + function hasAnyTextProps(props: SpanProps): boolean { - return props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true; + return ( + props.color !== undefined || + props.backgroundColor !== undefined || + props.dim === true || + props.bold === true || + props.italic === true || + props.underline === true || + props.strikethrough === true || + props.inverse === true + ) } // Text style props without weight (bold/dim) - these are handled separately type BaseTextStyleProps = { - color?: Color; - backgroundColor?: Color; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - inverse?: boolean; -}; + color?: Color + backgroundColor?: Color + italic?: boolean + underline?: boolean + strikethrough?: boolean + inverse?: boolean +} // Wrapper component that handles bold/dim mutual exclusivity for Text -function StyledText(t0) { - const $ = _c(14); - let bold; - let children; - let dim; - let rest; - if ($[0] !== t0) { - ({ - bold, - dim, - children, - ...rest - } = t0); - $[0] = t0; - $[1] = bold; - $[2] = children; - $[3] = dim; - $[4] = rest; - } else { - bold = $[1]; - children = $[2]; - dim = $[3]; - rest = $[4]; - } +function StyledText({ + bold, + dim, + children, + ...rest +}: BaseTextStyleProps & { + bold?: boolean + dim?: boolean + children: string +}): React.ReactNode { + // dim takes precedence over bold when both are set (terminals treat them as mutually exclusive) if (dim) { - let t1; - if ($[5] !== children || $[6] !== rest) { - t1 = {children}; - $[5] = children; - $[6] = rest; - $[7] = t1; - } else { - t1 = $[7]; - } - return t1; + return ( + + {children} + + ) } if (bold) { - let t1; - if ($[8] !== children || $[9] !== rest) { - t1 = {children}; - $[8] = children; - $[9] = rest; - $[10] = t1; - } else { - t1 = $[10]; - } - return t1; - } - let t1; - if ($[11] !== children || $[12] !== rest) { - t1 = {children}; - $[11] = children; - $[12] = rest; - $[13] = t1; - } else { - t1 = $[13]; + return ( + + {children} + + ) } - return t1; + return {children} } diff --git a/src/ink/components/AlternateScreen.tsx b/src/ink/components/AlternateScreen.tsx index 2a4dfe451..eeeb1152e 100644 --- a/src/ink/components/AlternateScreen.tsx +++ b/src/ink/components/AlternateScreen.tsx @@ -1,14 +1,23 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react'; -import instances from '../instances.js'; -import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'; -import { TerminalWriteContext } from '../useTerminalNotification.js'; -import Box from './Box.js'; -import { TerminalSizeContext } from './TerminalSizeContext.js'; +import React, { + type PropsWithChildren, + useContext, + useInsertionEffect, +} from 'react' +import instances from '../instances.js' +import { + DISABLE_MOUSE_TRACKING, + ENABLE_MOUSE_TRACKING, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, +} from '../termio/dec.js' +import { TerminalWriteContext } from '../useTerminalNotification.js' +import Box from './Box.js' +import { TerminalSizeContext } from './TerminalSizeContext.js' + type Props = PropsWithChildren<{ /** Enable SGR mouse tracking (wheel + click/drag). Default true. */ - mouseTracking?: boolean; -}>; + mouseTracking?: boolean +}> /** * Run children in the terminal's alternate screen buffer, constrained to @@ -30,50 +39,49 @@ type Props = PropsWithChildren<{ * from scrolling content) and so signal-exit cleanup can exit the alt * screen if the component's own unmount doesn't run. */ -export function AlternateScreen(t0) { - const $ = _c(7); - const { - children, - mouseTracking: t1 - } = t0; - const mouseTracking = t1 === undefined ? true : t1; - const size = useContext(TerminalSizeContext); - const writeRaw = useContext(TerminalWriteContext); - let t2; - let t3; - if ($[0] !== mouseTracking || $[1] !== writeRaw) { - t2 = () => { - const ink = instances.get(process.stdout); - if (!writeRaw) { - return; - } - writeRaw(ENTER_ALT_SCREEN + "\x1B[2J\x1B[H" + (mouseTracking ? ENABLE_MOUSE_TRACKING : "")); - ink?.setAltScreenActive(true, mouseTracking); - return () => { - ink?.setAltScreenActive(false); - ink?.clearTextSelection(); - writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : "") + EXIT_ALT_SCREEN); - }; - }; - t3 = [writeRaw, mouseTracking]; - $[0] = mouseTracking; - $[1] = writeRaw; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - useInsertionEffect(t2, t3); - const t4 = size?.rows ?? 24; - let t5; - if ($[4] !== children || $[5] !== t4) { - t5 = {children}; - $[4] = children; - $[5] = t4; - $[6] = t5; - } else { - t5 = $[6]; - } - return t5; +export function AlternateScreen({ + children, + mouseTracking = true, +}: Props): React.ReactNode { + const size = useContext(TerminalSizeContext) + const writeRaw = useContext(TerminalWriteContext) + + // useInsertionEffect (not useLayoutEffect): react-reconciler calls + // resetAfterCommit between the mutation and layout commit phases, and + // Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that + // first onRender fires BEFORE this effect — writing a full frame to the + // main screen with altScreen=false. That frame is preserved when we + // enter alt screen and revealed on exit as a broken view. Insertion + // effects fire during the mutation phase, before resetAfterCommit, so + // ENTER_ALT_SCREEN reaches the terminal before the first frame does. + // Cleanup timing is unchanged: both insertion and layout effect cleanup + // run in the mutation phase on unmount, before resetAfterCommit. + useInsertionEffect(() => { + const ink = instances.get(process.stdout) + if (!writeRaw) return + + writeRaw( + ENTER_ALT_SCREEN + + '\x1b[2J\x1b[H' + + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''), + ) + ink?.setAltScreenActive(true, mouseTracking) + + return () => { + ink?.setAltScreenActive(false) + ink?.clearTextSelection() + writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN) + } + }, [writeRaw, mouseTracking]) + + return ( + + {children} + + ) } diff --git a/src/ink/components/App.tsx b/src/ink/components/App.tsx index 8b82d1559..9bbb0c06a 100644 --- a/src/ink/components/App.tsx +++ b/src/ink/components/App.tsx @@ -1,223 +1,290 @@ -import React, { PureComponent, type ReactNode } from 'react'; -import { updateLastInteractionTime } from '../../bootstrap/state.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { isMouseClicksDisabled } from '../../utils/fullscreen.js'; -import { logError } from '../../utils/log.js'; -import { EventEmitter } from '../events/emitter.js'; -import { InputEvent } from '../events/input-event.js'; -import { TerminalFocusEvent } from '../events/terminal-focus-event.js'; -import { INITIAL_STATE, type ParsedInput, type ParsedKey, type ParsedMouse, parseMultipleKeypresses } from '../parse-keypress.js'; -import reconciler from '../reconciler.js'; -import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'; -import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'; -import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'; -import { TerminalQuerier, xtversion } from '../terminal-querier.js'; -import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT } from '../termio/csi.js'; -import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js'; -import AppContext from './AppContext.js'; -import { ClockProvider } from './ClockContext.js'; -import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js'; -import ErrorOverview from './ErrorOverview.js'; -import StdinContext from './StdinContext.js'; -import { TerminalFocusProvider } from './TerminalFocusContext.js'; -import { TerminalSizeContext } from './TerminalSizeContext.js'; +import React, { PureComponent, type ReactNode } from 'react' +import { updateLastInteractionTime } from '../../bootstrap/state.js' +import { logForDebugging } from '../../utils/debug.js' +import { stopCapturingEarlyInput } from '../../utils/earlyInput.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { isMouseClicksDisabled } from '../../utils/fullscreen.js' +import { logError } from '../../utils/log.js' +import { EventEmitter } from '../events/emitter.js' +import { InputEvent } from '../events/input-event.js' +import { TerminalFocusEvent } from '../events/terminal-focus-event.js' +import { + INITIAL_STATE, + type ParsedInput, + type ParsedKey, + type ParsedMouse, + parseMultipleKeypresses, +} from '../parse-keypress.js' +import reconciler from '../reconciler.js' +import { + finishSelection, + hasSelection, + type SelectionState, + startSelection, +} from '../selection.js' +import { + isXtermJs, + setXtversionName, + supportsExtendedKeys, +} from '../terminal.js' +import { + getTerminalFocused, + setTerminalFocused, +} from '../terminal-focus-state.js' +import { TerminalQuerier, xtversion } from '../terminal-querier.js' +import { + DISABLE_KITTY_KEYBOARD, + DISABLE_MODIFY_OTHER_KEYS, + ENABLE_KITTY_KEYBOARD, + ENABLE_MODIFY_OTHER_KEYS, + FOCUS_IN, + FOCUS_OUT, +} from '../termio/csi.js' +import { + DBP, + DFE, + DISABLE_MOUSE_TRACKING, + EBP, + EFE, + HIDE_CURSOR, + SHOW_CURSOR, +} from '../termio/dec.js' +import AppContext from './AppContext.js' +import { ClockProvider } from './ClockContext.js' +import CursorDeclarationContext, { + type CursorDeclarationSetter, +} from './CursorDeclarationContext.js' +import ErrorOverview from './ErrorOverview.js' +import StdinContext from './StdinContext.js' +import { TerminalFocusProvider } from './TerminalFocusContext.js' +import { TerminalSizeContext } from './TerminalSizeContext.js' // Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT) -const SUPPORTS_SUSPEND = process.platform !== 'win32'; +const SUPPORTS_SUSPEND = process.platform !== 'win32' // After this many milliseconds of stdin silence, the next chunk triggers // a terminal mode re-assert (mouse tracking). Catches tmux detach→attach, // ssh reconnect, and laptop wake — the terminal resets DEC private modes // but no signal reaches us. 5s is well above normal inter-keystroke gaps // but short enough that the first scroll after reattach works. -const STDIN_RESUME_GAP_MS = 5000; +const STDIN_RESUME_GAP_MS = 5000 + type Props = { - readonly children: ReactNode; - readonly stdin: NodeJS.ReadStream; - readonly stdout: NodeJS.WriteStream; - readonly stderr: NodeJS.WriteStream; - readonly exitOnCtrlC: boolean; - readonly onExit: (error?: Error) => void; - readonly terminalColumns: number; - readonly terminalRows: number; + readonly children: ReactNode + readonly stdin: NodeJS.ReadStream + readonly stdout: NodeJS.WriteStream + readonly stderr: NodeJS.WriteStream + readonly exitOnCtrlC: boolean + readonly onExit: (error?: Error) => void + readonly terminalColumns: number + readonly terminalRows: number // Text selection state. App mutates this directly from mouse events // and calls onSelectionChange to trigger a repaint. Mouse events only // arrive when (or similar) enables mouse tracking, // so the handler is always wired but dormant until tracking is on. - readonly selection: SelectionState; - readonly onSelectionChange: () => void; + readonly selection: SelectionState + readonly onSelectionChange: () => void // Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles // onClick handlers. Returns true if a DOM handler consumed the click. // No-op (returns false) outside fullscreen mode (Ink.dispatchClick // gates on altScreenActive). - readonly onClickAt: (col: number, row: number) => boolean; + readonly onClickAt: (col: number, row: number) => boolean // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over // DOM elements. Called for mode-1003 motion events with no button held. // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). - readonly onHoverAt: (col: number, row: number) => void; + readonly onHoverAt: (col: number, row: number) => void // Look up the OSC 8 hyperlink at (col, row) synchronously at click // time. Returns the URL or undefined. The browser-open is deferred by // MULTI_CLICK_TIMEOUT_MS so double-click can cancel it. - readonly getHyperlinkAt: (col: number, row: number) => string | undefined; + readonly getHyperlinkAt: (col: number, row: number) => string | undefined // Open a hyperlink URL in the browser. Called after the timer fires. - readonly onOpenHyperlink: (url: string) => void; + readonly onOpenHyperlink: (url: string) => void // Called on double/triple-click PRESS at (col, row). count=2 selects // the word under the cursor; count=3 selects the line. Ink reads the // screen buffer to find word/line boundaries and mutates selection, // setting isDragging=true so a subsequent drag extends by word/line. - readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void; + readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void // Called on drag-motion. Mode-aware: char mode updates focus to the // exact cell; word/line mode snaps to word/line boundaries. Needs // screen-buffer access (word boundaries) so lives on Ink, not here. - readonly onSelectionDrag: (col: number, row: number) => void; + readonly onSelectionDrag: (col: number, row: number) => void // Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap. // Ink re-asserts terminal modes: extended key reporting, and (when in // fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the // terminal side. Optional so testing.tsx doesn't need to stub it. - readonly onStdinResume?: () => void; + readonly onStdinResume?: () => void // Receives the declared native-cursor position from useDeclaredCursor // so ink.tsx can park the terminal cursor there after each frame. // Enables IME composition at the input caret and lets screen readers / // magnifiers track the input. Optional so testing.tsx doesn't stub it. - readonly onCursorDeclaration?: CursorDeclarationSetter; + readonly onCursorDeclaration?: CursorDeclarationSetter // Dispatch a keyboard event through the DOM tree. Called for each // parsed key alongside the legacy EventEmitter path. - readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void; -}; + readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void +} // Multi-click detection thresholds. 500ms is the macOS default; a small // position tolerance allows for trackpad jitter between clicks. -const MULTI_CLICK_TIMEOUT_MS = 500; -const MULTI_CLICK_DISTANCE = 1; +const MULTI_CLICK_TIMEOUT_MS = 500 +const MULTI_CLICK_DISTANCE = 1 + type State = { - readonly error?: Error; -}; + readonly error?: Error +} // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed // It also handles Ctrl+C exiting and cursor visibility export default class App extends PureComponent { - static displayName = 'InternalApp'; + static displayName = 'InternalApp' + static getDerivedStateFromError(error: Error) { - return { - error - }; + return { error } } + override state = { - error: undefined - }; + error: undefined, + } // Count how many components enabled raw mode to avoid disabling // raw mode until all components don't need it anymore - rawModeEnabledCount = 0; - internal_eventEmitter = new EventEmitter(); - keyParseState = INITIAL_STATE; + rawModeEnabledCount = 0 + + internal_eventEmitter = new EventEmitter() + keyParseState = INITIAL_STATE // Timer for flushing incomplete escape sequences - incompleteEscapeTimer: NodeJS.Timeout | null = null; + incompleteEscapeTimer: NodeJS.Timeout | null = null // Timeout durations for incomplete sequences (ms) - readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences - readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations + readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences + readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations // Terminal query/response dispatch. Responses arrive on stdin (parsed // out by parse-keypress) and are routed to pending promise resolvers. - querier = new TerminalQuerier(this.props.stdout); + querier = new TerminalQuerier(this.props.stdout) // Multi-click tracking for double/triple-click text selection. A click // within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous // click increments clickCount; otherwise it resets to 1. - lastClickTime = 0; - lastClickCol = -1; - lastClickRow = -1; - clickCount = 0; + lastClickTime = 0 + lastClickCol = -1 + lastClickRow = -1 + clickCount = 0 // Deferred hyperlink-open timer — cancelled if a second click arrives // within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects // the word without also opening the browser). DOM onClick dispatch is // NOT deferred — it returns true from onClickAt and skips this timer. - pendingHyperlinkTimer: ReturnType | null = null; + pendingHyperlinkTimer: ReturnType | null = null // Last mode-1003 motion position. Terminals already dedupe to cell // granularity but this also lets us skip dispatchHover entirely on // repeat events (drag-then-release at same cell, etc.). - lastHoverCol = -1; - lastHoverRow = -1; + lastHoverCol = -1 + lastHoverRow = -1 // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, // ssh reconnect, laptop wake) and trigger terminal mode re-assert. // Initialized to now so startup doesn't false-trigger. - lastStdinTime = Date.now(); + lastStdinTime = Date.now() // Determines if TTY is supported on the provided stdin isRawModeSupported(): boolean { - return this.props.stdin.isTTY; + return this.props.stdin.isTTY } + override render() { - return - - + return ( + + + - {})}> - {this.state.error ? : this.props.children} + {})} + > + {this.state.error ? ( + + ) : ( + this.props.children + )} - ; + + ) } + override componentDidMount() { // In accessibility mode, keep the native cursor visible for screen magnifiers and other tools - if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { - this.props.stdout.write(HIDE_CURSOR); + if ( + this.props.stdout.isTTY && + !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY) + ) { + this.props.stdout.write(HIDE_CURSOR) } } + override componentWillUnmount() { if (this.props.stdout.isTTY) { - this.props.stdout.write(SHOW_CURSOR); + this.props.stdout.write(SHOW_CURSOR) } // Clear any pending timers if (this.incompleteEscapeTimer) { - clearTimeout(this.incompleteEscapeTimer); - this.incompleteEscapeTimer = null; + clearTimeout(this.incompleteEscapeTimer) + this.incompleteEscapeTimer = null } if (this.pendingHyperlinkTimer) { - clearTimeout(this.pendingHyperlinkTimer); - this.pendingHyperlinkTimer = null; + clearTimeout(this.pendingHyperlinkTimer) + this.pendingHyperlinkTimer = null } // ignore calling setRawMode on an handle stdin it cannot be called if (this.isRawModeSupported()) { - this.handleSetRawMode(false); + this.handleSetRawMode(false) } } + override componentDidCatch(error: Error) { - this.handleExit(error); + this.handleExit(error) } + handleSetRawMode = (isEnabled: boolean): void => { - const { - stdin - } = this.props; + const { stdin } = this.props + if (!this.isRawModeSupported()) { if (stdin === process.stdin) { - throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); + throw new Error( + 'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', + ) } else { - throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); + throw new Error( + 'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', + ) } } - stdin.setEncoding('utf8'); + + stdin.setEncoding('utf8') + if (isEnabled) { // Ensure raw mode is enabled only once if (this.rawModeEnabledCount === 0) { @@ -225,22 +292,22 @@ export default class App extends PureComponent { // Both use the same stdin 'readable' + read() pattern, so they can't // coexist -- our handler would drain stdin before Ink's can see it. // The buffered text is preserved for REPL.tsx via consumeEarlyInput(). - stopCapturingEarlyInput(); - stdin.ref(); - stdin.setRawMode(true); - stdin.addListener('readable', this.handleReadable); + stopCapturingEarlyInput() + stdin.ref() + stdin.setRawMode(true) + stdin.addListener('readable', this.handleReadable) // Enable bracketed paste mode - this.props.stdout.write(EBP); + this.props.stdout.write(EBP) // Enable terminal focus reporting (DECSET 1004) - this.props.stdout.write(EFE); + this.props.stdout.write(EFE) // Enable extended key reporting so ctrl+shift+ is // distinguishable from ctrl+. We write both the kitty stack // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — // terminals honor whichever they implement (tmux only accepts the // latter). if (supportsExtendedKeys()) { - this.props.stdout.write(ENABLE_KITTY_KEYBOARD); - this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS); + this.props.stdout.write(ENABLE_KITTY_KEYBOARD) + this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS) } // Probe terminal identity. XTVERSION survives SSH (query/reply goes // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base @@ -251,41 +318,45 @@ export default class App extends PureComponent { // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { - void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { + void Promise.all([ + this.querier.send(xtversion()), + this.querier.flush(), + ]).then(([r]) => { if (r) { - setXtversionName(r.name); - logForDebugging(`XTVERSION: terminal identified as "${r.name}"`); + setXtversionName(r.name) + logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) } else { - logForDebugging('XTVERSION: no reply (terminal ignored query)'); + logForDebugging('XTVERSION: no reply (terminal ignored query)') } - }); - }); + }) + }) } - this.rawModeEnabledCount++; - return; + + this.rawModeEnabledCount++ + return } // Disable raw mode only when no components left that are using it if (--this.rawModeEnabledCount === 0) { - this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS); - this.props.stdout.write(DISABLE_KITTY_KEYBOARD); + this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS) + this.props.stdout.write(DISABLE_KITTY_KEYBOARD) // Disable terminal focus reporting (DECSET 1004) - this.props.stdout.write(DFE); + this.props.stdout.write(DFE) // Disable bracketed paste mode - this.props.stdout.write(DBP); - stdin.setRawMode(false); - stdin.removeListener('readable', this.handleReadable); - stdin.unref(); + this.props.stdout.write(DBP) + stdin.setRawMode(false) + stdin.removeListener('readable', this.handleReadable) + stdin.unref() } - }; + } // Helper to flush incomplete escape sequences flushIncomplete = (): void => { // Clear the timer reference - this.incompleteEscapeTimer = null; + this.incompleteEscapeTimer = null // Only proceed if we have incomplete sequences - if (!this.keyParseState.incomplete) return; + if (!this.keyParseState.incomplete) return // Fullscreen: if stdin has data waiting, it's almost certainly the // continuation of the buffered sequence (e.g. `[<64;74;16M` after a @@ -296,20 +367,23 @@ export default class App extends PureComponent { // drain stdin next and clear this timer. Prevents both the spurious // Escape key and the lost scroll event. if (this.props.stdin.readableLength > 0) { - this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT); - return; + this.incompleteEscapeTimer = setTimeout( + this.flushIncomplete, + this.NORMAL_TIMEOUT, + ) + return } // Process incomplete as a flush operation (input=null) // This reuses all existing parsing logic - this.processInput(null); - }; + this.processInput(null) + } // Process input through the parser and handle the results processInput = (input: string | Buffer | null): void => { // Parse input using our state machine - const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input); - this.keyParseState = newState; + const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input) + this.keyParseState = newState // Process ALL keys in a SINGLE discreteUpdates call to prevent // "Maximum update depth exceeded" error when many keys arrive at once @@ -317,87 +391,106 @@ export default class App extends PureComponent { // This batches all state updates from handleInput and all useInput // listeners together within one high-priority update context. if (keys.length > 0) { - reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined); + reconciler.discreteUpdates( + processKeysInBatch, + this, + keys, + undefined, + undefined, + ) } // If we have incomplete escape sequences, set a timer to flush them if (this.keyParseState.incomplete) { // Cancel any existing timer first if (this.incompleteEscapeTimer) { - clearTimeout(this.incompleteEscapeTimer); + clearTimeout(this.incompleteEscapeTimer) } - this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT); + this.incompleteEscapeTimer = setTimeout( + this.flushIncomplete, + this.keyParseState.mode === 'IN_PASTE' + ? this.PASTE_TIMEOUT + : this.NORMAL_TIMEOUT, + ) } - }; + } + handleReadable = (): void => { // Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake). // The terminal may have reset DEC private modes; re-assert mouse // tracking. Checked before the read loop so one Date.now() covers // all chunks in this readable event. - const now = Date.now(); + const now = Date.now() if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) { - this.props.onStdinResume?.(); + this.props.onStdinResume?.() } - this.lastStdinTime = now; + this.lastStdinTime = now try { - let chunk; + let chunk while ((chunk = this.props.stdin.read() as string | null) !== null) { // Process the input chunk - this.processInput(chunk); + this.processInput(chunk) } } catch (error) { // In Bun, an uncaught throw inside a stream 'readable' handler can // permanently wedge the stream: data stays buffered and 'readable' // never re-emits. Catching here ensures the stream stays healthy so // subsequent keystrokes are still delivered. - logError(error); + logError(error) // Re-attach the listener in case the exception detached it. // Bun may remove the listener after an error; without this, // the session freezes permanently (stdin reader dead, event loop alive). - const { - stdin - } = this.props; - if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) { - logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', { - level: 'warn' - }); - stdin.addListener('readable', this.handleReadable); + const { stdin } = this.props + if ( + this.rawModeEnabledCount > 0 && + !stdin.listeners('readable').includes(this.handleReadable) + ) { + logForDebugging( + 'handleReadable: re-attaching stdin readable listener after error recovery', + { level: 'warn' }, + ) + stdin.addListener('readable', this.handleReadable) } } - }; + } + handleInput = (input: string | undefined): void => { // Exit on Ctrl+C if (input === '\x03' && this.props.exitOnCtrlC) { - this.handleExit(); + this.handleExit() } // Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the // parsed key to support both raw (\x1a) and CSI u format from Kitty // keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm) - }; + } + handleExit = (error?: Error): void => { if (this.isRawModeSupported()) { - this.handleSetRawMode(false); + this.handleSetRawMode(false) } - this.props.onExit(error); - }; + + this.props.onExit(error) + } + handleTerminalFocus = (isFocused: boolean): void => { // setTerminalFocused notifies subscribers: TerminalFocusProvider (context) // and Clock (interval speed) — no App setState needed. - setTerminalFocused(isFocused); - }; + setTerminalFocused(isFocused) + } + handleSuspend = (): void => { if (!this.isRawModeSupported()) { - return; + return } // Store the exact raw mode count to restore it properly - const rawModeCountBeforeSuspend = this.rawModeEnabledCount; + const rawModeCountBeforeSuspend = this.rawModeEnabledCount // Completely disable raw mode before suspending while (this.rawModeEnabledCount > 0) { - this.handleSetRawMode(false); + this.handleSetRawMode(false) } // Show cursor, disable focus reporting, and disable mouse tracking @@ -406,108 +499,125 @@ export default class App extends PureComponent { // it, SGR mouse sequences would appear as garbled text at the // shell prompt while suspended. if (this.props.stdout.isTTY) { - this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING); + this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING) } // Emit suspend event for Claude Code to handle. Mostly just has a notification - this.internal_eventEmitter.emit('suspend'); + this.internal_eventEmitter.emit('suspend') // Set up resume handler const resumeHandler = () => { // Restore raw mode to exact previous state for (let i = 0; i < rawModeCountBeforeSuspend; i++) { if (this.isRawModeSupported()) { - this.handleSetRawMode(true); + this.handleSetRawMode(true) } } // Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming if (this.props.stdout.isTTY) { if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { - this.props.stdout.write(HIDE_CURSOR); + this.props.stdout.write(HIDE_CURSOR) } // Re-enable focus reporting to restore terminal state - this.props.stdout.write(EFE); + this.props.stdout.write(EFE) } // Emit resume event for Claude Code to handle - this.internal_eventEmitter.emit('resume'); - process.removeListener('SIGCONT', resumeHandler); - }; - process.on('SIGCONT', resumeHandler); - process.kill(process.pid, 'SIGSTOP'); - }; + this.internal_eventEmitter.emit('resume') + + process.removeListener('SIGCONT', resumeHandler) + } + + process.on('SIGCONT', resumeHandler) + process.kill(process.pid, 'SIGSTOP') + } } // Helper to process all keys within a single discrete update context. // discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d) -function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void { +function processKeysInBatch( + app: App, + items: ParsedInput[], + _unused1: undefined, + _unused2: undefined, +): void { // Update interaction time for notification timeout tracking. // This is called from the central input handler to avoid having multiple // stdin listeners that can cause race conditions and dropped input. // Terminal responses (kind: 'response') are automated, not user input. // Mode-1003 no-button motion is also excluded — passive cursor drift is // not engagement (would suppress idle notifications + defer housekeeping). - if (items.some(i => i.kind === 'key' || i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3))) { - updateLastInteractionTime(); + if ( + items.some( + i => + i.kind === 'key' || + (i.kind === 'mouse' && + !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)), + ) + ) { + updateLastInteractionTime() } + for (const item of items) { // Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user // input — route them to the querier to resolve pending promises. if (item.kind === 'response') { - app.querier.onResponse(item.response); - continue; + app.querier.onResponse(item.response) + continue } // Mouse click/drag events update selection state (fullscreen only). // Terminal sends 1-indexed col/row; convert to 0-indexed for the // screen buffer. Button bit 0x20 = drag (motion while button held). if (item.kind === 'mouse') { - handleMouseEvent(app, item); - continue; + handleMouseEvent(app, item) + continue } - const sequence = item.sequence; + + const sequence = item.sequence // Handle terminal focus events (DECSET 1004) if (sequence === FOCUS_IN) { - app.handleTerminalFocus(true); - const event = new TerminalFocusEvent('terminalfocus'); - app.internal_eventEmitter.emit('terminalfocus', event); - continue; + app.handleTerminalFocus(true) + const event = new TerminalFocusEvent('terminalfocus') + app.internal_eventEmitter.emit('terminalfocus', event) + continue } if (sequence === FOCUS_OUT) { - app.handleTerminalFocus(false); + app.handleTerminalFocus(false) // Defensive: if we lost the release event (mouse released outside // terminal window — some emulators drop it rather than capturing the // pointer), focus-out is the next observable signal that the drag is // over. Without this, drag-to-scroll's timer runs until the scroll // boundary is hit. if (app.props.selection.isDragging) { - finishSelection(app.props.selection); - app.props.onSelectionChange(); + finishSelection(app.props.selection) + app.props.onSelectionChange() } - const event = new TerminalFocusEvent('terminalblur'); - app.internal_eventEmitter.emit('terminalblur', event); - continue; + const event = new TerminalFocusEvent('terminalblur') + app.internal_eventEmitter.emit('terminalblur', event) + continue } // Failsafe: if we receive input, the terminal must be focused if (!getTerminalFocused()) { - setTerminalFocused(true); + setTerminalFocused(true) } // Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and // CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) { - app.handleSuspend(); - continue; + app.handleSuspend() + continue } - app.handleInput(sequence); - const event = new InputEvent(item); - app.internal_eventEmitter.emit('input', event); + + app.handleInput(sequence) + const event = new InputEvent(item) + app.internal_eventEmitter.emit('input', event) // Also dispatch through the DOM tree so onKeyDown handlers fire. - app.props.dispatchKeyboardEvent(item); + app.props.dispatchKeyboardEvent(item) } } @@ -515,12 +625,14 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, export function handleMouseEvent(app: App, m: ParsedMouse): void { // Allow disabling click handling while keeping wheel scroll (which goes // through the keybinding system as 'wheelup'/'wheeldown', not here). - if (isMouseClicksDisabled()) return; - const sel = app.props.selection; + if (isMouseClicksDisabled()) return + + const sel = app.props.selection // Terminal coords are 1-indexed; screen buffer is 0-indexed - const col = m.col - 1; - const row = m.row - 1; - const baseButton = m.button & 0x03; + const col = m.col - 1 + const row = m.row - 1 + const baseButton = m.button & 0x03 + if (m.action === 'press') { if ((m.button & 0x20) !== 0 && baseButton === 3) { // Mode-1003 motion with no button held. Dispatch hover; skip the @@ -533,25 +645,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // past the edge, came back" — and tmux drops focus events unless // `focus-events on` is set, so this is the more reliable signal. if (sel.isDragging) { - finishSelection(sel); - app.props.onSelectionChange(); + finishSelection(sel) + app.props.onSelectionChange() } - if (col === app.lastHoverCol && row === app.lastHoverRow) return; - app.lastHoverCol = col; - app.lastHoverRow = row; - app.props.onHoverAt(col, row); - return; + if (col === app.lastHoverCol && row === app.lastHoverRow) return + app.lastHoverCol = col + app.lastHoverRow = row + app.props.onHoverAt(col, row) + return } if (baseButton !== 0) { // Non-left press breaks the multi-click chain. - app.clickCount = 0; - return; + app.clickCount = 0 + return } if ((m.button & 0x20) !== 0) { // Drag motion: mode-aware extension (char/word/line). onSelectionDrag // calls notifySelectionChange internally — no extra onSelectionChange. - app.props.onSelectionDrag(col, row); - return; + app.props.onSelectionDrag(col, row) + return } // Lost-release fallback for mode-1002-only terminals: a fresh press // while isDragging=true means the previous release was dropped (cursor @@ -559,40 +671,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // before startSelection/onMultiClick clobbers it. Mode-1003 terminals // hit the no-button-motion recovery above instead, so this is rare. if (sel.isDragging) { - finishSelection(sel); - app.props.onSelectionChange(); + finishSelection(sel) + app.props.onSelectionChange() } // Fresh left press. Detect multi-click HERE (not on release) so the // word/line highlight appears immediately and a subsequent drag can // extend by word/line like native macOS. Previously detected on // release, which meant (a) visible latency before the word highlights // and (b) double-click+drag fell through to char-mode selection. - const now = Date.now(); - const nearLast = now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE; - app.clickCount = nearLast ? app.clickCount + 1 : 1; - app.lastClickTime = now; - app.lastClickCol = col; - app.lastClickRow = row; + const now = Date.now() + const nearLast = + now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && + Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && + Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE + app.clickCount = nearLast ? app.clickCount + 1 : 1 + app.lastClickTime = now + app.lastClickCol = col + app.lastClickRow = row if (app.clickCount >= 2) { // Cancel any pending hyperlink-open from the first click — this is // a double-click, not a single-click on a link. if (app.pendingHyperlinkTimer) { - clearTimeout(app.pendingHyperlinkTimer); - app.pendingHyperlinkTimer = null; + clearTimeout(app.pendingHyperlinkTimer) + app.pendingHyperlinkTimer = null } // Cap at 3 (line select) for quadruple+ clicks. - const count = app.clickCount === 2 ? 2 : 3; - app.props.onMultiClick(col, row, count); - return; + const count = app.clickCount === 2 ? 2 : 3 + app.props.onMultiClick(col, row, count) + return } - startSelection(sel, col, row); + startSelection(sel, col, row) // SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see // comment at the hyperlink-open guard below). On macOS xterm.js, // receiving alt means macOptionClickForcesSelection is OFF (otherwise // xterm.js would have consumed the event for native selection). - sel.lastPressHadAlt = (m.button & 0x08) !== 0; - app.props.onSelectionChange(); - return; + sel.lastPressHadAlt = (m.button & 0x08) !== 0 + app.props.onSelectionChange() + return } // Release: end the drag even for non-zero button codes. Some terminals @@ -602,12 +717,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // scroll boundary. Only act on non-left releases when we ARE dragging // (so an unrelated middle/right click-release doesn't touch selection). if (baseButton !== 0) { - if (!sel.isDragging) return; - finishSelection(sel); - app.props.onSelectionChange(); - return; + if (!sel.isDragging) return + finishSelection(sel) + app.props.onSelectionChange() + return } - finishSelection(sel); + finishSelection(sel) // NOTE: unlike the old release-based detection we do NOT reset clickCount // on release-after-drag. This aligns with NSEvent.clickCount semantics: // an intervening drag doesn't break the click chain. Practical upside: @@ -628,7 +743,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // Resolve the hyperlink URL synchronously while the screen buffer // still reflects what the user clicked — deferring only the // browser-open so double-click can cancel it. - const url = app.props.getHyperlinkAt(col, row); + const url = app.props.getHyperlinkAt(col, row) // xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link // handler that fires on Cmd+click *without consuming the mouse event* // (Linkifier._handleMouseUp calls link.activate() but never @@ -644,14 +759,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // Clear any prior pending timer — clicking a second link // supersedes the first (only the latest click opens). if (app.pendingHyperlinkTimer) { - clearTimeout(app.pendingHyperlinkTimer); + clearTimeout(app.pendingHyperlinkTimer) } - app.pendingHyperlinkTimer = setTimeout((app, url) => { - app.pendingHyperlinkTimer = null; - app.props.onOpenHyperlink(url); - }, MULTI_CLICK_TIMEOUT_MS, app, url); + app.pendingHyperlinkTimer = setTimeout( + (app, url) => { + app.pendingHyperlinkTimer = null + app.props.onOpenHyperlink(url) + }, + MULTI_CLICK_TIMEOUT_MS, + app, + url, + ) } } } - app.props.onSelectionChange(); + app.props.onSelectionChange() } diff --git a/src/ink/components/Box.tsx b/src/ink/components/Box.tsx index 27b3f8ead..42785f523 100644 --- a/src/ink/components/Box.tsx +++ b/src/ink/components/Box.tsx @@ -1,212 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren, type Ref } from 'react'; -import type { Except } from 'type-fest'; -import type { DOMElement } from '../dom.js'; -import type { ClickEvent } from '../events/click-event.js'; -import type { FocusEvent } from '../events/focus-event.js'; -import type { KeyboardEvent } from '../events/keyboard-event.js'; -import type { Styles } from '../styles.js'; -import * as warn from '../warn.js'; +import React, { type PropsWithChildren, type Ref } from 'react' +import type { Except } from 'type-fest' +import type { DOMElement } from '../dom.js' +import type { ClickEvent } from '../events/click-event.js' +import type { FocusEvent } from '../events/focus-event.js' +import type { KeyboardEvent } from '../events/keyboard-event.js' +import type { Styles } from '../styles.js' +import * as warn from '../warn.js' + export type Props = Except & { - ref?: Ref; + ref?: Ref /** * Tab order index. Nodes with `tabIndex >= 0` participate in * Tab/Shift+Tab cycling; `-1` means programmatically focusable only. */ - tabIndex?: number; + tabIndex?: number /** * Focus this element when it mounts. Like the HTML `autofocus` * attribute — the FocusManager calls `focus(node)` during the * reconciler's `commitMount` phase. */ - autoFocus?: boolean; + autoFocus?: boolean /** * Fired on left-button click (press + release without drag). Only works * inside `` where mouse tracking is enabled — no-op * otherwise. The event bubbles from the deepest hit Box up through * ancestors; call `event.stopImmediatePropagation()` to stop bubbling. */ - onClick?: (event: ClickEvent) => void; - onFocus?: (event: FocusEvent) => void; - onFocusCapture?: (event: FocusEvent) => void; - onBlur?: (event: FocusEvent) => void; - onBlurCapture?: (event: FocusEvent) => void; - onKeyDown?: (event: KeyboardEvent) => void; - onKeyDownCapture?: (event: KeyboardEvent) => void; + onClick?: (event: ClickEvent) => void + onFocus?: (event: FocusEvent) => void + onFocusCapture?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void + onKeyDown?: (event: KeyboardEvent) => void + onKeyDownCapture?: (event: KeyboardEvent) => void /** * Fired when the mouse moves into this Box's rendered rect. Like DOM * `mouseenter`, does NOT bubble — moving between children does not * re-fire on the parent. Only works inside `` where * mode-1003 mouse tracking is enabled. */ - onMouseEnter?: () => void; + onMouseEnter?: () => void /** Fired when the mouse moves out of this Box's rendered rect. */ - onMouseLeave?: () => void; -}; + onMouseLeave?: () => void +} /** * `` is an essential Ink component to build your layout. It's like `
` in the browser. */ -function Box(t0) { - const $ = _c(42); - let autoFocus; - let children; - let flexDirection; - let flexGrow; - let flexShrink; - let flexWrap; - let onBlur; - let onBlurCapture; - let onClick; - let onFocus; - let onFocusCapture; - let onKeyDown; - let onKeyDownCapture; - let onMouseEnter; - let onMouseLeave; - let ref; - let style; - let tabIndex; - if ($[0] !== t0) { - const { - children: t1, - flexWrap: t2, - flexDirection: t3, - flexGrow: t4, - flexShrink: t5, - ref: t6, - tabIndex: t7, - autoFocus: t8, - onClick: t9, - onFocus: t10, - onFocusCapture: t11, - onBlur: t12, - onBlurCapture: t13, - onMouseEnter: t14, - onMouseLeave: t15, - onKeyDown: t16, - onKeyDownCapture: t17, - ...t18 - } = t0; - children = t1; - ref = t6; - tabIndex = t7; - autoFocus = t8; - onClick = t9; - onFocus = t10; - onFocusCapture = t11; - onBlur = t12; - onBlurCapture = t13; - onMouseEnter = t14; - onMouseLeave = t15; - onKeyDown = t16; - onKeyDownCapture = t17; - style = t18; - flexWrap = t2 === undefined ? "nowrap" : t2; - flexDirection = t3 === undefined ? "row" : t3; - flexGrow = t4 === undefined ? 0 : t4; - flexShrink = t5 === undefined ? 1 : t5; - warn.ifNotInteger(style.margin, "margin"); - warn.ifNotInteger(style.marginX, "marginX"); - warn.ifNotInteger(style.marginY, "marginY"); - warn.ifNotInteger(style.marginTop, "marginTop"); - warn.ifNotInteger(style.marginBottom, "marginBottom"); - warn.ifNotInteger(style.marginLeft, "marginLeft"); - warn.ifNotInteger(style.marginRight, "marginRight"); - warn.ifNotInteger(style.padding, "padding"); - warn.ifNotInteger(style.paddingX, "paddingX"); - warn.ifNotInteger(style.paddingY, "paddingY"); - warn.ifNotInteger(style.paddingTop, "paddingTop"); - warn.ifNotInteger(style.paddingBottom, "paddingBottom"); - warn.ifNotInteger(style.paddingLeft, "paddingLeft"); - warn.ifNotInteger(style.paddingRight, "paddingRight"); - warn.ifNotInteger(style.gap, "gap"); - warn.ifNotInteger(style.columnGap, "columnGap"); - warn.ifNotInteger(style.rowGap, "rowGap"); - $[0] = t0; - $[1] = autoFocus; - $[2] = children; - $[3] = flexDirection; - $[4] = flexGrow; - $[5] = flexShrink; - $[6] = flexWrap; - $[7] = onBlur; - $[8] = onBlurCapture; - $[9] = onClick; - $[10] = onFocus; - $[11] = onFocusCapture; - $[12] = onKeyDown; - $[13] = onKeyDownCapture; - $[14] = onMouseEnter; - $[15] = onMouseLeave; - $[16] = ref; - $[17] = style; - $[18] = tabIndex; - } else { - autoFocus = $[1]; - children = $[2]; - flexDirection = $[3]; - flexGrow = $[4]; - flexShrink = $[5]; - flexWrap = $[6]; - onBlur = $[7]; - onBlurCapture = $[8]; - onClick = $[9]; - onFocus = $[10]; - onFocusCapture = $[11]; - onKeyDown = $[12]; - onKeyDownCapture = $[13]; - onMouseEnter = $[14]; - onMouseLeave = $[15]; - ref = $[16]; - style = $[17]; - tabIndex = $[18]; - } - const t1 = style.overflowX ?? style.overflow ?? "visible"; - const t2 = style.overflowY ?? style.overflow ?? "visible"; - let t3; - if ($[19] !== flexDirection || $[20] !== flexGrow || $[21] !== flexShrink || $[22] !== flexWrap || $[23] !== style || $[24] !== t1 || $[25] !== t2) { - t3 = { - flexWrap, - flexDirection, - flexGrow, - flexShrink, - ...style, - overflowX: t1, - overflowY: t2 - }; - $[19] = flexDirection; - $[20] = flexGrow; - $[21] = flexShrink; - $[22] = flexWrap; - $[23] = style; - $[24] = t1; - $[25] = t2; - $[26] = t3; - } else { - t3 = $[26]; - } - let t4; - if ($[27] !== autoFocus || $[28] !== children || $[29] !== onBlur || $[30] !== onBlurCapture || $[31] !== onClick || $[32] !== onFocus || $[33] !== onFocusCapture || $[34] !== onKeyDown || $[35] !== onKeyDownCapture || $[36] !== onMouseEnter || $[37] !== onMouseLeave || $[38] !== ref || $[39] !== t3 || $[40] !== tabIndex) { - t4 = {children}; - $[27] = autoFocus; - $[28] = children; - $[29] = onBlur; - $[30] = onBlurCapture; - $[31] = onClick; - $[32] = onFocus; - $[33] = onFocusCapture; - $[34] = onKeyDown; - $[35] = onKeyDownCapture; - $[36] = onMouseEnter; - $[37] = onMouseLeave; - $[38] = ref; - $[39] = t3; - $[40] = tabIndex; - $[41] = t4; - } else { - t4 = $[41]; - } - return t4; +function Box({ + children, + flexWrap = 'nowrap', + flexDirection = 'row', + flexGrow = 0, + flexShrink = 1, + ref, + tabIndex, + autoFocus, + onClick, + onFocus, + onFocusCapture, + onBlur, + onBlurCapture, + onMouseEnter, + onMouseLeave, + onKeyDown, + onKeyDownCapture, + ...style +}: PropsWithChildren): React.ReactNode { + // Warn if spacing values are not integers to prevent fractional layout dimensions + warn.ifNotInteger(style.margin, 'margin') + warn.ifNotInteger(style.marginX, 'marginX') + warn.ifNotInteger(style.marginY, 'marginY') + warn.ifNotInteger(style.marginTop, 'marginTop') + warn.ifNotInteger(style.marginBottom, 'marginBottom') + warn.ifNotInteger(style.marginLeft, 'marginLeft') + warn.ifNotInteger(style.marginRight, 'marginRight') + warn.ifNotInteger(style.padding, 'padding') + warn.ifNotInteger(style.paddingX, 'paddingX') + warn.ifNotInteger(style.paddingY, 'paddingY') + warn.ifNotInteger(style.paddingTop, 'paddingTop') + warn.ifNotInteger(style.paddingBottom, 'paddingBottom') + warn.ifNotInteger(style.paddingLeft, 'paddingLeft') + warn.ifNotInteger(style.paddingRight, 'paddingRight') + warn.ifNotInteger(style.gap, 'gap') + warn.ifNotInteger(style.columnGap, 'columnGap') + warn.ifNotInteger(style.rowGap, 'rowGap') + + return ( + + {children} + + ) } -export default Box; + +export default Box diff --git a/src/ink/components/Button.tsx b/src/ink/components/Button.tsx index 95b3ae711..0095d9c59 100644 --- a/src/ink/components/Button.tsx +++ b/src/ink/components/Button.tsx @@ -1,32 +1,39 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react'; -import type { Except } from 'type-fest'; -import type { DOMElement } from '../dom.js'; -import type { ClickEvent } from '../events/click-event.js'; -import type { FocusEvent } from '../events/focus-event.js'; -import type { KeyboardEvent } from '../events/keyboard-event.js'; -import type { Styles } from '../styles.js'; -import Box from './Box.js'; +import React, { + type Ref, + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import type { Except } from 'type-fest' +import type { DOMElement } from '../dom.js' +import type { ClickEvent } from '../events/click-event.js' +import type { FocusEvent } from '../events/focus-event.js' +import type { KeyboardEvent } from '../events/keyboard-event.js' +import type { Styles } from '../styles.js' +import Box from './Box.js' + type ButtonState = { - focused: boolean; - hovered: boolean; - active: boolean; -}; + focused: boolean + hovered: boolean + active: boolean +} + export type Props = Except & { - ref?: Ref; + ref?: Ref /** * Called when the button is activated via Enter, Space, or click. */ - onAction: () => void; + onAction: () => void /** * Tab order index. Defaults to 0 (in tab order). * Set to -1 for programmatically focusable only. */ - tabIndex?: number; + tabIndex?: number /** * Focus this button when it mounts. */ - autoFocus?: boolean; + autoFocus?: boolean /** * Render prop receiving the interactive state. Use this to * style children based on focus/hover/active — Button itself @@ -34,158 +41,82 @@ export type Props = Except & { * * If not provided, children render as-is (no state-dependent styling). */ - children: ((state: ButtonState) => React.ReactNode) | React.ReactNode; -}; -function Button(t0) { - const $ = _c(30); - let autoFocus; - let children; - let onAction; - let ref; - let style; - let t1; - if ($[0] !== t0) { - ({ - onAction, - tabIndex: t1, - autoFocus, - children, - ref, - ...style - } = t0); - $[0] = t0; - $[1] = autoFocus; - $[2] = children; - $[3] = onAction; - $[4] = ref; - $[5] = style; - $[6] = t1; - } else { - autoFocus = $[1]; - children = $[2]; - onAction = $[3]; - ref = $[4]; - style = $[5]; - t1 = $[6]; - } - const tabIndex = t1 === undefined ? 0 : t1; - const [isFocused, setIsFocused] = useState(false); - const [isHovered, setIsHovered] = useState(false); - const [isActive, setIsActive] = useState(false); - const activeTimer = useRef(null); - let t2; - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => () => { - if (activeTimer.current) { - clearTimeout(activeTimer.current); - } - }; - t3 = []; - $[7] = t2; - $[8] = t3; - } else { - t2 = $[7]; - t3 = $[8]; - } - useEffect(t2, t3); - let t4; - if ($[9] !== onAction) { - t4 = e => { - if (e.key === "return" || e.key === " ") { - e.preventDefault(); - setIsActive(true); - onAction(); - if (activeTimer.current) { - clearTimeout(activeTimer.current); - } - activeTimer.current = setTimeout(_temp, 100, setIsActive); + children: ((state: ButtonState) => React.ReactNode) | React.ReactNode +} + +function Button({ + onAction, + tabIndex = 0, + autoFocus, + children, + ref, + ...style +}: Props): React.ReactNode { + const [isFocused, setIsFocused] = useState(false) + const [isHovered, setIsHovered] = useState(false) + const [isActive, setIsActive] = useState(false) + + const activeTimer = useRef | null>(null) + + useEffect(() => { + return () => { + if (activeTimer.current) clearTimeout(activeTimer.current) + } + }, []) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'return' || e.key === ' ') { + e.preventDefault() + setIsActive(true) + onAction() + if (activeTimer.current) clearTimeout(activeTimer.current) + activeTimer.current = setTimeout( + setter => setter(false), + 100, + setIsActive, + ) } - }; - $[9] = onAction; - $[10] = t4; - } else { - t4 = $[10]; - } - const handleKeyDown = t4; - let t5; - if ($[11] !== onAction) { - t5 = _e => { - onAction(); - }; - $[11] = onAction; - $[12] = t5; - } else { - t5 = $[12]; - } - const handleClick = t5; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = _e_0 => setIsFocused(true); - $[13] = t6; - } else { - t6 = $[13]; + }, + [onAction], + ) + + const handleClick = useCallback( + (_e: ClickEvent) => { + onAction() + }, + [onAction], + ) + + const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), []) + const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), []) + const handleMouseEnter = useCallback(() => setIsHovered(true), []) + const handleMouseLeave = useCallback(() => setIsHovered(false), []) + + const state: ButtonState = { + focused: isFocused, + hovered: isHovered, + active: isActive, } - const handleFocus = t6; - let t7; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t7 = _e_1 => setIsFocused(false); - $[14] = t7; - } else { - t7 = $[14]; - } - const handleBlur = t7; - let t8; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t8 = () => setIsHovered(true); - $[15] = t8; - } else { - t8 = $[15]; - } - const handleMouseEnter = t8; - let t9; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t9 = () => setIsHovered(false); - $[16] = t9; - } else { - t9 = $[16]; - } - const handleMouseLeave = t9; - let t10; - if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) { - const state = { - focused: isFocused, - hovered: isHovered, - active: isActive - }; - t10 = typeof children === "function" ? children(state) : children; - $[17] = children; - $[18] = isActive; - $[19] = isFocused; - $[20] = isHovered; - $[21] = t10; - } else { - t10 = $[21]; - } - const content = t10; - let t11; - if ($[22] !== autoFocus || $[23] !== content || $[24] !== handleClick || $[25] !== handleKeyDown || $[26] !== ref || $[27] !== style || $[28] !== tabIndex) { - t11 = {content}; - $[22] = autoFocus; - $[23] = content; - $[24] = handleClick; - $[25] = handleKeyDown; - $[26] = ref; - $[27] = style; - $[28] = tabIndex; - $[29] = t11; - } else { - t11 = $[29]; - } - return t11; -} -function _temp(setter) { - return setter(false); + const content = typeof children === 'function' ? children(state) : children + + return ( + + {content} + + ) } -export default Button; -export type { ButtonState }; + +export default Button +export type { ButtonState } diff --git a/src/ink/components/ClockContext.tsx b/src/ink/components/ClockContext.tsx index 62b5bf0a5..32a8b9a28 100644 --- a/src/ink/components/ClockContext.tsx +++ b/src/ink/components/ClockContext.tsx @@ -1,111 +1,99 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useEffect, useState } from 'react'; -import { FRAME_INTERVAL_MS } from '../constants.js'; -import { useTerminalFocus } from '../hooks/use-terminal-focus.js'; +import React, { createContext, useEffect, useState } from 'react' +import { FRAME_INTERVAL_MS } from '../constants.js' +import { useTerminalFocus } from '../hooks/use-terminal-focus.js' + export type Clock = { - subscribe: (onChange: () => void, keepAlive: boolean) => () => void; - now: () => number; - setTickInterval: (ms: number) => void; -}; + subscribe: (onChange: () => void, keepAlive: boolean) => () => void + now: () => number + setTickInterval: (ms: number) => void +} + export function createClock(tickIntervalMs: number): Clock { - const subscribers = new Map<() => void, boolean>(); - let interval: ReturnType | null = null; - let currentTickIntervalMs = tickIntervalMs; - let startTime = 0; + const subscribers = new Map<() => void, boolean>() + let interval: ReturnType | null = null + let currentTickIntervalMs = tickIntervalMs + let startTime = 0 // Snapshot of the current tick's time, ensuring all subscribers in the same // tick see the same value (keeps animations synchronized) - let tickTime = 0; + let tickTime = 0 + function tick(): void { - tickTime = Date.now() - startTime; + tickTime = Date.now() - startTime for (const onChange of subscribers.keys()) { - onChange(); + onChange() } } + function updateInterval(): void { - const anyKeepAlive = [...subscribers.values()].some(Boolean); + const anyKeepAlive = [...subscribers.values()].some(Boolean) + if (anyKeepAlive) { if (interval) { - clearInterval(interval); - interval = null; + clearInterval(interval) + interval = null } if (startTime === 0) { - startTime = Date.now(); + startTime = Date.now() } - interval = setInterval(tick, currentTickIntervalMs); + interval = setInterval(tick, currentTickIntervalMs) } else if (interval) { - clearInterval(interval); - interval = null; + clearInterval(interval) + interval = null } } + return { subscribe(onChange, keepAlive) { - subscribers.set(onChange, keepAlive); - updateInterval(); + subscribers.set(onChange, keepAlive) + updateInterval() return () => { - subscribers.delete(onChange); - updateInterval(); - }; + subscribers.delete(onChange) + updateInterval() + } }, + now() { if (startTime === 0) { - startTime = Date.now(); + startTime = Date.now() } // When the clock interval is running, return the synchronized tickTime // so all subscribers in the same tick see the same value. // When paused (no keepAlive subscribers), return real-time to avoid // returning a stale tickTime from the last tick before the pause. if (interval && tickTime) { - return tickTime; + return tickTime } - return Date.now() - startTime; + return Date.now() - startTime }, + setTickInterval(ms) { - if (ms === currentTickIntervalMs) return; - currentTickIntervalMs = ms; - updateInterval(); - } - }; + if (ms === currentTickIntervalMs) return + currentTickIntervalMs = ms + updateInterval() + }, + } } -export const ClockContext = createContext(null); -const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2; + +export const ClockContext = createContext(null) + +const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2 // Own component so App.tsx doesn't re-render when the clock is created. // The clock value is stable (created once via useState), so the provider // never causes consumer re-renders on its own. -export function ClockProvider(t0) { - const $ = _c(7); - const { - children - } = t0; - const [clock] = useState(_temp); - const focused = useTerminalFocus(); - let t1; - let t2; - if ($[0] !== clock || $[1] !== focused) { - t1 = () => { - clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS); - }; - t2 = [clock, focused]; - $[0] = clock; - $[1] = focused; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== children || $[5] !== clock) { - t3 = {children}; - $[4] = children; - $[5] = clock; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; -} -function _temp() { - return createClock(FRAME_INTERVAL_MS); +export function ClockProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + const [clock] = useState(() => createClock(FRAME_INTERVAL_MS)) + const focused = useTerminalFocus() + + useEffect(() => { + clock.setTickInterval( + focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS, + ) + }, [clock, focused]) + + return {children} } diff --git a/src/ink/components/ErrorOverview.tsx b/src/ink/components/ErrorOverview.tsx index da8ce9367..3effc4217 100644 --- a/src/ink/components/ErrorOverview.tsx +++ b/src/ink/components/ErrorOverview.tsx @@ -1,55 +1,57 @@ -import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'; -import { readFileSync } from 'fs'; -import React from 'react'; -import StackUtils from 'stack-utils'; -import Box from './Box.js'; -import Text from './Text.js'; +import codeExcerpt, { type CodeExcerpt } from 'code-excerpt' +import { readFileSync } from 'fs' +import React from 'react' +import StackUtils from 'stack-utils' +import Box from './Box.js' +import Text from './Text.js' /* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */ // Error's source file is reported as file:///home/user/file.js // This function removes the file://[cwd] part const cleanupPath = (path: string | undefined): string | undefined => { - return path?.replace(`file://${process.cwd()}/`, ''); -}; -let stackUtils: StackUtils | undefined; + return path?.replace(`file://${process.cwd()}/`, '') +} + +let stackUtils: StackUtils | undefined function getStackUtils(): StackUtils { - return stackUtils ??= new StackUtils({ + return (stackUtils ??= new StackUtils({ cwd: process.cwd(), - internals: StackUtils.nodeInternals() - }); + internals: StackUtils.nodeInternals(), + })) } /* eslint-enable custom-rules/no-process-cwd */ type Props = { - readonly error: Error; -}; -export default function ErrorOverview({ - error -}: Props) { - const stack = error.stack ? error.stack.split('\n').slice(1) : undefined; - const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined; - const filePath = cleanupPath(origin?.file); - let excerpt: CodeExcerpt[] | undefined; - let lineWidth = 0; + readonly error: Error +} + +export default function ErrorOverview({ error }: Props) { + const stack = error.stack ? error.stack.split('\n').slice(1) : undefined + const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined + const filePath = cleanupPath(origin?.file) + let excerpt: CodeExcerpt[] | undefined + let lineWidth = 0 + if (filePath && origin?.line) { try { // eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring - const sourceCode = readFileSync(filePath, 'utf8'); - excerpt = codeExcerpt(sourceCode, origin.line); + const sourceCode = readFileSync(filePath, 'utf8') + excerpt = codeExcerpt(sourceCode, origin.line) + if (excerpt) { - for (const { - line - } of excerpt) { - lineWidth = Math.max(lineWidth, String(line).length); + for (const { line } of excerpt) { + lineWidth = Math.max(lineWidth, String(line).length) } } } catch { // file not readable — skip source context } } - return + + return ( + {' '} @@ -59,41 +61,62 @@ export default function ErrorOverview({ {error.message} - {origin && filePath && + {origin && filePath && ( + {filePath}:{origin.line}:{origin.column} - } + + )} - {origin && excerpt && - {excerpt.map(({ - line: line_0, - value - }) => + {origin && excerpt && ( + + {excerpt.map(({ line, value }) => ( + - - {String(line_0).padStart(lineWidth, ' ')}: + + {String(line).padStart(lineWidth, ' ')}: - + {' ' + value} - )} - } + + ))} + + )} - {error.stack && - {error.stack.split('\n').slice(1).map(line_1 => { - const parsedLine = getStackUtils().parseLine(line_1); + {error.stack && ( + + {error.stack + .split('\n') + .slice(1) + .map(line => { + const parsedLine = getStackUtils().parseLine(line) - // If the line from the stack cannot be parsed, we print out the unparsed line. - if (!parsedLine) { - return + // If the line from the stack cannot be parsed, we print out the unparsed line. + if (!parsedLine) { + return ( + - - {line_1} - ; - } - return + {line} + + ) + } + + return ( + - {parsedLine.function} @@ -101,8 +124,11 @@ export default function ErrorOverview({ ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}: {parsedLine.column}) - ; - })} - } - ; + + ) + })} + + )} + + ) } diff --git a/src/ink/components/Link.tsx b/src/ink/components/Link.tsx index 772f344d0..ee7f04d14 100644 --- a/src/ink/components/Link.tsx +++ b/src/ink/components/Link.tsx @@ -1,41 +1,31 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React from 'react'; -import { supportsHyperlinks } from '../supports-hyperlinks.js'; -import Text from './Text.js'; +import type { ReactNode } from 'react' +import React from 'react' +import { supportsHyperlinks } from '../supports-hyperlinks.js' +import Text from './Text.js' + export type Props = { - readonly children?: ReactNode; - readonly url: string; - readonly fallback?: ReactNode; -}; -export default function Link(t0) { - const $ = _c(5); - const { - children, - url, - fallback - } = t0; - const content = children ?? url; + readonly children?: ReactNode + readonly url: string + readonly fallback?: ReactNode +} + +export default function Link({ + children, + url, + fallback, +}: Props): React.ReactNode { + // Use children if provided, otherwise display the URL + const content = children ?? url + if (supportsHyperlinks()) { - let t1; - if ($[0] !== content || $[1] !== url) { - t1 = {content}; - $[0] = content; - $[1] = url; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - const t1 = fallback ?? content; - let t2; - if ($[3] !== t1) { - t2 = {t1}; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; + // Wrap in Text to ensure we're in a text context + // (ink-link is a text element like ink-text) + return ( + + {content} + + ) } - return t2; + + return {fallback ?? content} } diff --git a/src/ink/components/Newline.tsx b/src/ink/components/Newline.tsx index c5e6b2b76..b8d6a88a2 100644 --- a/src/ink/components/Newline.tsx +++ b/src/ink/components/Newline.tsx @@ -1,38 +1,17 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; +import React from 'react' + export type Props = { /** * Number of newlines to insert. * * @default 1 */ - readonly count?: number; -}; + readonly count?: number +} /** * Adds one or more newline (\n) characters. Must be used within components. */ -export default function Newline(t0) { - const $ = _c(4); - const { - count: t1 - } = t0; - const count = t1 === undefined ? 1 : t1; - let t2; - if ($[0] !== count) { - t2 = "\n".repeat(count); - $[0] = count; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; +export default function Newline({ count = 1 }: Props) { + return {'\n'.repeat(count)} } diff --git a/src/ink/components/NoSelect.tsx b/src/ink/components/NoSelect.tsx index ab0876919..882097608 100644 --- a/src/ink/components/NoSelect.tsx +++ b/src/ink/components/NoSelect.tsx @@ -1,6 +1,6 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren } from 'react'; -import Box, { type Props as BoxProps } from './Box.js'; +import React, { type PropsWithChildren } from 'react' +import Box, { type Props as BoxProps } from './Box.js' + type Props = Omit & { /** * Extend the exclusion zone from column 0 to this box's right edge, @@ -11,8 +11,8 @@ type Props = Omit & { * * @default false */ - fromLeftEdge?: boolean; -}; + fromLeftEdge?: boolean +} /** * Marks its contents as non-selectable in fullscreen text selection. @@ -32,36 +32,14 @@ type Props = Omit & { * tracking). No-op in the main-screen scrollback render where the * terminal's native selection is used instead. */ -export function NoSelect(t0) { - const $ = _c(8); - let boxProps; - let children; - let fromLeftEdge; - if ($[0] !== t0) { - ({ - children, - fromLeftEdge, - ...boxProps - } = t0); - $[0] = t0; - $[1] = boxProps; - $[2] = children; - $[3] = fromLeftEdge; - } else { - boxProps = $[1]; - children = $[2]; - fromLeftEdge = $[3]; - } - const t1 = fromLeftEdge ? "from-left-edge" : true; - let t2; - if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) { - t2 = {children}; - $[4] = boxProps; - $[5] = children; - $[6] = t1; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; +export function NoSelect({ + children, + fromLeftEdge, + ...boxProps +}: PropsWithChildren): React.ReactNode { + return ( + + {children} + + ) } diff --git a/src/ink/components/RawAnsi.tsx b/src/ink/components/RawAnsi.tsx index 732005164..a1a23ab4b 100644 --- a/src/ink/components/RawAnsi.tsx +++ b/src/ink/components/RawAnsi.tsx @@ -1,14 +1,14 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; +import React from 'react' + type Props = { /** * Pre-rendered ANSI lines. Each element must be exactly one terminal row * (already wrapped to `width` by the producer) with ANSI escape codes inline. */ - lines: string[]; + lines: string[] /** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */ - width: number; -}; + width: number +} /** * Bypass the → React tree → Yoga → squash → re-serialize roundtrip for @@ -25,32 +25,15 @@ type Props = { * (width × lines.length) and hands the joined string straight to output.write(), * which already splits on '\n' and parses ANSI into the screen buffer. */ -export function RawAnsi(t0) { - const $ = _c(6); - const { - lines, - width - } = t0; +export function RawAnsi({ lines, width }: Props): React.ReactNode { if (lines.length === 0) { - return null; - } - let t1; - if ($[0] !== lines) { - t1 = lines.join("\n"); - $[0] = lines; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) { - t2 = ; - $[2] = lines.length; - $[3] = t1; - $[4] = width; - $[5] = t2; - } else { - t2 = $[5]; + return null } - return t2; + return ( + + ) } diff --git a/src/ink/components/ScrollBox.tsx b/src/ink/components/ScrollBox.tsx index 7174deede..c2d432be2 100644 --- a/src/ink/components/ScrollBox.tsx +++ b/src/ink/components/ScrollBox.tsx @@ -1,14 +1,21 @@ -import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react'; -import type { Except } from 'type-fest'; -import { markScrollActivity } from '../../bootstrap/state.js'; -import type { DOMElement } from '../dom.js'; -import { markDirty, scheduleRenderFrom } from '../dom.js'; -import { markCommitStart } from '../reconciler.js'; -import type { Styles } from '../styles.js'; -import Box from './Box.js'; +import React, { + type PropsWithChildren, + type Ref, + useImperativeHandle, + useRef, + useState, +} from 'react' +import type { Except } from 'type-fest' +import { markScrollActivity } from '../../bootstrap/state.js' +import type { DOMElement } from '../dom.js' +import { markDirty, scheduleRenderFrom } from '../dom.js' +import { markCommitStart } from '../reconciler.js' +import type { Styles } from '../styles.js' +import Box from './Box.js' + export type ScrollBoxHandle = { - scrollTo: (y: number) => void; - scrollBy: (dy: number) => void; + scrollTo: (y: number) => void + scrollBy: (dy: number) => void /** * Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike * scrollTo which bakes a number that's stale by the time the throttled @@ -16,24 +23,24 @@ export type ScrollBoxHandle = { * render-node-to-output reads `el.yogaNode.getComputedTop()` in the * SAME Yoga pass that computes scrollHeight. Deterministic. One-shot. */ - scrollToElement: (el: DOMElement, offset?: number) => void; - scrollToBottom: () => void; - getScrollTop: () => number; - getPendingDelta: () => number; - getScrollHeight: () => number; + scrollToElement: (el: DOMElement, offset?: number) => void + scrollToBottom: () => void + getScrollTop: () => number + getPendingDelta: () => number + getScrollHeight: () => number /** * Like getScrollHeight, but reads Yoga directly instead of the cached * value written by render-node-to-output (throttled, up to 16ms stale). * Use when you need a fresh value in useLayoutEffect after a React commit * that grew content. Slightly more expensive (native Yoga call). */ - getFreshScrollHeight: () => number; - getViewportHeight: () => number; + getFreshScrollHeight: () => number + getViewportHeight: () => number /** * Absolute screen-buffer row of the first visible content line (inside * padding). Used for drag-to-scroll edge detection. */ - getViewportTop: () => number; + getViewportTop: () => number /** * True when scroll is pinned to the bottom. Set by scrollToBottom, the * initial stickyScroll attribute, and by the renderer when positional @@ -41,14 +48,14 @@ export type ScrollBoxHandle = { * scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on * layout values (unlike scrollTop+viewportH >= scrollHeight). */ - isSticky: () => boolean; + isSticky: () => boolean /** * Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom). * Does NOT fire for stickyScroll updates done by the Ink renderer — those * happen during Ink's render phase after React has committed. Callers that * care about the sticky case should treat "at bottom" as a fallback. */ - subscribe: (listener: () => void) => () => void; + subscribe: (listener: () => void) => () => void /** * Set the render-time scrollTop clamp to the currently-mounted children's * coverage span. Called by useVirtualScroll after computing its range; @@ -57,16 +64,20 @@ export type ScrollBoxHandle = { * content instead of blank spacer. Pass undefined to disable (sticky, * cold start). */ - setClampBounds: (min: number | undefined, max: number | undefined) => void; -}; -export type ScrollBoxProps = Except & { - ref?: Ref; + setClampBounds: (min: number | undefined, max: number | undefined) => void +} + +export type ScrollBoxProps = Except< + Styles, + 'textWrap' | 'overflow' | 'overflowX' | 'overflowY' +> & { + ref?: Ref /** * When true, automatically pins scroll position to the bottom when content * grows. Unset manually via scrollTo/scrollBy to break the stickiness. */ - stickyScroll?: boolean; -}; + stickyScroll?: boolean +} /** * A Box with `overflow: scroll` and an imperative scroll API. @@ -84,7 +95,7 @@ function ScrollBox({ stickyScroll, ...style }: PropsWithChildren): React.ReactNode { - const domRef = useRef(null); + const domRef = useRef(null) // scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node, // mark it dirty, and call the root's throttled scheduleRender directly. // The Ink renderer reads scrollTop from the node — no React state needed, @@ -93,114 +104,121 @@ function ScrollBox({ // render — otherwise scheduleRender's leading edge fires on the FIRST // event before subsequent events mutate scrollTop. scrollToBottom still // forces a React render: sticky is attribute-observed, no DOM-only path. - const [, forceRender] = useState(0); - const listenersRef = useRef(new Set<() => void>()); - const renderQueuedRef = useRef(false); + const [, forceRender] = useState(0) + const listenersRef = useRef(new Set<() => void>()) + const renderQueuedRef = useRef(false) + const notify = () => { - for (const l of listenersRef.current) l(); - }; + for (const l of listenersRef.current) l() + } + function scrollMutated(el: DOMElement): void { // Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan // check) to skip their next tick — they compete for the event loop and // contributed to 1402ms max frame gaps during scroll drain. - markScrollActivity(); - markDirty(el); - markCommitStart(); - notify(); - if (renderQueuedRef.current) return; - renderQueuedRef.current = true; + markScrollActivity() + markDirty(el) + markCommitStart() + notify() + if (renderQueuedRef.current) return + renderQueuedRef.current = true queueMicrotask(() => { - renderQueuedRef.current = false; - scheduleRenderFrom(el); - }); + renderQueuedRef.current = false + scheduleRenderFrom(el) + }) } - useImperativeHandle(ref, (): ScrollBoxHandle => ({ - scrollTo(y: number) { - const el = domRef.current; - if (!el) return; - // Explicit false overrides the DOM attribute so manual scroll - // breaks stickiness. Render code checks ?? precedence. - el.stickyScroll = false; - el.pendingScrollDelta = undefined; - el.scrollAnchor = undefined; - el.scrollTop = Math.max(0, Math.floor(y)); - scrollMutated(el); - }, - scrollToElement(el: DOMElement, offset = 0) { - const box = domRef.current; - if (!box) return; - box.stickyScroll = false; - box.pendingScrollDelta = undefined; - box.scrollAnchor = { - el, - offset - }; - scrollMutated(box); - }, - scrollBy(dy: number) { - const el = domRef.current; - if (!el) return; - el.stickyScroll = false; - // Wheel input cancels any in-flight anchor seek — user override. - el.scrollAnchor = undefined; - // Accumulate in pendingScrollDelta; renderer drains it at a capped - // rate so fast flicks show intermediate frames. Pure accumulator: - // scroll-up followed by scroll-down naturally cancels. - el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy); - scrollMutated(el); - }, - scrollToBottom() { - const el = domRef.current; - if (!el) return; - el.pendingScrollDelta = undefined; - el.stickyScroll = true; - markDirty(el); - notify(); - forceRender(n => n + 1); - }, - getScrollTop() { - return domRef.current?.scrollTop ?? 0; - }, - getPendingDelta() { - // Accumulated-but-not-yet-drained delta. useVirtualScroll needs - // this to mount the union [committed, committed+pending] range — - // otherwise intermediate drain frames find no children (blank). - return domRef.current?.pendingScrollDelta ?? 0; - }, - getScrollHeight() { - return domRef.current?.scrollHeight ?? 0; - }, - getFreshScrollHeight() { - const content = domRef.current?.childNodes[0] as DOMElement | undefined; - return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0; - }, - getViewportHeight() { - return domRef.current?.scrollViewportHeight ?? 0; - }, - getViewportTop() { - return domRef.current?.scrollViewportTop ?? 0; - }, - isSticky() { - const el = domRef.current; - if (!el) return false; - return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']); - }, - subscribe(listener: () => void) { - listenersRef.current.add(listener); - return () => listenersRef.current.delete(listener); - }, - setClampBounds(min, max) { - const el = domRef.current; - if (!el) return; - el.scrollClampMin = min; - el.scrollClampMax = max; - } - }), - // notify/scrollMutated are inline (no useCallback) but only close over - // refs + imports — stable. Empty deps avoids rebuilding the handle on - // every render (which re-registers the ref = churn). - // eslint-disable-next-line react-hooks/exhaustive-deps - []); + + useImperativeHandle( + ref, + (): ScrollBoxHandle => ({ + scrollTo(y: number) { + const el = domRef.current + if (!el) return + // Explicit false overrides the DOM attribute so manual scroll + // breaks stickiness. Render code checks ?? precedence. + el.stickyScroll = false + el.pendingScrollDelta = undefined + el.scrollAnchor = undefined + el.scrollTop = Math.max(0, Math.floor(y)) + scrollMutated(el) + }, + scrollToElement(el: DOMElement, offset = 0) { + const box = domRef.current + if (!box) return + box.stickyScroll = false + box.pendingScrollDelta = undefined + box.scrollAnchor = { el, offset } + scrollMutated(box) + }, + scrollBy(dy: number) { + const el = domRef.current + if (!el) return + el.stickyScroll = false + // Wheel input cancels any in-flight anchor seek — user override. + el.scrollAnchor = undefined + // Accumulate in pendingScrollDelta; renderer drains it at a capped + // rate so fast flicks show intermediate frames. Pure accumulator: + // scroll-up followed by scroll-down naturally cancels. + el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy) + scrollMutated(el) + }, + scrollToBottom() { + const el = domRef.current + if (!el) return + el.pendingScrollDelta = undefined + el.stickyScroll = true + markDirty(el) + notify() + forceRender(n => n + 1) + }, + getScrollTop() { + return domRef.current?.scrollTop ?? 0 + }, + getPendingDelta() { + // Accumulated-but-not-yet-drained delta. useVirtualScroll needs + // this to mount the union [committed, committed+pending] range — + // otherwise intermediate drain frames find no children (blank). + return domRef.current?.pendingScrollDelta ?? 0 + }, + getScrollHeight() { + return domRef.current?.scrollHeight ?? 0 + }, + getFreshScrollHeight() { + const content = domRef.current?.childNodes[0] as DOMElement | undefined + return ( + content?.yogaNode?.getComputedHeight() ?? + domRef.current?.scrollHeight ?? + 0 + ) + }, + getViewportHeight() { + return domRef.current?.scrollViewportHeight ?? 0 + }, + getViewportTop() { + return domRef.current?.scrollViewportTop ?? 0 + }, + isSticky() { + const el = domRef.current + if (!el) return false + return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']) + }, + subscribe(listener: () => void) { + listenersRef.current.add(listener) + return () => listenersRef.current.delete(listener) + }, + setClampBounds(min, max) { + const el = domRef.current + if (!el) return + el.scrollClampMin = min + el.scrollClampMax = max + }, + }), + // notify/scrollMutated are inline (no useCallback) but only close over + // refs + imports — stable. Empty deps avoids rebuilding the handle on + // every render (which re-registers the ref = churn). + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) // Structure: outer viewport (overflow:scroll, constrained height) > // inner content (flexGrow:1, flexShrink:0 — fills at least the viewport @@ -213,23 +231,28 @@ function ScrollBox({ // stickyScroll is passed as a DOM attribute (via ink-box directly) so it's // available on the first render — ref callbacks fire after the initial // commit, which is too late for the first frame. - return { - domRef.current = el; - if (el) el.scrollTop ??= 0; - }} style={{ - flexWrap: 'nowrap', - flexDirection: style.flexDirection ?? 'row', - flexGrow: style.flexGrow ?? 0, - flexShrink: style.flexShrink ?? 1, - ...style, - overflowX: 'scroll', - overflowY: 'scroll' - }} {...stickyScroll ? { - stickyScroll: true - } : {}}> + return ( + { + domRef.current = el + if (el) el.scrollTop ??= 0 + }} + style={{ + flexWrap: 'nowrap', + flexDirection: style.flexDirection ?? 'row', + flexGrow: style.flexGrow ?? 0, + flexShrink: style.flexShrink ?? 1, + ...style, + overflowX: 'scroll', + overflowY: 'scroll', + }} + {...(stickyScroll ? { stickyScroll: true } : {})} + > {children} - ; + + ) } -export default ScrollBox; + +export default ScrollBox diff --git a/src/ink/components/Spacer.tsx b/src/ink/components/Spacer.tsx index f005e0230..eb55fa9e4 100644 --- a/src/ink/components/Spacer.tsx +++ b/src/ink/components/Spacer.tsx @@ -1,19 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import Box from './Box.js'; +import React from 'react' +import Box from './Box.js' /** * A flexible space that expands along the major axis of its containing layout. * It's useful as a shortcut for filling all the available spaces between elements. */ export default function Spacer() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; + return } diff --git a/src/ink/components/TerminalFocusContext.tsx b/src/ink/components/TerminalFocusContext.tsx index 376e118a2..81dbaf60b 100644 --- a/src/ink/components/TerminalFocusContext.tsx +++ b/src/ink/components/TerminalFocusContext.tsx @@ -1,51 +1,53 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useMemo, useSyncExternalStore } from 'react'; -import { getTerminalFocused, getTerminalFocusState, subscribeTerminalFocus, type TerminalFocusState } from '../terminal-focus-state.js'; -export type { TerminalFocusState }; +import React, { createContext, useMemo, useSyncExternalStore } from 'react' +import { + getTerminalFocused, + getTerminalFocusState, + subscribeTerminalFocus, + type TerminalFocusState, +} from '../terminal-focus-state.js' + +export type { TerminalFocusState } + export type TerminalFocusContextProps = { - readonly isTerminalFocused: boolean; - readonly terminalFocusState: TerminalFocusState; -}; + readonly isTerminalFocused: boolean + readonly terminalFocusState: TerminalFocusState +} + const TerminalFocusContext = createContext({ isTerminalFocused: true, - terminalFocusState: 'unknown' -}); + terminalFocusState: 'unknown', +}) // eslint-disable-next-line custom-rules/no-top-level-side-effects -TerminalFocusContext.displayName = 'TerminalFocusContext'; +TerminalFocusContext.displayName = 'TerminalFocusContext' // Separate component so App.tsx doesn't re-render on focus changes. // Children are a stable prop reference, so they don't re-render either — // only components that consume the context will re-render. -export function TerminalFocusProvider(t0) { - const $ = _c(6); - const { - children - } = t0; - const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused); - const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState); - let t1; - if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) { - t1 = { - isTerminalFocused, - terminalFocusState - }; - $[0] = isTerminalFocused; - $[1] = terminalFocusState; - $[2] = t1; - } else { - t1 = $[2]; - } - const value = t1; - let t2; - if ($[3] !== children || $[4] !== value) { - t2 = {children}; - $[3] = children; - $[4] = value; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; +export function TerminalFocusProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + const isTerminalFocused = useSyncExternalStore( + subscribeTerminalFocus, + getTerminalFocused, + ) + const terminalFocusState = useSyncExternalStore( + subscribeTerminalFocus, + getTerminalFocusState, + ) + + const value = useMemo( + () => ({ isTerminalFocused, terminalFocusState }), + [isTerminalFocused, terminalFocusState], + ) + + return ( + + {children} + + ) } -export default TerminalFocusContext; + +export default TerminalFocusContext diff --git a/src/ink/components/TerminalSizeContext.tsx b/src/ink/components/TerminalSizeContext.tsx index 45cbf3ee8..cdf139c57 100644 --- a/src/ink/components/TerminalSizeContext.tsx +++ b/src/ink/components/TerminalSizeContext.tsx @@ -1,6 +1,8 @@ -import { createContext } from 'react'; +import { createContext } from 'react' + export type TerminalSize = { - columns: number; - rows: number; -}; -export const TerminalSizeContext = createContext(null); + columns: number + rows: number +} + +export const TerminalSizeContext = createContext(null) diff --git a/src/ink/components/Text.tsx b/src/ink/components/Text.tsx index bfec5b083..f2e2bdb77 100644 --- a/src/ink/components/Text.tsx +++ b/src/ink/components/Text.tsx @@ -1,253 +1,144 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React from 'react'; -import type { Color, Styles, TextStyles } from '../styles.js'; +import type { ReactNode } from 'react' +import React from 'react' +import type { Color, Styles, TextStyles } from '../styles.js' + type BaseProps = { /** * Change text color. Accepts a raw color value (rgb, hex, ansi). */ - readonly color?: Color; + readonly color?: Color /** * Same as `color`, but for background. */ - readonly backgroundColor?: Color; + readonly backgroundColor?: Color /** * Make the text italic. */ - readonly italic?: boolean; + readonly italic?: boolean /** * Make the text underlined. */ - readonly underline?: boolean; + readonly underline?: boolean /** * Make the text crossed with a line. */ - readonly strikethrough?: boolean; + readonly strikethrough?: boolean /** * Inverse background and foreground colors. */ - readonly inverse?: boolean; + readonly inverse?: boolean /** * This property tells Ink to wrap or truncate text if its width is larger than container. * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines. * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off. */ - readonly wrap?: Styles['textWrap']; - readonly children?: ReactNode; -}; + readonly wrap?: Styles['textWrap'] + + readonly children?: ReactNode +} /** * Bold and dim are mutually exclusive in terminals. * This type ensures you can use one or the other, but not both. */ -type WeightProps = { - bold?: never; - dim?: never; -} | { - bold: boolean; - dim?: never; -} | { - dim: boolean; - bold?: never; -}; -export type Props = BaseProps & WeightProps; +type WeightProps = + | { bold?: never; dim?: never } + | { bold: boolean; dim?: never } + | { dim: boolean; bold?: never } + +export type Props = BaseProps & WeightProps + const memoizedStylesForWrap: Record, Styles> = { wrap: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'wrap' + textWrap: 'wrap', }, 'wrap-trim': { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'wrap-trim' + textWrap: 'wrap-trim', }, end: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'end' + textWrap: 'end', }, middle: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'middle' + textWrap: 'middle', }, 'truncate-end': { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'truncate-end' + textWrap: 'truncate-end', }, truncate: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'truncate' + textWrap: 'truncate', }, 'truncate-middle': { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'truncate-middle' + textWrap: 'truncate-middle', }, 'truncate-start': { flexGrow: 0, flexShrink: 1, flexDirection: 'row', - textWrap: 'truncate-start' - } -} as const; + textWrap: 'truncate-start', + }, +} as const /** * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough. */ -export default function Text(t0) { - const $ = _c(29); - const { - color, - backgroundColor, - bold, - dim, - italic: t1, - underline: t2, - strikethrough: t3, - inverse: t4, - wrap: t5, - children - } = t0; - const italic = t1 === undefined ? false : t1; - const underline = t2 === undefined ? false : t2; - const strikethrough = t3 === undefined ? false : t3; - const inverse = t4 === undefined ? false : t4; - const wrap = t5 === undefined ? "wrap" : t5; +export default function Text({ + color, + backgroundColor, + bold, + dim, + italic = false, + underline = false, + strikethrough = false, + inverse = false, + wrap = 'wrap', + children, +}: Props): React.ReactNode { if (children === undefined || children === null) { - return null; - } - let t6; - if ($[0] !== color) { - t6 = color && { - color - }; - $[0] = color; - $[1] = t6; - } else { - t6 = $[1]; - } - let t7; - if ($[2] !== backgroundColor) { - t7 = backgroundColor && { - backgroundColor - }; - $[2] = backgroundColor; - $[3] = t7; - } else { - t7 = $[3]; + return null } - let t8; - if ($[4] !== dim) { - t8 = dim && { - dim - }; - $[4] = dim; - $[5] = t8; - } else { - t8 = $[5]; - } - let t9; - if ($[6] !== bold) { - t9 = bold && { - bold - }; - $[6] = bold; - $[7] = t9; - } else { - t9 = $[7]; - } - let t10; - if ($[8] !== italic) { - t10 = italic && { - italic - }; - $[8] = italic; - $[9] = t10; - } else { - t10 = $[9]; - } - let t11; - if ($[10] !== underline) { - t11 = underline && { - underline - }; - $[10] = underline; - $[11] = t11; - } else { - t11 = $[11]; - } - let t12; - if ($[12] !== strikethrough) { - t12 = strikethrough && { - strikethrough - }; - $[12] = strikethrough; - $[13] = t12; - } else { - t12 = $[13]; - } - let t13; - if ($[14] !== inverse) { - t13 = inverse && { - inverse - }; - $[14] = inverse; - $[15] = t13; - } else { - t13 = $[15]; - } - let t14; - if ($[16] !== t10 || $[17] !== t11 || $[18] !== t12 || $[19] !== t13 || $[20] !== t6 || $[21] !== t7 || $[22] !== t8 || $[23] !== t9) { - t14 = { - ...t6, - ...t7, - ...t8, - ...t9, - ...t10, - ...t11, - ...t12, - ...t13 - }; - $[16] = t10; - $[17] = t11; - $[18] = t12; - $[19] = t13; - $[20] = t6; - $[21] = t7; - $[22] = t8; - $[23] = t9; - $[24] = t14; - } else { - t14 = $[24]; - } - const textStyles = t14; - const t15 = memoizedStylesForWrap[wrap]; - let t16; - if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) { - t16 = {children}; - $[25] = children; - $[26] = t15; - $[27] = textStyles; - $[28] = t16; - } else { - t16 = $[28]; + + // Build textStyles object with only the properties that are set + const textStyles: TextStyles = { + ...(color && { color }), + ...(backgroundColor && { backgroundColor }), + ...(dim && { dim }), + ...(bold && { bold }), + ...(italic && { italic }), + ...(underline && { underline }), + ...(strikethrough && { strikethrough }), + ...(inverse && { inverse }), } - return t16; + + return ( + + {children} + + ) } diff --git a/src/ink/ink.tsx b/src/ink/ink.tsx index 4aa1abe1b..65bf32bd3 100644 --- a/src/ink/ink.tsx +++ b/src/ink/ink.tsx @@ -1,129 +1,195 @@ -import autoBind from 'auto-bind'; -import { closeSync, constants as fsConstants, openSync, readSync, writeSync } from 'fs'; -import noop from 'lodash-es/noop.js'; -import throttle from 'lodash-es/throttle.js'; -import React, { type ReactNode } from 'react'; -import type { FiberRoot } from 'react-reconciler'; -import { ConcurrentRoot } from 'react-reconciler/constants.js'; -import { onExit } from 'signal-exit'; -import { flushInteractionTime } from 'src/bootstrap/state.js'; -import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'; -import { logForDebugging } from 'src/utils/debug.js'; -import { logError } from 'src/utils/log.js'; -import { format } from 'util'; -import { colorize } from './colorize.js'; -import App from './components/App.js'; -import type { CursorDeclaration, CursorDeclarationSetter } from './components/CursorDeclarationContext.js'; -import { FRAME_INTERVAL_MS } from './constants.js'; -import * as dom from './dom.js'; -import { KeyboardEvent } from './events/keyboard-event.js'; -import { FocusManager } from './focus.js'; -import { emptyFrame, type Frame, type FrameEvent } from './frame.js'; -import { dispatchClick, dispatchHover } from './hit-test.js'; -import instances from './instances.js'; -import { LogUpdate } from './log-update.js'; -import { nodeCache } from './node-cache.js'; -import { optimize } from './optimizer.js'; -import Output from './output.js'; -import type { ParsedKey } from './parse-keypress.js'; -import reconciler, { dispatcher, getLastCommitMs, getLastYogaMs, isDebugRepaintsEnabled, recordYogaMs, resetProfileCounters } from './reconciler.js'; -import renderNodeToOutput, { consumeFollowScroll, didLayoutShift } from './render-node-to-output.js'; -import { applyPositionedHighlight, type MatchPosition, scanPositions } from './render-to-screen.js'; -import createRenderer, { type Renderer } from './renderer.js'; -import { CellWidth, CharPool, cellAt, createScreen, HyperlinkPool, isEmptyCellAt, migrateScreenPools, StylePool } from './screen.js'; -import { applySearchHighlight } from './searchHighlight.js'; -import { applySelectionOverlay, captureScrolledRows, clearSelection, createSelectionState, extendSelection, type FocusMove, findPlainTextUrlAt, getSelectedText, hasSelection, moveFocus, type SelectionState, selectLineAt, selectWordAt, shiftAnchor, shiftSelection, shiftSelectionForFollow, startSelection, updateSelection } from './selection.js'; -import { SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js'; -import { CURSOR_HOME, cursorMove, cursorPosition, DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, ERASE_SCREEN } from './termio/csi.js'; -import { DBP, DFE, DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN, SHOW_CURSOR } from './termio/dec.js'; -import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, setClipboard, supportsTabStatus, wrapForMultiplexer } from './termio/osc.js'; -import { TerminalWriteProvider } from './useTerminalNotification.js'; +import autoBind from 'auto-bind' +import { + closeSync, + constants as fsConstants, + openSync, + readSync, + writeSync, +} from 'fs' +import noop from 'lodash-es/noop.js' +import throttle from 'lodash-es/throttle.js' +import React, { type ReactNode } from 'react' +import type { FiberRoot } from 'react-reconciler' +import { ConcurrentRoot } from 'react-reconciler/constants.js' +import { onExit } from 'signal-exit' +import { flushInteractionTime } from 'src/bootstrap/state.js' +import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js' +import { logForDebugging } from 'src/utils/debug.js' +import { logError } from 'src/utils/log.js' +import { format } from 'util' +import { colorize } from './colorize.js' +import App from './components/App.js' +import type { + CursorDeclaration, + CursorDeclarationSetter, +} from './components/CursorDeclarationContext.js' +import { FRAME_INTERVAL_MS } from './constants.js' +import * as dom from './dom.js' +import { KeyboardEvent } from './events/keyboard-event.js' +import { FocusManager } from './focus.js' +import { emptyFrame, type Frame, type FrameEvent } from './frame.js' +import { dispatchClick, dispatchHover } from './hit-test.js' +import instances from './instances.js' +import { LogUpdate } from './log-update.js' +import { nodeCache } from './node-cache.js' +import { optimize } from './optimizer.js' +import Output from './output.js' +import type { ParsedKey } from './parse-keypress.js' +import reconciler, { + dispatcher, + getLastCommitMs, + getLastYogaMs, + isDebugRepaintsEnabled, + recordYogaMs, + resetProfileCounters, +} from './reconciler.js' +import renderNodeToOutput, { + consumeFollowScroll, + didLayoutShift, +} from './render-node-to-output.js' +import { + applyPositionedHighlight, + type MatchPosition, + scanPositions, +} from './render-to-screen.js' +import createRenderer, { type Renderer } from './renderer.js' +import { + CellWidth, + CharPool, + cellAt, + createScreen, + HyperlinkPool, + isEmptyCellAt, + migrateScreenPools, + StylePool, +} from './screen.js' +import { applySearchHighlight } from './searchHighlight.js' +import { + applySelectionOverlay, + captureScrolledRows, + clearSelection, + createSelectionState, + extendSelection, + type FocusMove, + findPlainTextUrlAt, + getSelectedText, + hasSelection, + moveFocus, + type SelectionState, + selectLineAt, + selectWordAt, + shiftAnchor, + shiftSelection, + shiftSelectionForFollow, + startSelection, + updateSelection, +} from './selection.js' +import { + SYNC_OUTPUT_SUPPORTED, + supportsExtendedKeys, + type Terminal, + writeDiffToTerminal, +} from './terminal.js' +import { + CURSOR_HOME, + cursorMove, + cursorPosition, + DISABLE_KITTY_KEYBOARD, + DISABLE_MODIFY_OTHER_KEYS, + ENABLE_KITTY_KEYBOARD, + ENABLE_MODIFY_OTHER_KEYS, + ERASE_SCREEN, +} from './termio/csi.js' +import { + DBP, + DFE, + DISABLE_MOUSE_TRACKING, + ENABLE_MOUSE_TRACKING, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, + SHOW_CURSOR, +} from './termio/dec.js' +import { + CLEAR_ITERM2_PROGRESS, + CLEAR_TAB_STATUS, + setClipboard, + supportsTabStatus, + wrapForMultiplexer, +} from './termio/osc.js' +import { TerminalWriteProvider } from './useTerminalNotification.js' // Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0, // which is always false in alt-screen (TTY + content fills screen). // Reusing a frozen object saves 1 allocation per frame. -const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ - x: 0, - y: 0, - visible: false -}); +const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ x: 0, y: 0, visible: false }) const CURSOR_HOME_PATCH = Object.freeze({ type: 'stdout' as const, - content: CURSOR_HOME -}); + content: CURSOR_HOME, +}) const ERASE_THEN_HOME_PATCH = Object.freeze({ type: 'stdout' as const, - content: ERASE_SCREEN + CURSOR_HOME -}); + content: ERASE_SCREEN + CURSOR_HOME, +}) // Cached per-Ink-instance, invalidated on resize. frame.cursor.y for // alt-screen is always terminalRows - 1 (renderer.ts). function makeAltScreenParkPatch(terminalRows: number) { return Object.freeze({ type: 'stdout' as const, - content: cursorPosition(terminalRows, 1) - }); + content: cursorPosition(terminalRows, 1), + }) } + export type Options = { - stdout: NodeJS.WriteStream; - stdin: NodeJS.ReadStream; - stderr: NodeJS.WriteStream; - exitOnCtrlC: boolean; - patchConsole: boolean; - waitUntilExit?: () => Promise; - onFrame?: (event: FrameEvent) => void; -}; + stdout: NodeJS.WriteStream + stdin: NodeJS.ReadStream + stderr: NodeJS.WriteStream + exitOnCtrlC: boolean + patchConsole: boolean + waitUntilExit?: () => Promise + onFrame?: (event: FrameEvent) => void +} + export default class Ink { - private readonly log: LogUpdate; - private readonly terminal: Terminal; - private scheduleRender: (() => void) & { - cancel?: () => void; - }; + private readonly log: LogUpdate + private readonly terminal: Terminal + private scheduleRender: (() => void) & { cancel?: () => void } // Ignore last render after unmounting a tree to prevent empty output before exit - private isUnmounted = false; - private isPaused = false; - private readonly container: FiberRoot; - private rootNode: dom.DOMElement; - readonly focusManager: FocusManager; - private renderer: Renderer; - private readonly stylePool: StylePool; - private charPool: CharPool; - private hyperlinkPool: HyperlinkPool; - private exitPromise?: Promise; - private restoreConsole?: () => void; - private restoreStderr?: () => void; - private readonly unsubscribeTTYHandlers?: () => void; - private terminalColumns: number; - private terminalRows: number; - private currentNode: ReactNode = null; - private frontFrame: Frame; - private backFrame: Frame; - private lastPoolResetTime = performance.now(); - private drainTimer: ReturnType | null = null; + private isUnmounted = false + private isPaused = false + private readonly container: FiberRoot + private rootNode: dom.DOMElement + readonly focusManager: FocusManager + private renderer: Renderer + private readonly stylePool: StylePool + private charPool: CharPool + private hyperlinkPool: HyperlinkPool + private exitPromise?: Promise + private restoreConsole?: () => void + private restoreStderr?: () => void + private readonly unsubscribeTTYHandlers?: () => void + private terminalColumns: number + private terminalRows: number + private currentNode: ReactNode = null + private frontFrame: Frame + private backFrame: Frame + private lastPoolResetTime = performance.now() + private drainTimer: ReturnType | null = null private lastYogaCounters: { - ms: number; - visited: number; - measured: number; - cacheHits: number; - live: number; - } = { - ms: 0, - visited: 0, - measured: 0, - cacheHits: 0, - live: 0 - }; - private altScreenParkPatch: Readonly<{ - type: 'stdout'; - content: string; - }>; + ms: number + visited: number + measured: number + cacheHits: number + live: number + } = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0 } + private altScreenParkPatch: Readonly<{ type: 'stdout'; content: string }> // Text selection state (alt-screen only). Owned here so the overlay // pass in onRender can read it and App.tsx can update it from mouse // events. Public so instances.get() callers can access. - readonly selection: SelectionState = createSelectionState(); + readonly selection: SelectionState = createSelectionState() // Search highlight query (alt-screen only). Setter below triggers // scheduleRender; applySearchHighlight in onRender inverts matching cells. - private searchHighlightQuery = ''; + private searchHighlightQuery = '' // Position-based highlight. VML scans positions ONCE (via // scanElementSubtree, when the target message is mounted), stores them // message-relative, sets this for every-frame apply. rowOffset = @@ -131,74 +197,88 @@ export default class Ink { // "current" (yellow). null clears. Positions are known upfront — // navigation is index arithmetic, no scan-feedback loop. private searchPositions: { - positions: MatchPosition[]; - rowOffset: number; - currentIdx: number; - } | null = null; + positions: MatchPosition[] + rowOffset: number + currentIdx: number + } | null = null // React-land subscribers for selection state changes (useHasSelection). // Fired alongside the terminal repaint whenever the selection mutates // so UI (e.g. footer hints) can react to selection appearing/clearing. - private readonly selectionListeners = new Set<() => void>(); + private readonly selectionListeners = new Set<() => void>() // DOM nodes currently under the pointer (mode-1003 motion). Held here // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // against this set and mutates it in place. - private readonly hoveredNodes = new Set(); + private readonly hoveredNodes = new Set() // Set by via setAltScreenActive(). Controls the // renderer's cursor.y clamping (keeps cursor in-viewport to avoid // LF-induced scroll when screen.height === terminalRows) and gates // alt-screen-aware SIGCONT/resize/unmount handling. - private altScreenActive = false; + private altScreenActive = false // Set alongside altScreenActive so SIGCONT resume knows whether to // re-enable mouse tracking (not all uses want it). - private altScreenMouseTracking = false; + private altScreenMouseTracking = false // True when the previous frame's screen buffer cannot be trusted for // blit — selection overlay mutated it, resetFramesForAltScreen() // replaced it with blanks, or forceRedraw() reset it to 0×0. Forces // one full-render frame; steady-state frames after clear it and regain // the blit + narrow-damage fast path. - private prevFrameContaminated = false; + private prevFrameContaminated = false // Set by handleResize: prepend ERASE_SCREEN to the next onRender's patches // INSIDE the BSU/ESU block so clear+paint is atomic. Writing ERASE_SCREEN // synchronously in handleResize would leave the screen blank for the ~80ms // render() takes; deferring into the atomic block means old content stays // visible until the new frame is fully ready. - private needsEraseBeforePaint = false; + private needsEraseBeforePaint = false // Native cursor positioning: a component (via useDeclaredCursor) declares // where the terminal cursor should be parked after each frame. Terminal // emulators render IME preedit text at the physical cursor position, and // screen readers / screen magnifiers track it — so parking at the text // input's caret makes CJK input appear inline and lets a11y tools follow. - private cursorDeclaration: CursorDeclaration | null = null; + private cursorDeclaration: CursorDeclaration | null = null // Main-screen: physical cursor position after the declared-cursor move, // tracked separately from frame.cursor (which must stay at content-bottom // for log-update's relative-move invariants). Alt-screen doesn't need // this — every frame begins with CSI H. null = no move emitted last frame. - private displayCursor: { - x: number; - y: number; - } | null = null; + private displayCursor: { x: number; y: number } | null = null + constructor(private readonly options: Options) { - autoBind(this); + autoBind(this) + if (this.options.patchConsole) { - this.restoreConsole = this.patchConsole(); - this.restoreStderr = this.patchStderr(); + this.restoreConsole = this.patchConsole() + this.restoreStderr = this.patchStderr() } + this.terminal = { stdout: options.stdout, - stderr: options.stderr - }; - this.terminalColumns = options.stdout.columns || 80; - this.terminalRows = options.stdout.rows || 24; - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows); - this.stylePool = new StylePool(); - this.charPool = new CharPool(); - this.hyperlinkPool = new HyperlinkPool(); - this.frontFrame = emptyFrame(this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool); - this.backFrame = emptyFrame(this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool); + stderr: options.stderr, + } + + this.terminalColumns = options.stdout.columns || 80 + this.terminalRows = options.stdout.rows || 24 + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + this.stylePool = new StylePool() + this.charPool = new CharPool() + this.hyperlinkPool = new HyperlinkPool() + this.frontFrame = emptyFrame( + this.terminalRows, + this.terminalColumns, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) + this.backFrame = emptyFrame( + this.terminalRows, + this.terminalColumns, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) + this.log = new LogUpdate({ - isTTY: options.stdout.isTTY as boolean | undefined || false, - stylePool: this.stylePool - }); + isTTY: (options.stdout.isTTY as boolean | undefined) || false, + stylePool: this.stylePool, + }) // scheduleRender is called from the reconciler's resetAfterCommit, which // runs BEFORE React's layout phase (ref attach + useLayoutEffect). Any @@ -209,94 +289,115 @@ export default class Ink { // a one-keystroke lag. Same event-loop tick, so throughput is unchanged. // Test env uses onImmediateRender (direct onRender, no throttle) so // existing synchronous lastFrame() tests are unaffected. - const deferredRender = (): void => queueMicrotask(this.onRender); + const deferredRender = (): void => queueMicrotask(this.onRender) this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, { leading: true, - trailing: true - }); + trailing: true, + }) // Ignore last render after unmounting a tree to prevent empty output before exit - this.isUnmounted = false; + this.isUnmounted = false // Unmount when process exits - this.unsubscribeExit = onExit(this.unmount, { - alwaysLast: false - }); + this.unsubscribeExit = onExit(this.unmount, { alwaysLast: false }) + if (options.stdout.isTTY) { - options.stdout.on('resize', this.handleResize); - process.on('SIGCONT', this.handleResume); + options.stdout.on('resize', this.handleResize) + process.on('SIGCONT', this.handleResume) + this.unsubscribeTTYHandlers = () => { - options.stdout.off('resize', this.handleResize); - process.off('SIGCONT', this.handleResume); - }; + options.stdout.off('resize', this.handleResize) + process.off('SIGCONT', this.handleResume) + } } - this.rootNode = dom.createNode('ink-root'); - this.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event)); - this.rootNode.focusManager = this.focusManager; - this.renderer = createRenderer(this.rootNode, this.stylePool); - this.rootNode.onRender = this.scheduleRender; - this.rootNode.onImmediateRender = this.onRender; + + this.rootNode = dom.createNode('ink-root') + this.focusManager = new FocusManager((target, event) => + dispatcher.dispatchDiscrete(target, event), + ) + this.rootNode.focusManager = this.focusManager + this.renderer = createRenderer(this.rootNode, this.stylePool) + this.rootNode.onRender = this.scheduleRender + this.rootNode.onImmediateRender = this.onRender this.rootNode.onComputeLayout = () => { // Calculate layout during React's commit phase so useLayoutEffect hooks // have access to fresh layout data // Guard against accessing freed Yoga nodes after unmount if (this.isUnmounted) { - return; + return } + if (this.rootNode.yogaNode) { - const t0 = performance.now(); - this.rootNode.yogaNode.setWidth(this.terminalColumns); - this.rootNode.yogaNode.calculateLayout(this.terminalColumns); - const ms = performance.now() - t0; - recordYogaMs(ms); - const c = getYogaCounters(); - this.lastYogaCounters = { - ms, - ...c - }; + const t0 = performance.now() + this.rootNode.yogaNode.setWidth(this.terminalColumns) + this.rootNode.yogaNode.calculateLayout(this.terminalColumns) + const ms = performance.now() - t0 + recordYogaMs(ms) + const c = getYogaCounters() + this.lastYogaCounters = { ms, ...c } } - }; - - this.container = reconciler.createContainer(this.rootNode, ConcurrentRoot, null, false, null, 'id', noop, - // onUncaughtError - noop, - // onCaughtError - noop, - // onRecoverableError - noop // onDefaultTransitionIndicator - ); - if (("production" as string) === 'development') { + } + + // @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks, + // but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks) + this.container = reconciler.createContainer( + this.rootNode, + ConcurrentRoot, + null, + false, + null, + 'id', + noop, // onUncaughtError + noop, // onCaughtError + noop, // onRecoverableError + noop, // onDefaultTransitionIndicator + ) + + if ("production" === 'development') { reconciler.injectIntoDevTools({ bundleType: 0, // Reporting React DOM's version, not Ink's // See https://github.com/facebook/react/issues/16666#issuecomment-532639905 version: '16.13.1', - rendererPackageName: 'ink' - }); + rendererPackageName: 'ink', + }) } } + private handleResume = () => { if (!this.options.stdout.isTTY) { - return; + return } // Alt screen: after SIGCONT, content is stale (shell may have written // to main screen, switching focus away) and mouse tracking was // disabled by handleSuspend. if (this.altScreenActive) { - this.reenterAltScreen(); - return; + this.reenterAltScreen() + return } // Main screen: start fresh to prevent clobbering terminal content - this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool); - this.backFrame = emptyFrame(this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool); - this.log.reset(); + this.frontFrame = emptyFrame( + this.frontFrame.viewport.height, + this.frontFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) + this.backFrame = emptyFrame( + this.backFrame.viewport.height, + this.backFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) + this.log.reset() // Physical cursor position is unknown after the shell took over during // suspend. Clear displayCursor so the next frame's cursor preamble // doesn't emit a relative move from a stale park position. - this.displayCursor = null; - }; + this.displayCursor = null + } // NOT debounced. A debounce opens a window where stdout.columns is NEW // but this.terminalColumns/Yoga are OLD — any scheduleRender during that @@ -305,15 +406,15 @@ export default class Ink { // blank→paint flicker). useVirtualScroll's height scaling already bounds // the per-resize cost; synchronous handling keeps dimensions consistent. private handleResize = () => { - const cols = this.options.stdout.columns || 80; - const rows = this.options.stdout.rows || 24; + const cols = this.options.stdout.columns || 80 + const rows = this.options.stdout.rows || 24 // Terminals often emit 2+ resize events for one user action (window // settling). Same-dimension events are no-ops; skip to avoid redundant // frame resets and renders. - if (cols === this.terminalColumns && rows === this.terminalRows) return; - this.terminalColumns = cols; - this.terminalRows = rows; - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows); + if (cols === this.terminalColumns && rows === this.terminalRows) return + this.terminalColumns = cols + this.terminalRows = rows + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) // Alt screen: reset frame buffers so the next render repaints from // scratch (prevFrameContaminated → every cell written, wrapped in @@ -327,10 +428,10 @@ export default class Ink { // can take ~80ms; erasing first leaves the screen blank that whole time. if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) { if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING); + this.options.stdout.write(ENABLE_MOUSE_TRACKING) } - this.resetFramesForAltScreen(); - this.needsEraseBeforePaint = true; + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true } // Re-render the React tree with updated props so the context value changes. @@ -339,12 +440,13 @@ export default class Ink { // We don't call scheduleRender() here because that would render before the // layout is updated, causing a mismatch between viewport and content dimensions. if (this.currentNode !== null) { - this.render(this.currentNode); + this.render(this.currentNode) } - }; - resolveExitPromise: () => void = () => {}; - rejectExitPromise: (reason?: Error) => void = () => {}; - unsubscribeExit: () => void = () => {}; + } + + resolveExitPromise: () => void = () => {} + rejectExitPromise: (reason?: Error) => void = () => {} + unsubscribeExit: () => void = () => {} /** * Pause Ink and hand the terminal over to an external TUI (e.g. git @@ -353,26 +455,22 @@ export default class Ink { * Call `exitAlternateScreen()` when done to restore Ink. */ enterAlternateScreen(): void { - this.pause(); - this.suspendStdin(); + this.pause() + this.suspendStdin() this.options.stdout.write( - // Disable extended key reporting first — editors that don't speak - // CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl- if - // kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables. - DISABLE_KITTY_KEYBOARD + DISABLE_MODIFY_OTHER_KEYS + (this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') + ( - // disable mouse (no-op if off) - this.altScreenActive ? '' : '\x1b[?1049h') + - // enter alt (already in alt if fullscreen) - '\x1b[?1004l' + - // disable focus reporting - '\x1b[0m' + - // reset attributes - '\x1b[?25h' + - // show cursor - '\x1b[2J' + - // clear screen - '\x1b[H' // cursor home - ); + // Disable extended key reporting first — editors that don't speak + // CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl- if + // kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables. + DISABLE_KITTY_KEYBOARD + + DISABLE_MODIFY_OTHER_KEYS + + (this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') + // disable mouse (no-op if off) + (this.altScreenActive ? '' : '\x1b[?1049h') + // enter alt (already in alt if fullscreen) + '\x1b[?1004l' + // disable focus reporting + '\x1b[0m' + // reset attributes + '\x1b[?25h' + // show cursor + '\x1b[2J' + // clear screen + '\x1b[H', // cursor home + ) } /** @@ -388,53 +486,59 @@ export default class Ink { * returns, fullscreen scroll is dead. */ exitAlternateScreen(): void { - this.options.stdout.write((this.altScreenActive ? ENTER_ALT_SCREEN : '') + - // re-enter alt — vim's rmcup dropped us to main - '\x1b[2J' + - // clear screen (now alt if fullscreen) - '\x1b[H' + ( - // cursor home - this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + ( - // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE) - this.altScreenActive ? '' : '\x1b[?1049l') + - // exit alt (non-fullscreen only) - '\x1b[?25l' // hide cursor (Ink manages) - ); - this.resumeStdin(); + this.options.stdout.write( + (this.altScreenActive ? ENTER_ALT_SCREEN : '') + // re-enter alt — vim's rmcup dropped us to main + '\x1b[2J' + // clear screen (now alt if fullscreen) + '\x1b[H' + // cursor home + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE) + (this.altScreenActive ? '' : '\x1b[?1049l') + // exit alt (non-fullscreen only) + '\x1b[?25l', // hide cursor (Ink manages) + ) + this.resumeStdin() if (this.altScreenActive) { - this.resetFramesForAltScreen(); + this.resetFramesForAltScreen() } else { - this.repaint(); + this.repaint() } - this.resume(); + this.resume() // Re-enable focus reporting and extended key reporting — terminal // editors (vim, nano, etc.) write their own modifyOtherKeys level on // entry and reset it on exit, leaving us unable to distinguish // ctrl+shift+ from ctrl+. Pop-before-push keeps the // Kitty stack balanced (a well-behaved editor restores our entry, so // without the pop we'd accumulate depth on each editor round-trip). - this.options.stdout.write('\x1b[?1004h' + (supportsExtendedKeys() ? DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS : '')); + this.options.stdout.write( + '\x1b[?1004h' + + (supportsExtendedKeys() + ? DISABLE_KITTY_KEYBOARD + + ENABLE_KITTY_KEYBOARD + + ENABLE_MODIFY_OTHER_KEYS + : ''), + ) } + onRender() { if (this.isUnmounted || this.isPaused) { - return; + return } // Entering a render cancels any pending drain tick — this render will // handle the drain (and re-schedule below if needed). Prevents a // wheel-event-triggered render AND a drain-timer render both firing. if (this.drainTimer !== null) { - clearTimeout(this.drainTimer); - this.drainTimer = null; + clearTimeout(this.drainTimer) + this.drainTimer = null } // Flush deferred interaction-time update before rendering so we call // Date.now() at most once per frame instead of once per keypress. // Done before the render to avoid dirtying state that would trigger // an extra React re-render cycle. - flushInteractionTime(); - const renderStart = performance.now(); - const terminalWidth = this.options.stdout.columns || 80; - const terminalRows = this.options.stdout.rows || 24; + flushInteractionTime() + + const renderStart = performance.now() + const terminalWidth = this.options.stdout.columns || 80 + const terminalRows = this.options.stdout.rows || 24 + const frame = this.renderer({ frontFrame: this.frontFrame, backFrame: this.backFrame, @@ -442,9 +546,9 @@ export default class Ink { terminalWidth, terminalRows, altScreen: this.altScreenActive, - prevFrameContaminated: this.prevFrameContaminated - }); - const rendererMs = performance.now() - renderStart; + prevFrameContaminated: this.prevFrameContaminated, + }) + const rendererMs = performance.now() - renderStart // Sticky/auto-follow scrolled the ScrollBox this frame. Translate the // selection by the same delta so the highlight stays anchored to the @@ -457,20 +561,20 @@ export default class Ink { // (screen-local) so only anchor shifts — selection grows toward the // mouse as the anchor walks up. After release, both ends are text- // anchored and move as a block. - const follow = consumeFollowScroll(); - if (follow && this.selection.anchor && - // Only translate if the selection is ON scrollbox content. Selections - // in the footer/prompt/StickyPromptHeader are on static text — the - // scroll doesn't move what's under them. Without this guard, a - // footer selection would be shifted by -delta then clamped to - // viewportBottom, teleporting it into the scrollbox. Mirror the - // bounds check the deleted check() in ScrollKeybindingHandler had. - this.selection.anchor.row >= follow.viewportTop && this.selection.anchor.row <= follow.viewportBottom) { - const { - delta, - viewportTop, - viewportBottom - } = follow; + const follow = consumeFollowScroll() + if ( + follow && + this.selection.anchor && + // Only translate if the selection is ON scrollbox content. Selections + // in the footer/prompt/StickyPromptHeader are on static text — the + // scroll doesn't move what's under them. Without this guard, a + // footer selection would be shifted by -delta then clamped to + // viewportBottom, teleporting it into the scrollbox. Mirror the + // bounds check the deleted check() in ScrollKeybindingHandler had. + this.selection.anchor.row >= follow.viewportTop && + this.selection.anchor.row <= follow.viewportBottom + ) { + const { delta, viewportTop, viewportBottom } = follow // captureScrolledRows and shift* are a pair: capture grabs rows about // to scroll off, shift moves the selection endpoint so the same rows // won't intersect again next frame. Capturing without shifting leaves @@ -480,33 +584,53 @@ export default class Ink { // each shift branch so the pairing can't be broken by a new guard. if (this.selection.isDragging) { if (hasSelection(this.selection)) { - captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above'); + captureScrolledRows( + this.selection, + this.frontFrame.screen, + viewportTop, + viewportTop + delta - 1, + 'above', + ) } - shiftAnchor(this.selection, -delta, viewportTop, viewportBottom); + shiftAnchor(this.selection, -delta, viewportTop, viewportBottom) } else if ( - // Flag-3 guard: the anchor check above only proves ONE endpoint is - // on scrollbox content. A drag from row 3 (scrollbox) into the - // footer at row 6, then release, leaves focus outside the viewport - // — shiftSelectionForFollow would clamp it to viewportBottom, - // teleporting the highlight from static footer into the scrollbox. - // Symmetric check: require BOTH ends inside to translate. A - // straddling selection falls through to NEITHER shift NOR capture: - // the footer endpoint pins the selection, text scrolls away under - // the highlight, and getSelectedText reads the CURRENT screen - // contents — no accumulation. Dragging branch doesn't need this: - // shiftAnchor ignores focus, and the anchor DOES shift (so capture - // is correct there even when focus is in the footer). - !this.selection.focus || this.selection.focus.row >= viewportTop && this.selection.focus.row <= viewportBottom) { + // Flag-3 guard: the anchor check above only proves ONE endpoint is + // on scrollbox content. A drag from row 3 (scrollbox) into the + // footer at row 6, then release, leaves focus outside the viewport + // — shiftSelectionForFollow would clamp it to viewportBottom, + // teleporting the highlight from static footer into the scrollbox. + // Symmetric check: require BOTH ends inside to translate. A + // straddling selection falls through to NEITHER shift NOR capture: + // the footer endpoint pins the selection, text scrolls away under + // the highlight, and getSelectedText reads the CURRENT screen + // contents — no accumulation. Dragging branch doesn't need this: + // shiftAnchor ignores focus, and the anchor DOES shift (so capture + // is correct there even when focus is in the footer). + !this.selection.focus || + (this.selection.focus.row >= viewportTop && + this.selection.focus.row <= viewportBottom) + ) { if (hasSelection(this.selection)) { - captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above'); + captureScrolledRows( + this.selection, + this.frontFrame.screen, + viewportTop, + viewportTop + delta - 1, + 'above', + ) } - const cleared = shiftSelectionForFollow(this.selection, -delta, viewportTop, viewportBottom); + const cleared = shiftSelectionForFollow( + this.selection, + -delta, + viewportTop, + viewportBottom, + ) // Auto-clear (both ends overshot minRow) must notify React-land // so useHasSelection re-renders and the footer copy/escape hint // disappears. notifySelectionChange() would recurse into onRender; // fire the listeners directly — they schedule a React update for // LATER, they don't re-enter this frame. - if (cleared) for (const cb of this.selectionListeners) cb(); + if (cleared) for (const cb of this.selectionListeners) cb() } } @@ -529,23 +653,33 @@ export default class Ink { // which doesn't track damage, and prev-frame overlay cells need to be // compared when selection moves/clears. prevFrameContaminated covers // the frame-after-selection-clears case. - let selActive = false; - let hlActive = false; + let selActive = false + let hlActive = false if (this.altScreenActive) { - selActive = hasSelection(this.selection); + selActive = hasSelection(this.selection) if (selActive) { - applySelectionOverlay(frame.screen, this.selection, this.stylePool); + applySelectionOverlay(frame.screen, this.selection, this.stylePool) } // Scan-highlight: inverse on ALL visible matches (less/vim style). // Position-highlight (below) overlays CURRENT (yellow) on top. - hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool); + hlActive = applySearchHighlight( + frame.screen, + this.searchHighlightQuery, + this.stylePool, + ) // Position-based CURRENT: write yellow at positions[currentIdx] + // rowOffset. No scanning — positions came from a prior scan when // the message first mounted. Message-relative + rowOffset = screen. if (this.searchPositions) { - const sp = this.searchPositions; - const posApplied = applyPositionedHighlight(frame.screen, this.stylePool, sp.positions, sp.rowOffset, sp.currentIdx); - hlActive = hlActive || posApplied; + const sp = this.searchPositions + const posApplied = applyPositionedHighlight( + frame.screen, + this.stylePool, + sp.positions, + sp.rowOffset, + sp.currentIdx, + ) + hlActive = hlActive || posApplied } } @@ -554,13 +688,18 @@ export default class Ink { // cells at sibling boundaries that per-node damage tracking misses. // Selection/highlight overlays write via setCellStyleId which doesn't // track damage. prevFrameContaminated covers the cleanup frame. - if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) { + if ( + didLayoutShift() || + selActive || + hlActive || + this.prevFrameContaminated + ) { frame.screen.damage = { x: 0, y: 0, width: frame.screen.width, - height: frame.screen.height - }; + height: frame.screen.height, + } } // Alt-screen: anchor the physical cursor to (0,0) before every diff. @@ -573,52 +712,63 @@ export default class Ink { // can't do this — cursor.y tracks scrollback rows CSI H can't reach. // The CSI H write is deferred until after the diff is computed so we // can skip it for empty diffs (no writes → physical cursor unused). - let prevFrame = this.frontFrame; + let prevFrame = this.frontFrame if (this.altScreenActive) { - prevFrame = { - ...this.frontFrame, - cursor: ALT_SCREEN_ANCHOR_CURSOR - }; + prevFrame = { ...this.frontFrame, cursor: ALT_SCREEN_ANCHOR_CURSOR } } - const tDiff = performance.now(); - const diff = this.log.render(prevFrame, frame, this.altScreenActive, - // DECSTBM needs BSU/ESU atomicity — without it the outer terminal - // renders the scrolled-but-not-yet-repainted intermediate state. - // tmux is the main case (re-emits DECSTBM with its own timing and - // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). - SYNC_OUTPUT_SUPPORTED); - const diffMs = performance.now() - tDiff; + + const tDiff = performance.now() + const diff = this.log.render( + prevFrame, + frame, + this.altScreenActive, + // DECSTBM needs BSU/ESU atomicity — without it the outer terminal + // renders the scrolled-but-not-yet-repainted intermediate state. + // tmux is the main case (re-emits DECSTBM with its own timing and + // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). + SYNC_OUTPUT_SUPPORTED, + ) + const diffMs = performance.now() - tDiff // Swap buffers - this.backFrame = this.frontFrame; - this.frontFrame = frame; + this.backFrame = this.frontFrame + this.frontFrame = frame // Periodically reset char/hyperlink pools to prevent unbounded growth // during long sessions. 5 minutes is infrequent enough that the O(cells) // migration cost is negligible. Reuses renderStart to avoid extra clock call. if (renderStart - this.lastPoolResetTime > 5 * 60 * 1000) { - this.resetPools(); - this.lastPoolResetTime = renderStart; + this.resetPools() + this.lastPoolResetTime = renderStart } - const flickers: FrameEvent['flickers'] = []; + + const flickers: FrameEvent['flickers'] = [] for (const patch of diff) { if (patch.type === 'clearTerminal') { flickers.push({ desiredHeight: frame.screen.height, availableHeight: frame.viewport.height, - reason: patch.reason - }); + reason: patch.reason, + }) if (isDebugRepaintsEnabled() && patch.debug) { - const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY); - logForDebugging(`[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + ` prev: "${patch.debug.prevLine}"\n` + ` next: "${patch.debug.nextLine}"\n` + ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, { - level: 'warn' - }); + const chain = dom.findOwnerChainAtRow( + this.rootNode, + patch.debug.triggerY, + ) + logForDebugging( + `[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + + ` prev: "${patch.debug.prevLine}"\n` + + ` next: "${patch.debug.nextLine}"\n` + + ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, + { level: 'warn' }, + ) } } } - const tOptimize = performance.now(); - const optimized = optimize(diff); - const optimizeMs = performance.now() - tOptimize; - const hasDiff = optimized.length > 0; + + const tOptimize = performance.now() + const optimized = optimize(diff) + const optimizeMs = performance.now() - tOptimize + const hasDiff = optimized.length > 0 if (this.altScreenActive && hasDiff) { // Prepend CSI H to anchor the physical cursor to (0,0) so // log-update's relative moves compute from a known spot (self-healing @@ -640,12 +790,12 @@ export default class Ink { // synchronously in handleResize would blank the screen for the ~80ms // render() takes. if (this.needsEraseBeforePaint) { - this.needsEraseBeforePaint = false; - optimized.unshift(ERASE_THEN_HOME_PATCH); + this.needsEraseBeforePaint = false + optimized.unshift(ERASE_THEN_HOME_PATCH) } else { - optimized.unshift(CURSOR_HOME_PATCH); + optimized.unshift(CURSOR_HOME_PATCH) } - optimized.push(this.altScreenParkPatch); + optimized.push(this.altScreenParkPatch) } // Native cursor positioning: park the terminal cursor at the declared @@ -655,60 +805,54 @@ export default class Ink { // translation) — if the declared node didn't render (stale declaration // after remount, or scrolled out of view), it won't be in the cache // and no move is emitted. - const decl = this.cursorDeclaration; - const rect = decl !== null ? nodeCache.get(decl.node) : undefined; - const target = decl !== null && rect !== undefined ? { - x: rect.x + decl.relativeX, - y: rect.y + decl.relativeY - } : null; - const parked = this.displayCursor; + const decl = this.cursorDeclaration + const rect = decl !== null ? nodeCache.get(decl.node) : undefined + const target = + decl !== null && rect !== undefined + ? { x: rect.x + decl.relativeX, y: rect.y + decl.relativeY } + : null + const parked = this.displayCursor // Preserve the empty-diff zero-write fast path: skip all cursor writes // when nothing rendered AND the park target is unchanged. - const targetMoved = target !== null && (parked === null || parked.x !== target.x || parked.y !== target.y); - if (hasDiff || targetMoved || target === null && parked !== null) { + const targetMoved = + target !== null && + (parked === null || parked.x !== target.x || parked.y !== target.y) + if (hasDiff || targetMoved || (target === null && parked !== null)) { // Main-screen preamble: log-update's relative moves assume the // physical cursor is at prevFrame.cursor. If last frame parked it // elsewhere, move back before the diff runs. Alt-screen's CSI H // already resets to (0,0) so no preamble needed. if (parked !== null && !this.altScreenActive && hasDiff) { - const pdx = prevFrame.cursor.x - parked.x; - const pdy = prevFrame.cursor.y - parked.y; + const pdx = prevFrame.cursor.x - parked.x + const pdy = prevFrame.cursor.y - parked.y if (pdx !== 0 || pdy !== 0) { - optimized.unshift({ - type: 'stdout', - content: cursorMove(pdx, pdy) - }); + optimized.unshift({ type: 'stdout', content: cursorMove(pdx, pdy) }) } } + if (target !== null) { if (this.altScreenActive) { // Absolute CUP (1-indexed); next frame's CSI H resets regardless. // Emitted after altScreenParkPatch so the declared position wins. - const row = Math.min(Math.max(target.y + 1, 1), terminalRows); - const col = Math.min(Math.max(target.x + 1, 1), terminalWidth); - optimized.push({ - type: 'stdout', - content: cursorPosition(row, col) - }); + const row = Math.min(Math.max(target.y + 1, 1), terminalRows) + const col = Math.min(Math.max(target.x + 1, 1), terminalWidth) + optimized.push({ type: 'stdout', content: cursorPosition(row, col) }) } else { // After the diff (or preamble), cursor is at frame.cursor. If no // diff AND previously parked, it's still at the old park position // (log-update wrote nothing). Otherwise it's at frame.cursor. - const from = !hasDiff && parked !== null ? parked : { - x: frame.cursor.x, - y: frame.cursor.y - }; - const dx = target.x - from.x; - const dy = target.y - from.y; + const from = + !hasDiff && parked !== null + ? parked + : { x: frame.cursor.x, y: frame.cursor.y } + const dx = target.x - from.x + const dy = target.y - from.y if (dx !== 0 || dy !== 0) { - optimized.push({ - type: 'stdout', - content: cursorMove(dx, dy) - }); + optimized.push({ type: 'stdout', content: cursorMove(dx, dy) }) } } - this.displayCursor = target; + this.displayCursor = target } else { // Declaration cleared (input blur, unmount). Restore physical cursor // to frame.cursor before forgetting the park position — otherwise @@ -718,27 +862,29 @@ export default class Ink { // !hasDiff (e.g. accessibility mode where blur doesn't change // renderedValue since invert is identity). if (parked !== null && !this.altScreenActive && !hasDiff) { - const rdx = frame.cursor.x - parked.x; - const rdy = frame.cursor.y - parked.y; + const rdx = frame.cursor.x - parked.x + const rdy = frame.cursor.y - parked.y if (rdx !== 0 || rdy !== 0) { - optimized.push({ - type: 'stdout', - content: cursorMove(rdx, rdy) - }); + optimized.push({ type: 'stdout', content: cursorMove(rdx, rdy) }) } } - this.displayCursor = null; + this.displayCursor = null } } - const tWrite = performance.now(); - writeDiffToTerminal(this.terminal, optimized, this.altScreenActive && !SYNC_OUTPUT_SUPPORTED); - const writeMs = performance.now() - tWrite; + + const tWrite = performance.now() + writeDiffToTerminal( + this.terminal, + optimized, + this.altScreenActive && !SYNC_OUTPUT_SUPPORTED, + ) + const writeMs = performance.now() - tWrite // Update blit safety for the NEXT frame. The frame just rendered // becomes frontFrame (= next frame's prevScreen). If we applied the // selection overlay, that buffer has inverted cells. selActive/hlActive // are only ever true in alt-screen; in main-screen this is false→false. - this.prevFrameContaminated = selActive || hlActive; + this.prevFrameContaminated = selActive || hlActive // A ScrollBox has pendingScrollDelta left to drain — schedule the next // frame. MUST NOT call this.scheduleRender() here: we're inside a @@ -753,20 +899,24 @@ export default class Ink { // quarter interval (~250fps, setTimeout practical floor) for max scroll // speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle. if (frame.scrollDrainPending) { - this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2); + this.drainTimer = setTimeout( + () => this.onRender(), + FRAME_INTERVAL_MS >> 2, + ) } - const yogaMs = getLastYogaMs(); - const commitMs = getLastCommitMs(); - const yc = this.lastYogaCounters; + + const yogaMs = getLastYogaMs() + const commitMs = getLastCommitMs() + const yc = this.lastYogaCounters // Reset so drain-only frames (no React commit) don't repeat stale values. - resetProfileCounters(); + resetProfileCounters() this.lastYogaCounters = { ms: 0, visited: 0, measured: 0, cacheHits: 0, - live: 0 - }; + live: 0, + } this.options.onFrame?.({ durationMs: performance.now() - renderStart, phases: { @@ -780,20 +930,24 @@ export default class Ink { yogaVisited: yc.visited, yogaMeasured: yc.measured, yogaCacheHits: yc.cacheHits, - yogaLive: yc.live + yogaLive: yc.live, }, - flickers - }); + flickers, + }) } + pause(): void { // Flush pending React updates and render before pausing. - reconciler.flushSyncFromReconciler(); - this.onRender(); - this.isPaused = true; + // @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler + reconciler.flushSyncFromReconciler() + this.onRender() + + this.isPaused = true } + resume(): void { - this.isPaused = false; - this.onRender(); + this.isPaused = false + this.onRender() } /** @@ -802,13 +956,25 @@ export default class Ink { * an external process (e.g. tmux, shell, full-screen TUI). */ repaint(): void { - this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool); - this.backFrame = emptyFrame(this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool); - this.log.reset(); + this.frontFrame = emptyFrame( + this.frontFrame.viewport.height, + this.frontFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) + this.backFrame = emptyFrame( + this.backFrame.viewport.height, + this.backFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) + this.log.reset() // Physical cursor position is unknown after external terminal corruption. // Clear displayCursor so the cursor preamble doesn't emit a stale // relative move from where we last parked it. - this.displayCursor = null; + this.displayCursor = null } /** @@ -820,18 +986,18 @@ export default class Ink { * unchanged cells don't need repainting. Scrollback is preserved. */ forceRedraw(): void { - if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return; - this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME); + if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return + this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME) if (this.altScreenActive) { - this.resetFramesForAltScreen(); + this.resetFramesForAltScreen() } else { - this.repaint(); + this.repaint() // repaint() resets frontFrame to 0×0. Without this flag the next // frame's blit optimization copies from that empty screen and the // diff sees no content. onRender resets the flag at frame end. - this.prevFrameContaminated = true; + this.prevFrameContaminated = true } - this.onRender(); + this.onRender() } /** @@ -845,7 +1011,7 @@ export default class Ink { * onRender resets the flag at frame end so it's one-shot. */ invalidatePrevFrame(): void { - this.prevFrameContaminated = true; + this.prevFrameContaminated = true } /** @@ -856,17 +1022,18 @@ export default class Ink { * a full redraw with no stale diff state. */ setAltScreenActive(active: boolean, mouseTracking = false): void { - if (this.altScreenActive === active) return; - this.altScreenActive = active; - this.altScreenMouseTracking = active && mouseTracking; + if (this.altScreenActive === active) return + this.altScreenActive = active + this.altScreenMouseTracking = active && mouseTracking if (active) { - this.resetFramesForAltScreen(); + this.resetFramesForAltScreen() } else { - this.repaint(); + this.repaint() } } + get isAltScreenActive(): boolean { - return this.altScreenActive; + return this.altScreenActive } /** @@ -891,29 +1058,33 @@ export default class Ink { * handleResize. */ reassertTerminalModes = (includeAltScreen = false): void => { - if (!this.options.stdout.isTTY) return; + if (!this.options.stdout.isTTY) return // Don't touch the terminal during an editor handoff — re-enabling kitty // keyboard here would undo enterAlternateScreen's disable and nano would // start seeing CSI-u sequences again. - if (this.isPaused) return; + if (this.isPaused) return // Extended keys — re-assert if enabled (App.tsx enables these on // allowlisted terminals at raw-mode entry; a terminal reset clears them). // Pop-before-push keeps Kitty stack depth at 1 instead of accumulating // on each call. if (supportsExtendedKeys()) { - this.options.stdout.write(DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS); + this.options.stdout.write( + DISABLE_KITTY_KEYBOARD + + ENABLE_KITTY_KEYBOARD + + ENABLE_MODIFY_OTHER_KEYS, + ) } - if (!this.altScreenActive) return; + if (!this.altScreenActive) return // Mouse tracking — idempotent, safe to re-assert on every stdin gap. if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING); + this.options.stdout.write(ENABLE_MOUSE_TRACKING) } // Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that // have a strong signal the terminal actually dropped mode 1049. if (includeAltScreen) { - this.reenterAltScreen(); + this.reenterAltScreen() } - }; + } /** * Mark this instance as unmounted so future unmount() calls early-return. @@ -927,28 +1098,28 @@ export default class Ink { * as restoring the saved cursor position — clobbering the resume hint. */ detachForShutdown(): void { - this.isUnmounted = true; + this.isUnmounted = true // Cancel any pending throttled render so it doesn't fire between // cleanupTerminalModes() and process.exit() and write to main screen. - this.scheduleRender.cancel?.(); + this.scheduleRender.cancel?.() // Restore stdin from raw mode. unmount() used to do this via React // unmount (App.componentWillUnmount → handleSetRawMode(false)) but we're // short-circuiting that path. Must use this.options.stdin — NOT // process.stdin — because getStdinOverride() may have opened /dev/tty // when stdin is piped. const stdin = this.options.stdin as NodeJS.ReadStream & { - isRaw?: boolean; - setRawMode?: (m: boolean) => void; - }; - this.drainStdin(); + isRaw?: boolean + setRawMode?: (m: boolean) => void + } + this.drainStdin() if (stdin.isTTY && stdin.isRaw && stdin.setRawMode) { - stdin.setRawMode(false); + stdin.setRawMode(false) } } /** @see drainStdin */ drainStdin(): void { - drainStdin(this.options.stdin); + drainStdin(this.options.stdin) } /** @@ -959,8 +1130,13 @@ export default class Ink { * stays true. ENTER_ALT_SCREEN is a terminal-side no-op if already in alt. */ private reenterAltScreen(): void { - this.options.stdout.write(ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '')); - this.resetFramesForAltScreen(); + this.options.stdout.write( + ENTER_ALT_SCREEN + + ERASE_SCREEN + + CURSOR_HOME + + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''), + ) + this.resetFramesForAltScreen() } /** @@ -979,30 +1155,29 @@ export default class Ink { * matches the physical cursor after ENTER_ALT_SCREEN + CSI H (home). */ private resetFramesForAltScreen(): void { - const rows = this.terminalRows; - const cols = this.terminalColumns; + const rows = this.terminalRows + const cols = this.terminalColumns const blank = (): Frame => ({ - screen: createScreen(cols, rows, this.stylePool, this.charPool, this.hyperlinkPool), - viewport: { - width: cols, - height: rows + 1 - }, - cursor: { - x: 0, - y: 0, - visible: true - } - }); - this.frontFrame = blank(); - this.backFrame = blank(); - this.log.reset(); + screen: createScreen( + cols, + rows, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ), + viewport: { width: cols, height: rows + 1 }, + cursor: { x: 0, y: 0, visible: true }, + }) + this.frontFrame = blank() + this.backFrame = blank() + this.log.reset() // Defense-in-depth: alt-screen skips the cursor preamble anyway (CSI H // resets), but a stale displayCursor would be misleading if we later // exit to main-screen without an intervening render. - this.displayCursor = null; + this.displayCursor = null // Fresh frontFrame is blank rows×cols — blitting from it would copy // blanks over content. Next alt-screen frame must full-render. - this.prevFrameContaminated = true; + this.prevFrameContaminated = true } /** @@ -1011,16 +1186,16 @@ export default class Ink { * region stays visible after the automatic copy. */ copySelectionNoClear(): string { - if (!hasSelection(this.selection)) return ''; - const text = getSelectedText(this.selection, this.frontFrame.screen); + if (!hasSelection(this.selection)) return '' + const text = getSelectedText(this.selection, this.frontFrame.screen) if (text) { // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux // drops it silently unless allow-passthrough is on — no regression). void setClipboard(text).then(raw => { - if (raw) this.options.stdout.write(raw); - }); + if (raw) this.options.stdout.write(raw) + }) } - return text; + return text } /** @@ -1028,18 +1203,18 @@ export default class Ink { * and clear the selection. Returns the copied text (empty if no selection). */ copySelection(): string { - if (!hasSelection(this.selection)) return ''; - const text = this.copySelectionNoClear(); - clearSelection(this.selection); - this.notifySelectionChange(); - return text; + if (!hasSelection(this.selection)) return '' + const text = this.copySelectionNoClear() + clearSelection(this.selection) + this.notifySelectionChange() + return text } /** Clear the current text selection without copying. */ clearTextSelection(): void { - if (!hasSelection(this.selection)) return; - clearSelection(this.selection); - this.notifySelectionChange(); + if (!hasSelection(this.selection)) return + clearSelection(this.selection) + this.notifySelectionChange() } /** @@ -1050,9 +1225,9 @@ export default class Ink { * damage, so the overlay forces full-frame damage while active. */ setSearchHighlight(query: string): void { - if (this.searchHighlightQuery === query) return; - this.searchHighlightQuery = query; - this.scheduleRender(); + if (this.searchHighlightQuery === query) return + this.searchHighlightQuery = query + this.scheduleRender() } /** Paint an EXISTING DOM subtree to a fresh Screen at its natural @@ -1066,35 +1241,49 @@ export default class Ink { * * ~1-2ms (paint only, no reconcile — the DOM is already built). */ scanElementSubtree(el: dom.DOMElement): MatchPosition[] { - if (!this.searchHighlightQuery || !el.yogaNode) return []; - const width = Math.ceil(el.yogaNode.getComputedWidth()); - const height = Math.ceil(el.yogaNode.getComputedHeight()); - if (width <= 0 || height <= 0) return []; + if (!this.searchHighlightQuery || !el.yogaNode) return [] + const width = Math.ceil(el.yogaNode.getComputedWidth()) + const height = Math.ceil(el.yogaNode.getComputedHeight()) + if (width <= 0 || height <= 0) return [] // renderNodeToOutput adds el's OWN computedLeft/Top to offsetX/Y. // Passing -elLeft/-elTop nets to 0 → paints at (0,0) in our buffer. - const elLeft = el.yogaNode.getComputedLeft(); - const elTop = el.yogaNode.getComputedTop(); - const screen = createScreen(width, height, this.stylePool, this.charPool, this.hyperlinkPool); + const elLeft = el.yogaNode.getComputedLeft() + const elTop = el.yogaNode.getComputedTop() + const screen = createScreen( + width, + height, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) const output = new Output({ width, height, stylePool: this.stylePool, - screen - }); + screen, + }) renderNodeToOutput(el, output, { offsetX: -elLeft, offsetY: -elTop, - prevScreen: undefined - }); - const rendered = output.get(); + prevScreen: undefined, + }) + const rendered = output.get() // renderNodeToOutput wrote our offset positions to nodeCache — // corrupts the main render (it'd blit from wrong coords). Mark the // subtree dirty so the next main render repaints + re-caches // correctly. One extra paint of this message, but correct > fast. - dom.markDirty(el); - const positions = scanPositions(rendered, this.searchHighlightQuery); - logForDebugging(`scanElementSubtree: q='${this.searchHighlightQuery}' ` + `el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` + `[${positions.slice(0, 10).map(p => `${p.row}:${p.col}`).join(',')}` + `${positions.length > 10 ? ',…' : ''}]`); - return positions; + dom.markDirty(el) + const positions = scanPositions(rendered, this.searchHighlightQuery) + logForDebugging( + `scanElementSubtree: q='${this.searchHighlightQuery}' ` + + `el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` + + `[${positions + .slice(0, 10) + .map(p => `${p.row}:${p.col}`) + .join(',')}` + + `${positions.length > 10 ? ',…' : ''}]`, + ) + return positions } /** Set the position-based highlight state. Every frame, writes CURRENT @@ -1102,13 +1291,15 @@ export default class Ink { * highlight (inverse on all matches) still runs — this overlays yellow * on top. rowOffset changes as the user scrolls (= message's current * screen-top); positions stay stable (message-relative). */ - setSearchPositions(state: { - positions: MatchPosition[]; - rowOffset: number; - currentIdx: number; - } | null): void { - this.searchPositions = state; - this.scheduleRender(); + setSearchPositions( + state: { + positions: MatchPosition[] + rowOffset: number + currentIdx: number + } | null, + ): void { + this.searchPositions = state + this.scheduleRender() } /** @@ -1129,17 +1320,17 @@ export default class Ink { // Wrap a NUL marker, then split on it to extract the open/close SGR. // colorize returns the input unchanged if the color string is bad — // no NUL-split then, so fall through to null (inverse fallback). - const wrapped = colorize('\0', color, 'background'); - const nul = wrapped.indexOf('\0'); + const wrapped = colorize('\0', color, 'background') + const nul = wrapped.indexOf('\0') if (nul <= 0 || nul === wrapped.length - 1) { - this.stylePool.setSelectionBg(null); - return; + this.stylePool.setSelectionBg(null) + return } this.stylePool.setSelectionBg({ type: 'ansi', code: wrapped.slice(0, nul), - endCode: wrapped.slice(nul + 1) // always \x1b[49m for bg - }); + endCode: wrapped.slice(nul + 1), // always \x1b[49m for bg + }) // No scheduleRender: this is called from a React effect that already // runs inside the render cycle, and the bg only matters once a // selection exists (which itself triggers a full-damage frame). @@ -1151,8 +1342,18 @@ export default class Ink { * screen buffer still holds the outgoing content. Accumulated into * the selection state and joined back in by getSelectedText. */ - captureScrolledRows(firstRow: number, lastRow: number, side: 'above' | 'below'): void { - captureScrolledRows(this.selection, this.frontFrame.screen, firstRow, lastRow, side); + captureScrolledRows( + firstRow: number, + lastRow: number, + side: 'above' | 'below', + ): void { + captureScrolledRows( + this.selection, + this.frontFrame.screen, + firstRow, + lastRow, + side, + ) } /** @@ -1163,14 +1364,20 @@ export default class Ink { * edge. Supplies screen.width for the col-reset-on-clamp boundary. */ shiftSelectionForScroll(dRow: number, minRow: number, maxRow: number): void { - const hadSel = hasSelection(this.selection); - shiftSelection(this.selection, dRow, minRow, maxRow, this.frontFrame.screen.width); + const hadSel = hasSelection(this.selection) + shiftSelection( + this.selection, + dRow, + minRow, + maxRow, + this.frontFrame.screen.width, + ) // shiftSelection clears when both endpoints overshoot the same edge // (Home/g/End/G page-jump past the selection). Notify subscribers so // useHasSelection updates. Safe to call notifySelectionChange here — // this runs from keyboard handlers, not inside onRender(). if (hadSel && !hasSelection(this.selection)) { - this.notifySelectionChange(); + this.notifySelectionChange() } } @@ -1183,55 +1390,49 @@ export default class Ink { * char mode. No-op outside alt-screen or without an active selection. */ moveSelectionFocus(move: FocusMove): void { - if (!this.altScreenActive) return; - const { - focus - } = this.selection; - if (!focus) return; - const { - width, - height - } = this.frontFrame.screen; - const maxCol = width - 1; - const maxRow = height - 1; - let { - col, - row - } = focus; + if (!this.altScreenActive) return + const { focus } = this.selection + if (!focus) return + const { width, height } = this.frontFrame.screen + const maxCol = width - 1 + const maxRow = height - 1 + let { col, row } = focus switch (move) { case 'left': - if (col > 0) col--;else if (row > 0) { - col = maxCol; - row--; + if (col > 0) col-- + else if (row > 0) { + col = maxCol + row-- } - break; + break case 'right': - if (col < maxCol) col++;else if (row < maxRow) { - col = 0; - row++; + if (col < maxCol) col++ + else if (row < maxRow) { + col = 0 + row++ } - break; + break case 'up': - if (row > 0) row--; - break; + if (row > 0) row-- + break case 'down': - if (row < maxRow) row++; - break; + if (row < maxRow) row++ + break case 'lineStart': - col = 0; - break; + col = 0 + break case 'lineEnd': - col = maxCol; - break; + col = maxCol + break } - if (col === focus.col && row === focus.row) return; - moveFocus(this.selection, col, row); - this.notifySelectionChange(); + if (col === focus.col && row === focus.row) return + moveFocus(this.selection, col, row) + this.notifySelectionChange() } /** Whether there is an active text selection. */ hasTextSelection(): boolean { - return hasSelection(this.selection); + return hasSelection(this.selection) } /** @@ -1239,12 +1440,13 @@ export default class Ink { * is started, updated, cleared, or copied. Returns an unsubscribe fn. */ subscribeToSelectionChange(cb: () => void): () => void { - this.selectionListeners.add(cb); - return () => this.selectionListeners.delete(cb); + this.selectionListeners.add(cb) + return () => this.selectionListeners.delete(cb) } + private notifySelectionChange(): void { - this.onRender(); - for (const cb of this.selectionListeners) cb(); + this.onRender() + for (const cb of this.selectionListeners) cb() } /** @@ -1255,26 +1457,33 @@ export default class Ink { * nodeCache rects map 1:1 to terminal cells (no scrollback offset). */ dispatchClick(col: number, row: number): boolean { - if (!this.altScreenActive) return false; - const blank = isEmptyCellAt(this.frontFrame.screen, col, row); - return dispatchClick(this.rootNode, col, row, blank); + if (!this.altScreenActive) return false + const blank = isEmptyCellAt(this.frontFrame.screen, col, row) + return dispatchClick(this.rootNode, col, row, blank) } + dispatchHover(col: number, row: number): void { - if (!this.altScreenActive) return; - dispatchHover(this.rootNode, col, row, this.hoveredNodes); + if (!this.altScreenActive) return + dispatchHover(this.rootNode, col, row, this.hoveredNodes) } + dispatchKeyboardEvent(parsedKey: ParsedKey): void { - const target = this.focusManager.activeElement ?? this.rootNode; - const event = new KeyboardEvent(parsedKey); - dispatcher.dispatchDiscrete(target, event); + const target = this.focusManager.activeElement ?? this.rootNode + const event = new KeyboardEvent(parsedKey) + dispatcher.dispatchDiscrete(target, event) // Tab cycling is the default action — only fires if no handler // called preventDefault(). Mirrors browser behavior. - if (!event.defaultPrevented && parsedKey.name === 'tab' && !parsedKey.ctrl && !parsedKey.meta) { + if ( + !event.defaultPrevented && + parsedKey.name === 'tab' && + !parsedKey.ctrl && + !parsedKey.meta + ) { if (parsedKey.shift) { - this.focusManager.focusPrevious(this.rootNode); + this.focusManager.focusPrevious(this.rootNode) } else { - this.focusManager.focusNext(this.rootNode); + this.focusManager.focusNext(this.rootNode) } } } @@ -1288,23 +1497,23 @@ export default class Ink { * the browser-open action via a timer. */ getHyperlinkAt(col: number, row: number): string | undefined { - if (!this.altScreenActive) return undefined; - const screen = this.frontFrame.screen; - const cell = cellAt(screen, col, row); - let url = cell?.hyperlink; + if (!this.altScreenActive) return undefined + const screen = this.frontFrame.screen + const cell = cellAt(screen, col, row) + let url = cell?.hyperlink // SpacerTail cells (right half of wide/CJK/emoji chars) store the // hyperlink on the head cell at col-1. if (!url && cell?.width === CellWidth.SpacerTail && col > 0) { - url = cellAt(screen, col - 1, row)?.hyperlink; + url = cellAt(screen, col - 1, row)?.hyperlink } - return url ?? findPlainTextUrlAt(screen, col, row); + return url ?? findPlainTextUrlAt(screen, col, row) } /** * Optional callback fired when clicking an OSC 8 hyperlink in fullscreen * mode. Set by FullscreenLayout via useLayoutEffect. */ - onHyperlinkClick: ((url: string) => void) | undefined; + onHyperlinkClick: ((url: string) => void) | undefined /** * Stable prototype wrapper for onHyperlinkClick. Passed to as @@ -1312,7 +1521,7 @@ export default class Ink { * the mutable field at call time — not the undefined-at-render value. */ openHyperlink(url: string): void { - this.onHyperlinkClick?.(url); + this.onHyperlinkClick?.(url) } /** @@ -1323,17 +1532,18 @@ export default class Ink { * char-mode startSelection if the click lands on a noSelect cell. */ handleMultiClick(col: number, row: number, count: 2 | 3): void { - if (!this.altScreenActive) return; - const screen = this.frontFrame.screen; + if (!this.altScreenActive) return + const screen = this.frontFrame.screen // selectWordAt/selectLineAt no-op on noSelect/out-of-bounds. Seed with // a char-mode selection so the press still starts a drag even if the // word/line scan finds nothing selectable. - startSelection(this.selection, col, row); - if (count === 2) selectWordAt(this.selection, screen, col, row);else selectLineAt(this.selection, screen, row); + startSelection(this.selection, col, row) + if (count === 2) selectWordAt(this.selection, screen, col, row) + else selectLineAt(this.selection, screen, row) // Ensure hasSelection is true so release doesn't re-dispatch onClickAt. // selectWordAt no-ops on noSelect; selectLineAt no-ops out-of-bounds. - if (!this.selection.focus) this.selection.focus = this.selection.anchor; - this.notifySelectionChange(); + if (!this.selection.focus) this.selection.focus = this.selection.anchor + this.notifySelectionChange() } /** @@ -1343,83 +1553,85 @@ export default class Ink { * altScreenActive for the same reason as dispatchClick. */ handleSelectionDrag(col: number, row: number): void { - if (!this.altScreenActive) return; - const sel = this.selection; + if (!this.altScreenActive) return + const sel = this.selection if (sel.anchorSpan) { - extendSelection(sel, this.frontFrame.screen, col, row); + extendSelection(sel, this.frontFrame.screen, col, row) } else { - updateSelection(sel, col, row); + updateSelection(sel, col, row) } - this.notifySelectionChange(); + this.notifySelectionChange() } // Methods to properly suspend stdin for external editor usage // This is needed to prevent Ink from swallowing keystrokes when an external editor is active private stdinListeners: Array<{ - event: string; - listener: (...args: unknown[]) => void; - }> = []; - private wasRawMode = false; + event: string + listener: (...args: unknown[]) => void + }> = [] + private wasRawMode = false + suspendStdin(): void { - const stdin = this.options.stdin; + const stdin = this.options.stdin if (!stdin.isTTY) { - return; + return } // Store and remove all 'readable' event listeners temporarily // This prevents Ink from consuming stdin while the editor is active - const readableListeners = stdin.listeners('readable'); - logForDebugging(`[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${(stdin as NodeJS.ReadStream & { - isRaw?: boolean; - }).isRaw ?? false}`); + const readableListeners = stdin.listeners('readable') + logForDebugging( + `[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${(stdin as NodeJS.ReadStream & { isRaw?: boolean }).isRaw ?? false}`, + ) readableListeners.forEach(listener => { this.stdinListeners.push({ event: 'readable', - listener: listener as (...args: unknown[]) => void - }); - stdin.removeListener('readable', listener as (...args: unknown[]) => void); - }); + listener: listener as (...args: unknown[]) => void, + }) + stdin.removeListener('readable', listener as (...args: unknown[]) => void) + }) // If raw mode is enabled, disable it temporarily const stdinWithRaw = stdin as NodeJS.ReadStream & { - isRaw?: boolean; - setRawMode?: (mode: boolean) => void; - }; + isRaw?: boolean + setRawMode?: (mode: boolean) => void + } if (stdinWithRaw.isRaw && stdinWithRaw.setRawMode) { - stdinWithRaw.setRawMode(false); - this.wasRawMode = true; + stdinWithRaw.setRawMode(false) + this.wasRawMode = true } } + resumeStdin(): void { - const stdin = this.options.stdin; + const stdin = this.options.stdin if (!stdin.isTTY) { - return; + return } // Re-attach all the stored listeners if (this.stdinListeners.length === 0 && !this.wasRawMode) { - logForDebugging('[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', { - level: 'warn' - }); + logForDebugging( + '[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', + { level: 'warn' }, + ) } - logForDebugging(`[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}`); - this.stdinListeners.forEach(({ - event, - listener - }) => { - stdin.addListener(event, listener); - }); - this.stdinListeners = []; + logForDebugging( + `[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}`, + ) + this.stdinListeners.forEach(({ event, listener }) => { + stdin.addListener(event, listener) + }) + this.stdinListeners = [] // Re-enable raw mode if it was enabled before if (this.wasRawMode) { const stdinWithRaw = stdin as NodeJS.ReadStream & { - setRawMode?: (mode: boolean) => void; - }; + setRawMode?: (mode: boolean) => void + } if (stdinWithRaw.setRawMode) { - stdinWithRaw.setRawMode(true); + stdinWithRaw.setRawMode(true) } - this.wasRawMode = false; + this.wasRawMode = false } } @@ -1428,41 +1640,78 @@ export default class Ink { // cascades through useContext → 's useLayoutEffect dep // array → spurious exit+re-enter of the alt screen on every SIGWINCH. private writeRaw(data: string): void { - this.options.stdout.write(data); + this.options.stdout.write(data) } - private setCursorDeclaration: CursorDeclarationSetter = (decl, clearIfNode) => { - if (decl === null && clearIfNode !== undefined && this.cursorDeclaration?.node !== clearIfNode) { - return; + + private setCursorDeclaration: CursorDeclarationSetter = ( + decl, + clearIfNode, + ) => { + if ( + decl === null && + clearIfNode !== undefined && + this.cursorDeclaration?.node !== clearIfNode + ) { + return } - this.cursorDeclaration = decl; - }; + this.cursorDeclaration = decl + } + render(node: ReactNode): void { - this.currentNode = node; - const tree = + this.currentNode = node + + const tree = ( + {node} - ; + + ) - reconciler.updateContainerSync(tree, this.container, null, noop); - reconciler.flushSyncWork(); + // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler + reconciler.updateContainerSync(tree, this.container, null, noop) + // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler + reconciler.flushSyncWork() } + unmount(error?: Error | number | null): void { if (this.isUnmounted) { - return; + return } - this.onRender(); - this.unsubscribeExit(); + + this.onRender() + this.unsubscribeExit() + if (typeof this.restoreConsole === 'function') { - this.restoreConsole(); + this.restoreConsole() } - this.restoreStderr?.(); - this.unsubscribeTTYHandlers?.(); + this.restoreStderr?.() + + this.unsubscribeTTYHandlers?.() // Non-TTY environments don't handle erasing ansi escapes well, so it's better to // only render last frame of non-static output - const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame); - writeDiffToTerminal(this.terminal, optimize(diff)); + const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame) + writeDiffToTerminal(this.terminal, optimize(diff)) // Clean up terminal modes synchronously before process exit. // React's componentWillUnmount won't run in time when process.exit() is called, @@ -1476,70 +1725,83 @@ export default class Ink { if (this.altScreenActive) { // 's unmount effect won't run during signal-exit. // Exit alt screen FIRST so other cleanup sequences go to the main screen. - writeSync(1, EXIT_ALT_SCREEN); + writeSync(1, EXIT_ALT_SCREEN) } // Disable mouse tracking — unconditional because altScreenActive can be // stale if AlternateScreen's unmount (which flips the flag) raced a // blocked event loop + SIGINT. No-op if tracking was never enabled. - writeSync(1, DISABLE_MOUSE_TRACKING); + writeSync(1, DISABLE_MOUSE_TRACKING) // Drain stdin so in-flight mouse events don't leak to the shell - this.drainStdin(); + this.drainStdin() // Disable extended key reporting (both kitty and modifyOtherKeys) - writeSync(1, DISABLE_MODIFY_OTHER_KEYS); - writeSync(1, DISABLE_KITTY_KEYBOARD); + writeSync(1, DISABLE_MODIFY_OTHER_KEYS) + writeSync(1, DISABLE_KITTY_KEYBOARD) // Disable focus events (DECSET 1004) - writeSync(1, DFE); + writeSync(1, DFE) // Disable bracketed paste mode - writeSync(1, DBP); + writeSync(1, DBP) // Show cursor - writeSync(1, SHOW_CURSOR); + writeSync(1, SHOW_CURSOR) // Clear iTerm2 progress bar - writeSync(1, CLEAR_ITERM2_PROGRESS); + writeSync(1, CLEAR_ITERM2_PROGRESS) // Clear tab status (OSC 21337) so a stale dot doesn't linger - if (supportsTabStatus()) writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)); + if (supportsTabStatus()) + writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)) } /* eslint-enable custom-rules/no-sync-fs */ - this.isUnmounted = true; + this.isUnmounted = true // Cancel any pending throttled renders to prevent accessing freed Yoga nodes - this.scheduleRender.cancel?.(); + this.scheduleRender.cancel?.() if (this.drainTimer !== null) { - clearTimeout(this.drainTimer); - this.drainTimer = null; + clearTimeout(this.drainTimer) + this.drainTimer = null } - reconciler.updateContainerSync(null, this.container, null, noop); - reconciler.flushSyncWork(); - instances.delete(this.options.stdout); + // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler + reconciler.updateContainerSync(null, this.container, null, noop) + // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler + reconciler.flushSyncWork() + instances.delete(this.options.stdout) // Free the root yoga node, then clear its reference. Children are already // freed by the reconciler's removeChildFromContainer; using .free() (not // .freeRecursive()) avoids double-freeing them. - this.rootNode.yogaNode?.free(); - this.rootNode.yogaNode = undefined; + this.rootNode.yogaNode?.free() + this.rootNode.yogaNode = undefined + if (error instanceof Error) { - this.rejectExitPromise(error); + this.rejectExitPromise(error) } else { - this.resolveExitPromise(); + this.resolveExitPromise() } } + async waitUntilExit(): Promise { this.exitPromise ||= new Promise((resolve, reject) => { - this.resolveExitPromise = resolve; - this.rejectExitPromise = reject; - }); - return this.exitPromise; + this.resolveExitPromise = resolve + this.rejectExitPromise = reject + }) + + return this.exitPromise } + resetLineCount(): void { if (this.options.stdout.isTTY) { // Swap so old front becomes back (for screen reuse), then reset front - this.backFrame = this.frontFrame; - this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool); - this.log.reset(); + this.backFrame = this.frontFrame + this.frontFrame = emptyFrame( + this.frontFrame.viewport.height, + this.frontFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool, + ) + this.log.reset() // frontFrame is reset, so frame.cursor on the next render is (0,0). // Clear displayCursor so the preamble doesn't compute a stale delta. - this.displayCursor = null; + this.displayCursor = null } } @@ -1552,34 +1814,41 @@ export default class Ink { * Call between conversation turns or periodically. */ resetPools(): void { - this.charPool = new CharPool(); - this.hyperlinkPool = new HyperlinkPool(); - migrateScreenPools(this.frontFrame.screen, this.charPool, this.hyperlinkPool); + this.charPool = new CharPool() + this.hyperlinkPool = new HyperlinkPool() + migrateScreenPools( + this.frontFrame.screen, + this.charPool, + this.hyperlinkPool, + ) // Back frame's data is zeroed by resetScreen before reads, but its pool // references are used by the renderer to intern new characters. Point // them at the new pools so the next frame's IDs are comparable. - this.backFrame.screen.charPool = this.charPool; - this.backFrame.screen.hyperlinkPool = this.hyperlinkPool; + this.backFrame.screen.charPool = this.charPool + this.backFrame.screen.hyperlinkPool = this.hyperlinkPool } + patchConsole(): () => void { // biome-ignore lint/suspicious/noConsole: intentionally patching global console - const con = console; - const originals: Partial> = {}; - const toDebug = (...args: unknown[]) => logForDebugging(`console.log: ${format(...args)}`); - const toError = (...args: unknown[]) => logError(new Error(`console.error: ${format(...args)}`)); + const con = console + const originals: Partial> = {} + const toDebug = (...args: unknown[]) => + logForDebugging(`console.log: ${format(...args)}`) + const toError = (...args: unknown[]) => + logError(new Error(`console.error: ${format(...args)}`)) for (const m of CONSOLE_STDOUT_METHODS) { - originals[m] = con[m]; - con[m] = toDebug; + originals[m] = con[m] + con[m] = toDebug } for (const m of CONSOLE_STDERR_METHODS) { - originals[m] = con[m]; - con[m] = toError; + originals[m] = con[m] + con[m] = toError } - originals.assert = con.assert; + originals.assert = con.assert con.assert = (condition: unknown, ...args: unknown[]) => { - if (!condition) toError(...args); - }; - return () => Object.assign(con, originals); + if (!condition) toError(...args) + } + return () => Object.assign(con, originals) } /** @@ -1595,40 +1864,46 @@ export default class Ink { * process.stdout — Ink itself writes there. */ private patchStderr(): () => void { - const stderr = process.stderr; - const originalWrite = stderr.write; - let reentered = false; - const intercept = (chunk: Uint8Array | string, encodingOrCb?: BufferEncoding | ((err?: Error) => void), cb?: (err?: Error) => void): boolean => { - const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb; + const stderr = process.stderr + const originalWrite = stderr.write + let reentered = false + const intercept = ( + chunk: Uint8Array | string, + encodingOrCb?: BufferEncoding | ((err?: Error) => void), + cb?: (err?: Error) => void, + ): boolean => { + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb // Reentrancy guard: logForDebugging → writeToStderr → here. Pass // through to the original so --debug-to-stderr still works and we // don't stack-overflow. if (reentered) { - const encoding = typeof encodingOrCb === 'string' ? encodingOrCb : undefined; - return originalWrite.call(stderr, chunk, encoding, callback); + const encoding = + typeof encodingOrCb === 'string' ? encodingOrCb : undefined + return originalWrite.call(stderr, chunk, encoding, callback) } - reentered = true; + reentered = true try { - const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); - logForDebugging(`[stderr] ${text}`, { - level: 'warn' - }); + const text = + typeof chunk === 'string' + ? chunk + : Buffer.from(chunk).toString('utf8') + logForDebugging(`[stderr] ${text}`, { level: 'warn' }) if (this.altScreenActive && !this.isUnmounted && !this.isPaused) { - this.prevFrameContaminated = true; - this.scheduleRender(); + this.prevFrameContaminated = true + this.scheduleRender() } } finally { - reentered = false; - callback?.(); + reentered = false + callback?.() } - return true; - }; - stderr.write = intercept; + return true + } + stderr.write = intercept return () => { if (stderr.write === intercept) { - stderr.write = originalWrite; + stderr.write = originalWrite } - }; + } } } @@ -1655,7 +1930,7 @@ export default class Ink { */ /* eslint-disable custom-rules/no-sync-fs -- must be sync; called from signal handler / unmount */ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { - if (!stdin.isTTY) return; + if (!stdin.isTTY) return // Drain Node's stream buffer (bytes libuv already pulled in). read() // returns null when empty — never blocks. try { @@ -1667,27 +1942,27 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { } // No /dev/tty on Windows; CONIN$ doesn't support O_NONBLOCK semantics. // Windows Terminal also doesn't buffer mouse reports the same way. - if (process.platform === 'win32') return; + if (process.platform === 'win32') return // termios is per-device: flip stdin to raw so canonical-mode line // buffering doesn't hide partial input from the non-blocking read. // Restored in the finally block. const tty = stdin as NodeJS.ReadStream & { - isRaw?: boolean; - setRawMode?: (raw: boolean) => void; - }; - const wasRaw = tty.isRaw === true; + isRaw?: boolean + setRawMode?: (raw: boolean) => void + } + const wasRaw = tty.isRaw === true // Drain the kernel TTY buffer via a fresh O_NONBLOCK fd. Bounded at 64 // reads (64KB) — a real mouse burst is a few hundred bytes; the cap // guards against a terminal that ignores O_NONBLOCK. - let fd = -1; + let fd = -1 try { // setRawMode inside try: on revoked TTY (SIGHUP/SSH disconnect) the // ioctl throws EBADF — same recovery path as openSync/readSync below. - if (!wasRaw) tty.setRawMode?.(true); - fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); - const buf = Buffer.alloc(1024); + if (!wasRaw) tty.setRawMode?.(true) + fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) + const buf = Buffer.alloc(1024) for (let i = 0; i < 64; i++) { - if (readSync(fd, buf, 0, buf.length, null) <= 0) break; + if (readSync(fd, buf, 0, buf.length, null) <= 0) break } } catch { // EAGAIN (buffer empty — expected), ENXIO/ENOENT (no controlling tty), @@ -1695,14 +1970,14 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { } finally { if (fd >= 0) { try { - closeSync(fd); + closeSync(fd) } catch { /* ignore */ } } if (!wasRaw) { try { - tty.setRawMode?.(false); + tty.setRawMode?.(false) } catch { /* TTY may be gone */ } @@ -1711,5 +1986,20 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { } /* eslint-enable custom-rules/no-sync-fs */ -const CONSOLE_STDOUT_METHODS = ['log', 'info', 'debug', 'dir', 'dirxml', 'count', 'countReset', 'group', 'groupCollapsed', 'groupEnd', 'table', 'time', 'timeEnd', 'timeLog'] as const; -const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const; +const CONSOLE_STDOUT_METHODS = [ + 'log', + 'info', + 'debug', + 'dir', + 'dirxml', + 'count', + 'countReset', + 'group', + 'groupCollapsed', + 'groupEnd', + 'table', + 'time', + 'timeEnd', + 'timeLog', +] as const +const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const diff --git a/src/interactiveHelpers.tsx b/src/interactiveHelpers.tsx index 384ca917a..72bf5e7be 100644 --- a/src/interactiveHelpers.tsx +++ b/src/interactiveHelpers.tsx @@ -1,46 +1,76 @@ -import { feature } from 'bun:bundle'; -import { appendFileSync } from 'fs'; -import React from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; -import { type ChannelEntry, getAllowedChannels, setAllowedChannels, setHasDevChannels, setSessionTrustAccepted, setStatsStore } from './bootstrap/state.js'; -import type { Command } from './commands.js'; -import { createStatsStore, type StatsStore } from './context/stats.js'; -import { getSystemContext } from './context.js'; -import { initializeTelemetryAfterTrust } from './entrypoints/init.js'; -import { isSynchronizedOutputSupported } from './ink/terminal.js'; -import type { RenderOptions, Root, TextProps } from './ink.js'; -import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; -import { startDeferredPrefetches } from './main.js'; -import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook } from './services/analytics/growthbook.js'; -import { isQualifiedForGrove } from './services/api/grove.js'; -import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js'; -import { AppStateProvider } from './state/AppState.js'; -import { onChangeAppState } from './state/onChangeAppState.js'; -import { normalizeApiKeyForConfig } from './utils/authPortable.js'; -import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning } from './utils/claudemd.js'; -import { checkHasTrustDialogAccepted, getCustomApiKeyStatus, getGlobalConfig, saveGlobalConfig } from './utils/config.js'; -import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js'; -import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js'; -import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js'; -import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js'; -import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; -import type { PermissionMode } from './utils/permissions/PermissionMode.js'; -import { getBaseRenderOptions } from './utils/renderOptions.js'; -import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'; -import { hasAutoModeOptIn, hasSkipDangerousModePermissionPrompt } from './utils/settings/settings.js'; +import { feature } from 'bun:bundle' +import { appendFileSync } from 'fs' +import React from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { + gracefulShutdown, + gracefulShutdownSync, +} from 'src/utils/gracefulShutdown.js' +import { + type ChannelEntry, + getAllowedChannels, + setAllowedChannels, + setHasDevChannels, + setSessionTrustAccepted, + setStatsStore, +} from './bootstrap/state.js' +import type { Command } from './commands.js' +import { createStatsStore, type StatsStore } from './context/stats.js' +import { getSystemContext } from './context.js' +import { initializeTelemetryAfterTrust } from './entrypoints/init.js' +import { isSynchronizedOutputSupported } from './ink/terminal.js' +import type { RenderOptions, Root, TextProps } from './ink.js' +import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js' +import { startDeferredPrefetches } from './main.js' +import { + checkGate_CACHED_OR_BLOCKING, + initializeGrowthBook, + resetGrowthBook, +} from './services/analytics/growthbook.js' +import { isQualifiedForGrove } from './services/api/grove.js' +import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js' +import { AppStateProvider } from './state/AppState.js' +import { onChangeAppState } from './state/onChangeAppState.js' +import { normalizeApiKeyForConfig } from './utils/authPortable.js' +import { + getExternalClaudeMdIncludes, + getMemoryFiles, + shouldShowClaudeMdExternalIncludesWarning, +} from './utils/claudemd.js' +import { + checkHasTrustDialogAccepted, + getCustomApiKeyStatus, + getGlobalConfig, + saveGlobalConfig, +} from './utils/config.js' +import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js' +import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js' +import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js' +import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js' +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js' +import type { PermissionMode } from './utils/permissions/PermissionMode.js' +import { getBaseRenderOptions } from './utils/renderOptions.js' +import { getSettingsWithAllErrors } from './utils/settings/allErrors.js' +import { + hasAutoModeOptIn, + hasSkipDangerousModePermissionPrompt, +} from './utils/settings/settings.js' + export function completeOnboarding(): void { saveGlobalConfig(current => ({ ...current, hasCompletedOnboarding: true, - lastOnboardingVersion: MACRO.VERSION - })); + lastOnboardingVersion: MACRO.VERSION, + })) } -export function showDialog(root: Root, renderer: (done: (result: T) => void) => React.ReactNode): Promise { +export function showDialog( + root: Root, + renderer: (done: (result: T) => void) => React.ReactNode, +): Promise { return new Promise(resolve => { - const done = (result: T): void => void resolve(result); - root.render(renderer(done)); - }); + const done = (result: T): void => void resolve(result) + root.render(renderer(done)) + }) } /** @@ -49,11 +79,12 @@ export function showDialog(root: Root, renderer: (done: (result: T) => * console.error is swallowed by Ink's patchConsole, so we render * through the React tree instead. */ -export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise): Promise { - return exitWithMessage(root, message, { - color: 'error', - beforeExit - }); +export async function exitWithError( + root: Root, + message: string, + beforeExit?: () => Promise, +): Promise { + return exitWithMessage(root, message, { color: 'error', beforeExit }) } /** @@ -62,64 +93,93 @@ export async function exitWithError(root: Root, message: string, beforeExit?: () * console output is swallowed by Ink's patchConsole, so we render * through the React tree instead. */ -export async function exitWithMessage(root: Root, message: string, options?: { - color?: TextProps['color']; - exitCode?: number; - beforeExit?: () => Promise; -}): Promise { - const { - Text - } = await import('./ink.js'); - const color = options?.color; - const exitCode = options?.exitCode ?? 1; - root.render(color ? {message} : {message}); - root.unmount(); - await options?.beforeExit?.(); +export async function exitWithMessage( + root: Root, + message: string, + options?: { + color?: TextProps['color'] + exitCode?: number + beforeExit?: () => Promise + }, +): Promise { + const { Text } = await import('./ink.js') + const color = options?.color + const exitCode = options?.exitCode ?? 1 + root.render( + color ? {message} : {message}, + ) + root.unmount() + await options?.beforeExit?.() // eslint-disable-next-line custom-rules/no-process-exit -- exit after Ink unmount - process.exit(exitCode); + process.exit(exitCode) } /** * Show a setup dialog wrapped in AppStateProvider + KeybindingSetup. * Reduces boilerplate in showSetupScreens() where every dialog needs these wrappers. */ -export function showSetupDialog(root: Root, renderer: (done: (result: T) => void) => React.ReactNode, options?: { - onChangeAppState?: typeof onChangeAppState; -}): Promise { - return showDialog(root, done => +export function showSetupDialog( + root: Root, + renderer: (done: (result: T) => void) => React.ReactNode, + options?: { onChangeAppState?: typeof onChangeAppState }, +): Promise { + return showDialog(root, done => ( + {renderer(done)} - ); + + )) } /** * Render the main UI into the root and wait for it to exit. * Handles the common epilogue: start deferred prefetches, wait for exit, graceful shutdown. */ -export async function renderAndRun(root: Root, element: React.ReactNode): Promise { - root.render(element); - startDeferredPrefetches(); - await root.waitUntilExit(); - await gracefulShutdown(0); +export async function renderAndRun( + root: Root, + element: React.ReactNode, +): Promise { + root.render(element) + startDeferredPrefetches() + await root.waitUntilExit() + await gracefulShutdown(0) } -export async function showSetupScreens(root: Root, permissionMode: PermissionMode, allowDangerouslySkipPermissions: boolean, commands?: Command[], claudeInChrome?: boolean, devChannels?: ChannelEntry[]): Promise { - if (("production" as string) === 'test' || isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode + +export async function showSetupScreens( + root: Root, + permissionMode: PermissionMode, + allowDangerouslySkipPermissions: boolean, + commands?: Command[], + claudeInChrome?: boolean, + devChannels?: ChannelEntry[], +): Promise { + if ( + "production" === 'test' || + isEnvTruthy(false) || + process.env.IS_DEMO // Skip onboarding in demo mode ) { - return false; + return false } - const config = getGlobalConfig(); - let onboardingShown = false; - if (!config.theme || !config.hasCompletedOnboarding // always show onboarding at least once + + const config = getGlobalConfig() + let onboardingShown = false + if ( + !config.theme || + !config.hasCompletedOnboarding // always show onboarding at least once ) { - onboardingShown = true; - const { - Onboarding - } = await import('./components/Onboarding.js'); - await showSetupDialog(root, done => { - completeOnboarding(); - void done(); - }} />, { - onChangeAppState - }); + onboardingShown = true + const { Onboarding } = await import('./components/Onboarding.js') + await showSetupDialog( + root, + done => ( + { + completeOnboarding() + void done() + }} + /> + ), + { onChangeAppState }, + ) } // Always show the trust dialog in interactive sessions, regardless of permission mode. @@ -133,70 +193,83 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // If it returns true, the TrustDialog would auto-resolve regardless of // security features, so we can skip the dynamic import and render cycle. if (!checkHasTrustDialogAccepted()) { - const { - TrustDialog - } = await import('./components/TrustDialog/TrustDialog.js'); - await showSetupDialog(root, done => ); + const { TrustDialog } = await import( + './components/TrustDialog/TrustDialog.js' + ) + await showSetupDialog(root, done => ( + + )) } // Signal that trust has been verified for this session. // GrowthBook checks this to decide whether to include auth headers. - setSessionTrustAccepted(true); + setSessionTrustAccepted(true) // Reset and reinitialize GrowthBook after trust is established. // Defense for login/logout: clears any prior client so the next init // picks up fresh auth headers. - resetGrowthBook(); - void initializeGrowthBook(); + resetGrowthBook() + void initializeGrowthBook() // Now that trust is established, prefetch system context if it wasn't already - void getSystemContext(); + void getSystemContext() // If settings are valid, check for any mcp.json servers that need approval - const { - errors: allErrors - } = getSettingsWithAllErrors(); + const { errors: allErrors } = getSettingsWithAllErrors() if (allErrors.length === 0) { - await handleMcpjsonServerApprovals(root); + await handleMcpjsonServerApprovals(root) } // Check for claude.md includes that need approval if (await shouldShowClaudeMdExternalIncludesWarning()) { - const externalIncludes = getExternalClaudeMdIncludes(await getMemoryFiles(true)); - const { - ClaudeMdExternalIncludesDialog - } = await import('./components/ClaudeMdExternalIncludesDialog.js'); - await showSetupDialog(root, done => ); + const externalIncludes = getExternalClaudeMdIncludes( + await getMemoryFiles(true), + ) + const { ClaudeMdExternalIncludesDialog } = await import( + './components/ClaudeMdExternalIncludesDialog.js' + ) + await showSetupDialog(root, done => ( + + )) } } // Track current repo path for teleport directory switching (fire-and-forget) // This must happen AFTER trust to prevent untrusted directories from poisoning the mapping - void updateGithubRepoPathMapping(); + void updateGithubRepoPathMapping() if (feature('LODESTONE')) { - updateDeepLinkTerminalPreference(); + updateDeepLinkTerminalPreference() } // Apply full environment variables after trust dialog is accepted OR in bypass mode // In bypass mode (CI/CD, automation), we trust the environment so apply all variables // In normal mode, this happens after the trust dialog is accepted // This includes potentially dangerous environment variables from untrusted sources - applyConfigEnvironmentVariables(); + applyConfigEnvironmentVariables() // Initialize telemetry after env vars are applied so OTEL endpoint env vars and // otelHeadersHelper (which requires trust to execute) are available. // Defer to next tick so the OTel dynamic import resolves after first render // instead of during the pre-render microtask queue. - setImmediate(() => initializeTelemetryAfterTrust()); + setImmediate(() => initializeTelemetryAfterTrust()) + if (await isQualifiedForGrove()) { - const { - GroveDialog - } = await import('src/components/grove/Grove.js'); - const decision = await showSetupDialog(root, done => ); + const { GroveDialog } = await import('src/components/grove/Grove.js') + const decision = await showSetupDialog(root, done => ( + + )) if (decision === 'escape') { - logEvent('tengu_grove_policy_exited', {}); - gracefulShutdownSync(0); - return false; + logEvent('tengu_grove_policy_exited', {}) + gracefulShutdownSync(0) + return false } } @@ -204,33 +277,54 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) { - const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); - const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated); + const customApiKeyTruncated = normalizeApiKeyForConfig( + process.env.ANTHROPIC_API_KEY, + ) + const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated) if (keyStatus === 'new') { - const { - ApproveApiKey - } = await import('./components/ApproveApiKey.js'); - await showSetupDialog(root, done => , { - onChangeAppState - }); + const { ApproveApiKey } = await import('./components/ApproveApiKey.js') + await showSetupDialog( + root, + done => ( + + ), + { onChangeAppState }, + ) } } - if ((permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt()) { - const { - BypassPermissionsModeDialog - } = await import('./components/BypassPermissionsModeDialog.js'); - await showSetupDialog(root, done => ); + + if ( + (permissionMode === 'bypassPermissions' || + allowDangerouslySkipPermissions) && + !hasSkipDangerousModePermissionPrompt() + ) { + const { BypassPermissionsModeDialog } = await import( + './components/BypassPermissionsModeDialog.js' + ) + await showSetupDialog(root, done => ( + + )) } + if (feature('TRANSCRIPT_CLASSIFIER')) { // Only show the opt-in dialog if auto mode actually resolved — if the // gate denied it (org not allowlisted, settings disabled), showing // consent for an unavailable feature is pointless. The // verifyAutoModeGateAccess notification will explain why instead. if (permissionMode === 'auto' && !hasAutoModeOptIn()) { - const { - AutoModeOptInDialog - } = await import('./components/AutoModeOptInDialog.js'); - await showSetupDialog(root, done => gracefulShutdownSync(1)} declineExits />); + const { AutoModeOptInDialog } = await import( + './components/AutoModeOptInDialog.js' + ) + await showSetupDialog(root, done => ( + gracefulShutdownSync(1)} + declineExits + /> + )) } } @@ -248,14 +342,15 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // initializeGrowthBook promise fired earlier). Also warms the // isChannelsEnabled() check in the dev-channels dialog below. if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) { - await checkGate_CACHED_OR_BLOCKING('tengu_harbor'); + await checkGate_CACHED_OR_BLOCKING('tengu_harbor') } + if (devChannels && devChannels.length > 0) { - const [{ - isChannelsEnabled - }, { - getClaudeAIOAuthTokens - }] = await Promise.all([import('./services/mcp/channelAllowlist.js'), import('./utils/auth.js')]); + const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] = + await Promise.all([ + import('./services/mcp/channelAllowlist.js'), + import('./utils/auth.js'), + ]) // Skip the dialog when channels are blocked (tengu_harbor off or no // OAuth) — accepting then immediately seeing "not available" in // ChannelsNotice is worse than no dialog. Append entries anyway so @@ -264,102 +359,115 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // (hasNonDev check); the allowlist bypass it also grants is moot // since the gate blocks upstream. if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) { - setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ - ...c, - dev: true - }))]); - setHasDevChannels(true); + setAllowedChannels([ + ...getAllowedChannels(), + ...devChannels.map(c => ({ ...c, dev: true })), + ]) + setHasDevChannels(true) } else { - const { - DevChannelsDialog - } = await import('./components/DevChannelsDialog.js'); - await showSetupDialog(root, done => { - // Mark dev entries per-entry so the allowlist bypass doesn't leak - // to --channels entries when both flags are passed. - setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ - ...c, - dev: true - }))]); - setHasDevChannels(true); - void done(); - }} />); + const { DevChannelsDialog } = await import( + './components/DevChannelsDialog.js' + ) + await showSetupDialog(root, done => ( + { + // Mark dev entries per-entry so the allowlist bypass doesn't leak + // to --channels entries when both flags are passed. + setAllowedChannels([ + ...getAllowedChannels(), + ...devChannels.map(c => ({ ...c, dev: true })), + ]) + setHasDevChannels(true) + void done() + }} + /> + )) } } } // Show Chrome onboarding for first-time Claude in Chrome users - if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) { - const { - ClaudeInChromeOnboarding - } = await import('./components/ClaudeInChromeOnboarding.js'); - await showSetupDialog(root, done => ); + if ( + claudeInChrome && + !getGlobalConfig().hasCompletedClaudeInChromeOnboarding + ) { + const { ClaudeInChromeOnboarding } = await import( + './components/ClaudeInChromeOnboarding.js' + ) + await showSetupDialog(root, done => ( + + )) } - return onboardingShown; + + return onboardingShown } + export function getRenderContext(exitOnCtrlC: boolean): { - renderOptions: RenderOptions; - getFpsMetrics: () => FpsMetrics | undefined; - stats: StatsStore; + renderOptions: RenderOptions + getFpsMetrics: () => FpsMetrics | undefined + stats: StatsStore } { - let lastFlickerTime = 0; - const baseOptions = getBaseRenderOptions(exitOnCtrlC); + let lastFlickerTime = 0 + const baseOptions = getBaseRenderOptions(exitOnCtrlC) // Log analytics event when stdin override is active if (baseOptions.stdin) { - logEvent('tengu_stdin_interactive', {}); + logEvent('tengu_stdin_interactive', {}) } - const fpsTracker = new FpsTracker(); - const stats = createStatsStore(); - setStatsStore(stats); + + const fpsTracker = new FpsTracker() + const stats = createStatsStore() + setStatsStore(stats) // Bench mode: when set, append per-frame phase timings as JSONL for // offline analysis by bench/repl-scroll.ts. Captures the full TUI // render pipeline (yoga → screen buffer → diff → optimize → stdout) // so perf work on any phase can be validated against real user flows. - const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG; + const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG return { getFpsMetrics: () => fpsTracker.getMetrics(), stats, renderOptions: { ...baseOptions, onFrame: event => { - fpsTracker.record(event.durationMs); - stats.observe('frame_duration_ms', event.durationMs); + fpsTracker.record(event.durationMs) + stats.observe('frame_duration_ms', event.durationMs) if (frameTimingLogPath && event.phases) { // Bench-only env-var-gated path: sync write so no frames dropped // on abrupt exit. ~100 bytes at ≤60fps is negligible. rss/cpu are // single syscalls; cpu is cumulative — bench side computes delta. const line = - // eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path - JSON.stringify({ - total: event.durationMs, - ...event.phases, - rss: process.memoryUsage.rss(), - cpu: process.cpuUsage() - }) + '\n'; + // eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path + JSON.stringify({ + total: event.durationMs, + ...event.phases, + rss: process.memoryUsage.rss(), + cpu: process.cpuUsage(), + }) + '\n' // eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit - appendFileSync(frameTimingLogPath, line); + appendFileSync(frameTimingLogPath, line) } // Skip flicker reporting for terminals with synchronized output — // DEC 2026 buffers between BSU/ESU so clear+redraw is atomic. if (isSynchronizedOutputSupported()) { - return; + return } for (const flicker of event.flickers) { if (flicker.reason === 'resize') { - continue; + continue } - const now = Date.now(); + const now = Date.now() if (now - lastFlickerTime < 1000) { logEvent('tengu_flicker', { desiredHeight: flicker.desiredHeight, actualHeight: flicker.availableHeight, - reason: flicker.reason - } as unknown as Record); + reason: flicker.reason, + } as unknown as Record) } - lastFlickerTime = now; + lastFlickerTime = now } - } - } - }; + }, + }, + } } diff --git a/src/keybindings/KeybindingContext.tsx b/src/keybindings/KeybindingContext.tsx index ef61bcf68..bc85e81c0 100644 --- a/src/keybindings/KeybindingContext.tsx +++ b/src/keybindings/KeybindingContext.tsx @@ -1,149 +1,152 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react'; -import type { Key } from '../ink.js'; -import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js'; -import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; +import React, { + createContext, + type RefObject, + useContext, + useLayoutEffect, + useMemo, +} from 'react' +import type { Key } from '../ink.js' +import { + type ChordResolveResult, + getBindingDisplayText, + resolveKeyWithChordState, +} from './resolver.js' +import type { + KeybindingContextName, + ParsedBinding, + ParsedKeystroke, +} from './types.js' /** Handler registration for action callbacks */ type HandlerRegistration = { - action: string; - context: KeybindingContextName; - handler: () => void; -}; + action: string + context: KeybindingContextName + handler: () => void +} + type KeybindingContextValue = { /** Resolve a key input to an action name (with chord support) */ - resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult; + resolve: ( + input: string, + key: Key, + activeContexts: KeybindingContextName[], + ) => ChordResolveResult /** Update the pending chord state */ - setPendingChord: (pending: ParsedKeystroke[] | null) => void; + setPendingChord: (pending: ParsedKeystroke[] | null) => void /** Get display text for an action (e.g., "ctrl+t") */ - getDisplayText: (action: string, context: KeybindingContextName) => string | undefined; + getDisplayText: ( + action: string, + context: KeybindingContextName, + ) => string | undefined /** All parsed bindings (for help display) */ - bindings: ParsedBinding[]; + bindings: ParsedBinding[] /** Current pending chord keystrokes (null if not in a chord) */ - pendingChord: ParsedKeystroke[] | null; + pendingChord: ParsedKeystroke[] | null /** Currently active keybinding contexts (for priority resolution) */ - activeContexts: Set; + activeContexts: Set /** Register a context as active (call on mount) */ - registerActiveContext: (context: KeybindingContextName) => void; + registerActiveContext: (context: KeybindingContextName) => void /** Unregister a context (call on unmount) */ - unregisterActiveContext: (context: KeybindingContextName) => void; + unregisterActiveContext: (context: KeybindingContextName) => void /** Register a handler for an action (used by useKeybinding) */ - registerHandler: (registration: HandlerRegistration) => () => void; + registerHandler: (registration: HandlerRegistration) => () => void /** Invoke all handlers for an action (used by ChordInterceptor) */ - invokeAction: (action: string) => boolean; -}; -const KeybindingContext = createContext(null); + invokeAction: (action: string) => boolean +} + +const KeybindingContext = createContext(null) + type ProviderProps = { - bindings: ParsedBinding[]; + bindings: ParsedBinding[] /** Ref for immediate access to pending chord (avoids React state delay) */ - pendingChordRef: RefObject; + pendingChordRef: RefObject /** State value for re-renders (UI updates) */ - pendingChord: ParsedKeystroke[] | null; - setPendingChord: (pending: ParsedKeystroke[] | null) => void; - activeContexts: Set; - registerActiveContext: (context: KeybindingContextName) => void; - unregisterActiveContext: (context: KeybindingContextName) => void; + pendingChord: ParsedKeystroke[] | null + setPendingChord: (pending: ParsedKeystroke[] | null) => void + activeContexts: Set + registerActiveContext: (context: KeybindingContextName) => void + unregisterActiveContext: (context: KeybindingContextName) => void /** Ref to handler registry (used by ChordInterceptor) */ - handlerRegistryRef: RefObject>>; - children: React.ReactNode; -}; -export function KeybindingProvider(t0) { - const $ = _c(24); - const { - bindings, - pendingChordRef, - pendingChord, - setPendingChord, - activeContexts, - registerActiveContext, - unregisterActiveContext, - handlerRegistryRef, - children - } = t0; - let t1; - if ($[0] !== bindings) { - t1 = (action, context) => getBindingDisplayText(action, context, bindings); - $[0] = bindings; - $[1] = t1; - } else { - t1 = $[1]; - } - const getDisplay = t1; - let t2; - if ($[2] !== handlerRegistryRef) { - t2 = registration => { - const registry = handlerRegistryRef.current; - if (!registry) { - return _temp; - } + handlerRegistryRef: RefObject>> + children: React.ReactNode +} + +export function KeybindingProvider({ + bindings, + pendingChordRef, + pendingChord, + setPendingChord, + activeContexts, + registerActiveContext, + unregisterActiveContext, + handlerRegistryRef, + children, +}: ProviderProps): React.ReactNode { + const value = useMemo(() => { + const getDisplay = (action: string, context: KeybindingContextName) => + getBindingDisplayText(action, context, bindings) + + // Register a handler for an action + const registerHandler = (registration: HandlerRegistration) => { + const registry = handlerRegistryRef.current + if (!registry) return () => {} + if (!registry.has(registration.action)) { - registry.set(registration.action, new Set()); + registry.set(registration.action, new Set()) } - registry.get(registration.action).add(registration); + registry.get(registration.action)!.add(registration) + + // Return unregister function return () => { - const handlers = registry.get(registration.action); + const handlers = registry.get(registration.action) if (handlers) { - handlers.delete(registration); + handlers.delete(registration) if (handlers.size === 0) { - registry.delete(registration.action); + registry.delete(registration.action) } } - }; - }; - $[2] = handlerRegistryRef; - $[3] = t2; - } else { - t2 = $[3]; - } - const registerHandler = t2; - let t3; - if ($[4] !== activeContexts || $[5] !== handlerRegistryRef) { - t3 = action_0 => { - const registry_0 = handlerRegistryRef.current; - if (!registry_0) { - return false; } - const handlers_0 = registry_0.get(action_0); - if (!handlers_0 || handlers_0.size === 0) { - return false; - } - for (const registration_0 of handlers_0) { - if (activeContexts.has(registration_0.context)) { - registration_0.handler(); - return true; + } + + // Invoke all handlers for an action + const invokeAction = (action: string): boolean => { + const registry = handlerRegistryRef.current + if (!registry) return false + + const handlers = registry.get(action) + if (!handlers || handlers.size === 0) return false + + // Find handlers whose context is active + for (const registration of handlers) { + if (activeContexts.has(registration.context)) { + registration.handler() + return true } } - return false; - }; - $[4] = activeContexts; - $[5] = handlerRegistryRef; - $[6] = t3; - } else { - t3 = $[6]; - } - const invokeAction = t3; - let t4; - if ($[7] !== bindings || $[8] !== pendingChordRef) { - t4 = (input, key, contexts) => resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); - $[7] = bindings; - $[8] = pendingChordRef; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== activeContexts || $[11] !== bindings || $[12] !== getDisplay || $[13] !== invokeAction || $[14] !== pendingChord || $[15] !== registerActiveContext || $[16] !== registerHandler || $[17] !== setPendingChord || $[18] !== t4 || $[19] !== unregisterActiveContext) { - t5 = { - resolve: t4, + return false + } + + return { + // Use ref for immediate access to pending chord, avoiding React state delay + // This is critical for chord sequences where the second key might be pressed + // before React re-renders with the updated pendingChord state + resolve: (input, key, contexts) => + resolveKeyWithChordState( + input, + key, + contexts, + bindings, + pendingChordRef.current, + ), setPendingChord, getDisplayText: getDisplay, bindings, @@ -152,49 +155,42 @@ export function KeybindingProvider(t0) { registerActiveContext, unregisterActiveContext, registerHandler, - invokeAction - }; - $[10] = activeContexts; - $[11] = bindings; - $[12] = getDisplay; - $[13] = invokeAction; - $[14] = pendingChord; - $[15] = registerActiveContext; - $[16] = registerHandler; - $[17] = setPendingChord; - $[18] = t4; - $[19] = unregisterActiveContext; - $[20] = t5; - } else { - t5 = $[20]; - } - const value = t5; - let t6; - if ($[21] !== children || $[22] !== value) { - t6 = {children}; - $[21] = children; - $[22] = value; - $[23] = t6; - } else { - t6 = $[23]; - } - return t6; + invokeAction, + } + }, [ + bindings, + pendingChordRef, + pendingChord, + setPendingChord, + activeContexts, + registerActiveContext, + unregisterActiveContext, + handlerRegistryRef, + ]) + + return ( + + {children} + + ) } -function _temp() {} -export function useKeybindingContext() { - const ctx = useContext(KeybindingContext); + +export function useKeybindingContext(): KeybindingContextValue { + const ctx = useContext(KeybindingContext) if (!ctx) { - throw new Error("useKeybindingContext must be used within KeybindingProvider"); + throw new Error( + 'useKeybindingContext must be used within KeybindingProvider', + ) } - return ctx; + return ctx } /** * Optional hook that returns undefined outside of KeybindingProvider. * Useful for components that may render before provider is available. */ -export function useOptionalKeybindingContext() { - return useContext(KeybindingContext); +export function useOptionalKeybindingContext(): KeybindingContextValue | null { + return useContext(KeybindingContext) } /** @@ -212,31 +208,18 @@ export function useOptionalKeybindingContext() { * } * ``` */ -export function useRegisterKeybindingContext(context, t0) { - const $ = _c(5); - const isActive = t0 === undefined ? true : t0; - const keybindingContext = useOptionalKeybindingContext(); - let t1; - let t2; - if ($[0] !== context || $[1] !== isActive || $[2] !== keybindingContext) { - t1 = () => { - if (!keybindingContext || !isActive) { - return; - } - keybindingContext.registerActiveContext(context); - return () => { - keybindingContext.unregisterActiveContext(context); - }; - }; - t2 = [context, keybindingContext, isActive]; - $[0] = context; - $[1] = isActive; - $[2] = keybindingContext; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - useLayoutEffect(t1, t2); +export function useRegisterKeybindingContext( + context: KeybindingContextName, + isActive: boolean = true, +): void { + const keybindingContext = useOptionalKeybindingContext() + + useLayoutEffect(() => { + if (!keybindingContext || !isActive) return + + keybindingContext.registerActiveContext(context) + return () => { + keybindingContext.unregisterActiveContext(context) + } + }, [context, keybindingContext, isActive]) } diff --git a/src/keybindings/KeybindingProviderSetup.tsx b/src/keybindings/KeybindingProviderSetup.tsx index 85609e8d8..2397468c8 100644 --- a/src/keybindings/KeybindingProviderSetup.tsx +++ b/src/keybindings/KeybindingProviderSetup.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Setup utilities for integrating KeybindingProvider into the app. * @@ -7,30 +6,40 @@ import { c as _c } from "react/compiler-runtime"; * user-defined bindings from ~/.claude/keybindings.json, with hot-reload * support when the file changes. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import type { InputEvent } from '../ink/events/input-event.js'; +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useNotifications } from '../context/notifications.js' +import type { InputEvent } from '../ink/events/input-event.js' // ChordInterceptor intentionally uses useInput to intercept all keystrokes before // other handlers process them - this is required for chord sequence support // eslint-disable-next-line custom-rules/prefer-use-keybindings -import { type Key, useInput } from '../ink.js'; -import { count } from '../utils/array.js'; -import { logForDebugging } from '../utils/debug.js'; -import { plural } from '../utils/stringUtils.js'; -import { KeybindingProvider } from './KeybindingContext.js'; -import { initializeKeybindingWatcher, type KeybindingsLoadResult, loadKeybindingsSyncWithWarnings, subscribeToKeybindingChanges } from './loadUserBindings.js'; -import { resolveKeyWithChordState } from './resolver.js'; -import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; -import type { KeybindingWarning } from './validate.js'; +import { type Key, useInput } from '../ink.js' +import { count } from '../utils/array.js' +import { logForDebugging } from '../utils/debug.js' +import { plural } from '../utils/stringUtils.js' +import { KeybindingProvider } from './KeybindingContext.js' +import { + initializeKeybindingWatcher, + type KeybindingsLoadResult, + loadKeybindingsSyncWithWarnings, + subscribeToKeybindingChanges, +} from './loadUserBindings.js' +import { resolveKeyWithChordState } from './resolver.js' +import type { + KeybindingContextName, + ParsedBinding, + ParsedKeystroke, +} from './types.js' +import type { KeybindingWarning } from './validate.js' /** * Timeout for chord sequences in milliseconds. * If the user doesn't complete the chord within this time, it's cancelled. */ -const CHORD_TIMEOUT_MS = 1000; +const CHORD_TIMEOUT_MS = 1000 + type Props = { - children: React.ReactNode; -}; + children: React.ReactNode +} /** * Keybinding provider with default + user bindings and hot-reload support. @@ -56,156 +65,179 @@ type Props = { * Display keybinding warnings to the user via notifications. * Shows a brief message pointing to /doctor for details. */ -function useKeybindingWarnings(warnings, isReload) { - const $ = _c(9); - const { - addNotification, - removeNotification - } = useNotifications(); - let t0; - if ($[0] !== addNotification || $[1] !== removeNotification || $[2] !== warnings) { - t0 = () => { - if (warnings.length === 0) { - removeNotification("keybinding-config-warning"); - return; - } - const errorCount = count(warnings, _temp); - const warnCount = count(warnings, _temp2); - let message; - if (errorCount > 0 && warnCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, "error")} and ${warnCount} ${plural(warnCount, "warning")}`; - } else { - if (errorCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, "error")}`; - } else { - message = `Found ${warnCount} keybinding ${plural(warnCount, "warning")}`; - } - } - message = message + " \xB7 /doctor for details"; - addNotification({ - key: "keybinding-config-warning", - text: message, - color: errorCount > 0 ? "error" : "warning", - priority: errorCount > 0 ? "immediate" : "high", - timeoutMs: 60000 - }); - }; - $[0] = addNotification; - $[1] = removeNotification; - $[2] = warnings; - $[3] = t0; - } else { - t0 = $[3]; - } - let t1; - if ($[4] !== addNotification || $[5] !== isReload || $[6] !== removeNotification || $[7] !== warnings) { - t1 = [warnings, isReload, addNotification, removeNotification]; - $[4] = addNotification; - $[5] = isReload; - $[6] = removeNotification; - $[7] = warnings; - $[8] = t1; - } else { - t1 = $[8]; - } - useEffect(t0, t1); -} -function _temp2(w_0) { - return w_0.severity === "warning"; -} -function _temp(w) { - return w.severity === "error"; +function useKeybindingWarnings( + warnings: KeybindingWarning[], + isReload: boolean, +): void { + const { addNotification, removeNotification } = useNotifications() + + useEffect(() => { + const notificationKey = 'keybinding-config-warning' + + if (warnings.length === 0) { + removeNotification(notificationKey) + return + } + + const errorCount = count(warnings, w => w.severity === 'error') + const warnCount = count(warnings, w => w.severity === 'warning') + + let message: string + if (errorCount > 0 && warnCount > 0) { + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')} and ${warnCount} ${plural(warnCount, 'warning')}` + } else if (errorCount > 0) { + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')}` + } else { + message = `Found ${warnCount} keybinding ${plural(warnCount, 'warning')}` + } + message += ' · /doctor for details' + + addNotification({ + key: notificationKey, + text: message, + color: errorCount > 0 ? 'error' : 'warning', + priority: errorCount > 0 ? 'immediate' : 'high', + // Keep visible for 60 seconds like settings errors + timeoutMs: 60000, + }) + }, [warnings, isReload, addNotification, removeNotification]) } -export function KeybindingSetup({ - children -}: Props): React.ReactNode { + +export function KeybindingSetup({ children }: Props): React.ReactNode { // Load bindings synchronously for initial render - const [{ - bindings, - warnings - }, setLoadResult] = useState(() => { - const result = loadKeybindingsSyncWithWarnings(); - logForDebugging(`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`); - return result; - }); + const [{ bindings, warnings }, setLoadResult] = + useState(() => { + const result = loadKeybindingsSyncWithWarnings() + logForDebugging( + `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`, + ) + return result + }) // Track if this is a reload (not initial load) - const [isReload, setIsReload] = useState(false); + const [isReload, setIsReload] = useState(false) // Display warnings via notifications - useKeybindingWarnings(warnings, isReload); + useKeybindingWarnings(warnings, isReload) // Chord state management - use ref for immediate access, state for re-renders // The ref is used by resolve() to get the current value without waiting for re-render // The state is used to trigger re-renders when needed (e.g., for UI updates) - const pendingChordRef = useRef(null); - const [pendingChord, setPendingChordState] = useState(null); - const chordTimeoutRef = useRef(null); + const pendingChordRef = useRef(null) + const [pendingChord, setPendingChordState] = useState< + ParsedKeystroke[] | null + >(null) + const chordTimeoutRef = useRef(null) // Handler registry for action callbacks (used by ChordInterceptor to invoke handlers) - const handlerRegistryRef = useRef(new Map void; - }>>()); + const handlerRegistryRef = useRef( + new Map< + string, + Set<{ + action: string + context: KeybindingContextName + handler: () => void + }> + >(), + ) // Active context tracking for keybinding priority resolution // Using a ref instead of state for synchronous updates - input handlers need // to see the current value immediately, not after a React render cycle. - const activeContextsRef = useRef>(new Set()); - const registerActiveContext = useCallback((context: KeybindingContextName) => { - activeContextsRef.current.add(context); - }, []); - const unregisterActiveContext = useCallback((context_0: KeybindingContextName) => { - activeContextsRef.current.delete(context_0); - }, []); + const activeContextsRef = useRef>(new Set()) + + const registerActiveContext = useCallback( + (context: KeybindingContextName) => { + activeContextsRef.current.add(context) + }, + [], + ) + + const unregisterActiveContext = useCallback( + (context: KeybindingContextName) => { + activeContextsRef.current.delete(context) + }, + [], + ) // Clear chord timeout when component unmounts or chord changes const clearChordTimeout = useCallback(() => { if (chordTimeoutRef.current) { - clearTimeout(chordTimeoutRef.current); - chordTimeoutRef.current = null; + clearTimeout(chordTimeoutRef.current) + chordTimeoutRef.current = null } - }, []); + }, []) // Wrapper for setPendingChord that manages timeout and syncs ref+state - const setPendingChord = useCallback((pending: ParsedKeystroke[] | null) => { - clearChordTimeout(); - if (pending !== null) { - // Set timeout to cancel chord if not completed - chordTimeoutRef.current = setTimeout((pendingChordRef_0, setPendingChordState_0) => { - logForDebugging('[keybindings] Chord timeout - cancelling'); - pendingChordRef_0.current = null; - setPendingChordState_0(null); - }, CHORD_TIMEOUT_MS, pendingChordRef, setPendingChordState); - } + const setPendingChord = useCallback( + (pending: ParsedKeystroke[] | null) => { + clearChordTimeout() + + if (pending !== null) { + // Set timeout to cancel chord if not completed + chordTimeoutRef.current = setTimeout( + (pendingChordRef, setPendingChordState) => { + logForDebugging('[keybindings] Chord timeout - cancelling') + pendingChordRef.current = null + setPendingChordState(null) + }, + CHORD_TIMEOUT_MS, + pendingChordRef, + setPendingChordState, + ) + } + + // Update ref immediately for synchronous access in resolve() + pendingChordRef.current = pending + // Update state to trigger re-renders for UI updates + setPendingChordState(pending) + }, + [clearChordTimeout], + ) - // Update ref immediately for synchronous access in resolve() - pendingChordRef.current = pending; - // Update state to trigger re-renders for UI updates - setPendingChordState(pending); - }, [clearChordTimeout]); useEffect(() => { // Initialize file watcher (idempotent - only runs once) - void initializeKeybindingWatcher(); + void initializeKeybindingWatcher() // Subscribe to changes - const unsubscribe = subscribeToKeybindingChanges(result_0 => { + const unsubscribe = subscribeToKeybindingChanges(result => { // Any callback invocation is a reload since initial load happens // synchronously in useState, not via this subscription - setIsReload(true); - setLoadResult(result_0); - logForDebugging(`[keybindings] Reloaded: ${result_0.bindings.length} bindings, ${result_0.warnings.length} warnings`); - }); + setIsReload(true) + + setLoadResult(result) + logForDebugging( + `[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`, + ) + }) + return () => { - unsubscribe(); - clearChordTimeout(); - }; - }, [clearChordTimeout]); - return - + unsubscribe() + clearChordTimeout() + } + }, [clearChordTimeout]) + + return ( + + {children} - ; + + ) } /** @@ -219,89 +251,131 @@ export function KeybindingSetup({ * system could recognize it as completing a chord. */ type HandlerRegistration = { - action: string; - context: KeybindingContextName; - handler: () => void; -}; -function ChordInterceptor(t0) { - const $ = _c(6); - const { - bindings, - pendingChordRef, - setPendingChord, - activeContexts, - handlerRegistryRef - } = t0; - let t1; - if ($[0] !== activeContexts || $[1] !== bindings || $[2] !== handlerRegistryRef || $[3] !== pendingChordRef || $[4] !== setPendingChord) { - t1 = (input, key, event) => { + action: string + context: KeybindingContextName + handler: () => void +} + +function ChordInterceptor({ + bindings, + pendingChordRef, + setPendingChord, + activeContexts, + handlerRegistryRef, +}: { + bindings: ParsedBinding[] + pendingChordRef: React.RefObject + setPendingChord: (pending: ParsedKeystroke[] | null) => void + activeContexts: Set + handlerRegistryRef: React.RefObject>> +}): null { + const handleInput = useCallback( + (input: string, key: Key, event: InputEvent) => { + // Wheel events can never start chord sequences — scroll:lineUp/Down are + // single-key bindings handled by per-component useKeybindings hooks, not + // here. Skip the registry scan. Mid-chord wheel still falls through so + // scrolling cancels the pending chord like any other non-matching key. if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) { - return; + return } - const registry = handlerRegistryRef.current; - const handlerContexts = new Set(); + + // Build context list from registered handlers + activeContexts + Global + // This ensures we can resolve chords for all contexts that have handlers + const registry = handlerRegistryRef.current + const handlerContexts = new Set() if (registry) { for (const handlers of registry.values()) { for (const registration of handlers) { - handlerContexts.add(registration.context); + handlerContexts.add(registration.context) } } } - const contexts = [...handlerContexts, ...activeContexts, "Global"]; - const wasInChord = pendingChordRef.current !== null; - const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); - bb23: switch (result.type) { - case "chord_started": - { - setPendingChord(result.pending); - event.stopImmediatePropagation(); - break bb23; - } - case "match": - { - setPendingChord(null); - if (wasInChord) { - const contextsSet = new Set(contexts); - if (registry) { - const handlers_0 = registry.get(result.action); - if (handlers_0 && handlers_0.size > 0) { - for (const registration_0 of handlers_0) { - if (contextsSet.has(registration_0.context)) { - registration_0.handler(); - event.stopImmediatePropagation(); - break; - } + const contexts: KeybindingContextName[] = [ + ...handlerContexts, + ...activeContexts, + 'Global', + ] + + // Track whether we're completing a chord (pending was non-null) + const wasInChord = pendingChordRef.current !== null + + // Check if this keystroke is part of a chord sequence + const result = resolveKeyWithChordState( + input, + key, + contexts, + bindings, + pendingChordRef.current, + ) + + switch (result.type) { + case 'chord_started': + // This key starts a chord - store pending state and stop propagation + setPendingChord(result.pending) + event.stopImmediatePropagation() + break + + case 'match': { + // Clear pending state + setPendingChord(null) + + // Only invoke handlers and stop propagation for chord completions + // (multi-keystroke sequences). Single-keystroke matches should propagate + // to per-hook handlers to avoid interfering with other input handling + // (e.g., Enter needs to reach useTypeahead for autocomplete acceptance + // before the submit handler fires). + if (wasInChord) { + // Find and invoke the handler for this action + // We need to check that the handler's context is in our resolved contexts + // (which includes handlerContexts + activeContexts + Global) + const contextsSet = new Set(contexts) + if (registry) { + const handlers = registry.get(result.action) + if (handlers && handlers.size > 0) { + // Find handlers whose context is in our resolved contexts + for (const registration of handlers) { + if (contextsSet.has(registration.context)) { + registration.handler() + event.stopImmediatePropagation() + break // Only invoke the first matching handler } } } } - break bb23; - } - case "chord_cancelled": - { - setPendingChord(null); - event.stopImmediatePropagation(); - break bb23; } - case "unbound": - { - setPendingChord(null); - event.stopImmediatePropagation(); - break bb23; - } - case "none": + break + } + + case 'chord_cancelled': + // Invalid key during chord - clear pending state and swallow the + // keystroke so it doesn't propagate as a standalone action + // (e.g., ctrl+x ctrl+c should not fire app:interrupt). + setPendingChord(null) + event.stopImmediatePropagation() + break + + case 'unbound': + // Key is explicitly unbound - clear pending state and swallow + // the keystroke (it was part of a chord sequence). + setPendingChord(null) + event.stopImmediatePropagation() + break + + case 'none': + // No chord involvement - let other handlers process + break } - }; - $[0] = activeContexts; - $[1] = bindings; - $[2] = handlerRegistryRef; - $[3] = pendingChordRef; - $[4] = setPendingChord; - $[5] = t1; - } else { - t1 = $[5]; - } - const handleInput = t1; - useInput(handleInput); - return null; + }, + [ + bindings, + pendingChordRef, + setPendingChord, + activeContexts, + handlerRegistryRef, + ], + ) + + useInput(handleInput) + + return null } diff --git a/src/main.tsx b/src/main.tsx index 56eb77b26..b382aee15 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,207 +6,449 @@ // key) in parallel — isRemoteManagedSettingsEligible() otherwise reads them // sequentially via sync spawn inside applySafeConfigEnvironmentVariables() // (~65ms on every macOS startup) -import { profileCheckpoint, profileReport } from './utils/startupProfiler.js'; +import { profileCheckpoint, profileReport } from './utils/startupProfiler.js' // eslint-disable-next-line custom-rules/no-top-level-side-effects -profileCheckpoint('main_tsx_entry'); -import { startMdmRawRead } from './utils/settings/mdm/rawRead.js'; +profileCheckpoint('main_tsx_entry') + +import { startMdmRawRead } from './utils/settings/mdm/rawRead.js' // eslint-disable-next-line custom-rules/no-top-level-side-effects -startMdmRawRead(); -import { ensureKeychainPrefetchCompleted, startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js'; +startMdmRawRead() + +import { + ensureKeychainPrefetchCompleted, + startKeychainPrefetch, +} from './utils/secureStorage/keychainPrefetch.js' // eslint-disable-next-line custom-rules/no-top-level-side-effects -startKeychainPrefetch(); -import { feature } from 'bun:bundle'; -import { Command as CommanderCommand, InvalidArgumentError, Option } from '@commander-js/extra-typings'; -import chalk from 'chalk'; -import { readFileSync } from 'fs'; -import mapValues from 'lodash-es/mapValues.js'; -import pickBy from 'lodash-es/pickBy.js'; -import uniqBy from 'lodash-es/uniqBy.js'; -import React from 'react'; -import { getOauthConfig } from './constants/oauth.js'; -import { getRemoteSessionUrl } from './constants/product.js'; -import { getSystemContext, getUserContext } from './context.js'; -import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js'; -import { addToHistory } from './history.js'; -import type { Root } from './ink.js'; -import { launchRepl } from './replLauncher.js'; -import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterAuthChange } from './services/analytics/growthbook.js'; -import { fetchBootstrapData } from './services/api/bootstrap.js'; -import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js'; -import { prefetchPassesEligibility } from './services/api/referral.js'; -import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js'; -import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js'; -import { isPolicyAllowed, loadPolicyLimits, refreshPolicyLimits, waitForPolicyLimitsToLoad } from './services/policyLimits/index.js'; -import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js'; -import type { ToolInputJSONSchema } from './Tool.js'; -import { createSyntheticOutputTool, isSyntheticOutputToolEnabled } from './tools/SyntheticOutputTool/SyntheticOutputTool.js'; -import { getTools } from './tools.js'; -import { canUserConfigureAdvisor, getInitialAdvisorSetting, isAdvisorEnabled, isValidAdvisorModel, modelSupportsAdvisor } from './utils/advisor.js'; -import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'; -import { count, uniq } from './utils/array.js'; -import { installAsciicastRecorder } from './utils/asciicast.js'; -import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBedRockInfoIfSafe, prefetchGcpCredentialsIfSafe, validateForceLoginOrg } from './utils/auth.js'; -import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, isAutoUpdaterDisabled, saveGlobalConfig } from './utils/config.js'; -import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js'; -import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js'; -import { getInitialFastModeSetting, isFastModeEnabled, prefetchFastModeStatus, resolveFastModeStatusFromCache } from './utils/fastMode.js'; -import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; -import { createSystemMessage, createUserMessage } from './utils/messages.js'; -import { getPlatform } from './utils/platform.js'; -import { getBaseRenderOptions } from './utils/renderOptions.js'; -import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js'; -import { settingsChangeDetector } from './utils/settings/changeDetector.js'; -import { skillChangeDetector } from './utils/skills/skillChangeDetector.js'; -import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js'; -import { computeInitialTeamContext } from './utils/swarm/reconnection.js'; -import { initializeWarningHandler } from './utils/warningHandler.js'; -import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js'; +startKeychainPrefetch() + +import { feature } from 'bun:bundle' +import { + Command as CommanderCommand, + InvalidArgumentError, + Option, +} from '@commander-js/extra-typings' +import chalk from 'chalk' +import { readFileSync } from 'fs' +import mapValues from 'lodash-es/mapValues.js' +import pickBy from 'lodash-es/pickBy.js' +import uniqBy from 'lodash-es/uniqBy.js' +import React from 'react' +import { getOauthConfig } from './constants/oauth.js' +import { getRemoteSessionUrl } from './constants/product.js' +import { getSystemContext, getUserContext } from './context.js' +import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js' +import { addToHistory } from './history.js' +import type { Root } from './ink.js' +import { launchRepl } from './replLauncher.js' +import { + hasGrowthBookEnvOverride, + initializeGrowthBook, + refreshGrowthBookAfterAuthChange, +} from './services/analytics/growthbook.js' +import { fetchBootstrapData } from './services/api/bootstrap.js' +import { + type DownloadResult, + downloadSessionFiles, + type FilesApiConfig, + parseFileSpecs, +} from './services/api/filesApi.js' +import { prefetchPassesEligibility } from './services/api/referral.js' +import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js' +import type { + McpSdkServerConfig, + McpServerConfig, + ScopedMcpServerConfig, +} from './services/mcp/types.js' +import { + isPolicyAllowed, + loadPolicyLimits, + refreshPolicyLimits, + waitForPolicyLimitsToLoad, +} from './services/policyLimits/index.js' +import { + loadRemoteManagedSettings, + refreshRemoteManagedSettings, +} from './services/remoteManagedSettings/index.js' +import type { ToolInputJSONSchema } from './Tool.js' +import { + createSyntheticOutputTool, + isSyntheticOutputToolEnabled, +} from './tools/SyntheticOutputTool/SyntheticOutputTool.js' +import { getTools } from './tools.js' +import { + canUserConfigureAdvisor, + getInitialAdvisorSetting, + isAdvisorEnabled, + isValidAdvisorModel, + modelSupportsAdvisor, +} from './utils/advisor.js' +import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js' +import { count, uniq } from './utils/array.js' +import { installAsciicastRecorder } from './utils/asciicast.js' +import { + getSubscriptionType, + isClaudeAISubscriber, + prefetchAwsCredentialsAndBedRockInfoIfSafe, + prefetchGcpCredentialsIfSafe, + validateForceLoginOrg, +} from './utils/auth.js' +import { + checkHasTrustDialogAccepted, + getGlobalConfig, + getRemoteControlAtStartup, + isAutoUpdaterDisabled, + saveGlobalConfig, +} from './utils/config.js' +import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js' +import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js' +import { + getInitialFastModeSetting, + isFastModeEnabled, + prefetchFastModeStatus, + resolveFastModeStatusFromCache, +} from './utils/fastMode.js' +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js' +import { createSystemMessage, createUserMessage } from './utils/messages.js' +import { getPlatform } from './utils/platform.js' +import { getBaseRenderOptions } from './utils/renderOptions.js' +import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js' +import { settingsChangeDetector } from './utils/settings/changeDetector.js' +import { skillChangeDetector } from './utils/skills/skillChangeDetector.js' +import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js' +import { computeInitialTeamContext } from './utils/swarm/reconnection.js' +import { initializeWarningHandler } from './utils/warningHandler.js' +import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js' // Lazy require to avoid circular dependency: teammate.ts -> AppState.tsx -> ... -> main.tsx /* eslint-disable @typescript-eslint/no-require-imports */ -const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js'); -const getTeammatePromptAddendum = () => require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js'); -const getTeammateModeSnapshot = () => require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js'); +const getTeammateUtils = () => + require('./utils/teammate.js') as typeof import('./utils/teammate.js') +const getTeammatePromptAddendum = () => + require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js') +const getTeammateModeSnapshot = () => + require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js') /* eslint-enable @typescript-eslint/no-require-imports */ // Dead code elimination: conditional import for COORDINATOR_MODE /* eslint-disable @typescript-eslint/no-require-imports */ -const coordinatorModeModule = feature('COORDINATOR_MODE') ? require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js') : null; +const coordinatorModeModule = feature('COORDINATOR_MODE') + ? (require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Dead code elimination: conditional import for KAIROS (assistant mode) /* eslint-disable @typescript-eslint/no-require-imports */ -const assistantModule = feature('KAIROS') ? require('./assistant/index.js') as typeof import('./assistant/index.js') : null; -const kairosGate = feature('KAIROS') ? require('./assistant/gate.js') as typeof import('./assistant/gate.js') : null; -import { relative, resolve } from 'path'; -import { isAnalyticsDisabled } from 'src/services/analytics/config.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { initializeAnalyticsGates } from 'src/services/analytics/sink.js'; -import { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js'; -import { filterCommandsForRemoteMode, getCommands } from './commands.js'; -import type { StatsStore } from './context/stats.js'; -import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js'; -import { SHOW_CURSOR } from './ink/termio/dec.js'; -import { exitWithError, exitWithMessage, getRenderContext, renderAndRun, showSetupScreens } from './interactiveHelpers.js'; -import { initBuiltinPlugins } from './plugins/bundled/index.js'; +const assistantModule = feature('KAIROS') + ? (require('./assistant/index.js') as typeof import('./assistant/index.js')) + : null +const kairosGate = feature('KAIROS') + ? (require('./assistant/gate.js') as typeof import('./assistant/gate.js')) + : null + +import { relative, resolve } from 'path' +import { isAnalyticsDisabled } from 'src/services/analytics/config.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { initializeAnalyticsGates } from 'src/services/analytics/sink.js' +import { + getOriginalCwd, + setAdditionalDirectoriesForClaudeMd, + setIsRemoteMode, + setMainLoopModelOverride, + setMainThreadAgentType, + setTeleportedSessionInfo, +} from './bootstrap/state.js' +import { filterCommandsForRemoteMode, getCommands } from './commands.js' +import type { StatsStore } from './context/stats.js' +import { + launchAssistantInstallWizard, + launchAssistantSessionChooser, + launchInvalidSettingsDialog, + launchResumeChooser, + launchSnapshotUpdateDialog, + launchTeleportRepoMismatchDialog, + launchTeleportResumeWrapper, +} from './dialogLaunchers.js' +import { SHOW_CURSOR } from './ink/termio/dec.js' +import { + exitWithError, + exitWithMessage, + getRenderContext, + renderAndRun, + showSetupScreens, +} from './interactiveHelpers.js' +import { initBuiltinPlugins } from './plugins/bundled/index.js' /* eslint-enable @typescript-eslint/no-require-imports */ -import { checkQuotaStatus } from './services/claudeAiLimits.js'; -import { getMcpToolsCommandsAndResources, prefetchAllMcpResources } from './services/mcp/client.js'; -import { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } from './services/plugins/pluginCliCommands.js'; -import { initBundledSkills } from './skills/bundled/index.js'; -import type { AgentColorName } from './tools/AgentTool/agentColorManager.js'; -import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAgent, isCustomAgent, parseAgentsFromJson } from './tools/AgentTool/loadAgentsDir.js'; -import type { LogOption } from './types/logs.js'; -import type { Message as MessageType } from './types/message.js'; -import { assertMinVersion } from './utils/autoUpdater.js'; -import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER } from './utils/claudeInChrome/prompt.js'; -import { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome } from './utils/claudeInChrome/setup.js'; -import { getContextWindowForModel } from './utils/context.js'; -import { loadConversationForResume } from './utils/conversationRecovery.js'; -import { buildDeepLinkBanner } from './utils/deepLink/banner.js'; -import { hasNodeOption, isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js'; -import { refreshExampleCommands } from './utils/exampleCommands.js'; -import type { FpsMetrics } from './utils/fpsTracker.js'; -import { getWorktreePaths } from './utils/getWorktreePaths.js'; -import { findGitRoot, getBranch, getIsGit, getWorktreeCount } from './utils/git.js'; -import { getGhAuthStatus } from './utils/github/ghAuthStatus.js'; -import { safeParseJSON } from './utils/json.js'; -import { logError } from './utils/log.js'; -import { getModelDeprecationWarning } from './utils/model/deprecation.js'; -import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; -import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; -import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; -import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; -import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; -import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; -import { getManagedPluginNames } from './utils/plugins/managedPlugins.js'; -import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js'; -import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js'; -import { countFilesRoundedRg } from './utils/ripgrep.js'; -import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js'; -import { cacheSessionTitle, getSessionIdFromLog, loadTranscriptFromFile, saveAgentSetting, saveMode, searchSessionsByCustomTitle, sessionIdExists } from './utils/sessionStorage.js'; -import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js'; -import { getInitialSettings, getManagedSettingsKeysForLogging, getSettingsForSource, getSettingsWithErrors } from './utils/settings/settings.js'; -import { resetSettingsCache } from './utils/settings/settingsCache.js'; -import type { ValidationError } from './utils/settings/validation.js'; -import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.js'; -import { logPluginLoadErrors, logPluginsEnabledForSession } from './utils/telemetry/pluginTelemetry.js'; -import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js'; -import { generateTempFilePath } from './utils/tempfile.js'; -import { validateUuid } from './utils/uuid.js'; +import { checkQuotaStatus } from './services/claudeAiLimits.js' +import { + getMcpToolsCommandsAndResources, + prefetchAllMcpResources, +} from './services/mcp/client.js' +import { + VALID_INSTALLABLE_SCOPES, + VALID_UPDATE_SCOPES, +} from './services/plugins/pluginCliCommands.js' +import { initBundledSkills } from './skills/bundled/index.js' +import type { AgentColorName } from './tools/AgentTool/agentColorManager.js' +import { + getActiveAgentsFromList, + getAgentDefinitionsWithOverrides, + isBuiltInAgent, + isCustomAgent, + parseAgentsFromJson, +} from './tools/AgentTool/loadAgentsDir.js' +import type { LogOption } from './types/logs.js' +import type { Message as MessageType } from './types/message.js' +import { assertMinVersion } from './utils/autoUpdater.js' +import { + CLAUDE_IN_CHROME_SKILL_HINT, + CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, +} from './utils/claudeInChrome/prompt.js' +import { + setupClaudeInChrome, + shouldAutoEnableClaudeInChrome, + shouldEnableClaudeInChrome, +} from './utils/claudeInChrome/setup.js' +import { getContextWindowForModel } from './utils/context.js' +import { loadConversationForResume } from './utils/conversationRecovery.js' +import { buildDeepLinkBanner } from './utils/deepLink/banner.js' +import { + hasNodeOption, + isBareMode, + isEnvTruthy, + isInProtectedNamespace, +} from './utils/envUtils.js' +import { refreshExampleCommands } from './utils/exampleCommands.js' +import type { FpsMetrics } from './utils/fpsTracker.js' +import { getWorktreePaths } from './utils/getWorktreePaths.js' +import { + findGitRoot, + getBranch, + getIsGit, + getWorktreeCount, +} from './utils/git.js' +import { getGhAuthStatus } from './utils/github/ghAuthStatus.js' +import { safeParseJSON } from './utils/json.js' +import { logError } from './utils/log.js' +import { getModelDeprecationWarning } from './utils/model/deprecation.js' +import { + getDefaultMainLoopModel, + getUserSpecifiedModelSetting, + normalizeModelStringForAPI, + parseUserSpecifiedModel, +} from './utils/model/model.js' +import { ensureModelStringsInitialized } from './utils/model/modelStrings.js' +import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js' +import { + checkAndDisableBypassPermissions, + getAutoModeEnabledStateIfCached, + initializeToolPermissionContext, + initialPermissionModeFromCLI, + isDefaultPermissionModeAuto, + parseToolListFromCLI, + removeDangerousPermissions, + stripDangerousPermissionsForAutoMode, + verifyAutoModeGateAccess, +} from './utils/permissions/permissionSetup.js' +import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js' +import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js' +import { getManagedPluginNames } from './utils/plugins/managedPlugins.js' +import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js' +import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js' +import { countFilesRoundedRg } from './utils/ripgrep.js' +import { + processSessionStartHooks, + processSetupHooks, +} from './utils/sessionStart.js' +import { + cacheSessionTitle, + getSessionIdFromLog, + loadTranscriptFromFile, + saveAgentSetting, + saveMode, + searchSessionsByCustomTitle, + sessionIdExists, +} from './utils/sessionStorage.js' +import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js' +import { + getInitialSettings, + getManagedSettingsKeysForLogging, + getSettingsForSource, + getSettingsWithErrors, +} from './utils/settings/settings.js' +import { resetSettingsCache } from './utils/settings/settingsCache.js' +import type { ValidationError } from './utils/settings/validation.js' +import { + DEFAULT_TASKS_MODE_TASK_LIST_ID, + TASK_STATUSES, +} from './utils/tasks.js' +import { + logPluginLoadErrors, + logPluginsEnabledForSession, +} from './utils/telemetry/pluginTelemetry.js' +import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js' +import { generateTempFilePath } from './utils/tempfile.js' +import { validateUuid } from './utils/uuid.js' // Plugin startup checks are now handled non-blockingly in REPL.tsx -import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js'; -import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js'; -import { logPermissionContextForAnts } from 'src/services/internalLogging.js'; -import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js'; -import { clearServerCache } from 'src/services/mcp/client.js'; -import { areMcpConfigsAllowedWithEnterpriseMcpConfig, dedupClaudeAiMcpServers, doesEnterpriseMcpConfigExist, filterMcpServersByPolicy, getClaudeCodeMcpConfigs, getMcpServerSignature, parseMcpConfig, parseMcpConfigFromFilePath } from 'src/services/mcp/config.js'; -import { excludeCommandsByServer, excludeResourcesByServer } from 'src/services/mcp/utils.js'; -import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js'; -import { getRelevantTips } from 'src/services/tips/tipRegistry.js'; -import { logContextMetrics } from 'src/utils/api.js'; -import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isClaudeInChromeMCPServer } from 'src/utils/claudeInChrome/common.js'; -import { registerCleanup } from 'src/utils/cleanupRegistry.js'; -import { eagerParseCliFlag } from 'src/utils/cliArgs.js'; -import { createEmptyAttributionState } from 'src/utils/commitAttribution.js'; -import { countConcurrentSessions, registerSession, updateSessionName } from 'src/utils/concurrentSessions.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { logForDebugging, setHasFormattedOutput } from 'src/utils/debug.js'; -import { errorMessage, getErrnoCode, isENOENT, TeleportOperationError, toError } from 'src/utils/errors.js'; -import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js'; -import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; -import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js'; -import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js'; -import { peekForStdinData, writeToStderr } from 'src/utils/process.js'; -import { setCwd } from 'src/utils/Shell.js'; -import { type ProcessedResume, processResumedConversation } from 'src/utils/sessionRestore.js'; -import { parseSettingSourcesFlag } from 'src/utils/settings/constants.js'; -import { plural } from 'src/utils/stringUtils.js'; -import { type ChannelEntry, getInitialMainLoopModel, getIsNonInteractiveSession, getSdkBetas, getSessionId, getUserMsgOptIn, setAllowedChannels, setAllowedSettingSources, setChromeFlagOverride, setClientType, setCwdState, setDirectConnectServerUrl, setFlagSettingsPath, setInitialMainLoopModel, setInlinePlugins, setIsInteractive, setKairosActive, setOriginalCwd, setQuestionPreviewFormat, setSdkBetas, setSessionBypassPermissionsMode, setSessionPersistenceDisabled, setSessionSource, setUserMsgOptIn, switchSession } from './bootstrap/state.js'; +import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js' +import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js' +import { logPermissionContextForAnts } from 'src/services/internalLogging.js' +import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js' +import { clearServerCache } from 'src/services/mcp/client.js' +import { + areMcpConfigsAllowedWithEnterpriseMcpConfig, + dedupClaudeAiMcpServers, + doesEnterpriseMcpConfigExist, + filterMcpServersByPolicy, + getClaudeCodeMcpConfigs, + getMcpServerSignature, + parseMcpConfig, + parseMcpConfigFromFilePath, +} from 'src/services/mcp/config.js' +import { + excludeCommandsByServer, + excludeResourcesByServer, +} from 'src/services/mcp/utils.js' +import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js' +import { getRelevantTips } from 'src/services/tips/tipRegistry.js' +import { logContextMetrics } from 'src/utils/api.js' +import { + CLAUDE_IN_CHROME_MCP_SERVER_NAME, + isClaudeInChromeMCPServer, +} from 'src/utils/claudeInChrome/common.js' +import { registerCleanup } from 'src/utils/cleanupRegistry.js' +import { eagerParseCliFlag } from 'src/utils/cliArgs.js' +import { createEmptyAttributionState } from 'src/utils/commitAttribution.js' +import { + countConcurrentSessions, + registerSession, + updateSessionName, +} from 'src/utils/concurrentSessions.js' +import { getCwd } from 'src/utils/cwd.js' +import { logForDebugging, setHasFormattedOutput } from 'src/utils/debug.js' +import { + errorMessage, + getErrnoCode, + isENOENT, + TeleportOperationError, + toError, +} from 'src/utils/errors.js' +import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js' +import { + gracefulShutdown, + gracefulShutdownSync, +} from 'src/utils/gracefulShutdown.js' +import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js' +import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js' +import { peekForStdinData, writeToStderr } from 'src/utils/process.js' +import { setCwd } from 'src/utils/Shell.js' +import { + type ProcessedResume, + processResumedConversation, +} from 'src/utils/sessionRestore.js' +import { parseSettingSourcesFlag } from 'src/utils/settings/constants.js' +import { plural } from 'src/utils/stringUtils.js' +import { + type ChannelEntry, + getInitialMainLoopModel, + getIsNonInteractiveSession, + getSdkBetas, + getSessionId, + getUserMsgOptIn, + setAllowedChannels, + setAllowedSettingSources, + setChromeFlagOverride, + setClientType, + setCwdState, + setDirectConnectServerUrl, + setFlagSettingsPath, + setInitialMainLoopModel, + setInlinePlugins, + setIsInteractive, + setKairosActive, + setOriginalCwd, + setQuestionPreviewFormat, + setSdkBetas, + setSessionBypassPermissionsMode, + setSessionPersistenceDisabled, + setSessionSource, + setUserMsgOptIn, + switchSession, +} from './bootstrap/state.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? require('./utils/permissions/autoModeState.js') as typeof import('./utils/permissions/autoModeState.js') : null; +const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') + ? (require('./utils/permissions/autoModeState.js') as typeof import('./utils/permissions/autoModeState.js')) + : null // TeleportRepoMismatchDialog, TeleportResumeWrapper dynamically imported at call sites -import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.js'; -import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js'; -import { migrateEnableAllProjectMcpServersToSettings } from './migrations/migrateEnableAllProjectMcpServersToSettings.js'; -import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js'; -import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js'; -import { migrateOpusToOpus1m } from './migrations/migrateOpusToOpus1m.js'; -import { migrateReplBridgeEnabledToRemoteControlAtStartup } from './migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js'; -import { migrateSonnet1mToSonnet45 } from './migrations/migrateSonnet1mToSonnet45.js'; -import { migrateSonnet45ToSonnet46 } from './migrations/migrateSonnet45ToSonnet46.js'; -import { resetAutoModeOptInForDefaultOffer } from './migrations/resetAutoModeOptInForDefaultOffer.js'; -import { resetProToOpusDefault } from './migrations/resetProToOpusDefault.js'; -import { createRemoteSessionConfig } from './remote/RemoteSessionManager.js'; +import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.js' +import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js' +import { migrateEnableAllProjectMcpServersToSettings } from './migrations/migrateEnableAllProjectMcpServersToSettings.js' +import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js' +import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js' +import { migrateOpusToOpus1m } from './migrations/migrateOpusToOpus1m.js' +import { migrateReplBridgeEnabledToRemoteControlAtStartup } from './migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js' +import { migrateSonnet1mToSonnet45 } from './migrations/migrateSonnet1mToSonnet45.js' +import { migrateSonnet45ToSonnet46 } from './migrations/migrateSonnet45ToSonnet46.js' +import { resetAutoModeOptInForDefaultOffer } from './migrations/resetAutoModeOptInForDefaultOffer.js' +import { resetProToOpusDefault } from './migrations/resetProToOpusDefault.js' +import { createRemoteSessionConfig } from './remote/RemoteSessionManager.js' /* eslint-enable @typescript-eslint/no-require-imports */ // teleportWithProgress dynamically imported at call site -import { createDirectConnectSession, DirectConnectError } from './server/createDirectConnectSession.js'; -import { initializeLspServerManager } from './services/lsp/manager.js'; -import { shouldEnablePromptSuggestion } from './services/PromptSuggestion/promptSuggestion.js'; -import { type AppState, getDefaultAppState, IDLE_SPECULATION_STATE } from './state/AppStateStore.js'; -import { onChangeAppState } from './state/onChangeAppState.js'; -import { createStore } from './state/store.js'; -import { asSessionId } from './types/ids.js'; -import { filterAllowedSdkBetas } from './utils/betas.js'; -import { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js'; -import { logForDiagnosticsNoPII } from './utils/diagLogs.js'; -import { filterExistingPaths, getKnownPathsForRepo } from './utils/githubRepoPathMapping.js'; -import { clearPluginCache, loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js'; -import { migrateChangelogFromConfig } from './utils/releaseNotes.js'; -import { SandboxManager } from './utils/sandbox/sandbox-adapter.js'; -import { fetchSession, prepareApiRequest } from './utils/teleport/api.js'; -import { checkOutTeleportedSessionBranch, processMessagesForTeleportResume, teleportToRemoteWithErrorHandling, validateGitState, validateSessionRepository } from './utils/teleport.js'; -import { shouldEnableThinkingByDefault, type ThinkingConfig } from './utils/thinking.js'; -import { initUser, resetUserCache } from './utils/user.js'; -import { getTmuxInstallInstructions, isTmuxAvailable, parsePRReference } from './utils/worktree.js'; +import { + createDirectConnectSession, + DirectConnectError, +} from './server/createDirectConnectSession.js' +import { initializeLspServerManager } from './services/lsp/manager.js' +import { shouldEnablePromptSuggestion } from './services/PromptSuggestion/promptSuggestion.js' +import { + type AppState, + getDefaultAppState, + IDLE_SPECULATION_STATE, +} from './state/AppStateStore.js' +import { onChangeAppState } from './state/onChangeAppState.js' +import { createStore } from './state/store.js' +import { asSessionId } from './types/ids.js' +import { filterAllowedSdkBetas } from './utils/betas.js' +import { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js' +import { logForDiagnosticsNoPII } from './utils/diagLogs.js' +import { + filterExistingPaths, + getKnownPathsForRepo, +} from './utils/githubRepoPathMapping.js' +import { + clearPluginCache, + loadAllPluginsCacheOnly, +} from './utils/plugins/pluginLoader.js' +import { migrateChangelogFromConfig } from './utils/releaseNotes.js' +import { SandboxManager } from './utils/sandbox/sandbox-adapter.js' +import { fetchSession, prepareApiRequest } from './utils/teleport/api.js' +import { + checkOutTeleportedSessionBranch, + processMessagesForTeleportResume, + teleportToRemoteWithErrorHandling, + validateGitState, + validateSessionRepository, +} from './utils/teleport.js' +import { + shouldEnableThinkingByDefault, + type ThinkingConfig, +} from './utils/thinking.js' +import { initUser, resetUserCache } from './utils/user.js' +import { + getTmuxInstallInstructions, + isTmuxAvailable, + parsePRReference, +} from './utils/worktree.js' // eslint-disable-next-line custom-rules/no-top-level-side-effects -profileCheckpoint('main_tsx_imports_loaded'); +profileCheckpoint('main_tsx_imports_loaded') /** * Log managed settings keys to Statsig for analytics. @@ -215,13 +457,15 @@ profileCheckpoint('main_tsx_imports_loaded'); */ function logManagedSettings(): void { try { - const policySettings = getSettingsForSource('policySettings'); + const policySettings = getSettingsForSource('policySettings') if (policySettings) { - const allKeys = getManagedSettingsKeysForLogging(policySettings); + const allKeys = getManagedSettingsKeysForLogging(policySettings) logEvent('tengu_managed_settings_loaded', { keyCount: allKeys.length, - keys: allKeys.join(',') as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + keys: allKeys.join( + ',', + ) as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } } catch { // Silently ignore errors - this is just for analytics @@ -230,7 +474,7 @@ function logManagedSettings(): void { // Check if running in debug/inspection mode function isBeingDebugged() { - const isBun = isRunningWithBun(); + const isBun = isRunningWithBun() // Check for inspect flags in process arguments (including all variants) const hasInspectArg = process.execArgv.some(arg => { @@ -239,33 +483,38 @@ function isBeingDebugged() { // from process.argv leak into process.execArgv (similar to https://github.com/oven-sh/bun/issues/11673) // This breaks use of --debug mode if we omit this branch // We're fine to skip that check, because Bun doesn't support Node.js legacy --debug or --debug-brk flags - return /--inspect(-brk)?/.test(arg); + return /--inspect(-brk)?/.test(arg) } else { // In Node.js, check for both --inspect and legacy --debug flags - return /--inspect(-brk)?|--debug(-brk)?/.test(arg); + return /--inspect(-brk)?|--debug(-brk)?/.test(arg) } - }); + }) // Check if NODE_OPTIONS contains inspect flags - const hasInspectEnv = process.env.NODE_OPTIONS && /--inspect(-brk)?|--debug(-brk)?/.test(process.env.NODE_OPTIONS); + const hasInspectEnv = + process.env.NODE_OPTIONS && + /--inspect(-brk)?|--debug(-brk)?/.test(process.env.NODE_OPTIONS) // Check if inspector is available and active (indicates debugging) try { // Dynamic import would be better but is async - use global object instead // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inspector = (global as any).require('inspector'); - const hasInspectorUrl = !!inspector.url(); - return hasInspectorUrl || hasInspectArg || hasInspectEnv; + const inspector = (global as any).require('inspector') + const hasInspectorUrl = !!inspector.url() + return hasInspectorUrl || hasInspectArg || hasInspectEnv } catch { // Ignore error and fall back to argument detection - return hasInspectArg || hasInspectEnv; + return hasInspectArg || hasInspectEnv } } -// Anti-debugging check disabled for local development -// if ((process.env.USER_TYPE) !== 'ant' && isBeingDebugged()) { -// process.exit(1); -// } +// Exit if we detect node debugging or inspection +if ("external" !== 'ant' && isBeingDebugged()) { + // Use process.exit directly here since we're in the top-level code before imports + // and gracefulShutdown is not yet available + // eslint-disable-next-line custom-rules/no-top-level-side-effects + process.exit(1) +} /** * Per-session skill/plugin telemetry. Called from both the interactive path @@ -274,78 +523,90 @@ function isBeingDebugged() { * call sites here rather than one here + one in QueryEngine. */ function logSessionTelemetry(): void { - const model = parseUserSpecifiedModel(getInitialMainLoopModel() ?? getDefaultMainLoopModel()); - void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas())); - void loadAllPluginsCacheOnly().then(({ - enabled, - errors - }) => { - const managedNames = getManagedPluginNames(); - logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs()); - logPluginLoadErrors(errors, managedNames); - }).catch(err => logError(err)); + const model = parseUserSpecifiedModel( + getInitialMainLoopModel() ?? getDefaultMainLoopModel(), + ) + void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas())) + void loadAllPluginsCacheOnly() + .then(({ enabled, errors }) => { + const managedNames = getManagedPluginNames() + logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs()) + logPluginLoadErrors(errors, managedNames) + }) + .catch(err => logError(err)) } + function getCertEnvVarTelemetry(): Record { - const result: Record = {}; + const result: Record = {} if (process.env.NODE_EXTRA_CA_CERTS) { - result.has_node_extra_ca_certs = true; + result.has_node_extra_ca_certs = true } if (process.env.CLAUDE_CODE_CLIENT_CERT) { - result.has_client_cert = true; + result.has_client_cert = true } if (hasNodeOption('--use-system-ca')) { - result.has_use_system_ca = true; + result.has_use_system_ca = true } if (hasNodeOption('--use-openssl-ca')) { - result.has_use_openssl_ca = true; + result.has_use_openssl_ca = true } - return result; + return result } + async function logStartupTelemetry(): Promise { - if (isAnalyticsDisabled()) return; - const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([getIsGit(), getWorktreeCount(), getGhAuthStatus()]); + if (isAnalyticsDisabled()) return + const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([ + getIsGit(), + getWorktreeCount(), + getGhAuthStatus(), + ]) + logEvent('tengu_startup_telemetry', { is_git: isGit, worktree_count: worktreeCount, - gh_auth_status: ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + gh_auth_status: + ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, sandbox_enabled: SandboxManager.isSandboxingEnabled(), - are_unsandboxed_commands_allowed: SandboxManager.areUnsandboxedCommandsAllowed(), - is_auto_bash_allowed_if_sandbox_enabled: SandboxManager.isAutoAllowBashIfSandboxedEnabled(), + are_unsandboxed_commands_allowed: + SandboxManager.areUnsandboxedCommandsAllowed(), + is_auto_bash_allowed_if_sandbox_enabled: + SandboxManager.isAutoAllowBashIfSandboxedEnabled(), auto_updater_disabled: isAutoUpdaterDisabled(), prefers_reduced_motion: getInitialSettings().prefersReducedMotion ?? false, - ...getCertEnvVarTelemetry() - }); + ...getCertEnvVarTelemetry(), + }) } // @[MODEL LAUNCH]: Consider any migrations you may need for model strings. See migrateSonnet1mToSonnet45.ts for an example. // Bump this when adding a new sync migration so existing users re-run the set. -const CURRENT_MIGRATION_VERSION = 11; +const CURRENT_MIGRATION_VERSION = 11 function runMigrations(): void { if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { - migrateAutoUpdatesToSettings(); - migrateBypassPermissionsAcceptedToSettings(); - migrateEnableAllProjectMcpServersToSettings(); - resetProToOpusDefault(); - migrateSonnet1mToSonnet45(); - migrateLegacyOpusToCurrent(); - migrateSonnet45ToSonnet46(); - migrateOpusToOpus1m(); - migrateReplBridgeEnabledToRemoteControlAtStartup(); + migrateAutoUpdatesToSettings() + migrateBypassPermissionsAcceptedToSettings() + migrateEnableAllProjectMcpServersToSettings() + resetProToOpusDefault() + migrateSonnet1mToSonnet45() + migrateLegacyOpusToCurrent() + migrateSonnet45ToSonnet46() + migrateOpusToOpus1m() + migrateReplBridgeEnabledToRemoteControlAtStartup() if (feature('TRANSCRIPT_CLASSIFIER')) { - resetAutoModeOptInForDefaultOffer(); + resetAutoModeOptInForDefaultOffer() } - if ((process.env.USER_TYPE) === 'ant') { - migrateFennecToOpus(); + if (process.env.USER_TYPE === 'ant') { + migrateFennecToOpus() } - saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION ? prev : { - ...prev, - migrationVersion: CURRENT_MIGRATION_VERSION - }); + saveGlobalConfig(prev => + prev.migrationVersion === CURRENT_MIGRATION_VERSION + ? prev + : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }, + ) } // Async migration - fire and forget since it's non-blocking migrateChangelogFromConfig().catch(() => { // Silently ignore migration errors - will retry on next startup - }); + }) } /** @@ -355,23 +616,23 @@ function runMigrations(): void { * non-interactive mode where trust is implicit. */ function prefetchSystemContextIfSafe(): void { - const isNonInteractiveSession = getIsNonInteractiveSession(); + const isNonInteractiveSession = getIsNonInteractiveSession() // In non-interactive mode (--print), trust dialog is skipped and // execution is considered trusted (as documented in help text) if (isNonInteractiveSession) { - logForDiagnosticsNoPII('info', 'prefetch_system_context_non_interactive'); - void getSystemContext(); - return; + logForDiagnosticsNoPII('info', 'prefetch_system_context_non_interactive') + void getSystemContext() + return } // In interactive mode, only prefetch if trust has already been established - const hasTrust = checkHasTrustDialogAccepted(); + const hasTrust = checkHasTrustDialogAccepted() if (hasTrust) { - logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust'); - void getSystemContext(); + logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust') + void getSystemContext() } else { - logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust'); + logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust') } // Otherwise, don't prefetch - wait for trust to be established first } @@ -387,56 +648,73 @@ export function startDeferredPrefetches(): void { // However, the spawned processes and async work still contend for CPU and event // loop time, which skews startup benchmarks (CPU profiles, time-to-first-render // measurements). Skip all of it when we're only measuring startup performance. - if (isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) || - // --bare: skip ALL prefetches. These are cache-warms for the REPL's - // first-turn responsiveness (initUser, getUserContext, tips, countFiles, - // modelCapabilities, change detectors). Scripted -p calls don't have a - // "user is typing" window to hide this work in — it's pure overhead on - // the critical path. - isBareMode()) { - return; + if ( + isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) || + // --bare: skip ALL prefetches. These are cache-warms for the REPL's + // first-turn responsiveness (initUser, getUserContext, tips, countFiles, + // modelCapabilities, change detectors). Scripted -p calls don't have a + // "user is typing" window to hide this work in — it's pure overhead on + // the critical path. + isBareMode() + ) { + return } // Process-spawning prefetches (consumed at first API call, user is still typing) - void initUser(); - void getUserContext(); - prefetchSystemContextIfSafe(); - void getRelevantTips(); - if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) { - void prefetchAwsCredentialsAndBedRockInfoIfSafe(); + void initUser() + void getUserContext() + prefetchSystemContextIfSafe() + void getRelevantTips() + if ( + isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && + !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH) + ) { + void prefetchAwsCredentialsAndBedRockInfoIfSafe() } - if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { - void prefetchGcpCredentialsIfSafe(); + if ( + isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && + !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH) + ) { + void prefetchGcpCredentialsIfSafe() } - void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); + void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []) // Analytics and feature flag initialization - void initializeAnalyticsGates(); - void prefetchOfficialMcpUrls(); - void refreshModelCapabilities(); + void initializeAnalyticsGates() + void prefetchOfficialMcpUrls() + + void refreshModelCapabilities() // File change detectors deferred from init() to unblock first render - void settingsChangeDetector.initialize(); + void settingsChangeDetector.initialize() if (!isBareMode()) { - void skillChangeDetector.initialize(); + void skillChangeDetector.initialize() } // Event loop stall detector — logs when the main thread is blocked >500ms - if ((process.env.USER_TYPE) === 'ant') { - void import('./utils/eventLoopStallDetector.js').then(m => m.startEventLoopStallDetector()); + if (process.env.USER_TYPE === 'ant') { + void import('./utils/eventLoopStallDetector.js').then(m => + m.startEventLoopStallDetector(), + ) } } + function loadSettingsFromFlag(settingsFile: string): void { try { - const trimmedSettings = settingsFile.trim(); - const looksLikeJson = trimmedSettings.startsWith('{') && trimmedSettings.endsWith('}'); - let settingsPath: string; + const trimmedSettings = settingsFile.trim() + const looksLikeJson = + trimmedSettings.startsWith('{') && trimmedSettings.endsWith('}') + + let settingsPath: string + if (looksLikeJson) { // It's a JSON string - validate and create temp file - const parsedJson = safeParseJSON(trimmedSettings); + const parsedJson = safeParseJSON(trimmedSettings) if (!parsedJson) { - process.stderr.write(chalk.red('Error: Invalid JSON provided to --settings\n')); - process.exit(1); + process.stderr.write( + chalk.red('Error: Invalid JSON provided to --settings\n'), + ) + process.exit(1) } // Create a temporary file and write the JSON to it. @@ -449,46 +727,57 @@ function loadSettingsFromFlag(settingsFile: string): void { // The content hash ensures identical settings produce the same path // across process boundaries (each SDK query() spawns a new process). settingsPath = generateTempFilePath('claude-settings', '.json', { - contentHash: trimmedSettings - }); - writeFileSync_DEPRECATED(settingsPath, trimmedSettings, 'utf8'); + contentHash: trimmedSettings, + }) + writeFileSync_DEPRECATED(settingsPath, trimmedSettings, 'utf8') } else { // It's a file path - resolve and validate by attempting to read - const { - resolvedPath: resolvedSettingsPath - } = safeResolvePath(getFsImplementation(), settingsFile); + const { resolvedPath: resolvedSettingsPath } = safeResolvePath( + getFsImplementation(), + settingsFile, + ) try { - readFileSync(resolvedSettingsPath, 'utf8'); + readFileSync(resolvedSettingsPath, 'utf8') } catch (e) { if (isENOENT(e)) { - process.stderr.write(chalk.red(`Error: Settings file not found: ${resolvedSettingsPath}\n`)); - process.exit(1); + process.stderr.write( + chalk.red( + `Error: Settings file not found: ${resolvedSettingsPath}\n`, + ), + ) + process.exit(1) } - throw e; + throw e } - settingsPath = resolvedSettingsPath; + settingsPath = resolvedSettingsPath } - setFlagSettingsPath(settingsPath); - resetSettingsCache(); + + setFlagSettingsPath(settingsPath) + resetSettingsCache() } catch (error) { if (error instanceof Error) { - logError(error); + logError(error) } - process.stderr.write(chalk.red(`Error processing settings: ${errorMessage(error)}\n`)); - process.exit(1); + process.stderr.write( + chalk.red(`Error processing settings: ${errorMessage(error)}\n`), + ) + process.exit(1) } } + function loadSettingSourcesFromFlag(settingSourcesArg: string): void { try { - const sources = parseSettingSourcesFlag(settingSourcesArg); - setAllowedSettingSources(sources); - resetSettingsCache(); + const sources = parseSettingSourcesFlag(settingSourcesArg) + setAllowedSettingSources(sources) + resetSettingsCache() } catch (error) { if (error instanceof Error) { - logError(error); + logError(error) } - process.stderr.write(chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`)); - process.exit(1); + process.stderr.write( + chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`), + ) + process.exit(1) } } @@ -497,143 +786,155 @@ function loadSettingSourcesFromFlag(settingSourcesArg: string): void { * This ensures settings are filtered from the start of initialization */ function eagerLoadSettings(): void { - profileCheckpoint('eagerLoadSettings_start'); + profileCheckpoint('eagerLoadSettings_start') // Parse --settings flag early to ensure settings are loaded before init() - const settingsFile = eagerParseCliFlag('--settings'); + const settingsFile = eagerParseCliFlag('--settings') if (settingsFile) { - loadSettingsFromFlag(settingsFile); + loadSettingsFromFlag(settingsFile) } // Parse --setting-sources flag early to control which sources are loaded - const settingSourcesArg = eagerParseCliFlag('--setting-sources'); + const settingSourcesArg = eagerParseCliFlag('--setting-sources') if (settingSourcesArg !== undefined) { - loadSettingSourcesFromFlag(settingSourcesArg); + loadSettingSourcesFromFlag(settingSourcesArg) } - profileCheckpoint('eagerLoadSettings_end'); + profileCheckpoint('eagerLoadSettings_end') } + function initializeEntrypoint(isNonInteractive: boolean): void { // Skip if already set (e.g., by SDK or other entrypoints) if (process.env.CLAUDE_CODE_ENTRYPOINT) { - return; + return } - const cliArgs = process.argv.slice(2); + + const cliArgs = process.argv.slice(2) // Check for MCP serve command (handle flags before mcp serve, e.g., --debug mcp serve) - const mcpIndex = cliArgs.indexOf('mcp'); + const mcpIndex = cliArgs.indexOf('mcp') if (mcpIndex !== -1 && cliArgs[mcpIndex + 1] === 'serve') { - process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp'; - return; + process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp' + return } + if (isEnvTruthy(process.env.CLAUDE_CODE_ACTION)) { - process.env.CLAUDE_CODE_ENTRYPOINT = 'claude-code-github-action'; - return; + process.env.CLAUDE_CODE_ENTRYPOINT = 'claude-code-github-action' + return } // Note: 'local-agent' entrypoint is set by the local agent mode launcher // via CLAUDE_CODE_ENTRYPOINT env var (handled by early return above) // Set based on interactive status - process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? 'sdk-cli' : 'cli'; + process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? 'sdk-cli' : 'cli' } // Set by early argv processing when `claude open ` is detected (interactive mode only) type PendingConnect = { - url: string | undefined; - authToken: string | undefined; - dangerouslySkipPermissions: boolean; -}; -const _pendingConnect: PendingConnect | undefined = feature('DIRECT_CONNECT') ? { - url: undefined, - authToken: undefined, - dangerouslySkipPermissions: false -} : undefined; + url: string | undefined + authToken: string | undefined + dangerouslySkipPermissions: boolean +} +const _pendingConnect: PendingConnect | undefined = feature('DIRECT_CONNECT') + ? { url: undefined, authToken: undefined, dangerouslySkipPermissions: false } + : undefined // Set by early argv processing when `claude assistant [sessionId]` is detected -type PendingAssistantChat = { - sessionId?: string; - discover: boolean; -}; -const _pendingAssistantChat: PendingAssistantChat | undefined = feature('KAIROS') ? { - sessionId: undefined, - discover: false -} : undefined; +type PendingAssistantChat = { sessionId?: string; discover: boolean } +const _pendingAssistantChat: PendingAssistantChat | undefined = feature( + 'KAIROS', +) + ? { sessionId: undefined, discover: false } + : undefined // `claude ssh [dir]` — parsed from argv early (same pattern as // DIRECT_CONNECT above) so the main command path can pick it up and hand // the REPL an SSH-backed session instead of a local one. type PendingSSH = { - host: string | undefined; - cwd: string | undefined; - permissionMode: string | undefined; - dangerouslySkipPermissions: boolean; + host: string | undefined + cwd: string | undefined + permissionMode: string | undefined + dangerouslySkipPermissions: boolean /** --local: spawn the child CLI directly, skip ssh/probe/deploy. e2e test mode. */ - local: boolean; + local: boolean /** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */ - extraCliArgs: string[]; -}; -const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE') ? { - host: undefined, - cwd: undefined, - permissionMode: undefined, - dangerouslySkipPermissions: false, - local: false, - extraCliArgs: [] -} : undefined; + extraCliArgs: string[] +} +const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE') + ? { + host: undefined, + cwd: undefined, + permissionMode: undefined, + dangerouslySkipPermissions: false, + local: false, + extraCliArgs: [], + } + : undefined + export async function main() { - profileCheckpoint('main_function_start'); + profileCheckpoint('main_function_start') // SECURITY: Prevent Windows from executing commands from current directory // This must be set before ANY command execution to prevent PATH hijacking attacks // See: https://docs.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-searchpathw - process.env.NoDefaultCurrentDirectoryInExePath = '1'; + process.env.NoDefaultCurrentDirectoryInExePath = '1' // Initialize warning handler early to catch warnings - initializeWarningHandler(); + initializeWarningHandler() + process.on('exit', () => { - resetCursor(); - }); + resetCursor() + }) process.on('SIGINT', () => { // In print mode, print.ts registers its own SIGINT handler that aborts // the in-flight query and calls gracefulShutdown; skip here to avoid // preempting it with a synchronous process.exit(). if (process.argv.includes('-p') || process.argv.includes('--print')) { - return; + return } - process.exit(0); - }); - profileCheckpoint('main_warning_handler_initialized'); + process.exit(0) + }) + profileCheckpoint('main_warning_handler_initialized') // Check for cc:// or cc+unix:// URL in argv — rewrite so the main command // handles it, giving the full interactive TUI instead of a stripped-down subcommand. // For headless (-p), we rewrite to the internal `open` subcommand. if (feature('DIRECT_CONNECT')) { - const rawCliArgs = process.argv.slice(2); - const ccIdx = rawCliArgs.findIndex(a => a.startsWith('cc://') || a.startsWith('cc+unix://')); + const rawCliArgs = process.argv.slice(2) + const ccIdx = rawCliArgs.findIndex( + a => a.startsWith('cc://') || a.startsWith('cc+unix://'), + ) if (ccIdx !== -1 && _pendingConnect) { - const ccUrl = rawCliArgs[ccIdx]!; - const { - parseConnectUrl - } = await import('./server/parseConnectUrl.js'); - const parsed = parseConnectUrl(ccUrl); - _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes('--dangerously-skip-permissions'); + const ccUrl = rawCliArgs[ccIdx]! + const { parseConnectUrl } = await import('./server/parseConnectUrl.js') + const parsed = parseConnectUrl(ccUrl) + _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes( + '--dangerously-skip-permissions', + ) + if (rawCliArgs.includes('-p') || rawCliArgs.includes('--print')) { // Headless: rewrite to internal `open` subcommand - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx) + const dspIdx = stripped.indexOf('--dangerously-skip-permissions') if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); + stripped.splice(dspIdx, 1) } - process.argv = [process.argv[0]!, process.argv[1]!, 'open', ccUrl, ...stripped]; + process.argv = [ + process.argv[0]!, + process.argv[1]!, + 'open', + ccUrl, + ...stripped, + ] } else { // Interactive: strip cc:// URL and flags, run main command - _pendingConnect.url = parsed.serverUrl; - _pendingConnect.authToken = parsed.authToken; - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); + _pendingConnect.url = parsed.serverUrl + _pendingConnect.authToken = parsed.authToken + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx) + const dspIdx = stripped.indexOf('--dangerously-skip-permissions') if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); + stripped.splice(dspIdx, 1) } - process.argv = [process.argv[0]!, process.argv[1]!, ...stripped]; + process.argv = [process.argv[0]!, process.argv[1]!, ...stripped] } } } @@ -642,34 +943,34 @@ export async function main() { // and should bail out before full init since it only needs to parse the URI // and open a terminal. if (feature('LODESTONE')) { - const handleUriIdx = process.argv.indexOf('--handle-uri'); + const handleUriIdx = process.argv.indexOf('--handle-uri') if (handleUriIdx !== -1 && process.argv[handleUriIdx + 1]) { - const { - enableConfigs - } = await import('./utils/config.js'); - enableConfigs(); - const uri = process.argv[handleUriIdx + 1]!; - const { - handleDeepLinkUri - } = await import('./utils/deepLink/protocolHandler.js'); - const exitCode = await handleDeepLinkUri(uri); - process.exit(exitCode); + const { enableConfigs } = await import('./utils/config.js') + enableConfigs() + const uri = process.argv[handleUriIdx + 1]! + const { handleDeepLinkUri } = await import( + './utils/deepLink/protocolHandler.js' + ) + const exitCode = await handleDeepLinkUri(uri) + process.exit(exitCode) } // macOS URL handler: when LaunchServices launches our .app bundle, the // URL arrives via Apple Event (not argv). LaunchServices overwrites // __CFBundleIdentifier to the launching bundle's ID, which is a precise // positive signal — cheaper than importing and guessing with heuristics. - if (process.platform === 'darwin' && process.env.__CFBundleIdentifier === 'com.anthropic.claude-code-url-handler') { - const { - enableConfigs - } = await import('./utils/config.js'); - enableConfigs(); - const { - handleUrlSchemeLaunch - } = await import('./utils/deepLink/protocolHandler.js'); - const urlSchemeResult = await handleUrlSchemeLaunch(); - process.exit(urlSchemeResult ?? 1); + if ( + process.platform === 'darwin' && + process.env.__CFBundleIdentifier === + 'com.anthropic.claude-code-url-handler' + ) { + const { enableConfigs } = await import('./utils/config.js') + enableConfigs() + const { handleUrlSchemeLaunch } = await import( + './utils/deepLink/protocolHandler.js' + ) + const urlSchemeResult = await handleUrlSchemeLaunch() + process.exit(urlSchemeResult ?? 1) } } @@ -680,17 +981,17 @@ export async function main() { // (e.g. `--debug assistant`) falls through to the stub, which // prints usage. if (feature('KAIROS') && _pendingAssistantChat) { - const rawArgs = process.argv.slice(2); + const rawArgs = process.argv.slice(2) if (rawArgs[0] === 'assistant') { - const nextArg = rawArgs[1]; + const nextArg = rawArgs[1] if (nextArg && !nextArg.startsWith('-')) { - _pendingAssistantChat.sessionId = nextArg; - rawArgs.splice(0, 2); // drop 'assistant' and sessionId - process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; + _pendingAssistantChat.sessionId = nextArg + rawArgs.splice(0, 2) // drop 'assistant' and sessionId + process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs] } else if (!nextArg) { - _pendingAssistantChat.discover = true; - rawArgs.splice(0, 1); // drop 'assistant' - process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; + _pendingAssistantChat.discover = true + rawArgs.splice(0, 1) // drop 'assistant' + process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs] } // else: `claude assistant --help` → fall through to stub } @@ -701,7 +1002,7 @@ export async function main() { // ~line 3720 to pick up. Headless (-p) mode not supported in v1: SSH // sessions need the local REPL to drive them (interrupt, permissions). if (feature('SSH_REMOTE') && _pendingSSH) { - const rawCliArgs = process.argv.slice(2); + const rawCliArgs = process.argv.slice(2) // SSH-specific flags can appear before the host positional (e.g. // `ssh --permission-mode auto host /tmp` — standard POSIX flags-before- // positionals). Pull them all out BEFORE checking whether a host was @@ -709,215 +1010,259 @@ export async function main() { // --permission-mode auto` are equivalent. The host check below only needs // to guard against `-h`/`--help` (which commander should handle). if (rawCliArgs[0] === 'ssh') { - const localIdx = rawCliArgs.indexOf('--local'); + const localIdx = rawCliArgs.indexOf('--local') if (localIdx !== -1) { - _pendingSSH.local = true; - rawCliArgs.splice(localIdx, 1); + _pendingSSH.local = true + rawCliArgs.splice(localIdx, 1) } - const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions'); + const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions') if (dspIdx !== -1) { - _pendingSSH.dangerouslySkipPermissions = true; - rawCliArgs.splice(dspIdx, 1); + _pendingSSH.dangerouslySkipPermissions = true + rawCliArgs.splice(dspIdx, 1) } - const pmIdx = rawCliArgs.indexOf('--permission-mode'); - if (pmIdx !== -1 && rawCliArgs[pmIdx + 1] && !rawCliArgs[pmIdx + 1]!.startsWith('-')) { - _pendingSSH.permissionMode = rawCliArgs[pmIdx + 1]; - rawCliArgs.splice(pmIdx, 2); + const pmIdx = rawCliArgs.indexOf('--permission-mode') + if ( + pmIdx !== -1 && + rawCliArgs[pmIdx + 1] && + !rawCliArgs[pmIdx + 1]!.startsWith('-') + ) { + _pendingSSH.permissionMode = rawCliArgs[pmIdx + 1] + rawCliArgs.splice(pmIdx, 2) } - const pmEqIdx = rawCliArgs.findIndex(a => a.startsWith('--permission-mode=')); + const pmEqIdx = rawCliArgs.findIndex(a => + a.startsWith('--permission-mode='), + ) if (pmEqIdx !== -1) { - _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1]; - rawCliArgs.splice(pmEqIdx, 1); + _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1] + rawCliArgs.splice(pmEqIdx, 1) } // Forward session-resume + model flags to the remote CLI's initial spawn. // --continue/-c and --resume operate on the REMOTE session history // (which persists under the remote's ~/.claude/projects//). // --model controls which model the remote uses. - const extractFlag = (flag: string, opts: { - hasValue?: boolean; - as?: string; - } = {}) => { - const i = rawCliArgs.indexOf(flag); + const extractFlag = ( + flag: string, + opts: { hasValue?: boolean; as?: string } = {}, + ) => { + const i = rawCliArgs.indexOf(flag) if (i !== -1) { - _pendingSSH.extraCliArgs.push(opts.as ?? flag); - const val = rawCliArgs[i + 1]; + _pendingSSH.extraCliArgs.push(opts.as ?? flag) + const val = rawCliArgs[i + 1] if (opts.hasValue && val && !val.startsWith('-')) { - _pendingSSH.extraCliArgs.push(val); - rawCliArgs.splice(i, 2); + _pendingSSH.extraCliArgs.push(val) + rawCliArgs.splice(i, 2) } else { - rawCliArgs.splice(i, 1); + rawCliArgs.splice(i, 1) } } - const eqI = rawCliArgs.findIndex(a => a.startsWith(`${flag}=`)); + const eqI = rawCliArgs.findIndex(a => a.startsWith(`${flag}=`)) if (eqI !== -1) { - _pendingSSH.extraCliArgs.push(opts.as ?? flag, rawCliArgs[eqI]!.slice(flag.length + 1)); - rawCliArgs.splice(eqI, 1); + _pendingSSH.extraCliArgs.push( + opts.as ?? flag, + rawCliArgs[eqI]!.slice(flag.length + 1), + ) + rawCliArgs.splice(eqI, 1) } - }; - extractFlag('-c', { - as: '--continue' - }); - extractFlag('--continue'); - extractFlag('--resume', { - hasValue: true - }); - extractFlag('--model', { - hasValue: true - }); + } + extractFlag('-c', { as: '--continue' }) + extractFlag('--continue') + extractFlag('--resume', { hasValue: true }) + extractFlag('--model', { hasValue: true }) } // After pre-extraction, any remaining dash-arg at [1] is either -h/--help // (commander handles) or an unknown-to-ssh flag (fall through to commander // so it surfaces a proper error). Only a non-dash arg is the host. - if (rawCliArgs[0] === 'ssh' && rawCliArgs[1] && !rawCliArgs[1].startsWith('-')) { - _pendingSSH.host = rawCliArgs[1]; + if ( + rawCliArgs[0] === 'ssh' && + rawCliArgs[1] && + !rawCliArgs[1].startsWith('-') + ) { + _pendingSSH.host = rawCliArgs[1] // Optional positional cwd. - let consumed = 2; + let consumed = 2 if (rawCliArgs[2] && !rawCliArgs[2].startsWith('-')) { - _pendingSSH.cwd = rawCliArgs[2]; - consumed = 3; + _pendingSSH.cwd = rawCliArgs[2] + consumed = 3 } - const rest = rawCliArgs.slice(consumed); + const rest = rawCliArgs.slice(consumed) // Headless (-p) mode is not supported with SSH in v1 — reject early // so the flag doesn't silently cause local execution. if (rest.includes('-p') || rest.includes('--print')) { - process.stderr.write('Error: headless (-p/--print) mode is not supported with claude ssh\n'); - gracefulShutdownSync(1); - return; + process.stderr.write( + 'Error: headless (-p/--print) mode is not supported with claude ssh\n', + ) + gracefulShutdownSync(1) + return } // Rewrite argv so the main command sees remaining flags but not `ssh`. - process.argv = [process.argv[0]!, process.argv[1]!, ...rest]; + process.argv = [process.argv[0]!, process.argv[1]!, ...rest] } } // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() // This is needed because telemetry initialization calls auth functions that need this flag - const cliArgs = process.argv.slice(2); - const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print'); - const hasInitOnlyFlag = cliArgs.includes('--init-only'); - const hasSdkUrl = cliArgs.some(arg => arg.startsWith('--sdk-url')); - const isNonInteractive = hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY; + const cliArgs = process.argv.slice(2) + const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print') + const hasInitOnlyFlag = cliArgs.includes('--init-only') + const hasSdkUrl = cliArgs.some(arg => arg.startsWith('--sdk-url')) + const isNonInteractive = + hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY // Stop capturing early input for non-interactive modes if (isNonInteractive) { - stopCapturingEarlyInput(); + stopCapturingEarlyInput() } // Set simplified tracking fields - const isInteractive = !isNonInteractive; - setIsInteractive(isInteractive); + const isInteractive = !isNonInteractive + setIsInteractive(isInteractive) // Initialize entrypoint based on mode - needs to be set before any event is logged - initializeEntrypoint(isNonInteractive); + initializeEntrypoint(isNonInteractive) // Determine client type const clientType = (() => { - if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return 'github-action'; - if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-ts') return 'sdk-typescript'; - if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-py') return 'sdk-python'; - if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-cli') return 'sdk-cli'; - if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-vscode') return 'claude-vscode'; - if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') return 'local-agent'; - if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop') return 'claude-desktop'; + if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return 'github-action' + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-ts') return 'sdk-typescript' + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-py') return 'sdk-python' + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-cli') return 'sdk-cli' + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-vscode') + return 'claude-vscode' + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') + return 'local-agent' + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop') + return 'claude-desktop' // Check if session-ingress token is provided (indicates remote session) - const hasSessionIngressToken = process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN || process.env.CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR; - if (process.env.CLAUDE_CODE_ENTRYPOINT === 'remote' || hasSessionIngressToken) { - return 'remote'; + const hasSessionIngressToken = + process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN || + process.env.CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR + if ( + process.env.CLAUDE_CODE_ENTRYPOINT === 'remote' || + hasSessionIngressToken + ) { + return 'remote' } - return 'cli'; - })(); - setClientType(clientType); - const previewFormat = process.env.CLAUDE_CODE_QUESTION_PREVIEW_FORMAT; + + return 'cli' + })() + setClientType(clientType) + + const previewFormat = process.env.CLAUDE_CODE_QUESTION_PREVIEW_FORMAT if (previewFormat === 'markdown' || previewFormat === 'html') { - setQuestionPreviewFormat(previewFormat); - } else if (!clientType.startsWith('sdk-') && - // Desktop and CCR pass previewFormat via toolConfig; when the feature is - // gated off they pass undefined — don't override that with markdown. - clientType !== 'claude-desktop' && clientType !== 'local-agent' && clientType !== 'remote') { - setQuestionPreviewFormat('markdown'); + setQuestionPreviewFormat(previewFormat) + } else if ( + !clientType.startsWith('sdk-') && + // Desktop and CCR pass previewFormat via toolConfig; when the feature is + // gated off they pass undefined — don't override that with markdown. + clientType !== 'claude-desktop' && + clientType !== 'local-agent' && + clientType !== 'remote' + ) { + setQuestionPreviewFormat('markdown') } // Tag sessions created via `claude remote-control` so the backend can identify them if (process.env.CLAUDE_CODE_ENVIRONMENT_KIND === 'bridge') { - setSessionSource('remote-control'); + setSessionSource('remote-control') } - profileCheckpoint('main_client_type_determined'); + + profileCheckpoint('main_client_type_determined') // Parse and load settings flags early, before init() - eagerLoadSettings(); - profileCheckpoint('main_before_run'); - await run(); - profileCheckpoint('main_after_run'); + eagerLoadSettings() + + profileCheckpoint('main_before_run') + + await run() + profileCheckpoint('main_after_run') } -async function getInputPrompt(prompt: string, inputFormat: 'text' | 'stream-json'): Promise> { - if (!process.stdin.isTTY && - // Input hijacking breaks MCP. - !process.argv.includes('mcp')) { + +async function getInputPrompt( + prompt: string, + inputFormat: 'text' | 'stream-json', +): Promise> { + if ( + !process.stdin.isTTY && + // Input hijacking breaks MCP. + !process.argv.includes('mcp') + ) { if (inputFormat === 'stream-json') { - return process.stdin; + return process.stdin } - process.stdin.setEncoding('utf8'); - let data = ''; + process.stdin.setEncoding('utf8') + let data = '' const onData = (chunk: string) => { - data += chunk; - }; - process.stdin.on('data', onData); + data += chunk + } + process.stdin.on('data', onData) // If no data arrives in 3s, stop waiting and warn. Stdin is likely an // inherited pipe from a parent that isn't writing (subprocess spawned // without explicit stdin handling). 3s covers slow producers like curl, // jq on large files, python with import overhead. The warning makes // silent data loss visible for the rare producer that's slower still. - const timedOut = await peekForStdinData(process.stdin, 3000); - process.stdin.off('data', onData); + const timedOut = await peekForStdinData(process.stdin, 3000) + process.stdin.off('data', onData) if (timedOut) { - process.stderr.write('Warning: no stdin data received in 3s, proceeding without it. ' + 'If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.\n'); + process.stderr.write( + 'Warning: no stdin data received in 3s, proceeding without it. ' + + 'If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.\n', + ) } - return [prompt, data].filter(Boolean).join('\n'); + return [prompt, data].filter(Boolean).join('\n') } - return prompt; + return prompt } + async function run(): Promise { - profileCheckpoint('run_function_start'); + profileCheckpoint('run_function_start') // Create help config that sorts options by long option name. // Commander supports compareOptions at runtime but @commander-js/extra-typings // doesn't include it in the type definitions, so we use Object.assign to add it. function createSortedHelpConfig(): { - sortSubcommands: true; - sortOptions: true; + sortSubcommands: true + sortOptions: true } { - const getOptionSortKey = (opt: Option): string => opt.long?.replace(/^--/, '') ?? opt.short?.replace(/^-/, '') ?? ''; - return Object.assign({ - sortSubcommands: true, - sortOptions: true - } as const, { - compareOptions: (a: Option, b: Option) => getOptionSortKey(a).localeCompare(getOptionSortKey(b)) - }); + const getOptionSortKey = (opt: Option): string => + opt.long?.replace(/^--/, '') ?? opt.short?.replace(/^-/, '') ?? '' + return Object.assign( + { sortSubcommands: true, sortOptions: true } as const, + { + compareOptions: (a: Option, b: Option) => + getOptionSortKey(a).localeCompare(getOptionSortKey(b)), + }, + ) } - const program = new CommanderCommand().configureHelp(createSortedHelpConfig()).enablePositionalOptions(); - profileCheckpoint('run_commander_initialized'); + const program = new CommanderCommand() + .configureHelp(createSortedHelpConfig()) + .enablePositionalOptions() + profileCheckpoint('run_commander_initialized') // Use preAction hook to run initialization only when executing a command, // not when displaying help. This avoids the need for env variable signaling. program.hook('preAction', async thisCommand => { - profileCheckpoint('preAction_start'); + profileCheckpoint('preAction_start') // Await async subprocess loads started at module evaluation (lines 12-20). // Nearly free — subprocesses complete during the ~135ms of imports above. // Must resolve before init() which triggers the first settings read // (applySafeConfigEnvironmentVariables → getSettingsForSource('policySettings') // → isRemoteManagedSettingsEligible → sync keychain reads otherwise ~65ms). - await Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]); - profileCheckpoint('preAction_after_mdm'); - await init(); - profileCheckpoint('preAction_after_init'); + await Promise.all([ + ensureMdmSettingsLoaded(), + ensureKeychainPrefetchCompleted(), + ]) + profileCheckpoint('preAction_after_mdm') + await init() + profileCheckpoint('preAction_after_init') // process.title on Windows sets the console title directly; on POSIX, // terminal shell integration may mirror the process name to the tab. // After init() so settings.json env can also gate this (gh-4765). if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) { - process.title = 'claude'; + process.title = 'claude' } // Attach logging sinks so subcommand handlers can use logEvent/logError. @@ -925,11 +1270,9 @@ async function run(): Promise { // a sink attaches. setup() attaches sinks for the default command, but // subcommands (doctor, mcp, plugin, auth) never call setup() and would // silently drop events on process.exit(). Both inits are idempotent. - const { - initSinks - } = await import('./utils/sinks.js'); - initSinks(); - profileCheckpoint('preAction_after_sinks'); + const { initSinks } = await import('./utils/sinks.js') + initSinks() + profileCheckpoint('preAction_after_sinks') // gh-33508: --plugin-dir is a top-level program option. The default // action reads it from its own options destructure, but subcommands @@ -939,2935 +1282,4121 @@ async function run(): Promise { // before .option('--plugin-dir', ...) in the chain — extra-typings // builds the type as options are added. Narrow with a runtime guard; // the collect accumulator + [] default guarantee string[] in practice. - const pluginDir = thisCommand.getOptionValue('pluginDir'); - if (Array.isArray(pluginDir) && pluginDir.length > 0 && pluginDir.every(p => typeof p === 'string')) { - setInlinePlugins(pluginDir); - clearPluginCache('preAction: --plugin-dir inline plugins'); + const pluginDir = thisCommand.getOptionValue('pluginDir') + if ( + Array.isArray(pluginDir) && + pluginDir.length > 0 && + pluginDir.every(p => typeof p === 'string') + ) { + setInlinePlugins(pluginDir) + clearPluginCache('preAction: --plugin-dir inline plugins') } - runMigrations(); - profileCheckpoint('preAction_after_migrations'); + + runMigrations() + profileCheckpoint('preAction_after_migrations') // Load remote managed settings for enterprise customers (non-blocking) // Fails open - if fetch fails, continues without remote settings // Settings are applied via hot-reload when they arrive // Must happen after init() to ensure config reading is allowed - void loadRemoteManagedSettings(); - void loadPolicyLimits(); - profileCheckpoint('preAction_after_remote_settings'); + void loadRemoteManagedSettings() + void loadPolicyLimits() + + profileCheckpoint('preAction_after_remote_settings') // Load settings sync (non-blocking, fail-open) // CLI: uploads local settings to remote (CCR download is handled by print.ts) if (feature('UPLOAD_USER_SETTINGS')) { - void import('./services/settingsSync/index.js').then(m => m.uploadUserSettingsInBackground()); - } - profileCheckpoint('preAction_after_settings_sync'); - }); - program.name('claude').description(`Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`).argument('[prompt]', 'Your prompt', String) - // Subcommands inherit helpOption via commander's copyInheritedSettings — - // setting it once here covers mcp, plugin, auth, and all other subcommands. - .helpOption('-h, --help', 'Display help for command').option('-d, --debug [filter]', 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', (_value: string | true) => { - // If value is provided, it will be the filter string - // If not provided but flag is present, value will be true - // The actual filtering is handled in debug.ts by parsing process.argv - return true; - }).addOption(new Option('--debug-to-stderr', 'Enable debug mode (to stderr)').argParser(Boolean).hideHelp()).option('--debug-file ', 'Write debug logs to a specific file path (implicitly enables debug mode)', () => true).option('--verbose', 'Override verbose mode setting from config', () => true).option('-p, --print', 'Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust.', () => true).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { - const amount = Number(value); - if (isNaN(amount) || amount <= 0) { - throw new Error('--max-budget-usd must be a positive number greater than 0'); - } - return amount; - })).addOption(new Option('--task-budget ', 'API-side task budget in tokens (output_config.task_budget)').argParser(value => { - const tokens = Number(value); - if (isNaN(tokens) || tokens <= 0 || !Number.isInteger(tokens)) { - throw new Error('--task-budget must be a positive integer'); - } - return tokens; - }).hideHelp()).option('--replay-user-messages', 'Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)', () => true).addOption(new Option('--enable-auth-status', 'Enable auth status messages in SDK mode').default(false).hideHelp()).option('--allowedTools, --allowed-tools ', 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")').option('--tools ', 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").').option('--disallowedTools, --disallowed-tools ', 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")').option('--mcp-config ', 'Load MCP servers from JSON files or strings (space-separated)').addOption(new Option('--permission-prompt-tool ', 'MCP tool to use for permission prompts (only works with --print)').argParser(String).hideHelp()).addOption(new Option('--system-prompt ', 'System prompt to use for the session').argParser(String)).addOption(new Option('--system-prompt-file ', 'Read system prompt from a file').argParser(String).hideHelp()).addOption(new Option('--append-system-prompt ', 'Append a system prompt to the default system prompt').argParser(String)).addOption(new Option('--append-system-prompt-file ', 'Read system prompt from a file and append to the default system prompt').argParser(String).hideHelp()).addOption(new Option('--permission-mode ', 'Permission mode to use for the session').argParser(String).choices(PERMISSION_MODES)).option('-c, --continue', 'Continue the most recent conversation in the current directory', () => true).option('-r, --resume [value]', 'Resume a conversation by session ID, or open interactive picker with optional search term', value => value || true).option('--fork-session', 'When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)', () => true).addOption(new Option('--prefill ', 'Pre-fill the prompt input with text without submitting it').hideHelp()).addOption(new Option('--deep-link-origin', 'Signal that this session was launched from a deep link').hideHelp()).addOption(new Option('--deep-link-repo ', 'Repo slug the deep link ?repo= parameter resolved to the current cwd').hideHelp()).addOption(new Option('--deep-link-last-fetch ', 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline').argParser(v => { - const n = Number(v); - return Number.isFinite(n) ? n : undefined; - }).hideHelp()).option('--from-pr [value]', 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', value => value || true).option('--no-session-persistence', 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)').addOption(new Option('--resume-session-at ', 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files ', 'Restore files to state at the specified user message and exit (requires --resume)').hideHelp()) - // @[MODEL LAUNCH]: Update the example model ID in the --model help text. - .option('--model ', `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`).addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => { - const value = rawValue.toLowerCase(); - const allowed = ['low', 'medium', 'high', 'max']; - if (!allowed.includes(value)) { - throw new InvalidArgumentError(`It must be one of: ${allowed.join(', ')}`); - } - return value; - })).option('--agent ', `Agent for the current session. Overrides the 'agent' setting.`).option('--betas ', 'Beta headers to include in API requests (API key users only)').option('--fallback-model ', 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)').addOption(new Option('--workload ', 'Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)').hideHelp()).option('--settings ', 'Path to a settings JSON file or a JSON string to load additional settings from').option('--add-dir ', 'Additional directories to allow tool access to').option('--ide', 'Automatically connect to IDE on startup if exactly one valid IDE is available', () => true).option('--strict-mcp-config', 'Only use MCP servers from --mcp-config, ignoring all other MCP configurations', () => true).option('--session-id ', 'Use a specific session ID for the conversation (must be a valid UUID)').option('-n, --name ', 'Set a display name for this session (shown in /resume and terminal title)').option('--agents ', 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')').option('--setting-sources ', 'Comma-separated list of setting sources to load (user, project, local).') - // gh-33508: (variadic) consumed everything until the next - // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed - // `mcp` and `add` as paths, then choked on --transport as an unknown - // top-level option. Single-value + collect accumulator means each - // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. - .option('--plugin-dir ', 'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)', (val: string, prev: string[]) => [...prev, val], [] as string[]).option('--disable-slash-commands', 'Disable all skills', () => true).option('--chrome', 'Enable Claude in Chrome integration').option('--no-chrome', 'Disable Claude in Chrome integration').option('--file ', 'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)').action(async (prompt, options) => { - profileCheckpoint('action_handler_start'); - - // --bare = one-switch minimal mode. Sets SIMPLE so all the existing - // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent - // dir-walk). Must be set before setup() / any of the gated work runs. - if ((options as { - bare?: boolean; - }).bare) { - process.env.CLAUDE_CODE_SIMPLE = '1'; - } - - // Ignore "code" as a prompt - treat it the same as no prompt - if (prompt === 'code') { - logEvent('tengu_code_prompt_ignored', {}); - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.warn(chalk.yellow('Tip: You can launch Claude Code with just `claude`')); - prompt = undefined; - } - - // Log event for any single-word prompt - if (prompt && typeof prompt === 'string' && !/\s/.test(prompt) && prompt.length > 0) { - logEvent('tengu_single_word_prompt', { - length: prompt.length - }); + void import('./services/settingsSync/index.js').then(m => + m.uploadUserSettingsInBackground(), + ) } - // Assistant mode: when .claude/settings.json has assistant: true AND - // the tengu_kairos GrowthBook gate is on, force brief on. Permission - // mode is left to the user — settings defaultMode or --permission-mode - // apply as normal. REPL-typed messages already default to 'next' - // priority (messageQueueManager.enqueue) so they drain mid-turn between - // tool calls. SendUserMessage (BriefTool) is enabled via the brief env - // var. SleepTool stays disabled (its isEnabled() gates on proactive). - // kairosEnabled is computed once here and reused at the - // getAssistantSystemPromptAddendum() call site further down. - // - // Trust gate: .claude/settings.json is attacker-controllable in an - // untrusted clone. We run ~1000 lines before showSetupScreens() shows - // the trust dialog, and by then we've already appended - // .claude/agents/assistant.md to the system prompt. Refuse to activate - // until the directory has been explicitly trusted. - let kairosEnabled = false; - let assistantTeamContext: Awaited['initializeAssistantTeam']>> | undefined; - if (feature('KAIROS') && (options as { - assistant?: boolean; - }).assistant && assistantModule) { - // --assistant (Agent SDK daemon mode): force the latch before - // isAssistantMode() runs below. The daemon has already checked - // entitlement — don't make the child re-check tengu_kairos. - assistantModule.markAssistantForced(); - } - if (feature('KAIROS') && assistantModule?.isAssistantMode() && - // Spawned teammates share the leader's cwd + settings.json, so - // isAssistantMode() is true for them too. --agent-id being set - // means we ARE a spawned teammate (extractTeammateOptions runs - // ~170 lines later so check the raw commander option) — don't - // re-init the team or override teammateMode/proactive/brief. - !(options as { - agentId?: unknown; - }).agentId && kairosGate) { - if (!checkHasTrustDialogAccepted()) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.warn(chalk.yellow('Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.')); - } else { - // Blocking gate check — returns cached `true` instantly; if disk - // cache is false/missing, lazily inits GrowthBook and fetches fresh - // (max ~5s). --assistant skips the gate entirely (daemon is - // pre-entitled). - kairosEnabled = assistantModule.isAssistantForced() || (await kairosGate.isKairosEnabled()); - if (kairosEnabled) { - const opts = options as { - brief?: boolean; - }; - opts.brief = true; - setKairosActive(true); - // Pre-seed an in-process team so Agent(name: "foo") spawns - // teammates without TeamCreate. Must run BEFORE setup() captures - // the teammateMode snapshot (initializeAssistantTeam calls - // setCliTeammateModeOverride internally). - assistantTeamContext = await assistantModule.initializeAssistantTeam(); + profileCheckpoint('preAction_after_settings_sync') + }) + + program + .name('claude') + .description( + `Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`, + ) + .argument('[prompt]', 'Your prompt', String) + // Subcommands inherit helpOption via commander's copyInheritedSettings — + // setting it once here covers mcp, plugin, auth, and all other subcommands. + .helpOption('-h, --help', 'Display help for command') + .option( + '-d, --debug [filter]', + 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', + (_value: string | true) => { + // If value is provided, it will be the filter string + // If not provided but flag is present, value will be true + // The actual filtering is handled in debug.ts by parsing process.argv + return true + }, + ) + .addOption( + new Option('--debug-to-stderr', 'Enable debug mode (to stderr)') + .argParser(Boolean) + .hideHelp(), + ) + .option( + '--debug-file ', + 'Write debug logs to a specific file path (implicitly enables debug mode)', + () => true, + ) + .option( + '--verbose', + 'Override verbose mode setting from config', + () => true, + ) + .option( + '-p, --print', + 'Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust.', + () => true, + ) + .option( + '--bare', + 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', + () => true, + ) + .addOption( + new Option( + '--init', + 'Run Setup hooks with init trigger, then continue', + ).hideHelp(), + ) + .addOption( + new Option( + '--init-only', + 'Run Setup and SessionStart:startup hooks, then exit', + ).hideHelp(), + ) + .addOption( + new Option( + '--maintenance', + 'Run Setup hooks with maintenance trigger, then continue', + ).hideHelp(), + ) + .addOption( + new Option( + '--output-format ', + 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', + ).choices(['text', 'json', 'stream-json']), + ) + .addOption( + new Option( + '--json-schema ', + 'JSON Schema for structured output validation. ' + + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', + ).argParser(String), + ) + .option( + '--include-hook-events', + 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', + () => true, + ) + .option( + '--include-partial-messages', + 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', + () => true, + ) + .addOption( + new Option( + '--input-format ', + 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)', + ).choices(['text', 'stream-json']), + ) + .option( + '--mcp-debug', + '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', + () => true, + ) + .option( + '--dangerously-skip-permissions', + 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', + () => true, + ) + .option( + '--allow-dangerously-skip-permissions', + 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', + () => true, + ) + .addOption( + new Option( + '--thinking ', + 'Thinking mode: enabled (equivalent to adaptive), disabled', + ) + .choices(['enabled', 'adaptive', 'disabled']) + .hideHelp(), + ) + .addOption( + new Option( + '--max-thinking-tokens ', + '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option( + '--max-turns ', + 'Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option( + '--max-budget-usd ', + 'Maximum dollar amount to spend on API calls (only works with --print)', + ).argParser(value => { + const amount = Number(value) + if (isNaN(amount) || amount <= 0) { + throw new Error( + '--max-budget-usd must be a positive number greater than 0', + ) + } + return amount + }), + ) + .addOption( + new Option( + '--task-budget ', + 'API-side task budget in tokens (output_config.task_budget)', + ) + .argParser(value => { + const tokens = Number(value) + if (isNaN(tokens) || tokens <= 0 || !Number.isInteger(tokens)) { + throw new Error('--task-budget must be a positive integer') + } + return tokens + }) + .hideHelp(), + ) + .option( + '--replay-user-messages', + 'Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)', + () => true, + ) + .addOption( + new Option( + '--enable-auth-status', + 'Enable auth status messages in SDK mode', + ) + .default(false) + .hideHelp(), + ) + .option( + '--allowedTools, --allowed-tools ', + 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', + ) + .option( + '--tools ', + 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").', + ) + .option( + '--disallowedTools, --disallowed-tools ', + 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', + ) + .option( + '--mcp-config ', + 'Load MCP servers from JSON files or strings (space-separated)', + ) + .addOption( + new Option( + '--permission-prompt-tool ', + 'MCP tool to use for permission prompts (only works with --print)', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--system-prompt ', + 'System prompt to use for the session', + ).argParser(String), + ) + .addOption( + new Option( + '--system-prompt-file ', + 'Read system prompt from a file', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--append-system-prompt ', + 'Append a system prompt to the default system prompt', + ).argParser(String), + ) + .addOption( + new Option( + '--append-system-prompt-file ', + 'Read system prompt from a file and append to the default system prompt', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--permission-mode ', + 'Permission mode to use for the session', + ) + .argParser(String) + .choices(PERMISSION_MODES), + ) + .option( + '-c, --continue', + 'Continue the most recent conversation in the current directory', + () => true, + ) + .option( + '-r, --resume [value]', + 'Resume a conversation by session ID, or open interactive picker with optional search term', + value => value || true, + ) + .option( + '--fork-session', + 'When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)', + () => true, + ) + .addOption( + new Option( + '--prefill ', + 'Pre-fill the prompt input with text without submitting it', + ).hideHelp(), + ) + .addOption( + new Option( + '--deep-link-origin', + 'Signal that this session was launched from a deep link', + ).hideHelp(), + ) + .addOption( + new Option( + '--deep-link-repo ', + 'Repo slug the deep link ?repo= parameter resolved to the current cwd', + ).hideHelp(), + ) + .addOption( + new Option( + '--deep-link-last-fetch ', + 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline', + ) + .argParser(v => { + const n = Number(v) + return Number.isFinite(n) ? n : undefined + }) + .hideHelp(), + ) + .option( + '--from-pr [value]', + 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', + value => value || true, + ) + .option( + '--no-session-persistence', + 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)', + ) + .addOption( + new Option( + '--resume-session-at ', + 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--rewind-files ', + 'Restore files to state at the specified user message and exit (requires --resume)', + ).hideHelp(), + ) + // @[MODEL LAUNCH]: Update the example model ID in the --model help text. + .option( + '--model ', + `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`, + ) + .addOption( + new Option( + '--effort ', + `Effort level for the current session (low, medium, high, max)`, + ).argParser((rawValue: string) => { + const value = rawValue.toLowerCase() + const allowed = ['low', 'medium', 'high', 'max'] + if (!allowed.includes(value)) { + throw new InvalidArgumentError( + `It must be one of: ${allowed.join(', ')}`, + ) } + return value + }), + ) + .option( + '--agent ', + `Agent for the current session. Overrides the 'agent' setting.`, + ) + .option( + '--betas ', + 'Beta headers to include in API requests (API key users only)', + ) + .option( + '--fallback-model ', + 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)', + ) + .addOption( + new Option( + '--workload ', + 'Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)', + ).hideHelp(), + ) + .option( + '--settings ', + 'Path to a settings JSON file or a JSON string to load additional settings from', + ) + .option( + '--add-dir ', + 'Additional directories to allow tool access to', + ) + .option( + '--ide', + 'Automatically connect to IDE on startup if exactly one valid IDE is available', + () => true, + ) + .option( + '--strict-mcp-config', + 'Only use MCP servers from --mcp-config, ignoring all other MCP configurations', + () => true, + ) + .option( + '--session-id ', + 'Use a specific session ID for the conversation (must be a valid UUID)', + ) + .option( + '-n, --name ', + 'Set a display name for this session (shown in /resume and terminal title)', + ) + .option( + '--agents ', + 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')', + ) + .option( + '--setting-sources ', + 'Comma-separated list of setting sources to load (user, project, local).', + ) + // gh-33508: (variadic) consumed everything until the next + // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed + // `mcp` and `add` as paths, then choked on --transport as an unknown + // top-level option. Single-value + collect accumulator means each + // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. + .option( + '--plugin-dir ', + 'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)', + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option('--disable-slash-commands', 'Disable all skills', () => true) + .option('--chrome', 'Enable Claude in Chrome integration') + .option('--no-chrome', 'Disable Claude in Chrome integration') + .option( + '--file ', + 'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)', + ) + .action(async (prompt, options) => { + profileCheckpoint('action_handler_start') + + // --bare = one-switch minimal mode. Sets SIMPLE so all the existing + // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent + // dir-walk). Must be set before setup() / any of the gated work runs. + if ((options as { bare?: boolean }).bare) { + process.env.CLAUDE_CODE_SIMPLE = '1' } - } - const { - debug = false, - debugToStderr = false, - dangerouslySkipPermissions, - allowDangerouslySkipPermissions = false, - tools: baseTools = [], - allowedTools = [], - disallowedTools = [], - mcpConfig = [], - permissionMode: permissionModeCli, - addDir = [], - fallbackModel, - betas = [], - ide = false, - sessionId, - includeHookEvents, - includePartialMessages - } = options; - if (options.prefill) { - seedEarlyInput(options.prefill); - } - - // Promise for file downloads - started early, awaited before REPL renders - let fileDownloadPromise: Promise | undefined; - const agentsJson = options.agents; - const agentCli = options.agent; - if (feature('BG_SESSIONS') && agentCli) { - process.env.CLAUDE_CODE_AGENT = agentCli; - } - - // NOTE: LSP manager initialization is intentionally deferred until after - // the trust dialog is accepted. This prevents plugin LSP servers from - // executing code in untrusted directories before user consent. - - // Extract these separately so they can be modified if needed - let outputFormat = options.outputFormat; - let inputFormat = options.inputFormat; - let verbose = options.verbose ?? getGlobalConfig().verbose; - let print = options.print; - const init = options.init ?? false; - const initOnly = options.initOnly ?? false; - const maintenance = options.maintenance ?? false; - - // Extract disable slash commands flag - const disableSlashCommands = options.disableSlashCommands || false; - - // Extract tasks mode options (ant-only) - const tasksOption = (process.env.USER_TYPE) === 'ant' && (options as { - tasks?: boolean | string; - }).tasks; - const taskListId = tasksOption ? typeof tasksOption === 'string' ? tasksOption : DEFAULT_TASKS_MODE_TASK_LIST_ID : undefined; - if ((process.env.USER_TYPE) === 'ant' && taskListId) { - process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId; - } - // Extract worktree option - // worktree can be true (flag without value) or a string (custom name or PR reference) - const worktreeOption = isWorktreeModeEnabled() ? (options as { - worktree?: boolean | string; - }).worktree : undefined; - let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined; - const worktreeEnabled = worktreeOption !== undefined; - - // Check if worktree name is a PR reference (#N or GitHub PR URL) - let worktreePRNumber: number | undefined; - if (worktreeName) { - const prNum = parsePRReference(worktreeName); - if (prNum !== null) { - worktreePRNumber = prNum; - worktreeName = undefined; // slug will be generated in setup() + // Ignore "code" as a prompt - treat it the same as no prompt + if (prompt === 'code') { + logEvent('tengu_code_prompt_ignored', {}) + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.warn( + chalk.yellow('Tip: You can launch Claude Code with just `claude`'), + ) + prompt = undefined } - } - - // Extract tmux option (requires --worktree) - const tmuxEnabled = isWorktreeModeEnabled() && (options as { - tmux?: boolean; - }).tmux === true; - // Validate tmux option - if (tmuxEnabled) { - if (!worktreeEnabled) { - process.stderr.write(chalk.red('Error: --tmux requires --worktree\n')); - process.exit(1); + // Log event for any single-word prompt + if ( + prompt && + typeof prompt === 'string' && + !/\s/.test(prompt) && + prompt.length > 0 + ) { + logEvent('tengu_single_word_prompt', { length: prompt.length }) } - if (getPlatform() === 'windows') { - process.stderr.write(chalk.red('Error: --tmux is not supported on Windows\n')); - process.exit(1); + + // Assistant mode: when .claude/settings.json has assistant: true AND + // the tengu_kairos GrowthBook gate is on, force brief on. Permission + // mode is left to the user — settings defaultMode or --permission-mode + // apply as normal. REPL-typed messages already default to 'next' + // priority (messageQueueManager.enqueue) so they drain mid-turn between + // tool calls. SendUserMessage (BriefTool) is enabled via the brief env + // var. SleepTool stays disabled (its isEnabled() gates on proactive). + // kairosEnabled is computed once here and reused at the + // getAssistantSystemPromptAddendum() call site further down. + // + // Trust gate: .claude/settings.json is attacker-controllable in an + // untrusted clone. We run ~1000 lines before showSetupScreens() shows + // the trust dialog, and by then we've already appended + // .claude/agents/assistant.md to the system prompt. Refuse to activate + // until the directory has been explicitly trusted. + let kairosEnabled = false + let assistantTeamContext: + | Awaited< + ReturnType< + NonNullable['initializeAssistantTeam'] + > + > + | undefined + if ( + feature('KAIROS') && + (options as { assistant?: boolean }).assistant && + assistantModule + ) { + // --assistant (Agent SDK daemon mode): force the latch before + // isAssistantMode() runs below. The daemon has already checked + // entitlement — don't make the child re-check tengu_kairos. + assistantModule.markAssistantForced() } - if (!(await isTmuxAvailable())) { - process.stderr.write(chalk.red(`Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`)); - process.exit(1); + if ( + feature('KAIROS') && + assistantModule?.isAssistantMode() && + // Spawned teammates share the leader's cwd + settings.json, so + // isAssistantMode() is true for them too. --agent-id being set + // means we ARE a spawned teammate (extractTeammateOptions runs + // ~170 lines later so check the raw commander option) — don't + // re-init the team or override teammateMode/proactive/brief. + !(options as { agentId?: unknown }).agentId && + kairosGate + ) { + if (!checkHasTrustDialogAccepted()) { + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.warn( + chalk.yellow( + 'Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.', + ), + ) + } else { + // Blocking gate check — returns cached `true` instantly; if disk + // cache is false/missing, lazily inits GrowthBook and fetches fresh + // (max ~5s). --assistant skips the gate entirely (daemon is + // pre-entitled). + kairosEnabled = + assistantModule.isAssistantForced() || + (await kairosGate.isKairosEnabled()) + if (kairosEnabled) { + const opts = options as { brief?: boolean } + opts.brief = true + setKairosActive(true) + // Pre-seed an in-process team so Agent(name: "foo") spawns + // teammates without TeamCreate. Must run BEFORE setup() captures + // the teammateMode snapshot (initializeAssistantTeam calls + // setCliTeammateModeOverride internally). + assistantTeamContext = + await assistantModule.initializeAssistantTeam() + } + } } - } - // Extract teammate options (for tmux-spawned agents) - // Declared outside the if block so it's accessible later for system prompt addendum - let storedTeammateOpts: TeammateOptions | undefined; - if (isAgentSwarmsEnabled()) { - // Extract agent identity options (for tmux-spawned agents) - // These replace the CLAUDE_CODE_* environment variables - const teammateOpts = extractTeammateOptions(options); - storedTeammateOpts = teammateOpts; - - // If any teammate identity option is provided, all three required ones must be present - const hasAnyTeammateOpt = teammateOpts.agentId || teammateOpts.agentName || teammateOpts.teamName; - const hasAllRequiredTeammateOpts = teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName; - if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { - process.stderr.write(chalk.red('Error: --agent-id, --agent-name, and --team-name must all be provided together\n')); - process.exit(1); + const { + debug = false, + debugToStderr = false, + dangerouslySkipPermissions, + allowDangerouslySkipPermissions = false, + tools: baseTools = [], + allowedTools = [], + disallowedTools = [], + mcpConfig = [], + permissionMode: permissionModeCli, + addDir = [], + fallbackModel, + betas = [], + ide = false, + sessionId, + includeHookEvents, + includePartialMessages, + } = options + + if (options.prefill) { + seedEarlyInput(options.prefill) } - // If teammate identity is provided via CLI, set up dynamicTeamContext - if (teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName) { - getTeammateUtils().setDynamicTeamContext?.({ - agentId: teammateOpts.agentId, - agentName: teammateOpts.agentName, - teamName: teammateOpts.teamName, - color: teammateOpts.agentColor, - planModeRequired: teammateOpts.planModeRequired ?? false, - parentSessionId: teammateOpts.parentSessionId - }); - } + // Promise for file downloads - started early, awaited before REPL renders + let fileDownloadPromise: Promise | undefined - // Set teammate mode CLI override if provided - // This must be done before setup() captures the snapshot - if (teammateOpts.teammateMode) { - getTeammateModeSnapshot().setCliTeammateModeOverride?.(teammateOpts.teammateMode); + const agentsJson = options.agents + const agentCli = options.agent + if (feature('BG_SESSIONS') && agentCli) { + process.env.CLAUDE_CODE_AGENT = agentCli } - } - // Extract remote sdk options - const sdkUrl = (options as { - sdkUrl?: string; - }).sdkUrl ?? undefined; + // NOTE: LSP manager initialization is intentionally deferred until after + // the trust dialog is accepted. This prevents plugin LSP servers from + // executing code in untrusted directories before user consent. + + // Extract these separately so they can be modified if needed + let outputFormat = options.outputFormat + let inputFormat = options.inputFormat + let verbose = options.verbose ?? getGlobalConfig().verbose + let print = options.print + const init = options.init ?? false + const initOnly = options.initOnly ?? false + const maintenance = options.maintenance ?? false + + // Extract disable slash commands flag + const disableSlashCommands = options.disableSlashCommands || false + + // Extract tasks mode options (ant-only) + const tasksOption = + process.env.USER_TYPE === 'ant' && + (options as { tasks?: boolean | string }).tasks + const taskListId = tasksOption + ? typeof tasksOption === 'string' + ? tasksOption + : DEFAULT_TASKS_MODE_TASK_LIST_ID + : undefined + if (process.env.USER_TYPE === 'ant' && taskListId) { + process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId + } - // Allow env var to enable partial messages (used by sandbox gateway for baku) - const effectiveIncludePartialMessages = includePartialMessages || isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES); + // Extract worktree option + // worktree can be true (flag without value) or a string (custom name or PR reference) + const worktreeOption = isWorktreeModeEnabled() + ? (options as { worktree?: boolean | string }).worktree + : undefined + let worktreeName = + typeof worktreeOption === 'string' ? worktreeOption : undefined + const worktreeEnabled = worktreeOption !== undefined + + // Check if worktree name is a PR reference (#N or GitHub PR URL) + let worktreePRNumber: number | undefined + if (worktreeName) { + const prNum = parsePRReference(worktreeName) + if (prNum !== null) { + worktreePRNumber = prNum + worktreeName = undefined // slug will be generated in setup() + } + } - // Enable all hook event types when explicitly requested via SDK option - // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). - // Without this, only SessionStart and Setup events are emitted. - if (includeHookEvents || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { - setAllHookEventsEnabled(true); - } + // Extract tmux option (requires --worktree) + const tmuxEnabled = + isWorktreeModeEnabled() && (options as { tmux?: boolean }).tmux === true - // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided - if (sdkUrl) { - // If SDK URL is provided, automatically use stream-json formats unless explicitly set - if (!inputFormat) { - inputFormat = 'stream-json'; - } - if (!outputFormat) { - outputFormat = 'stream-json'; - } - // Auto-enable verbose mode unless explicitly disabled or already set - if (options.verbose === undefined) { - verbose = true; - } - // Auto-enable print mode unless explicitly disabled - if (!options.print) { - print = true; + // Validate tmux option + if (tmuxEnabled) { + if (!worktreeEnabled) { + process.stderr.write(chalk.red('Error: --tmux requires --worktree\n')) + process.exit(1) + } + if (getPlatform() === 'windows') { + process.stderr.write( + chalk.red('Error: --tmux is not supported on Windows\n'), + ) + process.exit(1) + } + if (!(await isTmuxAvailable())) { + process.stderr.write( + chalk.red( + `Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`, + ), + ) + process.exit(1) + } } - } - // Extract teleport option - const teleport = (options as { - teleport?: string | true; - }).teleport ?? null; - - // Extract remote option (can be true if no description provided, or a string) - const remoteOption = (options as { - remote?: string | true; - }).remote; - const remote = remoteOption === true ? '' : remoteOption ?? null; - - // Extract --remote-control / --rc flag (enable bridge in interactive session) - const remoteControlOption = (options as { - remoteControl?: string | true; - }).remoteControl ?? (options as { - rc?: string | true; - }).rc; - // Actual bridge check is deferred to after showSetupScreens() so that - // trust is established and GrowthBook has auth headers. - let remoteControl = false; - const remoteControlName = typeof remoteControlOption === 'string' && remoteControlOption.length > 0 ? remoteControlOption : undefined; - - // Validate session ID if provided - if (sessionId) { - // Check for conflicting flags - // --session-id can be used with --continue or --resume when --fork-session is also provided - // (to specify a custom ID for the forked session) - if ((options.continue || options.resume) && !options.forkSession) { - process.stderr.write(chalk.red('Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n')); - process.exit(1); - } + // Extract teammate options (for tmux-spawned agents) + // Declared outside the if block so it's accessible later for system prompt addendum + let storedTeammateOpts: TeammateOptions | undefined + if (isAgentSwarmsEnabled()) { + // Extract agent identity options (for tmux-spawned agents) + // These replace the CLAUDE_CODE_* environment variables + const teammateOpts = extractTeammateOptions(options) + storedTeammateOpts = teammateOpts + + // If any teammate identity option is provided, all three required ones must be present + const hasAnyTeammateOpt = + teammateOpts.agentId || + teammateOpts.agentName || + teammateOpts.teamName + const hasAllRequiredTeammateOpts = + teammateOpts.agentId && + teammateOpts.agentName && + teammateOpts.teamName + + if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { + process.stderr.write( + chalk.red( + 'Error: --agent-id, --agent-name, and --team-name must all be provided together\n', + ), + ) + process.exit(1) + } - // When --sdk-url is provided (bridge/remote mode), the session ID is a - // server-assigned tagged ID (e.g. "session_local_01...") rather than a - // UUID. Skip UUID validation and local existence checks in that case. - if (!sdkUrl) { - const validatedSessionId = validateUuid(sessionId); - if (!validatedSessionId) { - process.stderr.write(chalk.red('Error: Invalid session ID. Must be a valid UUID.\n')); - process.exit(1); + // If teammate identity is provided via CLI, set up dynamicTeamContext + if ( + teammateOpts.agentId && + teammateOpts.agentName && + teammateOpts.teamName + ) { + getTeammateUtils().setDynamicTeamContext?.({ + agentId: teammateOpts.agentId, + agentName: teammateOpts.agentName, + teamName: teammateOpts.teamName, + color: teammateOpts.agentColor, + planModeRequired: teammateOpts.planModeRequired ?? false, + parentSessionId: teammateOpts.parentSessionId, + }) } - // Check if session ID already exists - if (sessionIdExists(validatedSessionId)) { - process.stderr.write(chalk.red(`Error: Session ID ${validatedSessionId} is already in use.\n`)); - process.exit(1); + // Set teammate mode CLI override if provided + // This must be done before setup() captures the snapshot + if (teammateOpts.teammateMode) { + getTeammateModeSnapshot().setCliTeammateModeOverride?.( + teammateOpts.teammateMode, + ) } } - } - // Download file resources if specified via --file flag - const fileSpecs = (options as { - file?: string[]; - }).file; - if (fileSpecs && fileSpecs.length > 0) { - // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) - const sessionToken = getSessionIngressAuthToken(); - if (!sessionToken) { - process.stderr.write(chalk.red('Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n')); - process.exit(1); + // Extract remote sdk options + const sdkUrl = (options as { sdkUrl?: string }).sdkUrl ?? undefined + + // Allow env var to enable partial messages (used by sandbox gateway for baku) + const effectiveIncludePartialMessages = + includePartialMessages || + isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES) + + // Enable all hook event types when explicitly requested via SDK option + // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). + // Without this, only SessionStart and Setup events are emitted. + if (includeHookEvents || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { + setAllHookEventsEnabled(true) } - // Resolve session ID: prefer remote session ID, fall back to internal session ID - const fileSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId(); - const files = parseFileSpecs(fileSpecs); - if (files.length > 0) { - // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config - // This ensures consistency with session ingress API in all environments - const config: FilesApiConfig = { - baseUrl: process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL, - oauthToken: sessionToken, - sessionId: fileSessionId - }; - - // Start download without blocking startup - await before REPL renders - fileDownloadPromise = downloadSessionFiles(files, config); + // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided + if (sdkUrl) { + // If SDK URL is provided, automatically use stream-json formats unless explicitly set + if (!inputFormat) { + inputFormat = 'stream-json' + } + if (!outputFormat) { + outputFormat = 'stream-json' + } + // Auto-enable verbose mode unless explicitly disabled or already set + if (options.verbose === undefined) { + verbose = true + } + // Auto-enable print mode unless explicitly disabled + if (!options.print) { + print = true + } } - } - // Get isNonInteractiveSession from state (was set before init()) - const isNonInteractiveSession = getIsNonInteractiveSession(); + // Extract teleport option + const teleport = + (options as { teleport?: string | true }).teleport ?? null + + // Extract remote option (can be true if no description provided, or a string) + const remoteOption = (options as { remote?: string | true }).remote + const remote = remoteOption === true ? '' : (remoteOption ?? null) + + // Extract --remote-control / --rc flag (enable bridge in interactive session) + const remoteControlOption = + (options as { remoteControl?: string | true }).remoteControl ?? + (options as { rc?: string | true }).rc + // Actual bridge check is deferred to after showSetupScreens() so that + // trust is established and GrowthBook has auth headers. + let remoteControl = false + const remoteControlName = + typeof remoteControlOption === 'string' && + remoteControlOption.length > 0 + ? remoteControlOption + : undefined + + // Validate session ID if provided + if (sessionId) { + // Check for conflicting flags + // --session-id can be used with --continue or --resume when --fork-session is also provided + // (to specify a custom ID for the forked session) + if ((options.continue || options.resume) && !options.forkSession) { + process.stderr.write( + chalk.red( + 'Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n', + ), + ) + process.exit(1) + } - // Validate that fallback model is different from main model - if (fallbackModel && options.model && fallbackModel === options.model) { - process.stderr.write(chalk.red('Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n')); - process.exit(1); - } + // When --sdk-url is provided (bridge/remote mode), the session ID is a + // server-assigned tagged ID (e.g. "session_local_01...") rather than a + // UUID. Skip UUID validation and local existence checks in that case. + if (!sdkUrl) { + const validatedSessionId = validateUuid(sessionId) + if (!validatedSessionId) { + process.stderr.write( + chalk.red('Error: Invalid session ID. Must be a valid UUID.\n'), + ) + process.exit(1) + } - // Handle system prompt options - let systemPrompt = options.systemPrompt; - if (options.systemPromptFile) { - if (options.systemPrompt) { - process.stderr.write(chalk.red('Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n')); - process.exit(1); - } - try { - const filePath = resolve(options.systemPromptFile); - systemPrompt = readFileSync(filePath, 'utf8'); - } catch (error) { - const code = getErrnoCode(error); - if (code === 'ENOENT') { - process.stderr.write(chalk.red(`Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`)); - process.exit(1); + // Check if session ID already exists + if (sessionIdExists(validatedSessionId)) { + process.stderr.write( + chalk.red( + `Error: Session ID ${validatedSessionId} is already in use.\n`, + ), + ) + process.exit(1) + } } - process.stderr.write(chalk.red(`Error reading system prompt file: ${errorMessage(error)}\n`)); - process.exit(1); } - } - // Handle append system prompt options - let appendSystemPrompt = options.appendSystemPrompt; - if (options.appendSystemPromptFile) { - if (options.appendSystemPrompt) { - process.stderr.write(chalk.red('Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n')); - process.exit(1); - } - try { - const filePath = resolve(options.appendSystemPromptFile); - appendSystemPrompt = readFileSync(filePath, 'utf8'); - } catch (error) { - const code = getErrnoCode(error); - if (code === 'ENOENT') { - process.stderr.write(chalk.red(`Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`)); - process.exit(1); + // Download file resources if specified via --file flag + const fileSpecs = (options as { file?: string[] }).file + if (fileSpecs && fileSpecs.length > 0) { + // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) + const sessionToken = getSessionIngressAuthToken() + if (!sessionToken) { + process.stderr.write( + chalk.red( + 'Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n', + ), + ) + process.exit(1) } - process.stderr.write(chalk.red(`Error reading append system prompt file: ${errorMessage(error)}\n`)); - process.exit(1); - } - } - - // Add teammate-specific system prompt addendum for tmux teammates - if (isAgentSwarmsEnabled() && storedTeammateOpts?.agentId && storedTeammateOpts?.agentName && storedTeammateOpts?.teamName) { - const addendum = getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM; - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${addendum}` : addendum; - } - const { - mode: permissionMode, - notification: permissionModeNotification - } = initialPermissionModeFromCLI({ - permissionModeCli, - dangerouslySkipPermissions - }); - - // Store session bypass permissions mode for trust dialog check - setSessionBypassPermissionsMode(permissionMode === 'bypassPermissions'); - if (feature('TRANSCRIPT_CLASSIFIER')) { - // autoModeFlagCli is the "did the user intend auto this session" signal. - // Set when: --enable-auto-mode, --permission-mode auto, resolved mode - // is auto, OR settings defaultMode is auto but the gate denied it - // (permissionMode resolved to default with no explicit CLI override). - // Used by verifyAutoModeGateAccess to decide whether to notify on - // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. - if ((options as { - enableAutoMode?: boolean; - }).enableAutoMode || permissionModeCli === 'auto' || permissionMode === 'auto' || !permissionModeCli && isDefaultPermissionModeAuto()) { - autoModeStateModule?.setAutoModeFlagCli(true); - } - } - // Parse the MCP config files/strings if provided - let dynamicMcpConfig: Record = {}; - if (mcpConfig && mcpConfig.length > 0) { - // Process mcpConfig array - const processedConfigs = mcpConfig.map(config => config.trim()).filter(config => config.length > 0); - let allConfigs: Record = {}; - const allErrors: ValidationError[] = []; - for (const configItem of processedConfigs) { - let configs: Record | null = null; - let errors: ValidationError[] = []; - - // First try to parse as JSON string - const parsedJson = safeParseJSON(configItem); - if (parsedJson) { - const result = parseMcpConfig({ - configObject: parsedJson, - filePath: 'command line', - expandVars: true, - scope: 'dynamic' - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; - } - } else { - // Try as file path - const configPath = resolve(configItem); - const result = parseMcpConfigFromFilePath({ - filePath: configPath, - expandVars: true, - scope: 'dynamic' - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; + // Resolve session ID: prefer remote session ID, fall back to internal session ID + const fileSessionId = + process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId() + + const files = parseFileSpecs(fileSpecs) + if (files.length > 0) { + // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config + // This ensures consistency with session ingress API in all environments + const config: FilesApiConfig = { + baseUrl: + process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL, + oauthToken: sessionToken, + sessionId: fileSessionId, } - } - if (errors.length > 0) { - allErrors.push(...errors); - } else if (configs) { - // Merge configs, later ones override earlier ones - allConfigs = { - ...allConfigs, - ...configs - }; + + // Start download without blocking startup - await before REPL renders + fileDownloadPromise = downloadSessionFiles(files, config) } } - if (allErrors.length > 0) { - const formattedErrors = allErrors.map(err => `${err.path ? err.path + ': ' : ''}${err.message}`).join('\n'); - logForDebugging(`--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, { - level: 'error' - }); - process.stderr.write(`Error: Invalid MCP configuration:\n${formattedErrors}\n`); - process.exit(1); + + // Get isNonInteractiveSession from state (was set before init()) + const isNonInteractiveSession = getIsNonInteractiveSession() + + // Validate that fallback model is different from main model + if (fallbackModel && options.model && fallbackModel === options.model) { + process.stderr.write( + chalk.red( + 'Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n', + ), + ) + process.exit(1) } - if (Object.keys(allConfigs).length > 0) { - // SDK hosts (Nest/Desktop) own their server naming and may reuse - // built-in names — skip reserved-name checks for type:'sdk'. - const nonSdkConfigNames = Object.entries(allConfigs).filter(([, config]) => config.type !== 'sdk').map(([name]) => name); - let reservedNameError: string | null = null; - if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.`; - } else if (feature('CHICAGO_MCP')) { - const { - isComputerUseMCPServer, - COMPUTER_USE_MCP_SERVER_NAME - } = await import('src/utils/computerUse/common.js'); - if (nonSdkConfigNames.some(isComputerUseMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.`; - } - } - if (reservedNameError) { - // stderr+exit(1) — a throw here becomes a silent unhandled - // rejection in stream-json mode (void main() in cli.tsx). - process.stderr.write(`Error: ${reservedNameError}\n`); - process.exit(1); - } - // Add dynamic scope to all configs. type:'sdk' entries pass through - // unchanged — they're extracted into sdkMcpConfigs downstream and - // passed to print.ts. The Python SDK relies on this path (it doesn't - // send sdkMcpServers in the initialize message). Dropping them here - // broke Coworker (inc-5122). The policy filter below already exempts - // type:'sdk', and the entries are inert without an SDK transport on - // stdin, so there's no bypass risk from letting them through. - const scopedConfigs = mapValues(allConfigs, config => ({ - ...config, - scope: 'dynamic' as const - })); - - // Enforce managed policy (allowedMcpServers / deniedMcpServers) on - // --mcp-config servers. Without this, the CLI flag bypasses the - // enterprise allowlist that user/project/local configs go through in - // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on - // top of filtered results. Filter here at the source so all - // downstream consumers see the policy-filtered set. - const { - allowed, - blocked - } = filterMcpServersByPolicy(scopedConfigs); - if (blocked.length > 0) { - process.stderr.write(`Warning: MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`); + // Handle system prompt options + let systemPrompt = options.systemPrompt + if (options.systemPromptFile) { + if (options.systemPrompt) { + process.stderr.write( + chalk.red( + 'Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n', + ), + ) + process.exit(1) } - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...allowed - } as Record; - } - } - // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) - const chromeOpts = options as { - chrome?: boolean; - }; - // Store the explicit CLI flag so teammates can inherit it - setChromeFlagOverride(chromeOpts.chrome); - const enableClaudeInChrome = shouldEnableClaudeInChrome(chromeOpts.chrome); - const autoEnableClaudeInChrome = !enableClaudeInChrome && shouldAutoEnableClaudeInChrome(); - if (enableClaudeInChrome) { - const platform = getPlatform(); - try { - logEvent('tengu_claude_in_chrome_setup', { - platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const { - mcpConfig: chromeMcpConfig, - allowedTools: chromeMcpTools, - systemPrompt: chromeSystemPrompt - } = setupClaudeInChrome(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...chromeMcpConfig - }; - allowedTools.push(...chromeMcpTools); - if (chromeSystemPrompt) { - appendSystemPrompt = appendSystemPrompt ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` : chromeSystemPrompt; + try { + const filePath = resolve(options.systemPromptFile) + systemPrompt = readFileSync(filePath, 'utf8') + } catch (error) { + const code = getErrnoCode(error) + if (code === 'ENOENT') { + process.stderr.write( + chalk.red( + `Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`, + ), + ) + process.exit(1) + } + process.stderr.write( + chalk.red( + `Error reading system prompt file: ${errorMessage(error)}\n`, + ), + ) + process.exit(1) } - } catch (error) { - logEvent('tengu_claude_in_chrome_setup_failed', { - platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - logForDebugging(`[Claude in Chrome] Error: ${error}`); - logError(error); - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(`Error: Failed to run with Claude in Chrome.`); - process.exit(1); - } - } else if (autoEnableClaudeInChrome) { - try { - const { - mcpConfig: chromeMcpConfig - } = setupClaudeInChrome(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...chromeMcpConfig - }; - const hint = feature('WEB_BROWSER_TOOL') && typeof Bun !== 'undefined' && 'WebView' in Bun ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER : CLAUDE_IN_CHROME_SKILL_HINT; - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${hint}` : hint; - } catch (error) { - // Silently skip any errors for the auto-enable - logForDebugging(`[Claude in Chrome] Error (auto-enable): ${error}`); } - } - // Extract strict MCP config flag - const strictMcpConfig = options.strictMcpConfig || false; + // Handle append system prompt options + let appendSystemPrompt = options.appendSystemPrompt + if (options.appendSystemPromptFile) { + if (options.appendSystemPrompt) { + process.stderr.write( + chalk.red( + 'Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n', + ), + ) + process.exit(1) + } - // Check if enterprise MCP configuration exists. When it does, only allow dynamic MCP - // configs that contain special server types (sdk) - if (doesEnterpriseMcpConfigExist()) { - if (strictMcpConfig) { - process.stderr.write(chalk.red('You cannot use --strict-mcp-config when an enterprise MCP config is present')); - process.exit(1); + try { + const filePath = resolve(options.appendSystemPromptFile) + appendSystemPrompt = readFileSync(filePath, 'utf8') + } catch (error) { + const code = getErrnoCode(error) + if (code === 'ENOENT') { + process.stderr.write( + chalk.red( + `Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`, + ), + ) + process.exit(1) + } + process.stderr.write( + chalk.red( + `Error reading append system prompt file: ${errorMessage(error)}\n`, + ), + ) + process.exit(1) + } } - // For --mcp-config, allow if all servers are internal types (sdk) - if (dynamicMcpConfig && !areMcpConfigsAllowedWithEnterpriseMcpConfig(dynamicMcpConfig)) { - process.stderr.write(chalk.red('You cannot dynamically configure MCP servers when an enterprise MCP config is present')); - process.exit(1); + // Add teammate-specific system prompt addendum for tmux teammates + if ( + isAgentSwarmsEnabled() && + storedTeammateOpts?.agentId && + storedTeammateOpts?.agentName && + storedTeammateOpts?.teamName + ) { + const addendum = + getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${addendum}` + : addendum } - } - // chicago MCP: guarded Computer Use (app allowlist + frontmost gate + - // SCContentFilter screenshots). Ant-only, GrowthBook-gated — failures - // are silent (this is dogfooding). Platform + interactive checks inline - // so non-macOS / print-mode ants skip the heavy @ant/computer-use-mcp - // import entirely. gates.js is light (type-only package import). - // - // Placed AFTER the enterprise-MCP-config check: that check rejects any - // dynamicMcpConfig entry with `type !== 'sdk'`, and our config is - // `type: 'stdio'`. An enterprise-config ant with the GB gate on would - // otherwise process.exit(1). Chrome has the same latent issue but has - // shipped without incident; chicago places itself correctly. - if (feature('CHICAGO_MCP') && !getIsNonInteractiveSession()) { - try { - const { - getChicagoEnabled - } = await import('src/utils/computerUse/gates.js'); - if (getChicagoEnabled()) { - const { - setupComputerUseMCP - } = await import('src/utils/computerUse/setup.js'); - const { - mcpConfig, - allowedTools: cuTools - } = setupComputerUseMCP(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...mcpConfig - }; - allowedTools.push(...cuTools); + const { mode: permissionMode, notification: permissionModeNotification } = + initialPermissionModeFromCLI({ + permissionModeCli, + dangerouslySkipPermissions, + }) + + // Store session bypass permissions mode for trust dialog check + setSessionBypassPermissionsMode(permissionMode === 'bypassPermissions') + if (feature('TRANSCRIPT_CLASSIFIER')) { + // autoModeFlagCli is the "did the user intend auto this session" signal. + // Set when: --enable-auto-mode, --permission-mode auto, resolved mode + // is auto, OR settings defaultMode is auto but the gate denied it + // (permissionMode resolved to default with no explicit CLI override). + // Used by verifyAutoModeGateAccess to decide whether to notify on + // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. + if ( + (options as { enableAutoMode?: boolean }).enableAutoMode || + permissionModeCli === 'auto' || + permissionMode === 'auto' || + (!permissionModeCli && isDefaultPermissionModeAuto()) + ) { + autoModeStateModule?.setAutoModeFlagCli(true) } - } catch (error) { - logForDebugging(`[Computer Use MCP] Setup failed: ${errorMessage(error)}`); } - } - // Store additional directories for CLAUDE.md loading (controlled by env var) - setAdditionalDirectoriesForClaudeMd(addDir); - - // Channel server allowlist from --channels flag — servers whose - // inbound push notifications should register this session. The option - // is added inside a feature() block so TS doesn't know about it - // on the options type — same pattern as --assistant at main.tsx:1824. - // devChannels is deferred: showSetupScreens shows a confirmation dialog - // and only appends to allowedChannels on accept. - let devChannels: ChannelEntry[] | undefined; - if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { - // Parse plugin:name@marketplace / server:Y tags into typed entries. - // Tag decides trust model downstream: plugin-kind hits marketplace - // verification + GrowthBook allowlist, server-kind always fails - // allowlist (schema is plugin-only) unless dev flag is set. - // Untagged or marketplace-less plugin entries are hard errors — - // silently not-matching in the gate would look like channels are - // "on" but nothing ever fires. - const parseChannelEntries = (raw: string[], flag: string): ChannelEntry[] => { - const entries: ChannelEntry[] = []; - const bad: string[] = []; - for (const c of raw) { - if (c.startsWith('plugin:')) { - const rest = c.slice(7); - const at = rest.indexOf('@'); - if (at <= 0 || at === rest.length - 1) { - bad.push(c); + // Parse the MCP config files/strings if provided + let dynamicMcpConfig: Record = {} + + if (mcpConfig && mcpConfig.length > 0) { + // Process mcpConfig array + const processedConfigs = mcpConfig + .map(config => config.trim()) + .filter(config => config.length > 0) + + let allConfigs: Record = {} + const allErrors: ValidationError[] = [] + + for (const configItem of processedConfigs) { + let configs: Record | null = null + let errors: ValidationError[] = [] + + // First try to parse as JSON string + const parsedJson = safeParseJSON(configItem) + if (parsedJson) { + const result = parseMcpConfig({ + configObject: parsedJson, + filePath: 'command line', + expandVars: true, + scope: 'dynamic', + }) + if (result.config) { + configs = result.config.mcpServers } else { - entries.push({ - kind: 'plugin', - name: rest.slice(0, at), - marketplace: rest.slice(at + 1) - }); + errors = result.errors } - } else if (c.startsWith('server:') && c.length > 7) { - entries.push({ - kind: 'server', - name: c.slice(7) - }); } else { - bad.push(c); + // Try as file path + const configPath = resolve(configItem) + const result = parseMcpConfigFromFilePath({ + filePath: configPath, + expandVars: true, + scope: 'dynamic', + }) + if (result.config) { + configs = result.config.mcpServers + } else { + errors = result.errors + } + } + + if (errors.length > 0) { + allErrors.push(...errors) + } else if (configs) { + // Merge configs, later ones override earlier ones + allConfigs = { ...allConfigs, ...configs } } } - if (bad.length > 0) { - process.stderr.write(chalk.red(`${flag} entries must be tagged: ${bad.join(', ')}\n` + ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + ` server: — manually configured MCP server\n`)); - process.exit(1); + + if (allErrors.length > 0) { + const formattedErrors = allErrors + .map(err => `${err.path ? err.path + ': ' : ''}${err.message}`) + .join('\n') + logForDebugging( + `--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, + { level: 'error' }, + ) + process.stderr.write( + `Error: Invalid MCP configuration:\n${formattedErrors}\n`, + ) + process.exit(1) } - return entries; - }; - const channelOpts = options as { - channels?: string[]; - dangerouslyLoadDevelopmentChannels?: string[]; - }; - const rawChannels = channelOpts.channels; - const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels; - // Always parse + set. ChannelsNotice reads getAllowedChannels() and - // renders the appropriate branch (disabled/noAuth/policyBlocked/ - // listening) in the startup screen. gateChannelServer() enforces. - // --channels works in both interactive and print/SDK modes; dev-channels - // stays interactive-only (requires a confirmation dialog). - let channelEntries: ChannelEntry[] = []; - if (rawChannels && rawChannels.length > 0) { - channelEntries = parseChannelEntries(rawChannels, '--channels'); - setAllowedChannels(channelEntries); - } - if (!isNonInteractiveSession) { - if (rawDev && rawDev.length > 0) { - devChannels = parseChannelEntries(rawDev, '--dangerously-load-development-channels'); + + if (Object.keys(allConfigs).length > 0) { + // SDK hosts (Nest/Desktop) own their server naming and may reuse + // built-in names — skip reserved-name checks for type:'sdk'. + const nonSdkConfigNames = Object.entries(allConfigs) + .filter(([, config]) => config.type !== 'sdk') + .map(([name]) => name) + + let reservedNameError: string | null = null + if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.` + } else if (feature('CHICAGO_MCP')) { + const { isComputerUseMCPServer, COMPUTER_USE_MCP_SERVER_NAME } = + await import('src/utils/computerUse/common.js') + if (nonSdkConfigNames.some(isComputerUseMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.` + } + } + if (reservedNameError) { + // stderr+exit(1) — a throw here becomes a silent unhandled + // rejection in stream-json mode (void main() in cli.tsx). + process.stderr.write(`Error: ${reservedNameError}\n`) + process.exit(1) + } + + // Add dynamic scope to all configs. type:'sdk' entries pass through + // unchanged — they're extracted into sdkMcpConfigs downstream and + // passed to print.ts. The Python SDK relies on this path (it doesn't + // send sdkMcpServers in the initialize message). Dropping them here + // broke Coworker (inc-5122). The policy filter below already exempts + // type:'sdk', and the entries are inert without an SDK transport on + // stdin, so there's no bypass risk from letting them through. + const scopedConfigs = mapValues(allConfigs, config => ({ + ...config, + scope: 'dynamic' as const, + })) + + // Enforce managed policy (allowedMcpServers / deniedMcpServers) on + // --mcp-config servers. Without this, the CLI flag bypasses the + // enterprise allowlist that user/project/local configs go through in + // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on + // top of filtered results. Filter here at the source so all + // downstream consumers see the policy-filtered set. + const { allowed, blocked } = filterMcpServersByPolicy(scopedConfigs) + if (blocked.length > 0) { + process.stderr.write( + `Warning: MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`, + ) + } + dynamicMcpConfig = { ...dynamicMcpConfig, ...allowed } } } - // Flag-usage telemetry. Plugin identifiers are logged (same tier as - // tengu_plugin_installed — public-registry-style names); server-kind - // names are not (MCP-server-name tier, opt-in-only elsewhere). - // Per-server gate outcomes land in tengu_mcp_channel_gate once - // servers connect. Dev entries go through a confirmation dialog after - // this — dev_plugins captures what was typed, not what was accepted. - if (channelEntries.length > 0 || (devChannels?.length ?? 0) > 0) { - const joinPluginIds = (entries: ChannelEntry[]) => { - const ids = entries.flatMap(e => e.kind === 'plugin' ? [`${e.name}@${e.marketplace}`] : []); - return ids.length > 0 ? ids.sort().join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS : undefined; - }; - logEvent('tengu_mcp_channel_flags', { - channels_count: channelEntries.length, - dev_count: devChannels?.length ?? 0, - plugins: joinPluginIds(channelEntries), - dev_plugins: joinPluginIds(devChannels ?? []) - }); - } - } - // SDK opt-in for SendUserMessage via --tools. All sessions require - // explicit opt-in; listing it in --tools signals intent. Runs BEFORE - // initializeToolPermissionContext so getToolsForDefaultPreset() sees - // the tool as enabled when computing the base-tools disallow filter. - // Conditional require avoids leaking the tool-name string into - // external builds. - if ((feature('KAIROS') || feature('KAIROS_BRIEF')) && baseTools.length > 0) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { - BRIEF_TOOL_NAME, - LEGACY_BRIEF_TOOL_NAME - } = require('./tools/BriefTool/prompt.js') as typeof import('./tools/BriefTool/prompt.js'); - const { - isBriefEntitled - } = require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ - const parsed = parseToolListFromCLI(baseTools); - if ((parsed.includes(BRIEF_TOOL_NAME) || parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && isBriefEntitled()) { - setUserMsgOptIn(true); - } - } + // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) + const chromeOpts = options as { chrome?: boolean } + // Store the explicit CLI flag so teammates can inherit it + setChromeFlagOverride(chromeOpts.chrome) + const enableClaudeInChrome = + shouldEnableClaudeInChrome(chromeOpts.chrome) && + (process.env.USER_TYPE === 'ant' || isClaudeAISubscriber()) + const autoEnableClaudeInChrome = + !enableClaudeInChrome && shouldAutoEnableClaudeInChrome() + + if (enableClaudeInChrome) { + const platform = getPlatform() + try { + logEvent('tengu_claude_in_chrome_setup', { + platform: + platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) - // This await replaces blocking existsSync/statSync calls that were already in - // the startup path. Wall-clock time is unchanged; we just yield to the event - // loop during the fs I/O instead of blocking it. See #19661. - const initResult = await initializeToolPermissionContext({ - allowedToolsCli: allowedTools, - disallowedToolsCli: disallowedTools, - baseToolsCli: baseTools, - permissionMode, - allowDangerouslySkipPermissions, - addDirs: addDir - }); - let toolPermissionContext = initResult.toolPermissionContext; - const { - warnings, - dangerousPermissions, - overlyBroadBashPermissions - } = initResult; - - // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) - if ((process.env.USER_TYPE) === 'ant' && overlyBroadBashPermissions.length > 0) { - for (const permission of overlyBroadBashPermissions) { - logForDebugging(`Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`); + const { + mcpConfig: chromeMcpConfig, + allowedTools: chromeMcpTools, + systemPrompt: chromeSystemPrompt, + } = setupClaudeInChrome() + dynamicMcpConfig = { ...dynamicMcpConfig, ...chromeMcpConfig } + allowedTools.push(...chromeMcpTools) + if (chromeSystemPrompt) { + appendSystemPrompt = appendSystemPrompt + ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` + : chromeSystemPrompt + } + } catch (error) { + logEvent('tengu_claude_in_chrome_setup_failed', { + platform: + platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + logForDebugging(`[Claude in Chrome] Error: ${error}`) + logError(error) + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error(`Error: Failed to run with Claude in Chrome.`) + process.exit(1) + } + } else if (autoEnableClaudeInChrome) { + try { + const { mcpConfig: chromeMcpConfig } = setupClaudeInChrome() + dynamicMcpConfig = { ...dynamicMcpConfig, ...chromeMcpConfig } + + const hint = + feature('WEB_BROWSER_TOOL') && + typeof Bun !== 'undefined' && + 'WebView' in Bun + ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER + : CLAUDE_IN_CHROME_SKILL_HINT + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${hint}` + : hint + } catch (error) { + // Silently skip any errors for the auto-enable + logForDebugging(`[Claude in Chrome] Error (auto-enable): ${error}`) + } } - toolPermissionContext = removeDangerousPermissions(toolPermissionContext, overlyBroadBashPermissions); - } - if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) { - toolPermissionContext = stripDangerousPermissionsForAutoMode(toolPermissionContext); - } - // Print any warnings from initialization - warnings.forEach(warning => { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(warning); - }); - void assertMinVersion(); - - // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections - // two-phase loading). Kicked off here to overlap with setup(); awaited - // before runHeadless so single-turn -p sees connectors. Skipped under - // enterprise/strict MCP to preserve policy boundaries. - const claudeaiConfigPromise: Promise> = isNonInteractiveSession && !strictMcpConfig && !doesEnterpriseMcpConfigExist() && - // --bare / SIMPLE: skip claude.ai proxy servers (datadog, Gmail, - // Slack, BigQuery, PubMed — 6-14s each to connect). Scripted calls - // that need MCP pass --mcp-config explicitly. - !isBareMode() ? fetchClaudeAIMcpConfigsIfEligible().then(configs => { - const { - allowed, - blocked - } = filterMcpServersByPolicy(configs); - if (blocked.length > 0) { - process.stderr.write(`Warning: claude.ai MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`); - } - return allowed; - }) : Promise.resolve({}); - - // Kick off MCP config loading early (safe - just reads files, no execution). - // Both interactive and -p use getClaudeCodeMcpConfigs (local file reads only). - // The local promise is awaited later (before prefetchAllMcpResources) to - // overlap config I/O with setup(), commands loading, and trust dialog. - logForDebugging('[STARTUP] Loading MCP configs...'); - const mcpConfigStart = Date.now(); - let mcpConfigResolvedMs: number | undefined; - // --bare skips auto-discovered MCP (.mcp.json, user settings, plugins) — - // only explicit --mcp-config works. dynamicMcpConfig is spread onto - // allMcpConfigs downstream so it survives this skip. - const mcpConfigPromise = (strictMcpConfig || isBareMode() ? Promise.resolve({ - servers: {} as Record - }) : getClaudeCodeMcpConfigs(dynamicMcpConfig)).then(result => { - mcpConfigResolvedMs = Date.now() - mcpConfigStart; - return result; - }); - - // NOTE: We do NOT call prefetchAllMcpResources here - that's deferred until after trust dialog - - if (inputFormat && inputFormat !== 'text' && inputFormat !== 'stream-json') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(`Error: Invalid input format "${inputFormat}".`); - process.exit(1); - } - if (inputFormat === 'stream-json' && outputFormat !== 'stream-json') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(`Error: --input-format=stream-json requires output-format=stream-json.`); - process.exit(1); - } + // Extract strict MCP config flag + const strictMcpConfig = options.strictMcpConfig || false + + // Check if enterprise MCP configuration exists. When it does, only allow dynamic MCP + // configs that contain special server types (sdk) + if (doesEnterpriseMcpConfigExist()) { + if (strictMcpConfig) { + process.stderr.write( + chalk.red( + 'You cannot use --strict-mcp-config when an enterprise MCP config is present', + ), + ) + process.exit(1) + } - // Validate sdkUrl is only used with appropriate formats (formats are auto-set above) - if (sdkUrl) { - if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(`Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`); - process.exit(1); + // For --mcp-config, allow if all servers are internal types (sdk) + if ( + dynamicMcpConfig && + !areMcpConfigsAllowedWithEnterpriseMcpConfig(dynamicMcpConfig) + ) { + process.stderr.write( + chalk.red( + 'You cannot dynamically configure MCP servers when an enterprise MCP config is present', + ), + ) + process.exit(1) + } } - } - // Validate replayUserMessages is only used with stream-json formats - if (options.replayUserMessages) { - if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(`Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`); - process.exit(1); + // chicago MCP: guarded Computer Use (app allowlist + frontmost gate + + // SCContentFilter screenshots). Ant-only, GrowthBook-gated — failures + // are silent (this is dogfooding). Platform + interactive checks inline + // so non-macOS / print-mode ants skip the heavy @ant/computer-use-mcp + // import entirely. gates.js is light (type-only package import). + // + // Placed AFTER the enterprise-MCP-config check: that check rejects any + // dynamicMcpConfig entry with `type !== 'sdk'`, and our config is + // `type: 'stdio'`. An enterprise-config ant with the GB gate on would + // otherwise process.exit(1). Chrome has the same latent issue but has + // shipped without incident; chicago places itself correctly. + if ( + feature('CHICAGO_MCP') && + getPlatform() === 'macos' && + !getIsNonInteractiveSession() + ) { + try { + const { getChicagoEnabled } = await import( + 'src/utils/computerUse/gates.js' + ) + if (getChicagoEnabled()) { + const { setupComputerUseMCP } = await import( + 'src/utils/computerUse/setup.js' + ) + const { mcpConfig, allowedTools: cuTools } = setupComputerUseMCP() + dynamicMcpConfig = { ...dynamicMcpConfig, ...mcpConfig } + allowedTools.push(...cuTools) + } + } catch (error) { + logForDebugging( + `[Computer Use MCP] Setup failed: ${errorMessage(error)}`, + ) + } } - } - // Validate includePartialMessages is only used with print mode and stream-json output - if (effectiveIncludePartialMessages) { - if (!isNonInteractiveSession || outputFormat !== 'stream-json') { - writeToStderr(`Error: --include-partial-messages requires --print and --output-format=stream-json.`); - process.exit(1); - } - } + // Store additional directories for CLAUDE.md loading (controlled by env var) + setAdditionalDirectoriesForClaudeMd(addDir) + + // Channel server allowlist from --channels flag — servers whose + // inbound push notifications should register this session. The option + // is added inside a feature() block so TS doesn't know about it + // on the options type — same pattern as --assistant at main.tsx:1824. + // devChannels is deferred: showSetupScreens shows a confirmation dialog + // and only appends to allowedChannels on accept. + let devChannels: ChannelEntry[] | undefined + if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { + // Parse plugin:name@marketplace / server:Y tags into typed entries. + // Tag decides trust model downstream: plugin-kind hits marketplace + // verification + GrowthBook allowlist, server-kind always fails + // allowlist (schema is plugin-only) unless dev flag is set. + // Untagged or marketplace-less plugin entries are hard errors — + // silently not-matching in the gate would look like channels are + // "on" but nothing ever fires. + const parseChannelEntries = ( + raw: string[], + flag: string, + ): ChannelEntry[] => { + const entries: ChannelEntry[] = [] + const bad: string[] = [] + for (const c of raw) { + if (c.startsWith('plugin:')) { + const rest = c.slice(7) + const at = rest.indexOf('@') + if (at <= 0 || at === rest.length - 1) { + bad.push(c) + } else { + entries.push({ + kind: 'plugin', + name: rest.slice(0, at), + marketplace: rest.slice(at + 1), + }) + } + } else if (c.startsWith('server:') && c.length > 7) { + entries.push({ kind: 'server', name: c.slice(7) }) + } else { + bad.push(c) + } + } + if (bad.length > 0) { + process.stderr.write( + chalk.red( + `${flag} entries must be tagged: ${bad.join(', ')}\n` + + ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + + ` server: — manually configured MCP server\n`, + ), + ) + process.exit(1) + } + return entries + } - // Validate --no-session-persistence is only used with print mode - if (options.sessionPersistence === false && !isNonInteractiveSession) { - writeToStderr(`Error: --no-session-persistence can only be used with --print mode.`); - process.exit(1); - } - const effectivePrompt = prompt || ''; - let inputPrompt = await getInputPrompt(effectivePrompt, (inputFormat ?? 'text') as 'text' | 'stream-json'); - profileCheckpoint('action_after_input_prompt'); - - // Activate proactive mode BEFORE getTools() so SleepTool.isEnabled() - // (which returns isProactiveActive()) passes and Sleep is included. - // The later REPL-path maybeActivateProactive() calls are idempotent. - maybeActivateProactive(options); - let tools = getTools(toolPermissionContext); - - // Apply coordinator mode tool filtering for headless path - // (mirrors useMergedTools.ts filtering for REPL/interactive path) - if (feature('COORDINATOR_MODE') && isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) { - const { - applyCoordinatorToolFilter - } = await import('./utils/toolPool.js'); - tools = applyCoordinatorToolFilter(tools); - } - profileCheckpoint('action_tools_loaded'); - let jsonSchema: ToolInputJSONSchema | undefined; - if (isSyntheticOutputToolEnabled({ - isNonInteractiveSession - }) && options.jsonSchema) { - jsonSchema = jsonParse(options.jsonSchema) as ToolInputJSONSchema; - } - if (jsonSchema) { - const syntheticOutputResult = createSyntheticOutputTool(jsonSchema); - if ('tool' in syntheticOutputResult) { - // Add SyntheticOutputTool to the tools array AFTER getTools() filtering. - // This tool is excluded from normal filtering (see tools.ts) because it's - // an implementation detail for structured output, not a user-controlled tool. - tools = [...tools, syntheticOutputResult.tool]; - logEvent('tengu_structured_output_enabled', { - schema_property_count: Object.keys(jsonSchema.properties as Record || {}).length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - has_required_fields: Boolean(jsonSchema.required) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } else { - logEvent('tengu_structured_output_failure', { - error: 'Invalid JSON schema' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + const channelOpts = options as { + channels?: string[] + dangerouslyLoadDevelopmentChannels?: string[] + } + const rawChannels = channelOpts.channels + const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels + // Always parse + set. ChannelsNotice reads getAllowedChannels() and + // renders the appropriate branch (disabled/noAuth/policyBlocked/ + // listening) in the startup screen. gateChannelServer() enforces. + // --channels works in both interactive and print/SDK modes; dev-channels + // stays interactive-only (requires a confirmation dialog). + let channelEntries: ChannelEntry[] = [] + if (rawChannels && rawChannels.length > 0) { + channelEntries = parseChannelEntries(rawChannels, '--channels') + setAllowedChannels(channelEntries) + } + if (!isNonInteractiveSession) { + if (rawDev && rawDev.length > 0) { + devChannels = parseChannelEntries( + rawDev, + '--dangerously-load-development-channels', + ) + } + } + // Flag-usage telemetry. Plugin identifiers are logged (same tier as + // tengu_plugin_installed — public-registry-style names); server-kind + // names are not (MCP-server-name tier, opt-in-only elsewhere). + // Per-server gate outcomes land in tengu_mcp_channel_gate once + // servers connect. Dev entries go through a confirmation dialog after + // this — dev_plugins captures what was typed, not what was accepted. + if (channelEntries.length > 0 || (devChannels?.length ?? 0) > 0) { + const joinPluginIds = (entries: ChannelEntry[]) => { + const ids = entries.flatMap(e => + e.kind === 'plugin' ? [`${e.name}@${e.marketplace}`] : [], + ) + return ids.length > 0 + ? (ids + .sort() + .join( + ',', + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + : undefined + } + logEvent('tengu_mcp_channel_flags', { + channels_count: channelEntries.length, + dev_count: devChannels?.length ?? 0, + plugins: joinPluginIds(channelEntries), + dev_plugins: joinPluginIds(devChannels ?? []), + }) + } } - } - // IMPORTANT: setup() must be called before any other code that depends on the cwd or worktree setup - profileCheckpoint('action_before_setup'); - logForDebugging('[STARTUP] Running setup()...'); - const setupStart = Date.now(); - const { - setup - } = await import('./setup.js'); - const messagingSocketPath = feature('UDS_INBOX') ? (options as { - messagingSocketPath?: string; - }).messagingSocketPath : undefined; - // Parallelize setup() with commands+agents loading. setup()'s ~28ms is - // mostly startUdsMessaging (socket bind, ~20ms) — not disk-bound, so it - // doesn't contend with getCommands' file reads. Gated on !worktreeEnabled - // since --worktree makes setup() process.chdir() (setup.ts:203), and - // commands/agents need the post-chdir cwd. - const preSetupCwd = getCwd(); - // Register bundled skills/plugins before kicking getCommands() — they're - // pure in-memory array pushes (<1ms, zero I/O) that getBundledSkills() - // reads synchronously. Previously ran inside setup() after ~20ms of - // await points, so the parallel getCommands() memoized an empty list. - if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') { - initBuiltinPlugins(); - initBundledSkills(); - } - const setupPromise = setup(preSetupCwd, permissionMode, allowDangerouslySkipPermissions, worktreeEnabled, worktreeName, tmuxEnabled, sessionId ? validateUuid(sessionId) : undefined, worktreePRNumber, messagingSocketPath); - const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd); - const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd); - // Suppress transient unhandledRejection if these reject during the - // ~28ms setupPromise await before Promise.all joins them below. - commandsPromise?.catch(() => {}); - agentDefsPromise?.catch(() => {}); - await setupPromise; - logForDebugging(`[STARTUP] setup() completed in ${Date.now() - setupStart}ms`); - profileCheckpoint('action_after_setup'); - - // Replay user messages into stream-json only when the socket was - // explicitly requested. The auto-generated socket is passive — it - // lets tools inject if they want to, but turning it on by default - // shouldn't reshape stream-json for SDK consumers who never touch it. - // Callers who inject and also want those injections visible in the - // stream pass --messaging-socket-path explicitly (or --replay-user-messages). - let effectiveReplayUserMessages = !!options.replayUserMessages; - if (feature('UDS_INBOX')) { - if (!effectiveReplayUserMessages && outputFormat === 'stream-json') { - effectiveReplayUserMessages = !!(options as { - messagingSocketPath?: string; - }).messagingSocketPath; + // SDK opt-in for SendUserMessage via --tools. All sessions require + // explicit opt-in; listing it in --tools signals intent. Runs BEFORE + // initializeToolPermissionContext so getToolsForDefaultPreset() sees + // the tool as enabled when computing the base-tools disallow filter. + // Conditional require avoids leaking the tool-name string into + // external builds. + if ( + (feature('KAIROS') || feature('KAIROS_BRIEF')) && + baseTools.length > 0 + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { BRIEF_TOOL_NAME, LEGACY_BRIEF_TOOL_NAME } = + require('./tools/BriefTool/prompt.js') as typeof import('./tools/BriefTool/prompt.js') + const { isBriefEntitled } = + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + const parsed = parseToolListFromCLI(baseTools) + if ( + (parsed.includes(BRIEF_TOOL_NAME) || + parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && + isBriefEntitled() + ) { + setUserMsgOptIn(true) + } } - } - if (getIsNonInteractiveSession()) { - // Apply full merged settings env now (including project-scoped - // .claude/settings.json PATH/GIT_DIR/GIT_WORK_TREE) so gitExe() and - // the git spawn below see it. Trust is implicit in -p mode; the - // docstring at managedEnv.ts:96-97 says this applies "potentially - // dangerous environment variables such as LD_PRELOAD, PATH" from all - // sources. The later call in the isNonInteractiveSession block below - // is idempotent (Object.assign, configureGlobalAgents ejects prior - // interceptor) and picks up any plugin-contributed env after plugin - // init. Project settings are already loaded here: - // applySafeConfigEnvironmentVariables in init() called - // getSettings_DEPRECATED at managedEnv.ts:86 which merges all enabled - // sources including projectSettings/localSettings. - applyConfigEnvironmentVariables(); - - // Spawn git status/log/branch now so the subprocess execution overlaps - // with the getCommands await below and startDeferredPrefetches. After - // setup() so cwd is final (setup.ts:254 may process.chdir(worktreePath) - // for --worktree) and after the applyConfigEnvironmentVariables above - // so PATH/GIT_DIR/GIT_WORK_TREE from all sources (trusted + project) - // are applied. getSystemContext is memoized; the - // prefetchSystemContextIfSafe call in startDeferredPrefetches becomes - // a cache hit. The microtask from await getIsGit() drains at the - // getCommands Promise.all await below. Trust is implicit in -p mode - // (same gate as prefetchSystemContextIfSafe). - void getSystemContext(); - // Kick getUserContext now too — its first await (fs.readFile in - // getMemoryFiles) yields naturally, so the CLAUDE.md directory walk - // runs during the ~280ms overlap window before the context - // Promise.all join in print.ts. The void getUserContext() in - // startDeferredPrefetches becomes a memoize cache-hit. - void getUserContext(); - // Kick ensureModelStringsInitialized now — for Bedrock this triggers - // a 100-200ms profile fetch that was awaited serially at - // print.ts:739. updateBedrockModelStrings is sequential()-wrapped so - // the await joins the in-flight fetch. Non-Bedrock is a sync - // early-return (zero-cost). - void ensureModelStringsInitialized(); - } - - // Apply --name: cache-only so no orphan file is created before the - // session ID is finalized by --continue/--resume. materializeSessionFile - // persists it on the first user message; REPL's useTerminalTitle reads it - // via getCurrentSessionTitle. - const sessionNameArg = options.name?.trim(); - if (sessionNameArg) { - cacheSessionTitle(sessionNameArg); - } - - // Ant model aliases (capybara-fast etc.) resolve via the - // tengu_ant_model_override GrowthBook flag. _CACHED_MAY_BE_STALE reads - // disk synchronously; disk is populated by a fire-and-forget write. On a - // cold cache, parseUserSpecifiedModel returns the unresolved alias, the - // API 404s, and -p exits before the async write lands — crashloop on - // fresh pods. Awaiting init here populates the in-memory payload map that - // _CACHED_MAY_BE_STALE now checks first. Gated so the warm path stays - // non-blocking: - // - explicit model via --model or ANTHROPIC_MODEL (both feed alias resolution) - // - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk) - // - flag absent from disk (== null also catches pre-#22279 poisoned null) - const explicitModel = options.model || process.env.ANTHROPIC_MODEL; - if ((process.env.USER_TYPE) === 'ant' && explicitModel && explicitModel !== 'default' && !hasGrowthBookEnvOverride('tengu_ant_model_override') && getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null) { - await initializeGrowthBook(); - } - // Special case the default model with the null keyword - // NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth - const userSpecifiedModel = options.model === 'default' ? getDefaultMainLoopModel() : options.model; - const userSpecifiedFallbackModel = fallbackModel === 'default' ? getDefaultMainLoopModel() : fallbackModel; - - // Reuse preSetupCwd unless setup() chdir'd (worktreeEnabled). Saves a - // getCwd() syscall in the common path. - const currentCwd = worktreeEnabled ? getCwd() : preSetupCwd; - logForDebugging('[STARTUP] Loading commands and agents...'); - const commandsStart = Date.now(); - // Join the promises kicked before setup() (or start fresh if - // worktreeEnabled gated the early kick). Both memoized by cwd. - const [commands, agentDefinitionsResult] = await Promise.all([commandsPromise ?? getCommands(currentCwd), agentDefsPromise ?? getAgentDefinitionsWithOverrides(currentCwd)]); - logForDebugging(`[STARTUP] Commands and agents loaded in ${Date.now() - commandsStart}ms`); - profileCheckpoint('action_commands_loaded'); - - // Parse CLI agents if provided via --agents flag - let cliAgents: typeof agentDefinitionsResult.activeAgents = []; - if (agentsJson) { - try { - const parsedAgents = safeParseJSON(agentsJson); - if (parsedAgents) { - cliAgents = parseAgentsFromJson(parsedAgents, 'flagSettings'); + // This await replaces blocking existsSync/statSync calls that were already in + // the startup path. Wall-clock time is unchanged; we just yield to the event + // loop during the fs I/O instead of blocking it. See #19661. + const initResult = await initializeToolPermissionContext({ + allowedToolsCli: allowedTools, + disallowedToolsCli: disallowedTools, + baseToolsCli: baseTools, + permissionMode, + allowDangerouslySkipPermissions, + addDirs: addDir, + }) + let toolPermissionContext = initResult.toolPermissionContext + const { warnings, dangerousPermissions, overlyBroadBashPermissions } = + initResult + + // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) + if ( + process.env.USER_TYPE === 'ant' && + overlyBroadBashPermissions.length > 0 + ) { + for (const permission of overlyBroadBashPermissions) { + logForDebugging( + `Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`, + ) } - } catch (error) { - logError(error); + toolPermissionContext = removeDangerousPermissions( + toolPermissionContext, + overlyBroadBashPermissions, + ) } - } - // Merge CLI agents with existing ones - const allAgents = [...agentDefinitionsResult.allAgents, ...cliAgents]; - const agentDefinitions = { - ...agentDefinitionsResult, - allAgents, - activeAgents: getActiveAgentsFromList(allAgents) - }; - - // Look up main thread agent from CLI flag or settings - const agentSetting = agentCli ?? getInitialSettings().agent; - let mainThreadAgentDefinition: (typeof agentDefinitions.activeAgents)[number] | undefined; - if (agentSetting) { - mainThreadAgentDefinition = agentDefinitions.activeAgents.find(agent => agent.agentType === agentSetting); - if (!mainThreadAgentDefinition) { - logForDebugging(`Warning: agent "${agentSetting}" not found. ` + `Available agents: ${agentDefinitions.activeAgents.map(a => a.agentType).join(', ')}. ` + `Using default behavior.`); + if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) { + toolPermissionContext = stripDangerousPermissionsForAutoMode( + toolPermissionContext, + ) } - } - // Store the main thread agent type in bootstrap state so hooks can access it - setMainThreadAgentType(mainThreadAgentDefinition?.agentType); + // Print any warnings from initialization + warnings.forEach(warning => { + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error(warning) + }) - // Log agent flag usage — only log agent name for built-in agents to avoid leaking custom agent names - if (mainThreadAgentDefinition) { - logEvent('tengu_agent_flag', { - agentType: isBuiltInAgent(mainThreadAgentDefinition) ? mainThreadAgentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS : 'custom' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...(agentCli && { - source: 'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }) - }); - } + void assertMinVersion() + + // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections + // two-phase loading). Kicked off here to overlap with setup(); awaited + // before runHeadless so single-turn -p sees connectors. Skipped under + // enterprise/strict MCP to preserve policy boundaries. + const claudeaiConfigPromise: Promise< + Record + > = + isNonInteractiveSession && + !strictMcpConfig && + !doesEnterpriseMcpConfigExist() && + // --bare / SIMPLE: skip claude.ai proxy servers (datadog, Gmail, + // Slack, BigQuery, PubMed — 6-14s each to connect). Scripted calls + // that need MCP pass --mcp-config explicitly. + !isBareMode() + ? fetchClaudeAIMcpConfigsIfEligible().then(configs => { + const { allowed, blocked } = filterMcpServersByPolicy(configs) + if (blocked.length > 0) { + process.stderr.write( + `Warning: claude.ai MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`, + ) + } + return allowed + }) + : Promise.resolve({}) + + // Kick off MCP config loading early (safe - just reads files, no execution). + // Both interactive and -p use getClaudeCodeMcpConfigs (local file reads only). + // The local promise is awaited later (before prefetchAllMcpResources) to + // overlap config I/O with setup(), commands loading, and trust dialog. + logForDebugging('[STARTUP] Loading MCP configs...') + const mcpConfigStart = Date.now() + let mcpConfigResolvedMs: number | undefined + // --bare skips auto-discovered MCP (.mcp.json, user settings, plugins) — + // only explicit --mcp-config works. dynamicMcpConfig is spread onto + // allMcpConfigs downstream so it survives this skip. + const mcpConfigPromise = ( + strictMcpConfig || isBareMode() + ? Promise.resolve({ + servers: {} as Record, + }) + : getClaudeCodeMcpConfigs(dynamicMcpConfig) + ).then(result => { + mcpConfigResolvedMs = Date.now() - mcpConfigStart + return result + }) - // Persist agent setting to session transcript for resume view display and restoration - if (mainThreadAgentDefinition?.agentType) { - saveAgentSetting(mainThreadAgentDefinition.agentType); - } + // NOTE: We do NOT call prefetchAllMcpResources here - that's deferred until after trust dialog - // Apply the agent's system prompt for non-interactive sessions - // (interactive mode uses buildEffectiveSystemPrompt instead) - if (isNonInteractiveSession && mainThreadAgentDefinition && !systemPrompt && !isBuiltInAgent(mainThreadAgentDefinition)) { - const agentSystemPrompt = mainThreadAgentDefinition.getSystemPrompt(); - if (agentSystemPrompt) { - systemPrompt = agentSystemPrompt; + if ( + inputFormat && + inputFormat !== 'text' && + inputFormat !== 'stream-json' + ) { + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error(`Error: Invalid input format "${inputFormat}".`) + process.exit(1) } - } - - // initialPrompt goes first so its slash command (if any) is processed; - // user-provided text becomes trailing context. - // Only concatenate when inputPrompt is a string. When it's an - // AsyncIterable (SDK stream-json mode), template interpolation would - // call .toString() producing "[object Object]". The AsyncIterable case - // is handled in print.ts via structuredIO.prependUserMessage(). - if (mainThreadAgentDefinition?.initialPrompt) { - if (typeof inputPrompt === 'string') { - inputPrompt = inputPrompt ? `${mainThreadAgentDefinition.initialPrompt}\n\n${inputPrompt}` : mainThreadAgentDefinition.initialPrompt; - } else if (!inputPrompt) { - inputPrompt = mainThreadAgentDefinition.initialPrompt; + if (inputFormat === 'stream-json' && outputFormat !== 'stream-json') { + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error( + `Error: --input-format=stream-json requires output-format=stream-json.`, + ) + process.exit(1) } - } - // Compute effective model early so hooks can run in parallel with MCP - // If user didn't specify a model but agent has one, use the agent's model - let effectiveModel = userSpecifiedModel; - if (!effectiveModel && mainThreadAgentDefinition?.model && mainThreadAgentDefinition.model !== 'inherit') { - effectiveModel = parseUserSpecifiedModel(mainThreadAgentDefinition.model); - } - setMainLoopModelOverride(effectiveModel); - - // Compute resolved model for hooks (use user-specified model at launch) - setInitialMainLoopModel(getUserSpecifiedModelSetting() || null); - const initialMainLoopModel = getInitialMainLoopModel(); - const resolvedInitialModel = parseUserSpecifiedModel(initialMainLoopModel ?? getDefaultMainLoopModel()); - let advisorModel: string | undefined; - if (isAdvisorEnabled()) { - const advisorOption = canUserConfigureAdvisor() ? (options as { - advisor?: string; - }).advisor : undefined; - if (advisorOption) { - logForDebugging(`[AdvisorTool] --advisor ${advisorOption}`); - if (!modelSupportsAdvisor(resolvedInitialModel)) { - process.stderr.write(chalk.red(`Error: The model "${resolvedInitialModel}" does not support the advisor tool.\n`)); - process.exit(1); + // Validate sdkUrl is only used with appropriate formats (formats are auto-set above) + if (sdkUrl) { + if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error( + `Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`, + ) + process.exit(1) } - const normalizedAdvisorModel = normalizeModelStringForAPI(parseUserSpecifiedModel(advisorOption)); - if (!isValidAdvisorModel(normalizedAdvisorModel)) { - process.stderr.write(chalk.red(`Error: The model "${advisorOption}" cannot be used as an advisor.\n`)); - process.exit(1); - } - } - advisorModel = canUserConfigureAdvisor() ? advisorOption ?? getInitialAdvisorSetting() : advisorOption; - if (advisorModel) { - logForDebugging(`[AdvisorTool] Advisor model: ${advisorModel}`); } - } - // For tmux teammates with --agent-type, append the custom agent's prompt - if (isAgentSwarmsEnabled() && storedTeammateOpts?.agentId && storedTeammateOpts?.agentName && storedTeammateOpts?.teamName && storedTeammateOpts?.agentType) { - // Look up the custom agent definition - const customAgent = agentDefinitions.activeAgents.find(a => a.agentType === storedTeammateOpts.agentType); - if (customAgent) { - // Get the prompt - need to handle both built-in and custom agents - let customPrompt: string | undefined; - if (customAgent.source === 'built-in') { - // Built-in agents have getSystemPrompt that takes toolUseContext - // We can't access full toolUseContext here, so skip for now - logForDebugging(`[teammate] Built-in agent ${storedTeammateOpts.agentType} - skipping custom prompt (not supported)`); - } else { - // Custom agents have getSystemPrompt that takes no args - customPrompt = customAgent.getSystemPrompt(); + // Validate replayUserMessages is only used with stream-json formats + if (options.replayUserMessages) { + if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error( + `Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`, + ) + process.exit(1) } + } - // Log agent memory loaded event for tmux teammates - if (customAgent.memory) { - logEvent('tengu_agent_memory_loaded', { - ...((process.env.USER_TYPE) === 'ant' && { - agent_type: customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }), - scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - } - if (customPrompt) { - const customInstructions = `\n# Custom Agent Instructions\n${customPrompt}`; - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${customInstructions}` : customInstructions; + // Validate includePartialMessages is only used with print mode and stream-json output + if (effectiveIncludePartialMessages) { + if (!isNonInteractiveSession || outputFormat !== 'stream-json') { + writeToStderr( + `Error: --include-partial-messages requires --print and --output-format=stream-json.`, + ) + process.exit(1) } - } else { - logForDebugging(`[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`); } - } - maybeActivateBrief(options); - // defaultView: 'chat' is a persisted opt-in — check entitlement and set - // userMsgOptIn so the tool + prompt section activate. Interactive-only: - // defaultView is a display preference; SDK sessions have no display, and - // the assistant installer writes defaultView:'chat' to settings.local.json - // which would otherwise leak into --print sessions in the same directory. - // Runs right after maybeActivateBrief() so all startup opt-in paths fire - // BEFORE any isBriefEnabled() read below (proactive prompt's - // briefVisibility). A persisted 'chat' after a GB kill-switch falls - // through (entitlement fails). - if ((feature('KAIROS') || feature('KAIROS_BRIEF')) && !getIsNonInteractiveSession() && !getUserMsgOptIn() && getInitialSettings().defaultView === 'chat') { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEntitled - } = require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ - if (isBriefEntitled()) { - setUserMsgOptIn(true); + + // Validate --no-session-persistence is only used with print mode + if (options.sessionPersistence === false && !isNonInteractiveSession) { + writeToStderr( + `Error: --no-session-persistence can only be used with --print mode.`, + ) + process.exit(1) } - } - // Coordinator mode has its own system prompt and filters out Sleep, so - // the generic proactive prompt would tell it to call a tool it can't - // access and conflict with delegation instructions. - if ((feature('PROACTIVE') || feature('KAIROS')) && ((options as { - proactive?: boolean; - }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && !coordinatorModeModule?.isCoordinatorMode()) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const briefVisibility = feature('KAIROS') || feature('KAIROS_BRIEF') ? (require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js')).isBriefEnabled() ? 'Call SendUserMessage at checkpoints to mark where things stand.' : 'The user will see any text you output.' : 'The user will see any text you output.'; - /* eslint-enable @typescript-eslint/no-require-imports */ - const proactivePrompt = `\n# Proactive Mode\n\nYou are in proactive mode. Take initiative — explore, act, and make progress without waiting for instructions.\n\nStart by briefly greeting the user.\n\nYou will receive periodic prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}`; - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${proactivePrompt}` : proactivePrompt; - } - if (feature('KAIROS') && kairosEnabled && assistantModule) { - const assistantAddendum = assistantModule.getAssistantSystemPromptAddendum(); - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${assistantAddendum}` : assistantAddendum; - } - // Ink root is only needed for interactive sessions — patchConsole in the - // Ink constructor would swallow console output in headless mode. - let root!: Root; - let getFpsMetrics!: () => FpsMetrics | undefined; - let stats!: StatsStore; - - // Show setup screens after commands are loaded - if (!isNonInteractiveSession) { - const ctx = getRenderContext(false); - getFpsMetrics = ctx.getFpsMetrics; - stats = ctx.stats; - // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) - if ((process.env.USER_TYPE) === 'ant') { - installAsciicastRecorder(); + const effectivePrompt = prompt || '' + let inputPrompt = await getInputPrompt( + effectivePrompt, + (inputFormat ?? 'text') as 'text' | 'stream-json', + ) + profileCheckpoint('action_after_input_prompt') + + // Activate proactive mode BEFORE getTools() so SleepTool.isEnabled() + // (which returns isProactiveActive()) passes and Sleep is included. + // The later REPL-path maybeActivateProactive() calls are idempotent. + maybeActivateProactive(options) + + let tools = getTools(toolPermissionContext) + + // Apply coordinator mode tool filtering for headless path + // (mirrors useMergedTools.ts filtering for REPL/interactive path) + if ( + feature('COORDINATOR_MODE') && + isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + ) { + const { applyCoordinatorToolFilter } = await import( + './utils/toolPool.js' + ) + tools = applyCoordinatorToolFilter(tools) } - const { - createRoot - } = await import('./ink.js'); - root = await createRoot(ctx.renderOptions); - - // Log startup time now, before any blocking dialog renders. Logging - // from REPL's first render (the old location) included however long - // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s - // dominated by dialog-wait time, not code-path startup. - logEvent('tengu_timer', { - event: 'startup' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - durationMs: Math.round(process.uptime() * 1000) - }); - logForDebugging('[STARTUP] Running showSetupScreens()...'); - const setupScreensStart = Date.now(); - const onboardingShown = await showSetupScreens(root, permissionMode, allowDangerouslySkipPermissions, commands, enableClaudeInChrome, devChannels); - logForDebugging(`[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`); - - // Now that trust is established and GrowthBook has auth headers, - // resolve the --remote-control / --rc entitlement gate. - if (feature('BRIDGE_MODE') && remoteControlOption !== undefined) { - const { - getBridgeDisabledReason - } = await import('./bridge/bridgeEnabled.js'); - const disabledReason = await getBridgeDisabledReason(); - remoteControl = disabledReason === null; - if (disabledReason) { - process.stderr.write(chalk.yellow(`${disabledReason}\n--rc flag ignored.\n`)); - } + + profileCheckpoint('action_tools_loaded') + + let jsonSchema: ToolInputJSONSchema | undefined + if ( + isSyntheticOutputToolEnabled({ isNonInteractiveSession }) && + options.jsonSchema + ) { + jsonSchema = jsonParse(options.jsonSchema) as ToolInputJSONSchema } - // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) - if (feature('AGENT_MEMORY_SNAPSHOT') && mainThreadAgentDefinition && isCustomAgent(mainThreadAgentDefinition) && mainThreadAgentDefinition.memory && mainThreadAgentDefinition.pendingSnapshotUpdate) { - const agentDef = mainThreadAgentDefinition; - const choice = await launchSnapshotUpdateDialog(root, { - agentType: agentDef.agentType, - scope: agentDef.memory!, - snapshotTimestamp: agentDef.pendingSnapshotUpdate!.snapshotTimestamp - }); - if (choice === 'merge') { - const { - buildMergePrompt - } = await import('./components/agents/SnapshotUpdateDialog.js'); - const mergePrompt = buildMergePrompt(agentDef.agentType, agentDef.memory!); - inputPrompt = inputPrompt ? `${mergePrompt}\n\n${inputPrompt}` : mergePrompt; + if (jsonSchema) { + const syntheticOutputResult = createSyntheticOutputTool(jsonSchema) + if ('tool' in syntheticOutputResult) { + // Add SyntheticOutputTool to the tools array AFTER getTools() filtering. + // This tool is excluded from normal filtering (see tools.ts) because it's + // an implementation detail for structured output, not a user-controlled tool. + tools = [...tools, syntheticOutputResult.tool] + + logEvent('tengu_structured_output_enabled', { + schema_property_count: Object.keys( + (jsonSchema.properties as Record) || {}, + ) + .length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + has_required_fields: Boolean( + jsonSchema.required, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + } else { + logEvent('tengu_structured_output_failure', { + error: + 'Invalid JSON schema' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } - agentDef.pendingSnapshotUpdate = undefined; } - // Skip executing /login if we just completed onboarding for it - if (onboardingShown && prompt?.trim().toLowerCase() === '/login') { - prompt = ''; + // IMPORTANT: setup() must be called before any other code that depends on the cwd or worktree setup + profileCheckpoint('action_before_setup') + logForDebugging('[STARTUP] Running setup()...') + const setupStart = Date.now() + const { setup } = await import('./setup.js') + const messagingSocketPath = feature('UDS_INBOX') + ? (options as { messagingSocketPath?: string }).messagingSocketPath + : undefined + // Parallelize setup() with commands+agents loading. setup()'s ~28ms is + // mostly startUdsMessaging (socket bind, ~20ms) — not disk-bound, so it + // doesn't contend with getCommands' file reads. Gated on !worktreeEnabled + // since --worktree makes setup() process.chdir() (setup.ts:203), and + // commands/agents need the post-chdir cwd. + const preSetupCwd = getCwd() + // Register bundled skills/plugins before kicking getCommands() — they're + // pure in-memory array pushes (<1ms, zero I/O) that getBundledSkills() + // reads synchronously. Previously ran inside setup() after ~20ms of + // await points, so the parallel getCommands() memoized an empty list. + if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') { + initBuiltinPlugins() + initBundledSkills() } - if (onboardingShown) { - // Refresh auth-dependent services now that the user has logged in during onboarding. - // Keep in sync with the post-login logic in src/commands/login.tsx - void refreshRemoteManagedSettings(); - void refreshPolicyLimits(); - // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials - resetUserCache(); - // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) - refreshGrowthBookAfterAuthChange(); - // Clear any stale trusted device token then enroll for Remote Control. - // Both self-gate on tengu_sessions_elevated_auth_enforcement internally - // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits - // the GrowthBook reinit above), clearTrustedDeviceToken() via the - // sync cached check (acceptable since clear is idempotent). - void import('./bridge/trustedDevice.js').then(m => { - m.clearTrustedDeviceToken(); - return m.enrollTrustedDevice(); - }); + const setupPromise = setup( + preSetupCwd, + permissionMode, + allowDangerouslySkipPermissions, + worktreeEnabled, + worktreeName, + tmuxEnabled, + sessionId ? validateUuid(sessionId) : undefined, + worktreePRNumber, + messagingSocketPath, + ) + const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd) + const agentDefsPromise = worktreeEnabled + ? null + : getAgentDefinitionsWithOverrides(preSetupCwd) + // Suppress transient unhandledRejection if these reject during the + // ~28ms setupPromise await before Promise.all joins them below. + commandsPromise?.catch(() => {}) + agentDefsPromise?.catch(() => {}) + await setupPromise + logForDebugging( + `[STARTUP] setup() completed in ${Date.now() - setupStart}ms`, + ) + profileCheckpoint('action_after_setup') + + // Replay user messages into stream-json only when the socket was + // explicitly requested. The auto-generated socket is passive — it + // lets tools inject if they want to, but turning it on by default + // shouldn't reshape stream-json for SDK consumers who never touch it. + // Callers who inject and also want those injections visible in the + // stream pass --messaging-socket-path explicitly (or --replay-user-messages). + let effectiveReplayUserMessages = !!options.replayUserMessages + if (feature('UDS_INBOX')) { + if (!effectiveReplayUserMessages && outputFormat === 'stream-json') { + effectiveReplayUserMessages = !!( + options as { messagingSocketPath?: string } + ).messagingSocketPath + } } - // Validate that the active token's org matches forceLoginOrgUUID (if set - // in managed settings). Runs after onboarding so managed settings and - // login state are fully loaded. - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - await exitWithError(root, (orgValidation as { valid: false; message: string }).message); + if (getIsNonInteractiveSession()) { + // Apply full merged settings env now (including project-scoped + // .claude/settings.json PATH/GIT_DIR/GIT_WORK_TREE) so gitExe() and + // the git spawn below see it. Trust is implicit in -p mode; the + // docstring at managedEnv.ts:96-97 says this applies "potentially + // dangerous environment variables such as LD_PRELOAD, PATH" from all + // sources. The later call in the isNonInteractiveSession block below + // is idempotent (Object.assign, configureGlobalAgents ejects prior + // interceptor) and picks up any plugin-contributed env after plugin + // init. Project settings are already loaded here: + // applySafeConfigEnvironmentVariables in init() called + // getSettings_DEPRECATED at managedEnv.ts:86 which merges all enabled + // sources including projectSettings/localSettings. + applyConfigEnvironmentVariables() + + // Spawn git status/log/branch now so the subprocess execution overlaps + // with the getCommands await below and startDeferredPrefetches. After + // setup() so cwd is final (setup.ts:254 may process.chdir(worktreePath) + // for --worktree) and after the applyConfigEnvironmentVariables above + // so PATH/GIT_DIR/GIT_WORK_TREE from all sources (trusted + project) + // are applied. getSystemContext is memoized; the + // prefetchSystemContextIfSafe call in startDeferredPrefetches becomes + // a cache hit. The microtask from await getIsGit() drains at the + // getCommands Promise.all await below. Trust is implicit in -p mode + // (same gate as prefetchSystemContextIfSafe). + void getSystemContext() + // Kick getUserContext now too — its first await (fs.readFile in + // getMemoryFiles) yields naturally, so the CLAUDE.md directory walk + // runs during the ~280ms overlap window before the context + // Promise.all join in print.ts. The void getUserContext() in + // startDeferredPrefetches becomes a memoize cache-hit. + void getUserContext() + // Kick ensureModelStringsInitialized now — for Bedrock this triggers + // a 100-200ms profile fetch that was awaited serially at + // print.ts:739. updateBedrockModelStrings is sequential()-wrapped so + // the await joins the in-flight fetch. Non-Bedrock is a sync + // early-return (zero-cost). + void ensureModelStringsInitialized() } - } - - // If gracefulShutdown was initiated (e.g., user rejected trust dialog), - // process.exitCode will be set. Skip all subsequent operations that could - // trigger code execution before the process exits (e.g. we don't want apiKeyHelper - // to run if trust was not established). - if (process.exitCode !== undefined) { - logForDebugging('Graceful shutdown initiated, skipping further initialization'); - return; - } - - // Initialize LSP manager AFTER trust is established (or in non-interactive mode - // where trust is implicit). This prevents plugin LSP servers from executing - // code in untrusted directories before user consent. - // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. - initializeLspServerManager(); - // Show settings validation errors after trust is established - // MCP config errors don't block settings from loading, so exclude them - if (!isNonInteractiveSession) { - const { - errors - } = getSettingsWithErrors(); - const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata); - if (nonMcpErrors.length > 0) { - await launchInvalidSettingsDialog(root, { - settingsErrors: nonMcpErrors, - onExit: () => gracefulShutdownSync(1) - }); + // Apply --name: cache-only so no orphan file is created before the + // session ID is finalized by --continue/--resume. materializeSessionFile + // persists it on the first user message; REPL's useTerminalTitle reads it + // via getCurrentSessionTitle. + const sessionNameArg = options.name?.trim() + if (sessionNameArg) { + cacheSessionTitle(sessionNameArg) } - } - // Check quota status, fast mode, passes eligibility, and bootstrap data - // after trust is established. These make API calls which could trigger - // apiKeyHelper execution. - // --bare / SIMPLE: skip — these are cache-warms for the REPL's - // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast - // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). - const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE('tengu_cicada_nap_ms', 0); - const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0; - const skipStartupPrefetches = isBareMode() || bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs; - if (!skipStartupPrefetches) { - const lastPrefetchedInfo = lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : ''; - logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`); - checkQuotaStatus().catch(error => logError(error)); - - // Fetch bootstrap data from the server and update all cache values. - void fetchBootstrapData(); - - // TODO: Consolidate other prefetches into a single bootstrap request. - void prefetchPassesEligibility(); - if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_miraculo_the_bard', false)) { - void prefetchFastModeStatus(); - } else { - // Kill switch skips the network call, not org-policy enforcement. - // Resolve from cache so orgStatus doesn't stay 'pending' (which - // getFastModeUnavailableReason treats as permissive). - resolveFastModeStatusFromCache(); + // Ant model aliases (capybara-fast etc.) resolve via the + // tengu_ant_model_override GrowthBook flag. _CACHED_MAY_BE_STALE reads + // disk synchronously; disk is populated by a fire-and-forget write. On a + // cold cache, parseUserSpecifiedModel returns the unresolved alias, the + // API 404s, and -p exits before the async write lands — crashloop on + // fresh pods. Awaiting init here populates the in-memory payload map that + // _CACHED_MAY_BE_STALE now checks first. Gated so the warm path stays + // non-blocking: + // - explicit model via --model or ANTHROPIC_MODEL (both feed alias resolution) + // - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk) + // - flag absent from disk (== null also catches pre-#22279 poisoned null) + const explicitModel = options.model || process.env.ANTHROPIC_MODEL + if ( + process.env.USER_TYPE === 'ant' && + explicitModel && + explicitModel !== 'default' && + !hasGrowthBookEnvOverride('tengu_ant_model_override') && + getGlobalConfig().cachedGrowthBookFeatures?.[ + 'tengu_ant_model_override' + ] == null + ) { + await initializeGrowthBook() } - if (bgRefreshThrottleMs > 0) { - saveGlobalConfig(current => ({ - ...current, - startupPrefetchedAt: Date.now() - })); - } - } else { - logForDebugging(`Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`); - // Resolve fast mode org status from cache (no network) - resolveFastModeStatusFromCache(); - } - if (!isNonInteractiveSession) { - void refreshExampleCommands(); // Pre-fetch example commands (runs git log, no API call) - } - // Resolve MCP configs (started early, overlaps with setup/trust dialog work) - const { - servers: existingMcpConfigs - } = await mcpConfigPromise; - logForDebugging(`[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`); - // CLI flag (--mcp-config) should override file-based configs, matching settings precedence - const allMcpConfigs = { - ...existingMcpConfigs, - ...dynamicMcpConfig - }; - - // Separate SDK configs from regular MCP configs - const sdkMcpConfigs: Record = {}; - const regularMcpConfigs: Record = {}; - for (const [name, config] of Object.entries(allMcpConfigs)) { - const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig; - if (typedConfig.type === 'sdk') { - sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig; - } else { - regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig; - } - } - profileCheckpoint('action_mcp_configs_loaded'); - - // Prefetch MCP resources after trust dialog (this is where execution happens). - // Interactive mode only: print mode defers connects until headlessStore exists - // and pushes per-server (below), so ToolSearch's pending-client handling works - // and one slow server doesn't block the batch. - const localMcpPromise = isNonInteractiveSession ? Promise.resolve({ - clients: [], - tools: [], - commands: [] - }) : prefetchAllMcpResources(regularMcpConfigs); - const claudeaiMcpPromise = isNonInteractiveSession ? Promise.resolve({ - clients: [], - tools: [], - commands: [] - }) : claudeaiConfigPromise.then(configs => Object.keys(configs).length > 0 ? prefetchAllMcpResources(configs) : { - clients: [], - tools: [], - commands: [] - }); - // Merge with dedup by name: each prefetchAllMcpResources call independently - // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via - // local dedup flags, so merging two calls can yield duplicates. print.ts - // already uniqBy's the final tool pool, but dedup here keeps appState clean. - const mcpPromise = Promise.all([localMcpPromise, claudeaiMcpPromise]).then(([local, claudeai]) => ({ - clients: [...local.clients, ...claudeai.clients], - tools: uniqBy([...local.tools, ...claudeai.tools], 'name'), - commands: uniqBy([...local.commands, ...claudeai.commands], 'name') - })); - - // Start hooks early so they run in parallel with MCP connections. - // Skip for initOnly/init/maintenance (handled separately), non-interactive - // (handled via setupTrigger), and resume/continue (conversationRecovery.ts - // fires 'resume' instead — without this guard, hooks fire TWICE on /resume - // and the second systemMessage clobbers the first. gh-30825) - const hooksPromise = initOnly || init || maintenance || isNonInteractiveSession || options.continue || options.resume ? null : processSessionStartHooks('startup', { - agentType: mainThreadAgentDefinition?.agentType, - model: resolvedInitialModel - }); - - // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections - // populates appState.mcp async as servers connect (connectToServer is - // memoized — the prefetch calls above and the hook converge on the same - // connections). getToolUseContext reads store.getState() fresh via - // computeTools(), so turn 1 sees whatever's connected by query time. - // Slow servers populate for turn 2+. Matches interactive-no-prompt - // behavior. Print mode: per-server push into headlessStore (below). - const hookMessages: Awaited> = []; - // Suppress transient unhandledRejection — the prefetch warms the - // memoized connectToServer cache but nobody awaits it in interactive. - mcpPromise.catch(() => {}); - const mcpClients: Awaited['clients'] = []; - const mcpTools: Awaited['tools'] = []; - const mcpCommands: Awaited['commands'] = []; - let thinkingEnabled = shouldEnableThinkingByDefault(); - let thinkingConfig: ThinkingConfig = thinkingEnabled !== false ? { - type: 'adaptive' - } : { - type: 'disabled' - }; - if (options.thinking === 'adaptive' || options.thinking === 'enabled') { - thinkingEnabled = true; - thinkingConfig = { - type: 'adaptive' - }; - } else if (options.thinking === 'disabled') { - thinkingEnabled = false; - thinkingConfig = { - type: 'disabled' - }; - } else { - const maxThinkingTokens = process.env.MAX_THINKING_TOKENS ? parseInt(process.env.MAX_THINKING_TOKENS, 10) : options.maxThinkingTokens; - if (maxThinkingTokens !== undefined) { - if (maxThinkingTokens > 0) { - thinkingEnabled = true; - thinkingConfig = { - type: 'enabled', - budgetTokens: maxThinkingTokens - }; - } else if (maxThinkingTokens === 0) { - thinkingEnabled = false; - thinkingConfig = { - type: 'disabled' - }; + // Special case the default model with the null keyword + // NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth + const userSpecifiedModel = + options.model === 'default' ? getDefaultMainLoopModel() : options.model + const userSpecifiedFallbackModel = + fallbackModel === 'default' ? getDefaultMainLoopModel() : fallbackModel + + // Reuse preSetupCwd unless setup() chdir'd (worktreeEnabled). Saves a + // getCwd() syscall in the common path. + const currentCwd = worktreeEnabled ? getCwd() : preSetupCwd + logForDebugging('[STARTUP] Loading commands and agents...') + const commandsStart = Date.now() + // Join the promises kicked before setup() (or start fresh if + // worktreeEnabled gated the early kick). Both memoized by cwd. + const [commands, agentDefinitionsResult] = await Promise.all([ + commandsPromise ?? getCommands(currentCwd), + agentDefsPromise ?? getAgentDefinitionsWithOverrides(currentCwd), + ]) + logForDebugging( + `[STARTUP] Commands and agents loaded in ${Date.now() - commandsStart}ms`, + ) + profileCheckpoint('action_commands_loaded') + + // Parse CLI agents if provided via --agents flag + let cliAgents: typeof agentDefinitionsResult.activeAgents = [] + if (agentsJson) { + try { + const parsedAgents = safeParseJSON(agentsJson) + if (parsedAgents) { + cliAgents = parseAgentsFromJson(parsedAgents, 'flagSettings') + } + } catch (error) { + logError(error) } } - } - logForDiagnosticsNoPII('info', 'started', { - version: MACRO.VERSION, - is_native_binary: isInBundledMode() - }); - registerCleanup(async () => { - logForDiagnosticsNoPII('info', 'exited'); - }); - void logTenguInit({ - hasInitialPrompt: Boolean(prompt), - hasStdin: Boolean(inputPrompt), - verbose, - debug, - debugToStderr, - print: print ?? false, - outputFormat: outputFormat ?? 'text', - inputFormat: inputFormat ?? 'text', - numAllowedTools: allowedTools.length, - numDisallowedTools: disallowedTools.length, - mcpClientCount: Object.keys(allMcpConfigs).length, - worktreeEnabled, - skipWebFetchPreflight: getInitialSettings().skipWebFetchPreflight, - githubActionInputs: process.env.GITHUB_ACTION_INPUTS, - dangerouslySkipPermissionsPassed: dangerouslySkipPermissions ?? false, - permissionMode, - modeIsBypass: permissionMode === 'bypassPermissions', - allowDangerouslySkipPermissionsPassed: allowDangerouslySkipPermissions, - systemPromptFlag: systemPrompt ? options.systemPromptFile ? 'file' : 'flag' : undefined, - appendSystemPromptFlag: appendSystemPrompt ? options.appendSystemPromptFile ? 'file' : 'flag' : undefined, - thinkingConfig, - assistantActivationPath: feature('KAIROS') && kairosEnabled ? assistantModule?.getAssistantActivationPath() : undefined - }); - - // Log context metrics once at initialization - void logContextMetrics(regularMcpConfigs, toolPermissionContext); - void logPermissionContextForAnts(null, 'initialization'); - logManagedSettings(); - - // Register PID file for concurrent-session detection (~/.claude/sessions/) - // and fire multi-clauding telemetry. Lives here (not init.ts) so only the - // REPL path registers — not subcommands like `claude doctor`. Chained: - // count must run after register's write completes or it misses our own file. - void registerSession().then(registered => { - if (!registered) return; - if (sessionNameArg) { - void updateSessionName(sessionNameArg); + + // Merge CLI agents with existing ones + const allAgents = [...agentDefinitionsResult.allAgents, ...cliAgents] + const agentDefinitions = { + ...agentDefinitionsResult, + allAgents, + activeAgents: getActiveAgentsFromList(allAgents), } - void countConcurrentSessions().then(count => { - if (count >= 2) { - logEvent('tengu_concurrent_sessions', { - num_sessions: count - }); + + // Look up main thread agent from CLI flag or settings + const agentSetting = agentCli ?? getInitialSettings().agent + let mainThreadAgentDefinition: + | (typeof agentDefinitions.activeAgents)[number] + | undefined + if (agentSetting) { + mainThreadAgentDefinition = agentDefinitions.activeAgents.find( + agent => agent.agentType === agentSetting, + ) + if (!mainThreadAgentDefinition) { + logForDebugging( + `Warning: agent "${agentSetting}" not found. ` + + `Available agents: ${agentDefinitions.activeAgents.map(a => a.agentType).join(', ')}. ` + + `Using default behavior.`, + ) } - }); - }); - - // Initialize versioned plugins system (triggers V1→V2 migration if - // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. - // Sequencing matters: the warmup scans disk for .orphaned_at markers, - // so it must see the GC's Pass 1 (remove markers from reinstalled - // versions) and Pass 2 (stamp unmarked orphans) already applied. The - // warm also lands before autoupdate (fires on first submit in REPL) - // can orphan this session's active version underneath us. - // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These - // are install/upgrade bookkeeping that scripted calls don't need — - // the next interactive session will reconcile. The await here was - // blocking -p on a marketplace round-trip. - if (isBareMode()) { - // skip — no-op - } else if (isNonInteractiveSession) { - // In headless mode, await to ensure plugin sync completes before CLI exits - await initializeVersionedPlugins(); - profileCheckpoint('action_after_plugins_init'); - void cleanupOrphanedPluginVersionsInBackground().then(() => getGlobExclusionsForPluginCache()); - } else { - // In interactive mode, fire-and-forget — this is purely bookkeeping - // that doesn't affect runtime behavior of the current session - void initializeVersionedPlugins().then(async () => { - profileCheckpoint('action_after_plugins_init'); - await cleanupOrphanedPluginVersionsInBackground(); - void getGlobExclusionsForPluginCache(); - }); - } - const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null; - if (initOnly) { - applyConfigEnvironmentVariables(); - await processSetupHooks('init', { - forceSyncExecution: true - }); - await processSessionStartHooks('startup', { - forceSyncExecution: true - }); - gracefulShutdownSync(0); - return; - } + } - // --print mode - if (isNonInteractiveSession) { - if (outputFormat === 'stream-json' || outputFormat === 'json') { - setHasFormattedOutput(true); + // Store the main thread agent type in bootstrap state so hooks can access it + setMainThreadAgentType(mainThreadAgentDefinition?.agentType) + + // Log agent flag usage — only log agent name for built-in agents to avoid leaking custom agent names + if (mainThreadAgentDefinition) { + logEvent('tengu_agent_flag', { + agentType: isBuiltInAgent(mainThreadAgentDefinition) + ? (mainThreadAgentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + : ('custom' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), + ...(agentCli && { + source: + 'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + }) } - // Apply full environment variables in print mode since trust dialog is bypassed - // This includes potentially dangerous environment variables from untrusted sources - // but print mode is considered trusted (as documented in help text) - applyConfigEnvironmentVariables(); - - // Initialize telemetry after env vars are applied so OTEL endpoint env vars and - // otelHeadersHelper (which requires trust to execute) are available. - initializeTelemetryAfterTrust(); - - // Kick SessionStart hooks now so the subprocess spawn overlaps with - // MCP connect + plugin init + print.ts import below. loadInitialMessages - // joins this at print.ts:4397. Guarded same as loadInitialMessages — - // continue/resume/teleport paths don't fire startup hooks (or fire them - // conditionally inside the resume branch, where this promise is - // undefined and the ?? fallback runs). Also skip when setupTrigger is - // set — those paths run setup hooks first (print.ts:544), and session - // start hooks must wait until setup completes. - const sessionStartHooksPromise = options.continue || options.resume || teleport || setupTrigger ? undefined : processSessionStartHooks('startup'); - // Suppress transient unhandledRejection if this rejects before - // loadInitialMessages awaits it. Downstream await still observes the - // rejection — this just prevents the spurious global handler fire. - sessionStartHooksPromise?.catch(() => {}); - profileCheckpoint('before_validateForceLoginOrg'); - // Validate org restriction for non-interactive sessions - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - process.stderr.write((orgValidation as { valid: false; message: string }).message + '\n'); - process.exit(1); + // Persist agent setting to session transcript for resume view display and restoration + if (mainThreadAgentDefinition?.agentType) { + saveAgentSetting(mainThreadAgentDefinition.agentType) } - // Headless mode supports all prompt commands and some local commands - // If disableSlashCommands is true, return empty array - const commandsHeadless = disableSlashCommands ? [] : commands.filter(command => command.type === 'prompt' && !command.disableNonInteractive || command.type === 'local' && command.supportsNonInteractive); - const defaultState = getDefaultAppState(); - const headlessInitialState: AppState = { - ...defaultState, - mcp: { - ...defaultState.mcp, - clients: mcpClients, - commands: mcpCommands, - tools: mcpTools - }, - toolPermissionContext, - effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), - ...(isFastModeEnabled() && { - fastMode: getInitialFastModeSetting(effectiveModel ?? null) - }), - ...(isAdvisorEnabled() && advisorModel && { - advisorModel - }), - // kairosEnabled gates the async fire-and-forget path in - // executeForkedSlashCommand (processSlashCommand.tsx:132) and - // AgentTool's shouldRunAsync. The REPL initialState sets this at - // ~3459; headless was defaulting to false, so the daemon child's - // scheduled tasks and Agent-tool calls ran synchronously — N - // overdue cron tasks on spawn = N serial subagent turns blocking - // user input. Computed at :1620, well before this branch. - ...(feature('KAIROS') ? { - kairosEnabled - } : {}) - }; - - // Init app state - const headlessStore = createStore(headlessInitialState, onChangeAppState); - - // Check if bypassPermissions should be disabled based on Statsig gate - // This runs in parallel to the code below, to avoid blocking the main loop. - if (toolPermissionContext.mode === 'bypassPermissions' || allowDangerouslySkipPermissions) { - void checkAndDisableBypassPermissions(toolPermissionContext); + // Apply the agent's system prompt for non-interactive sessions + // (interactive mode uses buildEffectiveSystemPrompt instead) + if ( + isNonInteractiveSession && + mainThreadAgentDefinition && + !systemPrompt && + !isBuiltInAgent(mainThreadAgentDefinition) + ) { + const agentSystemPrompt = mainThreadAgentDefinition.getSystemPrompt() + if (agentSystemPrompt) { + systemPrompt = agentSystemPrompt + } } - // Async check of auto mode gate — corrects state and disables auto if needed. - // Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too. - if (feature('TRANSCRIPT_CLASSIFIER')) { - void verifyAutoModeGateAccess(toolPermissionContext, headlessStore.getState().fastMode).then(({ - updateContext - }) => { - headlessStore.setState(prev => { - const nextCtx = updateContext(prev.toolPermissionContext); - if (nextCtx === prev.toolPermissionContext) return prev; - return { - ...prev, - toolPermissionContext: nextCtx - }; - }); - }); + // initialPrompt goes first so its slash command (if any) is processed; + // user-provided text becomes trailing context. + // Only concatenate when inputPrompt is a string. When it's an + // AsyncIterable (SDK stream-json mode), template interpolation would + // call .toString() producing "[object Object]". The AsyncIterable case + // is handled in print.ts via structuredIO.prependUserMessage(). + if (mainThreadAgentDefinition?.initialPrompt) { + if (typeof inputPrompt === 'string') { + inputPrompt = inputPrompt + ? `${mainThreadAgentDefinition.initialPrompt}\n\n${inputPrompt}` + : mainThreadAgentDefinition.initialPrompt + } else if (!inputPrompt) { + inputPrompt = mainThreadAgentDefinition.initialPrompt + } } - // Set global state for session persistence - if (options.sessionPersistence === false) { - setSessionPersistenceDisabled(true); + // Compute effective model early so hooks can run in parallel with MCP + // If user didn't specify a model but agent has one, use the agent's model + let effectiveModel = userSpecifiedModel + if ( + !effectiveModel && + mainThreadAgentDefinition?.model && + mainThreadAgentDefinition.model !== 'inherit' + ) { + effectiveModel = parseUserSpecifiedModel( + mainThreadAgentDefinition.model, + ) } - // Store SDK betas in global state for context window calculation - // Only store allowed betas (filters by allowlist and subscriber status) - setSdkBetas(filterAllowedSdkBetas(betas)); - - // Print-mode MCP: per-server incremental push into headlessStore. - // Mirrors useManageMCPConnections — push pending first (so ToolSearch's - // pending-check at ToolSearchTool.ts:334 sees them), then replace with - // connected/failed as each server settles. - const connectMcpBatch = (configs: Record, label: string): Promise => { - if (Object.keys(configs).length === 0) return Promise.resolve(); - headlessStore.setState(prev => ({ - ...prev, - mcp: { - ...prev.mcp, - clients: [...prev.mcp.clients, ...Object.entries(configs).map(([name, config]) => ({ - name, - type: 'pending' as const, - config - }))] + setMainLoopModelOverride(effectiveModel) + + // Compute resolved model for hooks (use user-specified model at launch) + setInitialMainLoopModel(getUserSpecifiedModelSetting() || null) + const initialMainLoopModel = getInitialMainLoopModel() + const resolvedInitialModel = parseUserSpecifiedModel( + initialMainLoopModel ?? getDefaultMainLoopModel(), + ) + + let advisorModel: string | undefined + if (isAdvisorEnabled()) { + const advisorOption = canUserConfigureAdvisor() + ? (options as { advisor?: string }).advisor + : undefined + if (advisorOption) { + logForDebugging(`[AdvisorTool] --advisor ${advisorOption}`) + if (!modelSupportsAdvisor(resolvedInitialModel)) { + process.stderr.write( + chalk.red( + `Error: The model "${resolvedInitialModel}" does not support the advisor tool.\n`, + ), + ) + process.exit(1) } - })); - return getMcpToolsCommandsAndResources(({ - client, - tools, - commands - }) => { - headlessStore.setState(prev => ({ - ...prev, - mcp: { - ...prev.mcp, - clients: prev.mcp.clients.some(c => c.name === client.name) ? prev.mcp.clients.map(c => c.name === client.name ? client : c) : [...prev.mcp.clients, client], - tools: uniqBy([...prev.mcp.tools, ...tools], 'name'), - commands: uniqBy([...prev.mcp.commands, ...commands], 'name') - } - })); - }, configs).catch(err => logForDebugging(`[MCP] ${label} connect error: ${err}`)); - }; - // Await all MCP configs — print mode is often single-turn, so - // "late-connecting servers visible next turn" doesn't help. SDK init - // message and turn-1 tool list both need configured MCP tools present. - // Zero-server case is free via the early return in connectMcpBatch. - // Connectors parallelize inside getMcpToolsCommandsAndResources - // (processBatched with Promise.all). claude.ai is awaited too — its - // fetch was kicked off early (line ~2558) so only residual time blocks - // here. --bare skips claude.ai entirely for perf-sensitive scripts. - profileCheckpoint('before_connectMcp'); - await connectMcpBatch(regularMcpConfigs, 'regular'); - profileCheckpoint('after_connectMcp'); - // Dedup: suppress plugin MCP servers that duplicate a claude.ai - // connector (connector wins), then connect claude.ai servers. - // Bounded wait — #23725 made this blocking so single-turn -p sees - // connectors, but with 40+ slow connectors tengu_startup_perf p99 - // climbed to 76s. If fetch+connect doesn't finish in time, proceed; - // the promise keeps running and updates headlessStore in the - // background so turn 2+ still sees connectors. - const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000; - const claudeaiConnect = claudeaiConfigPromise.then(claudeaiConfigs => { - if (Object.keys(claudeaiConfigs).length > 0) { - const claudeaiSigs = new Set(); - for (const config of Object.values(claudeaiConfigs)) { - const sig = getMcpServerSignature(config); - if (sig) claudeaiSigs.add(sig); + const normalizedAdvisorModel = normalizeModelStringForAPI( + parseUserSpecifiedModel(advisorOption), + ) + if (!isValidAdvisorModel(normalizedAdvisorModel)) { + process.stderr.write( + chalk.red( + `Error: The model "${advisorOption}" cannot be used as an advisor.\n`, + ), + ) + process.exit(1) } - const suppressed = new Set(); - for (const [name, config] of Object.entries(regularMcpConfigs)) { - if (!name.startsWith('plugin:')) continue; - const sig = getMcpServerSignature(config); - if (sig && claudeaiSigs.has(sig)) suppressed.add(name); + } + advisorModel = canUserConfigureAdvisor() + ? (advisorOption ?? getInitialAdvisorSetting()) + : advisorOption + if (advisorModel) { + logForDebugging(`[AdvisorTool] Advisor model: ${advisorModel}`) + } + } + + // For tmux teammates with --agent-type, append the custom agent's prompt + if ( + isAgentSwarmsEnabled() && + storedTeammateOpts?.agentId && + storedTeammateOpts?.agentName && + storedTeammateOpts?.teamName && + storedTeammateOpts?.agentType + ) { + // Look up the custom agent definition + const customAgent = agentDefinitions.activeAgents.find( + a => a.agentType === storedTeammateOpts.agentType, + ) + if (customAgent) { + // Get the prompt - need to handle both built-in and custom agents + let customPrompt: string | undefined + if (customAgent.source === 'built-in') { + // Built-in agents have getSystemPrompt that takes toolUseContext + // We can't access full toolUseContext here, so skip for now + logForDebugging( + `[teammate] Built-in agent ${storedTeammateOpts.agentType} - skipping custom prompt (not supported)`, + ) + } else { + // Custom agents have getSystemPrompt that takes no args + customPrompt = customAgent.getSystemPrompt() } - if (suppressed.size > 0) { - logForDebugging(`[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(', ')}`); - // Disconnect before filtering from state. Only connected - // servers need cleanup — clearServerCache on a never-connected - // server triggers a real connect just to kill it (memoize - // cache-miss path, see useManageMCPConnections.ts:870). - for (const c of headlessStore.getState().mcp.clients) { - if (!suppressed.has(c.name) || c.type !== 'connected') continue; - c.client.onclose = undefined; - void clearServerCache(c.name, c.config).catch(() => {}); - } - headlessStore.setState(prev => { - let { - clients, - tools, - commands, - resources - } = prev.mcp; - clients = clients.filter(c => !suppressed.has(c.name)); - tools = tools.filter(t => !t.mcpInfo || !suppressed.has(t.mcpInfo.serverName)); - for (const name of suppressed) { - commands = excludeCommandsByServer(commands, name); - resources = excludeResourcesByServer(resources, name); - } - return { - ...prev, - mcp: { - ...prev.mcp, - clients, - tools, - commands, - resources - } - }; - }); + + // Log agent memory loaded event for tmux teammates + if (customAgent.memory) { + logEvent('tengu_agent_memory_loaded', { + ...(process.env.USER_TYPE === 'ant' && { + agent_type: + customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + scope: + customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + } + + if (customPrompt) { + const customInstructions = `\n# Custom Agent Instructions\n${customPrompt}` + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${customInstructions}` + : customInstructions } + } else { + logForDebugging( + `[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`, + ) } - // Suppress claude.ai connectors that duplicate an enabled - // manual server (URL-signature match). Plugin dedup above only - // handles `plugin:*` keys; this catches manual `.mcp.json` entries. - // plugin:* must be excluded here — step 1 already suppressed - // those (claude.ai wins); leaving them in suppresses the - // connector too, and neither survives (gh-39974). - const nonPluginConfigs = pickBy(regularMcpConfigs, (_, n) => !n.startsWith('plugin:')); - const { - servers: dedupedClaudeAi - } = dedupClaudeAiMcpServers(claudeaiConfigs, nonPluginConfigs); - return connectMcpBatch(dedupedClaudeAi, 'claudeai'); - }); - let claudeaiTimer: ReturnType | undefined; - const claudeaiTimedOut = await Promise.race([claudeaiConnect.then(() => false), new Promise(resolve => { - claudeaiTimer = setTimeout(r => r(true), CLAUDE_AI_MCP_TIMEOUT_MS, resolve); - })]); - if (claudeaiTimer) clearTimeout(claudeaiTimer); - if (claudeaiTimedOut) { - logForDebugging(`[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`); } - profileCheckpoint('after_connectMcp_claudeai'); - - // In headless mode, start deferred prefetches immediately (no user typing delay) - // --bare / SIMPLE: startDeferredPrefetches early-returns internally. - // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, - // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping - // that scripted calls don't need — the next interactive session reconciles. - if (!isBareMode()) { - startDeferredPrefetches(); - void import('./utils/backgroundHousekeeping.js').then(m => m.startBackgroundHousekeeping()); - if ((process.env.USER_TYPE) === 'ant') { - void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor()); + + maybeActivateBrief(options) + // defaultView: 'chat' is a persisted opt-in — check entitlement and set + // userMsgOptIn so the tool + prompt section activate. Interactive-only: + // defaultView is a display preference; SDK sessions have no display, and + // the assistant installer writes defaultView:'chat' to settings.local.json + // which would otherwise leak into --print sessions in the same directory. + // Runs right after maybeActivateBrief() so all startup opt-in paths fire + // BEFORE any isBriefEnabled() read below (proactive prompt's + // briefVisibility). A persisted 'chat' after a GB kill-switch falls + // through (entitlement fails). + if ( + (feature('KAIROS') || feature('KAIROS_BRIEF')) && + !getIsNonInteractiveSession() && + !getUserMsgOptIn() && + getInitialSettings().defaultView === 'chat' + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isBriefEntitled } = + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + if (isBriefEntitled()) { + setUserMsgOptIn(true) } } - logSessionTelemetry(); - profileCheckpoint('before_print_import'); - const { - runHeadless - } = await import('src/cli/print.js'); - profileCheckpoint('after_print_import'); - void runHeadless(inputPrompt, () => headlessStore.getState(), headlessStore.setState, commandsHeadless, tools, sdkMcpConfigs, agentDefinitions.activeAgents, { - continue: options.continue, - resume: options.resume, - verbose: verbose, - outputFormat: outputFormat, - jsonSchema, - permissionPromptToolName: options.permissionPromptTool, - allowedTools, - thinkingConfig, - maxTurns: options.maxTurns, - maxBudgetUsd: options.maxBudgetUsd, - taskBudget: options.taskBudget ? { - total: options.taskBudget - } : undefined, - systemPrompt, - appendSystemPrompt, - userSpecifiedModel: effectiveModel, - fallbackModel: userSpecifiedFallbackModel, - teleport, - sdkUrl, - replayUserMessages: effectiveReplayUserMessages, - includePartialMessages: effectiveIncludePartialMessages, - forkSession: options.forkSession || false, - resumeSessionAt: options.resumeSessionAt || undefined, - rewindFiles: options.rewindFiles, - enableAuthStatus: options.enableAuthStatus, - agent: agentCli, - workload: options.workload, - setupTrigger: setupTrigger ?? undefined, - sessionStartHooksPromise - }); - return; - } + // Coordinator mode has its own system prompt and filters out Sleep, so + // the generic proactive prompt would tell it to call a tool it can't + // access and conflict with delegation instructions. + if ( + (feature('PROACTIVE') || feature('KAIROS')) && + ((options as { proactive?: boolean }).proactive || + isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && + !coordinatorModeModule?.isCoordinatorMode() + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const briefVisibility = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? ( + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') + ).isBriefEnabled() + ? 'Call SendUserMessage at checkpoints to mark where things stand.' + : 'The user will see any text you output.' + : 'The user will see any text you output.' + /* eslint-enable @typescript-eslint/no-require-imports */ + const proactivePrompt = `\n# Proactive Mode\n\nYou are in proactive mode. Take initiative — explore, act, and make progress without waiting for instructions.\n\nStart by briefly greeting the user.\n\nYou will receive periodic prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}` + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${proactivePrompt}` + : proactivePrompt + } - // Log model config at startup - logEvent('tengu_startup_manual_model_config', { - cli_flag: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - env_var: process.env.ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - settings_file: (getInitialSettings() || {}).model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - subscriptionType: getSubscriptionType() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - agent: agentSetting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - - // Get deprecation warning for the initial model (resolvedInitialModel computed earlier for hooks parallelization) - const deprecationWarning = getModelDeprecationWarning(resolvedInitialModel); - - // Build initial notification queue - const initialNotifications: Array<{ - key: string; - text: string; - color?: 'warning'; - priority: 'high'; - }> = []; - if (permissionModeNotification) { - initialNotifications.push({ - key: 'permission-mode-notification', - text: permissionModeNotification, - priority: 'high' - }); - } - if (deprecationWarning) { - initialNotifications.push({ - key: 'model-deprecation-warning', - text: deprecationWarning, - color: 'warning', - priority: 'high' - }); - } - if (overlyBroadBashPermissions.length > 0) { - const displayList = uniq(overlyBroadBashPermissions.map(p => p.ruleDisplay)); - const displays = displayList.join(', '); - const sources = uniq(overlyBroadBashPermissions.map(p => p.sourceDisplay)).join(', '); - const n = displayList.length; - initialNotifications.push({ - key: 'overly-broad-bash-notification', - text: `${displays} allow ${plural(n, 'rule')} from ${sources} ${plural(n, 'was', 'were')} ignored \u2014 not available for Ants, please use auto-mode instead`, - color: 'warning', - priority: 'high' - }); - } - const effectiveToolPermissionContext = { - ...toolPermissionContext, - mode: isAgentSwarmsEnabled() && getTeammateUtils().isPlanModeRequired() ? 'plan' as const : toolPermissionContext.mode - }; - // All startup opt-in paths (--tools, --brief, defaultView) have fired - // above; initialIsBriefOnly just reads the resulting state. - const initialIsBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? getUserMsgOptIn() : false; - const fullRemoteControl = remoteControl || getRemoteControlAtStartup() || kairosEnabled; - let ccrMirrorEnabled = false; - if (feature('CCR_MIRROR') && !fullRemoteControl) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isCcrMirrorEnabled - } = require('./bridge/bridgeEnabled.js') as typeof import('./bridge/bridgeEnabled.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ - ccrMirrorEnabled = isCcrMirrorEnabled(); - } - const initialState: AppState = { - settings: getInitialSettings(), - tasks: {}, - agentNameRegistry: new Map(), - verbose: verbose ?? getGlobalConfig().verbose ?? false, - mainLoopModel: initialMainLoopModel, - mainLoopModelForSession: null, - isBriefOnly: initialIsBriefOnly, - expandedView: getGlobalConfig().showSpinnerTree ? 'teammates' : getGlobalConfig().showExpandedTodos ? 'tasks' : 'none', - showTeammateMessagePreview: isAgentSwarmsEnabled() ? false : undefined, - selectedIPAgentIndex: -1, - coordinatorTaskIndex: -1, - viewSelectionMode: 'none', - footerSelection: null, - toolPermissionContext: effectiveToolPermissionContext, - agent: mainThreadAgentDefinition?.agentType, - agentDefinitions, - mcp: { - clients: [], - tools: [], - commands: [], - resources: {}, - pluginReconnectKey: 0 - }, - plugins: { - enabled: [], - disabled: [], - commands: [], - errors: [], - installationStatus: { - marketplaces: [], - plugins: [] - }, - needsRefresh: false - }, - statusLineText: undefined, - kairosEnabled, - remoteSessionUrl: undefined, - remoteConnectionStatus: 'connecting', - remoteBackgroundTaskCount: 0, - replBridgeEnabled: fullRemoteControl || ccrMirrorEnabled, - replBridgeExplicit: remoteControl, - replBridgeOutboundOnly: ccrMirrorEnabled, - replBridgeConnected: false, - replBridgeSessionActive: false, - replBridgeReconnecting: false, - replBridgeConnectUrl: undefined, - replBridgeSessionUrl: undefined, - replBridgeEnvironmentId: undefined, - replBridgeSessionId: undefined, - replBridgeError: undefined, - replBridgeInitialName: remoteControlName, - showRemoteCallout: false, - notifications: { - current: null, - queue: initialNotifications - }, - elicitation: { - queue: [] - }, - todos: {}, - remoteAgentTaskSuggestions: [], - fileHistory: { - snapshots: [], - trackedFiles: new Set(), - snapshotSequence: 0 - }, - attribution: createEmptyAttributionState(), - thinkingEnabled, - promptSuggestionEnabled: shouldEnablePromptSuggestion(), - sessionHooks: new Map(), - inbox: { - messages: [] - }, - promptSuggestion: { - text: null, - promptId: null, - shownAt: 0, - acceptedAt: 0, - generationRequestId: null - }, - speculation: IDLE_SPECULATION_STATE, - speculationSessionTimeSavedMs: 0, - skillImprovement: { - suggestion: null - }, - workerSandboxPermissions: { - queue: [], - selectedIndex: 0 - }, - pendingWorkerRequest: null, - pendingSandboxRequest: null, - authVersion: 0, - initialMessage: inputPrompt ? { - message: createUserMessage({ - content: String(inputPrompt) + if (feature('KAIROS') && kairosEnabled && assistantModule) { + const assistantAddendum = + assistantModule.getAssistantSystemPromptAddendum() + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${assistantAddendum}` + : assistantAddendum + } + + // Ink root is only needed for interactive sessions — patchConsole in the + // Ink constructor would swallow console output in headless mode. + let root!: Root + let getFpsMetrics!: () => FpsMetrics | undefined + let stats!: StatsStore + + // Show setup screens after commands are loaded + if (!isNonInteractiveSession) { + const ctx = getRenderContext(false) + getFpsMetrics = ctx.getFpsMetrics + stats = ctx.stats + // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) + if (process.env.USER_TYPE === 'ant') { + installAsciicastRecorder() + } + + const { createRoot } = await import('./ink.js') + root = await createRoot(ctx.renderOptions) + + // Log startup time now, before any blocking dialog renders. Logging + // from REPL's first render (the old location) included however long + // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s + // dominated by dialog-wait time, not code-path startup. + logEvent('tengu_timer', { + event: + 'startup' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + durationMs: Math.round(process.uptime() * 1000), }) - } : null, - effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), - activeOverlays: new Set(), - fastMode: getInitialFastModeSetting(resolvedInitialModel), - ...(isAdvisorEnabled() && advisorModel && { - advisorModel - }), - // Compute teamContext synchronously to avoid useEffect setState during render. - // KAIROS: assistantTeamContext takes precedence — set earlier in the - // KAIROS block so Agent(name: "foo") can spawn in-process teammates - // without TeamCreate. computeInitialTeamContext() is for tmux-spawned - // teammates reading their own identity, not the assistant-mode leader. - teamContext: (feature('KAIROS') ? assistantTeamContext ?? computeInitialTeamContext?.() : computeInitialTeamContext?.()) || undefined - }; - - // Add CLI initial prompt to history - if (inputPrompt) { - addToHistory(String(inputPrompt)); - } - const initialTools = mcpTools; - - // Increment numStartups synchronously — first-render readers like - // shouldShowEffortCallout (via useState initializer) need the updated - // value before setImmediate fires. Defer only telemetry. - saveGlobalConfig(current => ({ - ...current, - numStartups: (current.numStartups ?? 0) + 1 - })); - setImmediate(() => { - void logStartupTelemetry(); - logSessionTelemetry(); - }); - - // Set up per-turn session environment data uploader (ant-only build). - // Default-enabled for all ant users when working in an Anthropic-owned - // repo. Captures git/filesystem state (NOT transcripts) at each turn so - // environments can be recreated at any user message index. Gating: - // - Build-time: this import is stubbed in external builds. - // - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth. - // - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this). - // Import is dynamic + async to avoid adding startup latency. - const sessionUploaderPromise = (process.env.USER_TYPE) === 'ant' ? import('./utils/sessionDataUploader.js') : null; - - // Defer session uploader resolution to the onTurnComplete callback to avoid - // adding a new top-level await in main.tsx (performance-critical path). - // The per-turn auth logic in sessionDataUploader.ts handles unauthenticated - // state gracefully (re-checks each turn, so auth recovery mid-session works). - const uploaderReady = sessionUploaderPromise ? sessionUploaderPromise.then(mod => mod.createSessionTurnUploader()).catch(() => null) : null; - const sessionConfig = { - debug: debug || debugToStderr, - commands: [...commands, ...mcpCommands], - initialTools, - mcpClients, - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - dynamicMcpConfig, - strictMcpConfig, - systemPrompt, - appendSystemPrompt, - taskListId, - thinkingConfig, - ...(uploaderReady && { - onTurnComplete: (messages: MessageType[]) => { - void uploaderReady.then(uploader => uploader?.(messages)); + + logForDebugging('[STARTUP] Running showSetupScreens()...') + const setupScreensStart = Date.now() + const onboardingShown = await showSetupScreens( + root, + permissionMode, + allowDangerouslySkipPermissions, + commands, + enableClaudeInChrome, + devChannels, + ) + logForDebugging( + `[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`, + ) + + // Now that trust is established and GrowthBook has auth headers, + // resolve the --remote-control / --rc entitlement gate. + if (feature('BRIDGE_MODE') && remoteControlOption !== undefined) { + const { getBridgeDisabledReason } = await import( + './bridge/bridgeEnabled.js' + ) + const disabledReason = await getBridgeDisabledReason() + remoteControl = disabledReason === null + if (disabledReason) { + process.stderr.write( + chalk.yellow(`${disabledReason}\n--rc flag ignored.\n`), + ) + } } - }) - }; - - // Shared context for processResumedConversation calls - const resumeContext = { - modeApi: coordinatorModeModule, - mainThreadAgentDefinition, - agentDefinitions, - currentCwd, - cliAgents, - initialState - }; - if (options.continue) { - // Continue the most recent conversation directly - let resumeSucceeded = false; - try { - const resumeStart = performance.now(); - // Clear stale caches before resuming to ensure fresh file/skill discovery - const { - clearSessionCaches - } = await import('./commands/clear/caches.js'); - clearSessionCaches(); - const result = await loadConversationForResume(undefined /* sessionId */, undefined /* sourceFile */); - if (!result) { - logEvent('tengu_continue', { - success: false - }); - return await exitWithError(root, 'No conversation found to continue'); + // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) + if ( + feature('AGENT_MEMORY_SNAPSHOT') && + mainThreadAgentDefinition && + isCustomAgent(mainThreadAgentDefinition) && + mainThreadAgentDefinition.memory && + mainThreadAgentDefinition.pendingSnapshotUpdate + ) { + const agentDef = mainThreadAgentDefinition + const choice = await launchSnapshotUpdateDialog(root, { + agentType: agentDef.agentType, + scope: agentDef.memory!, + snapshotTimestamp: + agentDef.pendingSnapshotUpdate!.snapshotTimestamp, + }) + if (choice === 'merge') { + const { buildMergePrompt } = await import( + './components/agents/SnapshotUpdateDialog.js' + ) + const mergePrompt = buildMergePrompt( + agentDef.agentType, + agentDef.memory!, + ) + inputPrompt = inputPrompt + ? `${mergePrompt}\n\n${inputPrompt}` + : mergePrompt + } + agentDef.pendingSnapshotUpdate = undefined } - const loaded = await processResumedConversation(result, { - forkSession: !!options.forkSession, - includeAttribution: true, - transcriptPath: result.fullPath - }, resumeContext); - if (loaded.restoredAgentDef) { - mainThreadAgentDefinition = loaded.restoredAgentDef; + + // Skip executing /login if we just completed onboarding for it + if (onboardingShown && prompt?.trim().toLowerCase() === '/login') { + prompt = '' } - maybeActivateProactive(options); - maybeActivateBrief(options); - logEvent('tengu_continue', { - success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); - resumeSucceeded = true; - await launchRepl(root, { - getFpsMetrics, - stats, - initialState: loaded.initialState - }, { - ...sessionConfig, - mainThreadAgentDefinition: loaded.restoredAgentDef ?? mainThreadAgentDefinition, - initialMessages: loaded.messages, - initialFileHistorySnapshots: loaded.fileHistorySnapshots, - initialContentReplacements: loaded.contentReplacements, - initialAgentName: loaded.agentName, - initialAgentColor: loaded.agentColor - }, renderAndRun); - } catch (error) { - if (!resumeSucceeded) { - logEvent('tengu_continue', { - success: false - }); + + if (onboardingShown) { + // Refresh auth-dependent services now that the user has logged in during onboarding. + // Keep in sync with the post-login logic in src/commands/login.tsx + void refreshRemoteManagedSettings() + void refreshPolicyLimits() + // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials + resetUserCache() + // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) + refreshGrowthBookAfterAuthChange() + // Clear any stale trusted device token then enroll for Remote Control. + // Both self-gate on tengu_sessions_elevated_auth_enforcement internally + // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits + // the GrowthBook reinit above), clearTrustedDeviceToken() via the + // sync cached check (acceptable since clear is idempotent). + void import('./bridge/trustedDevice.js').then(m => { + m.clearTrustedDeviceToken() + return m.enrollTrustedDevice() + }) + } + + // Validate that the active token's org matches forceLoginOrgUUID (if set + // in managed settings). Runs after onboarding so managed settings and + // login state are fully loaded. + const orgValidation = await validateForceLoginOrg() + if (!orgValidation.valid) { + await exitWithError(root, orgValidation.message) } - logError(error); - process.exit(1); } - } else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) { - // `claude connect ` — full interactive TUI connected to a remote server - let directConnectConfig; - try { - const session = await createDirectConnectSession({ - serverUrl: _pendingConnect.url, - authToken: _pendingConnect.authToken, - cwd: getOriginalCwd(), - dangerouslySkipPermissions: _pendingConnect.dangerouslySkipPermissions - }); - if (session.workDir) { - setOriginalCwd(session.workDir); - setCwdState(session.workDir); + + // If gracefulShutdown was initiated (e.g., user rejected trust dialog), + // process.exitCode will be set. Skip all subsequent operations that could + // trigger code execution before the process exits (e.g. we don't want apiKeyHelper + // to run if trust was not established). + if (process.exitCode !== undefined) { + logForDebugging( + 'Graceful shutdown initiated, skipping further initialization', + ) + return + } + + // Initialize LSP manager AFTER trust is established (or in non-interactive mode + // where trust is implicit). This prevents plugin LSP servers from executing + // code in untrusted directories before user consent. + // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. + initializeLspServerManager() + + // Show settings validation errors after trust is established + // MCP config errors don't block settings from loading, so exclude them + if (!isNonInteractiveSession) { + const { errors } = getSettingsWithErrors() + const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata) + if (nonMcpErrors.length > 0) { + await launchInvalidSettingsDialog(root, { + settingsErrors: nonMcpErrors, + onExit: () => gracefulShutdownSync(1), + }) } - setDirectConnectServerUrl(_pendingConnect.url); - directConnectConfig = session.config; - } catch (err) { - return await exitWithError(root, err instanceof DirectConnectError ? err.message : String(err), () => gracefulShutdown(1)); } - const connectInfoMessage = createSystemMessage(`Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, 'info'); - await launchRepl(root, { - getFpsMetrics, - stats, - initialState - }, { - debug: debug || debugToStderr, - commands, - initialTools: [], - initialMessages: [connectInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - directConnectConfig, - thinkingConfig - }, renderAndRun); - return; - } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { - // `claude ssh [dir]` — probe remote, deploy binary if needed, - // spawn ssh with unix-socket -R forward to a local auth proxy, hand - // the REPL an SSHSession. Tools run remotely, UI renders locally. - // `--local` skips probe/deploy/ssh and spawns the current binary - // directly with the same env — e2e test of the proxy/auth plumbing. - const { - createSSHSession, - createLocalSSHSession, - SSHSessionError - } = await import('./ssh/createSSHSession.js'); - let sshSession; - try { - if (_pendingSSH.local) { - process.stderr.write('Starting local ssh-proxy test session...\n'); - sshSession = createLocalSSHSession({ - cwd: _pendingSSH.cwd, - permissionMode: _pendingSSH.permissionMode, - dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions - }); + + // Check quota status, fast mode, passes eligibility, and bootstrap data + // after trust is established. These make API calls which could trigger + // apiKeyHelper execution. + // --bare / SIMPLE: skip — these are cache-warms for the REPL's + // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast + // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). + const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_cicada_nap_ms', + 0, + ) + const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0 + const skipStartupPrefetches = + isBareMode() || + (bgRefreshThrottleMs > 0 && + Date.now() - lastPrefetched < bgRefreshThrottleMs) + + if (!skipStartupPrefetches) { + const lastPrefetchedInfo = + lastPrefetched > 0 + ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` + : '' + logForDebugging( + `Starting background startup prefetches${lastPrefetchedInfo}`, + ) + + checkQuotaStatus().catch(error => logError(error)) + + // Fetch bootstrap data from the server and update all cache values. + void fetchBootstrapData() + + // TODO: Consolidate other prefetches into a single bootstrap request. + void prefetchPassesEligibility() + if ( + !getFeatureValue_CACHED_MAY_BE_STALE('tengu_miraculo_the_bard', false) + ) { + void prefetchFastModeStatus() } else { - process.stderr.write(`Connecting to ${_pendingSSH.host}…\n`); - // In-place progress: \r + EL0 (erase to end of line). Final \n on - // success so the next message lands on a fresh line. No-op when - // stderr isn't a TTY (piped/redirected) — \r would just emit noise. - const isTTY = process.stderr.isTTY; - let hadProgress = false; - sshSession = await createSSHSession({ - host: _pendingSSH.host, - cwd: _pendingSSH.cwd, - localVersion: MACRO.VERSION, - permissionMode: _pendingSSH.permissionMode, - dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, - extraCliArgs: _pendingSSH.extraCliArgs - }, isTTY ? { - onProgress: msg => { - hadProgress = true; - process.stderr.write(`\r ${msg}\x1b[K`); + // Kill switch skips the network call, not org-policy enforcement. + // Resolve from cache so orgStatus doesn't stay 'pending' (which + // getFastModeUnavailableReason treats as permissive). + resolveFastModeStatusFromCache() + } + if (bgRefreshThrottleMs > 0) { + saveGlobalConfig(current => ({ + ...current, + startupPrefetchedAt: Date.now(), + })) + } + } else { + logForDebugging( + `Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`, + ) + // Resolve fast mode org status from cache (no network) + resolveFastModeStatusFromCache() + } + + if (!isNonInteractiveSession) { + void refreshExampleCommands() // Pre-fetch example commands (runs git log, no API call) + } + + // Resolve MCP configs (started early, overlaps with setup/trust dialog work) + const { servers: existingMcpConfigs } = await mcpConfigPromise + logForDebugging( + `[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`, + ) + // CLI flag (--mcp-config) should override file-based configs, matching settings precedence + const allMcpConfigs = { ...existingMcpConfigs, ...dynamicMcpConfig } + + // Separate SDK configs from regular MCP configs + const sdkMcpConfigs: Record = {} + const regularMcpConfigs: Record = {} + + for (const [name, config] of Object.entries(allMcpConfigs)) { + const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig + if (typedConfig.type === 'sdk') { + sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig + } else { + regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig + } + } + + profileCheckpoint('action_mcp_configs_loaded') + + // Prefetch MCP resources after trust dialog (this is where execution happens). + // Interactive mode only: print mode defers connects until headlessStore exists + // and pushes per-server (below), so ToolSearch's pending-client handling works + // and one slow server doesn't block the batch. + const localMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : prefetchAllMcpResources(regularMcpConfigs) + const claudeaiMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : claudeaiConfigPromise.then(configs => + Object.keys(configs).length > 0 + ? prefetchAllMcpResources(configs) + : { clients: [], tools: [], commands: [] }, + ) + // Merge with dedup by name: each prefetchAllMcpResources call independently + // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via + // local dedup flags, so merging two calls can yield duplicates. print.ts + // already uniqBy's the final tool pool, but dedup here keeps appState clean. + const mcpPromise = Promise.all([ + localMcpPromise, + claudeaiMcpPromise, + ]).then(([local, claudeai]) => ({ + clients: [...local.clients, ...claudeai.clients], + tools: uniqBy([...local.tools, ...claudeai.tools], 'name'), + commands: uniqBy([...local.commands, ...claudeai.commands], 'name'), + })) + + // Start hooks early so they run in parallel with MCP connections. + // Skip for initOnly/init/maintenance (handled separately), non-interactive + // (handled via setupTrigger), and resume/continue (conversationRecovery.ts + // fires 'resume' instead — without this guard, hooks fire TWICE on /resume + // and the second systemMessage clobbers the first. gh-30825) + const hooksPromise = + initOnly || + init || + maintenance || + isNonInteractiveSession || + options.continue || + options.resume + ? null + : processSessionStartHooks('startup', { + agentType: mainThreadAgentDefinition?.agentType, + model: resolvedInitialModel, + }) + + // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections + // populates appState.mcp async as servers connect (connectToServer is + // memoized — the prefetch calls above and the hook converge on the same + // connections). getToolUseContext reads store.getState() fresh via + // computeTools(), so turn 1 sees whatever's connected by query time. + // Slow servers populate for turn 2+. Matches interactive-no-prompt + // behavior. Print mode: per-server push into headlessStore (below). + const hookMessages: Awaited> = [] + // Suppress transient unhandledRejection — the prefetch warms the + // memoized connectToServer cache but nobody awaits it in interactive. + mcpPromise.catch(() => {}) + + const mcpClients: Awaited['clients'] = [] + const mcpTools: Awaited['tools'] = [] + const mcpCommands: Awaited['commands'] = [] + + let thinkingEnabled = shouldEnableThinkingByDefault() + let thinkingConfig: ThinkingConfig = + thinkingEnabled !== false ? { type: 'adaptive' } : { type: 'disabled' } + + if (options.thinking === 'adaptive' || options.thinking === 'enabled') { + thinkingEnabled = true + thinkingConfig = { type: 'adaptive' } + } else if (options.thinking === 'disabled') { + thinkingEnabled = false + thinkingConfig = { type: 'disabled' } + } else { + const maxThinkingTokens = process.env.MAX_THINKING_TOKENS + ? parseInt(process.env.MAX_THINKING_TOKENS, 10) + : options.maxThinkingTokens + if (maxThinkingTokens !== undefined) { + if (maxThinkingTokens > 0) { + thinkingEnabled = true + thinkingConfig = { + type: 'enabled', + budgetTokens: maxThinkingTokens, } - } : {}); - if (hadProgress) process.stderr.write('\n'); + } else if (maxThinkingTokens === 0) { + thinkingEnabled = false + thinkingConfig = { type: 'disabled' } + } } - setOriginalCwd(sshSession.remoteCwd); - setCwdState(sshSession.remoteCwd); - setDirectConnectServerUrl(_pendingSSH.local ? 'local' : _pendingSSH.host); - } catch (err) { - return await exitWithError(root, err instanceof SSHSessionError ? err.message : String(err), () => gracefulShutdown(1)); } - const sshInfoMessage = createSystemMessage(_pendingSSH.local ? `Local ssh-proxy test session\ncwd: ${sshSession.remoteCwd}\nAuth: unix socket → local proxy` : `SSH session to ${_pendingSSH.host}\nRemote cwd: ${sshSession.remoteCwd}\nAuth: unix socket -R → local proxy`, 'info'); - await launchRepl(root, { - getFpsMetrics, - stats, - initialState - }, { - debug: debug || debugToStderr, - commands, - initialTools: [], - initialMessages: [sshInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - sshSession, - thinkingConfig - }, renderAndRun); - return; - } else if (feature('KAIROS') && _pendingAssistantChat && (_pendingAssistantChat.sessionId || _pendingAssistantChat.discover)) { - // `claude assistant [sessionId]` — REPL as a pure viewer client - // of a remote assistant session. The agentic loop runs remotely; this - // process streams live events and POSTs messages. History is lazy- - // loaded by useAssistantHistory on scroll-up (no blocking fetch here). - const { - discoverAssistantSessions - } = await import('./assistant/sessionDiscovery.js'); - let targetSessionId = _pendingAssistantChat.sessionId; - // Discovery flow — list bridge environments, filter sessions - if (!targetSessionId) { - let sessions; - try { - sessions = await discoverAssistantSessions(); - } catch (e) { - return await exitWithError(root, `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, () => gracefulShutdown(1)); + logForDiagnosticsNoPII('info', 'started', { + version: MACRO.VERSION, + is_native_binary: isInBundledMode(), + }) + + registerCleanup(async () => { + logForDiagnosticsNoPII('info', 'exited') + }) + + void logTenguInit({ + hasInitialPrompt: Boolean(prompt), + hasStdin: Boolean(inputPrompt), + verbose, + debug, + debugToStderr, + print: print ?? false, + outputFormat: outputFormat ?? 'text', + inputFormat: inputFormat ?? 'text', + numAllowedTools: allowedTools.length, + numDisallowedTools: disallowedTools.length, + mcpClientCount: Object.keys(allMcpConfigs).length, + worktreeEnabled, + skipWebFetchPreflight: getInitialSettings().skipWebFetchPreflight, + githubActionInputs: process.env.GITHUB_ACTION_INPUTS, + dangerouslySkipPermissionsPassed: dangerouslySkipPermissions ?? false, + permissionMode, + modeIsBypass: permissionMode === 'bypassPermissions', + allowDangerouslySkipPermissionsPassed: allowDangerouslySkipPermissions, + systemPromptFlag: systemPrompt + ? options.systemPromptFile + ? 'file' + : 'flag' + : undefined, + appendSystemPromptFlag: appendSystemPrompt + ? options.appendSystemPromptFile + ? 'file' + : 'flag' + : undefined, + thinkingConfig, + assistantActivationPath: + feature('KAIROS') && kairosEnabled + ? assistantModule?.getAssistantActivationPath() + : undefined, + }) + + // Log context metrics once at initialization + void logContextMetrics(regularMcpConfigs, toolPermissionContext) + + void logPermissionContextForAnts(null, 'initialization') + + logManagedSettings() + + // Register PID file for concurrent-session detection (~/.claude/sessions/) + // and fire multi-clauding telemetry. Lives here (not init.ts) so only the + // REPL path registers — not subcommands like `claude doctor`. Chained: + // count must run after register's write completes or it misses our own file. + void registerSession().then(registered => { + if (!registered) return + if (sessionNameArg) { + void updateSessionName(sessionNameArg) } - if (sessions.length === 0) { - let installedDir: string | null; - try { - installedDir = await launchAssistantInstallWizard(root); - } catch (e) { - return await exitWithError(root, `Assistant installation failed: ${e instanceof Error ? e.message : e}`, () => gracefulShutdown(1)); + void countConcurrentSessions().then(count => { + if (count >= 2) { + logEvent('tengu_concurrent_sessions', { num_sessions: count }) } - if (installedDir === null) { - await gracefulShutdown(0); - process.exit(0); + }) + }) + + // Initialize versioned plugins system (triggers V1→V2 migration if + // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. + // Sequencing matters: the warmup scans disk for .orphaned_at markers, + // so it must see the GC's Pass 1 (remove markers from reinstalled + // versions) and Pass 2 (stamp unmarked orphans) already applied. The + // warm also lands before autoupdate (fires on first submit in REPL) + // can orphan this session's active version underneath us. + // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These + // are install/upgrade bookkeeping that scripted calls don't need — + // the next interactive session will reconcile. The await here was + // blocking -p on a marketplace round-trip. + if (isBareMode()) { + // skip — no-op + } else if (isNonInteractiveSession) { + // In headless mode, await to ensure plugin sync completes before CLI exits + await initializeVersionedPlugins() + profileCheckpoint('action_after_plugins_init') + void cleanupOrphanedPluginVersionsInBackground().then(() => + getGlobExclusionsForPluginCache(), + ) + } else { + // In interactive mode, fire-and-forget — this is purely bookkeeping + // that doesn't affect runtime behavior of the current session + void initializeVersionedPlugins().then(async () => { + profileCheckpoint('action_after_plugins_init') + await cleanupOrphanedPluginVersionsInBackground() + void getGlobExclusionsForPluginCache() + }) + } + + const setupTrigger = + initOnly || init ? 'init' : maintenance ? 'maintenance' : null + if (initOnly) { + applyConfigEnvironmentVariables() + await processSetupHooks('init', { forceSyncExecution: true }) + await processSessionStartHooks('startup', { forceSyncExecution: true }) + gracefulShutdownSync(0) + return + } + + // --print mode + if (isNonInteractiveSession) { + if (outputFormat === 'stream-json' || outputFormat === 'json') { + setHasFormattedOutput(true) + } + + // Apply full environment variables in print mode since trust dialog is bypassed + // This includes potentially dangerous environment variables from untrusted sources + // but print mode is considered trusted (as documented in help text) + applyConfigEnvironmentVariables() + + // Initialize telemetry after env vars are applied so OTEL endpoint env vars and + // otelHeadersHelper (which requires trust to execute) are available. + initializeTelemetryAfterTrust() + + // Kick SessionStart hooks now so the subprocess spawn overlaps with + // MCP connect + plugin init + print.ts import below. loadInitialMessages + // joins this at print.ts:4397. Guarded same as loadInitialMessages — + // continue/resume/teleport paths don't fire startup hooks (or fire them + // conditionally inside the resume branch, where this promise is + // undefined and the ?? fallback runs). Also skip when setupTrigger is + // set — those paths run setup hooks first (print.ts:544), and session + // start hooks must wait until setup completes. + const sessionStartHooksPromise = + options.continue || options.resume || teleport || setupTrigger + ? undefined + : processSessionStartHooks('startup') + // Suppress transient unhandledRejection if this rejects before + // loadInitialMessages awaits it. Downstream await still observes the + // rejection — this just prevents the spurious global handler fire. + sessionStartHooksPromise?.catch(() => {}) + + profileCheckpoint('before_validateForceLoginOrg') + // Validate org restriction for non-interactive sessions + const orgValidation = await validateForceLoginOrg() + if (!orgValidation.valid) { + process.stderr.write(orgValidation.message + '\n') + process.exit(1) + } + + // Headless mode supports all prompt commands and some local commands + // If disableSlashCommands is true, return empty array + const commandsHeadless = disableSlashCommands + ? [] + : commands.filter( + command => + (command.type === 'prompt' && !command.disableNonInteractive) || + (command.type === 'local' && command.supportsNonInteractive), + ) + + const defaultState = getDefaultAppState() + const headlessInitialState: AppState = { + ...defaultState, + mcp: { + ...defaultState.mcp, + clients: mcpClients, + commands: mcpCommands, + tools: mcpTools, + }, + toolPermissionContext, + effortValue: + parseEffortValue(options.effort) ?? getInitialEffortSetting(), + ...(isFastModeEnabled() && { + fastMode: getInitialFastModeSetting(effectiveModel ?? null), + }), + ...(isAdvisorEnabled() && advisorModel && { advisorModel }), + // kairosEnabled gates the async fire-and-forget path in + // executeForkedSlashCommand (processSlashCommand.tsx:132) and + // AgentTool's shouldRunAsync. The REPL initialState sets this at + // ~3459; headless was defaulting to false, so the daemon child's + // scheduled tasks and Agent-tool calls ran synchronously — N + // overdue cron tasks on spawn = N serial subagent turns blocking + // user input. Computed at :1620, well before this branch. + ...(feature('KAIROS') ? { kairosEnabled } : {}), + } + + // Init app state + const headlessStore = createStore( + headlessInitialState, + onChangeAppState, + ) + + // Check if bypassPermissions should be disabled based on Statsig gate + // This runs in parallel to the code below, to avoid blocking the main loop. + if ( + toolPermissionContext.mode === 'bypassPermissions' || + allowDangerouslySkipPermissions + ) { + void checkAndDisableBypassPermissions(toolPermissionContext) + } + + // Async check of auto mode gate — corrects state and disables auto if needed. + // Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too. + if (feature('TRANSCRIPT_CLASSIFIER')) { + void verifyAutoModeGateAccess( + toolPermissionContext, + headlessStore.getState().fastMode, + ).then(({ updateContext }) => { + headlessStore.setState(prev => { + const nextCtx = updateContext(prev.toolPermissionContext) + if (nextCtx === prev.toolPermissionContext) return prev + return { ...prev, toolPermissionContext: nextCtx } + }) + }) + } + + // Set global state for session persistence + if (options.sessionPersistence === false) { + setSessionPersistenceDisabled(true) + } + + // Store SDK betas in global state for context window calculation + // Only store allowed betas (filters by allowlist and subscriber status) + setSdkBetas(filterAllowedSdkBetas(betas)) + + // Print-mode MCP: per-server incremental push into headlessStore. + // Mirrors useManageMCPConnections — push pending first (so ToolSearch's + // pending-check at ToolSearchTool.ts:334 sees them), then replace with + // connected/failed as each server settles. + const connectMcpBatch = ( + configs: Record, + label: string, + ): Promise => { + if (Object.keys(configs).length === 0) return Promise.resolve() + headlessStore.setState(prev => ({ + ...prev, + mcp: { + ...prev.mcp, + clients: [ + ...prev.mcp.clients, + ...Object.entries(configs).map(([name, config]) => ({ + name, + type: 'pending' as const, + config, + })), + ], + }, + })) + return getMcpToolsCommandsAndResources( + ({ client, tools, commands }) => { + headlessStore.setState(prev => ({ + ...prev, + mcp: { + ...prev.mcp, + clients: prev.mcp.clients.some(c => c.name === client.name) + ? prev.mcp.clients.map(c => + c.name === client.name ? client : c, + ) + : [...prev.mcp.clients, client], + tools: uniqBy([...prev.mcp.tools, ...tools], 'name'), + commands: uniqBy([...prev.mcp.commands, ...commands], 'name'), + }, + })) + }, + configs, + ).catch(err => + logForDebugging(`[MCP] ${label} connect error: ${err}`), + ) + } + // Await all MCP configs — print mode is often single-turn, so + // "late-connecting servers visible next turn" doesn't help. SDK init + // message and turn-1 tool list both need configured MCP tools present. + // Zero-server case is free via the early return in connectMcpBatch. + // Connectors parallelize inside getMcpToolsCommandsAndResources + // (processBatched with Promise.all). claude.ai is awaited too — its + // fetch was kicked off early (line ~2558) so only residual time blocks + // here. --bare skips claude.ai entirely for perf-sensitive scripts. + profileCheckpoint('before_connectMcp') + await connectMcpBatch(regularMcpConfigs, 'regular') + profileCheckpoint('after_connectMcp') + // Dedup: suppress plugin MCP servers that duplicate a claude.ai + // connector (connector wins), then connect claude.ai servers. + // Bounded wait — #23725 made this blocking so single-turn -p sees + // connectors, but with 40+ slow connectors tengu_startup_perf p99 + // climbed to 76s. If fetch+connect doesn't finish in time, proceed; + // the promise keeps running and updates headlessStore in the + // background so turn 2+ still sees connectors. + const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000 + const claudeaiConnect = claudeaiConfigPromise.then(claudeaiConfigs => { + if (Object.keys(claudeaiConfigs).length > 0) { + const claudeaiSigs = new Set() + for (const config of Object.values(claudeaiConfigs)) { + const sig = getMcpServerSignature(config) + if (sig) claudeaiSigs.add(sig) + } + const suppressed = new Set() + for (const [name, config] of Object.entries(regularMcpConfigs)) { + if (!name.startsWith('plugin:')) continue + const sig = getMcpServerSignature(config) + if (sig && claudeaiSigs.has(sig)) suppressed.add(name) + } + if (suppressed.size > 0) { + logForDebugging( + `[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(', ')}`, + ) + // Disconnect before filtering from state. Only connected + // servers need cleanup — clearServerCache on a never-connected + // server triggers a real connect just to kill it (memoize + // cache-miss path, see useManageMCPConnections.ts:870). + for (const c of headlessStore.getState().mcp.clients) { + if (!suppressed.has(c.name) || c.type !== 'connected') continue + c.client.onclose = undefined + void clearServerCache(c.name, c.config).catch(() => {}) + } + headlessStore.setState(prev => { + let { clients, tools, commands, resources } = prev.mcp + clients = clients.filter(c => !suppressed.has(c.name)) + tools = tools.filter( + t => !t.mcpInfo || !suppressed.has(t.mcpInfo.serverName), + ) + for (const name of suppressed) { + commands = excludeCommandsByServer(commands, name) + resources = excludeResourcesByServer(resources, name) + } + return { + ...prev, + mcp: { ...prev.mcp, clients, tools, commands, resources }, + } + }) + } } - // The daemon needs a few seconds to spin up its worker and - // establish a bridge session before discovery will find it. - return await exitWithMessage(root, `Assistant installed in ${installedDir}. The daemon is starting up — run \`claude assistant\` again in a few seconds to connect.`, { - exitCode: 0, - beforeExit: () => gracefulShutdown(0) - }); + // Suppress claude.ai connectors that duplicate an enabled + // manual server (URL-signature match). Plugin dedup above only + // handles `plugin:*` keys; this catches manual `.mcp.json` entries. + // plugin:* must be excluded here — step 1 already suppressed + // those (claude.ai wins); leaving them in suppresses the + // connector too, and neither survives (gh-39974). + const nonPluginConfigs = pickBy( + regularMcpConfigs, + (_, n) => !n.startsWith('plugin:'), + ) + const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers( + claudeaiConfigs, + nonPluginConfigs, + ) + return connectMcpBatch(dedupedClaudeAi, 'claudeai') + }) + let claudeaiTimer: ReturnType | undefined + const claudeaiTimedOut = await Promise.race([ + claudeaiConnect.then(() => false), + new Promise(resolve => { + claudeaiTimer = setTimeout( + r => r(true), + CLAUDE_AI_MCP_TIMEOUT_MS, + resolve, + ) + }), + ]) + if (claudeaiTimer) clearTimeout(claudeaiTimer) + if (claudeaiTimedOut) { + logForDebugging( + `[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`, + ) } - if (sessions.length === 1) { - targetSessionId = sessions[0]!.id; - } else { - const picked = await launchAssistantSessionChooser(root, { - sessions - }); - if (!picked) { - await gracefulShutdown(0); - process.exit(0); + profileCheckpoint('after_connectMcp_claudeai') + + // In headless mode, start deferred prefetches immediately (no user typing delay) + // --bare / SIMPLE: startDeferredPrefetches early-returns internally. + // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, + // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping + // that scripted calls don't need — the next interactive session reconciles. + if (!isBareMode()) { + startDeferredPrefetches() + void import('./utils/backgroundHousekeeping.js').then(m => + m.startBackgroundHousekeeping(), + ) + if (process.env.USER_TYPE === 'ant') { + void import('./utils/sdkHeapDumpMonitor.js').then(m => + m.startSdkMemoryMonitor(), + ) } - targetSessionId = picked; } + + logSessionTelemetry() + profileCheckpoint('before_print_import') + const { runHeadless } = await import('src/cli/print.js') + profileCheckpoint('after_print_import') + void runHeadless( + inputPrompt, + () => headlessStore.getState(), + headlessStore.setState, + commandsHeadless, + tools, + sdkMcpConfigs, + agentDefinitions.activeAgents, + { + continue: options.continue, + resume: options.resume, + verbose: verbose, + outputFormat: outputFormat, + jsonSchema, + permissionPromptToolName: options.permissionPromptTool, + allowedTools, + thinkingConfig, + maxTurns: options.maxTurns, + maxBudgetUsd: options.maxBudgetUsd, + taskBudget: options.taskBudget + ? { total: options.taskBudget } + : undefined, + systemPrompt, + appendSystemPrompt, + userSpecifiedModel: effectiveModel, + fallbackModel: userSpecifiedFallbackModel, + teleport, + sdkUrl, + replayUserMessages: effectiveReplayUserMessages, + includePartialMessages: effectiveIncludePartialMessages, + forkSession: options.forkSession || false, + resumeSessionAt: options.resumeSessionAt || undefined, + rewindFiles: options.rewindFiles, + enableAuthStatus: options.enableAuthStatus, + agent: agentCli, + workload: options.workload, + setupTrigger: setupTrigger ?? undefined, + sessionStartHooksPromise, + }, + ) + return } - // Auth — call prepareApiRequest() once for orgUUID, but use a - // getAccessToken closure for the token so reconnects get fresh tokens. - const { - checkAndRefreshOAuthTokenIfNeeded, - getClaudeAIOAuthTokens - } = await import('./utils/auth.js'); - await checkAndRefreshOAuthTokenIfNeeded(); - let apiCreds; - try { - apiCreds = await prepareApiRequest(); - } catch (e) { - return await exitWithError(root, `Error: ${e instanceof Error ? e.message : 'Failed to authenticate'}`, () => gracefulShutdown(1)); + // Log model config at startup + logEvent('tengu_startup_manual_model_config', { + cli_flag: + options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + env_var: process.env + .ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + settings_file: (getInitialSettings() || {}) + .model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + subscriptionType: + getSubscriptionType() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent: + agentSetting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + // Get deprecation warning for the initial model (resolvedInitialModel computed earlier for hooks parallelization) + const deprecationWarning = + getModelDeprecationWarning(resolvedInitialModel) + + // Build initial notification queue + const initialNotifications: Array<{ + key: string + text: string + color?: 'warning' + priority: 'high' + }> = [] + if (permissionModeNotification) { + initialNotifications.push({ + key: 'permission-mode-notification', + text: permissionModeNotification, + priority: 'high', + }) + } + if (deprecationWarning) { + initialNotifications.push({ + key: 'model-deprecation-warning', + text: deprecationWarning, + color: 'warning', + priority: 'high', + }) + } + if (overlyBroadBashPermissions.length > 0) { + const displayList = uniq( + overlyBroadBashPermissions.map(p => p.ruleDisplay), + ) + const displays = displayList.join(', ') + const sources = uniq( + overlyBroadBashPermissions.map(p => p.sourceDisplay), + ).join(', ') + const n = displayList.length + initialNotifications.push({ + key: 'overly-broad-bash-notification', + text: `${displays} allow ${plural(n, 'rule')} from ${sources} ${plural(n, 'was', 'were')} ignored \u2014 not available for Ants, please use auto-mode instead`, + color: 'warning', + priority: 'high', + }) + } + + const effectiveToolPermissionContext = { + ...toolPermissionContext, + mode: + isAgentSwarmsEnabled() && getTeammateUtils().isPlanModeRequired() + ? ('plan' as const) + : toolPermissionContext.mode, + } + // All startup opt-in paths (--tools, --brief, defaultView) have fired + // above; initialIsBriefOnly just reads the resulting state. + const initialIsBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') ? getUserMsgOptIn() : false + const fullRemoteControl = + remoteControl || getRemoteControlAtStartup() || kairosEnabled + let ccrMirrorEnabled = false + if (feature('CCR_MIRROR') && !fullRemoteControl) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isCcrMirrorEnabled } = + require('./bridge/bridgeEnabled.js') as typeof import('./bridge/bridgeEnabled.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + ccrMirrorEnabled = isCcrMirrorEnabled() + } + + const initialState: AppState = { + settings: getInitialSettings(), + tasks: {}, + agentNameRegistry: new Map(), + verbose: verbose ?? getGlobalConfig().verbose ?? false, + mainLoopModel: initialMainLoopModel, + mainLoopModelForSession: null, + isBriefOnly: initialIsBriefOnly, + expandedView: getGlobalConfig().showSpinnerTree + ? 'teammates' + : getGlobalConfig().showExpandedTodos + ? 'tasks' + : 'none', + showTeammateMessagePreview: isAgentSwarmsEnabled() ? false : undefined, + selectedIPAgentIndex: -1, + coordinatorTaskIndex: -1, + viewSelectionMode: 'none', + footerSelection: null, + toolPermissionContext: effectiveToolPermissionContext, + agent: mainThreadAgentDefinition?.agentType, + agentDefinitions, + mcp: { + clients: [], + tools: [], + commands: [], + resources: {}, + pluginReconnectKey: 0, + }, + plugins: { + enabled: [], + disabled: [], + commands: [], + errors: [], + installationStatus: { + marketplaces: [], + plugins: [], + }, + needsRefresh: false, + }, + statusLineText: undefined, + kairosEnabled, + remoteSessionUrl: undefined, + remoteConnectionStatus: 'connecting', + remoteBackgroundTaskCount: 0, + replBridgeEnabled: fullRemoteControl || ccrMirrorEnabled, + replBridgeExplicit: remoteControl, + replBridgeOutboundOnly: ccrMirrorEnabled, + replBridgeConnected: false, + replBridgeSessionActive: false, + replBridgeReconnecting: false, + replBridgeConnectUrl: undefined, + replBridgeSessionUrl: undefined, + replBridgeEnvironmentId: undefined, + replBridgeSessionId: undefined, + replBridgeError: undefined, + replBridgeInitialName: remoteControlName, + showRemoteCallout: false, + notifications: { + current: null, + queue: initialNotifications, + }, + elicitation: { + queue: [], + }, + todos: {}, + remoteAgentTaskSuggestions: [], + fileHistory: { + snapshots: [], + trackedFiles: new Set(), + snapshotSequence: 0, + }, + attribution: createEmptyAttributionState(), + thinkingEnabled, + promptSuggestionEnabled: shouldEnablePromptSuggestion(), + sessionHooks: new Map(), + inbox: { + messages: [], + }, + promptSuggestion: { + text: null, + promptId: null, + shownAt: 0, + acceptedAt: 0, + generationRequestId: null, + }, + speculation: IDLE_SPECULATION_STATE, + speculationSessionTimeSavedMs: 0, + skillImprovement: { + suggestion: null, + }, + workerSandboxPermissions: { + queue: [], + selectedIndex: 0, + }, + pendingWorkerRequest: null, + pendingSandboxRequest: null, + authVersion: 0, + initialMessage: inputPrompt + ? { message: createUserMessage({ content: String(inputPrompt) }) } + : null, + effortValue: + parseEffortValue(options.effort) ?? getInitialEffortSetting(), + activeOverlays: new Set(), + fastMode: getInitialFastModeSetting(resolvedInitialModel), + ...(isAdvisorEnabled() && advisorModel && { advisorModel }), + // Compute teamContext synchronously to avoid useEffect setState during render. + // KAIROS: assistantTeamContext takes precedence — set earlier in the + // KAIROS block so Agent(name: "foo") can spawn in-process teammates + // without TeamCreate. computeInitialTeamContext() is for tmux-spawned + // teammates reading their own identity, not the assistant-mode leader. + teamContext: feature('KAIROS') + ? (assistantTeamContext ?? computeInitialTeamContext?.()) + : computeInitialTeamContext?.(), + } + + // Add CLI initial prompt to history + if (inputPrompt) { + addToHistory(String(inputPrompt)) } - const getAccessToken = (): string => getClaudeAIOAuthTokens()?.accessToken ?? apiCreds.accessToken; - - // Brief mode activation: setKairosActive(true) satisfies BOTH opt-in - // and entitlement for isBriefEnabled() (BriefTool.ts:124-132). - setKairosActive(true); - setUserMsgOptIn(true); - setIsRemoteMode(true); - const remoteSessionConfig = createRemoteSessionConfig(targetSessionId, getAccessToken, apiCreds.orgUUID, /* hasInitialPrompt */false, /* viewerOnly */true); - const infoMessage = createSystemMessage(`Attached to assistant session ${targetSessionId.slice(0, 8)}…`, 'info'); - const assistantInitialState: AppState = { - ...initialState, - isBriefOnly: true, - kairosEnabled: false, - replBridgeEnabled: false - }; - const remoteCommands = filterCommandsForRemoteMode(commands); - await launchRepl(root, { - getFpsMetrics, - stats, - initialState: assistantInitialState - }, { + + const initialTools = mcpTools + + // Increment numStartups synchronously — first-render readers like + // shouldShowEffortCallout (via useState initializer) need the updated + // value before setImmediate fires. Defer only telemetry. + saveGlobalConfig(current => ({ + ...current, + numStartups: (current.numStartups ?? 0) + 1, + })) + setImmediate(() => { + void logStartupTelemetry() + logSessionTelemetry() + }) + + // Set up per-turn session environment data uploader (ant-only build). + // Default-enabled for all ant users when working in an Anthropic-owned + // repo. Captures git/filesystem state (NOT transcripts) at each turn so + // environments can be recreated at any user message index. Gating: + // - Build-time: this import is stubbed in external builds. + // - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth. + // - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this). + // Import is dynamic + async to avoid adding startup latency. + const sessionUploaderPromise = + process.env.USER_TYPE === 'ant' + ? import('./utils/sessionDataUploader.js') + : null + + // Defer session uploader resolution to the onTurnComplete callback to avoid + // adding a new top-level await in main.tsx (performance-critical path). + // The per-turn auth logic in sessionDataUploader.ts handles unauthenticated + // state gracefully (re-checks each turn, so auth recovery mid-session works). + const uploaderReady = sessionUploaderPromise + ? sessionUploaderPromise + .then(mod => mod.createSessionTurnUploader()) + .catch(() => null) + : null + + const sessionConfig = { debug: debug || debugToStderr, - commands: remoteCommands, - initialTools: [], - initialMessages: [infoMessage], - mcpClients: [], + commands: [...commands, ...mcpCommands], + initialTools, + mcpClients, autoConnectIdeFlag: ide, mainThreadAgentDefinition, disableSlashCommands, - remoteSessionConfig, - thinkingConfig - }, renderAndRun); - return; - } else if (options.resume || options.fromPr || teleport || remote !== null) { - // Handle resume flow - from file (ant-only), session ID, or interactive selector - - // Clear stale caches before resuming to ensure fresh file/skill discovery - const { - clearSessionCaches - } = await import('./commands/clear/caches.js'); - clearSessionCaches(); - let messages: MessageType[] | null = null; - let processedResume: ProcessedResume | undefined = undefined; - let maybeSessionId = validateUuid(options.resume); - let searchTerm: string | undefined = undefined; - // Store full LogOption when found by custom title (for cross-worktree resume) - let matchedLog: LogOption | null = null; - // PR filter for --from-pr flag - let filterByPr: boolean | number | string | undefined = undefined; - - // Handle --from-pr flag - if (options.fromPr) { - if (options.fromPr === true) { - // Show all sessions with linked PRs - filterByPr = true; - } else if (typeof options.fromPr === 'string') { - // Could be a PR number or URL - filterByPr = options.fromPr; - } + dynamicMcpConfig, + strictMcpConfig, + systemPrompt, + appendSystemPrompt, + taskListId, + thinkingConfig, + ...(uploaderReady && { + onTurnComplete: (messages: MessageType[]) => { + void uploaderReady.then(uploader => uploader?.(messages)) + }, + }), } - // If resume value is not a UUID, try exact match by custom title first - if (options.resume && typeof options.resume === 'string' && !maybeSessionId) { - const trimmedValue = options.resume.trim(); - if (trimmedValue) { - const matches = await searchSessionsByCustomTitle(trimmedValue, { - exact: true - }); - if (matches.length === 1) { - // Exact match found - store full LogOption for cross-worktree resume - matchedLog = matches[0]!; - maybeSessionId = getSessionIdFromLog(matchedLog) ?? null; - } else { - // No match or multiple matches - use as search term for picker - searchTerm = trimmedValue; + // Shared context for processResumedConversation calls + const resumeContext = { + modeApi: coordinatorModeModule, + mainThreadAgentDefinition, + agentDefinitions, + currentCwd, + cliAgents, + initialState, + } + + if (options.continue) { + // Continue the most recent conversation directly + let resumeSucceeded = false + try { + const resumeStart = performance.now() + + // Clear stale caches before resuming to ensure fresh file/skill discovery + const { clearSessionCaches } = await import( + './commands/clear/caches.js' + ) + clearSessionCaches() + + const result = await loadConversationForResume( + undefined /* sessionId */, + undefined /* sourceFile */, + ) + if (!result) { + logEvent('tengu_continue', { + success: false, + }) + return await exitWithError( + root, + 'No conversation found to continue', + ) } + + const loaded = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + includeAttribution: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ) + + if (loaded.restoredAgentDef) { + mainThreadAgentDefinition = loaded.restoredAgentDef + } + + maybeActivateProactive(options) + maybeActivateBrief(options) + + logEvent('tengu_continue', { + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }) + resumeSucceeded = true + + await launchRepl( + root, + { getFpsMetrics, stats, initialState: loaded.initialState }, + { + ...sessionConfig, + mainThreadAgentDefinition: + loaded.restoredAgentDef ?? mainThreadAgentDefinition, + initialMessages: loaded.messages, + initialFileHistorySnapshots: loaded.fileHistorySnapshots, + initialContentReplacements: loaded.contentReplacements, + initialAgentName: loaded.agentName, + initialAgentColor: loaded.agentColor, + }, + renderAndRun, + ) + } catch (error) { + if (!resumeSucceeded) { + logEvent('tengu_continue', { + success: false, + }) + } + logError(error) + process.exit(1) + } + } else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) { + // `claude connect ` — full interactive TUI connected to a remote server + let directConnectConfig + try { + const session = await createDirectConnectSession({ + serverUrl: _pendingConnect.url, + authToken: _pendingConnect.authToken, + cwd: getOriginalCwd(), + dangerouslySkipPermissions: + _pendingConnect.dangerouslySkipPermissions, + }) + if (session.workDir) { + setOriginalCwd(session.workDir) + setCwdState(session.workDir) + } + setDirectConnectServerUrl(_pendingConnect.url) + directConnectConfig = session.config + } catch (err) { + return await exitWithError( + root, + err instanceof DirectConnectError ? err.message : String(err), + () => gracefulShutdown(1), + ) } - } - // --remote and --teleport both create/resume Claude Code Web (CCR) sessions. - // Remote Control (--rc) is a separate feature gated in initReplBridge.ts. - if (remote !== null || teleport) { - await waitForPolicyLimitsToLoad(); - if (!isPolicyAllowed('allow_remote_sessions')) { - return await exitWithError(root, "Error: Remote sessions are disabled by your organization's policy.", () => gracefulShutdown(1)); + const connectInfoMessage = createSystemMessage( + `Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, + 'info', + ) + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + debug: debug || debugToStderr, + commands, + initialTools: [], + initialMessages: [connectInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + directConnectConfig, + thinkingConfig, + }, + renderAndRun, + ) + return + } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { + // `claude ssh [dir]` — probe remote, deploy binary if needed, + // spawn ssh with unix-socket -R forward to a local auth proxy, hand + // the REPL an SSHSession. Tools run remotely, UI renders locally. + // `--local` skips probe/deploy/ssh and spawns the current binary + // directly with the same env — e2e test of the proxy/auth plumbing. + const { createSSHSession, createLocalSSHSession, SSHSessionError } = + await import('./ssh/createSSHSession.js') + let sshSession + try { + if (_pendingSSH.local) { + process.stderr.write('Starting local ssh-proxy test session...\n') + sshSession = createLocalSSHSession({ + cwd: _pendingSSH.cwd, + permissionMode: _pendingSSH.permissionMode, + dangerouslySkipPermissions: + _pendingSSH.dangerouslySkipPermissions, + }) + } else { + process.stderr.write(`Connecting to ${_pendingSSH.host}…\n`) + // In-place progress: \r + EL0 (erase to end of line). Final \n on + // success so the next message lands on a fresh line. No-op when + // stderr isn't a TTY (piped/redirected) — \r would just emit noise. + const isTTY = process.stderr.isTTY + let hadProgress = false + sshSession = await createSSHSession( + { + host: _pendingSSH.host, + cwd: _pendingSSH.cwd, + localVersion: MACRO.VERSION, + permissionMode: _pendingSSH.permissionMode, + dangerouslySkipPermissions: + _pendingSSH.dangerouslySkipPermissions, + extraCliArgs: _pendingSSH.extraCliArgs, + }, + isTTY + ? { + onProgress: msg => { + hadProgress = true + process.stderr.write(`\r ${msg}\x1b[K`) + }, + } + : {}, + ) + if (hadProgress) process.stderr.write('\n') + } + setOriginalCwd(sshSession.remoteCwd) + setCwdState(sshSession.remoteCwd) + setDirectConnectServerUrl( + _pendingSSH.local ? 'local' : _pendingSSH.host, + ) + } catch (err) { + return await exitWithError( + root, + err instanceof SSHSessionError ? err.message : String(err), + () => gracefulShutdown(1), + ) } - } - if (remote !== null) { - // Create remote session (optionally with initial prompt) - const hasInitialPrompt = remote.length > 0; - - // Check if TUI mode is enabled - description is only optional in TUI mode - const isRemoteTuiEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_remote_backend', false); - if (!isRemoteTuiEnabled && !hasInitialPrompt) { - return await exitWithError(root, 'Error: --remote requires a description.\nUsage: claude --remote "your task description"', () => gracefulShutdown(1)); + + const sshInfoMessage = createSystemMessage( + _pendingSSH.local + ? `Local ssh-proxy test session\ncwd: ${sshSession.remoteCwd}\nAuth: unix socket → local proxy` + : `SSH session to ${_pendingSSH.host}\nRemote cwd: ${sshSession.remoteCwd}\nAuth: unix socket -R → local proxy`, + 'info', + ) + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + debug: debug || debugToStderr, + commands, + initialTools: [], + initialMessages: [sshInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + sshSession, + thinkingConfig, + }, + renderAndRun, + ) + return + } else if ( + feature('KAIROS') && + _pendingAssistantChat && + (_pendingAssistantChat.sessionId || _pendingAssistantChat.discover) + ) { + // `claude assistant [sessionId]` — REPL as a pure viewer client + // of a remote assistant session. The agentic loop runs remotely; this + // process streams live events and POSTs messages. History is lazy- + // loaded by useAssistantHistory on scroll-up (no blocking fetch here). + const { discoverAssistantSessions } = await import( + './assistant/sessionDiscovery.js' + ) + + let targetSessionId = _pendingAssistantChat.sessionId + + // Discovery flow — list bridge environments, filter sessions + if (!targetSessionId) { + let sessions + try { + sessions = await discoverAssistantSessions() + } catch (e) { + return await exitWithError( + root, + `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, + () => gracefulShutdown(1), + ) + } + if (sessions.length === 0) { + let installedDir: string | null + try { + installedDir = await launchAssistantInstallWizard(root) + } catch (e) { + return await exitWithError( + root, + `Assistant installation failed: ${e instanceof Error ? e.message : e}`, + () => gracefulShutdown(1), + ) + } + if (installedDir === null) { + await gracefulShutdown(0) + process.exit(0) + } + // The daemon needs a few seconds to spin up its worker and + // establish a bridge session before discovery will find it. + return await exitWithMessage( + root, + `Assistant installed in ${installedDir}. The daemon is starting up — run \`claude assistant\` again in a few seconds to connect.`, + { exitCode: 0, beforeExit: () => gracefulShutdown(0) }, + ) + } + if (sessions.length === 1) { + targetSessionId = sessions[0]!.id + } else { + const picked = await launchAssistantSessionChooser(root, { + sessions, + }) + if (!picked) { + await gracefulShutdown(0) + process.exit(0) + } + targetSessionId = picked + } } - logEvent('tengu_remote_create_session', { - has_initial_prompt: String(hasInitialPrompt) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - - // Pass current branch so CCR clones the repo at the right revision - const currentBranch = await getBranch(); - const createdSession = await teleportToRemoteWithErrorHandling(root, hasInitialPrompt ? remote : null, new AbortController().signal, currentBranch || undefined); - if (!createdSession) { - logEvent('tengu_remote_create_session_error', { - error: 'unable_to_create_session' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - return await exitWithError(root, 'Error: Unable to create remote session', () => gracefulShutdown(1)); + + // Auth — call prepareApiRequest() once for orgUUID, but use a + // getAccessToken closure for the token so reconnects get fresh tokens. + const { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens } = + await import('./utils/auth.js') + await checkAndRefreshOAuthTokenIfNeeded() + let apiCreds + try { + apiCreds = await prepareApiRequest() + } catch (e) { + return await exitWithError( + root, + `Error: ${e instanceof Error ? e.message : 'Failed to authenticate'}`, + () => gracefulShutdown(1), + ) } - logEvent('tengu_remote_create_session_success', { - session_id: createdSession.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - - // Check if new remote TUI mode is enabled via feature gate - if (!isRemoteTuiEnabled) { - // Original behavior: print session info and exit - process.stdout.write(`Created remote session: ${createdSession.title}\n`); - process.stdout.write(`View: ${getRemoteSessionUrl(createdSession.id)}?m=0\n`); - process.stdout.write(`Resume with: claude --teleport ${createdSession.id}\n`); - await gracefulShutdown(0); - process.exit(0); + const getAccessToken = (): string => + getClaudeAIOAuthTokens()?.accessToken ?? apiCreds.accessToken + + // Brief mode activation: setKairosActive(true) satisfies BOTH opt-in + // and entitlement for isBriefEnabled() (BriefTool.ts:124-132). + setKairosActive(true) + setUserMsgOptIn(true) + setIsRemoteMode(true) + + const remoteSessionConfig = createRemoteSessionConfig( + targetSessionId, + getAccessToken, + apiCreds.orgUUID, + /* hasInitialPrompt */ false, + /* viewerOnly */ true, + ) + + const infoMessage = createSystemMessage( + `Attached to assistant session ${targetSessionId.slice(0, 8)}…`, + 'info', + ) + + const assistantInitialState: AppState = { + ...initialState, + isBriefOnly: true, + kairosEnabled: false, + replBridgeEnabled: false, } - // New behavior: start local TUI with CCR engine - // Mark that we're in remote mode for command visibility - setIsRemoteMode(true); - switchSession(asSessionId(createdSession.id)); + const remoteCommands = filterCommandsForRemoteMode(commands) + await launchRepl( + root, + { getFpsMetrics, stats, initialState: assistantInitialState }, + { + debug: debug || debugToStderr, + commands: remoteCommands, + initialTools: [], + initialMessages: [infoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + remoteSessionConfig, + thinkingConfig, + }, + renderAndRun, + ) + return + } else if ( + options.resume || + options.fromPr || + teleport || + remote !== null + ) { + // Handle resume flow - from file (ant-only), session ID, or interactive selector - // Get OAuth credentials for remote session - let apiCreds: { - accessToken: string; - orgUUID: string; - }; - try { - apiCreds = await prepareApiRequest(); - } catch (error) { - logError(toError(error)); - return await exitWithError(root, `Error: ${errorMessage(error) || 'Failed to authenticate'}`, () => gracefulShutdown(1)); + // Clear stale caches before resuming to ensure fresh file/skill discovery + const { clearSessionCaches } = await import( + './commands/clear/caches.js' + ) + clearSessionCaches() + + let messages: MessageType[] | null = null + let processedResume: ProcessedResume | undefined = undefined + + let maybeSessionId = validateUuid(options.resume) + let searchTerm: string | undefined = undefined + // Store full LogOption when found by custom title (for cross-worktree resume) + let matchedLog: LogOption | null = null + // PR filter for --from-pr flag + let filterByPr: boolean | number | string | undefined = undefined + + // Handle --from-pr flag + if (options.fromPr) { + if (options.fromPr === true) { + // Show all sessions with linked PRs + filterByPr = true + } else if (typeof options.fromPr === 'string') { + // Could be a PR number or URL + filterByPr = options.fromPr + } } - // Create remote session config for the REPL - const { - getClaudeAIOAuthTokens: getTokensForRemote - } = await import('./utils/auth.js'); - const getAccessTokenForRemote = (): string => getTokensForRemote()?.accessToken ?? apiCreds.accessToken; - const remoteSessionConfig = createRemoteSessionConfig(createdSession.id, getAccessTokenForRemote, apiCreds.orgUUID, hasInitialPrompt); + // If resume value is not a UUID, try exact match by custom title first + if ( + options.resume && + typeof options.resume === 'string' && + !maybeSessionId + ) { + const trimmedValue = options.resume.trim() + if (trimmedValue) { + const matches = await searchSessionsByCustomTitle(trimmedValue, { + exact: true, + }) + + if (matches.length === 1) { + // Exact match found - store full LogOption for cross-worktree resume + matchedLog = matches[0]! + maybeSessionId = getSessionIdFromLog(matchedLog) ?? null + } else { + // No match or multiple matches - use as search term for picker + searchTerm = trimmedValue + } + } + } - // Add remote session info as initial system message - const remoteSessionUrl = `${getRemoteSessionUrl(createdSession.id)}?m=0`; - const remoteInfoMessage = createSystemMessage(`/remote-control is active. Code in CLI or at ${remoteSessionUrl}`, 'info'); + // --remote and --teleport both create/resume Claude Code Web (CCR) sessions. + // Remote Control (--rc) is a separate feature gated in initReplBridge.ts. + if (remote !== null || teleport) { + await waitForPolicyLimitsToLoad() + if (!isPolicyAllowed('allow_remote_sessions')) { + return await exitWithError( + root, + "Error: Remote sessions are disabled by your organization's policy.", + () => gracefulShutdown(1), + ) + } + } - // Create initial user message from the prompt if provided (CCR echoes it back but we ignore that) - const initialUserMessage = hasInitialPrompt ? createUserMessage({ - content: remote - }) : null; + if (remote !== null) { + // Create remote session (optionally with initial prompt) + const hasInitialPrompt = remote.length > 0 + + // Check if TUI mode is enabled - description is only optional in TUI mode + const isRemoteTuiEnabled = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_remote_backend', + false, + ) + if (!isRemoteTuiEnabled && !hasInitialPrompt) { + return await exitWithError( + root, + 'Error: --remote requires a description.\nUsage: claude --remote "your task description"', + () => gracefulShutdown(1), + ) + } - // Set remote session URL in app state for footer indicator - const remoteInitialState = { - ...initialState, - remoteSessionUrl - }; - - // Pre-filter commands to only include remote-safe ones. - // CCR's init response may further refine the list (via handleRemoteInit in REPL). - const remoteCommands = filterCommandsForRemoteMode(commands); - await launchRepl(root, { - getFpsMetrics, - stats, - initialState: remoteInitialState - }, { - debug: debug || debugToStderr, - commands: remoteCommands, - initialTools: [], - initialMessages: (initialUserMessage ? [remoteInfoMessage, initialUserMessage] : [remoteInfoMessage]) as MessageType[], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - remoteSessionConfig, - thinkingConfig - }, renderAndRun); - return; - } else if (teleport) { - if (teleport === true || teleport === '') { - // Interactive mode: show task selector and handle resume - logEvent('tengu_teleport_interactive_mode', {}); - logForDebugging('selectAndResumeTeleportTask: Starting teleport flow...'); - const teleportResult = await launchTeleportResumeWrapper(root); - if (!teleportResult) { - // User cancelled or error occurred - await gracefulShutdown(0); - process.exit(0); + logEvent('tengu_remote_create_session', { + has_initial_prompt: String( + hasInitialPrompt, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + // Pass current branch so CCR clones the repo at the right revision + const currentBranch = await getBranch() + const createdSession = await teleportToRemoteWithErrorHandling( + root, + hasInitialPrompt ? remote : null, + new AbortController().signal, + currentBranch || undefined, + ) + if (!createdSession) { + logEvent('tengu_remote_create_session_error', { + error: + 'unable_to_create_session' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return await exitWithError( + root, + 'Error: Unable to create remote session', + () => gracefulShutdown(1), + ) } - const { - branchError - } = await checkOutTeleportedSessionBranch(teleportResult.branch); - messages = processMessagesForTeleportResume(teleportResult.log, branchError); - } else if (typeof teleport === 'string') { - logEvent('tengu_teleport_resume_session', { - mode: 'direct' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + logEvent('tengu_remote_create_session_success', { + session_id: + createdSession.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + // Check if new remote TUI mode is enabled via feature gate + if (!isRemoteTuiEnabled) { + // Original behavior: print session info and exit + process.stdout.write( + `Created remote session: ${createdSession.title}\n`, + ) + process.stdout.write( + `View: ${getRemoteSessionUrl(createdSession.id)}?m=0\n`, + ) + process.stdout.write( + `Resume with: claude --teleport ${createdSession.id}\n`, + ) + await gracefulShutdown(0) + process.exit(0) + } + + // New behavior: start local TUI with CCR engine + // Mark that we're in remote mode for command visibility + setIsRemoteMode(true) + switchSession(asSessionId(createdSession.id)) + + // Get OAuth credentials for remote session + let apiCreds: { accessToken: string; orgUUID: string } try { - // First, fetch session and validate repository before checking git state - const sessionData = await fetchSession(teleport); - const repoValidation = await validateSessionRepository(sessionData); - - // Handle repo mismatch or not in repo cases - if (repoValidation.status === 'mismatch' || repoValidation.status === 'not_in_repo') { - const sessionRepo = repoValidation.sessionRepo; - if (sessionRepo) { - // Check for known paths - const knownPaths = getKnownPathsForRepo(sessionRepo); - const existingPaths = await filterExistingPaths(knownPaths); - if (existingPaths.length > 0) { - // Show directory switch dialog - const selectedPath = await launchTeleportRepoMismatchDialog(root, { - targetRepo: sessionRepo, - initialPaths: existingPaths - }); - if (selectedPath) { - // Change to the selected directory - process.chdir(selectedPath); - setCwd(selectedPath); - setOriginalCwd(selectedPath); - } else { - // User cancelled - await gracefulShutdown(0); - } - } else { - // No known paths - show original error - throw new TeleportOperationError(`You must run claude --teleport ${teleport} from a checkout of ${sessionRepo}.`, chalk.red(`You must run claude --teleport ${teleport} from a checkout of ${chalk.bold(sessionRepo)}.\n`)); - } - } - } else if (repoValidation.status === 'error') { - throw new TeleportOperationError(repoValidation.errorMessage || 'Failed to validate session', chalk.red(`Error: ${repoValidation.errorMessage || 'Failed to validate session'}\n`)); - } - await validateGitState(); - - // Use progress UI for teleport - const { - teleportWithProgress - } = await import('./components/TeleportProgress.js'); - const result = await teleportWithProgress(root, teleport); - // Track teleported session for reliability logging - setTeleportedSessionInfo({ - sessionId: teleport - }); - messages = result.messages; + apiCreds = await prepareApiRequest() } catch (error) { - if (error instanceof TeleportOperationError) { - process.stderr.write(error.formattedMessage + '\n'); - } else { - logError(error); - process.stderr.write(chalk.red(`Error: ${errorMessage(error)}\n`)); - } - await gracefulShutdown(1); + logError(toError(error)) + return await exitWithError( + root, + `Error: ${errorMessage(error) || 'Failed to authenticate'}`, + () => gracefulShutdown(1), + ) } - } - } - if ((process.env.USER_TYPE) === 'ant') { - if (options.resume && typeof options.resume === 'string' && !maybeSessionId) { - // Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036) - const { - parseCcshareId, - loadCcshare - } = await import('./utils/ccshareResume.js'); - const ccshareId = parseCcshareId(options.resume); - if (ccshareId) { + + // Create remote session config for the REPL + const { getClaudeAIOAuthTokens: getTokensForRemote } = await import( + './utils/auth.js' + ) + const getAccessTokenForRemote = (): string => + getTokensForRemote()?.accessToken ?? apiCreds.accessToken + const remoteSessionConfig = createRemoteSessionConfig( + createdSession.id, + getAccessTokenForRemote, + apiCreds.orgUUID, + hasInitialPrompt, + ) + + // Add remote session info as initial system message + const remoteSessionUrl = `${getRemoteSessionUrl(createdSession.id)}?m=0` + const remoteInfoMessage = createSystemMessage( + `/remote-control is active. Code in CLI or at ${remoteSessionUrl}`, + 'info', + ) + + // Create initial user message from the prompt if provided (CCR echoes it back but we ignore that) + const initialUserMessage = hasInitialPrompt + ? createUserMessage({ content: remote }) + : null + + // Set remote session URL in app state for footer indicator + const remoteInitialState = { + ...initialState, + remoteSessionUrl, + } + + // Pre-filter commands to only include remote-safe ones. + // CCR's init response may further refine the list (via handleRemoteInit in REPL). + const remoteCommands = filterCommandsForRemoteMode(commands) + await launchRepl( + root, + { getFpsMetrics, stats, initialState: remoteInitialState }, + { + debug: debug || debugToStderr, + commands: remoteCommands, + initialTools: [], + initialMessages: initialUserMessage + ? [remoteInfoMessage, initialUserMessage] + : [remoteInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + remoteSessionConfig, + thinkingConfig, + }, + renderAndRun, + ) + return + } else if (teleport) { + if (teleport === true || teleport === '') { + // Interactive mode: show task selector and handle resume + logEvent('tengu_teleport_interactive_mode', {}) + logForDebugging( + 'selectAndResumeTeleportTask: Starting teleport flow...', + ) + const teleportResult = await launchTeleportResumeWrapper(root) + if (!teleportResult) { + // User cancelled or error occurred + await gracefulShutdown(0) + process.exit(0) + } + const { branchError } = await checkOutTeleportedSessionBranch( + teleportResult.branch, + ) + messages = processMessagesForTeleportResume( + teleportResult.log, + branchError, + ) + } else if (typeof teleport === 'string') { + logEvent('tengu_teleport_resume_session', { + mode: 'direct' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) try { - const resumeStart = performance.now(); - const logOption = await loadCcshare(ccshareId); - const result = await loadConversationForResume(logOption, undefined); - if (result) { - processedResume = await processResumedConversation(result, { - forkSession: true, - transcriptPath: result.fullPath - }, resumeContext); - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = processedResume.restoredAgentDef; + // First, fetch session and validate repository before checking git state + const sessionData = await fetchSession(teleport) + const repoValidation = + await validateSessionRepository(sessionData) + + // Handle repo mismatch or not in repo cases + if ( + repoValidation.status === 'mismatch' || + repoValidation.status === 'not_in_repo' + ) { + const sessionRepo = repoValidation.sessionRepo + if (sessionRepo) { + // Check for known paths + const knownPaths = getKnownPathsForRepo(sessionRepo) + const existingPaths = await filterExistingPaths(knownPaths) + + if (existingPaths.length > 0) { + // Show directory switch dialog + const selectedPath = await launchTeleportRepoMismatchDialog( + root, + { + targetRepo: sessionRepo, + initialPaths: existingPaths, + }, + ) + + if (selectedPath) { + // Change to the selected directory + process.chdir(selectedPath) + setCwd(selectedPath) + setOriginalCwd(selectedPath) + } else { + // User cancelled + await gracefulShutdown(0) + } + } else { + // No known paths - show original error + throw new TeleportOperationError( + `You must run claude --teleport ${teleport} from a checkout of ${sessionRepo}.`, + chalk.red( + `You must run claude --teleport ${teleport} from a checkout of ${chalk.bold(sessionRepo)}.\n`, + ), + ) + } } - logEvent('tengu_session_resumed', { - entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); - } else { - logEvent('tengu_session_resumed', { - entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); + } else if (repoValidation.status === 'error') { + throw new TeleportOperationError( + repoValidation.errorMessage || 'Failed to validate session', + chalk.red( + `Error: ${repoValidation.errorMessage || 'Failed to validate session'}\n`, + ), + ) } + + await validateGitState() + + // Use progress UI for teleport + const { teleportWithProgress } = await import( + './components/TeleportProgress.js' + ) + const result = await teleportWithProgress(root, teleport) + // Track teleported session for reliability logging + setTeleportedSessionInfo({ sessionId: teleport }) + messages = result.messages } catch (error) { - logEvent('tengu_session_resumed', { - entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); - logError(error); - await exitWithError(root, `Unable to resume from ccshare: ${errorMessage(error)}`, () => gracefulShutdown(1)); + if (error instanceof TeleportOperationError) { + process.stderr.write(error.formattedMessage + '\n') + } else { + logError(error) + process.stderr.write( + chalk.red(`Error: ${errorMessage(error)}\n`), + ) + } + await gracefulShutdown(1) } - } else { - const resolvedPath = resolve(options.resume); - try { - const resumeStart = performance.now(); - let logOption; + } + } + if (process.env.USER_TYPE === 'ant') { + if ( + options.resume && + typeof options.resume === 'string' && + !maybeSessionId + ) { + // Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036) + const { parseCcshareId, loadCcshare } = await import( + './utils/ccshareResume.js' + ) + const ccshareId = parseCcshareId(options.resume) + if (ccshareId) { try { - // Attempt to load as a transcript file; ENOENT falls through to session-ID handling - logOption = await loadTranscriptFromFile(resolvedPath); - } catch (error) { - if (!isENOENT(error)) throw error; - // ENOENT: not a file path — fall through to session-ID handling - } - if (logOption) { - const result = await loadConversationForResume(logOption, undefined /* sourceFile */); + const resumeStart = performance.now() + const logOption = await loadCcshare(ccshareId) + const result = await loadConversationForResume( + logOption, + undefined, + ) if (result) { - processedResume = await processResumedConversation(result, { - forkSession: !!options.forkSession, - transcriptPath: result.fullPath - }, resumeContext); + processedResume = await processResumedConversation( + result, + { + forkSession: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ) if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = processedResume.restoredAgentDef; + mainThreadAgentDefinition = processedResume.restoredAgentDef } logEvent('tengu_session_resumed', { - entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: + 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); + resume_duration_ms: Math.round( + performance.now() - resumeStart, + ), + }) } else { logEvent('tengu_session_resumed', { - entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); + entrypoint: + 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) } + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: + 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + logError(error) + await exitWithError( + root, + `Unable to resume from ccshare: ${errorMessage(error)}`, + () => gracefulShutdown(1), + ) + } + } else { + const resolvedPath = resolve(options.resume) + try { + const resumeStart = performance.now() + let logOption + try { + // Attempt to load as a transcript file; ENOENT falls through to session-ID handling + logOption = await loadTranscriptFromFile(resolvedPath) + } catch (error) { + if (!isENOENT(error)) throw error + // ENOENT: not a file path — fall through to session-ID handling + } + if (logOption) { + const result = await loadConversationForResume( + logOption, + undefined /* sourceFile */, + ) + if (result) { + processedResume = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + transcriptPath: result.fullPath, + }, + resumeContext, + ) + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = + processedResume.restoredAgentDef + } + logEvent('tengu_session_resumed', { + entrypoint: + 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round( + performance.now() - resumeStart, + ), + }) + } else { + logEvent('tengu_session_resumed', { + entrypoint: + 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + } + } + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: + 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + logError(error) + await exitWithError( + root, + `Unable to load transcript from file: ${options.resume}`, + () => gracefulShutdown(1), + ) } - } catch (error) { - logEvent('tengu_session_resumed', { - entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); - logError(error); - await exitWithError(root, `Unable to load transcript from file: ${options.resume}`, () => gracefulShutdown(1)); } } } - } - // If not loaded as a file, try as session ID - if (maybeSessionId) { - // Resume specific session by ID - const sessionId = maybeSessionId; - try { - const resumeStart = performance.now(); - // Use matchedLog if available (for cross-worktree resume by custom title) - // Otherwise fall back to sessionId string (for direct UUID resume) - const result = await loadConversationForResume(matchedLog ?? sessionId, undefined); - if (!result) { + // If not loaded as a file, try as session ID + if (maybeSessionId) { + // Resume specific session by ID + const sessionId = maybeSessionId + try { + const resumeStart = performance.now() + // Use matchedLog if available (for cross-worktree resume by custom title) + // Otherwise fall back to sessionId string (for direct UUID resume) + const result = await loadConversationForResume( + matchedLog ?? sessionId, + undefined, + ) + + if (!result) { + logEvent('tengu_session_resumed', { + entrypoint: + 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + return await exitWithError( + root, + `No conversation found with session ID: ${sessionId}`, + ) + } + + const fullPath = matchedLog?.fullPath ?? result.fullPath + processedResume = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + sessionIdOverride: sessionId, + transcriptPath: fullPath, + }, + resumeContext, + ) + + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = processedResume.restoredAgentDef + } logEvent('tengu_session_resumed', { - entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); - return await exitWithError(root, `No conversation found with session ID: ${sessionId}`); - } - const fullPath = matchedLog?.fullPath ?? result.fullPath; - processedResume = await processResumedConversation(result, { - forkSession: !!options.forkSession, - sessionIdOverride: sessionId, - transcriptPath: fullPath - }, resumeContext); - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = processedResume.restoredAgentDef; + entrypoint: + 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }) + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: + 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + logError(error) + await exitWithError(root, `Failed to resume session ${sessionId}`) } - logEvent('tengu_session_resumed', { - entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); - } catch (error) { - logEvent('tengu_session_resumed', { - entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); - logError(error); - await exitWithError(root, `Failed to resume session ${sessionId}`); } - } - // Await file downloads before rendering REPL (files must be available) - if (fileDownloadPromise) { - try { - const results = await fileDownloadPromise; - const failedCount = count(results, r => !r.success); - if (failedCount > 0) { - process.stderr.write(chalk.yellow(`Warning: ${failedCount}/${results.length} file(s) failed to download.\n`)); + // Await file downloads before rendering REPL (files must be available) + if (fileDownloadPromise) { + try { + const results = await fileDownloadPromise + const failedCount = count(results, r => !r.success) + if (failedCount > 0) { + process.stderr.write( + chalk.yellow( + `Warning: ${failedCount}/${results.length} file(s) failed to download.\n`, + ), + ) + } + } catch (error) { + return await exitWithError( + root, + `Error downloading files: ${errorMessage(error)}`, + ) } - } catch (error) { - return await exitWithError(root, `Error downloading files: ${errorMessage(error)}`); } - } - // If we have a processed resume or teleport messages, render the REPL - const resumeData = processedResume ?? (Array.isArray(messages) ? { - messages, - fileHistorySnapshots: undefined, - agentName: undefined, - agentColor: undefined as AgentColorName | undefined, - restoredAgentDef: mainThreadAgentDefinition, - initialState, - contentReplacements: undefined - } : undefined); - if (resumeData) { - maybeActivateProactive(options); - maybeActivateBrief(options); - await launchRepl(root, { - getFpsMetrics, - stats, - initialState: resumeData.initialState - }, { - ...sessionConfig, - mainThreadAgentDefinition: resumeData.restoredAgentDef ?? mainThreadAgentDefinition, - initialMessages: resumeData.messages, - initialFileHistorySnapshots: resumeData.fileHistorySnapshots, - initialContentReplacements: resumeData.contentReplacements, - initialAgentName: resumeData.agentName, - initialAgentColor: resumeData.agentColor - }, renderAndRun); + // If we have a processed resume or teleport messages, render the REPL + const resumeData = + processedResume ?? + (Array.isArray(messages) + ? { + messages, + fileHistorySnapshots: undefined, + agentName: undefined, + agentColor: undefined as AgentColorName | undefined, + restoredAgentDef: mainThreadAgentDefinition, + initialState, + contentReplacements: undefined, + } + : undefined) + if (resumeData) { + maybeActivateProactive(options) + maybeActivateBrief(options) + + await launchRepl( + root, + { getFpsMetrics, stats, initialState: resumeData.initialState }, + { + ...sessionConfig, + mainThreadAgentDefinition: + resumeData.restoredAgentDef ?? mainThreadAgentDefinition, + initialMessages: resumeData.messages, + initialFileHistorySnapshots: resumeData.fileHistorySnapshots, + initialContentReplacements: resumeData.contentReplacements, + initialAgentName: resumeData.agentName, + initialAgentColor: resumeData.agentColor, + }, + renderAndRun, + ) + } else { + // Show interactive selector (includes same-repo worktrees) + // Note: ResumeConversation loads logs internally to ensure proper GC after selection + await launchResumeChooser( + root, + { getFpsMetrics, stats, initialState }, + getWorktreePaths(getOriginalCwd()), + { + ...sessionConfig, + initialSearchQuery: searchTerm, + forkSession: options.forkSession, + filterByPr, + }, + ) + } } else { - // Show interactive selector (includes same-repo worktrees) - // Note: ResumeConversation loads logs internally to ensure proper GC after selection - await launchResumeChooser(root, { - getFpsMetrics, - stats, - initialState - }, getWorktreePaths(getOriginalCwd()), { - ...sessionConfig, - initialSearchQuery: searchTerm, - forkSession: options.forkSession, - filterByPr - }); - } - } else { - // Pass unresolved hooks promise to REPL so it can render immediately - // instead of blocking ~500ms waiting for SessionStart hooks to finish. - // REPL will inject hook messages when they resolve and await them before - // the first API call so the model always sees hook context. - const pendingHookMessages = hooksPromise && hookMessages.length === 0 ? hooksPromise : undefined; - profileCheckpoint('action_after_hooks'); - maybeActivateProactive(options); - maybeActivateBrief(options); - // Persist the current mode for fresh sessions so future resumes know what mode was used - if (feature('COORDINATOR_MODE')) { - saveMode(coordinatorModeModule?.isCoordinatorMode() ? 'coordinator' : 'normal'); - } + // Pass unresolved hooks promise to REPL so it can render immediately + // instead of blocking ~500ms waiting for SessionStart hooks to finish. + // REPL will inject hook messages when they resolve and await them before + // the first API call so the model always sees hook context. + const pendingHookMessages = + hooksPromise && hookMessages.length === 0 ? hooksPromise : undefined + + profileCheckpoint('action_after_hooks') + maybeActivateProactive(options) + maybeActivateBrief(options) + // Persist the current mode for fresh sessions so future resumes know what mode was used + if (feature('COORDINATOR_MODE')) { + saveMode( + coordinatorModeModule?.isCoordinatorMode() + ? 'coordinator' + : 'normal', + ) + } - // If launched via a deep link, show a provenance banner so the user - // knows the session originated externally. Linux xdg-open and - // browsers with "always allow" set dispatch the link with no OS-level - // confirmation, so this is the only signal the user gets that the - // prompt — and the working directory / CLAUDE.md it implies — came - // from an external source rather than something they typed. - let deepLinkBanner: ReturnType | null = null; - if (feature('LODESTONE')) { - if (options.deepLinkOrigin) { - logEvent('tengu_deep_link_opened', { - has_prefill: Boolean(options.prefill), - has_repo: Boolean(options.deepLinkRepo) - }); - deepLinkBanner = createSystemMessage(buildDeepLinkBanner({ - cwd: getCwd(), - prefillLength: options.prefill?.length, - repo: options.deepLinkRepo, - lastFetch: options.deepLinkLastFetch !== undefined ? new Date(options.deepLinkLastFetch) : undefined - }), 'warning'); - } else if (options.prefill) { - deepLinkBanner = createSystemMessage('Launched with a pre-filled prompt — review it before pressing Enter.', 'warning'); + // If launched via a deep link, show a provenance banner so the user + // knows the session originated externally. Linux xdg-open and + // browsers with "always allow" set dispatch the link with no OS-level + // confirmation, so this is the only signal the user gets that the + // prompt — and the working directory / CLAUDE.md it implies — came + // from an external source rather than something they typed. + let deepLinkBanner: ReturnType | null = null + if (feature('LODESTONE')) { + if (options.deepLinkOrigin) { + logEvent('tengu_deep_link_opened', { + has_prefill: Boolean(options.prefill), + has_repo: Boolean(options.deepLinkRepo), + }) + deepLinkBanner = createSystemMessage( + buildDeepLinkBanner({ + cwd: getCwd(), + prefillLength: options.prefill?.length, + repo: options.deepLinkRepo, + lastFetch: + options.deepLinkLastFetch !== undefined + ? new Date(options.deepLinkLastFetch) + : undefined, + }), + 'warning', + ) + } else if (options.prefill) { + deepLinkBanner = createSystemMessage( + 'Launched with a pre-filled prompt — review it before pressing Enter.', + 'warning', + ) + } } + const initialMessages = deepLinkBanner + ? [deepLinkBanner, ...hookMessages] + : hookMessages.length > 0 + ? hookMessages + : undefined + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + ...sessionConfig, + initialMessages, + pendingHookMessages, + }, + renderAndRun, + ) } - const initialMessages = deepLinkBanner ? [deepLinkBanner, ...hookMessages] : hookMessages.length > 0 ? hookMessages : undefined; - await launchRepl(root, { - getFpsMetrics, - stats, - initialState - }, { - ...sessionConfig, - initialMessages, - pendingHookMessages - }, renderAndRun); - } - }).version(`${MACRO.VERSION} (Claude Code)`, '-v, --version', 'Output the version number'); + }) + .version( + `${MACRO.VERSION} (Claude Code)`, + '-v, --version', + 'Output the version number', + ) // Worktree flags - program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)'); - program.option('--tmux', 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.'); + program.option( + '-w, --worktree [name]', + 'Create a new git worktree for this session (optionally specify a name)', + ) + program.option( + '--tmux', + 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.', + ) + if (canUserConfigureAdvisor()) { - program.addOption(new Option('--advisor ', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp()); + program.addOption( + new Option( + '--advisor ', + 'Enable the server-side advisor tool with the specified model (alias or full ID).', + ).hideHelp(), + ) } - if ((process.env.USER_TYPE) === 'ant') { - program.addOption(new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({ - permissionMode: 'auto' - })); - program.addOption(new Option('--dangerously-skip-permissions-with-classifiers', '[ANT-ONLY] Deprecated alias for --permission-mode auto.').hideHelp().implies({ - permissionMode: 'auto' - })); - program.addOption(new Option('--afk', '[ANT-ONLY] Deprecated alias for --permission-mode auto.').hideHelp().implies({ - permissionMode: 'auto' - })); - program.addOption(new Option('--tasks [id]', '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").').argParser(String).hideHelp()); - program.option('--agent-teams', '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', () => true); + + if (process.env.USER_TYPE === 'ant') { + program.addOption( + new Option( + '--delegate-permissions', + '[ANT-ONLY] Alias for --permission-mode auto.', + ).implies({ permissionMode: 'auto' }), + ) + program.addOption( + new Option( + '--dangerously-skip-permissions-with-classifiers', + '[ANT-ONLY] Deprecated alias for --permission-mode auto.', + ) + .hideHelp() + .implies({ permissionMode: 'auto' }), + ) + program.addOption( + new Option( + '--afk', + '[ANT-ONLY] Deprecated alias for --permission-mode auto.', + ) + .hideHelp() + .implies({ permissionMode: 'auto' }), + ) + program.addOption( + new Option( + '--tasks [id]', + '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").', + ) + .argParser(String) + .hideHelp(), + ) + program.option( + '--agent-teams', + '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', + () => true, + ) } + if (feature('TRANSCRIPT_CLASSIFIER')) { - program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp()); + program.addOption( + new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp(), + ) } + if (feature('PROACTIVE') || feature('KAIROS')) { - program.addOption(new Option('--proactive', 'Start in proactive autonomous mode')); + program.addOption( + new Option('--proactive', 'Start in proactive autonomous mode'), + ) } + if (feature('UDS_INBOX')) { - program.addOption(new Option('--messaging-socket-path ', 'Unix domain socket path for the UDS messaging server (defaults to a tmp path)')); + program.addOption( + new Option( + '--messaging-socket-path ', + 'Unix domain socket path for the UDS messaging server (defaults to a tmp path)', + ), + ) } + if (feature('KAIROS') || feature('KAIROS_BRIEF')) { - program.addOption(new Option('--brief', 'Enable SendUserMessage tool for agent-to-user communication')); + program.addOption( + new Option( + '--brief', + 'Enable SendUserMessage tool for agent-to-user communication', + ), + ) } if (feature('KAIROS')) { - program.addOption(new Option('--assistant', 'Force assistant mode (Agent SDK daemon use)').hideHelp()); + program.addOption( + new Option( + '--assistant', + 'Force assistant mode (Agent SDK daemon use)', + ).hideHelp(), + ) } if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { - program.addOption(new Option('--channels ', 'MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.').hideHelp()); - program.addOption(new Option('--dangerously-load-development-channels ', 'Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.').hideHelp()); + program.addOption( + new Option( + '--channels ', + 'MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.', + ).hideHelp(), + ) + program.addOption( + new Option( + '--dangerously-load-development-channels ', + 'Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.', + ).hideHelp(), + ) } // Teammate identity options (set by leader when spawning tmux teammates) // These replace the CLAUDE_CODE_* environment variables - program.addOption(new Option('--agent-id ', 'Teammate agent ID').hideHelp()); - program.addOption(new Option('--agent-name ', 'Teammate display name').hideHelp()); - program.addOption(new Option('--team-name ', 'Team name for swarm coordination').hideHelp()); - program.addOption(new Option('--agent-color ', 'Teammate UI color').hideHelp()); - program.addOption(new Option('--plan-mode-required', 'Require plan mode before implementation').hideHelp()); - program.addOption(new Option('--parent-session-id ', 'Parent session ID for analytics correlation').hideHelp()); - program.addOption(new Option('--teammate-mode ', 'How to spawn teammates: "tmux", "in-process", or "auto"').choices(['auto', 'tmux', 'in-process']).hideHelp()); - program.addOption(new Option('--agent-type ', 'Custom agent type for this teammate').hideHelp()); + program.addOption( + new Option('--agent-id ', 'Teammate agent ID').hideHelp(), + ) + program.addOption( + new Option('--agent-name ', 'Teammate display name').hideHelp(), + ) + program.addOption( + new Option( + '--team-name ', + 'Team name for swarm coordination', + ).hideHelp(), + ) + program.addOption( + new Option('--agent-color ', 'Teammate UI color').hideHelp(), + ) + program.addOption( + new Option( + '--plan-mode-required', + 'Require plan mode before implementation', + ).hideHelp(), + ) + program.addOption( + new Option( + '--parent-session-id ', + 'Parent session ID for analytics correlation', + ).hideHelp(), + ) + program.addOption( + new Option( + '--teammate-mode ', + 'How to spawn teammates: "tmux", "in-process", or "auto"', + ) + .choices(['auto', 'tmux', 'in-process']) + .hideHelp(), + ) + program.addOption( + new Option( + '--agent-type ', + 'Custom agent type for this teammate', + ).hideHelp(), + ) // Enable SDK URL for all builds but hide from help - program.addOption(new Option('--sdk-url ', 'Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)').hideHelp()); + program.addOption( + new Option( + '--sdk-url ', + 'Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)', + ).hideHelp(), + ) // Enable teleport/remote flags for all builds but keep them undocumented until GA - program.addOption(new Option('--teleport [session]', 'Resume a teleport session, optionally specify session ID').hideHelp()); - program.addOption(new Option('--remote [description]', 'Create a remote session with the given description').hideHelp()); + program.addOption( + new Option( + '--teleport [session]', + 'Resume a teleport session, optionally specify session ID', + ).hideHelp(), + ) + program.addOption( + new Option( + '--remote [description]', + 'Create a remote session with the given description', + ).hideHelp(), + ) if (feature('BRIDGE_MODE')) { - program.addOption(new Option('--remote-control [name]', 'Start an interactive session with Remote Control enabled (optionally named)').argParser(value => value || true).hideHelp()); - program.addOption(new Option('--rc [name]', 'Alias for --remote-control').argParser(value => value || true).hideHelp()); + program.addOption( + new Option( + '--remote-control [name]', + 'Start an interactive session with Remote Control enabled (optionally named)', + ) + .argParser(value => value || true) + .hideHelp(), + ) + program.addOption( + new Option('--rc [name]', 'Alias for --remote-control') + .argParser(value => value || true) + .hideHelp(), + ) } + if (feature('HARD_FAIL')) { - program.addOption(new Option('--hard-fail', 'Crash on logError calls instead of silently logging').hideHelp()); + program.addOption( + new Option( + '--hard-fail', + 'Crash on logError calls instead of silently logging', + ).hideHelp(), + ) } - profileCheckpoint('run_main_options_built'); + + profileCheckpoint('run_main_options_built') // -p/--print mode: skip subcommand registration. The 52 subcommands // (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are @@ -3877,161 +5406,228 @@ async function run(): Promise { // + 40ms sync keychain subprocess), both hidden by the try/catch that // always returns false before enableConfigs(). cc:// URLs are rewritten to // `open` at main() line ~851 BEFORE this runs, so argv check is safe here. - const isPrintMode = process.argv.includes('-p') || process.argv.includes('--print'); - const isCcUrl = process.argv.some(a => a.startsWith('cc://') || a.startsWith('cc+unix://')); + const isPrintMode = + process.argv.includes('-p') || process.argv.includes('--print') + const isCcUrl = process.argv.some( + a => a.startsWith('cc://') || a.startsWith('cc+unix://'), + ) if (isPrintMode && !isCcUrl) { - profileCheckpoint('run_before_parse'); - await program.parseAsync(process.argv); - profileCheckpoint('run_after_parse'); - return program; + profileCheckpoint('run_before_parse') + await program.parseAsync(process.argv) + profileCheckpoint('run_after_parse') + return program } // claude mcp - const mcp = program.command('mcp').description('Configure and manage MCP servers').configureHelp(createSortedHelpConfig()).enablePositionalOptions(); - mcp.command('serve').description(`Start the Claude Code MCP server`).option('-d, --debug', 'Enable debug mode', () => true).option('--verbose', 'Override verbose mode setting from config', () => true).action(async ({ - debug, - verbose - }: { - debug?: boolean; - verbose?: boolean; - }) => { - const { - mcpServeHandler - } = await import('./cli/handlers/mcp.js'); - await mcpServeHandler({ - debug, - verbose - }); - }); + const mcp = program + .command('mcp') + .description('Configure and manage MCP servers') + .configureHelp(createSortedHelpConfig()) + .enablePositionalOptions() + + mcp + .command('serve') + .description(`Start the Claude Code MCP server`) + .option('-d, --debug', 'Enable debug mode', () => true) + .option( + '--verbose', + 'Override verbose mode setting from config', + () => true, + ) + .action( + async ({ debug, verbose }: { debug?: boolean; verbose?: boolean }) => { + const { mcpServeHandler } = await import('./cli/handlers/mcp.js') + await mcpServeHandler({ debug, verbose }) + }, + ) // Register the mcp add subcommand (extracted for testability) - registerMcpAddCommand(mcp); + registerMcpAddCommand(mcp) + if (isXaaEnabled()) { - registerMcpXaaIdpCommand(mcp); + registerMcpXaaIdpCommand(mcp) } - mcp.command('remove ').description('Remove an MCP server').option('-s, --scope ', 'Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in').action(async (name: string, options: { - scope?: string; - }) => { - const { - mcpRemoveHandler - } = await import('./cli/handlers/mcp.js'); - await mcpRemoveHandler(name, options); - }); - mcp.command('list').description('List configured MCP servers. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.').action(async () => { - const { - mcpListHandler - } = await import('./cli/handlers/mcp.js'); - await mcpListHandler(); - }); - mcp.command('get ').description('Get details about an MCP server. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.').action(async (name: string) => { - const { - mcpGetHandler - } = await import('./cli/handlers/mcp.js'); - await mcpGetHandler(name); - }); - mcp.command('add-json ').description('Add an MCP server (stdio or SSE) with a JSON string').option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local').option('--client-secret', 'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)').action(async (name: string, json: string, options: { - scope?: string; - clientSecret?: true; - }) => { - const { - mcpAddJsonHandler - } = await import('./cli/handlers/mcp.js'); - await mcpAddJsonHandler(name, json, options); - }); - mcp.command('add-from-claude-desktop').description('Import MCP servers from Claude Desktop (Mac and WSL only)').option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local').action(async (options: { - scope?: string; - }) => { - const { - mcpAddFromDesktopHandler - } = await import('./cli/handlers/mcp.js'); - await mcpAddFromDesktopHandler(options); - }); - mcp.command('reset-project-choices').description('Reset all approved and rejected project-scoped (.mcp.json) servers within this project').action(async () => { - const { - mcpResetChoicesHandler - } = await import('./cli/handlers/mcp.js'); - await mcpResetChoicesHandler(); - }); + + mcp + .command('remove ') + .description('Remove an MCP server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in', + ) + .action(async (name: string, options: { scope?: string }) => { + const { mcpRemoveHandler } = await import('./cli/handlers/mcp.js') + await mcpRemoveHandler(name, options) + }) + + mcp + .command('list') + .description( + 'List configured MCP servers. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.', + ) + .action(async () => { + const { mcpListHandler } = await import('./cli/handlers/mcp.js') + await mcpListHandler() + }) + + mcp + .command('get ') + .description( + 'Get details about an MCP server. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.', + ) + .action(async (name: string) => { + const { mcpGetHandler } = await import('./cli/handlers/mcp.js') + await mcpGetHandler(name) + }) + + mcp + .command('add-json ') + .description('Add an MCP server (stdio or SSE) with a JSON string') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .option( + '--client-secret', + 'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)', + ) + .action( + async ( + name: string, + json: string, + options: { scope?: string; clientSecret?: true }, + ) => { + const { mcpAddJsonHandler } = await import('./cli/handlers/mcp.js') + await mcpAddJsonHandler(name, json, options) + }, + ) + + mcp + .command('add-from-claude-desktop') + .description('Import MCP servers from Claude Desktop (Mac and WSL only)') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .action(async (options: { scope?: string }) => { + const { mcpAddFromDesktopHandler } = await import('./cli/handlers/mcp.js') + await mcpAddFromDesktopHandler(options) + }) + + mcp + .command('reset-project-choices') + .description( + 'Reset all approved and rejected project-scoped (.mcp.json) servers within this project', + ) + .action(async () => { + const { mcpResetChoicesHandler } = await import('./cli/handlers/mcp.js') + await mcpResetChoicesHandler() + }) // claude server if (feature('DIRECT_CONNECT')) { - program.command('server').description('Start a Claude Code session server').option('--port ', 'HTTP port', '0').option('--host ', 'Bind address', '0.0.0.0').option('--auth-token ', 'Bearer token for auth').option('--unix ', 'Listen on a unix domain socket').option('--workspace ', 'Default working directory for sessions that do not specify cwd').option('--idle-timeout ', 'Idle timeout for detached sessions in ms (0 = never expire)', '600000').option('--max-sessions ', 'Maximum concurrent sessions (0 = unlimited)', '32').action(async (opts: { - port: string; - host: string; - authToken?: string; - unix?: string; - workspace?: string; - idleTimeout: string; - maxSessions: string; - }) => { - const { - randomBytes - } = await import('crypto'); - const { - startServer - } = await import('./server/server.js'); - const { - SessionManager - } = await import('./server/sessionManager.js'); - const { - DangerousBackend - } = await import('./server/backends/dangerousBackend.js'); - const { - printBanner - } = await import('./server/serverBanner.js'); - const { - createServerLogger - } = await import('./server/serverLog.js'); - const { - writeServerLock, - removeServerLock, - probeRunningServer - } = await import('./server/lockfile.js'); - const existing = await probeRunningServer(); - if (existing) { - process.stderr.write(`A claude server is already running (pid ${existing.pid}) at ${existing.httpUrl}\n`); - process.exit(1); - } - const authToken = opts.authToken ?? `sk-ant-cc-${randomBytes(16).toString('base64url')}`; - const config = { - port: parseInt(opts.port, 10), - host: opts.host, - authToken, - unix: opts.unix, - workspace: opts.workspace, - idleTimeoutMs: parseInt(opts.idleTimeout, 10), - maxSessions: parseInt(opts.maxSessions, 10) - }; - const backend = new DangerousBackend(); - const sessionManager = new SessionManager(backend, { - idleTimeoutMs: config.idleTimeoutMs, - maxSessions: config.maxSessions - }); - const logger = createServerLogger(); - const server = startServer(config, sessionManager, logger); - const actualPort = server.port ?? config.port; - printBanner(config, authToken, actualPort); - await writeServerLock({ - pid: process.pid, - port: actualPort, - host: config.host, - httpUrl: config.unix ? `unix:${config.unix}` : `http://${config.host}:${actualPort}`, - startedAt: Date.now() - }); - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - // Stop accepting new connections before tearing down sessions. - server.stop(true); - await sessionManager.destroyAll(); - await removeServerLock(); - process.exit(0); - }; - process.once('SIGINT', () => void shutdown()); - process.once('SIGTERM', () => void shutdown()); - }); + program + .command('server') + .description('Start a Claude Code session server') + .option('--port ', 'HTTP port', '0') + .option('--host ', 'Bind address', '0.0.0.0') + .option('--auth-token ', 'Bearer token for auth') + .option('--unix ', 'Listen on a unix domain socket') + .option( + '--workspace ', + 'Default working directory for sessions that do not specify cwd', + ) + .option( + '--idle-timeout ', + 'Idle timeout for detached sessions in ms (0 = never expire)', + '600000', + ) + .option( + '--max-sessions ', + 'Maximum concurrent sessions (0 = unlimited)', + '32', + ) + .action( + async (opts: { + port: string + host: string + authToken?: string + unix?: string + workspace?: string + idleTimeout: string + maxSessions: string + }) => { + const { randomBytes } = await import('crypto') + const { startServer } = await import('./server/server.js') + const { SessionManager } = await import('./server/sessionManager.js') + const { DangerousBackend } = await import( + './server/backends/dangerousBackend.js' + ) + const { printBanner } = await import('./server/serverBanner.js') + const { createServerLogger } = await import('./server/serverLog.js') + const { writeServerLock, removeServerLock, probeRunningServer } = + await import('./server/lockfile.js') + + const existing = await probeRunningServer() + if (existing) { + process.stderr.write( + `A claude server is already running (pid ${existing.pid}) at ${existing.httpUrl}\n`, + ) + process.exit(1) + } + + const authToken = + opts.authToken ?? + `sk-ant-cc-${randomBytes(16).toString('base64url')}` + + const config = { + port: parseInt(opts.port, 10), + host: opts.host, + authToken, + unix: opts.unix, + workspace: opts.workspace, + idleTimeoutMs: parseInt(opts.idleTimeout, 10), + maxSessions: parseInt(opts.maxSessions, 10), + } + + const backend = new DangerousBackend() + const sessionManager = new SessionManager(backend, { + idleTimeoutMs: config.idleTimeoutMs, + maxSessions: config.maxSessions, + }) + const logger = createServerLogger() + + const server = startServer(config, sessionManager, logger) + const actualPort = server.port ?? config.port + printBanner(config, authToken, actualPort) + + await writeServerLock({ + pid: process.pid, + port: actualPort, + host: config.host, + httpUrl: config.unix + ? `unix:${config.unix}` + : `http://${config.host}:${actualPort}`, + startedAt: Date.now(), + }) + + let shuttingDown = false + const shutdown = async () => { + if (shuttingDown) return + shuttingDown = true + // Stop accepting new connections before tearing down sessions. + server.stop(true) + await sessionManager.destroyAll() + await removeServerLock() + process.exit(0) + } + process.once('SIGINT', () => void shutdown()) + process.once('SIGTERM', () => void shutdown()) + }, + ) } // `claude ssh [dir]` — registered here only so --help shows it. @@ -4040,97 +5636,157 @@ async function run(): Promise { // this action it means the argv rewrite didn't fire (e.g. user ran // `claude ssh` with no host) — just print usage. if (feature('SSH_REMOTE')) { - program.command('ssh [dir]').description('Run Claude Code on a remote host over SSH. Deploys the binary and ' + 'tunnels API auth back through your local machine — no remote setup needed.').option('--permission-mode ', 'Permission mode for the remote session').option('--dangerously-skip-permissions', 'Skip all permission prompts on the remote (dangerous)').option('--local', 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + 'Exercises the auth proxy and unix-socket plumbing without a remote host.').action(async () => { - // Argv rewriting in main() should have consumed `ssh ` before - // commander runs. Reaching here means host was missing or the - // rewrite predicate didn't match. - process.stderr.write('Usage: claude ssh [dir]\n\n' + "Runs Claude Code on a remote Linux host. You don't need to install\n" + 'anything on the remote or run `claude auth login` there — the binary is\n' + 'deployed over SSH and API auth tunnels back through your local machine.\n'); - process.exit(1); - }); + program + .command('ssh [dir]') + .description( + 'Run Claude Code on a remote host over SSH. Deploys the binary and ' + + 'tunnels API auth back through your local machine — no remote setup needed.', + ) + .option( + '--permission-mode ', + 'Permission mode for the remote session', + ) + .option( + '--dangerously-skip-permissions', + 'Skip all permission prompts on the remote (dangerous)', + ) + .option( + '--local', + 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + + 'Exercises the auth proxy and unix-socket plumbing without a remote host.', + ) + .action(async () => { + // Argv rewriting in main() should have consumed `ssh ` before + // commander runs. Reaching here means host was missing or the + // rewrite predicate didn't match. + process.stderr.write( + 'Usage: claude ssh [dir]\n\n' + + "Runs Claude Code on a remote Linux host. You don't need to install\n" + + 'anything on the remote or run `claude auth login` there — the binary is\n' + + 'deployed over SSH and API auth tunnels back through your local machine.\n', + ) + process.exit(1) + }) } // claude connect — subcommand only handles -p (headless) mode. // Interactive mode (without -p) is handled by early argv rewriting in main() // which redirects to the main command with full TUI support. if (feature('DIRECT_CONNECT')) { - program.command('open ').description('Connect to a Claude Code server (internal — use cc:// URLs)').option('-p, --print [prompt]', 'Print mode (headless)').option('--output-format ', 'Output format: text, json, stream-json', 'text').action(async (ccUrl: string, opts: { - print?: string | true; - outputFormat?: string; - }, _command) => { - const { - parseConnectUrl - } = await import('./server/parseConnectUrl.js'); - const { - serverUrl, - authToken - } = parseConnectUrl(ccUrl); - let connectConfig; - try { - const session = await createDirectConnectSession({ - serverUrl, - authToken, - cwd: getOriginalCwd(), - dangerouslySkipPermissions: _pendingConnect?.dangerouslySkipPermissions - }); - if (session.workDir) { - setOriginalCwd(session.workDir); - setCwdState(session.workDir); - } - setDirectConnectServerUrl(serverUrl); - connectConfig = session.config; - } catch (err) { - // biome-ignore lint/suspicious/noConsole: intentional error output - console.error(err instanceof DirectConnectError ? err.message : String(err)); - process.exit(1); - } - const { - runConnectHeadless - } = await import('./server/connectHeadless.js'); - const prompt = typeof opts.print === 'string' ? opts.print : ''; - const interactive = opts.print === true; - await runConnectHeadless(connectConfig, prompt, opts.outputFormat, interactive); - }); + program + .command('open ') + .description( + 'Connect to a Claude Code server (internal — use cc:// URLs)', + ) + .option('-p, --print [prompt]', 'Print mode (headless)') + .option( + '--output-format ', + 'Output format: text, json, stream-json', + 'text', + ) + .action( + async ( + ccUrl: string, + opts: { + print?: string | boolean + outputFormat: string + }, + ) => { + const { parseConnectUrl } = await import( + './server/parseConnectUrl.js' + ) + const { serverUrl, authToken } = parseConnectUrl(ccUrl) + + let connectConfig + try { + const session = await createDirectConnectSession({ + serverUrl, + authToken, + cwd: getOriginalCwd(), + dangerouslySkipPermissions: + _pendingConnect?.dangerouslySkipPermissions, + }) + if (session.workDir) { + setOriginalCwd(session.workDir) + setCwdState(session.workDir) + } + setDirectConnectServerUrl(serverUrl) + connectConfig = session.config + } catch (err) { + // biome-ignore lint/suspicious/noConsole: intentional error output + console.error( + err instanceof DirectConnectError ? err.message : String(err), + ) + process.exit(1) + } + + const { runConnectHeadless } = await import( + './server/connectHeadless.js' + ) + + const prompt = typeof opts.print === 'string' ? opts.print : '' + const interactive = opts.print === true + await runConnectHeadless( + connectConfig, + prompt, + opts.outputFormat, + interactive, + ) + }, + ) } // claude auth - const auth = program.command('auth').description('Manage authentication').configureHelp(createSortedHelpConfig()); - auth.command('login').description('Sign in to your Anthropic account').option('--email ', 'Pre-populate email address on the login page').option('--sso', 'Force SSO login flow').option('--console', 'Use Anthropic Console (API usage billing) instead of Claude subscription').option('--claudeai', 'Use Claude subscription (default)').action(async ({ - email, - sso, - console: useConsole, - claudeai - }: { - email?: string; - sso?: boolean; - console?: boolean; - claudeai?: boolean; - }) => { - const { - authLogin - } = await import('./cli/handlers/auth.js'); - await authLogin({ - email, - sso, - console: useConsole, - claudeai - }); - }); - auth.command('status').description('Show authentication status').option('--json', 'Output as JSON (default)').option('--text', 'Output as human-readable text').action(async (opts: { - json?: boolean; - text?: boolean; - }) => { - const { - authStatus - } = await import('./cli/handlers/auth.js'); - await authStatus(opts); - }); - auth.command('logout').description('Log out from your Anthropic account').action(async () => { - const { - authLogout - } = await import('./cli/handlers/auth.js'); - await authLogout(); - }); + const auth = program + .command('auth') + .description('Manage authentication') + .configureHelp(createSortedHelpConfig()) + + auth + .command('login') + .description('Sign in to your Anthropic account') + .option('--email ', 'Pre-populate email address on the login page') + .option('--sso', 'Force SSO login flow') + .option( + '--console', + 'Use Anthropic Console (API usage billing) instead of Claude subscription', + ) + .option('--claudeai', 'Use Claude subscription (default)') + .action( + async ({ + email, + sso, + console: useConsole, + claudeai, + }: { + email?: string + sso?: boolean + console?: boolean + claudeai?: boolean + }) => { + const { authLogin } = await import('./cli/handlers/auth.js') + await authLogin({ email, sso, console: useConsole, claudeai }) + }, + ) + + auth + .command('status') + .description('Show authentication status') + .option('--json', 'Output as JSON (default)') + .option('--text', 'Output as human-readable text') + .action(async (opts: { json?: boolean; text?: boolean }) => { + const { authStatus } = await import('./cli/handlers/auth.js') + await authStatus(opts) + }) + + auth + .command('logout') + .description('Log out from your Anthropic account') + .action(async () => { + const { authLogout } = await import('./cli/handlers/auth.js') + await authLogout() + }) /** * Helper function to handle marketplace command errors consistently. @@ -4139,172 +5795,300 @@ async function run(): Promise { * @param action Description of the action that failed */ // Hidden flag on all plugin/marketplace subcommands to target cowork_plugins. - const coworkOption = () => new Option('--cowork', 'Use cowork_plugins directory').hideHelp(); + const coworkOption = () => + new Option('--cowork', 'Use cowork_plugins directory').hideHelp() // Plugin validate command - const pluginCmd = program.command('plugin').alias('plugins').description('Manage Claude Code plugins').configureHelp(createSortedHelpConfig()); - pluginCmd.command('validate ').description('Validate a plugin or marketplace manifest').addOption(coworkOption()).action(async (manifestPath: string, options: { - cowork?: boolean; - }) => { - const { - pluginValidateHandler - } = await import('./cli/handlers/plugins.js'); - await pluginValidateHandler(manifestPath, options); - }); + const pluginCmd = program + .command('plugin') + .alias('plugins') + .description('Manage Claude Code plugins') + .configureHelp(createSortedHelpConfig()) + + pluginCmd + .command('validate ') + .description('Validate a plugin or marketplace manifest') + .addOption(coworkOption()) + .action(async (manifestPath: string, options: { cowork?: boolean }) => { + const { pluginValidateHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginValidateHandler(manifestPath, options) + }) // Plugin list command - pluginCmd.command('list').description('List installed plugins').option('--json', 'Output as JSON').option('--available', 'Include available plugins from marketplaces (requires --json)').addOption(coworkOption()).action(async (options: { - json?: boolean; - available?: boolean; - cowork?: boolean; - }) => { - const { - pluginListHandler - } = await import('./cli/handlers/plugins.js'); - await pluginListHandler(options); - }); + pluginCmd + .command('list') + .description('List installed plugins') + .option('--json', 'Output as JSON') + .option( + '--available', + 'Include available plugins from marketplaces (requires --json)', + ) + .addOption(coworkOption()) + .action( + async (options: { + json?: boolean + available?: boolean + cowork?: boolean + }) => { + const { pluginListHandler } = await import('./cli/handlers/plugins.js') + await pluginListHandler(options) + }, + ) // Marketplace subcommands - const marketplaceCmd = pluginCmd.command('marketplace').description('Manage Claude Code marketplaces').configureHelp(createSortedHelpConfig()); - marketplaceCmd.command('add ').description('Add a marketplace from a URL, path, or GitHub repo').addOption(coworkOption()).option('--sparse ', 'Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins').option('--scope ', 'Where to declare the marketplace: user (default), project, or local').action(async (source: string, options: { - cowork?: boolean; - sparse?: string[]; - scope?: string; - }) => { - const { - marketplaceAddHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceAddHandler(source, options); - }); - marketplaceCmd.command('list').description('List all configured marketplaces').option('--json', 'Output as JSON').addOption(coworkOption()).action(async (options: { - json?: boolean; - cowork?: boolean; - }) => { - const { - marketplaceListHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceListHandler(options); - }); - marketplaceCmd.command('remove ').alias('rm').description('Remove a configured marketplace').addOption(coworkOption()).action(async (name: string, options: { - cowork?: boolean; - }) => { - const { - marketplaceRemoveHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceRemoveHandler(name, options); - }); - marketplaceCmd.command('update [name]').description('Update marketplace(s) from their source - updates all if no name specified').addOption(coworkOption()).action(async (name: string | undefined, options: { - cowork?: boolean; - }) => { - const { - marketplaceUpdateHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceUpdateHandler(name, options); - }); + const marketplaceCmd = pluginCmd + .command('marketplace') + .description('Manage Claude Code marketplaces') + .configureHelp(createSortedHelpConfig()) + + marketplaceCmd + .command('add ') + .description('Add a marketplace from a URL, path, or GitHub repo') + .addOption(coworkOption()) + .option( + '--sparse ', + 'Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins', + ) + .option( + '--scope ', + 'Where to declare the marketplace: user (default), project, or local', + ) + .action( + async ( + source: string, + options: { cowork?: boolean; sparse?: string[]; scope?: string }, + ) => { + const { marketplaceAddHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceAddHandler(source, options) + }, + ) + + marketplaceCmd + .command('list') + .description('List all configured marketplaces') + .option('--json', 'Output as JSON') + .addOption(coworkOption()) + .action(async (options: { json?: boolean; cowork?: boolean }) => { + const { marketplaceListHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceListHandler(options) + }) + + marketplaceCmd + .command('remove ') + .alias('rm') + .description('Remove a configured marketplace') + .addOption(coworkOption()) + .action(async (name: string, options: { cowork?: boolean }) => { + const { marketplaceRemoveHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceRemoveHandler(name, options) + }) + + marketplaceCmd + .command('update [name]') + .description( + 'Update marketplace(s) from their source - updates all if no name specified', + ) + .addOption(coworkOption()) + .action(async (name: string | undefined, options: { cowork?: boolean }) => { + const { marketplaceUpdateHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceUpdateHandler(name, options) + }) // Plugin install command - pluginCmd.command('install ').alias('i').description('Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)').option('-s, --scope ', 'Installation scope: user, project, or local', 'user').addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - }) => { - const { - pluginInstallHandler - } = await import('./cli/handlers/plugins.js'); - await pluginInstallHandler(plugin, options); - }); + pluginCmd + .command('install ') + .alias('i') + .description( + 'Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)', + ) + .option( + '-s, --scope ', + 'Installation scope: user, project, or local', + 'user', + ) + .addOption(coworkOption()) + .action( + async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginInstallHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginInstallHandler(plugin, options) + }, + ) // Plugin uninstall command - pluginCmd.command('uninstall ').alias('remove').alias('rm').description('Uninstall an installed plugin').option('-s, --scope ', 'Uninstall from scope: user, project, or local', 'user').option('--keep-data', "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)").addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - keepData?: boolean; - }) => { - const { - pluginUninstallHandler - } = await import('./cli/handlers/plugins.js'); - await pluginUninstallHandler(plugin, options); - }); + pluginCmd + .command('uninstall ') + .alias('remove') + .alias('rm') + .description('Uninstall an installed plugin') + .option( + '-s, --scope ', + 'Uninstall from scope: user, project, or local', + 'user', + ) + .option( + '--keep-data', + "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)", + ) + .addOption(coworkOption()) + .action( + async ( + plugin: string, + options: { scope?: string; cowork?: boolean; keepData?: boolean }, + ) => { + const { pluginUninstallHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginUninstallHandler(plugin, options) + }, + ) // Plugin enable command - pluginCmd.command('enable ').description('Enable a disabled plugin').option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`).addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - }) => { - const { - pluginEnableHandler - } = await import('./cli/handlers/plugins.js'); - await pluginEnableHandler(plugin, options); - }); + pluginCmd + .command('enable ') + .description('Enable a disabled plugin') + .option( + '-s, --scope ', + `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`, + ) + .addOption(coworkOption()) + .action( + async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginEnableHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginEnableHandler(plugin, options) + }, + ) // Plugin disable command - pluginCmd.command('disable [plugin]').description('Disable an enabled plugin').option('-a, --all', 'Disable all enabled plugins').option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`).addOption(coworkOption()).action(async (plugin: string | undefined, options: { - scope?: string; - cowork?: boolean; - all?: boolean; - }) => { - const { - pluginDisableHandler - } = await import('./cli/handlers/plugins.js'); - await pluginDisableHandler(plugin, options); - }); + pluginCmd + .command('disable [plugin]') + .description('Disable an enabled plugin') + .option('-a, --all', 'Disable all enabled plugins') + .option( + '-s, --scope ', + `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`, + ) + .addOption(coworkOption()) + .action( + async ( + plugin: string | undefined, + options: { scope?: string; cowork?: boolean; all?: boolean }, + ) => { + const { pluginDisableHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginDisableHandler(plugin, options) + }, + ) // Plugin update command - pluginCmd.command('update ').description('Update a plugin to the latest version (restart required to apply)').option('-s, --scope ', `Installation scope: ${VALID_UPDATE_SCOPES.join(', ')} (default: user)`).addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - }) => { - const { - pluginUpdateHandler - } = await import('./cli/handlers/plugins.js'); - await pluginUpdateHandler(plugin, options); - }); + pluginCmd + .command('update ') + .description( + 'Update a plugin to the latest version (restart required to apply)', + ) + .option( + '-s, --scope ', + `Installation scope: ${VALID_UPDATE_SCOPES.join(', ')} (default: user)`, + ) + .addOption(coworkOption()) + .action( + async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginUpdateHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginUpdateHandler(plugin, options) + }, + ) // END ANT-ONLY // Setup token command - program.command('setup-token').description('Set up a long-lived authentication token (requires Claude subscription)').action(async () => { - const [{ - setupTokenHandler - }, { - createRoot - }] = await Promise.all([import('./cli/handlers/util.js'), import('./ink.js')]); - const root = await createRoot(getBaseRenderOptions(false)); - await setupTokenHandler(root); - }); + program + .command('setup-token') + .description( + 'Set up a long-lived authentication token (requires Claude subscription)', + ) + .action(async () => { + const [{ setupTokenHandler }, { createRoot }] = await Promise.all([ + import('./cli/handlers/util.js'), + import('./ink.js'), + ]) + const root = await createRoot(getBaseRenderOptions(false)) + await setupTokenHandler(root) + }) // Agents command - list configured agents - program.command('agents').description('List configured agents').option('--setting-sources ', 'Comma-separated list of setting sources to load (user, project, local).').action(async () => { - const { - agentsHandler - } = await import('./cli/handlers/agents.js'); - await agentsHandler(); - process.exit(0); - }); + program + .command('agents') + .description('List configured agents') + .option( + '--setting-sources ', + 'Comma-separated list of setting sources to load (user, project, local).', + ) + .action(async () => { + const { agentsHandler } = await import('./cli/handlers/agents.js') + await agentsHandler() + process.exit(0) + }) + if (feature('TRANSCRIPT_CLASSIFIER')) { // Skip when tengu_auto_mode_config.enabled === 'disabled' (circuit breaker). // Reads from disk cache — GrowthBook isn't initialized at registration time. if (getAutoModeEnabledStateIfCached() !== 'disabled') { - const autoModeCmd = program.command('auto-mode').description('Inspect auto mode classifier configuration'); - autoModeCmd.command('defaults').description('Print the default auto mode environment, allow, and deny rules as JSON').action(async () => { - const { - autoModeDefaultsHandler - } = await import('./cli/handlers/autoMode.js'); - autoModeDefaultsHandler(); - process.exit(0); - }); - autoModeCmd.command('config').description('Print the effective auto mode config as JSON: your settings where set, defaults otherwise').action(async () => { - const { - autoModeConfigHandler - } = await import('./cli/handlers/autoMode.js'); - autoModeConfigHandler(); - process.exit(0); - }); - autoModeCmd.command('critique').description('Get AI feedback on your custom auto mode rules').option('--model ', 'Override which model is used').action(async options => { - const { - autoModeCritiqueHandler - } = await import('./cli/handlers/autoMode.js'); - await autoModeCritiqueHandler(options); - process.exit(); - }); + const autoModeCmd = program + .command('auto-mode') + .description('Inspect auto mode classifier configuration') + + autoModeCmd + .command('defaults') + .description( + 'Print the default auto mode environment, allow, and deny rules as JSON', + ) + .action(async () => { + const { autoModeDefaultsHandler } = await import( + './cli/handlers/autoMode.js' + ) + autoModeDefaultsHandler() + process.exit(0) + }) + + autoModeCmd + .command('config') + .description( + 'Print the effective auto mode config as JSON: your settings where set, defaults otherwise', + ) + .action(async () => { + const { autoModeConfigHandler } = await import( + './cli/handlers/autoMode.js' + ) + autoModeConfigHandler() + process.exit(0) + }) + + autoModeCmd + .command('critique') + .description('Get AI feedback on your custom auto mode rules') + .option('--model ', 'Override which model is used') + .action(async options => { + const { autoModeCritiqueHandler } = await import( + './cli/handlers/autoMode.js' + ) + await autoModeCritiqueHandler(options) + process.exit() + }) } } @@ -4317,38 +6101,54 @@ async function run(): Promise { // (25ms settings Zod parse + 40ms sync `security` keychain subprocess). // The dynamic visibility never worked; the command was always hidden. if (feature('BRIDGE_MODE')) { - program.command('remote-control', { - hidden: true - }).alias('rc').description('Connect your local environment for remote-control sessions via claude.ai/code').action(async () => { - // Unreachable — cli.tsx fast-path handles this command before main.tsx loads. - // If somehow reached, delegate to bridgeMain. - const { - bridgeMain - } = await import('./bridge/bridgeMain.js'); - await bridgeMain(process.argv.slice(3)); - }); + program + .command('remote-control', { hidden: true }) + .alias('rc') + .description( + 'Connect your local environment for remote-control sessions via claude.ai/code', + ) + .action(async () => { + // Unreachable — cli.tsx fast-path handles this command before main.tsx loads. + // If somehow reached, delegate to bridgeMain. + const { bridgeMain } = await import('./bridge/bridgeMain.js') + await bridgeMain(process.argv.slice(3)) + }) } + if (feature('KAIROS')) { - program.command('assistant [sessionId]').description('Attach the REPL as a client to a running bridge session. Discovers sessions via API if no sessionId given.').action(() => { - // Argv rewriting above should have consumed `assistant [id]` - // before commander runs. Reaching here means a root flag came first - // (e.g. `--debug assistant`) and the position-0 predicate - // didn't match. Print usage like the ssh stub does. - process.stderr.write('Usage: claude assistant [sessionId]\n\n' + 'Attach the REPL as a viewer client to a running bridge session.\n' + 'Omit sessionId to discover and pick from available sessions.\n'); - process.exit(1); - }); + program + .command('assistant [sessionId]') + .description( + 'Attach the REPL as a client to a running bridge session. Discovers sessions via API if no sessionId given.', + ) + .action(() => { + // Argv rewriting above should have consumed `assistant [id]` + // before commander runs. Reaching here means a root flag came first + // (e.g. `--debug assistant`) and the position-0 predicate + // didn't match. Print usage like the ssh stub does. + process.stderr.write( + 'Usage: claude assistant [sessionId]\n\n' + + 'Attach the REPL as a viewer client to a running bridge session.\n' + + 'Omit sessionId to discover and pick from available sessions.\n', + ) + process.exit(1) + }) } // Doctor command - check installation health - program.command('doctor').description('Check the health of your Claude Code auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.').action(async () => { - const [{ - doctorHandler - }, { - createRoot - }] = await Promise.all([import('./cli/handlers/util.js'), import('./ink.js')]); - const root = await createRoot(getBaseRenderOptions(false)); - await doctorHandler(root); - }); + program + .command('doctor') + .description( + 'Check the health of your Claude Code auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.', + ) + .action(async () => { + const [{ doctorHandler }, { createRoot }] = await Promise.all([ + import('./cli/handlers/util.js'), + import('./ink.js'), + ]) + const root = await createRoot(getBaseRenderOptions(false)) + await doctorHandler(root) + }) // claude update // @@ -4356,158 +6156,240 @@ async function run(): Promise { // - We perform exact string comparison (including SHA) to detect any change // - This ensures users always get the latest build, even when only the SHA changes // - UI shows both versions including build metadata for clarity - program.command('update').alias('upgrade').description('Check for updates and install if available').action(async () => { - const { - update - } = await import('src/cli/update.js'); - await update(); - }); + program + .command('update') + .alias('upgrade') + .description('Check for updates and install if available') + .action(async () => { + const { update } = await import('src/cli/update.js') + await update() + }) // claude up — run the project's CLAUDE.md "# claude up" setup instructions. - if ((process.env.USER_TYPE) === 'ant') { - program.command('up').description('[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md').action(async () => { - const { - up - } = await import('src/cli/up.js'); - await up(); - }); + if (process.env.USER_TYPE === 'ant') { + program + .command('up') + .description( + '[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md', + ) + .action(async () => { + const { up } = await import('src/cli/up.js') + await up() + }) } // claude rollback (ant-only) // Rolls back to previous releases - if ((process.env.USER_TYPE) === 'ant') { - program.command('rollback [target]').description('[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version').option('-l, --list', 'List recent published versions with ages').option('--dry-run', 'Show what would be installed without installing').option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)').action(async (target?: string, options?: { - list?: boolean; - dryRun?: boolean; - safe?: boolean; - }) => { - const { - rollback - } = await import('src/cli/rollback.js'); - await rollback(target, options); - }); + if (process.env.USER_TYPE === 'ant') { + program + .command('rollback [target]') + .description( + '[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version', + ) + .option('-l, --list', 'List recent published versions with ages') + .option('--dry-run', 'Show what would be installed without installing') + .option( + '--safe', + 'Roll back to the server-pinned safe version (set by oncall during incidents)', + ) + .action( + async ( + target?: string, + options?: { list?: boolean; dryRun?: boolean; safe?: boolean }, + ) => { + const { rollback } = await import('src/cli/rollback.js') + await rollback(target, options) + }, + ) } // claude install - program.command('install [target]').description('Install Claude Code native build. Use [target] to specify version (stable, latest, or specific version)').option('--force', 'Force installation even if already installed').action(async (target: string | undefined, options: { - force?: boolean; - }) => { - const { - installHandler - } = await import('./cli/handlers/util.js'); - await installHandler(target, options); - }); + program + .command('install [target]') + .description( + 'Install Claude Code native build. Use [target] to specify version (stable, latest, or specific version)', + ) + .option('--force', 'Force installation even if already installed') + .action( + async (target: string | undefined, options: { force?: boolean }) => { + const { installHandler } = await import('./cli/handlers/util.js') + await installHandler(target, options) + }, + ) // ant-only commands - if ((process.env.USER_TYPE) === 'ant') { + if (process.env.USER_TYPE === 'ant') { const validateLogId = (value: string) => { - const maybeSessionId = validateUuid(value); - if (maybeSessionId) return maybeSessionId; - return Number(value); - }; + const maybeSessionId = validateUuid(value) + if (maybeSessionId) return maybeSessionId + return Number(value) + } // claude log - program.command('log').description('[ANT-ONLY] Manage conversation logs.').argument('[number|sessionId]', 'A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log', validateLogId).action(async (logId: string | number | undefined) => { - const { - logHandler - } = await import('./cli/handlers/ant.js'); - await logHandler(logId); - }); + program + .command('log') + .description('[ANT-ONLY] Manage conversation logs.') + .argument( + '[number|sessionId]', + 'A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log', + validateLogId, + ) + .action(async (logId: string | number | undefined) => { + const { logHandler } = await import('./cli/handlers/ant.js') + await logHandler(logId) + }) // claude error - program.command('error').description('[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.').argument('[number]', 'A number (0, 1, 2, etc.) to display a specific log', parseInt).action(async (number: number | undefined) => { - const { - errorHandler - } = await import('./cli/handlers/ant.js'); - await errorHandler(number); - }); + program + .command('error') + .description( + '[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.', + ) + .argument( + '[number]', + 'A number (0, 1, 2, etc.) to display a specific log', + parseInt, + ) + .action(async (number: number | undefined) => { + const { errorHandler } = await import('./cli/handlers/ant.js') + await errorHandler(number) + }) // claude export - program.command('export').description('[ANT-ONLY] Export a conversation to a text file.').usage(' ').argument('', 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file').argument('', 'Output file path for the exported text').addHelpText('after', ` + program + .command('export') + .description('[ANT-ONLY] Export a conversation to a text file.') + .usage(' ') + .argument( + '', + 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file', + ) + .argument('', 'Output file path for the exported text') + .addHelpText( + 'after', + ` Examples: $ claude export 0 conversation.txt Export conversation at log index 0 $ claude export conversation.txt Export conversation by session ID $ claude export input.json output.txt Render JSON log file to text - $ claude export .jsonl output.txt Render JSONL session file to text`).action(async (source: string, outputFile: string) => { - const { - exportHandler - } = await import('./cli/handlers/ant.js'); - await exportHandler(source, outputFile); - }); - if ((process.env.USER_TYPE) === 'ant') { - const taskCmd = program.command('task').description('[ANT-ONLY] Manage task list tasks'); - taskCmd.command('create ').description('Create a new task').option('-d, --description ', 'Task description').option('-l, --list ', 'Task list ID (defaults to "tasklist")').action(async (subject: string, opts: { - description?: string; - list?: string; - }) => { - const { - taskCreateHandler - } = await import('./cli/handlers/ant.js'); - await taskCreateHandler(subject, opts); - }); - taskCmd.command('list').description('List all tasks').option('-l, --list ', 'Task list ID (defaults to "tasklist")').option('--pending', 'Show only pending tasks').option('--json', 'Output as JSON').action(async (opts: { - list?: string; - pending?: boolean; - json?: boolean; - }) => { - const { - taskListHandler - } = await import('./cli/handlers/ant.js'); - await taskListHandler(opts); - }); - taskCmd.command('get ').description('Get details of a task').option('-l, --list ', 'Task list ID (defaults to "tasklist")').action(async (id: string, opts: { - list?: string; - }) => { - const { - taskGetHandler - } = await import('./cli/handlers/ant.js'); - await taskGetHandler(id, opts); - }); - taskCmd.command('update ').description('Update a task').option('-l, --list ', 'Task list ID (defaults to "tasklist")').option('-s, --status ', `Set status (${TASK_STATUSES.join(', ')})`).option('--subject ', 'Update subject').option('-d, --description ', 'Update description').option('--owner ', 'Set owner').option('--clear-owner', 'Clear owner').action(async (id: string, opts: { - list?: string; - status?: string; - subject?: string; - description?: string; - owner?: string; - clearOwner?: boolean; - }) => { - const { - taskUpdateHandler - } = await import('./cli/handlers/ant.js'); - await taskUpdateHandler(id, opts); - }); - taskCmd.command('dir').description('Show the tasks directory path').option('-l, --list ', 'Task list ID (defaults to "tasklist")').action(async (opts: { - list?: string; - }) => { - const { - taskDirHandler - } = await import('./cli/handlers/ant.js'); - await taskDirHandler(opts); - }); + $ claude export .jsonl output.txt Render JSONL session file to text`, + ) + .action(async (source: string, outputFile: string) => { + const { exportHandler } = await import('./cli/handlers/ant.js') + await exportHandler(source, outputFile) + }) + + if (process.env.USER_TYPE === 'ant') { + const taskCmd = program + .command('task') + .description('[ANT-ONLY] Manage task list tasks') + + taskCmd + .command('create ') + .description('Create a new task') + .option('-d, --description ', 'Task description') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action( + async ( + subject: string, + opts: { description?: string; list?: string }, + ) => { + const { taskCreateHandler } = await import('./cli/handlers/ant.js') + await taskCreateHandler(subject, opts) + }, + ) + + taskCmd + .command('list') + .description('List all tasks') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option('--pending', 'Show only pending tasks') + .option('--json', 'Output as JSON') + .action( + async (opts: { + list?: string + pending?: boolean + json?: boolean + }) => { + const { taskListHandler } = await import('./cli/handlers/ant.js') + await taskListHandler(opts) + }, + ) + + taskCmd + .command('get ') + .description('Get details of a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (id: string, opts: { list?: string }) => { + const { taskGetHandler } = await import('./cli/handlers/ant.js') + await taskGetHandler(id, opts) + }) + + taskCmd + .command('update ') + .description('Update a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option( + '-s, --status ', + `Set status (${TASK_STATUSES.join(', ')})`, + ) + .option('--subject ', 'Update subject') + .option('-d, --description ', 'Update description') + .option('--owner ', 'Set owner') + .option('--clear-owner', 'Clear owner') + .action( + async ( + id: string, + opts: { + list?: string + status?: string + subject?: string + description?: string + owner?: string + clearOwner?: boolean + }, + ) => { + const { taskUpdateHandler } = await import('./cli/handlers/ant.js') + await taskUpdateHandler(id, opts) + }, + ) + + taskCmd + .command('dir') + .description('Show the tasks directory path') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (opts: { list?: string }) => { + const { taskDirHandler } = await import('./cli/handlers/ant.js') + await taskDirHandler(opts) + }) } // claude completion - program.command('completion ', { - hidden: true - }).description('Generate shell completion script (bash, zsh, or fish)').option('--output ', 'Write completion script directly to a file instead of stdout').action(async (shell: string, opts: { - output?: string; - }) => { - const { - completionHandler - } = await import('./cli/handlers/ant.js'); - await completionHandler(shell, opts, program); - }); + program + .command('completion ', { hidden: true }) + .description('Generate shell completion script (bash, zsh, or fish)') + .option( + '--output ', + 'Write completion script directly to a file instead of stdout', + ) + .action(async (shell: string, opts: { output?: string }) => { + const { completionHandler } = await import('./cli/handlers/ant.js') + await completionHandler(shell, opts, program) + }) } - profileCheckpoint('run_before_parse'); - await program.parseAsync(process.argv); - profileCheckpoint('run_after_parse'); + + profileCheckpoint('run_before_parse') + await program.parseAsync(process.argv) + profileCheckpoint('run_after_parse') // Record final checkpoint for total_time calculation - profileCheckpoint('main_after_run'); + profileCheckpoint('main_after_run') // Log startup perf to Statsig (sampled) and output detailed report if enabled - profileReport(); - return program; + profileReport() + + return program } + async function logTenguInit({ hasInitialPrompt, hasStdin, @@ -4530,99 +6412,120 @@ async function logTenguInit({ systemPromptFlag, appendSystemPromptFlag, thinkingConfig, - assistantActivationPath + assistantActivationPath, }: { - hasInitialPrompt: boolean; - hasStdin: boolean; - verbose: boolean; - debug: boolean; - debugToStderr: boolean; - print: boolean; - outputFormat: string; - inputFormat: string; - numAllowedTools: number; - numDisallowedTools: number; - mcpClientCount: number; - worktreeEnabled: boolean; - skipWebFetchPreflight: boolean | undefined; - githubActionInputs: string | undefined; - dangerouslySkipPermissionsPassed: boolean; - permissionMode: string; - modeIsBypass: boolean; - allowDangerouslySkipPermissionsPassed: boolean; - systemPromptFlag: 'file' | 'flag' | undefined; - appendSystemPromptFlag: 'file' | 'flag' | undefined; - thinkingConfig: ThinkingConfig; - assistantActivationPath: string | undefined; + hasInitialPrompt: boolean + hasStdin: boolean + verbose: boolean + debug: boolean + debugToStderr: boolean + print: boolean + outputFormat: string + inputFormat: string + numAllowedTools: number + numDisallowedTools: number + mcpClientCount: number + worktreeEnabled: boolean + skipWebFetchPreflight: boolean | undefined + githubActionInputs: string | undefined + dangerouslySkipPermissionsPassed: boolean + permissionMode: string + modeIsBypass: boolean + allowDangerouslySkipPermissionsPassed: boolean + systemPromptFlag: 'file' | 'flag' | undefined + appendSystemPromptFlag: 'file' | 'flag' | undefined + thinkingConfig: ThinkingConfig + assistantActivationPath: string | undefined }): Promise { try { logEvent('tengu_init', { - entrypoint: 'claude' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: + 'claude' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, hasInitialPrompt, hasStdin, verbose, debug, debugToStderr, print, - outputFormat: outputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - inputFormat: inputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outputFormat: + outputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + inputFormat: + inputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, numAllowedTools, numDisallowedTools, mcpClientCount, worktree: worktreeEnabled, skipWebFetchPreflight, ...(githubActionInputs && { - githubActionInputs: githubActionInputs as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + githubActionInputs: + githubActionInputs as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), dangerouslySkipPermissionsPassed, - permissionMode: permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + permissionMode: + permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, modeIsBypass, inProtectedNamespace: isInProtectedNamespace(), allowDangerouslySkipPermissionsPassed, - thinkingType: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + thinkingType: + thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(systemPromptFlag && { - systemPromptFlag: systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + systemPromptFlag: + systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(appendSystemPromptFlag && { - appendSystemPromptFlag: appendSystemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + appendSystemPromptFlag: + appendSystemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), is_simple: isBareMode() || undefined, - is_coordinator: feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode() ? true : undefined, + is_coordinator: + feature('COORDINATOR_MODE') && + coordinatorModeModule?.isCoordinatorMode() + ? true + : undefined, ...(assistantActivationPath && { - assistantActivationPath: assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + assistantActivationPath: + assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), - autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...((process.env.USER_TYPE) === 'ant' ? (() => { - const cwd = getCwd(); - const gitRoot = findGitRoot(cwd); - const rp = gitRoot ? relative(gitRoot, cwd) || '.' : undefined; - return rp ? { - relativeProjectPath: rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - } : {}; - })() : {}) - }); + autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? + 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(process.env.USER_TYPE === 'ant' + ? (() => { + const cwd = getCwd() + const gitRoot = findGitRoot(cwd) + const rp = gitRoot ? relative(gitRoot, cwd) || '.' : undefined + return rp + ? { + relativeProjectPath: + rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {} + })() + : {}), + }) } catch (error) { - logError(error); + logError(error) } } + function maybeActivateProactive(options: unknown): void { - if ((feature('PROACTIVE') || feature('KAIROS')) && ((options as { - proactive?: boolean; - }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE))) { + if ( + (feature('PROACTIVE') || feature('KAIROS')) && + ((options as { proactive?: boolean }).proactive || + isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) + ) { // eslint-disable-next-line @typescript-eslint/no-require-imports - const proactiveModule = require('./proactive/index.js'); + const proactiveModule = require('./proactive/index.js') if (!proactiveModule.isProactiveActive()) { - proactiveModule.activateProactive('command'); + proactiveModule.activateProactive('command') } } } + function maybeActivateBrief(options: unknown): void { - if (!(feature('KAIROS') || feature('KAIROS_BRIEF'))) return; - const briefFlag = (options as { - brief?: boolean; - }).brief; - const briefEnv = isEnvTruthy(process.env.CLAUDE_CODE_BRIEF); - if (!briefFlag && !briefEnv) return; + if (!(feature('KAIROS') || feature('KAIROS_BRIEF'))) return + const briefFlag = (options as { brief?: boolean }).brief + const briefEnv = isEnvTruthy(process.env.CLAUDE_CODE_BRIEF) + if (!briefFlag && !briefEnv) return // --brief / CLAUDE_CODE_BRIEF are explicit opt-ins: check entitlement, // then set userMsgOptIn to activate the tool + prompt section. The env // var also grants entitlement (isBriefEntitled() reads it), so setting @@ -4631,50 +6534,70 @@ function maybeActivateBrief(options: unknown): void { // Conditional require: static import would leak the tool name string // into external builds via BriefTool.ts → prompt.ts. /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEntitled - } = require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js'); + const { isBriefEntitled } = + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ - const entitled = isBriefEntitled(); + const entitled = isBriefEntitled() if (entitled) { - setUserMsgOptIn(true); + setUserMsgOptIn(true) } // Fire unconditionally once intent is seen: enabled=false captures the // "user tried but was gated" failure mode in Datadog. logEvent('tengu_brief_mode_enabled', { enabled: entitled, gated: !entitled, - source: (briefEnv ? 'env' : 'flag') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + source: (briefEnv + ? 'env' + : 'flag') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } + function resetCursor() { - const terminal = process.stderr.isTTY ? process.stderr : process.stdout.isTTY ? process.stdout : undefined; - terminal?.write(SHOW_CURSOR); + const terminal = process.stderr.isTTY + ? process.stderr + : process.stdout.isTTY + ? process.stdout + : undefined + terminal?.write(SHOW_CURSOR) } + type TeammateOptions = { - agentId?: string; - agentName?: string; - teamName?: string; - agentColor?: string; - planModeRequired?: boolean; - parentSessionId?: string; - teammateMode?: 'auto' | 'tmux' | 'in-process'; - agentType?: string; -}; + agentId?: string + agentName?: string + teamName?: string + agentColor?: string + planModeRequired?: boolean + parentSessionId?: string + teammateMode?: 'auto' | 'tmux' | 'in-process' + agentType?: string +} + function extractTeammateOptions(options: unknown): TeammateOptions { if (typeof options !== 'object' || options === null) { - return {}; + return {} } - const opts = options as Record; - const teammateMode = opts.teammateMode; + const opts = options as Record + const teammateMode = opts.teammateMode return { agentId: typeof opts.agentId === 'string' ? opts.agentId : undefined, agentName: typeof opts.agentName === 'string' ? opts.agentName : undefined, teamName: typeof opts.teamName === 'string' ? opts.teamName : undefined, - agentColor: typeof opts.agentColor === 'string' ? opts.agentColor : undefined, - planModeRequired: typeof opts.planModeRequired === 'boolean' ? opts.planModeRequired : undefined, - parentSessionId: typeof opts.parentSessionId === 'string' ? opts.parentSessionId : undefined, - teammateMode: teammateMode === 'auto' || teammateMode === 'tmux' || teammateMode === 'in-process' ? teammateMode : undefined, - agentType: typeof opts.agentType === 'string' ? opts.agentType : undefined - }; + agentColor: + typeof opts.agentColor === 'string' ? opts.agentColor : undefined, + planModeRequired: + typeof opts.planModeRequired === 'boolean' + ? opts.planModeRequired + : undefined, + parentSessionId: + typeof opts.parentSessionId === 'string' + ? opts.parentSessionId + : undefined, + teammateMode: + teammateMode === 'auto' || + teammateMode === 'tmux' || + teammateMode === 'in-process' + ? teammateMode + : undefined, + agentType: typeof opts.agentType === 'string' ? opts.agentType : undefined, + } } diff --git a/src/moreright/useMoreRight.tsx b/src/moreright/useMoreRight.tsx index fab6fa2a6..fb605d9e3 100644 --- a/src/moreright/useMoreRight.tsx +++ b/src/moreright/useMoreRight.tsx @@ -5,21 +5,22 @@ // would resolve to scripts/external-stubs/src/types/ (doesn't exist). // eslint-disable-next-line @typescript-eslint/no-explicit-any -type M = any; +type M = any + export function useMoreRight(_args: { - enabled: boolean; - setMessages: (action: M[] | ((prev: M[]) => M[])) => void; - inputValue: string; - setInputValue: (s: string) => void; - setToolJSX: (args: M) => void; + enabled: boolean + setMessages: (action: M[] | ((prev: M[]) => M[])) => void + inputValue: string + setInputValue: (s: string) => void + setToolJSX: (args: M) => void }): { - onBeforeQuery: (input: string, all: M[], n: number) => Promise; - onTurnComplete: (all: M[], aborted: boolean) => Promise; - render: () => null; + onBeforeQuery: (input: string, all: M[], n: number) => Promise + onTurnComplete: (all: M[], aborted: boolean) => Promise + render: () => null } { return { onBeforeQuery: async () => true, onTurnComplete: async () => {}, - render: () => null - }; + render: () => null, + } } diff --git a/src/replLauncher.tsx b/src/replLauncher.tsx index 2738d8a00..664e95839 100644 --- a/src/replLauncher.tsx +++ b/src/replLauncher.tsx @@ -1,22 +1,28 @@ -import React from 'react'; -import type { StatsStore } from './context/stats.js'; -import type { Root } from './ink.js'; -import type { Props as REPLProps } from './screens/REPL.js'; -import type { AppState } from './state/AppStateStore.js'; -import type { FpsMetrics } from './utils/fpsTracker.js'; +import React from 'react' +import type { StatsStore } from './context/stats.js' +import type { Root } from './ink.js' +import type { Props as REPLProps } from './screens/REPL.js' +import type { AppState } from './state/AppStateStore.js' +import type { FpsMetrics } from './utils/fpsTracker.js' + type AppWrapperProps = { - getFpsMetrics: () => FpsMetrics | undefined; - stats?: StatsStore; - initialState: AppState; -}; -export async function launchRepl(root: Root, appProps: AppWrapperProps, replProps: REPLProps, renderAndRun: (root: Root, element: React.ReactNode) => Promise): Promise { - const { - App - } = await import('./components/App.js'); - const { - REPL - } = await import('./screens/REPL.js'); - await renderAndRun(root, + getFpsMetrics: () => FpsMetrics | undefined + stats?: StatsStore + initialState: AppState +} + +export async function launchRepl( + root: Root, + appProps: AppWrapperProps, + replProps: REPLProps, + renderAndRun: (root: Root, element: React.ReactNode) => Promise, +): Promise { + const { App } = await import('./components/App.js') + const { REPL } = await import('./screens/REPL.js') + await renderAndRun( + root, + - ); + , + ) } diff --git a/src/screens/Doctor.tsx b/src/screens/Doctor.tsx index eb511c063..d8de3714a 100644 --- a/src/screens/Doctor.tsx +++ b/src/screens/Doctor.tsx @@ -1,574 +1,516 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import { join } from 'path'; -import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react'; -import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js'; -import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js'; -import { getModelMaxOutputTokens } from 'src/utils/context.js'; -import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import { getOriginalCwd } from '../bootstrap/state.js'; -import type { CommandResultDisplay } from '../commands.js'; -import { Pane } from '../components/design-system/Pane.js'; -import { PressEnterToContinue } from '../components/PressEnterToContinue.js'; -import { SandboxDoctorSection } from '../components/sandbox/SandboxDoctorSection.js'; -import { ValidationErrorsList } from '../components/ValidationErrorsList.js'; -import { useSettingsErrors } from '../hooks/notifs/useSettingsErrors.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useAppState } from '../state/AppState.js'; -import { getPluginErrorMessage } from '../types/plugin.js'; -import { getGcsDistTags, getNpmDistTags, type NpmDistTags } from '../utils/autoUpdater.js'; -import { type ContextWarnings, checkContextWarnings } from '../utils/doctorContextWarnings.js'; -import { type DiagnosticInfo, getDoctorDiagnostic } from '../utils/doctorDiagnostic.js'; -import { validateBoundedIntEnvVar } from '../utils/envValidation.js'; -import { pathExists } from '../utils/file.js'; -import { cleanupStaleLocks, getAllLockInfo, isPidBasedLockingEnabled, type LockInfo } from '../utils/nativeInstaller/pidLock.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; -import { BASH_MAX_OUTPUT_DEFAULT, BASH_MAX_OUTPUT_UPPER_LIMIT } from '../utils/shell/outputLimits.js'; -import { TASK_MAX_OUTPUT_DEFAULT, TASK_MAX_OUTPUT_UPPER_LIMIT } from '../utils/task/outputFormatting.js'; -import { getXDGStateHome } from '../utils/xdg.js'; +import figures from 'figures' +import { join } from 'path' +import React, { + Suspense, + use, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js' +import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js' +import { getModelMaxOutputTokens } from 'src/utils/context.js' +import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js' +import type { SettingSource } from 'src/utils/settings/constants.js' +import { getOriginalCwd } from '../bootstrap/state.js' +import type { CommandResultDisplay } from '../commands.js' +import { Pane } from '../components/design-system/Pane.js' +import { PressEnterToContinue } from '../components/PressEnterToContinue.js' +import { SandboxDoctorSection } from '../components/sandbox/SandboxDoctorSection.js' +import { ValidationErrorsList } from '../components/ValidationErrorsList.js' +import { useSettingsErrors } from '../hooks/notifs/useSettingsErrors.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useAppState } from '../state/AppState.js' +import { getPluginErrorMessage } from '../types/plugin.js' +import { + getGcsDistTags, + getNpmDistTags, + type NpmDistTags, +} from '../utils/autoUpdater.js' +import { + type ContextWarnings, + checkContextWarnings, +} from '../utils/doctorContextWarnings.js' +import { + type DiagnosticInfo, + getDoctorDiagnostic, +} from '../utils/doctorDiagnostic.js' +import { validateBoundedIntEnvVar } from '../utils/envValidation.js' +import { pathExists } from '../utils/file.js' +import { + cleanupStaleLocks, + getAllLockInfo, + isPidBasedLockingEnabled, + type LockInfo, +} from '../utils/nativeInstaller/pidLock.js' +import { getInitialSettings } from '../utils/settings/settings.js' +import { + BASH_MAX_OUTPUT_DEFAULT, + BASH_MAX_OUTPUT_UPPER_LIMIT, +} from '../utils/shell/outputLimits.js' +import { + TASK_MAX_OUTPUT_DEFAULT, + TASK_MAX_OUTPUT_UPPER_LIMIT, +} from '../utils/task/outputFormatting.js' +import { getXDGStateHome } from '../utils/xdg.js' + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + type AgentInfo = { activeAgents: Array<{ - agentType: string; - source: SettingSource | 'built-in' | 'plugin'; - }>; - userAgentsDir: string; - projectAgentsDir: string; - userDirExists: boolean; - projectDirExists: boolean; - failedFiles?: Array<{ - path: string; - error: string; - }>; -}; + agentType: string + source: SettingSource | 'built-in' | 'plugin' + }> + userAgentsDir: string + projectAgentsDir: string + userDirExists: boolean + projectDirExists: boolean + failedFiles?: Array<{ path: string; error: string }> +} + type VersionLockInfo = { - enabled: boolean; - locks: LockInfo[]; - locksDir: string; - staleLocksCleaned: number; -}; -function DistTagsDisplay(t0: { promise: Promise }) { - const $ = _c(8); - const { - promise - } = t0; - const distTags = use(promise) as NpmDistTags; + enabled: boolean + locks: LockInfo[] + locksDir: string + staleLocksCleaned: number +} + +function DistTagsDisplay({ + promise, +}: { + promise: Promise +}): React.ReactNode { + const distTags = use(promise) if (!distTags.latest) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = └ Failed to fetch versions; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - let t1; - if ($[1] !== distTags.stable) { - t1 = distTags.stable && └ Stable version: {distTags.stable}; - $[1] = distTags.stable; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== distTags.latest) { - t2 = └ Latest version: {distTags.latest}; - $[3] = distTags.latest; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== t1 || $[6] !== t2) { - t3 = <>{t1}{t2}; - $[5] = t1; - $[6] = t2; - $[7] = t3; - } else { - t3 = $[7]; - } - return t3; + return └ Failed to fetch versions + } + return ( + <> + {distTags.stable && └ Stable version: {distTags.stable}} + └ Latest version: {distTags.latest} + + ) } -export function Doctor(t0) { - const $ = _c(84); - const { - onDone - } = t0; - const agentDefinitions = useAppState(_temp); - const mcpTools = useAppState(_temp2); - const toolPermissionContext = useAppState(_temp3); - const pluginsErrors = useAppState(_temp4); - useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] !== mcpTools) { - t1 = mcpTools || []; - $[0] = mcpTools; - $[1] = t1; - } else { - t1 = $[1]; - } - const tools = t1; - const [diagnostic, setDiagnostic] = useState(null); - const [agentInfo, setAgentInfo] = useState(null); - const [contextWarnings, setContextWarnings] = useState(null); - const [versionLockInfo, setVersionLockInfo] = useState(null); - const validationErrors = useSettingsErrors(); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getDoctorDiagnostic().then(_temp6); - $[2] = t2; - } else { - t2 = $[2]; - } - const distTagsPromise = t2; - const autoUpdatesChannel = getInitialSettings()?.autoUpdatesChannel ?? "latest"; - let t3; - if ($[3] !== validationErrors) { - t3 = validationErrors.filter(_temp7); - $[3] = validationErrors; - $[4] = t3; - } else { - t3 = $[4]; - } - const errorsExcludingMcp = t3; - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - const envVars = [{ - name: "BASH_MAX_OUTPUT_LENGTH", - default: BASH_MAX_OUTPUT_DEFAULT, - upperLimit: BASH_MAX_OUTPUT_UPPER_LIMIT - }, { - name: "TASK_MAX_OUTPUT_LENGTH", - default: TASK_MAX_OUTPUT_DEFAULT, - upperLimit: TASK_MAX_OUTPUT_UPPER_LIMIT - }, { - name: "CLAUDE_CODE_MAX_OUTPUT_TOKENS", - ...getModelMaxOutputTokens("claude-opus-4-6") - }]; - t4 = envVars.map(_temp8).filter(_temp9); - $[5] = t4; - } else { - t4 = $[5]; - } - const envValidationErrors = t4; - let t5; - let t6; - if ($[6] !== agentDefinitions || $[7] !== toolPermissionContext || $[8] !== tools) { - t5 = () => { - getDoctorDiagnostic().then(setDiagnostic); - (async () => { - const userAgentsDir = join(getClaudeConfigHomeDir(), "agents"); - const projectAgentsDir = join(getOriginalCwd(), ".claude", "agents"); - const { - activeAgents, - allAgents, - failedFiles - } = agentDefinitions; - const [userDirExists, projectDirExists] = await Promise.all([pathExists(userAgentsDir), pathExists(projectAgentsDir)]); - const agentInfoData = { - activeAgents: activeAgents.map(_temp0), - userAgentsDir, - projectAgentsDir, - userDirExists, - projectDirExists, - failedFiles - }; - setAgentInfo(agentInfoData); - const warnings = await checkContextWarnings(tools, { + +export function Doctor({ onDone }: Props): React.ReactNode { + const agentDefinitions = useAppState(s => s.agentDefinitions) + const mcpTools = useAppState(s => s.mcp.tools) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const pluginsErrors = useAppState(s => s.plugins.errors) + useExitOnCtrlCDWithKeybindings() + + const tools = useMemo(() => { + return mcpTools || [] + }, [mcpTools]) + + const [diagnostic, setDiagnostic] = useState(null) + const [agentInfo, setAgentInfo] = useState(null) + const [contextWarnings, setContextWarnings] = + useState(null) + const [versionLockInfo, setVersionLockInfo] = + useState(null) + const validationErrors = useSettingsErrors() + + // Create promise once for dist-tags fetch (depends on diagnostic) + const distTagsPromise = useMemo( + () => + getDoctorDiagnostic().then(diag => { + const fetchDistTags = + diag.installationType === 'native' ? getGcsDistTags : getNpmDistTags + return fetchDistTags().catch(() => ({ latest: null, stable: null })) + }), + [], + ) + const autoUpdatesChannel = + getInitialSettings()?.autoUpdatesChannel ?? 'latest' + + const errorsExcludingMcp = validationErrors.filter( + error => error.mcpErrorMetadata === undefined, + ) + + const envValidationErrors = useMemo(() => { + const envVars = [ + { + name: 'BASH_MAX_OUTPUT_LENGTH', + default: BASH_MAX_OUTPUT_DEFAULT, + upperLimit: BASH_MAX_OUTPUT_UPPER_LIMIT, + }, + { + name: 'TASK_MAX_OUTPUT_LENGTH', + default: TASK_MAX_OUTPUT_DEFAULT, + upperLimit: TASK_MAX_OUTPUT_UPPER_LIMIT, + }, + { + name: 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', + // Check for values against the latest supported model + ...getModelMaxOutputTokens('claude-opus-4-6'), + }, + ] + return envVars + .map(v => { + const value = process.env[v.name] + const result = validateBoundedIntEnvVar( + v.name, + value, + v.default, + v.upperLimit, + ) + return { name: v.name, ...result } + }) + .filter(v => v.status !== 'valid') + }, []) + + useEffect(() => { + void getDoctorDiagnostic().then(setDiagnostic) + + void (async () => { + const userAgentsDir = join(getClaudeConfigHomeDir(), 'agents') + const projectAgentsDir = join(getOriginalCwd(), '.claude', 'agents') + + const { activeAgents, allAgents, failedFiles } = agentDefinitions + + const [userDirExists, projectDirExists] = await Promise.all([ + pathExists(userAgentsDir), + pathExists(projectAgentsDir), + ]) + + const agentInfoData = { + activeAgents: activeAgents.map(a => ({ + agentType: a.agentType, + source: a.source, + })), + userAgentsDir, + projectAgentsDir, + userDirExists, + projectDirExists, + failedFiles, + } + setAgentInfo(agentInfoData) + + const warnings = await checkContextWarnings( + tools, + { activeAgents, allAgents, - failedFiles - }, async () => toolPermissionContext); - setContextWarnings(warnings); - if (isPidBasedLockingEnabled()) { - const locksDir = join(getXDGStateHome(), "claude", "locks"); - const staleLocksCleaned = cleanupStaleLocks(locksDir); - const locks = getAllLockInfo(locksDir); - setVersionLockInfo({ - enabled: true, - locks, - locksDir, - staleLocksCleaned - }); - } else { - setVersionLockInfo({ - enabled: false, - locks: [], - locksDir: "", - staleLocksCleaned: 0 - }); - } - })(); - }; - t6 = [toolPermissionContext, tools, agentDefinitions]; - $[6] = agentDefinitions; - $[7] = toolPermissionContext; - $[8] = tools; - $[9] = t5; - $[10] = t6; - } else { - t5 = $[9]; - t6 = $[10]; - } - useEffect(t5, t6); - let t7; - if ($[11] !== onDone) { - t7 = () => { - onDone("Claude Code diagnostics dismissed", { - display: "system" - }); - }; - $[11] = onDone; - $[12] = t7; - } else { - t7 = $[12]; - } - const handleDismiss = t7; - let t8; - if ($[13] !== handleDismiss) { - t8 = { - "confirm:yes": handleDismiss, - "confirm:no": handleDismiss - }; - $[13] = handleDismiss; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t9 = { - context: "Confirmation" - }; - $[15] = t9; - } else { - t9 = $[15]; - } - useKeybindings(t8, t9); + failedFiles, + }, + async () => toolPermissionContext, + ) + setContextWarnings(warnings) + + // Fetch version lock info if PID-based locking is enabled + if (isPidBasedLockingEnabled()) { + const locksDir = join(getXDGStateHome(), 'claude', 'locks') + const staleLocksCleaned = cleanupStaleLocks(locksDir) + const locks = getAllLockInfo(locksDir) + setVersionLockInfo({ + enabled: true, + locks, + locksDir, + staleLocksCleaned, + }) + } else { + setVersionLockInfo({ + enabled: false, + locks: [], + locksDir: '', + staleLocksCleaned: 0, + }) + } + })() + }, [toolPermissionContext, tools, agentDefinitions]) + + const handleDismiss = useCallback(() => { + onDone('Claude Code diagnostics dismissed', { display: 'system' }) + }, [onDone]) + + // Handle dismiss via keybindings (Enter, Escape, or Ctrl+C) + useKeybindings( + { + 'confirm:yes': handleDismiss, + 'confirm:no': handleDismiss, + }, + { context: 'Confirmation' }, + ) + + // Loading state if (!diagnostic) { - let t10; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Checking installation status…; - $[16] = t10; - } else { - t10 = $[16]; - } - return t10; - } - let t10; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Diagnostics; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== diagnostic.installationType || $[19] !== diagnostic.version) { - t11 = └ Currently running: {diagnostic.installationType} ({diagnostic.version}); - $[18] = diagnostic.installationType; - $[19] = diagnostic.version; - $[20] = t11; - } else { - t11 = $[20]; - } - let t12; - if ($[21] !== diagnostic.packageManager) { - t12 = diagnostic.packageManager && └ Package manager: {diagnostic.packageManager}; - $[21] = diagnostic.packageManager; - $[22] = t12; - } else { - t12 = $[22]; - } - let t13; - if ($[23] !== diagnostic.installationPath) { - t13 = └ Path: {diagnostic.installationPath}; - $[23] = diagnostic.installationPath; - $[24] = t13; - } else { - t13 = $[24]; - } - let t14; - if ($[25] !== diagnostic.invokedBinary) { - t14 = └ Invoked: {diagnostic.invokedBinary}; - $[25] = diagnostic.invokedBinary; - $[26] = t14; - } else { - t14 = $[26]; - } - let t15; - if ($[27] !== diagnostic.configInstallMethod) { - t15 = └ Config install method: {diagnostic.configInstallMethod}; - $[27] = diagnostic.configInstallMethod; - $[28] = t15; - } else { - t15 = $[28]; - } - const t16 = diagnostic.ripgrepStatus.working ? "OK" : "Not working"; - const t17 = diagnostic.ripgrepStatus.mode === "embedded" ? "bundled" : diagnostic.ripgrepStatus.mode === "builtin" ? "vendor" : diagnostic.ripgrepStatus.systemPath || "system"; - let t18; - if ($[29] !== t16 || $[30] !== t17) { - t18 = └ Search: {t16} ({t17}); - $[29] = t16; - $[30] = t17; - $[31] = t18; - } else { - t18 = $[31]; - } - let t19; - if ($[32] !== diagnostic.recommendation) { - t19 = diagnostic.recommendation && <>Recommendation: {diagnostic.recommendation.split("\n")[0]}{diagnostic.recommendation.split("\n")[1]}; - $[32] = diagnostic.recommendation; - $[33] = t19; - } else { - t19 = $[33]; - } - let t20; - if ($[34] !== diagnostic.multipleInstallations) { - t20 = diagnostic.multipleInstallations.length > 1 && <>Warning: Multiple installations found{diagnostic.multipleInstallations.map(_temp1)}; - $[34] = diagnostic.multipleInstallations; - $[35] = t20; - } else { - t20 = $[35]; - } - let t21; - if ($[36] !== diagnostic.warnings) { - t21 = diagnostic.warnings.length > 0 && <>{diagnostic.warnings.map(_temp10)}; - $[36] = diagnostic.warnings; - $[37] = t21; - } else { - t21 = $[37]; - } - let t22; - if ($[38] !== errorsExcludingMcp) { - t22 = errorsExcludingMcp.length > 0 && Invalid Settings; - $[38] = errorsExcludingMcp; - $[39] = t22; - } else { - t22 = $[39]; - } - let t23; - if ($[40] !== t11 || $[41] !== t12 || $[42] !== t13 || $[43] !== t14 || $[44] !== t15 || $[45] !== t18 || $[46] !== t19 || $[47] !== t20 || $[48] !== t21 || $[49] !== t22) { - t23 = {t10}{t11}{t12}{t13}{t14}{t15}{t18}{t19}{t20}{t21}{t22}; - $[40] = t11; - $[41] = t12; - $[42] = t13; - $[43] = t14; - $[44] = t15; - $[45] = t18; - $[46] = t19; - $[47] = t20; - $[48] = t21; - $[49] = t22; - $[50] = t23; - } else { - t23 = $[50]; - } - let t24; - if ($[51] === Symbol.for("react.memo_cache_sentinel")) { - t24 = Updates; - $[51] = t24; - } else { - t24 = $[51]; - } - const t25 = diagnostic.packageManager ? "Managed by package manager" : diagnostic.autoUpdates; - let t26; - if ($[52] !== t25) { - t26 = └ Auto-updates:{" "}{t25}; - $[52] = t25; - $[53] = t26; - } else { - t26 = $[53]; - } - let t27; - if ($[54] !== diagnostic.hasUpdatePermissions) { - t27 = diagnostic.hasUpdatePermissions !== null && └ Update permissions:{" "}{diagnostic.hasUpdatePermissions ? "Yes" : "No (requires sudo)"}; - $[54] = diagnostic.hasUpdatePermissions; - $[55] = t27; - } else { - t27 = $[55]; - } - let t28; - if ($[56] === Symbol.for("react.memo_cache_sentinel")) { - t28 = └ Auto-update channel: {autoUpdatesChannel}; - $[56] = t28; - } else { - t28 = $[56]; - } - let t29; - if ($[57] === Symbol.for("react.memo_cache_sentinel")) { - t29 = ; - $[57] = t29; - } else { - t29 = $[57]; - } - let t30; - if ($[58] !== t26 || $[59] !== t27) { - t30 = {t24}{t26}{t27}{t28}{t29}; - $[58] = t26; - $[59] = t27; - $[60] = t30; - } else { - t30 = $[60]; - } - let t31; - let t32; - let t33; - let t34; - if ($[61] === Symbol.for("react.memo_cache_sentinel")) { - t31 = ; - t32 = ; - t33 = ; - t34 = envValidationErrors.length > 0 && Environment Variables{envValidationErrors.map(_temp11)}; - $[61] = t31; - $[62] = t32; - $[63] = t33; - $[64] = t34; - } else { - t31 = $[61]; - t32 = $[62]; - t33 = $[63]; - t34 = $[64]; - } - let t35; - if ($[65] !== versionLockInfo) { - t35 = versionLockInfo?.enabled && Version Locks{versionLockInfo.staleLocksCleaned > 0 && └ Cleaned {versionLockInfo.staleLocksCleaned} stale lock(s)}{versionLockInfo.locks.length === 0 ? └ No active version locks : versionLockInfo.locks.map(_temp12)}; - $[65] = versionLockInfo; - $[66] = t35; - } else { - t35 = $[66]; - } - let t36; - if ($[67] !== agentInfo) { - t36 = agentInfo?.failedFiles && agentInfo.failedFiles.length > 0 && Agent Parse Errors└ Failed to parse {agentInfo.failedFiles.length} agent file(s):{agentInfo.failedFiles.map(_temp13)}; - $[67] = agentInfo; - $[68] = t36; - } else { - t36 = $[68]; - } - let t37; - if ($[69] !== pluginsErrors) { - t37 = pluginsErrors.length > 0 && Plugin Errors└ {pluginsErrors.length} plugin error(s) detected:{pluginsErrors.map(_temp14)}; - $[69] = pluginsErrors; - $[70] = t37; - } else { - t37 = $[70]; - } - let t38; - if ($[71] !== contextWarnings) { - t38 = contextWarnings?.unreachableRulesWarning && Unreachable Permission Rules└{" "}{figures.warning}{" "}{contextWarnings.unreachableRulesWarning.message}{contextWarnings.unreachableRulesWarning.details.map(_temp15)}; - $[71] = contextWarnings; - $[72] = t38; - } else { - t38 = $[72]; - } - let t39; - if ($[73] !== contextWarnings) { - t39 = contextWarnings && (contextWarnings.claudeMdWarning || contextWarnings.agentWarning || contextWarnings.mcpWarning) && Context Usage Warnings{contextWarnings.claudeMdWarning && <>└{" "}{figures.warning} {contextWarnings.claudeMdWarning.message}{" "}└ Files:{contextWarnings.claudeMdWarning.details.map(_temp16)}}{contextWarnings.agentWarning && <>└{" "}{figures.warning} {contextWarnings.agentWarning.message}{" "}└ Top contributors:{contextWarnings.agentWarning.details.map(_temp17)}}{contextWarnings.mcpWarning && <>└{" "}{figures.warning} {contextWarnings.mcpWarning.message}{" "}└ MCP servers:{contextWarnings.mcpWarning.details.map(_temp18)}}; - $[73] = contextWarnings; - $[74] = t39; - } else { - t39 = $[74]; - } - let t40; - if ($[75] === Symbol.for("react.memo_cache_sentinel")) { - t40 = ; - $[75] = t40; - } else { - t40 = $[75]; - } - let t41; - if ($[76] !== t23 || $[77] !== t30 || $[78] !== t35 || $[79] !== t36 || $[80] !== t37 || $[81] !== t38 || $[82] !== t39) { - t41 = {t23}{t30}{t31}{t32}{t33}{t34}{t35}{t36}{t37}{t38}{t39}{t40}; - $[76] = t23; - $[77] = t30; - $[78] = t35; - $[79] = t36; - $[80] = t37; - $[81] = t38; - $[82] = t39; - $[83] = t41; - } else { - t41 = $[83]; - } - return t41; -} -function _temp18(detail_2, i_8) { - return {" "}└ {detail_2}; -} -function _temp17(detail_1, i_7) { - return {" "}└ {detail_1}; -} -function _temp16(detail_0, i_6) { - return {" "}└ {detail_0}; -} -function _temp15(detail, i_5) { - return {" "}└ {detail}; -} -function _temp14(error_0, i_4) { - return {" "}└ {error_0.source || "unknown"}{"plugin" in error_0 && error_0.plugin ? ` [${error_0.plugin}]` : ""}:{" "}{getPluginErrorMessage(error_0)}; -} -function _temp13(file, i_3) { - return {" "}└ {file.path}: {file.error}; -} -function _temp12(lock, i_2) { - return └ {lock.version}: PID {lock.pid}{" "}{lock.isProcessRunning ? (running) : (stale)}; -} -function _temp11(validation, i_1) { - return └ {validation.name}:{" "}{validation.message}; -} -function _temp10(warning, i_0) { - return Warning: {warning.issue}Fix: {warning.fix}; -} -function _temp1(install, i) { - return └ {install.type} at {install.path}; -} -function _temp0(a) { - return { - agentType: a.agentType, - source: a.source - }; -} -function _temp9(v_0) { - return v_0.status !== "valid"; -} -function _temp8(v) { - const value = process.env[v.name]; - const result = validateBoundedIntEnvVar(v.name, value, v.default, v.upperLimit); - return { - name: v.name, - ...result - }; -} -function _temp7(error) { - return error.mcpErrorMetadata === undefined; -} -function _temp6(diag) { - const fetchDistTags = diag.installationType === "native" ? getGcsDistTags : getNpmDistTags; - return fetchDistTags().catch(_temp5); -} -function _temp5() { - return { - latest: null, - stable: null - }; -} -function _temp4(s_2) { - return s_2.plugins.errors; -} -function _temp3(s_1) { - return s_1.toolPermissionContext; -} -function _temp2(s_0) { - return s_0.mcp.tools; -} -function _temp(s) { - return s.agentDefinitions; + return ( + + Checking installation status… + + ) + } + + // Format the diagnostic output according to spec + return ( + + + Diagnostics + + └ Currently running: {diagnostic.installationType} ( + {diagnostic.version}) + + {diagnostic.packageManager && ( + └ Package manager: {diagnostic.packageManager} + )} + └ Path: {diagnostic.installationPath} + └ Invoked: {diagnostic.invokedBinary} + └ Config install method: {diagnostic.configInstallMethod} + + └ Search: {diagnostic.ripgrepStatus.working ? 'OK' : 'Not working'} ( + {diagnostic.ripgrepStatus.mode === 'embedded' + ? 'bundled' + : diagnostic.ripgrepStatus.mode === 'builtin' + ? 'vendor' + : diagnostic.ripgrepStatus.systemPath || 'system'} + ) + + + {/* Show recommendation if auto-updates are disabled */} + {diagnostic.recommendation && ( + <> + + + Recommendation: {diagnostic.recommendation.split('\n')[0]} + + {diagnostic.recommendation.split('\n')[1]} + + )} + + {/* Show multiple installations warning */} + {diagnostic.multipleInstallations.length > 1 && ( + <> + + Warning: Multiple installations found + {diagnostic.multipleInstallations.map((install, i) => ( + + └ {install.type} at {install.path} + + ))} + + )} + + {/* Show configuration warnings */} + {diagnostic.warnings.length > 0 && ( + <> + + {diagnostic.warnings.map((warning, i) => ( + + Warning: {warning.issue} + Fix: {warning.fix} + + ))} + + )} + + {/* Show invalid settings errors */} + {errorsExcludingMcp.length > 0 && ( + + Invalid Settings + + + )} + + + {/* Updates section */} + + Updates + + └ Auto-updates:{' '} + {diagnostic.packageManager + ? 'Managed by package manager' + : diagnostic.autoUpdates} + + {diagnostic.hasUpdatePermissions !== null && ( + + └ Update permissions:{' '} + {diagnostic.hasUpdatePermissions ? 'Yes' : 'No (requires sudo)'} + + )} + └ Auto-update channel: {autoUpdatesChannel} + + + + + + + + + + + + {/* Environment Variables */} + {envValidationErrors.length > 0 && ( + + Environment Variables + {envValidationErrors.map((validation, i) => ( + + └ {validation.name}:{' '} + + {validation.message} + + + ))} + + )} + + {/* Version Locks (PID-based locking) */} + {versionLockInfo?.enabled && ( + + Version Locks + {versionLockInfo.staleLocksCleaned > 0 && ( + + └ Cleaned {versionLockInfo.staleLocksCleaned} stale lock(s) + + )} + {versionLockInfo.locks.length === 0 ? ( + └ No active version locks + ) : ( + versionLockInfo.locks.map((lock, i) => ( + + └ {lock.version}: PID {lock.pid}{' '} + {lock.isProcessRunning ? ( + (running) + ) : ( + (stale) + )} + + )) + )} + + )} + + {agentInfo?.failedFiles && agentInfo.failedFiles.length > 0 && ( + + + Agent Parse Errors + + + └ Failed to parse {agentInfo.failedFiles.length} agent file(s): + + {agentInfo.failedFiles.map((file, i) => ( + + {' '}└ {file.path}: {file.error} + + ))} + + )} + + {/* Plugin Errors */} + {pluginsErrors.length > 0 && ( + + + Plugin Errors + + + └ {pluginsErrors.length} plugin error(s) detected: + + {pluginsErrors.map((error, i) => ( + + {' '}└ {error.source || 'unknown'} + {'plugin' in error && error.plugin ? ` [${error.plugin}]` : ''}:{' '} + {getPluginErrorMessage(error)} + + ))} + + )} + + {/* Unreachable Permission Rules Warning */} + {contextWarnings?.unreachableRulesWarning && ( + + + Unreachable Permission Rules + + + └{' '} + + {figures.warning}{' '} + {contextWarnings.unreachableRulesWarning.message} + + + {contextWarnings.unreachableRulesWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + {/* Context Usage Warnings */} + {contextWarnings && + (contextWarnings.claudeMdWarning || + contextWarnings.agentWarning || + contextWarnings.mcpWarning) && ( + + Context Usage Warnings + + {contextWarnings.claudeMdWarning && ( + <> + + └{' '} + + {figures.warning} {contextWarnings.claudeMdWarning.message} + + + {' '}└ Files: + {contextWarnings.claudeMdWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + {contextWarnings.agentWarning && ( + <> + + └{' '} + + {figures.warning} {contextWarnings.agentWarning.message} + + + {' '}└ Top contributors: + {contextWarnings.agentWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + {contextWarnings.mcpWarning && ( + <> + + └{' '} + + {figures.warning} {contextWarnings.mcpWarning.message} + + + {' '}└ MCP servers: + {contextWarnings.mcpWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + )} + + + + + + ) } diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 34e217ecf..fc512d8a9 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1,366 +1,694 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle'; -import { spawnSync } from 'child_process'; -import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens } from '../bootstrap/state.js'; -import { parseTokenBudget } from '../utils/tokenBudget.js'; -import { count } from '../utils/array.js'; -import { dirname, join } from 'path'; -import { tmpdir } from 'os'; -import figures from 'figures'; +import { feature } from 'bun:bundle' +import { spawnSync } from 'child_process' +import { + snapshotOutputTokensForTurn, + getCurrentTurnTokenBudget, + getTurnOutputTokens, + getBudgetContinuationCount, + getTotalInputTokens, +} from '../bootstrap/state.js' +import { parseTokenBudget } from '../utils/tokenBudget.js' +import { count } from '../utils/array.js' +import { dirname, join } from 'path' +import { tmpdir } from 'os' +import figures from 'figures' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler -import { useInput } from '../ink.js'; -import { useSearchInput } from '../hooks/useSearchInput.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js'; -import type { JumpHandle } from '../components/VirtualMessageList.js'; -import { renderMessagesToPlainText } from '../utils/exportRenderer.js'; -import { openFileInExternalEditor } from '../utils/editor.js'; -import { writeFile } from 'fs/promises'; -import { Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '../ink.js'; -import type { TabStatusKind } from '../ink/hooks/use-tab-status.js'; -import { CostThresholdDialog } from '../components/CostThresholdDialog.js'; -import { IdleReturnDialog } from '../components/IdleReturnDialog.js'; -import * as React from 'react'; -import { useEffect, useMemo, useRef, useState, useCallback, useDeferredValue, useLayoutEffect, type RefObject } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { sendNotification } from '../services/notifier.js'; -import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js'; -import { useTerminalNotification } from '../ink/useTerminalNotification.js'; -import { hasCursorUpViewportYankBug } from '../ink/terminal.js'; -import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE } from '../utils/fileStateCache.js'; -import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration } from '../bootstrap/state.js'; -import { asSessionId, asAgentId } from '../types/ids.js'; -import { logForDebugging } from '../utils/debug.js'; -import { QueryGuard } from '../utils/QueryGuard.js'; -import { isEnvTruthy } from '../utils/envUtils.js'; -import { formatTokens, truncateToWidth } from '../utils/format.js'; -import { consumeEarlyInput } from '../utils/earlyInput.js'; -import { setMemberActive } from '../utils/swarm/teamHelpers.js'; -import { isSwarmWorker, generateSandboxRequestId, sendSandboxPermissionRequestViaMailbox, sendSandboxPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js'; -import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js'; -import { getTeamName, getAgentName } from '../utils/teammate.js'; -import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js'; -import { injectUserMessageToTeammate, getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'; -import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext } from '../utils/swarm/leaderPermissionBridge.js'; -import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'; -import { useLogMessages } from '../hooks/useLogMessages.js'; -import { useReplBridge } from '../hooks/useReplBridge.js'; -import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js'; -import type { PromptInputMode, QueuedCommand, VimMode } from '../types/textInputTypes.js'; -import { MessageSelector, selectableUserMessagesFilter, messagesAfterAreOnlySynthetic } from '../components/MessageSelector.js'; -import { useIdeLogging } from '../hooks/useIdeLogging.js'; -import { PermissionRequest, type ToolUseConfirm } from '../components/permissions/PermissionRequest.js'; -import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js'; -import { PromptDialog } from '../components/hooks/PromptDialog.js'; -import type { PromptRequest, PromptResponse } from '../types/hooks.js'; -import PromptInput from '../components/PromptInput/PromptInput.js'; -import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js'; -import { useRemoteSession } from '../hooks/useRemoteSession.js'; -import { useDirectConnect } from '../hooks/useDirectConnect.js'; -import type { DirectConnectConfig } from '../server/directConnectManager.js'; -import { useSSHSession } from '../hooks/useSSHSession.js'; -import { useAssistantHistory } from '../hooks/useAssistantHistory.js'; -import type { SSHSession } from '../ssh/createSSHSession.js'; -import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js'; -import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js'; -import { useMoreRight } from '../moreright/useMoreRight.js'; -import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js'; -import { getSystemPrompt } from '../constants/prompts.js'; -import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js'; -import { getSystemContext, getUserContext } from '../context.js'; -import { getMemoryFiles } from '../utils/claudemd.js'; -import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js'; -import { getTotalCost, saveCurrentSessionCosts, resetCostState, getStoredSessionCosts } from '../cost-tracker.js'; -import { useCostSummary } from '../costHook.js'; -import { useFpsMetrics } from '../context/fpsMetrics.js'; -import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js'; -import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js'; -import { addToHistory, removeLastFromHistory, expandPastedTextRefs, parseReferences } from '../history.js'; -import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js'; -import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js'; -import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js'; -import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js'; -import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js'; -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; -import { CancelRequestHandler } from '../hooks/useCancelRequest.js'; -import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js'; -import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js'; -import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js'; -import { errorMessage } from '../utils/errors.js'; -import { isHumanTurn } from '../utils/messagePredicates.js'; -import { logError } from '../utils/log.js'; +import { useInput } from '../ink.js' +import { useSearchInput } from '../hooks/useSearchInput.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js' +import type { JumpHandle } from '../components/VirtualMessageList.js' +import { renderMessagesToPlainText } from '../utils/exportRenderer.js' +import { openFileInExternalEditor } from '../utils/editor.js' +import { writeFile } from 'fs/promises' +import { + Box, + Text, + useStdin, + useTheme, + useTerminalFocus, + useTerminalTitle, + useTabStatus, +} from '../ink.js' +import type { TabStatusKind } from '../ink/hooks/use-tab-status.js' +import { CostThresholdDialog } from '../components/CostThresholdDialog.js' +import { IdleReturnDialog } from '../components/IdleReturnDialog.js' +import * as React from 'react' +import { + useEffect, + useMemo, + useRef, + useState, + useCallback, + useDeferredValue, + useLayoutEffect, + type RefObject, +} from 'react' +import { useNotifications } from '../context/notifications.js' +import { sendNotification } from '../services/notifier.js' +import { + startPreventSleep, + stopPreventSleep, +} from '../services/preventSleep.js' +import { useTerminalNotification } from '../ink/useTerminalNotification.js' +import { hasCursorUpViewportYankBug } from '../ink/terminal.js' +import { + createFileStateCacheWithSizeLimit, + mergeFileStateCaches, + READ_FILE_STATE_CACHE_SIZE, +} from '../utils/fileStateCache.js' +import { + updateLastInteractionTime, + getLastInteractionTime, + getOriginalCwd, + getProjectRoot, + getSessionId, + switchSession, + setCostStateForRestore, + getTurnHookDurationMs, + getTurnHookCount, + resetTurnHookDuration, + getTurnToolDurationMs, + getTurnToolCount, + resetTurnToolDuration, + getTurnClassifierDurationMs, + getTurnClassifierCount, + resetTurnClassifierDuration, +} from '../bootstrap/state.js' +import { asSessionId, asAgentId } from '../types/ids.js' +import { logForDebugging } from '../utils/debug.js' +import { QueryGuard } from '../utils/QueryGuard.js' +import { isEnvTruthy } from '../utils/envUtils.js' +import { formatTokens, truncateToWidth } from '../utils/format.js' +import { consumeEarlyInput } from '../utils/earlyInput.js' + +import { setMemberActive } from '../utils/swarm/teamHelpers.js' +import { + isSwarmWorker, + generateSandboxRequestId, + sendSandboxPermissionRequestViaMailbox, + sendSandboxPermissionResponseViaMailbox, +} from '../utils/swarm/permissionSync.js' +import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js' +import { getTeamName, getAgentName } from '../utils/teammate.js' +import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js' +import { + injectUserMessageToTeammate, + getAllInProcessTeammateTasks, +} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import { + isLocalAgentTask, + queuePendingMessage, + appendMessageToLocalAgent, + type LocalAgentTaskState, +} from '../tasks/LocalAgentTask/LocalAgentTask.js' +import { + registerLeaderToolUseConfirmQueue, + unregisterLeaderToolUseConfirmQueue, + registerLeaderSetToolPermissionContext, + unregisterLeaderSetToolPermissionContext, +} from '../utils/swarm/leaderPermissionBridge.js' +import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js' +import { useLogMessages } from '../hooks/useLogMessages.js' +import { useReplBridge } from '../hooks/useReplBridge.js' +import { + type Command, + type CommandResultDisplay, + type ResumeEntrypoint, + getCommandName, + isCommandEnabled, +} from '../commands.js' +import type { + PromptInputMode, + QueuedCommand, + VimMode, +} from '../types/textInputTypes.js' +import { + MessageSelector, + selectableUserMessagesFilter, + messagesAfterAreOnlySynthetic, +} from '../components/MessageSelector.js' +import { useIdeLogging } from '../hooks/useIdeLogging.js' +import { + PermissionRequest, + type ToolUseConfirm, +} from '../components/permissions/PermissionRequest.js' +import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js' +import { PromptDialog } from '../components/hooks/PromptDialog.js' +import type { PromptRequest, PromptResponse } from '../types/hooks.js' +import PromptInput from '../components/PromptInput/PromptInput.js' +import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js' +import { useRemoteSession } from '../hooks/useRemoteSession.js' +import { useDirectConnect } from '../hooks/useDirectConnect.js' +import type { DirectConnectConfig } from '../server/directConnectManager.js' +import { useSSHSession } from '../hooks/useSSHSession.js' +import { useAssistantHistory } from '../hooks/useAssistantHistory.js' +import type { SSHSession } from '../ssh/createSSHSession.js' +import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js' +import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js' +import { useMoreRight } from '../moreright/useMoreRight.js' +import { + SpinnerWithVerb, + BriefIdleStatus, + type SpinnerMode, +} from '../components/Spinner.js' +import { getSystemPrompt } from '../constants/prompts.js' +import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js' +import { getSystemContext, getUserContext } from '../context.js' +import { getMemoryFiles } from '../utils/claudemd.js' +import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js' +import { + getTotalCost, + saveCurrentSessionCosts, + resetCostState, + getStoredSessionCosts, +} from '../cost-tracker.js' +import { useCostSummary } from '../costHook.js' +import { useFpsMetrics } from '../context/fpsMetrics.js' +import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js' +import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js' +import { + addToHistory, + removeLastFromHistory, + expandPastedTextRefs, + parseReferences, +} from '../history.js' +import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js' +import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js' +import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js' +import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js' +import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' +import { CancelRequestHandler } from '../hooks/useCancelRequest.js' +import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js' +import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js' +import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js' +import { errorMessage } from '../utils/errors.js' +import { isHumanTurn } from '../utils/messagePredicates.js' +import { logError } from '../utils/log.js' // Dead code elimination: conditional imports /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration : () => ({ - stripTrailing: () => 0, - handleKeyEvent: () => {}, - resetAnchor: () => {} -}); -const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null; +const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = + feature('VOICE_MODE') + ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration + : () => ({ + stripTrailing: () => 0, + handleKeyEvent: () => {}, + resetAnchor: () => {}, + }) +const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = + feature('VOICE_MODE') + ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler + : () => null // Frustration detection is ant-only (dogfooding). Conditional require so external // builds eliminate the module entirely (including its two O(n) useMemos that run // on every messages change, plus the GrowthBook fetch). -const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = (process.env.USER_TYPE) === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({ - state: 'closed', - handleTranscriptSelect: () => {} -}); +const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = + process.env.USER_TYPE === 'ant' + ? require('../components/FeedbackSurvey/useFrustrationDetection.js') + .useFrustrationDetection + : () => ({ state: 'closed', handleTranscriptSelect: () => {} }) // Ant-only org warning. Conditional require so the org UUID list is // eliminated from external builds (one UUID is on excluded-strings). -const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = (process.env.USER_TYPE) === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {}; +const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = + process.env.USER_TYPE === 'ant' + ? require('../hooks/notifs/useAntOrgWarningNotification.js') + .useAntOrgWarningNotification + : () => {} // Dead code elimination: conditional import for coordinator mode -const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{ - name: string; -}>, scratchpadDir?: string) => { - [k: string]: string; -} = feature('COORDINATOR_MODE') ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext : () => ({}); +const getCoordinatorUserContext: ( + mcpClients: ReadonlyArray<{ name: string }>, + scratchpadDir?: string, +) => { [k: string]: string } = feature('COORDINATOR_MODE') + ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext + : () => ({}) /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -import useCanUseTool from '../hooks/useCanUseTool.js'; -import type { ToolPermissionContext, Tool } from '../Tool.js'; -import { applyPermissionUpdate, applyPermissionUpdates, persistPermissionUpdate } from '../utils/permissions/PermissionUpdate.js'; -import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'; -import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js'; -import type { PermissionMode } from '../types/permissions.js'; -import { getScratchpadDir, isScratchpadEnabled } from '../utils/permissions/filesystem.js'; -import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'; -import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js'; -import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js'; -import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; -import { getGlobalConfig, saveGlobalConfig, getGlobalConfigWriteCount } from '../utils/config.js'; -import { hasConsoleBillingAccess } from '../utils/billing.js'; -import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { textForResubmit, handleMessageFromStream, type StreamingToolUse, type StreamingThinking, isCompactBoundaryMessage, getMessagesAfterCompactBoundary, getContentText, createUserMessage, createAssistantMessage, createTurnDurationMessage, createAgentsKilledMessage, createApiMetricsMessage, createSystemMessage, createCommandInputMessage, formatCommandInputTags } from '../utils/messages.js'; -import { generateSessionTitle } from '../utils/sessionTitle.js'; -import { BASH_INPUT_TAG, COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, LOCAL_COMMAND_STDOUT_TAG } from '../constants/xml.js'; -import { escapeXml } from '../utils/xml.js'; -import type { ThinkingConfig } from '../utils/thinking.js'; -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; -import { handlePromptSubmit, type PromptInputHelpers } from '../utils/handlePromptSubmit.js'; -import { useQueueProcessor } from '../hooks/useQueueProcessor.js'; -import { useMailboxBridge } from '../hooks/useMailboxBridge.js'; -import { queryCheckpoint, logQueryProfileReport } from '../utils/queryProfiler.js'; -import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection } from '../types/message.js'; -import { query } from '../query.js'; -import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js'; -import { getQuerySourceForREPL } from '../utils/promptCategory.js'; -import { useMergedTools } from '../hooks/useMergedTools.js'; -import { mergeAndFilterTools } from '../utils/toolPool.js'; -import { useMergedCommands } from '../hooks/useMergedCommands.js'; -import { useSkillsChange } from '../hooks/useSkillsChange.js'; -import { useManagePlugins } from '../hooks/useManagePlugins.js'; -import { Messages } from '../components/Messages.js'; -import { TaskListV2 } from '../components/TaskListV2.js'; -import { TeammateViewHeader } from '../components/TeammateViewHeader.js'; -import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js'; -import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'; -import type { MCPServerConnection } from '../services/mcp/types.js'; -import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { randomUUID, type UUID } from 'crypto'; -import { processSessionStartHooks } from '../utils/sessionStart.js'; -import { executeSessionEndHooks, getSessionEndHookTimeoutMs } from '../utils/hooks.js'; -import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js'; -import { getTools, assembleToolPool } from '../tools.js'; -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; -import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js'; -import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js'; -import { useMainLoopModel } from '../hooks/useMainLoopModel.js'; -import { useAppState, useSetAppState, useAppStateStore } from '../state/AppState.js'; -import type { ContentBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; -import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js'; -import type { PastedContent } from '../utils/config.js'; -import { copyPlanForFork, copyPlanForResume, getPlanSlug, setPlanSlug } from '../utils/plans.js'; -import { clearSessionMetadata, resetSessionFilePointer, adoptResumedSessionFile, removeTranscriptMessage, restoreSessionMetadata, getCurrentSessionTitle, isEphemeralToolProgress, isLoggableMessage, saveWorktreeState, getAgentTranscript } from '../utils/sessionStorage.js'; -import { deserializeMessages } from '../utils/conversationRecovery.js'; -import { extractReadFilesFromMessages, extractBashToolsFromMessages } from '../utils/queryHelpers.js'; -import { resetMicrocompactState } from '../services/compact/microCompact.js'; -import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js'; -import { provisionContentReplacementState, reconstructContentReplacementState, type ContentReplacementRecord } from '../utils/toolResultStorage.js'; -import { partialCompactConversation } from '../services/compact/compact.js'; -import type { LogOption } from '../types/logs.js'; -import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; -import { fileHistoryMakeSnapshot, type FileHistoryState, fileHistoryRewind, type FileHistorySnapshot, copyFileHistoryForResume, fileHistoryEnabled, fileHistoryHasAnyChanges } from '../utils/fileHistory.js'; -import { type AttributionState, incrementPromptCount } from '../utils/commitAttribution.js'; -import { recordAttributionSnapshot } from '../utils/sessionStorage.js'; -import { computeStandaloneAgentContext, restoreAgentFromSession, restoreSessionStateFromLog, restoreWorktreeForResume, exitRestoredWorktree } from '../utils/sessionRestore.js'; -import { isBgSession, updateSessionName, updateSessionActivity } from '../utils/concurrentSessions.js'; -import { isInProcessTeammateTask, type InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js'; -import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { useInboxPoller } from '../hooks/useInboxPoller.js'; +import useCanUseTool from '../hooks/useCanUseTool.js' +import type { ToolPermissionContext, Tool } from '../Tool.js' +import { + applyPermissionUpdate, + applyPermissionUpdates, + persistPermissionUpdate, +} from '../utils/permissions/PermissionUpdate.js' +import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js' +import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js' +import { + getScratchpadDir, + isScratchpadEnabled, +} from '../utils/permissions/filesystem.js' +import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js' +import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js' +import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js' +import type { AutoUpdaterResult } from '../utils/autoUpdater.js' +import { + getGlobalConfig, + saveGlobalConfig, + getGlobalConfigWriteCount, +} from '../utils/config.js' +import { hasConsoleBillingAccess } from '../utils/billing.js' +import { + logEvent, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, +} from 'src/services/analytics/index.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + textForResubmit, + handleMessageFromStream, + type StreamingToolUse, + type StreamingThinking, + isCompactBoundaryMessage, + getMessagesAfterCompactBoundary, + getContentText, + createUserMessage, + createAssistantMessage, + createTurnDurationMessage, + createAgentsKilledMessage, + createApiMetricsMessage, + createSystemMessage, + createCommandInputMessage, + formatCommandInputTags, +} from '../utils/messages.js' +import { generateSessionTitle } from '../utils/sessionTitle.js' +import { + BASH_INPUT_TAG, + COMMAND_MESSAGE_TAG, + COMMAND_NAME_TAG, + LOCAL_COMMAND_STDOUT_TAG, +} from '../constants/xml.js' +import { escapeXml } from '../utils/xml.js' +import type { ThinkingConfig } from '../utils/thinking.js' +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' +import { + handlePromptSubmit, + type PromptInputHelpers, +} from '../utils/handlePromptSubmit.js' +import { useQueueProcessor } from '../hooks/useQueueProcessor.js' +import { useMailboxBridge } from '../hooks/useMailboxBridge.js' +import { + queryCheckpoint, + logQueryProfileReport, +} from '../utils/queryProfiler.js' +import type { + Message as MessageType, + UserMessage, + ProgressMessage, + HookResultMessage, + PartialCompactDirection, +} from '../types/message.js' +import { query } from '../query.js' +import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js' +import { getQuerySourceForREPL } from '../utils/promptCategory.js' +import { useMergedTools } from '../hooks/useMergedTools.js' +import { mergeAndFilterTools } from '../utils/toolPool.js' +import { useMergedCommands } from '../hooks/useMergedCommands.js' +import { useSkillsChange } from '../hooks/useSkillsChange.js' +import { useManagePlugins } from '../hooks/useManagePlugins.js' +import { Messages } from '../components/Messages.js' +import { TaskListV2 } from '../components/TaskListV2.js' +import { TeammateViewHeader } from '../components/TeammateViewHeader.js' +import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js' +import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js' +import type { MCPServerConnection } from '../services/mcp/types.js' +import type { ScopedMcpServerConfig } from '../services/mcp/types.js' +import { randomUUID, type UUID } from 'crypto' +import { processSessionStartHooks } from '../utils/sessionStart.js' +import { + executeSessionEndHooks, + getSessionEndHookTimeoutMs, +} from '../utils/hooks.js' +import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js' +import { getTools, assembleToolPool } from '../tools.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js' +import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js' +import { useMainLoopModel } from '../hooks/useMainLoopModel.js' +import { + useAppState, + useSetAppState, + useAppStateStore, +} from '../state/AppState.js' +import type { + ContentBlockParam, + ImageBlockParam, +} from '@anthropic-ai/sdk/resources/messages.mjs' +import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js' +import type { PastedContent } from '../utils/config.js' +import { + copyPlanForFork, + copyPlanForResume, + getPlanSlug, + setPlanSlug, +} from '../utils/plans.js' +import { + clearSessionMetadata, + resetSessionFilePointer, + adoptResumedSessionFile, + removeTranscriptMessage, + restoreSessionMetadata, + getCurrentSessionTitle, + isEphemeralToolProgress, + isLoggableMessage, + saveWorktreeState, + getAgentTranscript, +} from '../utils/sessionStorage.js' +import { deserializeMessages } from '../utils/conversationRecovery.js' +import { + extractReadFilesFromMessages, + extractBashToolsFromMessages, +} from '../utils/queryHelpers.js' +import { resetMicrocompactState } from '../services/compact/microCompact.js' +import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js' +import { + provisionContentReplacementState, + reconstructContentReplacementState, + type ContentReplacementRecord, +} from '../utils/toolResultStorage.js' +import { partialCompactConversation } from '../services/compact/compact.js' +import type { LogOption } from '../types/logs.js' +import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' +import { + fileHistoryMakeSnapshot, + type FileHistoryState, + fileHistoryRewind, + type FileHistorySnapshot, + copyFileHistoryForResume, + fileHistoryEnabled, + fileHistoryHasAnyChanges, +} from '../utils/fileHistory.js' +import { + type AttributionState, + incrementPromptCount, +} from '../utils/commitAttribution.js' +import { recordAttributionSnapshot } from '../utils/sessionStorage.js' +import { + computeStandaloneAgentContext, + restoreAgentFromSession, + restoreSessionStateFromLog, + restoreWorktreeForResume, + exitRestoredWorktree, +} from '../utils/sessionRestore.js' +import { + isBgSession, + updateSessionName, + updateSessionActivity, +} from '../utils/concurrentSessions.js' +import { + isInProcessTeammateTask, + type InProcessTeammateTaskState, +} from '../tasks/InProcessTeammateTask/types.js' +import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { useInboxPoller } from '../hooks/useInboxPoller.js' // Dead code elimination: conditional import for loop mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; -const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; -const PROACTIVE_FALSE = () => false; -const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; -const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; -const useScheduledTasks = require('../hooks/useScheduledTasks.js').useScheduledTasks; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? require('../proactive/index.js') + : null +const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} +const PROACTIVE_FALSE = () => false +const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false +const useProactive = + feature('PROACTIVE') || feature('KAIROS') + ? require('../proactive/useProactive.js').useProactive + : null +const useScheduledTasks = feature('AGENT_TRIGGERS') + ? require('../hooks/useScheduledTasks.js').useScheduledTasks + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; -import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; -import type { SandboxAskCallback, NetworkHostPattern } from '../utils/sandbox/sandbox-adapter.js'; -import { type IDEExtensionInstallationStatus, closeOpenDiffs, getConnectedIdeClient, type IdeType } from '../utils/ide.js'; -import { useIDEIntegration } from '../hooks/useIDEIntegration.js'; -import exit from '../commands/exit/index.js'; -import { ExitFlow } from '../components/ExitFlow.js'; -import { getCurrentWorktreeSession } from '../utils/worktree.js'; -import { popAllEditable, enqueue, type SetAppState, getCommandQueue, getCommandQueueLength, removeByFilter } from '../utils/messageQueueManager.js'; -import { useCommandQueue } from '../hooks/useCommandQueue.js'; -import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js'; -import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js'; -import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js'; -import { diagnosticTracker } from '../services/diagnosticTracking.js'; -import { handleSpeculationAccept, type ActiveSpeculationState } from '../services/PromptSuggestion/speculation.js'; -import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js'; -import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCallout.js'; -import type { EffortValue } from '../utils/effort.js'; -import { RemoteCallout } from '../components/RemoteCallout.js'; +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' +import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js' +import type { + SandboxAskCallback, + NetworkHostPattern, +} from '../utils/sandbox/sandbox-adapter.js' + +import { + type IDEExtensionInstallationStatus, + closeOpenDiffs, + getConnectedIdeClient, + type IdeType, +} from '../utils/ide.js' +import { useIDEIntegration } from '../hooks/useIDEIntegration.js' +import exit from '../commands/exit/index.js' +import { ExitFlow } from '../components/ExitFlow.js' +import { getCurrentWorktreeSession } from '../utils/worktree.js' +import { + popAllEditable, + enqueue, + type SetAppState, + getCommandQueue, + getCommandQueueLength, + removeByFilter, +} from '../utils/messageQueueManager.js' +import { useCommandQueue } from '../hooks/useCommandQueue.js' +import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js' +import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js' +import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js' +import { diagnosticTracker } from '../services/diagnosticTracking.js' +import { + handleSpeculationAccept, + type ActiveSpeculationState, +} from '../services/PromptSuggestion/speculation.js' +import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js' +import { + EffortCallout, + shouldShowEffortCallout, +} from '../components/EffortCallout.js' +import type { EffortValue } from '../utils/effort.js' +import { RemoteCallout } from '../components/RemoteCallout.js' /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -const AntModelSwitchCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null; -const shouldShowAntModelSwitch = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false; -const UndercoverAutoCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null; +const AntModelSwitchCallout = + process.env.USER_TYPE === 'ant' + ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout + : null +const shouldShowAntModelSwitch = + process.env.USER_TYPE === 'ant' + ? require('../components/AntModelSwitchCallout.js') + .shouldShowModelSwitchCallout + : (): boolean => false +const UndercoverAutoCallout = + process.env.USER_TYPE === 'ant' + ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout + : null /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -import { activityManager } from '../utils/activityManager.js'; -import { createAbortController } from '../utils/abortController.js'; -import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js'; -import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js'; -import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js'; -import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js'; -import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js'; -import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js'; -import { useAwaySummary } from 'src/hooks/useAwaySummary.js'; -import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js'; -import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js'; -import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js'; -import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; -import type { Theme } from 'src/utils/theme.js'; -import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js'; -import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; -import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js'; -import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js'; -import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js'; -import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js'; -import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js'; -import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js'; -import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js'; -import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js'; -import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js'; -import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js'; -import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js'; -import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js'; -import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js'; -import { DesktopUpsellStartup, shouldShowDesktopUpsellStartup } from 'src/components/DesktopUpsell/DesktopUpsellStartup.js'; -import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js'; -import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js'; -import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js'; -import { UserTextMessage } from 'src/components/messages/UserTextMessage.js'; -import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js'; -import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js'; -import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js'; -import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js'; -import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js'; -import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js'; -import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js'; -import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js'; -import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js'; -import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason } from '../utils/autoRunIssue.js'; -import type { HookProgress } from '../types/hooks.js'; -import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js'; +import { activityManager } from '../utils/activityManager.js' +import { createAbortController } from '../utils/abortController.js' +import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js' +import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js' +import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js' +import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js' +import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js' +import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js' +import { useAwaySummary } from 'src/hooks/useAwaySummary.js' +import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js' +import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js' +import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js' +import { + getTipToShowOnSpinner, + recordShownTip, +} from 'src/services/tips/tipScheduler.js' +import type { Theme } from 'src/utils/theme.js' +import { + checkAndDisableBypassPermissionsIfNeeded, + checkAndDisableAutoModeIfNeeded, + useKickOffCheckAndDisableBypassPermissionsIfNeeded, + useKickOffCheckAndDisableAutoModeIfNeeded, +} from 'src/utils/permissions/bypassPermissionsKillswitch.js' +import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' +import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js' +import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js' +import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js' +import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js' +import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js' +import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js' +import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js' +import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js' +import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js' +import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js' +import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js' +import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js' +import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js' +import { + DesktopUpsellStartup, + shouldShowDesktopUpsellStartup, +} from 'src/components/DesktopUpsell/DesktopUpsellStartup.js' +import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js' +import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js' +import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js' +import { UserTextMessage } from 'src/components/messages/UserTextMessage.js' +import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js' +import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js' +import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js' +import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js' +import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js' +import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js' +import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js' +import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js' +import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js' +import { + AutoRunIssueNotification, + shouldAutoRunIssue, + getAutoRunIssueReasonText, + getAutoRunCommand, + type AutoRunIssueReason, +} from '../utils/autoRunIssue.js' +import type { HookProgress } from '../types/hooks.js' +import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js') : null; +const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') + ? (require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'; -import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'; -import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js'; -import { triggerCompanionReaction } from '../buddy/companionReact.js'; -import { DevBar } from '../components/DevBar.js'; +import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js' +import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js' +import { + CompanionSprite, + CompanionFloatingBubble, + MIN_COLS_FOR_FULL_SPRITE, +} from '../buddy/CompanionSprite.js' +import { DevBar } from '../components/DevBar.js' // Session manager removed - using AppState now -import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'; -import { REMOTE_SAFE_COMMANDS } from '../commands.js'; -import type { RemoteMessageContent } from '../utils/teleport/api.js'; -import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js'; -import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js'; -import { AlternateScreen } from '../ink/components/AlternateScreen.js'; -import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'; -import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type MessageActionsState, type MessageActionsNav, type MessageActionCaps } from '../components/messageActions.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; +import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js' +import { REMOTE_SAFE_COMMANDS } from '../commands.js' +import type { RemoteMessageContent } from '../utils/teleport/api.js' +import { + FullscreenLayout, + useUnseenDivider, + computeUnseenDivider, +} from '../components/FullscreenLayout.js' +import { + isFullscreenEnvEnabled, + maybeGetTmuxMouseHint, + isMouseTrackingEnabled, +} from '../utils/fullscreen.js' +import { AlternateScreen } from '../ink/components/AlternateScreen.js' +import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js' +import { + useMessageActions, + MessageActionsKeybindings, + MessageActionsBar, + type MessageActionsState, + type MessageActionsNav, + type MessageActionCaps, +} from '../components/messageActions.js' +import { setClipboard } from '../ink/termio/osc.js' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import { + createAttachmentMessage, + getQueuedCommandAttachments, +} from '../utils/attachments.js' // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would // cause useEffect dependency changes and infinite re-render loops. -const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; +const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [] // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new // function identity each render, which would break composedOnScroll's memo. -const HISTORY_STUB = { - maybeLoadOlder: (_: ScrollBoxHandle) => {} -}; +const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => {} } // Window after a user-initiated scroll during which type-into-empty does NOT // repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll // up to read the start → start typing → before this fix, snapped to bottom. // https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739 -const RECENT_SCROLL_REPIN_WINDOW_MS = 3000; +const RECENT_SCROLL_REPIN_WINDOW_MS = 3000 // Use LRU cache to prevent unbounded memory growth // 100 files should be sufficient for most coding sessions while preventing // memory issues when working across many files in large projects function median(values: number[]): number { - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) : sorted[mid]!; + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) + : sorted[mid]! } /** * Small component to display transcript mode footer with dynamic keybinding. * Must be rendered inside KeybindingSetup to access keybinding context. */ -function TranscriptModeFooter(t0) { - const $ = _c(9); - const { - showAllInTranscript, - virtualScroll, - searchBadge, - suppressShowAll: t1, - status - } = t0; - const suppressShowAll = t1 === undefined ? false : t1; - const toggleShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - const showAllShortcut = useShortcutDisplay("transcript:toggleShowAll", "Transcript", "ctrl+e"); - const t2 = searchBadge ? " \xB7 n/N to navigate" : virtualScroll ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` : suppressShowAll ? "" : ` · ${showAllShortcut} to ${showAllInTranscript ? "collapse" : "show all"}`; - let t3; - if ($[0] !== t2 || $[1] !== toggleShortcut) { - t3 = Showing detailed transcript · {toggleShortcut} to toggle{t2}; - $[0] = t2; - $[1] = toggleShortcut; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== searchBadge || $[4] !== status) { - t4 = status ? <>{status} : searchBadge ? <>{searchBadge.current}/{searchBadge.count}{" "} : null; - $[3] = searchBadge; - $[4] = status; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t3 || $[7] !== t4) { - t5 = {t3}{t4}; - $[6] = t3; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - return t5; +function TranscriptModeFooter({ + showAllInTranscript, + virtualScroll, + searchBadge, + suppressShowAll = false, + status, +}: { + showAllInTranscript: boolean + virtualScroll: boolean + /** Minimap while navigating a closed-bar search. Shows n/N hints + + * right-aligned count instead of scroll hints. */ + searchBadge?: { current: number; count: number } + /** Hide the ctrl+e hint. The [ dump path shares this footer with + * env-opted dump (CLAUDE_CODE_NO_FLICKER=0 / DISABLE_VIRTUAL_SCROLL=1), + * but ctrl+e only works in the env case — useGlobalKeybindings.tsx + * gates on !virtualScrollActive which is env-derived, doesn't know + * [ happened. */ + suppressShowAll?: boolean + /** Transient status (v-for-editor progress). Notifications render inside + * PromptInput which isn't mounted in transcript — addNotification queues + * but nothing draws it. */ + status?: string +}): React.ReactNode { + const toggleShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + const showAllShortcut = useShortcutDisplay( + 'transcript:toggleShowAll', + 'Transcript', + 'ctrl+e', + ) + return ( + + + Showing detailed transcript · {toggleShortcut} to toggle + {searchBadge + ? ' · n/N to navigate' + : virtualScroll + ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` + : suppressShowAll + ? '' + : ` · ${showAllShortcut} to ${showAllInTranscript ? 'collapse' : 'show all'}`} + + {status ? ( + // v-for-editor render progress — transient, preempts the search + // badge since the user just pressed v and wants to see what's + // happening. Clears after 4s. + <> + + {status} + + ) : searchBadge ? ( + // Engine-counted — close enough for a rough location hint. May + // drift from render-count for ghost/phantom messages. + <> + + + {searchBadge.current}/{searchBadge.count} + {' '} + + + ) : null} + + ) } /** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter @@ -374,30 +702,27 @@ function TranscriptSearchBar({ onClose, onCancel, setHighlight, - initialQuery + initialQuery, }: { - jumpRef: RefObject; - count: number; - current: number; + jumpRef: RefObject + count: number + current: number /** Enter — commit. Query persists for n/N. */ - onClose: (lastQuery: string) => void; + onClose: (lastQuery: string) => void /** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */ - onCancel: () => void; - setHighlight: (query: string) => void; + onCancel: () => void + setHighlight: (query: string) => void // Seed with the previous query (less: / shows last pattern). Mount-fire // of the effect re-scans with the same query — idempotent (same matches, // nearest-ptr, same highlights). User can edit or clear. - initialQuery: string; + initialQuery: string }): React.ReactNode { - const { - query, - cursorOffset - } = useSearchInput({ + const { query, cursorOffset } = useSearchInput({ isActive: true, initialQuery, onExit: () => onClose(query), - onCancel - }); + onCancel, + }) // Index warm-up runs before the query effect so it measures the real // cost — otherwise setSearchQuery fills the cache first and warm // reports ~0ms while the user felt the actual lag. @@ -409,72 +734,89 @@ function TranscriptSearchBar({ // null initial, warmDone would be true on mount → [query] fires → // setSearchQuery fills cache → warm reports ~0ms while the user felt // the real lag. - const [indexStatus, setIndexStatus] = React.useState<'building' | { - ms: number; - } | null>('building'); + const [indexStatus, setIndexStatus] = React.useState< + 'building' | { ms: number } | null + >('building') React.useEffect(() => { - let alive = true; - const warm = jumpRef.current?.warmSearchIndex; + let alive = true + const warm = jumpRef.current?.warmSearchIndex if (!warm) { - setIndexStatus(null); // VML not mounted yet — rare, skip indicator - return; + setIndexStatus(null) // VML not mounted yet — rare, skip indicator + return } - setIndexStatus('building'); + setIndexStatus('building') warm().then(ms => { - if (!alive) return; + if (!alive) return // <20ms = imperceptible. No point showing "indexed in 3ms". if (ms < 20) { - setIndexStatus(null); + setIndexStatus(null) } else { - setIndexStatus({ - ms - }); - setTimeout(() => alive && setIndexStatus(null), 2000); + setIndexStatus({ ms }) + setTimeout(() => alive && setIndexStatus(null), 2000) } - }); + }) return () => { - alive = false; - }; + alive = false + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // mount-only: bar opens once per / + }, []) // mount-only: bar opens once per / // Gate the query effect on warm completion. setHighlight stays instant // (screen-space overlay, no indexing). setSearchQuery (the scan) waits. - const warmDone = indexStatus !== 'building'; + const warmDone = indexStatus !== 'building' useEffect(() => { - if (!warmDone) return; - jumpRef.current?.setSearchQuery(query); - setHighlight(query); + if (!warmDone) return + jumpRef.current?.setSearchQuery(query) + setHighlight(query) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, warmDone]); - const off = cursorOffset; - const cursorChar = off < query.length ? query[off] : ' '; - return + }, [query, warmDone]) + const off = cursorOffset + const cursorChar = off < query.length ? query[off] : ' ' + return ( + / {query.slice(0, off)} {cursorChar} {off < query.length && {query.slice(off + 1)}} - {indexStatus === 'building' ? indexing… : indexStatus ? indexed in {indexStatus.ms}ms : count === 0 && query ? no matches : count > 0 ? - // Engine-counted (indexOf on extractSearchText). May drift from - // render-count for ghost/phantom messages — badge is a rough - // location hint. scanElement gives exact per-message positions - // but counting ALL would cost ~1-3ms × matched-messages. - + {indexStatus === 'building' ? ( + indexing… + ) : indexStatus ? ( + indexed in {indexStatus.ms}ms + ) : count === 0 && query ? ( + no matches + ) : count > 0 ? ( + // Engine-counted (indexOf on extractSearchText). May drift from + // render-count for ghost/phantom messages — badge is a rough + // location hint. scanElement gives exact per-message positions + // but counting ALL would cost ~1-3ms × matched-messages. + {current}/{count} {' '} - : null} - ; + + ) : null} + + ) } -const TITLE_ANIMATION_FRAMES = ['⠂', '⠐']; -const TITLE_STATIC_PREFIX = '✳'; -const TITLE_ANIMATION_INTERVAL_MS = 960; + +const TITLE_ANIMATION_FRAMES = ['⠂', '⠐'] +const TITLE_STATIC_PREFIX = '✳' +const TITLE_ANIMATION_INTERVAL_MS = 960 /** * Sets the terminal tab title, with an animated prefix glyph while a query @@ -483,94 +825,86 @@ const TITLE_ANIMATION_INTERVAL_MS = 960; * entire REPL tree. Before extraction, the tick was ~1 REPL render/sec for * the duration of every turn, dragging PromptInput and friends along. */ -function AnimatedTerminalTitle(t0) { - const $ = _c(6); - const { - isAnimating, - title, - disabled, - noPrefix - } = t0; - const terminalFocused = useTerminalFocus(); - const [frame, setFrame] = useState(0); - let t1; - let t2; - if ($[0] !== disabled || $[1] !== isAnimating || $[2] !== noPrefix || $[3] !== terminalFocused) { - t1 = () => { - if (disabled || noPrefix || !isAnimating || !terminalFocused) { - return; - } - const interval = setInterval(_temp2, TITLE_ANIMATION_INTERVAL_MS, setFrame); - return () => clearInterval(interval); - }; - t2 = [disabled, noPrefix, isAnimating, terminalFocused]; - $[0] = disabled; - $[1] = isAnimating; - $[2] = noPrefix; - $[3] = terminalFocused; - $[4] = t1; - $[5] = t2; - } else { - t1 = $[4]; - t2 = $[5]; - } - useEffect(t1, t2); - const prefix = isAnimating ? TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX : TITLE_STATIC_PREFIX; - useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`); - return null; -} -function _temp2(setFrame_0) { - return setFrame_0(_temp); -} -function _temp(f) { - return (f + 1) % TITLE_ANIMATION_FRAMES.length; +function AnimatedTerminalTitle({ + isAnimating, + title, + disabled, + noPrefix, +}: { + isAnimating: boolean + title: string + disabled: boolean + noPrefix: boolean +}): null { + const terminalFocused = useTerminalFocus() + const [frame, setFrame] = useState(0) + useEffect(() => { + if (disabled || noPrefix || !isAnimating || !terminalFocused) return + const interval = setInterval( + setFrame => setFrame(f => (f + 1) % TITLE_ANIMATION_FRAMES.length), + TITLE_ANIMATION_INTERVAL_MS, + setFrame, + ) + return () => clearInterval(interval) + }, [disabled, noPrefix, isAnimating, terminalFocused]) + const prefix = isAnimating + ? (TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX) + : TITLE_STATIC_PREFIX + useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`) + return null } + export type Props = { - commands: Command[]; - debug: boolean; - initialTools: Tool[]; + commands: Command[] + debug: boolean + initialTools: Tool[] // Initial messages to populate the REPL with - initialMessages?: MessageType[]; + initialMessages?: MessageType[] // Deferred hook messages promise — REPL renders immediately and injects // hook messages when they resolve. Awaited before the first API call. - pendingHookMessages?: Promise; - initialFileHistorySnapshots?: FileHistorySnapshot[]; + pendingHookMessages?: Promise + initialFileHistorySnapshots?: FileHistorySnapshot[] // Content-replacement records from a resumed session's transcript — used to // reconstruct contentReplacementState so the same results are re-replaced - initialContentReplacements?: ContentReplacementRecord[]; + initialContentReplacements?: ContentReplacementRecord[] // Initial agent context for session resume (name/color set via /rename or /color) - initialAgentName?: string; - initialAgentColor?: AgentColorName; - mcpClients?: MCPServerConnection[]; - dynamicMcpConfig?: Record; - autoConnectIdeFlag?: boolean; - strictMcpConfig?: boolean; - systemPrompt?: string; - appendSystemPrompt?: string; + initialAgentName?: string + initialAgentColor?: AgentColorName + mcpClients?: MCPServerConnection[] + dynamicMcpConfig?: Record + autoConnectIdeFlag?: boolean + strictMcpConfig?: boolean + systemPrompt?: string + appendSystemPrompt?: string // Optional callback invoked before query execution // Called after user message is added to conversation but before API call // Return false to prevent query execution - onBeforeQuery?: (input: string, newMessages: MessageType[]) => Promise; + onBeforeQuery?: ( + input: string, + newMessages: MessageType[], + ) => Promise // Optional callback when a turn completes (model finishes responding) - onTurnComplete?: (messages: MessageType[]) => void | Promise; + onTurnComplete?: (messages: MessageType[]) => void | Promise // When true, disables REPL input (hides prompt and prevents message selector) - disabled?: boolean; + disabled?: boolean // Optional agent definition to use for the main thread - mainThreadAgentDefinition?: AgentDefinition; + mainThreadAgentDefinition?: AgentDefinition // When true, disables all slash commands - disableSlashCommands?: boolean; + disableSlashCommands?: boolean // Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks. - taskListId?: string; + taskListId?: string // Remote session config for --remote mode (uses CCR as execution engine) - remoteSessionConfig?: RemoteSessionConfig; + remoteSessionConfig?: RemoteSessionConfig // Direct connect config for `claude connect` mode (connects to a claude server) - directConnectConfig?: DirectConnectConfig; + directConnectConfig?: DirectConnectConfig // SSH session for `claude ssh` mode (local REPL, remote tools over ssh) - sshSession?: SSHSession; + sshSession?: SSHSession // Thinking configuration to use when thinking is enabled - thinkingConfig: ThinkingConfig; -}; -export type Screen = 'prompt' | 'transcript'; + thinkingConfig: ThinkingConfig +} + +export type Screen = 'prompt' | 'transcript' + export function REPL({ commands: initialCommands, debug, @@ -596,67 +930,92 @@ export function REPL({ remoteSessionConfig, directConnectConfig, sshSession, - thinkingConfig + thinkingConfig, }: Props): React.ReactNode { - const isRemoteSession = !!remoteSessionConfig; + const isRemoteSession = !!remoteSessionConfig // Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+ // includes, and these were on the render path (hot during PageUp spam). - const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []); - const moreRightEnabled = useMemo(() => (process.env.USER_TYPE) === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []); - const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); - const disableMessageActions = feature('MESSAGE_ACTIONS') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []) : false; + const titleDisabled = useMemo( + () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), + [], + ) + const moreRightEnabled = useMemo( + () => + process.env.USER_TYPE === 'ant' && + isEnvTruthy(process.env.CLAUDE_MORERIGHT), + [], + ) + const disableVirtualScroll = useMemo( + () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), + [], + ) + const disableMessageActions = feature('MESSAGE_ACTIONS') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useMemo( + () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), + [], + ) + : false // Log REPL mount/unmount lifecycle useEffect(() => { - logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`); - return () => logForDebugging(`[REPL:unmount] REPL unmounting`); - }, [disabled]); + logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`) + return () => logForDebugging(`[REPL:unmount] REPL unmounting`) + }, [disabled]) // Agent definition is state so /resume can update it mid-session - const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState(initialMainThreadAgentDefinition); - const toolPermissionContext = useAppState(s => s.toolPermissionContext); - const verbose = useAppState(s => s.verbose); - const mcp = useAppState(s => s.mcp); - const plugins = useAppState(s => s.plugins); - const agentDefinitions = useAppState(s => s.agentDefinitions); - const fileHistory = useAppState(s => s.fileHistory); - const initialMessage = useAppState(s => s.initialMessage); - const queuedCommands = useCommandQueue(); + const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState( + initialMainThreadAgentDefinition, + ) + + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const verbose = useAppState(s => s.verbose) + const mcp = useAppState(s => s.mcp) + const plugins = useAppState(s => s.plugins) + const agentDefinitions = useAppState(s => s.agentDefinitions) + const fileHistory = useAppState(s => s.fileHistory) + const initialMessage = useAppState(s => s.initialMessage) + const queuedCommands = useCommandQueue() // feature() is a build-time constant — dead code elimination removes the hook // call entirely in external builds, so this is safe despite looking conditional. // These fields contain excluded strings that must not appear in external builds. - const spinnerTip = useAppState(s => s.spinnerTip); - const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks'; - const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest); - const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest); - const teamContext = useAppState(s => s.teamContext); - const tasks = useAppState(s => s.tasks); - const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions); - const elicitation = useAppState(s => s.elicitation); - const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice); - const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending); - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); - const setAppState = useSetAppState(); + const spinnerTip = useAppState(s => s.spinnerTip) + const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks' + const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest) + const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest) + const teamContext = useAppState(s => s.teamContext) + const tasks = useAppState(s => s.tasks) + const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions) + const elicitation = useAppState(s => s.elicitation) + const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice) + const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const setAppState = useSetAppState() // Bootstrap: retained local_agent that hasn't loaded disk yet → read // sidechain JSONL and UUID-merge with whatever stream has appended so far. // Stream appends immediately on retain (no defer); bootstrap fills the // prefix. Disk-write-before-yield means live is always a suffix of disk. - const viewedLocalAgent = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; - const needsBootstrap = isLocalAgentTask(viewedLocalAgent) && viewedLocalAgent.retain && !viewedLocalAgent.diskLoaded; + const viewedLocalAgent = viewingAgentTaskId + ? tasks[viewingAgentTaskId] + : undefined + const needsBootstrap = + isLocalAgentTask(viewedLocalAgent) && + viewedLocalAgent.retain && + !viewedLocalAgent.diskLoaded useEffect(() => { - if (!viewingAgentTaskId || !needsBootstrap) return; - const taskId = viewingAgentTaskId; + if (!viewingAgentTaskId || !needsBootstrap) return + const taskId = viewingAgentTaskId void getAgentTranscript(asAgentId(taskId)).then(result => { setAppState(prev => { - const t = prev.tasks[taskId]; - if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev; - const live = t.messages ?? []; - const liveUuids = new Set(live.map(m => m.uuid)); - const diskOnly = result ? result.messages.filter(m => !liveUuids.has(m.uuid)) : []; + const t = prev.tasks[taskId] + if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev + const live = t.messages ?? [] + const liveUuids = new Set(live.map(m => m.uuid)) + const diskOnly = result + ? result.messages.filter(m => !liveUuids.has(m.uuid)) + : [] return { ...prev, tasks: { @@ -664,29 +1023,36 @@ export function REPL({ [taskId]: { ...t, messages: [...diskOnly, ...live], - diskLoaded: true - } - } - }; - }); - }); - }, [viewingAgentTaskId, needsBootstrap, setAppState]); - const store = useAppStateStore(); - const terminal = useTerminalNotification(); - const mainLoopModel = useMainLoopModel(); + diskLoaded: true, + }, + }, + } + }) + }) + }, [viewingAgentTaskId, needsBootstrap, setAppState]) + + const store = useAppStateStore() + const terminal = useTerminalNotification() + const mainLoopModel = useMainLoopModel() // Note: standaloneAgentContext is initialized in main.tsx (via initialState) or // ResumeConversation.tsx (via setAppState before rendering REPL) to avoid // useEffect-based state initialization on mount (per CLAUDE.md guidelines) // Local state for commands (hot-reloadable when skill files change) - const [localCommands, setLocalCommands] = useState(initialCommands); + const [localCommands, setLocalCommands] = useState(initialCommands) // Watch for skill file changes and reload all commands - useSkillsChange(isRemoteSession ? undefined : getProjectRoot(), setLocalCommands); + useSkillsChange( + isRemoteSession ? undefined : getProjectRoot(), + setLocalCommands, + ) // Track proactive mode for tools dependency - SleepTool filters by proactive state - const proactiveActive = React.useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE); + const proactiveActive = React.useSyncExternalStore( + proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, + proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE, + ) // BriefTool.isEnabled() reads getUserMsgOptIn() from bootstrap state, which // /brief flips mid-session alongside isBriefOnly. The memo below needs a @@ -694,99 +1060,113 @@ export function REPL({ // the AppState mirror that triggers the re-render. Without this, toggling // /brief mid-session leaves the stale tool list (no SendUserMessage) and // the model emits plain text the brief filter hides. - const isBriefOnly = useAppState(s => s.isBriefOnly); - const localTools = useMemo(() => getTools(toolPermissionContext), [toolPermissionContext, proactiveActive, isBriefOnly]); - useKickOffCheckAndDisableBypassPermissionsIfNeeded(); - useKickOffCheckAndDisableAutoModeIfNeeded(); - const [dynamicMcpConfig, setDynamicMcpConfig] = useState | undefined>(initialDynamicMcpConfig); - const onChangeDynamicMcpConfig = useCallback((config: Record) => { - setDynamicMcpConfig(config); - }, [setDynamicMcpConfig]); - const [screen, setScreen] = useState('prompt'); - const [showAllInTranscript, setShowAllInTranscript] = useState(false); + const isBriefOnly = useAppState(s => s.isBriefOnly) + + const localTools = useMemo( + () => getTools(toolPermissionContext), + [toolPermissionContext, proactiveActive, isBriefOnly], + ) + + useKickOffCheckAndDisableBypassPermissionsIfNeeded() + useKickOffCheckAndDisableAutoModeIfNeeded() + + const [dynamicMcpConfig, setDynamicMcpConfig] = useState< + Record | undefined + >(initialDynamicMcpConfig) + + const onChangeDynamicMcpConfig = useCallback( + (config: Record) => { + setDynamicMcpConfig(config) + }, + [setDynamicMcpConfig], + ) + + const [screen, setScreen] = useState('prompt') + const [showAllInTranscript, setShowAllInTranscript] = useState(false) // [ forces the dump-to-scrollback path inside transcript mode. Separate // from CLAUDE_CODE_NO_FLICKER=0 (which is process-lifetime) — this is // ephemeral, reset on transcript exit. Diagnostic escape hatch so // terminal/tmux native cmd-F can search the full flat render. - const [dumpMode, setDumpMode] = useState(false); + const [dumpMode, setDumpMode] = useState(false) // v-for-editor render progress. Inline in the footer — notifications // render inside PromptInput which isn't mounted in transcript. - const [editorStatus, setEditorStatus] = useState(''); + const [editorStatus, setEditorStatus] = useState('') // Incremented on transcript exit. Async v-render captures this at start; // each status write no-ops if stale (user left transcript mid-render — // the stable setState would otherwise stamp a ghost toast into the next // session). Also clears any pending 4s auto-clear. - const editorGenRef = useRef(0); - const editorTimerRef = useRef | undefined>(undefined); - const editorRenderingRef = useRef(false); - const { - addNotification, - removeNotification - } = useNotifications(); + const editorGenRef = useRef(0) + const editorTimerRef = useRef | undefined>( + undefined, + ) + const editorRenderingRef = useRef(false) + const { addNotification, removeNotification } = useNotifications() // eslint-disable-next-line prefer-const - let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP; - const mcpClients = useMergedClients(initialMcpClients, mcp.clients); + let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP + + const mcpClients = useMergedClients(initialMcpClients, mcp.clients) // IDE integration - const [ideSelection, setIDESelection] = useState(undefined); - const [ideToInstallExtension, setIDEToInstallExtension] = useState(null); - const [ideInstallationStatus, setIDEInstallationStatus] = useState(null); - const [showIdeOnboarding, setShowIdeOnboarding] = useState(false); + const [ideSelection, setIDESelection] = useState( + undefined, + ) + const [ideToInstallExtension, setIDEToInstallExtension] = + useState(null) + const [ideInstallationStatus, setIDEInstallationStatus] = + useState(null) + const [showIdeOnboarding, setShowIdeOnboarding] = useState(false) // Dead code elimination: model switch callout state (ant-only) const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => { - if ((process.env.USER_TYPE) === 'ant') { - return shouldShowAntModelSwitch(); + if (process.env.USER_TYPE === 'ant') { + return shouldShowAntModelSwitch() } - return false; - }); - const [showEffortCallout, setShowEffortCallout] = useState(() => shouldShowEffortCallout(mainLoopModel)); - const showRemoteCallout = useAppState(s => s.showRemoteCallout); - const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => shouldShowDesktopUpsellStartup()); + return false + }) + const [showEffortCallout, setShowEffortCallout] = useState(() => + shouldShowEffortCallout(mainLoopModel), + ) + const showRemoteCallout = useAppState(s => s.showRemoteCallout) + const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => + shouldShowDesktopUpsellStartup(), + ) // notifications - useModelMigrationNotifications(); - useCanSwitchToExistingSubscription(); - useIDEStatusIndicator({ - ideSelection, - mcpClients, - ideInstallationStatus - }); - useMcpConnectivityStatus({ - mcpClients - }); - useAutoModeUnavailableNotification(); - usePluginInstallationStatus(); - usePluginAutoupdateNotification(); - useSettingsErrors(); - useRateLimitWarningNotification(mainLoopModel); - useFastModeNotification(); - useDeprecationWarningNotification(mainLoopModel); - useNpmDeprecationNotification(); - useAntOrgWarningNotification(); - useInstallMessages(); - useChromeExtensionNotification(); - useOfficialMarketplaceNotification(); - useLspInitializationNotification(); - useTeammateLifecycleNotification(); + useModelMigrationNotifications() + useCanSwitchToExistingSubscription() + useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus }) + useMcpConnectivityStatus({ mcpClients }) + useAutoModeUnavailableNotification() + usePluginInstallationStatus() + usePluginAutoupdateNotification() + useSettingsErrors() + useRateLimitWarningNotification(mainLoopModel) + useFastModeNotification() + useDeprecationWarningNotification(mainLoopModel) + useNpmDeprecationNotification() + useAntOrgWarningNotification() + useInstallMessages() + useChromeExtensionNotification() + useOfficialMarketplaceNotification() + useLspInitializationNotification() + useTeammateLifecycleNotification() const { recommendation: lspRecommendation, - handleResponse: handleLspResponse - } = useLspPluginRecommendation(); + handleResponse: handleLspResponse, + } = useLspPluginRecommendation() const { recommendation: hintRecommendation, - handleResponse: handleHintResponse - } = useClaudeCodeHintRecommendation(); + handleResponse: handleHintResponse, + } = useClaudeCodeHintRecommendation() // Memoize the combined initial tools array to prevent reference changes const combinedInitialTools = useMemo(() => { - return [...localTools, ...initialTools]; - }, [localTools, initialTools]); + return [...localTools, ...initialTools] + }, [localTools, initialTools]) // Initialize plugin management - useManagePlugins({ - enabled: !isRemoteSession - }); - const tasksV2 = useTasksV2WithCollapseEffect(); + useManagePlugins({ enabled: !isRemoteSession }) + + const tasksV2 = useTasksV2WithCollapseEffect() // Start background plugin installations @@ -797,47 +1177,71 @@ export function REPL({ // This ensures that plugin installations from repository and user settings only // happen after explicit user consent to trust the current working directory. useEffect(() => { - if (isRemoteSession) return; - void performStartupChecks(setAppState); - }, [setAppState, isRemoteSession]); + if (isRemoteSession) return + void performStartupChecks(setAppState) + }, [setAppState, isRemoteSession]) // Allow Claude in Chrome MCP to send prompts through MCP notifications // and sync permission mode changes to the Chrome extension - usePromptsFromClaudeInChrome(isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, toolPermissionContext.mode); + usePromptsFromClaudeInChrome( + isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, + toolPermissionContext.mode, + ) // Initialize swarm features: teammate hooks and context // Handles both fresh spawns and resumed teammate sessions useSwarmInitialization(setAppState, initialMessages, { - enabled: !isRemoteSession - }); - const mergedTools = useMergedTools(combinedInitialTools, mcp.tools, toolPermissionContext); + enabled: !isRemoteSession, + }) + + const mergedTools = useMergedTools( + combinedInitialTools, + mcp.tools, + toolPermissionContext, + ) // Apply agent tool restrictions if mainThreadAgentDefinition is set - const { - tools, - allowedAgentTypes - } = useMemo(() => { + const { tools, allowedAgentTypes } = useMemo(() => { if (!mainThreadAgentDefinition) { return { tools: mergedTools, - allowedAgentTypes: undefined as string[] | undefined - }; + allowedAgentTypes: undefined as string[] | undefined, + } } - const resolved = resolveAgentTools(mainThreadAgentDefinition, mergedTools, false, true); + const resolved = resolveAgentTools( + mainThreadAgentDefinition, + mergedTools, + false, + true, + ) return { tools: resolved.resolvedTools, - allowedAgentTypes: resolved.allowedAgentTypes - }; - }, [mainThreadAgentDefinition, mergedTools]); + allowedAgentTypes: resolved.allowedAgentTypes, + } + }, [mainThreadAgentDefinition, mergedTools]) // Merge commands from local state, plugins, and MCP - const commandsWithPlugins = useMergedCommands(localCommands, plugins.commands as Command[]); - const mergedCommands = useMergedCommands(commandsWithPlugins, mcp.commands as Command[]); + const commandsWithPlugins = useMergedCommands( + localCommands, + plugins.commands as Command[], + ) + const mergedCommands = useMergedCommands( + commandsWithPlugins, + mcp.commands as Command[], + ) // Filter out all commands if disableSlashCommands is true - const commands = useMemo(() => disableSlashCommands ? [] : mergedCommands, [disableSlashCommands, mergedCommands]); - useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients); - useIdeSelection(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection); - const [streamMode, setStreamMode] = useState('responding'); + const commands = useMemo( + () => (disableSlashCommands ? [] : mergedCommands), + [disableSlashCommands, mergedCommands], + ) + + useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients) + useIdeSelection( + isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, + setIDESelection, + ) + + const [streamMode, setStreamMode] = useState('responding') // Ref mirror so onSubmit can read the latest value without adding // streamMode to its deps. streamMode flips between // requesting/responding/tool-use ~10x per turn during streaming; having it @@ -846,99 +1250,115 @@ export function REPL({ // invalidation. The only consumers inside callbacks are debug logging and // telemetry (handlePromptSubmit.ts), so a stale-by-one-render value is // harmless — but ref mirrors sync on every render anyway so it's fresh. - const streamModeRef = useRef(streamMode); - streamModeRef.current = streamMode; - const [streamingToolUses, setStreamingToolUses] = useState([]); - const [streamingThinking, setStreamingThinking] = useState(null); + const streamModeRef = useRef(streamMode) + streamModeRef.current = streamMode + const [streamingToolUses, setStreamingToolUses] = useState< + StreamingToolUse[] + >([]) + const [streamingThinking, setStreamingThinking] = + useState(null) // Auto-hide streaming thinking after 30 seconds of being completed useEffect(() => { - if (streamingThinking && !streamingThinking.isStreaming && streamingThinking.streamingEndedAt) { - const elapsed = Date.now() - streamingThinking.streamingEndedAt; - const remaining = 30000 - elapsed; + if ( + streamingThinking && + !streamingThinking.isStreaming && + streamingThinking.streamingEndedAt + ) { + const elapsed = Date.now() - streamingThinking.streamingEndedAt + const remaining = 30000 - elapsed if (remaining > 0) { - const timer = setTimeout(setStreamingThinking, remaining, null); - return () => clearTimeout(timer); + const timer = setTimeout(setStreamingThinking, remaining, null) + return () => clearTimeout(timer) } else { - setStreamingThinking(null); + setStreamingThinking(null) } } - }, [streamingThinking]); - const [abortController, setAbortController] = useState(null); + }, [streamingThinking]) + + const [abortController, setAbortController] = + useState(null) // Ref that always points to the current abort controller, used by the // REPL bridge to abort the active query when a remote interrupt arrives. - const abortControllerRef = useRef(null); - abortControllerRef.current = abortController; + const abortControllerRef = useRef(null) + abortControllerRef.current = abortController // Ref for the bridge result callback — set after useReplBridge initializes, // read in the onQuery finally block to notify mobile clients that a turn ended. - const sendBridgeResultRef = useRef<() => void>(() => {}); + const sendBridgeResultRef = useRef<() => void>(() => {}) // Ref for the synchronous restore callback — set after restoreMessageSync is // defined, read in the onQuery finally block for auto-restore on interrupt. - const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {}); + const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {}) // Ref to the fullscreen layout's scroll box for keyboard scrolling. // Null when fullscreen mode is disabled (ref never attached). - const scrollRef = useRef(null); + const scrollRef = useRef(null) // Separate ref for the modal slot's inner ScrollBox — passed through // FullscreenLayout → ModalContext so Tabs can attach it to its own // ScrollBox for tall content (e.g. /status's MCP-server list). NOT // keyboard-driven — ScrollKeybindingHandler stays on the outer ref so // PgUp/PgDn/wheel always scroll the transcript behind the modal. // Plumbing kept for future modal-scroll wiring. - const modalScrollRef = useRef(null); + const modalScrollRef = useRef(null) // Timestamp of the last user-initiated scroll (wheel, PgUp/PgDn, ctrl+u, // End/Home, G, drag-to-scroll). Stamped in composedOnScroll — the single // chokepoint ScrollKeybindingHandler calls for every user scroll action. // Programmatic scrolls (repinScroll's scrollToBottom, sticky auto-follow) // do NOT go through composedOnScroll, so they don't stamp this. Ref not // state: no re-render on every wheel tick. - const lastUserScrollTsRef = useRef(0); + const lastUserScrollTsRef = useRef(0) // Synchronous state machine for the query lifecycle. Replaces the // error-prone dual-state pattern where isLoading (React state, async // batched) and isQueryRunning (ref, sync) could desync. See QueryGuard.ts. - const queryGuard = React.useRef(new QueryGuard()).current; + const queryGuard = React.useRef(new QueryGuard()).current // Subscribe to the guard — true during dispatching or running. // This is the single source of truth for "is a local query in flight". - const isQueryActive = React.useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot); + const isQueryActive = React.useSyncExternalStore( + queryGuard.subscribe, + queryGuard.getSnapshot, + ) // Separate loading flag for operations outside the local query guard: // remote sessions (useRemoteSession / useDirectConnect) and foregrounded // background tasks (useSessionBackgrounding). These don't route through // onQuery / queryGuard, so they need their own spinner-visibility state. // Initialize true if remote mode with initial prompt (CCR processing it). - const [isExternalLoading, setIsExternalLoadingRaw] = React.useState(remoteSessionConfig?.hasInitialPrompt ?? false); + const [isExternalLoading, setIsExternalLoadingRaw] = React.useState( + remoteSessionConfig?.hasInitialPrompt ?? false, + ) // Derived: any loading source active. Read-only — no setter. Local query // loading is driven by queryGuard (reserve/tryStart/end/cancelReservation), // external loading by setIsExternalLoading. - const isLoading = isQueryActive || isExternalLoading; + const isLoading = isQueryActive || isExternalLoading // Elapsed time is computed by SpinnerWithVerb from these refs on each // animation frame, avoiding a useInterval that re-renders the entire REPL. - const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState(undefined); + const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState< + string | undefined + >(undefined) // messagesRef.current.length at the moment userInputOnProcessing was set. // The placeholder hides once displayedMessages grows past this — i.e. the // real user message has landed in the visible transcript. - const userInputBaselineRef = React.useRef(0); + const userInputBaselineRef = React.useRef(0) // True while the submitted prompt is being processed but its user message // hasn't reached setMessages yet. setMessages uses this to keep the // baseline in sync when unrelated async messages (bridge status, hook // results, scheduled tasks) land during that window. - const userMessagePendingRef = React.useRef(false); + const userMessagePendingRef = React.useRef(false) // Wall-clock time tracking refs for accurate elapsed time calculation - const loadingStartTimeRef = React.useRef(0); - const totalPausedMsRef = React.useRef(0); - const pauseStartTimeRef = React.useRef(null); + const loadingStartTimeRef = React.useRef(0) + const totalPausedMsRef = React.useRef(0) + const pauseStartTimeRef = React.useRef(null) const resetTimingRefs = React.useCallback(() => { - loadingStartTimeRef.current = Date.now(); - totalPausedMsRef.current = 0; - pauseStartTimeRef.current = null; - }, []); + loadingStartTimeRef.current = Date.now() + totalPausedMsRef.current = 0 + pauseStartTimeRef.current = null + }, []) // Reset timing refs inline when isQueryActive transitions false→true. // queryGuard.reserve() (in executeUserInput) fires BEFORE processUserInput's @@ -948,52 +1368,57 @@ export function REPL({ // first render where isQueryActive is observed true — the same render that // first shows the spinner — so the ref is correct by the time the spinner // reads it. See INC-4549. - const wasQueryActiveRef = React.useRef(false); + const wasQueryActiveRef = React.useRef(false) if (isQueryActive && !wasQueryActiveRef.current) { - resetTimingRefs(); + resetTimingRefs() } - wasQueryActiveRef.current = isQueryActive; + wasQueryActiveRef.current = isQueryActive // Wrapper for setIsExternalLoading that resets timing refs on transition // to true — SpinnerWithVerb reads these for elapsed time, so they must be // reset for remote sessions / foregrounded tasks too (not just local // queries, which reset them in onQuery). Without this, a remote-only // session would show ~56 years elapsed (Date.now() - 0). - const setIsExternalLoading = React.useCallback((value: boolean) => { - setIsExternalLoadingRaw(value); - if (value) resetTimingRefs(); - }, [resetTimingRefs]); + const setIsExternalLoading = React.useCallback( + (value: boolean) => { + setIsExternalLoadingRaw(value) + if (value) resetTimingRefs() + }, + [resetTimingRefs], + ) // Start time of the first turn that had swarm teammates running // Used to compute total elapsed time (including teammate execution) for the deferred message - const swarmStartTimeRef = React.useRef(null); - const swarmBudgetInfoRef = React.useRef<{ - tokens: number; - limit: number; - nudges: number; - } | undefined>(undefined); + const swarmStartTimeRef = React.useRef(null) + const swarmBudgetInfoRef = React.useRef< + { tokens: number; limit: number; nudges: number } | undefined + >(undefined) // Ref to track current focusedInputDialog for use in callbacks // This avoids stale closures when checking dialog state in timer callbacks - const focusedInputDialogRef = React.useRef>(undefined); + const focusedInputDialogRef = + React.useRef>(undefined) // How long after the last keystroke before deferred dialogs are shown - const PROMPT_SUPPRESSION_MS = 1500; + const PROMPT_SUPPRESSION_MS = 1500 // True when user is actively typing — defers interrupt dialogs so keystrokes // don't accidentally dismiss or answer a permission prompt the user hasn't read yet. - const [isPromptInputActive, setIsPromptInputActive] = React.useState(false); - const [autoUpdaterResult, setAutoUpdaterResult] = useState(null); + const [isPromptInputActive, setIsPromptInputActive] = React.useState(false) + + const [autoUpdaterResult, setAutoUpdaterResult] = + useState(null) + useEffect(() => { if (autoUpdaterResult?.notifications) { autoUpdaterResult.notifications.forEach(notification => { addNotification({ key: 'auto-updater-notification', text: notification, - priority: 'low' - }); - }); + priority: 'low', + }) + }) } - }, [autoUpdaterResult, addNotification]); + }, [autoUpdaterResult, addNotification]) // tmux + fullscreen + `mouse off`: one-time hint that wheel won't scroll. // We no longer mutate tmux's session-scoped mouse option (it poisoned @@ -1005,50 +1430,52 @@ export function REPL({ addNotification({ key: 'tmux-mouse-hint', text: hint, - priority: 'low' - }); + priority: 'low', + }) } - }); + }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const [showUndercoverCallout, setShowUndercoverCallout] = useState(false); + }, []) + + const [showUndercoverCallout, setShowUndercoverCallout] = useState(false) useEffect(() => { - if ((process.env.USER_TYPE) === 'ant') { + if (process.env.USER_TYPE === 'ant') { void (async () => { // Wait for repo classification to settle (memoized, no-op if primed). - const { - isInternalModelRepo - } = await import('../utils/commitAttribution.js'); - await isInternalModelRepo(); - const { - shouldShowUndercoverAutoNotice - } = await import('../utils/undercover.js'); + const { isInternalModelRepo } = await import( + '../utils/commitAttribution.js' + ) + await isInternalModelRepo() + const { shouldShowUndercoverAutoNotice } = await import( + '../utils/undercover.js' + ) if (shouldShowUndercoverAutoNotice()) { - setShowUndercoverCallout(true); + setShowUndercoverCallout(true) } - })(); + })() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []) + const [toolJSX, setToolJSXInternal] = useState<{ - jsx: React.ReactNode | null; - shouldHidePromptInput: boolean; - shouldContinueAnimation?: true; - showSpinner?: boolean; - isLocalJSXCommand?: boolean; - isImmediate?: boolean; - } | null>(null); + jsx: React.ReactNode | null + shouldHidePromptInput: boolean + shouldContinueAnimation?: true + showSpinner?: boolean + isLocalJSXCommand?: boolean + isImmediate?: boolean + } | null>(null) // Track local JSX commands separately so tools can't overwrite them. // This enables "immediate" commands (like /btw) to persist while Claude is processing. const localJSXCommandRef = useRef<{ - jsx: React.ReactNode | null; - shouldHidePromptInput: boolean; - shouldContinueAnimation?: true; - showSpinner?: boolean; - isLocalJSXCommand: true; - } | null>(null); + jsx: React.ReactNode | null + shouldHidePromptInput: boolean + shouldContinueAnimation?: true + showSpinner?: boolean + isLocalJSXCommand: true + } | null>(null) // Wrapper for setToolJSX that preserves local JSX commands (like /btw). // When a local JSX command is active, we ignore updates from tools @@ -1059,89 +1486,108 @@ export function REPL({ // 2. Set `isLocalJSXCommand: true` when calling setToolJSX in the command's JSX // 3. In the onDone callback, use `setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true })` // to explicitly clear the overlay when the user dismisses it - const setToolJSX = useCallback((args: { - jsx: React.ReactNode | null; - shouldHidePromptInput: boolean; - shouldContinueAnimation?: true; - showSpinner?: boolean; - isLocalJSXCommand?: boolean; - clearLocalJSX?: boolean; - } | null) => { - // If setting a local JSX command, store it in the ref - if (args?.isLocalJSXCommand) { - const { - clearLocalJSX: _, - ...rest - } = args; - localJSXCommandRef.current = { - ...rest, - isLocalJSXCommand: true - }; - setToolJSXInternal(rest); - return; - } + const setToolJSX = useCallback( + ( + args: { + jsx: React.ReactNode | null + shouldHidePromptInput: boolean + shouldContinueAnimation?: true + showSpinner?: boolean + isLocalJSXCommand?: boolean + clearLocalJSX?: boolean + } | null, + ) => { + // If setting a local JSX command, store it in the ref + if (args?.isLocalJSXCommand) { + const { clearLocalJSX: _, ...rest } = args + localJSXCommandRef.current = { ...rest, isLocalJSXCommand: true } + setToolJSXInternal(rest) + return + } - // If there's an active local JSX command in the ref - if (localJSXCommandRef.current) { - // Allow clearing only if explicitly requested (from onDone callbacks) - if (args?.clearLocalJSX) { - localJSXCommandRef.current = null; - setToolJSXInternal(null); - return; + // If there's an active local JSX command in the ref + if (localJSXCommandRef.current) { + // Allow clearing only if explicitly requested (from onDone callbacks) + if (args?.clearLocalJSX) { + localJSXCommandRef.current = null + setToolJSXInternal(null) + return + } + // Otherwise, keep the local JSX command visible - ignore tool updates + return } - // Otherwise, keep the local JSX command visible - ignore tool updates - return; - } - // No active local JSX command, allow any update - if (args?.clearLocalJSX) { - setToolJSXInternal(null); - return; - } - setToolJSXInternal(args); - }, []); - const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState([]); + // No active local JSX command, allow any update + if (args?.clearLocalJSX) { + setToolJSXInternal(null) + return + } + setToolJSXInternal(args) + }, + [], + ) + const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState< + ToolUseConfirm[] + >([]) // Sticky footer JSX registered by permission request components (currently // only ExitPlanModePermissionRequest). Renders in FullscreenLayout's `bottom` // slot so response options stay visible while the user scrolls a long plan. - const [permissionStickyFooter, setPermissionStickyFooter] = useState(null); - const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] = useState void; - }>>([]); - const [promptQueue, setPromptQueue] = useState void; - reject: (error: Error) => void; - }>>([]); + const [permissionStickyFooter, setPermissionStickyFooter] = + useState(null) + const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] = + useState< + Array<{ + hostPattern: NetworkHostPattern + resolvePromise: (allowConnection: boolean) => void + }> + >([]) + const [promptQueue, setPromptQueue] = useState< + Array<{ + request: PromptRequest + title: string + toolInputSummary?: string | null + resolve: (response: PromptResponse) => void + reject: (error: Error) => void + }> + >([]) // Track bridge cleanup functions for sandbox permission requests so the // local dialog handler can cancel the remote prompt when the local user // responds first. Keyed by host to support concurrent same-host requests. - const sandboxBridgeCleanupRef = useRef void>>>(new Map()); + const sandboxBridgeCleanupRef = useRef void>>>( + new Map(), + ) // -- Terminal title management // Session title (set via /rename or restored on resume) wins over // the agent name, which wins over the Haiku-extracted topic; // all fall back to the product name. - const terminalTitleFromRename = useAppState(s => s.settings.terminalTitleFromRename) !== false; - const sessionTitle = terminalTitleFromRename ? getCurrentSessionTitle(getSessionId()) : undefined; - const [haikuTitle, setHaikuTitle] = useState(); + const terminalTitleFromRename = + useAppState(s => s.settings.terminalTitleFromRename) !== false + const sessionTitle = terminalTitleFromRename + ? getCurrentSessionTitle(getSessionId()) + : undefined + const [haikuTitle, setHaikuTitle] = useState() // Gates the one-shot Haiku call that generates the tab title. Seeded true // on resume (initialMessages present) so we don't re-title a resumed // session from mid-conversation context. - const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0); - const agentTitle = mainThreadAgentDefinition?.agentType; - const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code'; - const isWaitingForApproval = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || pendingWorkerRequest || pendingSandboxRequest; + const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0) + const agentTitle = mainThreadAgentDefinition?.agentType + const terminalTitle = + sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code' + const isWaitingForApproval = + toolUseConfirmQueue.length > 0 || + promptQueue.length > 0 || + pendingWorkerRequest || + pendingSandboxRequest // Local-jsx commands (like /plugin, /config) show user-facing dialogs that // wait for input. Require jsx != null — if the flag is stuck true but jsx // is null, treat as not-showing so TextInput focus and queue processor // aren't deadlocked by a phantom overlay. - const isShowingLocalJSXCommand = toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null; - const titleIsAnimating = isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand; + const isShowingLocalJSXCommand = + toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null + const titleIsAnimating = + isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand // Title animation state lives in so the 960ms tick // doesn't re-render REPL. titleDisabled/terminalTitle are still computed // here because onQueryImpl reads them (background session description, @@ -1150,44 +1596,66 @@ export function REPL({ // Prevent macOS from sleeping while Claude is working useEffect(() => { if (isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand) { - startPreventSleep(); - return () => stopPreventSleep(); + startPreventSleep() + return () => stopPreventSleep() } - }, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand]); - const sessionStatus: TabStatusKind = isWaitingForApproval || isShowingLocalJSXCommand ? 'waiting' : isLoading ? 'busy' : 'idle'; - const waitingFor = sessionStatus !== 'waiting' ? undefined : toolUseConfirmQueue.length > 0 ? `approve ${toolUseConfirmQueue[0]!.tool.name}` : pendingWorkerRequest ? 'worker request' : pendingSandboxRequest ? 'sandbox request' : isShowingLocalJSXCommand ? 'dialog open' : 'input needed'; + }, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand]) + + const sessionStatus: TabStatusKind = + isWaitingForApproval || isShowingLocalJSXCommand + ? 'waiting' + : isLoading + ? 'busy' + : 'idle' + + const waitingFor = + sessionStatus !== 'waiting' + ? undefined + : toolUseConfirmQueue.length > 0 + ? `approve ${toolUseConfirmQueue[0]!.tool.name}` + : pendingWorkerRequest + ? 'worker request' + : pendingSandboxRequest + ? 'sandbox request' + : isShowingLocalJSXCommand + ? 'dialog open' + : 'input needed' // Push status to the PID file for `claude ps`. Fire-and-forget; ps falls // back to transcript-tail derivation when this is missing/stale. useEffect(() => { if (feature('BG_SESSIONS')) { - void updateSessionActivity({ - status: sessionStatus, - waitingFor - }); + void updateSessionActivity({ status: sessionStatus, waitingFor }) } - }, [sessionStatus, waitingFor]); + }, [sessionStatus, waitingFor]) // 3P default: off — OSC 21337 is ant-only while the spec stabilizes. // Gated so we can roll back if the sidebar indicator conflicts with // the title spinner in terminals that render both. When the flag is // on, the user-facing config setting controls whether it's active. - const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false); - const showStatusInTerminalTab = tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false); - useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus); + const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_terminal_sidebar', + false, + ) + const showStatusInTerminalTab = + tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false) + useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus) // Register the leader's setToolUseConfirmQueue for in-process teammates useEffect(() => { - registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue); - return () => unregisterLeaderToolUseConfirmQueue(); - }, [setToolUseConfirmQueue]); - const [messages, rawSetMessages] = useState(initialMessages ?? []); - const messagesRef = useRef(messages); + registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue) + return () => unregisterLeaderToolUseConfirmQueue() + }, [setToolUseConfirmQueue]) + + const [messages, rawSetMessages] = useState( + initialMessages ?? [], + ) + const messagesRef = useRef(messages) // Stores the willowMode variant that was shown (or false if no hint shown). // Captured at hint_shown time so hint_converted telemetry reports the same // variant — the GrowthBook value shouldn't change mid-session, but reading // it once guarantees consistency between the paired events. - const idleHintShownRef = useRef(false); + const idleHintShownRef = useRef(false) // Wrap setMessages so messagesRef is always current the instant the // call returns — not when React later processes the batch. Apply the // updater eagerly against the ref, then hand React the computed value @@ -1197,42 +1665,49 @@ export function REPL({ // truth, React state is the render projection. Without this, paths // that queue functional updaters then synchronously read the ref // (e.g. handleSpeculationAccept → onQuery) see stale data. - const setMessages = useCallback((action: React.SetStateAction) => { - const prev = messagesRef.current; - const next = typeof action === 'function' ? action(messagesRef.current) : action; - messagesRef.current = next; - if (next.length < userInputBaselineRef.current) { - // Shrank (compact/rewind/clear) — clamp so placeholderText's length - // check can't go stale. - userInputBaselineRef.current = 0; - } else if (next.length > prev.length && userMessagePendingRef.current) { - // Grew while the submitted user message hasn't landed yet. If the - // added messages don't include it (bridge status, hook results, - // scheduled tasks landing async during processUserInputBase), bump - // baseline so the placeholder stays visible. Once the user message - // lands, stop tracking — later additions (assistant stream) should - // not re-show the placeholder. - const delta = next.length - prev.length; - const added = prev.length === 0 || next[0] === prev[0] ? next.slice(-delta) : next.slice(0, delta); - if (added.some(isHumanTurn)) { - userMessagePendingRef.current = false; - } else { - userInputBaselineRef.current = next.length; + const setMessages = useCallback( + (action: React.SetStateAction) => { + const prev = messagesRef.current + const next = + typeof action === 'function' ? action(messagesRef.current) : action + messagesRef.current = next + if (next.length < userInputBaselineRef.current) { + // Shrank (compact/rewind/clear) — clamp so placeholderText's length + // check can't go stale. + userInputBaselineRef.current = 0 + } else if (next.length > prev.length && userMessagePendingRef.current) { + // Grew while the submitted user message hasn't landed yet. If the + // added messages don't include it (bridge status, hook results, + // scheduled tasks landing async during processUserInputBase), bump + // baseline so the placeholder stays visible. Once the user message + // lands, stop tracking — later additions (assistant stream) should + // not re-show the placeholder. + const delta = next.length - prev.length + const added = + prev.length === 0 || next[0] === prev[0] + ? next.slice(-delta) + : next.slice(0, delta) + if (added.some(isHumanTurn)) { + userMessagePendingRef.current = false + } else { + userInputBaselineRef.current = next.length + } } - } - rawSetMessages(next); - }, []); + rawSetMessages(next) + }, + [], + ) // Capture the baseline message count alongside the placeholder text so // the render can hide it once displayedMessages grows past the baseline. const setUserInputOnProcessing = useCallback((input: string | undefined) => { if (input !== undefined) { - userInputBaselineRef.current = messagesRef.current.length; - userMessagePendingRef.current = true; + userInputBaselineRef.current = messagesRef.current.length + userMessagePendingRef.current = true } else { - userMessagePendingRef.current = false; + userMessagePendingRef.current = false } - setUserInputOnProcessingRaw(input); - }, []); + setUserInputOnProcessingRaw(input) + }, []) // Fullscreen: track the unseen-divider position. dividerIndex changes // only ~twice/scroll-session (first scroll-away + repin). pillVisible // and stickyPrompt now live in FullscreenLayout — they subscribe to @@ -1243,149 +1718,186 @@ export function REPL({ onScrollAway, onRepin, jumpToNew, - shiftDivider - } = useUnseenDivider(messages.length); + shiftDivider, + } = useUnseenDivider(messages.length) if (feature('AWAY_SUMMARY')) { // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAwaySummary(messages, setMessages, isLoading); + useAwaySummary(messages, setMessages, isLoading) } - const [cursor, setCursor] = useState(null); - const cursorNavRef = useRef(null); + const [cursor, setCursor] = useState(null) + const cursorNavRef = useRef(null) // Memoized so Messages' React.memo holds. - const unseenDivider = useMemo(() => computeUnseenDivider(messages, dividerIndex), - // eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind - [dividerIndex, messages.length]); + const unseenDivider = useMemo( + () => computeUnseenDivider(messages, dividerIndex), + // eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind + [dividerIndex, messages.length], + ) // Re-pin scroll to bottom and clear the unseen-messages baseline. Called // on any user-driven return-to-live action (submit, type-into-empty, // overlay appear/dismiss). const repinScroll = useCallback(() => { - scrollRef.current?.scrollToBottom(); - onRepin(); - setCursor(null); - }, [onRepin, setCursor]); + scrollRef.current?.scrollToBottom() + onRepin() + setCursor(null) + }, [onRepin, setCursor]) // Backstop for the submit-handler repin at onSubmit. If a buffered stdin // event (wheel/drag) races between handler-fire and state-commit, the // handler's scrollToBottom can be undone. This effect fires on the render // where the user's message actually lands — tied to React's commit cycle, // so it can't race with stdin. Keyed on lastMsg identity (not messages.length) // so useAssistantHistory's prepends don't spuriously repin. - const lastMsg = messages.at(-1); - const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg); + const lastMsg = messages.at(-1) + const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg) useEffect(() => { if (lastMsgIsHuman) { - repinScroll(); + repinScroll() } - }, [lastMsgIsHuman, lastMsg, repinScroll]); + }, [lastMsgIsHuman, lastMsg, repinScroll]) // Assistant-chat: lazy-load remote history on scroll-up. No-op unless // KAIROS build + config.viewerOnly. feature() is build-time constant so // the branch is dead-code-eliminated in non-KAIROS builds (same pattern // as useUnseenDivider above). - const { - maybeLoadOlder - } = feature('KAIROS') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAssistantHistory({ - config: remoteSessionConfig, - setMessages, - scrollRef, - onPrepend: shiftDivider - }) : HISTORY_STUB; + const { maybeLoadOlder } = feature('KAIROS') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAssistantHistory({ + config: remoteSessionConfig, + setMessages, + scrollRef, + onPrepend: shiftDivider, + }) + : HISTORY_STUB // Compose useUnseenDivider's callbacks with the lazy-load trigger. - const composedOnScroll = useCallback((sticky: boolean, handle: ScrollBoxHandle) => { - lastUserScrollTsRef.current = Date.now(); - if (sticky) { - onRepin(); - } else { - onScrollAway(handle); - if (feature('KAIROS')) maybeLoadOlder(handle); - // Dismiss the companion bubble on scroll — it's absolute-positioned - // at bottom-right and covers transcript content. Scrolling = user is - // trying to read something under it. - if (feature('BUDDY')) { - setAppState(prev => prev.companionReaction === undefined ? prev : { - ...prev, - companionReaction: undefined - }); + const composedOnScroll = useCallback( + (sticky: boolean, handle: ScrollBoxHandle) => { + lastUserScrollTsRef.current = Date.now() + if (sticky) { + onRepin() + } else { + onScrollAway(handle) + if (feature('KAIROS')) maybeLoadOlder(handle) + // Dismiss the companion bubble on scroll — it's absolute-positioned + // at bottom-right and covers transcript content. Scrolling = user is + // trying to read something under it. + if (feature('BUDDY')) { + setAppState(prev => + prev.companionReaction === undefined + ? prev + : { ...prev, companionReaction: undefined }, + ) + } } - } - }, [onRepin, onScrollAway, maybeLoadOlder, setAppState]); + }, + [onRepin, onScrollAway, maybeLoadOlder, setAppState], + ) // Deferred SessionStart hook messages — REPL renders immediately and // hook messages are injected when they resolve. awaitPendingHooks() // must be called before the first API call so the model sees hook context. - const awaitPendingHooks = useDeferredHookMessages(pendingHookMessages, setMessages); + const awaitPendingHooks = useDeferredHookMessages( + pendingHookMessages, + setMessages, + ) // Deferred messages for the Messages component — renders at transition // priority so the reconciler yields every 5ms, keeping input responsive // while the expensive message processing pipeline runs. - const deferredMessages = useDeferredValue(messages); - const deferredBehind = messages.length - deferredMessages.length; + const deferredMessages = useDeferredValue(messages) + const deferredBehind = messages.length - deferredMessages.length if (deferredBehind > 0) { - logForDebugging(`[useDeferredValue] Messages deferred by ${deferredBehind} (${deferredMessages.length}→${messages.length})`); + logForDebugging( + `[useDeferredValue] Messages deferred by ${deferredBehind} (${deferredMessages.length}→${messages.length})`, + ) } // Frozen state for transcript mode - stores lengths instead of cloning arrays for memory efficiency const [frozenTranscriptState, setFrozenTranscriptState] = useState<{ - messagesLength: number; - streamingToolUsesLength: number; - } | null>(null); + messagesLength: number + streamingToolUsesLength: number + } | null>(null) // Initialize input with any early input that was captured before REPL was ready. // Using lazy initialization ensures cursor offset is set correctly in PromptInput. - const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput()); - const inputValueRef = useRef(inputValue); - inputValueRef.current = inputValue; + const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput()) + const inputValueRef = useRef(inputValue) + inputValueRef.current = inputValue const insertTextRef = useRef<{ - insert: (text: string) => void; - setInputWithCursor: (value: string, cursor: number) => void; - cursorOffset: number; - } | null>(null); + insert: (text: string) => void + setInputWithCursor: (value: string, cursor: number) => void + cursorOffset: number + } | null>(null) // Wrap setInputValue to co-locate suppression state updates. // Both setState calls happen in the same synchronous context so React // batches them into a single render, eliminating the extra render that // the previous useEffect → setState pattern caused. - const setInputValue = useCallback((value: string) => { - if (trySuggestBgPRIntercept(inputValueRef.current, value)) return; - // In fullscreen mode, typing into an empty prompt re-pins scroll to - // bottom. Only fires on empty→non-empty so scrolling up to reference - // something while composing a message doesn't yank the view back on - // every keystroke. Restores the pre-fullscreen muscle memory of - // typing to snap back to the end of the conversation. - // Skipped if the user scrolled within the last 3s — they're actively - // reading, not lost. lastUserScrollTsRef starts at 0 so the first- - // ever keypress (no scroll yet) always repins. - if (inputValueRef.current === '' && value !== '' && Date.now() - lastUserScrollTsRef.current >= RECENT_SCROLL_REPIN_WINDOW_MS) { - repinScroll(); - } - // Sync ref immediately (like setMessages) so callers that read - // inputValueRef before React commits — e.g. the auto-restore finally - // block's `=== ''` guard — see the fresh value, not the stale render. - inputValueRef.current = value; - setInputValueRaw(value); - setIsPromptInputActive(value.trim().length > 0); - }, [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept]); + const setInputValue = useCallback( + (value: string) => { + if (trySuggestBgPRIntercept(inputValueRef.current, value)) return + // In fullscreen mode, typing into an empty prompt re-pins scroll to + // bottom. Only fires on empty→non-empty so scrolling up to reference + // something while composing a message doesn't yank the view back on + // every keystroke. Restores the pre-fullscreen muscle memory of + // typing to snap back to the end of the conversation. + // Skipped if the user scrolled within the last 3s — they're actively + // reading, not lost. lastUserScrollTsRef starts at 0 so the first- + // ever keypress (no scroll yet) always repins. + if ( + inputValueRef.current === '' && + value !== '' && + Date.now() - lastUserScrollTsRef.current >= + RECENT_SCROLL_REPIN_WINDOW_MS + ) { + repinScroll() + } + // Sync ref immediately (like setMessages) so callers that read + // inputValueRef before React commits — e.g. the auto-restore finally + // block's `=== ''` guard — see the fresh value, not the stale render. + inputValueRef.current = value + setInputValueRaw(value) + setIsPromptInputActive(value.trim().length > 0) + }, + [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept], + ) // Schedule a timeout to stop suppressing dialogs after the user stops typing. // Only manages the timeout — the immediate activation is handled by setInputValue above. useEffect(() => { - if (inputValue.trim().length === 0) return; - const timer = setTimeout(setIsPromptInputActive, PROMPT_SUPPRESSION_MS, false); - return () => clearTimeout(timer); - }, [inputValue]); - const [inputMode, setInputMode] = useState('prompt'); - const [stashedPrompt, setStashedPrompt] = useState<{ - text: string; - cursorOffset: number; - pastedContents: Record; - } | undefined>(); + if (inputValue.trim().length === 0) return + const timer = setTimeout( + setIsPromptInputActive, + PROMPT_SUPPRESSION_MS, + false, + ) + return () => clearTimeout(timer) + }, [inputValue]) + + const [inputMode, setInputMode] = useState('prompt') + const [stashedPrompt, setStashedPrompt] = useState< + | { + text: string + cursorOffset: number + pastedContents: Record + } + | undefined + >() // Callback to filter commands based on CCR's available slash commands - const handleRemoteInit = useCallback((remoteSlashCommands: string[]) => { - const remoteCommandSet = new Set(remoteSlashCommands); - // Keep commands that CCR lists OR that are in the local-safe set - setLocalCommands(prev => prev.filter(cmd => remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd))); - }, [setLocalCommands]); - const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState>(new Set()); - const hasInterruptibleToolInProgressRef = useRef(false); + const handleRemoteInit = useCallback( + (remoteSlashCommands: string[]) => { + const remoteCommandSet = new Set(remoteSlashCommands) + // Keep commands that CCR lists OR that are in the local-safe set + setLocalCommands(prev => + prev.filter( + cmd => + remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd), + ), + ) + }, + [setLocalCommands], + ) + + const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState>( + new Set(), + ) + const hasInterruptibleToolInProgressRef = useRef(false) // Remote session hook - manages WebSocket connection and message handling for --remote mode const remoteSession = useRemoteSession({ @@ -1397,8 +1909,8 @@ export function REPL({ tools: combinedInitialTools, setStreamingToolUses, setStreamMode, - setInProgressToolUseIDs - }); + setInProgressToolUseIDs, + }) // Direct connect hook - manages WebSocket to a claude server for `claude connect` mode const directConnect = useDirectConnect({ @@ -1406,8 +1918,8 @@ export function REPL({ setMessages, setIsLoading: setIsExternalLoading, setToolUseConfirmQueue, - tools: combinedInitialTools - }); + tools: combinedInitialTools, + }) // SSH session hook - manages ssh child process for `claude ssh` mode. // Same callback shape as useDirectConnect; only the transport under the @@ -1417,79 +1929,101 @@ export function REPL({ setMessages, setIsLoading: setIsExternalLoading, setToolUseConfirmQueue, - tools: combinedInitialTools - }); + tools: combinedInitialTools, + }) // Use whichever remote mode is active - const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession; - const [pastedContents, setPastedContents] = useState>({}); - const [submitCount, setSubmitCount] = useState(0); + const activeRemote = sshRemote.isRemoteMode + ? sshRemote + : directConnect.isRemoteMode + ? directConnect + : remoteSession + + const [pastedContents, setPastedContents] = useState< + Record + >({}) + const [submitCount, setSubmitCount] = useState(0) // Ref instead of state to avoid triggering React re-renders on every // streaming text_delta. The spinner reads this via its animation timer. - const responseLengthRef = useRef(0); + const responseLengthRef = useRef(0) // API performance metrics ref for ant-only spinner display (TTFT/OTPS). // Accumulates metrics from all API requests in a turn for P50 aggregation. - const apiMetricsRef = useRef>([]); + const apiMetricsRef = useRef< + Array<{ + ttftMs: number + firstTokenTime: number + lastTokenTime: number + responseLengthBaseline: number + // Tracks responseLengthRef at the time of the last content addition. + // Updated by both streaming deltas and subagent message content. + // lastTokenTime is also updated at the same time, so the OTPS + // denominator correctly includes subagent processing time. + endResponseLength: number + }> + >([]) const setResponseLength = useCallback((f: (prev: number) => number) => { - const prev = responseLengthRef.current; - responseLengthRef.current = f(prev); + const prev = responseLengthRef.current + responseLengthRef.current = f(prev) // When content is added (not a compaction reset), update the latest // metrics entry so OTPS reflects all content generation activity. // Updating lastTokenTime here ensures the denominator includes both // streaming time AND subagent execution time, preventing inflation. if (responseLengthRef.current > prev) { - const entries = apiMetricsRef.current; + const entries = apiMetricsRef.current if (entries.length > 0) { - const lastEntry = entries.at(-1)!; - lastEntry.lastTokenTime = Date.now(); - lastEntry.endResponseLength = responseLengthRef.current; + const lastEntry = entries.at(-1)! + lastEntry.lastTokenTime = Date.now() + lastEntry.endResponseLength = responseLengthRef.current } } - }, []); + }, []) // Streaming text display: set state directly per delta (Ink's 16ms render // throttle batches rapid updates). Cleared on message arrival (messages.ts) // so displayedMessages switches from deferredMessages to messages atomically. - const [streamingText, setStreamingText] = useState(null); - const reducedMotion = useAppState(s => s.settings.prefersReducedMotion) ?? false; - const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug(); - const onStreamingText = useCallback((f: (current: string | null) => string | null) => { - if (!showStreamingText) return; - setStreamingText(f); - }, [showStreamingText]); + const [streamingText, setStreamingText] = useState(null) + const reducedMotion = + useAppState(s => s.settings.prefersReducedMotion) ?? false + const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug() + const onStreamingText = useCallback( + (f: (current: string | null) => string | null) => { + if (!showStreamingText) return + setStreamingText(f) + }, + [showStreamingText], + ) // Hide the in-progress source line so text streams line-by-line, not // char-by-char. lastIndexOf returns -1 when no newline, giving '' → null. // Guard on showStreamingText so toggling reducedMotion mid-stream // immediately hides the streaming preview. - const visibleStreamingText = streamingText && showStreamingText ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null : null; - const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0); - const [spinnerMessage, setSpinnerMessage] = useState(null); - const [spinnerColor, setSpinnerColor] = useState(null); - const [spinnerShimmerColor, setSpinnerShimmerColor] = useState(null); - const [isMessageSelectorVisible, setIsMessageSelectorVisible] = useState(false); - const [messageSelectorPreselect, setMessageSelectorPreselect] = useState(undefined); - const [showCostDialog, setShowCostDialog] = useState(false); - const [conversationId, setConversationId] = useState(randomUUID()); + const visibleStreamingText = + streamingText && showStreamingText + ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null + : null + + const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0) + const [spinnerMessage, setSpinnerMessage] = useState(null) + const [spinnerColor, setSpinnerColor] = useState(null) + const [spinnerShimmerColor, setSpinnerShimmerColor] = useState< + keyof Theme | null + >(null) + const [isMessageSelectorVisible, setIsMessageSelectorVisible] = + useState(false) + const [messageSelectorPreselect, setMessageSelectorPreselect] = useState< + UserMessage | undefined + >(undefined) + const [showCostDialog, setShowCostDialog] = useState(false) + const [conversationId, setConversationId] = useState(randomUUID()) // Idle-return dialog: shown when user submits after a long idle gap const [idleReturnPending, setIdleReturnPending] = useState<{ - input: string; - idleMinutes: number; - } | null>(null); - const skipIdleCheckRef = useRef(false); - const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime); - lastQueryCompletionTimeRef.current = lastQueryCompletionTime; + input: string + idleMinutes: number + } | null>(null) + const skipIdleCheckRef = useRef(false) + const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime) + lastQueryCompletionTimeRef.current = lastQueryCompletionTime // Aggregate tool result budget: per-conversation decision tracking. // When the GrowthBook flag is on, query.ts enforces the budget; when @@ -1503,13 +2037,21 @@ export function REPL({ // For large resumed sessions, reconstruction does O(messages × blocks) // work; we only want that once. const [contentReplacementStateRef] = useState(() => ({ - current: provisionContentReplacementState(initialMessages, initialContentReplacements) - })); - const [haveShownCostDialog, setHaveShownCostDialog] = useState(getGlobalConfig().hasAcknowledgedCostThreshold); - const [vimMode, setVimMode] = useState('INSERT'); - const [showBashesDialog, setShowBashesDialog] = useState(false); - const [isSearchingHistory, setIsSearchingHistory] = useState(false); - const [isHelpOpen, setIsHelpOpen] = useState(false); + current: provisionContentReplacementState( + initialMessages, + initialContentReplacements, + ), + })) + + const [haveShownCostDialog, setHaveShownCostDialog] = useState( + getGlobalConfig().hasAcknowledgedCostThreshold, + ) + const [vimMode, setVimMode] = useState('INSERT') + const [showBashesDialog, setShowBashesDialog] = useState( + false, + ) + const [isSearchingHistory, setIsSearchingHistory] = useState(false) + const [isHelpOpen, setIsHelpOpen] = useState(false) // showBashesDialog is REPL-level so it survives PromptInput unmounting. // When ultraplan approval fires while the pill dialog is open, PromptInput @@ -1518,51 +2060,48 @@ export function REPL({ // (the completed ultraplan task has been filtered out). Close it here. useEffect(() => { if (ultraplanPendingChoice && showBashesDialog) { - setShowBashesDialog(false); + setShowBashesDialog(false) } - }, [ultraplanPendingChoice, showBashesDialog]); - const isTerminalFocused = useTerminalFocus(); - const terminalFocusRef = useRef(isTerminalFocused); - terminalFocusRef.current = isTerminalFocused; - const [theme] = useTheme(); + }, [ultraplanPendingChoice, showBashesDialog]) + + const isTerminalFocused = useTerminalFocus() + const terminalFocusRef = useRef(isTerminalFocused) + terminalFocusRef.current = isTerminalFocused + + const [theme] = useTheme() // resetLoadingState runs twice per turn (onQueryImpl tail + onQuery finally). // Without this guard, both calls pick a tip → two recordShownTip → two // saveGlobalConfig writes back-to-back. Reset at submit in onSubmit. - const tipPickedThisTurnRef = React.useRef(false); + const tipPickedThisTurnRef = React.useRef(false) const pickNewSpinnerTip = useCallback(() => { - if (tipPickedThisTurnRef.current) return; - tipPickedThisTurnRef.current = true; - const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current); + if (tipPickedThisTurnRef.current) return + tipPickedThisTurnRef.current = true + const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current) for (const tool of extractBashToolsFromMessages(newMessages)) { - bashTools.current.add(tool); + bashTools.current.add(tool) } - bashToolsProcessedIdx.current = messagesRef.current.length; + bashToolsProcessedIdx.current = messagesRef.current.length void getTipToShowOnSpinner({ theme, readFileState: readFileState.current, - bashTools: bashTools.current + bashTools: bashTools.current, }).then(async tip => { if (tip) { - const content = await tip.content({ - theme - }); + const content = await tip.content({ theme }) setAppState(prev => ({ ...prev, - spinnerTip: content - })); - recordShownTip(tip); + spinnerTip: content, + })) + recordShownTip(tip) } else { setAppState(prev => { - if (prev.spinnerTip === undefined) return prev; - return { - ...prev, - spinnerTip: undefined - }; - }); + if (prev.spinnerTip === undefined) return prev + return { ...prev, spinnerTip: undefined } + }) } - }); - }, [setAppState, theme]); + }) + }, [setAppState, theme]) // Resets UI loading state. Does NOT call onTurnComplete - that should be // called explicitly only when a query turn actually completes. @@ -1571,159 +2110,226 @@ export function REPL({ // queryGuard.end() (onQuery finally) or cancelReservation() (executeUserInput // finally) have already transitioned the guard to idle by the time this runs. // External loading (remote/backgrounding) is reset separately by those hooks. - setIsExternalLoading(false); - setUserInputOnProcessing(undefined); - responseLengthRef.current = 0; - apiMetricsRef.current = []; - setStreamingText(null); - setStreamingToolUses([]); - setSpinnerMessage(null); - setSpinnerColor(null); - setSpinnerShimmerColor(null); - pickNewSpinnerTip(); - endInteractionSpan(); + setIsExternalLoading(false) + setUserInputOnProcessing(undefined) + responseLengthRef.current = 0 + apiMetricsRef.current = [] + setStreamingText(null) + setStreamingToolUses([]) + setSpinnerMessage(null) + setSpinnerColor(null) + setSpinnerShimmerColor(null) + pickNewSpinnerTip() + endInteractionSpan() // Speculative bash classifier checks are only valid for the current // turn's commands — clear after each turn to avoid accumulating // Promise chains for unconsumed checks (denied/aborted paths). - clearSpeculativeChecks(); - }, [pickNewSpinnerTip]); + clearSpeculativeChecks() + }, [pickNewSpinnerTip]) // Session backgrounding — hook is below, after getToolUseContext - const hasRunningTeammates = useMemo(() => getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'), [tasks]); + const hasRunningTeammates = useMemo( + () => getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'), + [tasks], + ) // Show deferred turn duration message once all swarm teammates finish useEffect(() => { if (!hasRunningTeammates && swarmStartTimeRef.current !== null) { - const totalMs = Date.now() - swarmStartTimeRef.current; - const deferredBudget = swarmBudgetInfoRef.current; - swarmStartTimeRef.current = null; - swarmBudgetInfoRef.current = undefined; - setMessages(prev => [...prev, createTurnDurationMessage(totalMs, deferredBudget, - // Count only what recordTranscript will persist — ephemeral - // progress ticks and non-ant attachments are filtered by - // isLoggableMessage and never reach disk. Using raw prev.length - // would make checkResumeConsistency report false delta<0 for - // every turn that ran a progress-emitting tool. - count(prev, isLoggableMessage))]); + const totalMs = Date.now() - swarmStartTimeRef.current + const deferredBudget = swarmBudgetInfoRef.current + swarmStartTimeRef.current = null + swarmBudgetInfoRef.current = undefined + setMessages(prev => [ + ...prev, + createTurnDurationMessage( + totalMs, + deferredBudget, + // Count only what recordTranscript will persist — ephemeral + // progress ticks and non-ant attachments are filtered by + // isLoggableMessage and never reach disk. Using raw prev.length + // would make checkResumeConsistency report false delta<0 for + // every turn that ran a progress-emitting tool. + count(prev, isLoggableMessage), + ), + ]) } - }, [hasRunningTeammates, setMessages]); + }, [hasRunningTeammates, setMessages]) // Show auto permissions warning when entering auto mode // (either via Shift+Tab toggle or on startup). Debounced to avoid // flashing when the user is cycling through modes quickly. // Only shown 3 times total across sessions. - const safeYoloMessageShownRef = useRef(false); + const safeYoloMessageShownRef = useRef(false) useEffect(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { if (toolPermissionContext.mode !== 'auto') { - safeYoloMessageShownRef.current = false; - return; + safeYoloMessageShownRef.current = false + return } - if (safeYoloMessageShownRef.current) return; - const config = getGlobalConfig(); - const count = config.autoPermissionsNotificationCount ?? 0; - if (count >= 3) return; - const timer = setTimeout((ref, setMessages) => { - ref.current = true; - saveGlobalConfig(prev => { - const prevCount = prev.autoPermissionsNotificationCount ?? 0; - if (prevCount >= 3) return prev; - return { + if (safeYoloMessageShownRef.current) return + const config = getGlobalConfig() + const count = config.autoPermissionsNotificationCount ?? 0 + if (count >= 3) return + const timer = setTimeout( + (ref, setMessages) => { + ref.current = true + saveGlobalConfig(prev => { + const prevCount = prev.autoPermissionsNotificationCount ?? 0 + if (prevCount >= 3) return prev + return { + ...prev, + autoPermissionsNotificationCount: prevCount + 1, + } + }) + setMessages(prev => [ ...prev, - autoPermissionsNotificationCount: prevCount + 1 - }; - }); - setMessages(prev => [...prev, createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning')]); - }, 800, safeYoloMessageShownRef, setMessages); - return () => clearTimeout(timer); + createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning'), + ]) + }, + 800, + safeYoloMessageShownRef, + setMessages, + ) + return () => clearTimeout(timer) } - }, [toolPermissionContext.mode, setMessages]); + }, [toolPermissionContext.mode, setMessages]) // If worktree creation was slow and sparse-checkout isn't configured, // nudge the user toward settings.worktree.sparsePaths. - const worktreeTipShownRef = useRef(false); + const worktreeTipShownRef = useRef(false) useEffect(() => { - if (worktreeTipShownRef.current) return; - const wt = getCurrentWorktreeSession(); - if (!wt?.creationDurationMs || wt.usedSparsePaths) return; - if (wt.creationDurationMs < 15_000) return; - worktreeTipShownRef.current = true; - const secs = Math.round(wt.creationDurationMs / 1000); - setMessages(prev => [...prev, createSystemMessage(`Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, 'info')]); - }, [setMessages]); + if (worktreeTipShownRef.current) return + const wt = getCurrentWorktreeSession() + if (!wt?.creationDurationMs || wt.usedSparsePaths) return + if (wt.creationDurationMs < 15_000) return + worktreeTipShownRef.current = true + const secs = Math.round(wt.creationDurationMs / 1000) + setMessages(prev => [ + ...prev, + createSystemMessage( + `Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, + 'info', + ), + ]) + }, [setMessages]) // Hide spinner when the only in-progress tool is Sleep const onlySleepToolActive = useMemo(() => { - const lastAssistant = messages.findLast(m => m.type === 'assistant'); - if (lastAssistant?.type !== 'assistant') return false; - const content = lastAssistant.message.content; - if (typeof content === 'string') return false; - const contentArr = content as unknown as Array<{ type: string; id?: string; name?: string; [key: string]: unknown }>; - const inProgressToolUses = contentArr.filter(b => b.type === 'tool_use' && b.id && inProgressToolUseIDs.has(b.id)); - return inProgressToolUses.length > 0 && inProgressToolUses.every(b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME); - }, [messages, inProgressToolUseIDs]); + const lastAssistant = messages.findLast(m => m.type === 'assistant') + if (lastAssistant?.type !== 'assistant') return false + const inProgressToolUses = lastAssistant.message.content.filter( + b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id), + ) + return ( + inProgressToolUses.length > 0 && + inProgressToolUses.every( + b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME, + ) + ) + }, [messages, inProgressToolUseIDs]) + const { onBeforeQuery: mrOnBeforeQuery, onTurnComplete: mrOnTurnComplete, - render: mrRender + render: mrRender, } = useMoreRight({ enabled: moreRightEnabled, setMessages, inputValue, setInputValue, - setToolJSX - }); - const showSpinner = (!toolJSX || toolJSX.showSpinner === true) && toolUseConfirmQueue.length === 0 && promptQueue.length === 0 && ( - // Show spinner during input processing, API call, while teammates are running, - // or while pending task notifications are queued (prevents spinner bounce between consecutive notifications) - isLoading || userInputOnProcessing || hasRunningTeammates || - // Keep spinner visible while task notifications are queued for processing. - // Without this, the spinner briefly disappears between consecutive notifications - // (e.g., multiple background agents completing in rapid succession) because - // isLoading goes false momentarily between processing each one. - getCommandQueueLength() > 0) && - // Hide spinner when waiting for leader to approve permission request - !pendingWorkerRequest && !onlySleepToolActive && ( - // Hide spinner when streaming text is visible (the text IS the feedback), - // but keep it when isBriefOnly suppresses the streaming text display - !visibleStreamingText || isBriefOnly); + setToolJSX, + }) + + const showSpinner = + (!toolJSX || toolJSX.showSpinner === true) && + toolUseConfirmQueue.length === 0 && + promptQueue.length === 0 && + // Show spinner during input processing, API call, while teammates are running, + // or while pending task notifications are queued (prevents spinner bounce between consecutive notifications) + (isLoading || + userInputOnProcessing || + hasRunningTeammates || + // Keep spinner visible while task notifications are queued for processing. + // Without this, the spinner briefly disappears between consecutive notifications + // (e.g., multiple background agents completing in rapid succession) because + // isLoading goes false momentarily between processing each one. + getCommandQueueLength() > 0) && + // Hide spinner when waiting for leader to approve permission request + !pendingWorkerRequest && + !onlySleepToolActive && + // Hide spinner when streaming text is visible (the text IS the feedback), + // but keep it when isBriefOnly suppresses the streaming text display + (!visibleStreamingText || isBriefOnly) // Check if any permission or ask question prompt is currently visible // This is used to prevent the survey from opening while prompts are active - const hasActivePrompt = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || sandboxPermissionRequestQueue.length > 0 || elicitation.queue.length > 0 || workerSandboxPermissions.queue.length > 0; - const feedbackSurveyOriginal = useFeedbackSurvey(messages, isLoading, submitCount, 'session', hasActivePrompt); - const skillImprovementSurvey = useSkillImprovementSurvey(setMessages); - const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount); + const hasActivePrompt = + toolUseConfirmQueue.length > 0 || + promptQueue.length > 0 || + sandboxPermissionRequestQueue.length > 0 || + elicitation.queue.length > 0 || + workerSandboxPermissions.queue.length > 0 + + const feedbackSurveyOriginal = useFeedbackSurvey( + messages, + isLoading, + submitCount, + 'session', + hasActivePrompt, + ) + + const skillImprovementSurvey = useSkillImprovementSurvey(setMessages) + + const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount) // Wrap feedback survey handler to trigger auto-run /issue - const feedbackSurvey = useMemo(() => ({ - ...feedbackSurveyOriginal, - handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => { - // Reset the ref when a new survey response comes in - didAutoRunIssueRef.current = false; - const showedTranscriptPrompt = feedbackSurveyOriginal.handleSelect(selected); - // Auto-run /issue for "bad" if transcript prompt wasn't shown - if (selected === 'bad' && !showedTranscriptPrompt && shouldAutoRunIssue('feedback_survey_bad')) { - setAutoRunIssueReason('feedback_survey_bad'); - didAutoRunIssueRef.current = true; - } - } - }), [feedbackSurveyOriginal]); + const feedbackSurvey = useMemo( + () => ({ + ...feedbackSurveyOriginal, + handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => { + // Reset the ref when a new survey response comes in + didAutoRunIssueRef.current = false + const showedTranscriptPrompt = + feedbackSurveyOriginal.handleSelect(selected) + // Auto-run /issue for "bad" if transcript prompt wasn't shown + if ( + selected === 'bad' && + !showedTranscriptPrompt && + shouldAutoRunIssue('feedback_survey_bad') + ) { + setAutoRunIssueReason('feedback_survey_bad') + didAutoRunIssueRef.current = true + } + }, + }), + [feedbackSurveyOriginal], + ) // Post-compact survey: shown after compaction if feature gate is enabled - const postCompactSurvey = usePostCompactSurvey(messages, isLoading, hasActivePrompt, { - enabled: !isRemoteSession - }); + const postCompactSurvey = usePostCompactSurvey( + messages, + isLoading, + hasActivePrompt, + { enabled: !isRemoteSession }, + ) // Memory survey: shown when the assistant mentions memory and a memory file // was read this conversation const memorySurvey = useMemorySurvey(messages, isLoading, hasActivePrompt, { - enabled: !isRemoteSession - }); + enabled: !isRemoteSession, + }) // Frustration detection: show transcript sharing prompt after detecting frustrated messages - const frustrationDetection = useFrustrationDetection(messages, isLoading, hasActivePrompt, feedbackSurvey.state !== 'closed' || postCompactSurvey.state !== 'closed' || memorySurvey.state !== 'closed'); + const frustrationDetection = useFrustrationDetection( + messages, + isLoading, + hasActivePrompt, + feedbackSurvey.state !== 'closed' || + postCompactSurvey.state !== 'closed' || + memorySurvey.state !== 'closed', + ) // Initialize IDE integration useIDEIntegration({ @@ -1731,366 +2337,464 @@ export function REPL({ ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, - setIDEInstallationState: setIDEInstallationStatus - }); - useFileHistorySnapshotInit(initialFileHistorySnapshots, fileHistory, fileHistoryState => setAppState(prev => ({ - ...prev, - fileHistory: fileHistoryState - }))); - const resume = useCallback(async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { - const resumeStart = performance.now(); - try { - // Deserialize messages to properly clean up the conversation - // This filters unresolved tool uses and adds a synthetic assistant message if needed - const messages = deserializeMessages(log.messages); - - // Match coordinator/normal mode to the resumed session - if (feature('COORDINATOR_MODE')) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ - const warning = coordinatorModule.matchSessionMode(log.mode); - if (warning) { - // Re-derive agent definitions after mode switch so built-in agents - // reflect the new coordinator/normal mode + setIDEInstallationState: setIDEInstallationStatus, + }) + + useFileHistorySnapshotInit( + initialFileHistorySnapshots, + fileHistory, + fileHistoryState => + setAppState(prev => ({ + ...prev, + fileHistory: fileHistoryState, + })), + ) + + const resume = useCallback( + async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { + const resumeStart = performance.now() + try { + // Deserialize messages to properly clean up the conversation + // This filters unresolved tool uses and adds a synthetic assistant message if needed + const messages = deserializeMessages(log.messages) + + // Match coordinator/normal mode to the resumed session + if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - getAgentDefinitionsWithOverrides, - getActiveAgentsFromList - } = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js'); + const coordinatorModule = + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') /* eslint-enable @typescript-eslint/no-require-imports */ - getAgentDefinitionsWithOverrides.cache.clear?.(); - const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd()); - setAppState(prev => ({ - ...prev, - agentDefinitions: { - ...freshAgentDefs, - allAgents: freshAgentDefs.allAgents, - activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents) - } - })); - messages.push(createSystemMessage(warning, 'warning')); + const warning = coordinatorModule.matchSessionMode(log.mode) + if (warning) { + // Re-derive agent definitions after mode switch so built-in agents + // reflect the new coordinator/normal mode + /* eslint-disable @typescript-eslint/no-require-imports */ + const { + getAgentDefinitionsWithOverrides, + getActiveAgentsFromList, + } = + require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + getAgentDefinitionsWithOverrides.cache.clear?.() + const freshAgentDefs = await getAgentDefinitionsWithOverrides( + getOriginalCwd(), + ) + + setAppState(prev => ({ + ...prev, + agentDefinitions: { + ...freshAgentDefs, + allAgents: freshAgentDefs.allAgents, + activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), + }, + })) + messages.push(createSystemMessage(warning, 'warning')) + } } - } - // Fire SessionEnd hooks for the current session before starting the - // resumed one, mirroring the /clear flow in conversation.ts. - const sessionEndTimeoutMs = getSessionEndHookTimeoutMs(); - await executeSessionEndHooks('resume', { - getAppState: () => store.getState(), - setAppState, - signal: AbortSignal.timeout(sessionEndTimeoutMs), - timeoutMs: sessionEndTimeoutMs - }); - - // Process session start hooks for resume - const hookMessages = await processSessionStartHooks('resume', { - sessionId, - agentType: mainThreadAgentDefinition?.agentType, - model: mainLoopModel - }); - - // Append hook messages to the conversation - messages.push(...hookMessages); - // For forks, generate a new plan slug and copy the plan content so the - // original and forked sessions don't clobber each other's plan files. - // For regular resumes, reuse the original session's plan slug. - if (entrypoint === 'fork') { - void copyPlanForFork(log, asSessionId(sessionId)); - } else { - void copyPlanForResume(log, asSessionId(sessionId)); - } + // Fire SessionEnd hooks for the current session before starting the + // resumed one, mirroring the /clear flow in conversation.ts. + const sessionEndTimeoutMs = getSessionEndHookTimeoutMs() + await executeSessionEndHooks('resume', { + getAppState: () => store.getState(), + setAppState, + signal: AbortSignal.timeout(sessionEndTimeoutMs), + timeoutMs: sessionEndTimeoutMs, + }) - // Restore file history and attribution state from the resumed conversation - restoreSessionStateFromLog(log, setAppState); - if (log.fileHistorySnapshots) { - void copyFileHistoryForResume(log); - } + // Process session start hooks for resume + const hookMessages = await processSessionStartHooks('resume', { + sessionId, + agentType: mainThreadAgentDefinition?.agentType, + model: mainLoopModel, + }) - // Restore agent setting from the resumed conversation - // Always reset to the new session's values (or clear if none), - // matching the standaloneAgentContext pattern below - const { - agentDefinition: restoredAgent - } = restoreAgentFromSession(log.agentSetting, initialMainThreadAgentDefinition, agentDefinitions); - setMainThreadAgentDefinition(restoredAgent); - setAppState(prev => ({ - ...prev, - agent: restoredAgent?.agentType - })); + // Append hook messages to the conversation + messages.push(...hookMessages) + // For forks, generate a new plan slug and copy the plan content so the + // original and forked sessions don't clobber each other's plan files. + // For regular resumes, reuse the original session's plan slug. + if (entrypoint === 'fork') { + void copyPlanForFork(log, asSessionId(sessionId)) + } else { + void copyPlanForResume(log, asSessionId(sessionId)) + } - // Restore standalone agent context from the resumed conversation - // Always reset to the new session's values (or clear if none) - setAppState(prev => ({ - ...prev, - standaloneAgentContext: computeStandaloneAgentContext(log.agentName, log.agentColor) - })); - void updateSessionName(log.agentName); - - // Restore read file state from the message history - restoreReadFileState(messages, log.projectPath ?? getOriginalCwd()); - - // Clear any active loading state (no queryId since we're not in a query) - resetLoadingState(); - setAbortController(null); - setConversationId(sessionId); - - // Get target session's costs BEFORE saving current session - // (saveCurrentSessionCosts overwrites the config, so we need to read first) - const targetSessionCosts = getStoredSessionCosts(sessionId); - - // Save current session's costs before switching to avoid losing accumulated costs - saveCurrentSessionCosts(); - - // Reset cost state for clean slate before restoring target session - resetCostState(); - - // Switch session (id + project dir atomically). fullPath may point to - // a different project (cross-worktree, /branch); null derives from - // current originalCwd. - switchSession(asSessionId(sessionId), log.fullPath ? dirname(log.fullPath) : null); - // Rename asciicast recording to match the resumed session ID - const { - renameRecordingForSession - } = await import('../utils/asciicast.js'); - await renameRecordingForSession(); - await resetSessionFilePointer(); - - // Clear then restore session metadata so it's re-appended on exit via - // reAppendSessionMetadata. clearSessionMetadata must be called first: - // restoreSessionMetadata only sets-if-truthy, so without the clear, - // a session without an agent name would inherit the previous session's - // cached name and write it to the wrong transcript on first message. - clearSessionMetadata(); - restoreSessionMetadata(log); - // Resumed sessions shouldn't re-title from mid-conversation context - // (same reasoning as the useRef seed), and the previous session's - // Haiku title shouldn't carry over. - haikuTitleAttemptedRef.current = true; - setHaikuTitle(undefined); - - // Exit any worktree a prior /resume entered, then cd into the one - // this session was in. Without the exit, resuming from worktree B - // to non-worktree C leaves cwd/currentWorktreeSession stale; - // resuming B→C where C is also a worktree fails entirely - // (getCurrentWorktreeSession guard blocks the switch). - // - // Skipped for /branch: forkLog doesn't carry worktreeSession, so - // this would kick the user out of a worktree they're still working - // in. Same fork skip as processResumedConversation for the adopt — - // fork materializes its own file via recordTranscript on REPL mount. - if (entrypoint !== 'fork') { - exitRestoredWorktree(); - restoreWorktreeForResume(log.worktreeSession); - adoptResumedSessionFile(); - void restoreRemoteAgentTasks({ - abortController: new AbortController(), - getAppState: () => store.getState(), - setAppState - }); - } else { - // Fork: same re-persist as /clear (conversation.ts). The clear - // above wiped currentSessionWorktree, forkLog doesn't carry it, - // and the process is still in the same worktree. - const ws = getCurrentWorktreeSession(); - if (ws) saveWorktreeState(ws); - } + // Restore file history and attribution state from the resumed conversation + restoreSessionStateFromLog(log, setAppState) + if (log.fileHistorySnapshots) { + void copyFileHistoryForResume(log) + } - // Persist the current mode so future resumes know what mode this session was in - if (feature('COORDINATOR_MODE')) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { - saveMode - } = require('../utils/sessionStorage.js'); - const { - isCoordinatorMode - } = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ - saveMode(isCoordinatorMode() ? 'coordinator' : 'normal'); - } + // Restore agent setting from the resumed conversation + // Always reset to the new session's values (or clear if none), + // matching the standaloneAgentContext pattern below + const { agentDefinition: restoredAgent } = restoreAgentFromSession( + log.agentSetting, + initialMainThreadAgentDefinition, + agentDefinitions, + ) + setMainThreadAgentDefinition(restoredAgent) + setAppState(prev => ({ ...prev, agent: restoredAgent?.agentType })) + + // Restore standalone agent context from the resumed conversation + // Always reset to the new session's values (or clear if none) + setAppState(prev => ({ + ...prev, + standaloneAgentContext: computeStandaloneAgentContext( + log.agentName, + log.agentColor, + ), + })) + void updateSessionName(log.agentName) + + // Restore read file state from the message history + restoreReadFileState(messages, log.projectPath ?? getOriginalCwd()) + + // Clear any active loading state (no queryId since we're not in a query) + resetLoadingState() + setAbortController(null) + + setConversationId(sessionId) + + // Get target session's costs BEFORE saving current session + // (saveCurrentSessionCosts overwrites the config, so we need to read first) + const targetSessionCosts = getStoredSessionCosts(sessionId) + + // Save current session's costs before switching to avoid losing accumulated costs + saveCurrentSessionCosts() + + // Reset cost state for clean slate before restoring target session + resetCostState() + + // Switch session (id + project dir atomically). fullPath may point to + // a different project (cross-worktree, /branch); null derives from + // current originalCwd. + switchSession( + asSessionId(sessionId), + log.fullPath ? dirname(log.fullPath) : null, + ) + // Rename asciicast recording to match the resumed session ID + const { renameRecordingForSession } = await import( + '../utils/asciicast.js' + ) + await renameRecordingForSession() + await resetSessionFilePointer() + + // Clear then restore session metadata so it's re-appended on exit via + // reAppendSessionMetadata. clearSessionMetadata must be called first: + // restoreSessionMetadata only sets-if-truthy, so without the clear, + // a session without an agent name would inherit the previous session's + // cached name and write it to the wrong transcript on first message. + clearSessionMetadata() + restoreSessionMetadata(log) + // Resumed sessions shouldn't re-title from mid-conversation context + // (same reasoning as the useRef seed), and the previous session's + // Haiku title shouldn't carry over. + haikuTitleAttemptedRef.current = true + setHaikuTitle(undefined) + + // Exit any worktree a prior /resume entered, then cd into the one + // this session was in. Without the exit, resuming from worktree B + // to non-worktree C leaves cwd/currentWorktreeSession stale; + // resuming B→C where C is also a worktree fails entirely + // (getCurrentWorktreeSession guard blocks the switch). + // + // Skipped for /branch: forkLog doesn't carry worktreeSession, so + // this would kick the user out of a worktree they're still working + // in. Same fork skip as processResumedConversation for the adopt — + // fork materializes its own file via recordTranscript on REPL mount. + if (entrypoint !== 'fork') { + exitRestoredWorktree() + restoreWorktreeForResume(log.worktreeSession) + adoptResumedSessionFile() + void restoreRemoteAgentTasks({ + abortController: new AbortController(), + getAppState: () => store.getState(), + setAppState, + }) + } else { + // Fork: same re-persist as /clear (conversation.ts). The clear + // above wiped currentSessionWorktree, forkLog doesn't carry it, + // and the process is still in the same worktree. + const ws = getCurrentWorktreeSession() + if (ws) saveWorktreeState(ws) + } - // Restore target session's costs from the data we read earlier - if (targetSessionCosts) { - setCostStateForRestore(targetSessionCosts); - } + // Persist the current mode so future resumes know what mode this session was in + if (feature('COORDINATOR_MODE')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { saveMode } = require('../utils/sessionStorage.js') + const { isCoordinatorMode } = + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + saveMode(isCoordinatorMode() ? 'coordinator' : 'normal') + } - // Reconstruct replacement state for the resumed session. Runs after - // setSessionId so any NEW replacements post-resume write to the - // resumed session's tool-results dir. Gated on ref.current: the - // initial mount already read the feature flag, so we don't re-read - // it here (mid-session flag flips stay unobservable in both - // directions). - // - // Skipped for in-session /branch: the existing ref is already correct - // (branch preserves tool_use_ids), so there's no need to reconstruct. - // createFork() does write content-replacement entries to the forked - // JSONL with the fork's sessionId, so `claude -r {forkId}` also works. - if (contentReplacementStateRef.current && entrypoint !== 'fork') { - contentReplacementStateRef.current = reconstructContentReplacementState(messages, log.contentReplacements ?? []); - } + // Restore target session's costs from the data we read earlier + if (targetSessionCosts) { + setCostStateForRestore(targetSessionCosts) + } - // Reset messages to the provided initial messages - // Use a callback to ensure we're not dependent on stale state - setMessages(() => messages); - - // Clear any active tool JSX - setToolJSX(null); - - // Clear input to ensure no residual state - setInputValue(''); - logEvent('tengu_session_resumed', { - entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); - } catch (error) { - logEvent('tengu_session_resumed', { - entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); - throw error; - } - }, [resetLoadingState, setAppState]); + // Reconstruct replacement state for the resumed session. Runs after + // setSessionId so any NEW replacements post-resume write to the + // resumed session's tool-results dir. Gated on ref.current: the + // initial mount already read the feature flag, so we don't re-read + // it here (mid-session flag flips stay unobservable in both + // directions). + // + // Skipped for in-session /branch: the existing ref is already correct + // (branch preserves tool_use_ids), so there's no need to reconstruct. + // createFork() does write content-replacement entries to the forked + // JSONL with the fork's sessionId, so `claude -r {forkId}` also works. + if (contentReplacementStateRef.current && entrypoint !== 'fork') { + contentReplacementStateRef.current = + reconstructContentReplacementState( + messages, + log.contentReplacements ?? [], + ) + } + + // Reset messages to the provided initial messages + // Use a callback to ensure we're not dependent on stale state + setMessages(() => messages) + + // Clear any active tool JSX + setToolJSX(null) + + // Clear input to ensure no residual state + setInputValue('') + + logEvent('tengu_session_resumed', { + entrypoint: + entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }) + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: + entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + throw error + } + }, + [resetLoadingState, setAppState], + ) // Lazy init: useRef(createX()) would call createX on every render and // discard the result. LRUCache construction inside FileStateCache is // expensive (~170ms), so we use useState's lazy initializer to create // it exactly once, then feed that stable reference into useRef. - const [initialReadFileState] = useState(() => createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)); - const readFileState = useRef(initialReadFileState); - const bashTools = useRef(new Set()); - const bashToolsProcessedIdx = useRef(0); + const [initialReadFileState] = useState(() => + createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), + ) + const readFileState = useRef(initialReadFileState) + const bashTools = useRef(new Set()) + const bashToolsProcessedIdx = useRef(0) // Session-scoped skill discovery tracking (feeds was_discovered on // tengu_skill_tool_invocation). Must persist across getToolUseContext // rebuilds within a session: turn-0 discovery writes via processUserInput // before onQuery builds its own context, and discovery on turn N must // still attribute a SkillTool call on turn N+k. Cleared in clearConversation. - const discoveredSkillNamesRef = useRef(new Set()); + const discoveredSkillNamesRef = useRef(new Set()) // Session-level dedup for nested_memory CLAUDE.md attachments. // readFileState is a 100-entry LRU; once it evicts a CLAUDE.md path, // the next discovery cycle re-injects it. Cleared in clearConversation. - const loadedNestedMemoryPathsRef = useRef(new Set()); + const loadedNestedMemoryPathsRef = useRef(new Set()) // Helper to restore read file state from messages (used for resume flows) // This allows Claude to edit files that were read in previous sessions - const restoreReadFileState = useCallback((messages: MessageType[], cwd: string) => { - const extracted = extractReadFilesFromMessages(messages, cwd, READ_FILE_STATE_CACHE_SIZE); - readFileState.current = mergeFileStateCaches(readFileState.current, extracted); - for (const tool of extractBashToolsFromMessages(messages)) { - bashTools.current.add(tool); - } - }, []); + const restoreReadFileState = useCallback( + (messages: MessageType[], cwd: string) => { + const extracted = extractReadFilesFromMessages( + messages, + cwd, + READ_FILE_STATE_CACHE_SIZE, + ) + readFileState.current = mergeFileStateCaches( + readFileState.current, + extracted, + ) + for (const tool of extractBashToolsFromMessages(messages)) { + bashTools.current.add(tool) + } + }, + [], + ) // Extract read file state from initialMessages on mount // This handles CLI flag resume (--resume-session) and ResumeConversation screen // where messages are passed as props rather than through the resume callback useEffect(() => { if (initialMessages && initialMessages.length > 0) { - restoreReadFileState(initialMessages, getOriginalCwd()); + restoreReadFileState(initialMessages, getOriginalCwd()) void restoreRemoteAgentTasks({ abortController: new AbortController(), getAppState: () => store.getState(), - setAppState - }); + setAppState, + }) } // Only run on mount - initialMessages shouldn't change during component lifetime // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const { - status: apiKeyStatus, - reverify - } = useApiKeyVerification(); + }, []) + + const { status: apiKeyStatus, reverify } = useApiKeyVerification() // Auto-run /issue state - const [autoRunIssueReason, setAutoRunIssueReason] = useState(null); + const [autoRunIssueReason, setAutoRunIssueReason] = + useState(null) // Ref to track if autoRunIssue was triggered this survey cycle, // so we can suppress the [1] follow-up prompt even after // autoRunIssueReason is cleared. - const didAutoRunIssueRef = useRef(false); + const didAutoRunIssueRef = useRef(false) // State for exit feedback flow - const [exitFlow, setExitFlow] = useState(null); - const [isExiting, setIsExiting] = useState(false); + const [exitFlow, setExitFlow] = useState(null) + const [isExiting, setIsExiting] = useState(false) // Calculate if cost dialog should be shown - const showingCostDialog = !isLoading && showCostDialog; + const showingCostDialog = !isLoading && showCostDialog // Determine which dialog should have focus (if any) // Permission and interactive dialogs can show even when toolJSX is set, // as long as shouldContinueAnimation is true. This prevents deadlocks when // agents set background hints while waiting for user interaction. - function getFocusedInputDialog(): 'message-selector' | 'sandbox-permission' | 'tool-permission' | 'prompt' | 'worker-sandbox-permission' | 'elicitation' | 'cost' | 'idle-return' | 'init-onboarding' | 'ide-onboarding' | 'model-switch' | 'undercover-callout' | 'effort-callout' | 'remote-callout' | 'lsp-recommendation' | 'plugin-hint' | 'desktop-upsell' | 'ultraplan-choice' | 'ultraplan-launch' | undefined { + function getFocusedInputDialog(): + | 'message-selector' + | 'sandbox-permission' + | 'tool-permission' + | 'prompt' + | 'worker-sandbox-permission' + | 'elicitation' + | 'cost' + | 'idle-return' + | 'init-onboarding' + | 'ide-onboarding' + | 'model-switch' + | 'undercover-callout' + | 'effort-callout' + | 'remote-callout' + | 'lsp-recommendation' + | 'plugin-hint' + | 'desktop-upsell' + | 'ultraplan-choice' + | 'ultraplan-launch' + | undefined { // Exit states always take precedence - if (isExiting || exitFlow) return undefined; + if (isExiting || exitFlow) return undefined // High priority dialogs (always show regardless of typing) - if (isMessageSelectorVisible) return 'message-selector'; + if (isMessageSelectorVisible) return 'message-selector' // Suppress interrupt dialogs while user is actively typing - if (isPromptInputActive) return undefined; - if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission'; + if (isPromptInputActive) return undefined + + if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission' // Permission/interactive dialogs (show unless blocked by toolJSX) - const allowDialogsWithAnimation = !toolJSX || toolJSX.shouldContinueAnimation; - if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) return 'tool-permission'; - if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt'; + const allowDialogsWithAnimation = + !toolJSX || toolJSX.shouldContinueAnimation + + if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) + return 'tool-permission' + if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt' // Worker sandbox permission prompts (network access) from swarm workers - if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) return 'worker-sandbox-permission'; - if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation'; - if (allowDialogsWithAnimation && showingCostDialog) return 'cost'; - if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return'; - if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanPendingChoice) return 'ultraplan-choice'; - if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanLaunchPending) return 'ultraplan-launch'; + if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) + return 'worker-sandbox-permission' + if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation' + if (allowDialogsWithAnimation && showingCostDialog) return 'cost' + if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return' + + if ( + feature('ULTRAPLAN') && + allowDialogsWithAnimation && + !isLoading && + ultraplanPendingChoice + ) + return 'ultraplan-choice' + + if ( + feature('ULTRAPLAN') && + allowDialogsWithAnimation && + !isLoading && + ultraplanLaunchPending + ) + return 'ultraplan-launch' // Onboarding dialogs (special conditions) - if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding'; + if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding' // Model switch callout (ant-only, eliminated from external builds) - if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch'; + if ( + process.env.USER_TYPE === 'ant' && + allowDialogsWithAnimation && + showModelSwitchCallout + ) + return 'model-switch' // Undercover auto-enable explainer (ant-only, eliminated from external builds) - if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout'; + if ( + process.env.USER_TYPE === 'ant' && + allowDialogsWithAnimation && + showUndercoverCallout + ) + return 'undercover-callout' // Effort callout (shown once for Opus 4.6 users when effort is enabled) - if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout'; + if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout' // Remote callout (shown once before first bridge enable) - if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout'; + if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout' // LSP plugin recommendation (lowest priority - non-blocking suggestion) - if (allowDialogsWithAnimation && lspRecommendation) return 'lsp-recommendation'; + if (allowDialogsWithAnimation && lspRecommendation) + return 'lsp-recommendation' // Plugin hint from CLI/SDK stderr (same priority band as LSP rec) - if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint'; + if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint' // Desktop app upsell (max 3 launches, lowest priority) - if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell'; - return undefined; + if (allowDialogsWithAnimation && showDesktopUpsellStartup) + return 'desktop-upsell' + + return undefined } - const focusedInputDialog = getFocusedInputDialog(); + + const focusedInputDialog = getFocusedInputDialog() // True when permission prompts exist but are hidden because the user is typing - const hasSuppressedDialogs = isPromptInputActive && (sandboxPermissionRequestQueue[0] || toolUseConfirmQueue[0] || promptQueue[0] || workerSandboxPermissions.queue[0] || elicitation.queue[0] || showingCostDialog); + const hasSuppressedDialogs = + isPromptInputActive && + (sandboxPermissionRequestQueue[0] || + toolUseConfirmQueue[0] || + promptQueue[0] || + workerSandboxPermissions.queue[0] || + elicitation.queue[0] || + showingCostDialog) // Keep ref in sync so timer callbacks can read the current value - focusedInputDialogRef.current = focusedInputDialog; + focusedInputDialogRef.current = focusedInputDialog // Immediately capture pause/resume when focusedInputDialog changes // This ensures accurate timing even under high system load, rather than // relying on the 100ms polling interval to detect state changes useEffect(() => { - if (!isLoading) return; - const isPaused = focusedInputDialog === 'tool-permission'; - const now = Date.now(); + if (!isLoading) return + + const isPaused = focusedInputDialog === 'tool-permission' + const now = Date.now() + if (isPaused && pauseStartTimeRef.current === null) { // Just entered pause state - record the exact moment - pauseStartTimeRef.current = now; + pauseStartTimeRef.current = now } else if (!isPaused && pauseStartTimeRef.current !== null) { // Just exited pause state - accumulate paused time immediately - totalPausedMsRef.current += now - pauseStartTimeRef.current; - pauseStartTimeRef.current = null; + totalPausedMsRef.current += now - pauseStartTimeRef.current + pauseStartTimeRef.current = null } - }, [focusedInputDialog, isLoading]); + }, [focusedInputDialog, isLoading]) // Re-pin scroll to bottom whenever the permission overlay appears or // dismisses. Overlay now renders below messages inside the same @@ -2101,98 +2805,105 @@ export function REPL({ // overlay, and onScroll was suppressed so the pill state is stale // useLayoutEffect so the re-pin commits before the Ink frame renders — // no 1-frame flash of the wrong scroll position. - const prevDialogRef = useRef(focusedInputDialog); + const prevDialogRef = useRef(focusedInputDialog) useLayoutEffect(() => { - const was = prevDialogRef.current === 'tool-permission'; - const now = focusedInputDialog === 'tool-permission'; - if (was !== now) repinScroll(); - prevDialogRef.current = focusedInputDialog; - }, [focusedInputDialog, repinScroll]); + const was = prevDialogRef.current === 'tool-permission' + const now = focusedInputDialog === 'tool-permission' + if (was !== now) repinScroll() + prevDialogRef.current = focusedInputDialog + }, [focusedInputDialog, repinScroll]) + function onCancel() { if (focusedInputDialog === 'elicitation') { // Elicitation dialog handles its own Escape, and closing it shouldn't affect any loading state. - return; + return } - logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`); + + logForDebugging( + `[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`, + ) // Pause proactive mode so the user gets control back. // It will resume when they submit their next input (see onSubmit). if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.pauseProactive(); + proactiveModule?.pauseProactive() } - queryGuard.forceEnd(); - skipIdleCheckRef.current = false; + + queryGuard.forceEnd() + skipIdleCheckRef.current = false // Preserve partially-streamed text so the user can read what was // generated before pressing Esc. Pushed before resetLoadingState clears // streamingText, and before query.ts yields the async interrupt marker, // giving final order [user, partial-assistant, [Request interrupted by user]]. if (streamingText?.trim()) { - setMessages(prev => [...prev, createAssistantMessage({ - content: streamingText - })]); + setMessages(prev => [ + ...prev, + createAssistantMessage({ content: streamingText }), + ]) } - resetLoadingState(); + + resetLoadingState() // Clear any active token budget so the backstop doesn't fire on // a stale budget if the query generator hasn't exited yet. if (feature('TOKEN_BUDGET')) { - snapshotOutputTokensForTurn(null); + snapshotOutputTokensForTurn(null) } + if (focusedInputDialog === 'tool-permission') { // Tool use confirm handles the abort signal itself - toolUseConfirmQueue[0]?.onAbort(); - setToolUseConfirmQueue([]); + toolUseConfirmQueue[0]?.onAbort() + setToolUseConfirmQueue([]) } else if (focusedInputDialog === 'prompt') { // Reject all pending prompts and clear the queue for (const item of promptQueue) { - item.reject(new Error('Prompt cancelled by user')); + item.reject(new Error('Prompt cancelled by user')) } - setPromptQueue([]); - abortController?.abort('user-cancel'); + setPromptQueue([]) + abortController?.abort('user-cancel') } else if (activeRemote.isRemoteMode) { // Remote mode: send interrupt signal to CCR - activeRemote.cancelRequest(); + activeRemote.cancelRequest() } else { - abortController?.abort('user-cancel'); + abortController?.abort('user-cancel') } // Clear the controller so subsequent Escape presses don't see a stale // aborted signal. Without this, canCancelRunningTask is false (signal // defined but .aborted === true), so isActive becomes false if no other // activating conditions hold — leaving the Escape keybinding inactive. - setAbortController(null); + setAbortController(null) // forceEnd() skips the finally path — fire directly (aborted=true). - void mrOnTurnComplete(messagesRef.current, true); + void mrOnTurnComplete(messagesRef.current, true) } // Function to handle queued command when canceling a permission request const handleQueuedCommandOnCancel = useCallback(() => { - const result = popAllEditable(inputValue, 0); - if (!result) return; - setInputValue(result.text); - setInputMode('prompt'); + const result = popAllEditable(inputValue, 0) + if (!result) return + setInputValue(result.text) + setInputMode('prompt') // Restore images from queued commands to pastedContents if (result.images.length > 0) { setPastedContents(prev => { - const newContents = { - ...prev - }; + const newContents = { ...prev } for (const image of result.images) { - newContents[image.id] = image; + newContents[image.id] = image } - return newContents; - }); + return newContents + }) } - }, [setInputValue, setInputMode, inputValue, setPastedContents]); + }, [setInputValue, setInputMode, inputValue, setPastedContents]) // CancelRequestHandler props - rendered inside KeybindingSetup const cancelRequestProps = { setToolUseConfirmQueue, onCancel, - onAgentsKilled: () => setMessages(prev => [...prev, createAgentsKilledMessage()]), + onAgentsKilled: () => + setMessages(prev => [...prev, createAgentsKilledMessage()]), isMessageSelectorVisible: isMessageSelectorVisible || !!showBashesDialog, screen, abortSignal: abortController?.signal, @@ -2203,116 +2914,146 @@ export function REPL({ isHelpOpen, inputMode, inputValue, - streamMode - }; + streamMode, + } + useEffect(() => { - const totalCost = getTotalCost(); + const totalCost = getTotalCost() if (totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog) { - logEvent('tengu_cost_threshold_reached', {}); + logEvent('tengu_cost_threshold_reached', {}) // Mark as shown even if the dialog won't render (no console billing // access). Otherwise this effect re-fires on every message change for // the rest of the session — 200k+ spurious events observed. - setHaveShownCostDialog(true); + setHaveShownCostDialog(true) if (hasConsoleBillingAccess()) { - setShowCostDialog(true); + setShowCostDialog(true) } } - }, [messages, showCostDialog, haveShownCostDialog]); - const sandboxAskCallback: SandboxAskCallback = useCallback(async (hostPattern: NetworkHostPattern) => { - // If running as a swarm worker, forward the request to the leader via mailbox - if (isAgentSwarmsEnabled() && isSwarmWorker()) { - const requestId = generateSandboxRequestId(); - - // Send the request to the leader via mailbox - const sent = await sendSandboxPermissionRequestViaMailbox(hostPattern.host, requestId); - return new Promise(resolveShouldAllowHost => { - if (!sent) { - // If we couldn't send via mailbox, fall back to local handling - setSandboxPermissionRequestQueue(prev => [...prev, { - hostPattern, - resolvePromise: resolveShouldAllowHost - }]); - return; - } + }, [messages, showCostDialog, haveShownCostDialog]) + + const sandboxAskCallback: SandboxAskCallback = useCallback( + async (hostPattern: NetworkHostPattern) => { + // If running as a swarm worker, forward the request to the leader via mailbox + if (isAgentSwarmsEnabled() && isSwarmWorker()) { + const requestId = generateSandboxRequestId() - // Register the callback for when the leader responds - registerSandboxPermissionCallback({ + // Send the request to the leader via mailbox + const sent = await sendSandboxPermissionRequestViaMailbox( + hostPattern.host, requestId, - host: hostPattern.host, - resolve: resolveShouldAllowHost - }); + ) - // Update AppState to show pending indicator - setAppState(prev => ({ - ...prev, - pendingSandboxRequest: { - requestId, - host: hostPattern.host + return new Promise(resolveShouldAllowHost => { + if (!sent) { + // If we couldn't send via mailbox, fall back to local handling + setSandboxPermissionRequestQueue(prev => [ + ...prev, + { + hostPattern, + resolvePromise: resolveShouldAllowHost, + }, + ]) + return } - })); - }); - } - // Normal flow for non-workers: show local UI and optionally race - // against the REPL bridge (Remote Control) if connected. - return new Promise(resolveShouldAllowHost => { - let resolved = false; - function resolveOnce(allow: boolean): void { - if (resolved) return; - resolved = true; - resolveShouldAllowHost(allow); + // Register the callback for when the leader responds + registerSandboxPermissionCallback({ + requestId, + host: hostPattern.host, + resolve: resolveShouldAllowHost, + }) + + // Update AppState to show pending indicator + setAppState(prev => ({ + ...prev, + pendingSandboxRequest: { + requestId, + host: hostPattern.host, + }, + })) + }) } - // Queue the local sandbox permission dialog - setSandboxPermissionRequestQueue(prev => [...prev, { - hostPattern, - resolvePromise: resolveOnce - }]); - - // When the REPL bridge is connected, also forward the sandbox - // permission request as a can_use_tool control_request so the - // remote user (e.g. on claude.ai) can approve it too. - if (feature('BRIDGE_MODE')) { - const bridgeCallbacks = store.getState().replBridgePermissionCallbacks; - if (bridgeCallbacks) { - const bridgeRequestId = randomUUID(); - bridgeCallbacks.sendRequest(bridgeRequestId, SANDBOX_NETWORK_ACCESS_TOOL_NAME, { - host: hostPattern.host - }, randomUUID(), `Allow network connection to ${hostPattern.host}?`); - const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => { - unsubscribe(); - const allow = response.behavior === 'allow'; - // Resolve ALL pending requests for the same host, not just - // this one — mirrors the local dialog handler pattern. - setSandboxPermissionRequestQueue(queue => { - queue.filter(item => item.hostPattern.host === hostPattern.host).forEach(item => item.resolvePromise(allow)); - return queue.filter(item => item.hostPattern.host !== hostPattern.host); - }); - // Clean up all sibling bridge subscriptions for this host - // (other concurrent same-host requests) before deleting. - const siblingCleanups = sandboxBridgeCleanupRef.current.get(hostPattern.host); - if (siblingCleanups) { - for (const fn of siblingCleanups) { - fn(); - } - sandboxBridgeCleanupRef.current.delete(hostPattern.host); + // Normal flow for non-workers: show local UI and optionally race + // against the REPL bridge (Remote Control) if connected. + return new Promise(resolveShouldAllowHost => { + let resolved = false + function resolveOnce(allow: boolean): void { + if (resolved) return + resolved = true + resolveShouldAllowHost(allow) + } + + // Queue the local sandbox permission dialog + setSandboxPermissionRequestQueue(prev => [ + ...prev, + { + hostPattern, + resolvePromise: resolveOnce, + }, + ]) + + // When the REPL bridge is connected, also forward the sandbox + // permission request as a can_use_tool control_request so the + // remote user (e.g. on claude.ai) can approve it too. + if (feature('BRIDGE_MODE')) { + const bridgeCallbacks = store.getState().replBridgePermissionCallbacks + if (bridgeCallbacks) { + const bridgeRequestId = randomUUID() + bridgeCallbacks.sendRequest( + bridgeRequestId, + SANDBOX_NETWORK_ACCESS_TOOL_NAME, + { host: hostPattern.host }, + randomUUID(), + `Allow network connection to ${hostPattern.host}?`, + ) + + const unsubscribe = bridgeCallbacks.onResponse( + bridgeRequestId, + response => { + unsubscribe() + const allow = response.behavior === 'allow' + // Resolve ALL pending requests for the same host, not just + // this one — mirrors the local dialog handler pattern. + setSandboxPermissionRequestQueue(queue => { + queue + .filter(item => item.hostPattern.host === hostPattern.host) + .forEach(item => item.resolvePromise(allow)) + return queue.filter( + item => item.hostPattern.host !== hostPattern.host, + ) + }) + // Clean up all sibling bridge subscriptions for this host + // (other concurrent same-host requests) before deleting. + const siblingCleanups = sandboxBridgeCleanupRef.current.get( + hostPattern.host, + ) + if (siblingCleanups) { + for (const fn of siblingCleanups) { + fn() + } + sandboxBridgeCleanupRef.current.delete(hostPattern.host) + } + }, + ) + + // Register cleanup so the local dialog handler can cancel + // the remote prompt and unsubscribe when the local user + // responds first. + const cleanup = () => { + unsubscribe() + bridgeCallbacks.cancelRequest(bridgeRequestId) } - }); - - // Register cleanup so the local dialog handler can cancel - // the remote prompt and unsubscribe when the local user - // responds first. - const cleanup = () => { - unsubscribe(); - bridgeCallbacks.cancelRequest(bridgeRequestId); - }; - const existing = sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? []; - existing.push(cleanup); - sandboxBridgeCleanupRef.current.set(hostPattern.host, existing); + const existing = + sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? [] + existing.push(cleanup) + sandboxBridgeCleanupRef.current.set(hostPattern.host, existing) + } } - } - }); - }, [setAppState, store]); + }) + }, + [setAppState, store], + ) // #34044: if user explicitly set sandbox.enabled=true but deps are missing, // isSandboxingEnabled() returns false silently. Surface the reason once at @@ -2320,247 +3061,345 @@ export function REPL({ // reason goes to debug log; notification points to /sandbox for details. // addNotification is stable (useCallback) so the effect fires once. useEffect(() => { - const reason = SandboxManager.getSandboxUnavailableReason(); - if (!reason) return; + const reason = SandboxManager.getSandboxUnavailableReason() + if (!reason) return if (SandboxManager.isSandboxRequired()) { - process.stderr.write(`\nError: sandbox required but unavailable: ${reason}\n` + ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`); - gracefulShutdownSync(1, 'other'); - return; + process.stderr.write( + `\nError: sandbox required but unavailable: ${reason}\n` + + ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`, + ) + gracefulShutdownSync(1, 'other') + return } - logForDebugging(`sandbox disabled: ${reason}`, { - level: 'warn' - }); + logForDebugging(`sandbox disabled: ${reason}`, { level: 'warn' }) addNotification({ key: 'sandbox-unavailable', - jsx: <> + jsx: ( + <> sandbox disabled · /sandbox - , - priority: 'medium' - }); - }, [addNotification]); + + ), + priority: 'medium', + }) + }, [addNotification]) + if (SandboxManager.isSandboxingEnabled()) { // If sandboxing is enabled (setting.sandbox is defined, initialise the manager) SandboxManager.initialize(sandboxAskCallback).catch(err => { // Initialization/validation failed - display error and exit - process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`); - gracefulShutdownSync(1, 'other'); - }); + process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`) + gracefulShutdownSync(1, 'other') + }) } - const setToolPermissionContext = useCallback((context: ToolPermissionContext, options?: { - preserveMode?: boolean; - }) => { - setAppState(prev => ({ - ...prev, - toolPermissionContext: { - ...context, - // Preserve the coordinator's mode only when explicitly requested. - // Workers' getAppState() returns a transformed context with mode - // 'acceptEdits' that must not leak into the coordinator's actual - // state via permission-rule updates — those call sites pass - // { preserveMode: true }. User-initiated mode changes (e.g., - // selecting "allow all edits") must NOT be overridden. - mode: options?.preserveMode ? prev.toolPermissionContext.mode : context.mode - } - })); - - // When permission context changes, recheck all queued items - // This handles the case where approving item1 with "don't ask again" - // should auto-approve other queued items that now match the updated rules - setImmediate(setToolUseConfirmQueue => { - // Use setToolUseConfirmQueue callback to get current queue state - // instead of capturing it in the closure, to avoid stale closure issues - setToolUseConfirmQueue(currentQueue => { - currentQueue.forEach(item => { - void item.recheckPermission(); - }); - return currentQueue; - }); - }, setToolUseConfirmQueue); - }, [setAppState, setToolUseConfirmQueue]); + + const setToolPermissionContext = useCallback( + (context: ToolPermissionContext, options?: { preserveMode?: boolean }) => { + setAppState(prev => ({ + ...prev, + toolPermissionContext: { + ...context, + // Preserve the coordinator's mode only when explicitly requested. + // Workers' getAppState() returns a transformed context with mode + // 'acceptEdits' that must not leak into the coordinator's actual + // state via permission-rule updates — those call sites pass + // { preserveMode: true }. User-initiated mode changes (e.g., + // selecting "allow all edits") must NOT be overridden. + mode: options?.preserveMode + ? prev.toolPermissionContext.mode + : context.mode, + }, + })) + + // When permission context changes, recheck all queued items + // This handles the case where approving item1 with "don't ask again" + // should auto-approve other queued items that now match the updated rules + setImmediate(setToolUseConfirmQueue => { + // Use setToolUseConfirmQueue callback to get current queue state + // instead of capturing it in the closure, to avoid stale closure issues + setToolUseConfirmQueue(currentQueue => { + currentQueue.forEach(item => { + void item.recheckPermission() + }) + return currentQueue + }) + }, setToolUseConfirmQueue) + }, + [setAppState, setToolUseConfirmQueue], + ) // Register the leader's setToolPermissionContext for in-process teammates useEffect(() => { - registerLeaderSetToolPermissionContext(setToolPermissionContext); - return () => unregisterLeaderSetToolPermissionContext(); - }, [setToolPermissionContext]); - const canUseTool = useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext); - const requestPrompt = useCallback((title: string, toolInputSummary?: string | null) => (request: PromptRequest): Promise => new Promise((resolve, reject) => { - setPromptQueue(prev => [...prev, { - request, - title, - toolInputSummary, - resolve, - reject - }]); - }), []); - const getToolUseContext = useCallback((messages: MessageType[], newMessages: MessageType[], abortController: AbortController, mainLoopModel: string): ProcessUserInputContext => { - // Read mutable values fresh from the store rather than closure-capturing - // useAppState() snapshots. Same values today (closure is refreshed by the - // render between turns); decouples freshness from React's render cycle for - // a future headless conversation loop. Same pattern refreshTools() uses. - const s = store.getState(); - - // Compute tools fresh from store.getState() rather than the closure- - // captured `tools`. useManageMCPConnections populates appState.mcp - // async as servers connect — the store may have newer MCP state than - // the closure captured at render time. Also doubles as refreshTools() - // for mid-query tool list updates. - const computeTools = () => { - const state = store.getState(); - const assembled = assembleToolPool(state.toolPermissionContext, state.mcp.tools); - const merged = mergeAndFilterTools(combinedInitialTools, assembled, state.toolPermissionContext.mode); - if (!mainThreadAgentDefinition) return merged; - return resolveAgentTools(mainThreadAgentDefinition, merged, false, true).resolvedTools; - }; - return { - abortController, - options: { - commands, - tools: computeTools(), - debug, - verbose: s.verbose, - mainLoopModel, - thinkingConfig: s.thinkingEnabled !== false ? thinkingConfig : { - type: 'disabled' + registerLeaderSetToolPermissionContext(setToolPermissionContext) + return () => unregisterLeaderSetToolPermissionContext() + }, [setToolPermissionContext]) + + const canUseTool = useCanUseTool( + setToolUseConfirmQueue, + setToolPermissionContext, + ) + + const requestPrompt = useCallback( + (title: string, toolInputSummary?: string | null) => + (request: PromptRequest): Promise => + new Promise((resolve, reject) => { + setPromptQueue(prev => [ + ...prev, + { request, title, toolInputSummary, resolve, reject }, + ]) + }), + [], + ) + + const getToolUseContext = useCallback( + ( + messages: MessageType[], + newMessages: MessageType[], + abortController: AbortController, + mainLoopModel: string, + ): ProcessUserInputContext => { + // Read mutable values fresh from the store rather than closure-capturing + // useAppState() snapshots. Same values today (closure is refreshed by the + // render between turns); decouples freshness from React's render cycle for + // a future headless conversation loop. Same pattern refreshTools() uses. + const s = store.getState() + + // Compute tools fresh from store.getState() rather than the closure- + // captured `tools`. useManageMCPConnections populates appState.mcp + // async as servers connect — the store may have newer MCP state than + // the closure captured at render time. Also doubles as refreshTools() + // for mid-query tool list updates. + const computeTools = () => { + const state = store.getState() + const assembled = assembleToolPool( + state.toolPermissionContext, + state.mcp.tools, + ) + const merged = mergeAndFilterTools( + combinedInitialTools, + assembled, + state.toolPermissionContext.mode, + ) + if (!mainThreadAgentDefinition) return merged + return resolveAgentTools(mainThreadAgentDefinition, merged, false, true) + .resolvedTools + } + + return { + abortController, + options: { + commands, + tools: computeTools(), + debug, + verbose: s.verbose, + mainLoopModel, + thinkingConfig: + s.thinkingEnabled !== false ? thinkingConfig : { type: 'disabled' }, + // Merge fresh from store rather than closing over useMergedClients' + // memoized output. initialMcpClients is a prop (session-constant). + mcpClients: mergeClients(initialMcpClients, s.mcp.clients), + mcpResources: s.mcp.resources, + ideInstallationStatus: ideInstallationStatus, + isNonInteractiveSession: false, + dynamicMcpConfig, + theme, + agentDefinitions: allowedAgentTypes + ? { ...s.agentDefinitions, allowedAgentTypes } + : s.agentDefinitions, + customSystemPrompt, + appendSystemPrompt, + refreshTools: computeTools, }, - // Merge fresh from store rather than closing over useMergedClients' - // memoized output. initialMcpClients is a prop (session-constant). - mcpClients: mergeClients(initialMcpClients, s.mcp.clients), - mcpResources: s.mcp.resources, - ideInstallationStatus: ideInstallationStatus, - isNonInteractiveSession: false, - dynamicMcpConfig, - theme, - agentDefinitions: allowedAgentTypes ? { - ...s.agentDefinitions, - allowedAgentTypes - } : s.agentDefinitions, - customSystemPrompt, - appendSystemPrompt, - refreshTools: computeTools - }, - getAppState: () => store.getState(), + getAppState: () => store.getState(), + setAppState, + messages, + setMessages, + updateFileHistoryState( + updater: (prev: FileHistoryState) => FileHistoryState, + ) { + // Perf: skip the setState when the updater returns the same reference + // (e.g. fileHistoryTrackEdit returns `state` when the file is already + // tracked). Otherwise every no-op call would notify all store listeners. + setAppState(prev => { + const updated = updater(prev.fileHistory) + if (updated === prev.fileHistory) return prev + return { ...prev, fileHistory: updated } + }) + }, + updateAttributionState( + updater: (prev: AttributionState) => AttributionState, + ) { + setAppState(prev => { + const updated = updater(prev.attribution) + if (updated === prev.attribution) return prev + return { ...prev, attribution: updated } + }) + }, + openMessageSelector: () => { + if (!disabled) { + setIsMessageSelectorVisible(true) + } + }, + onChangeAPIKey: reverify, + readFileState: readFileState.current, + setToolJSX, + addNotification, + appendSystemMessage: msg => setMessages(prev => [...prev, msg]), + sendOSNotification: opts => { + void sendNotification(opts, terminal) + }, + onChangeDynamicMcpConfig, + onInstallIDEExtension: setIDEToInstallExtension, + nestedMemoryAttachmentTriggers: new Set(), + loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, + dynamicSkillDirTriggers: new Set(), + discoveredSkillNames: discoveredSkillNamesRef.current, + setResponseLength, + pushApiMetricsEntry: + process.env.USER_TYPE === 'ant' + ? (ttftMs: number) => { + const now = Date.now() + const baseline = responseLengthRef.current + apiMetricsRef.current.push({ + ttftMs, + firstTokenTime: now, + lastTokenTime: now, + responseLengthBaseline: baseline, + endResponseLength: baseline, + }) + } + : undefined, + setStreamMode, + onCompactProgress: event => { + switch (event.type) { + case 'hooks_start': + setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER') + setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER') + setSpinnerMessage( + event.hookType === 'pre_compact' + ? 'Running PreCompact hooks\u2026' + : event.hookType === 'post_compact' + ? 'Running PostCompact hooks\u2026' + : 'Running SessionStart hooks\u2026', + ) + break + case 'compact_start': + setSpinnerMessage('Compacting conversation') + break + case 'compact_end': + setSpinnerMessage(null) + setSpinnerColor(null) + setSpinnerShimmerColor(null) + break + } + }, + setInProgressToolUseIDs, + setHasInterruptibleToolInProgress: (v: boolean) => { + hasInterruptibleToolInProgressRef.current = v + }, + resume, + setConversationId, + requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined, + contentReplacementState: contentReplacementStateRef.current, + } + }, + [ + commands, + combinedInitialTools, + mainThreadAgentDefinition, + debug, + initialMcpClients, + ideInstallationStatus, + dynamicMcpConfig, + theme, + allowedAgentTypes, + store, setAppState, - messages, + reverify, + addNotification, setMessages, - updateFileHistoryState(updater: (prev: FileHistoryState) => FileHistoryState) { - // Perf: skip the setState when the updater returns the same reference - // (e.g. fileHistoryTrackEdit returns `state` when the file is already - // tracked). Otherwise every no-op call would notify all store listeners. - setAppState(prev => { - const updated = updater(prev.fileHistory); - if (updated === prev.fileHistory) return prev; - return { - ...prev, - fileHistory: updated - }; - }); - }, - updateAttributionState(updater: (prev: AttributionState) => AttributionState) { - setAppState(prev => { - const updated = updater(prev.attribution); - if (updated === prev.attribution) return prev; - return { - ...prev, - attribution: updated - }; - }); - }, - openMessageSelector: () => { - if (!disabled) { - setIsMessageSelectorVisible(true); - } - }, - onChangeAPIKey: reverify, - readFileState: readFileState.current, - setToolJSX, - addNotification, - appendSystemMessage: msg => setMessages(prev => [...prev, msg]), - sendOSNotification: opts => { - void sendNotification(opts, terminal); - }, onChangeDynamicMcpConfig, - onInstallIDEExtension: setIDEToInstallExtension, - nestedMemoryAttachmentTriggers: new Set(), - loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, - dynamicSkillDirTriggers: new Set(), - discoveredSkillNames: discoveredSkillNamesRef.current, - setResponseLength, - pushApiMetricsEntry: (process.env.USER_TYPE) === 'ant' ? (ttftMs: number) => { - const now = Date.now(); - const baseline = responseLengthRef.current; - apiMetricsRef.current.push({ - ttftMs, - firstTokenTime: now, - lastTokenTime: now, - responseLengthBaseline: baseline, - endResponseLength: baseline - }); - } : undefined, - setStreamMode, - onCompactProgress: event => { - switch (event.type) { - case 'hooks_start': - setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER'); - setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER'); - setSpinnerMessage(event.hookType === 'pre_compact' ? 'Running PreCompact hooks\u2026' : event.hookType === 'post_compact' ? 'Running PostCompact hooks\u2026' : 'Running SessionStart hooks\u2026'); - break; - case 'compact_start': - setSpinnerMessage('Compacting conversation'); - break; - case 'compact_end': - setSpinnerMessage(null); - setSpinnerColor(null); - setSpinnerShimmerColor(null); - break; - } - }, - setInProgressToolUseIDs, - setHasInterruptibleToolInProgress: (v: boolean) => { - hasInterruptibleToolInProgressRef.current = v; - }, resume, + requestPrompt, + disabled, + customSystemPrompt, + appendSystemPrompt, setConversationId, - requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined, - contentReplacementState: contentReplacementStateRef.current - }; - }, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId]); + ], + ) // Session backgrounding (Ctrl+B to background/foreground) const handleBackgroundQuery = useCallback(() => { // Stop the foreground query so the background one takes over - abortController?.abort('background'); + abortController?.abort('background') // Aborting subagents may produce task-completed notifications. // Clear task notifications so the queue processor doesn't immediately // start a new foreground query; forward them to the background session. - const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification'); + const removedNotifications = removeByFilter( + cmd => cmd.mode === 'task-notification', + ) + void (async () => { - const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel); - const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]); + const toolUseContext = getToolUseContext( + messagesRef.current, + [], + new AbortController(), + mainLoopModel, + ) + + const [defaultSystemPrompt, userContext, systemContext] = + await Promise.all([ + getSystemPrompt( + toolUseContext.options.tools, + mainLoopModel, + Array.from( + toolPermissionContext.additionalWorkingDirectories.keys(), + ), + toolUseContext.options.mcpClients, + ), + getUserContext(), + getSystemContext(), + ]) + const systemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, toolUseContext, customSystemPrompt, defaultSystemPrompt, - appendSystemPrompt - }); - toolUseContext.renderedSystemPrompt = systemPrompt; - const notificationAttachments = await getQueuedCommandAttachments(removedNotifications).catch(() => []); - const notificationMessages = notificationAttachments.map(createAttachmentMessage); + appendSystemPrompt, + }) + toolUseContext.renderedSystemPrompt = systemPrompt + + const notificationAttachments = await getQueuedCommandAttachments( + removedNotifications, + ).catch(() => []) + const notificationMessages = notificationAttachments.map( + createAttachmentMessage, + ) // Deduplicate: if the query loop already yielded a notification into // messagesRef before we removed it from the queue, skip duplicates. // We use prompt text for dedup because source_uuid is not set on // task-notification QueuedCommands (enqueuePendingNotification callers // don't pass uuid), so it would always be undefined. - const existingPrompts = new Set(); + const existingPrompts = new Set() for (const m of messagesRef.current) { - if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') { - existingPrompts.add(m.attachment.prompt); + if ( + m.type === 'attachment' && + m.attachment.type === 'queued_command' && + m.attachment.commandMode === 'task-notification' && + typeof m.attachment.prompt === 'string' + ) { + existingPrompts.add(m.attachment.prompt) } } - const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt))); + const uniqueNotifications = notificationMessages.filter( + m => + m.attachment.type === 'queued_command' && + (typeof m.attachment.prompt !== 'string' || + !existingPrompts.has(m.attachment.prompt)), + ) + startBackgroundSession({ messages: [...messagesRef.current, ...uniqueNotifications], queryParams: { @@ -2569,485 +3408,705 @@ export function REPL({ systemContext, canUseTool, toolUseContext, - querySource: getQuerySourceForREPL() + querySource: getQuerySourceForREPL(), }, description: terminalTitle, setAppState, - agentDefinition: mainThreadAgentDefinition - }); - })(); - }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState]); - const { - handleBackgroundSession - } = useSessionBackgrounding({ + agentDefinition: mainThreadAgentDefinition, + }) + })() + }, [ + abortController, + mainLoopModel, + toolPermissionContext, + mainThreadAgentDefinition, + getToolUseContext, + customSystemPrompt, + appendSystemPrompt, + canUseTool, + setAppState, + ]) + + const { handleBackgroundSession } = useSessionBackgrounding({ setMessages, setIsLoading: setIsExternalLoading, resetLoadingState, setAbortController, - onBackgroundQuery: handleBackgroundQuery - }); - const onQueryEvent = useCallback((event: Parameters[0]) => { - handleMessageFromStream(event, newMessage => { - if (isCompactBoundaryMessage(newMessage)) { - // Fullscreen: keep pre-compact messages for scrollback. query.ts - // slices at the boundary for API calls, Messages.tsx skips the - // boundary filter in fullscreen, and useLogMessages treats this - // as an incremental append (first uuid unchanged). Cap at one - // compact-interval of scrollback — normalizeMessages/applyGrouping - // are O(n) per render, so drop everything before the previous - // boundary to keep n bounded across multi-day sessions. - if (isFullscreenEnvEnabled()) { - setMessages(old => [...getMessagesAfterCompactBoundary(old, { - includeSnipped: true - }), newMessage]); - } else { - setMessages(() => [newMessage]); + onBackgroundQuery: handleBackgroundQuery, + }) + + const onQueryEvent = useCallback( + (event: Parameters[0]) => { + handleMessageFromStream( + event, + newMessage => { + if (isCompactBoundaryMessage(newMessage)) { + // Fullscreen: keep pre-compact messages for scrollback. query.ts + // slices at the boundary for API calls, Messages.tsx skips the + // boundary filter in fullscreen, and useLogMessages treats this + // as an incremental append (first uuid unchanged). Cap at one + // compact-interval of scrollback — normalizeMessages/applyGrouping + // are O(n) per render, so drop everything before the previous + // boundary to keep n bounded across multi-day sessions. + if (isFullscreenEnvEnabled()) { + setMessages(old => [ + ...getMessagesAfterCompactBoundary(old, { + includeSnipped: true, + }), + newMessage, + ]) + } else { + setMessages(() => [newMessage]) + } + // Bump conversationId so Messages.tsx row keys change and + // stale memoized rows remount with post-compact content. + setConversationId(randomUUID()) + // Compaction succeeded — clear the context-blocked flag so ticks resume + if (feature('PROACTIVE') || feature('KAIROS')) { + proactiveModule?.setContextBlocked(false) + } + } else if ( + newMessage.type === 'progress' && + isEphemeralToolProgress(newMessage.data.type) + ) { + // Replace the previous ephemeral progress tick for the same tool + // call instead of appending. Sleep/Bash emit a tick per second and + // only the last one is rendered; appending blows up the messages + // array (13k+ observed) and the transcript (120MB of sleep_progress + // lines). useLogMessages tracks length, so same-length replacement + // also skips the transcript write. + // agent_progress / hook_progress / skill_progress are NOT ephemeral + // — each carries distinct state the UI needs (e.g. subagent tool + // history). Replacing those leaves the AgentTool UI stuck at + // "Initializing…" because it renders the full progress trail. + setMessages(oldMessages => { + const last = oldMessages.at(-1) + if ( + last?.type === 'progress' && + last.parentToolUseID === newMessage.parentToolUseID && + last.data.type === newMessage.data.type + ) { + const copy = oldMessages.slice() + copy[copy.length - 1] = newMessage + return copy + } + return [...oldMessages, newMessage] + }) + } else { + setMessages(oldMessages => [...oldMessages, newMessage]) + } + // Block ticks on API errors to prevent tick → error → tick + // runaway loops (e.g., auth failure, rate limit, blocking limit). + // Cleared on compact boundary (above) or successful response (below). + if (feature('PROACTIVE') || feature('KAIROS')) { + if ( + newMessage.type === 'assistant' && + 'isApiErrorMessage' in newMessage && + newMessage.isApiErrorMessage + ) { + proactiveModule?.setContextBlocked(true) + } else if (newMessage.type === 'assistant') { + proactiveModule?.setContextBlocked(false) + } + } + }, + newContent => { + // setResponseLength handles updating both responseLengthRef (for + // spinner animation) and apiMetricsRef (endResponseLength/lastTokenTime + // for OTPS). No separate metrics update needed here. + setResponseLength(length => length + newContent.length) + }, + setStreamMode, + setStreamingToolUses, + tombstonedMessage => { + setMessages(oldMessages => + oldMessages.filter(m => m !== tombstonedMessage), + ) + void removeTranscriptMessage(tombstonedMessage.uuid) + }, + setStreamingThinking, + metrics => { + const now = Date.now() + const baseline = responseLengthRef.current + apiMetricsRef.current.push({ + ...metrics, + firstTokenTime: now, + lastTokenTime: now, + responseLengthBaseline: baseline, + endResponseLength: baseline, + }) + }, + onStreamingText, + ) + }, + [ + setMessages, + setResponseLength, + setStreamMode, + setStreamingToolUses, + setStreamingThinking, + onStreamingText, + ], + ) + + const onQueryImpl = useCallback( + async ( + messagesIncludingNewMessages: MessageType[], + newMessages: MessageType[], + abortController: AbortController, + shouldQuery: boolean, + additionalAllowedTools: string[], + mainLoopModelParam: string, + effort?: EffortValue, + ) => { + // Prepare IDE integration for new prompt. Read mcpClients fresh from + // store — useManageMCPConnections may have populated it since the + // render that captured this closure (same pattern as computeTools). + if (shouldQuery) { + const freshClients = mergeClients( + initialMcpClients, + store.getState().mcp.clients, + ) + void diagnosticTracker.handleQueryStart(freshClients) + const ideClient = getConnectedIdeClient(freshClients) + if (ideClient) { + void closeOpenDiffs(ideClient) } - // Bump conversationId so Messages.tsx row keys change and - // stale memoized rows remount with post-compact content. - setConversationId(randomUUID()); - // Compaction succeeded — clear the context-blocked flag so ticks resume - if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.setContextBlocked(false); + } + + // Mark onboarding as complete when any user message is sent to Claude + void maybeMarkProjectOnboardingComplete() + + // Extract a session title from the first real user message. One-shot + // via ref (was tengu_birch_mist experiment: first-message-only to save + // Haiku calls). The ref replaces the old `messages.length <= 1` check, + // which was broken by SessionStart hook messages (prepended via + // useDeferredHookMessages) and attachment messages (appended by + // processTextPrompt) — both pushed length past 1 on turn one, so the + // title silently fell through to the "Claude Code" default. + if ( + !titleDisabled && + !sessionTitle && + !agentTitle && + !haikuTitleAttemptedRef.current + ) { + const firstUserMessage = newMessages.find( + m => m.type === 'user' && !m.isMeta, + ) + const text = + firstUserMessage?.type === 'user' + ? getContentText(firstUserMessage.message.content) + : null + // Skip synthetic breadcrumbs — slash-command output, prompt-skill + // expansions (/commit → ), local-command headers + // (/help → ), and bash-mode (!cmd → ). + // None of these are the user's topic; wait for real prose. + if ( + text && + !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) && + !text.startsWith(`<${COMMAND_MESSAGE_TAG}>`) && + !text.startsWith(`<${COMMAND_NAME_TAG}>`) && + !text.startsWith(`<${BASH_INPUT_TAG}>`) + ) { + haikuTitleAttemptedRef.current = true + void generateSessionTitle(text, new AbortController().signal).then( + title => { + if (title) setHaikuTitle(title) + else haikuTitleAttemptedRef.current = false + }, + () => { + haikuTitleAttemptedRef.current = false + }, + ) } - } else if ((newMessage as MessageType).type === 'progress' && isEphemeralToolProgress(((newMessage as MessageType).data as { type: string }).type)) { - // Replace the previous ephemeral progress tick for the same tool - // call instead of appending. Sleep/Bash emit a tick per second and - // only the last one is rendered; appending blows up the messages - // array (13k+ observed) and the transcript (120MB of sleep_progress - // lines). useLogMessages tracks length, so same-length replacement - // also skips the transcript write. - // agent_progress / hook_progress / skill_progress are NOT ephemeral - // — each carries distinct state the UI needs (e.g. subagent tool - // history). Replacing those leaves the AgentTool UI stuck at - // "Initializing…" because it renders the full progress trail. - setMessages(oldMessages => { - const last = oldMessages.at(-1); - if (last?.type === 'progress' && (last as MessageType).parentToolUseID === (newMessage as MessageType).parentToolUseID && ((last as MessageType).data as { type: string }).type === ((newMessage as MessageType).data as { type: string }).type) { - const copy = oldMessages.slice(); - copy[copy.length - 1] = newMessage; - return copy; - } - return [...oldMessages, newMessage]; - }); - } else { - setMessages(oldMessages => [...oldMessages, newMessage]); } - // Block ticks on API errors to prevent tick → error → tick - // runaway loops (e.g., auth failure, rate limit, blocking limit). - // Cleared on compact boundary (above) or successful response (below). - if (feature('PROACTIVE') || feature('KAIROS')) { - if (newMessage.type === 'assistant' && 'isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) { - proactiveModule?.setContextBlocked(true); - } else if (newMessage.type === 'assistant') { - proactiveModule?.setContextBlocked(false); + + // Apply slash-command-scoped allowedTools (from skill frontmatter) to the + // store once per turn. This also covers the reset: the next non-skill turn + // passes [] and clears it. Must run before the !shouldQuery gate: forked + // commands (executeForkedSlashCommand) return shouldQuery=false, and + // createGetAppStateWithAllowedTools in forkedAgent.ts reads this field, so + // stale skill tools would otherwise leak into forked agent permissions. + // Previously this write was hidden inside getToolUseContext's getAppState + // (~85 calls/turn); hoisting it here makes getAppState a pure read and stops + // ephemeral contexts (permission dialog, BackgroundTasksDialog) from + // accidentally clearing it mid-turn. + store.setState(prev => { + const cur = prev.toolPermissionContext.alwaysAllowRules.command + if ( + cur === additionalAllowedTools || + (cur?.length === additionalAllowedTools.length && + cur.every((v, i) => v === additionalAllowedTools[i])) + ) { + return prev + } + return { + ...prev, + toolPermissionContext: { + ...prev.toolPermissionContext, + alwaysAllowRules: { + ...prev.toolPermissionContext.alwaysAllowRules, + command: additionalAllowedTools, + }, + }, } + }) + + // The last message is an assistant message if the user input was a bash command, + // or if the user input was an invalid slash command. + if (!shouldQuery) { + // Manual /compact sets messages directly (shouldQuery=false) bypassing + // handleMessageFromStream. Clear context-blocked if a compact boundary + // is present so proactive ticks resume after compaction. + if (newMessages.some(isCompactBoundaryMessage)) { + // Bump conversationId so Messages.tsx row keys change and + // stale memoized rows remount with post-compact content. + setConversationId(randomUUID()) + if (feature('PROACTIVE') || feature('KAIROS')) { + proactiveModule?.setContextBlocked(false) + } + } + resetLoadingState() + setAbortController(null) + return } - }, newContent => { - // setResponseLength handles updating both responseLengthRef (for - // spinner animation) and apiMetricsRef (endResponseLength/lastTokenTime - // for OTPS). No separate metrics update needed here. - setResponseLength(length => length + newContent.length); - }, setStreamMode, setStreamingToolUses, tombstonedMessage => { - setMessages(oldMessages => oldMessages.filter(m => m !== tombstonedMessage)); - void removeTranscriptMessage(tombstonedMessage.uuid); - }, setStreamingThinking, metrics => { - const now = Date.now(); - const baseline = responseLengthRef.current; - apiMetricsRef.current.push({ - ...metrics, - firstTokenTime: now, - lastTokenTime: now, - responseLengthBaseline: baseline, - endResponseLength: baseline - }); - }, onStreamingText); - }, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]); - const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, effort?: EffortValue) => { - // Prepare IDE integration for new prompt. Read mcpClients fresh from - // store — useManageMCPConnections may have populated it since the - // render that captured this closure (same pattern as computeTools). - if (shouldQuery) { - const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients); - void diagnosticTracker.handleQueryStart(freshClients); - const ideClient = getConnectedIdeClient(freshClients); - if (ideClient) { - void closeOpenDiffs(ideClient); + + const toolUseContext = getToolUseContext( + messagesIncludingNewMessages, + newMessages, + abortController, + mainLoopModelParam, + ) + // getToolUseContext reads tools/mcpClients fresh from store.getState() + // (via computeTools/mergeClients). Use those rather than the closure- + // captured `tools`/`mcpClients` — useManageMCPConnections may have + // flushed new MCP state between the render that captured this closure + // and now. Turn 1 via processInitialMessage is the main beneficiary. + const { tools: freshTools, mcpClients: freshMcpClients } = + toolUseContext.options + + // Scope the skill's effort override to this turn's context only — + // wrapping getAppState keeps the override out of the global store so + // background agents and UI subscribers (Spinner, LogoV2) never see it. + if (effort !== undefined) { + const previousGetAppState = toolUseContext.getAppState + toolUseContext.getAppState = () => ({ + ...previousGetAppState(), + effortValue: effort, + }) } - } - // Mark onboarding as complete when any user message is sent to Claude - void maybeMarkProjectOnboardingComplete(); - - // Extract a session title from the first real user message. One-shot - // via ref (was tengu_birch_mist experiment: first-message-only to save - // Haiku calls). The ref replaces the old `messages.length <= 1` check, - // which was broken by SessionStart hook messages (prepended via - // useDeferredHookMessages) and attachment messages (appended by - // processTextPrompt) — both pushed length past 1 on turn one, so the - // title silently fell through to the "Claude Code" default. - if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) { - const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta); - const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message.content as string | ContentBlockParam[]) : null; - // Skip synthetic breadcrumbs — slash-command output, prompt-skill - // expansions (/commit → ), local-command headers - // (/help → ), and bash-mode (!cmd → ). - // None of these are the user's topic; wait for real prose. - if (text && !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) && !text.startsWith(`<${COMMAND_MESSAGE_TAG}>`) && !text.startsWith(`<${COMMAND_NAME_TAG}>`) && !text.startsWith(`<${BASH_INPUT_TAG}>`)) { - haikuTitleAttemptedRef.current = true; - void generateSessionTitle(text, new AbortController().signal).then(title => { - if (title) setHaikuTitle(title);else haikuTitleAttemptedRef.current = false; - }, () => { - haikuTitleAttemptedRef.current = false; - }); + queryCheckpoint('query_context_loading_start') + const [, , defaultSystemPrompt, baseUserContext, systemContext] = + await Promise.all([ + // IMPORTANT: do this after setMessages() above, to avoid UI jank + checkAndDisableBypassPermissionsIfNeeded( + toolPermissionContext, + setAppState, + ), + // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in + feature('TRANSCRIPT_CLASSIFIER') + ? checkAndDisableAutoModeIfNeeded( + toolPermissionContext, + setAppState, + store.getState().fastMode, + ) + : undefined, + getSystemPrompt( + freshTools, + mainLoopModelParam, + Array.from( + toolPermissionContext.additionalWorkingDirectories.keys(), + ), + freshMcpClients, + ), + getUserContext(), + getSystemContext(), + ]) + const userContext = { + ...baseUserContext, + ...getCoordinatorUserContext( + freshMcpClients, + isScratchpadEnabled() ? getScratchpadDir() : undefined, + ), + ...((feature('PROACTIVE') || feature('KAIROS')) && + proactiveModule?.isProactiveActive() && + !terminalFocusRef.current + ? { + terminalFocus: + 'The terminal is unfocused \u2014 the user is not actively watching.', + } + : {}), } - } + queryCheckpoint('query_context_loading_end') - // Apply slash-command-scoped allowedTools (from skill frontmatter) to the - // store once per turn. This also covers the reset: the next non-skill turn - // passes [] and clears it. Must run before the !shouldQuery gate: forked - // commands (executeForkedSlashCommand) return shouldQuery=false, and - // createGetAppStateWithAllowedTools in forkedAgent.ts reads this field, so - // stale skill tools would otherwise leak into forked agent permissions. - // Previously this write was hidden inside getToolUseContext's getAppState - // (~85 calls/turn); hoisting it here makes getAppState a pure read and stops - // ephemeral contexts (permission dialog, BackgroundTasksDialog) from - // accidentally clearing it mid-turn. - store.setState(prev => { - const cur = prev.toolPermissionContext.alwaysAllowRules.command; - if (cur === additionalAllowedTools || cur?.length === additionalAllowedTools.length && cur.every((v, i) => v === additionalAllowedTools[i])) { - return prev; + const systemPrompt = buildEffectiveSystemPrompt({ + mainThreadAgentDefinition, + toolUseContext, + customSystemPrompt, + defaultSystemPrompt, + appendSystemPrompt, + }) + toolUseContext.renderedSystemPrompt = systemPrompt + + queryCheckpoint('query_query_start') + resetTurnHookDuration() + resetTurnToolDuration() + resetTurnClassifierDuration() + + for await (const event of query({ + messages: messagesIncludingNewMessages, + systemPrompt, + userContext, + systemContext, + canUseTool, + toolUseContext, + querySource: getQuerySourceForREPL(), + })) { + onQueryEvent(event) } - return { - ...prev, - toolPermissionContext: { - ...prev.toolPermissionContext, - alwaysAllowRules: { - ...prev.toolPermissionContext.alwaysAllowRules, - command: additionalAllowedTools - } - } - }; - }); - - // The last message is an assistant message if the user input was a bash command, - // or if the user input was an invalid slash command. - if (!shouldQuery) { - // Manual /compact sets messages directly (shouldQuery=false) bypassing - // handleMessageFromStream. Clear context-blocked if a compact boundary - // is present so proactive ticks resume after compaction. - if (newMessages.some(isCompactBoundaryMessage)) { - // Bump conversationId so Messages.tsx row keys change and - // stale memoized rows remount with post-compact content. - setConversationId(randomUUID()); - if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.setContextBlocked(false); - } + + + if (feature('BUDDY') && typeof fireCompanionObserver === 'function') { + void fireCompanionObserver(messagesRef.current, reaction => + setAppState(prev => + prev.companionReaction === reaction + ? prev + : { ...prev, companionReaction: reaction }, + ), + ) } - resetLoadingState(); - setAbortController(null); - return; - } - const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam); - // getToolUseContext reads tools/mcpClients fresh from store.getState() - // (via computeTools/mergeClients). Use those rather than the closure- - // captured `tools`/`mcpClients` — useManageMCPConnections may have - // flushed new MCP state between the render that captured this closure - // and now. Turn 1 via processInitialMessage is the main beneficiary. - const { - tools: freshTools, - mcpClients: freshMcpClients - } = toolUseContext.options; - - // Scope the skill's effort override to this turn's context only — - // wrapping getAppState keeps the override out of the global store so - // background agents and UI subscribers (Spinner, LogoV2) never see it. - if (effort !== undefined) { - const previousGetAppState = toolUseContext.getAppState; - toolUseContext.getAppState = () => ({ - ...previousGetAppState(), - effortValue: effort - }); - } - queryCheckpoint('query_context_loading_start'); - const [,, defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ - // IMPORTANT: do this after setMessages() above, to avoid UI jank - checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), - // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in - feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]); - const userContext = { - ...baseUserContext, - ...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined), - ...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current ? { - terminalFocus: 'The terminal is unfocused \u2014 the user is not actively watching.' - } : {}) - }; - queryCheckpoint('query_context_loading_end'); - const systemPrompt = buildEffectiveSystemPrompt({ - mainThreadAgentDefinition, - toolUseContext, - customSystemPrompt, - defaultSystemPrompt, - appendSystemPrompt - }); - toolUseContext.renderedSystemPrompt = systemPrompt; - queryCheckpoint('query_query_start'); - resetTurnHookDuration(); - resetTurnToolDuration(); - resetTurnClassifierDuration(); - for await (const event of query({ - messages: messagesIncludingNewMessages, - systemPrompt, - userContext, - systemContext, - canUseTool, - toolUseContext, - querySource: getQuerySourceForREPL() - })) { - onQueryEvent(event); - } - if (feature('BUDDY')) { - triggerCompanionReaction(messagesRef.current, reaction => - setAppState(prev => prev.companionReaction === reaction ? prev : { - ...prev, - companionReaction: reaction as string | undefined, + + queryCheckpoint('query_end') + + // Capture ant-only API metrics before resetLoadingState clears the ref. + // For multi-request turns (tool use loops), compute P50 across all requests. + if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) { + const entries = apiMetricsRef.current + + const ttfts = entries.map(e => e.ttftMs) + // Compute per-request OTPS using only active streaming time and + // streaming-only content. endResponseLength tracks content added by + // streaming deltas only, excluding subagent/compaction inflation. + const otpsValues = entries.map(e => { + const delta = Math.round( + (e.endResponseLength - e.responseLengthBaseline) / 4, + ) + const samplingMs = e.lastTokenTime - e.firstTokenTime + return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0 }) - ); - } - queryCheckpoint('query_end'); - - // Capture ant-only API metrics before resetLoadingState clears the ref. - // For multi-request turns (tool use loops), compute P50 across all requests. - if ((process.env.USER_TYPE) === 'ant' && apiMetricsRef.current.length > 0) { - const entries = apiMetricsRef.current; - const ttfts = entries.map(e => e.ttftMs); - // Compute per-request OTPS using only active streaming time and - // streaming-only content. endResponseLength tracks content added by - // streaming deltas only, excluding subagent/compaction inflation. - const otpsValues = entries.map(e => { - const delta = Math.round((e.endResponseLength - e.responseLengthBaseline) / 4); - const samplingMs = e.lastTokenTime - e.firstTokenTime; - return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0; - }); - const isMultiRequest = entries.length > 1; - const hookMs = getTurnHookDurationMs(); - const hookCount = getTurnHookCount(); - const toolMs = getTurnToolDurationMs(); - const toolCount = getTurnToolCount(); - const classifierMs = getTurnClassifierDurationMs(); - const classifierCount = getTurnClassifierCount(); - const turnMs = Date.now() - loadingStartTimeRef.current; - setMessages(prev => [...prev, createApiMetricsMessage({ - ttftMs: isMultiRequest ? median(ttfts) : ttfts[0]!, - otps: isMultiRequest ? median(otpsValues) : otpsValues[0]!, - isP50: isMultiRequest, - hookDurationMs: hookMs > 0 ? hookMs : undefined, - hookCount: hookCount > 0 ? hookCount : undefined, - turnDurationMs: turnMs > 0 ? turnMs : undefined, - toolDurationMs: toolMs > 0 ? toolMs : undefined, - toolCount: toolCount > 0 ? toolCount : undefined, - classifierDurationMs: classifierMs > 0 ? classifierMs : undefined, - classifierCount: classifierCount > 0 ? classifierCount : undefined, - configWriteCount: getGlobalConfigWriteCount() - })]); - } - resetLoadingState(); - - // Log query profiling report if enabled - logQueryProfileReport(); - - // Signal that a query turn has completed successfully - await onTurnComplete?.(messagesRef.current); - }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled]); - const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise, input?: string, effort?: EffortValue): Promise => { - // If this is a teammate, mark them as active when starting a turn - if (isAgentSwarmsEnabled()) { - const teamName = getTeamName(); - const agentName = getAgentName(); - if (teamName && agentName) { - // Fire and forget - turn starts immediately, write happens in background - void setMemberActive(teamName, agentName, true); + + const isMultiRequest = entries.length > 1 + const hookMs = getTurnHookDurationMs() + const hookCount = getTurnHookCount() + const toolMs = getTurnToolDurationMs() + const toolCount = getTurnToolCount() + const classifierMs = getTurnClassifierDurationMs() + const classifierCount = getTurnClassifierCount() + const turnMs = Date.now() - loadingStartTimeRef.current + setMessages(prev => [ + ...prev, + createApiMetricsMessage({ + ttftMs: isMultiRequest ? median(ttfts) : ttfts[0]!, + otps: isMultiRequest ? median(otpsValues) : otpsValues[0]!, + isP50: isMultiRequest, + hookDurationMs: hookMs > 0 ? hookMs : undefined, + hookCount: hookCount > 0 ? hookCount : undefined, + turnDurationMs: turnMs > 0 ? turnMs : undefined, + toolDurationMs: toolMs > 0 ? toolMs : undefined, + toolCount: toolCount > 0 ? toolCount : undefined, + classifierDurationMs: classifierMs > 0 ? classifierMs : undefined, + classifierCount: classifierCount > 0 ? classifierCount : undefined, + configWriteCount: getGlobalConfigWriteCount(), + }), + ]) } - } - // Concurrent guard via state machine. tryStart() atomically checks - // and transitions idle→running, returning the generation number. - // Returns null if already running — no separate check-then-set. - const thisGeneration = queryGuard.tryStart(); - if (thisGeneration === null) { - logEvent('tengu_concurrent_onquery_detected', {}); - - // Extract and enqueue user message text, skipping meta messages - // (e.g. expanded skill content, tick prompts) that should not be - // replayed as user-visible text. - newMessages.filter((m): m is UserMessage => m.type === 'user' && !m.isMeta).map(_ => getContentText(_.message.content as string | ContentBlockParam[])).filter(_ => _ !== null).forEach((msg, i) => { - enqueue({ - value: msg, - mode: 'prompt' - }); - if (i === 0) { - logEvent('tengu_concurrent_onquery_enqueued', {}); + resetLoadingState() + + // Log query profiling report if enabled + logQueryProfileReport() + + // Signal that a query turn has completed successfully + await onTurnComplete?.(messagesRef.current) + }, + [ + initialMcpClients, + resetLoadingState, + getToolUseContext, + toolPermissionContext, + setAppState, + customSystemPrompt, + onTurnComplete, + appendSystemPrompt, + canUseTool, + mainThreadAgentDefinition, + onQueryEvent, + sessionTitle, + titleDisabled, + ], + ) + + const onQuery = useCallback( + async ( + newMessages: MessageType[], + abortController: AbortController, + shouldQuery: boolean, + additionalAllowedTools: string[], + mainLoopModelParam: string, + onBeforeQueryCallback?: ( + input: string, + newMessages: MessageType[], + ) => Promise, + input?: string, + effort?: EffortValue, + ): Promise => { + // If this is a teammate, mark them as active when starting a turn + if (isAgentSwarmsEnabled()) { + const teamName = getTeamName() + const agentName = getAgentName() + if (teamName && agentName) { + // Fire and forget - turn starts immediately, write happens in background + void setMemberActive(teamName, agentName, true) } - }); - return; - } - try { - // isLoading is derived from queryGuard — tryStart() above already - // transitioned dispatching→running, so no setter call needed here. - resetTimingRefs(); - setMessages(oldMessages => [...oldMessages, ...newMessages]); - responseLengthRef.current = 0; - if (feature('TOKEN_BUDGET')) { - const parsedBudget = input ? parseTokenBudget(input) : null; - snapshotOutputTokensForTurn(parsedBudget ?? getCurrentTurnTokenBudget()); } - apiMetricsRef.current = []; - setStreamingToolUses([]); - setStreamingText(null); - - // messagesRef is updated synchronously by the setMessages wrapper - // above, so it already includes newMessages from the append at the - // top of this try block. No reconstruction needed, no waiting for - // React's scheduler (previously cost 20-56ms per prompt; the 56ms - // case was a GC pause caught during the await). - const latestMessages = messagesRef.current; - if (input) { - await mrOnBeforeQuery(input, latestMessages, newMessages.length); + + // Concurrent guard via state machine. tryStart() atomically checks + // and transitions idle→running, returning the generation number. + // Returns null if already running — no separate check-then-set. + const thisGeneration = queryGuard.tryStart() + if (thisGeneration === null) { + logEvent('tengu_concurrent_onquery_detected', {}) + + // Extract and enqueue user message text, skipping meta messages + // (e.g. expanded skill content, tick prompts) that should not be + // replayed as user-visible text. + newMessages + .filter((m): m is UserMessage => m.type === 'user' && !m.isMeta) + .map(_ => getContentText(_.message.content)) + .filter(_ => _ !== null) + .forEach((msg, i) => { + enqueue({ value: msg, mode: 'prompt' }) + if (i === 0) { + logEvent('tengu_concurrent_onquery_enqueued', {}) + } + }) + return } - // Pass full conversation history to callback - if (onBeforeQueryCallback && input) { - const shouldProceed = await onBeforeQueryCallback(input, latestMessages); - if (!shouldProceed) { - return; + try { + // isLoading is derived from queryGuard — tryStart() above already + // transitioned dispatching→running, so no setter call needed here. + resetTimingRefs() + setMessages(oldMessages => [...oldMessages, ...newMessages]) + responseLengthRef.current = 0 + if (feature('TOKEN_BUDGET')) { + const parsedBudget = input ? parseTokenBudget(input) : null + snapshotOutputTokensForTurn( + parsedBudget ?? getCurrentTurnTokenBudget(), + ) } - } - await onQueryImpl(latestMessages, newMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, effort); - } finally { - // queryGuard.end() atomically checks generation and transitions - // running→idle. Returns false if a newer query owns the guard - // (cancel+resubmit race where the stale finally fires as a microtask). - if (queryGuard.end(thisGeneration)) { - setLastQueryCompletionTime(Date.now()); - skipIdleCheckRef.current = false; - // Always reset loading state in finally - this ensures cleanup even - // if onQueryImpl throws. onTurnComplete is called separately in - // onQueryImpl only on successful completion. - resetLoadingState(); - await mrOnTurnComplete(messagesRef.current, abortController.signal.aborted); - - // Notify bridge clients that the turn is complete so mobile apps - // can stop the spark animation and show post-turn UI. - sendBridgeResultRef.current(); - - // Auto-hide tungsten panel content at turn end (ant-only), but keep - // tungstenActiveSession set so the pill stays in the footer and the user - // can reopen the panel. Background tmux tasks (e.g. /hunter) run for - // minutes — wiping the session made the pill disappear entirely, forcing - // the user to re-invoke Tmux just to peek. Skip on abort so the panel - // stays open for inspection (matches the turn-duration guard below). - if ((process.env.USER_TYPE) === 'ant' && !abortController.signal.aborted) { - setAppState(prev => { - if (prev.tungstenActiveSession === undefined) return prev; - if (prev.tungstenPanelAutoHidden === true) return prev; - return { - ...prev, - tungstenPanelAutoHidden: true - }; - }); + apiMetricsRef.current = [] + setStreamingToolUses([]) + setStreamingText(null) + + // messagesRef is updated synchronously by the setMessages wrapper + // above, so it already includes newMessages from the append at the + // top of this try block. No reconstruction needed, no waiting for + // React's scheduler (previously cost 20-56ms per prompt; the 56ms + // case was a GC pause caught during the await). + const latestMessages = messagesRef.current + + if (input) { + await mrOnBeforeQuery(input, latestMessages, newMessages.length) } - // Capture budget info before clearing (ant-only) - let budgetInfo: { - tokens: number; - limit: number; - nudges: number; - } | undefined; - if (feature('TOKEN_BUDGET')) { - if (getCurrentTurnTokenBudget() !== null && getCurrentTurnTokenBudget()! > 0 && !abortController.signal.aborted) { - budgetInfo = { - tokens: getTurnOutputTokens(), - limit: getCurrentTurnTokenBudget()!, - nudges: getBudgetContinuationCount() - }; + // Pass full conversation history to callback + if (onBeforeQueryCallback && input) { + const shouldProceed = await onBeforeQueryCallback( + input, + latestMessages, + ) + if (!shouldProceed) { + return } - snapshotOutputTokensForTurn(null); } - // Add turn duration message for turns longer than 30s or with a budget - // Skip if user aborted or if in loop mode (too noisy between ticks) - // Defer if swarm teammates are still running (show when they finish) - const turnDurationMs = Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current; - if ((turnDurationMs > 30000 || budgetInfo !== undefined) && !abortController.signal.aborted && !proactiveActive) { - const hasRunningSwarmAgents = getAllInProcessTeammateTasks(store.getState().tasks).some(t => t.status === 'running'); - if (hasRunningSwarmAgents) { - // Only record start time on the first deferred turn - if (swarmStartTimeRef.current === null) { - swarmStartTimeRef.current = loadingStartTimeRef.current; + await onQueryImpl( + latestMessages, + newMessages, + abortController, + shouldQuery, + additionalAllowedTools, + mainLoopModelParam, + effort, + ) + } finally { + // queryGuard.end() atomically checks generation and transitions + // running→idle. Returns false if a newer query owns the guard + // (cancel+resubmit race where the stale finally fires as a microtask). + if (queryGuard.end(thisGeneration)) { + setLastQueryCompletionTime(Date.now()) + skipIdleCheckRef.current = false + // Always reset loading state in finally - this ensures cleanup even + // if onQueryImpl throws. onTurnComplete is called separately in + // onQueryImpl only on successful completion. + resetLoadingState() + + await mrOnTurnComplete( + messagesRef.current, + abortController.signal.aborted, + ) + + // Notify bridge clients that the turn is complete so mobile apps + // can stop the spark animation and show post-turn UI. + sendBridgeResultRef.current() + + // Auto-hide tungsten panel content at turn end (ant-only), but keep + // tungstenActiveSession set so the pill stays in the footer and the user + // can reopen the panel. Background tmux tasks (e.g. /hunter) run for + // minutes — wiping the session made the pill disappear entirely, forcing + // the user to re-invoke Tmux just to peek. Skip on abort so the panel + // stays open for inspection (matches the turn-duration guard below). + if ( + process.env.USER_TYPE === 'ant' && + !abortController.signal.aborted + ) { + setAppState(prev => { + if (prev.tungstenActiveSession === undefined) return prev + if (prev.tungstenPanelAutoHidden === true) return prev + return { ...prev, tungstenPanelAutoHidden: true } + }) + } + + // Capture budget info before clearing (ant-only) + let budgetInfo: + | { tokens: number; limit: number; nudges: number } + | undefined + if (feature('TOKEN_BUDGET')) { + if ( + getCurrentTurnTokenBudget() !== null && + getCurrentTurnTokenBudget()! > 0 && + !abortController.signal.aborted + ) { + budgetInfo = { + tokens: getTurnOutputTokens(), + limit: getCurrentTurnTokenBudget()!, + nudges: getBudgetContinuationCount(), + } } - // Always update budget — later turns may carry the actual budget - if (budgetInfo) { - swarmBudgetInfoRef.current = budgetInfo; + snapshotOutputTokensForTurn(null) + } + + // Add turn duration message for turns longer than 30s or with a budget + // Skip if user aborted or if in loop mode (too noisy between ticks) + // Defer if swarm teammates are still running (show when they finish) + const turnDurationMs = + Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current + if ( + (turnDurationMs > 30000 || budgetInfo !== undefined) && + !abortController.signal.aborted && + !proactiveActive + ) { + const hasRunningSwarmAgents = getAllInProcessTeammateTasks( + store.getState().tasks, + ).some(t => t.status === 'running') + if (hasRunningSwarmAgents) { + // Only record start time on the first deferred turn + if (swarmStartTimeRef.current === null) { + swarmStartTimeRef.current = loadingStartTimeRef.current + } + // Always update budget — later turns may carry the actual budget + if (budgetInfo) { + swarmBudgetInfoRef.current = budgetInfo + } + } else { + setMessages(prev => [ + ...prev, + createTurnDurationMessage( + turnDurationMs, + budgetInfo, + count(prev, isLoggableMessage), + ), + ]) } - } else { - setMessages(prev => [...prev, createTurnDurationMessage(turnDurationMs, budgetInfo, count(prev, isLoggableMessage))]); } + // Clear the controller so CancelRequestHandler's canCancelRunningTask + // reads false at the idle prompt. Without this, the stale non-aborted + // controller makes ctrl+c fire onCancel() (aborting nothing) instead of + // propagating to the double-press exit flow. + setAbortController(null) } - // Clear the controller so CancelRequestHandler's canCancelRunningTask - // reads false at the idle prompt. Without this, the stale non-aborted - // controller makes ctrl+c fire onCancel() (aborting nothing) instead of - // propagating to the double-press exit flow. - setAbortController(null); - } - // Auto-restore: if the user interrupted before any meaningful response - // arrived, rewind the conversation and restore their prompt — same as - // opening the message selector and picking the last message. - // This runs OUTSIDE the queryGuard.end() check because onCancel calls - // forceEnd(), which bumps the generation so end() returns false above. - // Guards: reason === 'user-cancel' (onCancel/Esc; programmatic aborts - // use 'background'/'interrupt' and must not rewind — note abort() with - // no args sets reason to a DOMException, not undefined), !isActive (no - // newer query started — cancel+resubmit race), empty input (don't - // clobber text typed during loading), no queued commands (user queued - // B while A was loading → they've moved on, don't restore A; also - // avoids removeLastFromHistory removing B's entry instead of A's), - // not viewing a teammate (messagesRef is the main conversation — the - // old Up-arrow quick-restore had this guard, preserve it). - if (abortController.signal.reason === 'user-cancel' && !queryGuard.isActive && inputValueRef.current === '' && getCommandQueueLength() === 0 && !store.getState().viewingAgentTaskId) { - const msgs = messagesRef.current; - const lastUserMsg = msgs.findLast(selectableUserMessagesFilter); - if (lastUserMsg) { - const idx = msgs.lastIndexOf(lastUserMsg); - if (messagesAfterAreOnlySynthetic(msgs, idx)) { - // The submit is being undone — undo its history entry too, - // otherwise Up-arrow shows the restored text twice. - removeLastFromHistory(); - restoreMessageSyncRef.current(lastUserMsg); + // Auto-restore: if the user interrupted before any meaningful response + // arrived, rewind the conversation and restore their prompt — same as + // opening the message selector and picking the last message. + // This runs OUTSIDE the queryGuard.end() check because onCancel calls + // forceEnd(), which bumps the generation so end() returns false above. + // Guards: reason === 'user-cancel' (onCancel/Esc; programmatic aborts + // use 'background'/'interrupt' and must not rewind — note abort() with + // no args sets reason to a DOMException, not undefined), !isActive (no + // newer query started — cancel+resubmit race), empty input (don't + // clobber text typed during loading), no queued commands (user queued + // B while A was loading → they've moved on, don't restore A; also + // avoids removeLastFromHistory removing B's entry instead of A's), + // not viewing a teammate (messagesRef is the main conversation — the + // old Up-arrow quick-restore had this guard, preserve it). + if ( + abortController.signal.reason === 'user-cancel' && + !queryGuard.isActive && + inputValueRef.current === '' && + getCommandQueueLength() === 0 && + !store.getState().viewingAgentTaskId + ) { + const msgs = messagesRef.current + const lastUserMsg = msgs.findLast(selectableUserMessagesFilter) + if (lastUserMsg) { + const idx = msgs.lastIndexOf(lastUserMsg) + if (messagesAfterAreOnlySynthetic(msgs, idx)) { + // The submit is being undone — undo its history entry too, + // otherwise Up-arrow shows the restored text twice. + removeLastFromHistory() + restoreMessageSyncRef.current(lastUserMsg) + } } } } - } - }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete]); + }, + [ + onQueryImpl, + setAppState, + resetLoadingState, + queryGuard, + mrOnBeforeQuery, + mrOnTurnComplete, + ], + ) // Handle initial message (from CLI args or plan mode exit with context clear) // This effect runs when isLoading becomes false and there's a pending message - const initialMessageRef = useRef(false); + const initialMessageRef = useRef(false) useEffect(() => { - const pending = initialMessage; - if (!pending || isLoading || initialMessageRef.current) return; + const pending = initialMessage + if (!pending || isLoading || initialMessageRef.current) return // Mark as processing to prevent re-entry - initialMessageRef.current = true; - async function processInitialMessage(initialMsg: NonNullable) { + initialMessageRef.current = true + + async function processInitialMessage( + initialMsg: NonNullable, + ) { // Clear context if requested (plan mode exit) if (initialMsg.clearContext) { // Preserve the plan slug before clearing context, so the new session // can access the same plan file after regenerateSessionId() - const oldPlanSlug = initialMsg.message.planContent ? getPlanSlug() : undefined; - const { - clearConversation - } = await import('../commands/clear/conversation.js'); + const oldPlanSlug = initialMsg.message.planContent + ? getPlanSlug() + : undefined + + const { clearConversation } = await import( + '../commands/clear/conversation.js' + ) await clearConversation({ setMessages, readFileState: readFileState.current, @@ -3055,66 +4114,82 @@ export function REPL({ loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, getAppState: () => store.getState(), setAppState, - setConversationId - }); - haikuTitleAttemptedRef.current = false; - setHaikuTitle(undefined); - bashTools.current.clear(); - bashToolsProcessedIdx.current = 0; + setConversationId, + }) + haikuTitleAttemptedRef.current = false + setHaikuTitle(undefined) + bashTools.current.clear() + bashToolsProcessedIdx.current = 0 // Restore the plan slug for the new session so getPlan() finds the file if (oldPlanSlug) { - setPlanSlug(getSessionId(), oldPlanSlug); + setPlanSlug(getSessionId(), oldPlanSlug) } } // Atomically: clear initial message, set permission mode and rules, and store plan for verification - const shouldStorePlanForVerification = initialMsg.message.planContent && (process.env.USER_TYPE) === 'ant' && isEnvTruthy(undefined); + const shouldStorePlanForVerification = + initialMsg.message.planContent && + process.env.USER_TYPE === 'ant' && + isEnvTruthy(undefined) + setAppState(prev => { // Build and apply permission updates (mode + allowedPrompts rules) - let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates(prev.toolPermissionContext, buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts)) : prev.toolPermissionContext; + let updatedToolPermissionContext = initialMsg.mode + ? applyPermissionUpdates( + prev.toolPermissionContext, + buildPermissionUpdates( + initialMsg.mode, + initialMsg.allowedPrompts, + ), + ) + : prev.toolPermissionContext // For auto, override the mode (buildPermissionUpdates maps // it to 'default' via toExternalPermissionMode) and strip dangerous rules if (feature('TRANSCRIPT_CLASSIFIER') && initialMsg.mode === 'auto') { updatedToolPermissionContext = stripDangerousPermissionsForAutoMode({ ...updatedToolPermissionContext, mode: 'auto', - prePlanMode: undefined - }); + prePlanMode: undefined, + }) } + return { ...prev, initialMessage: null, toolPermissionContext: updatedToolPermissionContext, ...(shouldStorePlanForVerification && { pendingPlanVerification: { - plan: initialMsg.message.planContent as string, + plan: initialMsg.message.planContent!, verificationStarted: false, - verificationCompleted: false - } - }) - }; - }); + verificationCompleted: false, + }, + }), + } + }) // Create file history snapshot for code rewind if (fileHistoryEnabled()) { - void fileHistoryMakeSnapshot((updater: (prev: FileHistoryState) => FileHistoryState) => { - setAppState(prev => ({ - ...prev, - fileHistory: updater(prev.fileHistory) - })); - }, initialMsg.message.uuid); + void fileHistoryMakeSnapshot( + (updater: (prev: FileHistoryState) => FileHistoryState) => { + setAppState(prev => ({ + ...prev, + fileHistory: updater(prev.fileHistory), + })) + }, + initialMsg.message.uuid, + ) } // Ensure SessionStart hook context is available before the first API // call. onSubmit calls this internally but the onQuery path below // bypasses onSubmit — hoist here so both paths see hook messages. - await awaitPendingHooks(); + await awaitPendingHooks() // Route all initial prompts through onSubmit to ensure UserPromptSubmit hooks fire // TODO: Simplify by always routing through onSubmit once it supports // ContentBlockParam arrays (images) as input - const content = initialMsg.message.message.content; + const content = initialMsg.message.message.content // Route all string content through onSubmit to ensure hooks fire // For complex content (images, etc.), fall back to direct onQuery @@ -3124,690 +4199,884 @@ export function REPL({ void onSubmit(content, { setCursorOffset: () => {}, clearBuffer: () => {}, - resetHistory: () => {} - }); + resetHistory: () => {}, + }) } else { // Plan messages or complex content (images, etc.) - send directly to model // Plan messages use onQuery to preserve planContent metadata for rendering // TODO: Once onSubmit supports ContentBlockParam arrays, remove this branch - const newAbortController = createAbortController(); - setAbortController(newAbortController); - void onQuery([initialMsg.message], newAbortController, true, - // shouldQuery - [], - // additionalAllowedTools - mainLoopModel); + const newAbortController = createAbortController() + setAbortController(newAbortController) + + void onQuery( + [initialMsg.message], + newAbortController, + true, // shouldQuery + [], // additionalAllowedTools + mainLoopModel, + ) } // Reset ref after a delay to allow new initial messages - setTimeout(ref => { - ref.current = false; - }, 100, initialMessageRef); - } - void processInitialMessage(pending); - }, [initialMessage, isLoading, setMessages, setAppState, onQuery, mainLoopModel, tools]); - const onSubmit = useCallback(async (input: string, helpers: PromptInputHelpers, speculationAccept?: { - state: ActiveSpeculationState; - speculationSessionTimeSavedMs: number; - setAppState: SetAppState; - }, options?: { - fromKeybinding?: boolean; - }) => { - // Re-pin scroll to bottom on submit so the user always sees the new - // exchange (matches OpenCode's auto-scroll behavior). - repinScroll(); - - // Resume loop mode if paused - if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.resumeProactive(); + setTimeout( + ref => { + ref.current = false + }, + 100, + initialMessageRef, + ) } - // Handle immediate commands - these bypass the queue and execute right away - // even while Claude is processing. Commands opt-in via `immediate: true`. - // Commands triggered via keybindings are always treated as immediate. - if (!speculationAccept && input.trim().startsWith('/')) { - // Expand [Pasted text #N] refs so immediate commands (e.g. /btw) receive - // the pasted content, not the placeholder. The non-immediate path gets - // this expansion later in handlePromptSubmit. - const trimmedInput = expandPastedTextRefs(input, pastedContents).trim(); - const spaceIndex = trimmedInput.indexOf(' '); - const commandName = spaceIndex === -1 ? trimmedInput.slice(1) : trimmedInput.slice(1, spaceIndex); - const commandArgs = spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim(); - - // Find matching command - treat as immediate if: - // 1. Command has `immediate: true`, OR - // 2. Command was triggered via keybinding (fromKeybinding option) - const matchingCommand = commands.find(cmd => isCommandEnabled(cmd) && (cmd.name === commandName || cmd.aliases?.includes(commandName) || getCommandName(cmd) === commandName)); - if (matchingCommand?.name === 'clear' && idleHintShownRef.current) { - logEvent('tengu_idle_return_action', { - action: 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - variant: idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - idleMinutes: Math.round((Date.now() - lastQueryCompletionTimeRef.current) / 60_000), - messageCount: messagesRef.current.length, - totalInputTokens: getTotalInputTokens() - }); - idleHintShownRef.current = false; + void processInitialMessage(pending) + }, [ + initialMessage, + isLoading, + setMessages, + setAppState, + onQuery, + mainLoopModel, + tools, + ]) + + const onSubmit = useCallback( + async ( + input: string, + helpers: PromptInputHelpers, + speculationAccept?: { + state: ActiveSpeculationState + speculationSessionTimeSavedMs: number + setAppState: SetAppState + }, + options?: { fromKeybinding?: boolean }, + ) => { + // Re-pin scroll to bottom on submit so the user always sees the new + // exchange (matches OpenCode's auto-scroll behavior). + repinScroll() + + // Resume loop mode if paused + if (feature('PROACTIVE') || feature('KAIROS')) { + proactiveModule?.resumeProactive() } - const shouldTreatAsImmediate = queryGuard.isActive && (matchingCommand?.immediate || options?.fromKeybinding); - if (matchingCommand && shouldTreatAsImmediate && matchingCommand.type === 'local-jsx') { - // Only clear input if the submitted text matches what's in the prompt. - // When a command keybinding fires, input is "/" but the actual - // input value is the user's existing text - don't clear it in that case. - if (input.trim() === inputValueRef.current.trim()) { - setInputValue(''); - helpers.setCursorOffset(0); - helpers.clearBuffer(); - setPastedContents({}); + + // Handle immediate commands - these bypass the queue and execute right away + // even while Claude is processing. Commands opt-in via `immediate: true`. + // Commands triggered via keybindings are always treated as immediate. + if (!speculationAccept && input.trim().startsWith('/')) { + // Expand [Pasted text #N] refs so immediate commands (e.g. /btw) receive + // the pasted content, not the placeholder. The non-immediate path gets + // this expansion later in handlePromptSubmit. + const trimmedInput = expandPastedTextRefs(input, pastedContents).trim() + const spaceIndex = trimmedInput.indexOf(' ') + const commandName = + spaceIndex === -1 + ? trimmedInput.slice(1) + : trimmedInput.slice(1, spaceIndex) + const commandArgs = + spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim() + + // Find matching command - treat as immediate if: + // 1. Command has `immediate: true`, OR + // 2. Command was triggered via keybinding (fromKeybinding option) + const matchingCommand = commands.find( + cmd => + isCommandEnabled(cmd) && + (cmd.name === commandName || + cmd.aliases?.includes(commandName) || + getCommandName(cmd) === commandName), + ) + if (matchingCommand?.name === 'clear' && idleHintShownRef.current) { + logEvent('tengu_idle_return_action', { + action: + 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + variant: + idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + idleMinutes: Math.round( + (Date.now() - lastQueryCompletionTimeRef.current) / 60_000, + ), + messageCount: messagesRef.current.length, + totalInputTokens: getTotalInputTokens(), + }) + idleHintShownRef.current = false } - const pastedTextRefs = parseReferences(input).filter(r => pastedContents[r.id]?.type === 'text'); - const pastedTextCount = pastedTextRefs.length; - const pastedTextBytes = pastedTextRefs.reduce((sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), 0); - logEvent('tengu_paste_text', { - pastedTextCount, - pastedTextBytes - }); - logEvent('tengu_immediate_command_executed', { - commandName: matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - fromKeybinding: options?.fromKeybinding ?? false - }); - - // Execute the command directly - const executeImmediateCommand = async (): Promise => { - let doneWasCalled = false; - const onDone = (result?: string, doneOptions?: { - display?: CommandResultDisplay; - metaMessages?: string[]; - }): void => { - doneWasCalled = true; - setToolJSX({ - jsx: null, - shouldHidePromptInput: false, - clearLocalJSX: true - }); - const newMessages: MessageType[] = []; - if (result && doneOptions?.display !== 'skip') { - addNotification({ - key: `immediate-${matchingCommand.name}`, - text: result, - priority: 'immediate' - }); - // In fullscreen the command just showed as a centered modal - // pane — the notification above is enough feedback. Adding - // "❯ /config" + "⎿ dismissed" to the transcript is clutter - // (those messages are type:system subtype:local_command — - // user-visible but NOT sent to the model, so skipping them - // doesn't change model context). Outside fullscreen the - // transcript entry stays so scrollback shows what ran. - if (!isFullscreenEnvEnabled()) { - newMessages.push(createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)), createCommandInputMessage(`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}`)); + + const shouldTreatAsImmediate = + queryGuard.isActive && + (matchingCommand?.immediate || options?.fromKeybinding) + + if ( + matchingCommand && + shouldTreatAsImmediate && + matchingCommand.type === 'local-jsx' + ) { + // Only clear input if the submitted text matches what's in the prompt. + // When a command keybinding fires, input is "/" but the actual + // input value is the user's existing text - don't clear it in that case. + if (input.trim() === inputValueRef.current.trim()) { + setInputValue('') + helpers.setCursorOffset(0) + helpers.clearBuffer() + setPastedContents({}) + } + + const pastedTextRefs = parseReferences(input).filter( + r => pastedContents[r.id]?.type === 'text', + ) + const pastedTextCount = pastedTextRefs.length + const pastedTextBytes = pastedTextRefs.reduce( + (sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), + 0, + ) + logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes }) + logEvent('tengu_immediate_command_executed', { + commandName: + matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + fromKeybinding: options?.fromKeybinding ?? false, + }) + + // Execute the command directly + const executeImmediateCommand = async (): Promise => { + let doneWasCalled = false + const onDone = ( + result?: string, + doneOptions?: { + display?: CommandResultDisplay + metaMessages?: string[] + }, + ): void => { + doneWasCalled = true + setToolJSX({ + jsx: null, + shouldHidePromptInput: false, + clearLocalJSX: true, + }) + const newMessages: MessageType[] = [] + if (result && doneOptions?.display !== 'skip') { + addNotification({ + key: `immediate-${matchingCommand.name}`, + text: result, + priority: 'immediate', + }) + // In fullscreen the command just showed as a centered modal + // pane — the notification above is enough feedback. Adding + // "❯ /config" + "⎿ dismissed" to the transcript is clutter + // (those messages are type:system subtype:local_command — + // user-visible but NOT sent to the model, so skipping them + // doesn't change model context). Outside fullscreen the + // transcript entry stays so scrollback shows what ran. + if (!isFullscreenEnvEnabled()) { + newMessages.push( + createCommandInputMessage( + formatCommandInputTags( + getCommandName(matchingCommand), + commandArgs, + ), + ), + createCommandInputMessage( + `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}`, + ), + ) + } + } + // Inject meta messages (model-visible, user-hidden) into the transcript + if (doneOptions?.metaMessages?.length) { + newMessages.push( + ...doneOptions.metaMessages.map(content => + createUserMessage({ content, isMeta: true }), + ), + ) + } + if (newMessages.length) { + setMessages(prev => [...prev, ...newMessages]) + } + // Restore stashed prompt after local-jsx command completes. + // The normal stash restoration path (below) is skipped because + // local-jsx commands return early from onSubmit. + if (stashedPrompt !== undefined) { + setInputValue(stashedPrompt.text) + helpers.setCursorOffset(stashedPrompt.cursorOffset) + setPastedContents(stashedPrompt.pastedContents) + setStashedPrompt(undefined) } } - // Inject meta messages (model-visible, user-hidden) into the transcript - if (doneOptions?.metaMessages?.length) { - newMessages.push(...doneOptions.metaMessages.map(content => createUserMessage({ - content, - isMeta: true - }))); - } - if (newMessages.length) { - setMessages(prev => [...prev, ...newMessages]); - } - // Restore stashed prompt after local-jsx command completes. - // The normal stash restoration path (below) is skipped because - // local-jsx commands return early from onSubmit. - if (stashedPrompt !== undefined) { - setInputValue(stashedPrompt.text); - helpers.setCursorOffset(stashedPrompt.cursorOffset); - setPastedContents(stashedPrompt.pastedContents); - setStashedPrompt(undefined); + + // Build context for the command (reuses existing getToolUseContext). + // Read messages via ref to keep onSubmit stable across message + // updates — matches the pattern at L2384/L2400/L2662 and avoids + // pinning stale REPL render scopes in downstream closures. + const context = getToolUseContext( + messagesRef.current, + [], + createAbortController(), + mainLoopModel, + ) + + const mod = await matchingCommand.load() + const jsx = await mod.call(onDone, context, commandArgs) + + // Skip if onDone already fired — prevents stuck isLocalJSXCommand + // (see processSlashCommand.tsx local-jsx case for full mechanism). + if (jsx && !doneWasCalled) { + // shouldHidePromptInput: false keeps Notifications mounted + // so the onDone result isn't lost + setToolJSX({ + jsx, + shouldHidePromptInput: false, + isLocalJSXCommand: true, + }) } - }; - - // Build context for the command (reuses existing getToolUseContext). - // Read messages via ref to keep onSubmit stable across message - // updates — matches the pattern at L2384/L2400/L2662 and avoids - // pinning stale REPL render scopes in downstream closures. - const context = getToolUseContext(messagesRef.current, [], createAbortController(), mainLoopModel); - const mod = await matchingCommand.load(); - const jsx = await mod.call(onDone, context, commandArgs); - - // Skip if onDone already fired — prevents stuck isLocalJSXCommand - // (see processSlashCommand.tsx local-jsx case for full mechanism). - if (jsx && !doneWasCalled) { - // shouldHidePromptInput: false keeps Notifications mounted - // so the onDone result isn't lost - setToolJSX({ - jsx, - shouldHidePromptInput: false, - isLocalJSXCommand: true - }); } - }; - void executeImmediateCommand(); - return; // Always return early - don't add to history or queue + void executeImmediateCommand() + return // Always return early - don't add to history or queue + } } - } - - // Remote mode: skip empty input early before any state mutations - if (activeRemote.isRemoteMode && !input.trim()) { - return; - } - // Idle-return: prompt returning users to start fresh when the - // conversation is large and the cache is cold. tengu_willow_mode - // controls treatment: "dialog" (blocking), "hint" (notification), "off". - { - const willowMode = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off'); - const idleThresholdMin = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75); - const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000); - if (willowMode !== 'off' && !getGlobalConfig().idleReturnDismissed && !skipIdleCheckRef.current && !speculationAccept && !input.trim().startsWith('/') && lastQueryCompletionTimeRef.current > 0 && getTotalInputTokens() >= tokenThreshold) { - const idleMs = Date.now() - lastQueryCompletionTimeRef.current; - const idleMinutes = idleMs / 60_000; - if (idleMinutes >= idleThresholdMin && willowMode === 'dialog') { - setIdleReturnPending({ - input, - idleMinutes - }); - setInputValue(''); - helpers.setCursorOffset(0); - helpers.clearBuffer(); - return; - } + // Remote mode: skip empty input early before any state mutations + if (activeRemote.isRemoteMode && !input.trim()) { + return } - } - // Add to history for direct user submissions. - // Queued command processing (executeQueuedInput) doesn't call onSubmit, - // so notifications and already-queued user input won't be added to history here. - // Skip history for keybinding-triggered commands (user didn't type the command). - if (!options?.fromKeybinding) { - addToHistory({ - display: speculationAccept ? input : prependModeCharacterToInput(input, inputMode), - pastedContents: speculationAccept ? {} : pastedContents - }); - // Add the just-submitted command to the front of the ghost-text - // cache so it's suggested immediately (not after the 60s TTL). - if (inputMode === 'bash') { - prependToShellHistoryCache(input.trim()); + // Idle-return: prompt returning users to start fresh when the + // conversation is large and the cache is cold. tengu_willow_mode + // controls treatment: "dialog" (blocking), "hint" (notification), "off". + { + const willowMode = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_willow_mode', + 'off', + ) + const idleThresholdMin = Number( + process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75, + ) + const tokenThreshold = Number( + process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000, + ) + if ( + willowMode !== 'off' && + !getGlobalConfig().idleReturnDismissed && + !skipIdleCheckRef.current && + !speculationAccept && + !input.trim().startsWith('/') && + lastQueryCompletionTimeRef.current > 0 && + getTotalInputTokens() >= tokenThreshold + ) { + const idleMs = Date.now() - lastQueryCompletionTimeRef.current + const idleMinutes = idleMs / 60_000 + if (idleMinutes >= idleThresholdMin && willowMode === 'dialog') { + setIdleReturnPending({ input, idleMinutes }) + setInputValue('') + helpers.setCursorOffset(0) + helpers.clearBuffer() + return + } + } } - } - // Restore stash if present, but NOT for slash commands or when loading. - // - Slash commands (especially interactive ones like /model, /context) hide - // the prompt and show a picker UI. Restoring the stash during a command would - // place the text in a hidden input, and the user would lose it by typing the - // next command. Instead, preserve the stash so it survives across command runs. - // - When loading, the submitted input will be queued and handlePromptSubmit - // will clear the input field (onInputChange('')), which would clobber the - // restored stash. Defer restoration to after handlePromptSubmit (below). - // Remote mode is exempt: it sends via WebSocket and returns early without - // calling handlePromptSubmit, so there's no clobbering risk — restore eagerly. - // In both deferred cases, the stash is restored after await handlePromptSubmit. - const isSlashCommand = !speculationAccept && input.trim().startsWith('/'); - // Submit runs "now" (not queued) when not already loading, or when - // accepting speculation, or in remote mode (which sends via WS and - // returns early without calling handlePromptSubmit). - const submitsNow = !isLoading || speculationAccept || activeRemote.isRemoteMode; - if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) { - setInputValue(stashedPrompt.text); - helpers.setCursorOffset(stashedPrompt.cursorOffset); - setPastedContents(stashedPrompt.pastedContents); - setStashedPrompt(undefined); - } else if (submitsNow) { + // Add to history for direct user submissions. + // Queued command processing (executeQueuedInput) doesn't call onSubmit, + // so notifications and already-queued user input won't be added to history here. + // Skip history for keybinding-triggered commands (user didn't type the command). if (!options?.fromKeybinding) { - // Clear input when not loading or accepting speculation. - // Preserve input for keybinding-triggered commands. - setInputValue(''); - helpers.setCursorOffset(0); - } - setPastedContents({}); - } - if (submitsNow) { - setInputMode('prompt'); - setIDESelection(undefined); - setSubmitCount(_ => _ + 1); - helpers.clearBuffer(); - tipPickedThisTurnRef.current = false; - - // Show the placeholder in the same React batch as setInputValue(''). - // Skip for slash/bash (they have their own echo), speculation and remote - // mode (both setMessages directly with no gap to bridge). - if (!isSlashCommand && inputMode === 'prompt' && !speculationAccept && !activeRemote.isRemoteMode) { - setUserInputOnProcessing(input); - // showSpinner includes userInputOnProcessing, so the spinner appears - // on this render. Reset timing refs now (before queryGuard.reserve() - // would) so elapsed time doesn't read as Date.now() - 0. The - // isQueryActive transition above does the same reset — idempotent. - resetTimingRefs(); + addToHistory({ + display: speculationAccept + ? input + : prependModeCharacterToInput(input, inputMode), + pastedContents: speculationAccept ? {} : pastedContents, + }) + // Add the just-submitted command to the front of the ghost-text + // cache so it's suggested immediately (not after the 60s TTL). + if (inputMode === 'bash') { + prependToShellHistoryCache(input.trim()) + } } - // Increment prompt count for attribution tracking and save snapshot - // The snapshot persists promptCount so it survives compaction - if (feature('COMMIT_ATTRIBUTION')) { - setAppState(prev => ({ - ...prev, - attribution: incrementPromptCount(prev.attribution, snapshot => { - void recordAttributionSnapshot(snapshot).catch(error => { - logForDebugging(`Attribution: Failed to save snapshot: ${error}`); - }); - }) - })); + // Restore stash if present, but NOT for slash commands or when loading. + // - Slash commands (especially interactive ones like /model, /context) hide + // the prompt and show a picker UI. Restoring the stash during a command would + // place the text in a hidden input, and the user would lose it by typing the + // next command. Instead, preserve the stash so it survives across command runs. + // - When loading, the submitted input will be queued and handlePromptSubmit + // will clear the input field (onInputChange('')), which would clobber the + // restored stash. Defer restoration to after handlePromptSubmit (below). + // Remote mode is exempt: it sends via WebSocket and returns early without + // calling handlePromptSubmit, so there's no clobbering risk — restore eagerly. + // In both deferred cases, the stash is restored after await handlePromptSubmit. + const isSlashCommand = !speculationAccept && input.trim().startsWith('/') + // Submit runs "now" (not queued) when not already loading, or when + // accepting speculation, or in remote mode (which sends via WS and + // returns early without calling handlePromptSubmit). + const submitsNow = + !isLoading || speculationAccept || activeRemote.isRemoteMode + if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) { + setInputValue(stashedPrompt.text) + helpers.setCursorOffset(stashedPrompt.cursorOffset) + setPastedContents(stashedPrompt.pastedContents) + setStashedPrompt(undefined) + } else if (submitsNow) { + if (!options?.fromKeybinding) { + // Clear input when not loading or accepting speculation. + // Preserve input for keybinding-triggered commands. + setInputValue('') + helpers.setCursorOffset(0) + } + setPastedContents({}) } - } - // Handle speculation acceptance - if (speculationAccept) { - const { - queryRequired - } = await handleSpeculationAccept(speculationAccept.state, speculationAccept.speculationSessionTimeSavedMs, speculationAccept.setAppState, input, { - setMessages, - readFileState, - cwd: getOriginalCwd() - }); - if (queryRequired) { - const newAbortController = createAbortController(); - setAbortController(newAbortController); - void onQuery([], newAbortController, true, [], mainLoopModel); + if (submitsNow) { + setInputMode('prompt') + setIDESelection(undefined) + setSubmitCount(_ => _ + 1) + helpers.clearBuffer() + tipPickedThisTurnRef.current = false + + // Show the placeholder in the same React batch as setInputValue(''). + // Skip for slash/bash (they have their own echo), speculation and remote + // mode (both setMessages directly with no gap to bridge). + if ( + !isSlashCommand && + inputMode === 'prompt' && + !speculationAccept && + !activeRemote.isRemoteMode + ) { + setUserInputOnProcessing(input) + // showSpinner includes userInputOnProcessing, so the spinner appears + // on this render. Reset timing refs now (before queryGuard.reserve() + // would) so elapsed time doesn't read as Date.now() - 0. The + // isQueryActive transition above does the same reset — idempotent. + resetTimingRefs() + } + + // Increment prompt count for attribution tracking and save snapshot + // The snapshot persists promptCount so it survives compaction + if (feature('COMMIT_ATTRIBUTION')) { + setAppState(prev => ({ + ...prev, + attribution: incrementPromptCount(prev.attribution, snapshot => { + void recordAttributionSnapshot(snapshot).catch(error => { + logForDebugging( + `Attribution: Failed to save snapshot: ${error}`, + ) + }) + }), + })) + } } - return; - } - // Remote mode: send input via stream-json instead of local query. - // Permission requests from the remote are bridged into toolUseConfirmQueue - // and rendered using the standard PermissionRequest component. - // - // local-jsx slash commands (e.g. /agents, /config) render UI in THIS - // process — they have no remote equivalent. Let those fall through to - // handlePromptSubmit so they execute locally. Prompt commands and - // plain text go to the remote. - if (activeRemote.isRemoteMode && !(isSlashCommand && commands.find(c => { - const name = input.trim().slice(1).split(/\s/)[0]; - return isCommandEnabled(c) && (c.name === name || c.aliases?.includes(name!) || getCommandName(c) === name); - })?.type === 'local-jsx')) { - // Build content blocks when there are pasted attachments (images) - const pastedValues = Object.values(pastedContents); - const imageContents = pastedValues.filter(c => c.type === 'image'); - const imagePasteIds = imageContents.length > 0 ? imageContents.map(c => c.id) : undefined; - let messageContent: string | ContentBlockParam[] = input.trim(); - let remoteContent: RemoteMessageContent = input.trim(); - if (pastedValues.length > 0) { - const contentBlocks: ContentBlockParam[] = []; - const remoteBlocks: Array<{ - type: string; - [key: string]: unknown; - }> = []; - const trimmedInput = input.trim(); - if (trimmedInput) { - contentBlocks.push({ - type: 'text', - text: trimmedInput - }); - remoteBlocks.push({ - type: 'text', - text: trimmedInput - }); + // Handle speculation acceptance + if (speculationAccept) { + const { queryRequired } = await handleSpeculationAccept( + speculationAccept.state, + speculationAccept.speculationSessionTimeSavedMs, + speculationAccept.setAppState, + input, + { + setMessages, + readFileState, + cwd: getOriginalCwd(), + }, + ) + if (queryRequired) { + const newAbortController = createAbortController() + setAbortController(newAbortController) + void onQuery([], newAbortController, true, [], mainLoopModel) } - for (const pasted of pastedValues) { - if (pasted.type === 'image') { - const source = { - type: 'base64' as const, - media_type: (pasted.mediaType ?? 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', - data: pasted.content - }; - contentBlocks.push({ - type: 'image', - source - }); - remoteBlocks.push({ - type: 'image', - source - }); - } else { - contentBlocks.push({ - type: 'text', - text: pasted.content - }); - remoteBlocks.push({ - type: 'text', - text: pasted.content - }); + return + } + + // Remote mode: send input via stream-json instead of local query. + // Permission requests from the remote are bridged into toolUseConfirmQueue + // and rendered using the standard PermissionRequest component. + // + // local-jsx slash commands (e.g. /agents, /config) render UI in THIS + // process — they have no remote equivalent. Let those fall through to + // handlePromptSubmit so they execute locally. Prompt commands and + // plain text go to the remote. + if ( + activeRemote.isRemoteMode && + !( + isSlashCommand && + commands.find(c => { + const name = input.trim().slice(1).split(/\s/)[0] + return ( + isCommandEnabled(c) && + (c.name === name || + c.aliases?.includes(name!) || + getCommandName(c) === name) + ) + })?.type === 'local-jsx' + ) + ) { + // Build content blocks when there are pasted attachments (images) + const pastedValues = Object.values(pastedContents) + const imageContents = pastedValues.filter(c => c.type === 'image') + const imagePasteIds = + imageContents.length > 0 ? imageContents.map(c => c.id) : undefined + + let messageContent: string | ContentBlockParam[] = input.trim() + let remoteContent: RemoteMessageContent = input.trim() + if (pastedValues.length > 0) { + const contentBlocks: ContentBlockParam[] = [] + const remoteBlocks: Array<{ type: string; [key: string]: unknown }> = + [] + + const trimmedInput = input.trim() + if (trimmedInput) { + contentBlocks.push({ type: 'text', text: trimmedInput }) + remoteBlocks.push({ type: 'text', text: trimmedInput }) + } + + for (const pasted of pastedValues) { + if (pasted.type === 'image') { + const source = { + type: 'base64' as const, + media_type: (pasted.mediaType ?? 'image/png') as + | 'image/jpeg' + | 'image/png' + | 'image/gif' + | 'image/webp', + data: pasted.content, + } + contentBlocks.push({ type: 'image', source }) + remoteBlocks.push({ type: 'image', source }) + } else { + contentBlocks.push({ type: 'text', text: pasted.content }) + remoteBlocks.push({ type: 'text', text: pasted.content }) + } } + + messageContent = contentBlocks + remoteContent = remoteBlocks } - messageContent = contentBlocks; - remoteContent = remoteBlocks; + + // Create and add user message to UI + // Note: empty input already handled by early return above + const userMessage = createUserMessage({ + content: messageContent, + imagePasteIds, + }) + setMessages(prev => [...prev, userMessage]) + + // Send to remote session + await activeRemote.sendMessage(remoteContent, { + uuid: userMessage.uuid, + }) + return } - // Create and add user message to UI - // Note: empty input already handled by early return above - const userMessage = createUserMessage({ - content: messageContent, - imagePasteIds - }); - setMessages(prev => [...prev, userMessage]); - - // Send to remote session - await activeRemote.sendMessage(remoteContent, { - uuid: userMessage.uuid - }); - return; - } + // Ensure SessionStart hook context is available before the first API call. + await awaitPendingHooks() - // Ensure SessionStart hook context is available before the first API call. - await awaitPendingHooks(); - await handlePromptSubmit({ - input, - helpers, + await handlePromptSubmit({ + input, + helpers, + queryGuard, + isExternalLoading, + mode: inputMode, + commands, + onInputChange: setInputValue, + setPastedContents, + setToolJSX, + getToolUseContext, + messages: messagesRef.current, + mainLoopModel, + pastedContents, + ideSelection, + setUserInputOnProcessing, + setAbortController, + abortController, + onQuery, + setAppState, + querySource: getQuerySourceForREPL(), + onBeforeQuery, + canUseTool, + addNotification, + setMessages, + // Read via ref so streamMode can be dropped from onSubmit deps — + // handlePromptSubmit only uses it for debug log + telemetry event. + streamMode: streamModeRef.current, + hasInterruptibleToolInProgress: + hasInterruptibleToolInProgressRef.current, + }) + + // Restore stash that was deferred above. Two cases: + // - Slash command: handlePromptSubmit awaited the full command execution + // (including interactive pickers). Restoring now places the stash back in + // the visible input. + // - Loading (queued): handlePromptSubmit enqueued + cleared input, then + // returned quickly. Restoring now places the stash back after the clear. + if ((isSlashCommand || isLoading) && stashedPrompt !== undefined) { + setInputValue(stashedPrompt.text) + helpers.setCursorOffset(stashedPrompt.cursorOffset) + setPastedContents(stashedPrompt.pastedContents) + setStashedPrompt(undefined) + } + }, + [ queryGuard, + // isLoading is read at the !isLoading checks above for input-clearing + // and submitCount gating. It's derived from isQueryActive || isExternalLoading, + // so including it here ensures the closure captures the fresh value. + isLoading, isExternalLoading, - mode: inputMode, + inputMode, commands, - onInputChange: setInputValue, + setInputValue, + setInputMode, setPastedContents, + setSubmitCount, + setIDESelection, setToolJSX, getToolUseContext, - messages: messagesRef.current, + // messages is read via messagesRef.current inside the callback to + // keep onSubmit stable across message updates (see L2384/L2400/L2662). + // Without this, each setMessages call (~30× per turn) recreates + // onSubmit, pinning the REPL render scope (1776B) + that render's + // messages array in downstream closures (PromptInput, handleAutoRunIssue). + // Heap analysis showed ~9 REPL scopes and ~15 messages array versions + // accumulating after #20174/#20175, all traced to this dep. mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, - abortController, + addNotification, onQuery, + stashedPrompt, + setStashedPrompt, setAppState, - querySource: getQuerySourceForREPL(), onBeforeQuery, canUseTool, - addNotification, + remoteSession, setMessages, - // Read via ref so streamMode can be dropped from onSubmit deps — - // handlePromptSubmit only uses it for debug log + telemetry event. - streamMode: streamModeRef.current, - hasInterruptibleToolInProgress: hasInterruptibleToolInProgressRef.current - }); - - // Restore stash that was deferred above. Two cases: - // - Slash command: handlePromptSubmit awaited the full command execution - // (including interactive pickers). Restoring now places the stash back in - // the visible input. - // - Loading (queued): handlePromptSubmit enqueued + cleared input, then - // returned quickly. Restoring now places the stash back after the clear. - if ((isSlashCommand || isLoading) && stashedPrompt !== undefined) { - setInputValue(stashedPrompt.text); - helpers.setCursorOffset(stashedPrompt.cursorOffset); - setPastedContents(stashedPrompt.pastedContents); - setStashedPrompt(undefined); - } - }, [queryGuard, - // isLoading is read at the !isLoading checks above for input-clearing - // and submitCount gating. It's derived from isQueryActive || isExternalLoading, - // so including it here ensures the closure captures the fresh value. - isLoading, isExternalLoading, inputMode, commands, setInputValue, setInputMode, setPastedContents, setSubmitCount, setIDESelection, setToolJSX, getToolUseContext, - // messages is read via messagesRef.current inside the callback to - // keep onSubmit stable across message updates (see L2384/L2400/L2662). - // Without this, each setMessages call (~30× per turn) recreates - // onSubmit, pinning the REPL render scope (1776B) + that render's - // messages array in downstream closures (PromptInput, handleAutoRunIssue). - // Heap analysis showed ~9 REPL scopes and ~15 messages array versions - // accumulating after #20174/#20175, all traced to this dep. - mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, addNotification, onQuery, stashedPrompt, setStashedPrompt, setAppState, onBeforeQuery, canUseTool, remoteSession, setMessages, awaitPendingHooks, repinScroll]); + awaitPendingHooks, + repinScroll, + ], + ) // Callback for when user submits input while viewing a teammate's transcript - const onAgentSubmit = useCallback(async (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => { - if (isLocalAgentTask(task)) { - appendMessageToLocalAgent(task.id, createUserMessage({ - content: input - }), setAppState); - if (task.status === 'running') { - queuePendingMessage(task.id, input, setAppState); - } else { - void resumeAgentBackground({ - agentId: task.id, - prompt: input, - toolUseContext: getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel), - canUseTool - }).catch(err => { - logForDebugging(`resumeAgentBackground failed: ${errorMessage(err)}`); - addNotification({ - key: `resume-agent-failed-${task.id}`, - jsx: + const onAgentSubmit = useCallback( + async ( + input: string, + task: InProcessTeammateTaskState | LocalAgentTaskState, + helpers: PromptInputHelpers, + ) => { + if (isLocalAgentTask(task)) { + appendMessageToLocalAgent( + task.id, + createUserMessage({ content: input }), + setAppState, + ) + if (task.status === 'running') { + queuePendingMessage(task.id, input, setAppState) + } else { + void resumeAgentBackground({ + agentId: task.id, + prompt: input, + toolUseContext: getToolUseContext( + messagesRef.current, + [], + new AbortController(), + mainLoopModel, + ), + canUseTool, + }).catch(err => { + logForDebugging( + `resumeAgentBackground failed: ${errorMessage(err)}`, + ) + addNotification({ + key: `resume-agent-failed-${task.id}`, + jsx: ( + Failed to resume agent: {errorMessage(err)} - , - priority: 'low' - }); - }); + + ), + priority: 'low', + }) + }) + } + } else { + injectUserMessageToTeammate(task.id, input, setAppState) } - } else { - injectUserMessageToTeammate(task.id, input, setAppState); - } - setInputValue(''); - helpers.setCursorOffset(0); - helpers.clearBuffer(); - }, [setAppState, setInputValue, getToolUseContext, canUseTool, mainLoopModel, addNotification]); + setInputValue('') + helpers.setCursorOffset(0) + helpers.clearBuffer() + }, + [ + setAppState, + setInputValue, + getToolUseContext, + canUseTool, + mainLoopModel, + addNotification, + ], + ) // Handlers for auto-run /issue or /good-claude (defined after onSubmit) const handleAutoRunIssue = useCallback(() => { - const command = autoRunIssueReason ? getAutoRunCommand(autoRunIssueReason) : '/issue'; - setAutoRunIssueReason(null); // Clear the state + const command = autoRunIssueReason + ? getAutoRunCommand(autoRunIssueReason) + : '/issue' + setAutoRunIssueReason(null) // Clear the state onSubmit(command, { setCursorOffset: () => {}, clearBuffer: () => {}, - resetHistory: () => {} + resetHistory: () => {}, }).catch(err => { - logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`); - }); - }, [onSubmit, autoRunIssueReason]); + logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`) + }) + }, [onSubmit, autoRunIssueReason]) + const handleCancelAutoRunIssue = useCallback(() => { - setAutoRunIssueReason(null); - }, []); + setAutoRunIssueReason(null) + }, []) // Handler for when user presses 1 on survey thanks screen to share details const handleSurveyRequestFeedback = useCallback(() => { - const command = (process.env.USER_TYPE) === 'ant' ? '/issue' : '/feedback'; + const command = process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback' onSubmit(command, { setCursorOffset: () => {}, clearBuffer: () => {}, - resetHistory: () => {} + resetHistory: () => {}, }).catch(err => { - logForDebugging(`Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`); - }); - }, [onSubmit]); + logForDebugging( + `Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`, + ) + }) + }, [onSubmit]) // onSubmit is unstable (deps include `messages` which changes every turn). // `handleOpenRateLimitOptions` is prop-drilled to every MessageRow, and each // MessageRow fiber pins the closure (and transitively the entire REPL render // scope, ~1.8KB) at mount time. Using a ref keeps this callback stable so // old REPL scopes can be GC'd — saves ~35MB over a 1000-turn session. - const onSubmitRef = useRef(onSubmit); - onSubmitRef.current = onSubmit; + const onSubmitRef = useRef(onSubmit) + onSubmitRef.current = onSubmit const handleOpenRateLimitOptions = useCallback(() => { void onSubmitRef.current('/rate-limit-options', { setCursorOffset: () => {}, clearBuffer: () => {}, - resetHistory: () => {} - }); - }, []); + resetHistory: () => {}, + }) + }, []) + const handleExit = useCallback(async () => { - setIsExiting(true); + setIsExiting(true) // In bg sessions, always detach instead of kill — even when a worktree is // active. Without this guard, the worktree branch below short-circuits into // ExitFlow (which calls gracefulShutdown) before exit.tsx is ever loaded. if (feature('BG_SESSIONS') && isBgSession()) { - spawnSync('tmux', ['detach-client'], { - stdio: 'ignore' - }); - setIsExiting(false); - return; + spawnSync('tmux', ['detach-client'], { stdio: 'ignore' }) + setIsExiting(false) + return } - const showWorktree = getCurrentWorktreeSession() !== null; + const showWorktree = getCurrentWorktreeSession() !== null if (showWorktree) { - setExitFlow( {}} onCancel={() => { - setExitFlow(null); - setIsExiting(false); - }} />); - return; + setExitFlow( + {}} + onCancel={() => { + setExitFlow(null) + setIsExiting(false) + }} + />, + ) + return } - const exitMod = await exit.load(); - const exitFlowResult = await exitMod.call(() => {}); - setExitFlow(exitFlowResult); + const exitMod = await exit.load() + const exitFlowResult = await exitMod.call(() => {}) + setExitFlow(exitFlowResult) // If call() returned without killing the process (bg session detach), // clear isExiting so the UI is usable on reattach. No-op on the normal // path — gracefulShutdown's process.exit() means we never get here. if (exitFlowResult === null) { - setIsExiting(false); + setIsExiting(false) } - }, []); + }, []) + const handleShowMessageSelector = useCallback(() => { - setIsMessageSelectorVisible(prev => !prev); - }, []); + setIsMessageSelectorVisible(prev => !prev) + }, []) // Rewind conversation state to just before `message`: slice messages, // reset conversation ID, microcompact state, permission mode, prompt suggestion. // Does NOT touch the prompt input. Index is computed from messagesRef (always // fresh via the setMessages wrapper) so callers don't need to worry about // stale closures. - const rewindConversationTo = useCallback((message: UserMessage) => { - const prev = messagesRef.current; - const messageIndex = prev.lastIndexOf(message); - if (messageIndex === -1) return; - logEvent('tengu_conversation_rewind', { - preRewindMessageCount: prev.length, - postRewindMessageCount: messageIndex, - messagesRemoved: prev.length - messageIndex, - rewindToMessageIndex: messageIndex - }); - setMessages(prev.slice(0, messageIndex)); - // Careful, this has to happen after setMessages - setConversationId(randomUUID()); - // Reset cached microcompact state so stale pinned cache edits - // don't reference tool_use_ids from truncated messages - resetMicrocompactState(); - if (feature('CONTEXT_COLLAPSE')) { - // Rewind truncates the REPL array. Commits whose archived span - // was past the rewind point can't be projected anymore - // (projectView silently skips them) but the staged queue and ID - // maps reference stale uuids. Simplest safe reset: drop - // everything. The ctx-agent will re-stage on the next - // threshold crossing. - /* eslint-disable @typescript-eslint/no-require-imports */ - ; - (require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js')).resetContextCollapse(); - /* eslint-enable @typescript-eslint/no-require-imports */ - } - - // Restore state from the message we're rewinding to - setAppState(prev => ({ - ...prev, - // Restore permission mode from the message - toolPermissionContext: message.permissionMode && prev.toolPermissionContext.mode !== message.permissionMode ? { - ...prev.toolPermissionContext, - mode: message.permissionMode as PermissionMode - } : prev.toolPermissionContext, - // Clear stale prompt suggestion from previous conversation state - promptSuggestion: { - text: null, - promptId: null, - shownAt: 0, - acceptedAt: 0, - generationRequestId: null + const rewindConversationTo = useCallback( + (message: UserMessage) => { + const prev = messagesRef.current + const messageIndex = prev.lastIndexOf(message) + if (messageIndex === -1) return + + logEvent('tengu_conversation_rewind', { + preRewindMessageCount: prev.length, + postRewindMessageCount: messageIndex, + messagesRemoved: prev.length - messageIndex, + rewindToMessageIndex: messageIndex, + }) + setMessages(prev.slice(0, messageIndex)) + // Careful, this has to happen after setMessages + setConversationId(randomUUID()) + // Reset cached microcompact state so stale pinned cache edits + // don't reference tool_use_ids from truncated messages + resetMicrocompactState() + if (feature('CONTEXT_COLLAPSE')) { + // Rewind truncates the REPL array. Commits whose archived span + // was past the rewind point can't be projected anymore + // (projectView silently skips them) but the staged queue and ID + // maps reference stale uuids. Simplest safe reset: drop + // everything. The ctx-agent will re-stage on the next + // threshold crossing. + /* eslint-disable @typescript-eslint/no-require-imports */ + ;( + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + ).resetContextCollapse() + /* eslint-enable @typescript-eslint/no-require-imports */ } - })); - }, [setMessages, setAppState]); + + // Restore state from the message we're rewinding to + setAppState(prev => ({ + ...prev, + // Restore permission mode from the message + toolPermissionContext: + message.permissionMode && + prev.toolPermissionContext.mode !== message.permissionMode + ? { + ...prev.toolPermissionContext, + mode: message.permissionMode, + } + : prev.toolPermissionContext, + // Clear stale prompt suggestion from previous conversation state + promptSuggestion: { + text: null, + promptId: null, + shownAt: 0, + acceptedAt: 0, + generationRequestId: null, + }, + })) + }, + [setMessages, setAppState], + ) // Synchronous rewind + input population. Used directly by auto-restore on // interrupt (so React batches with the abort's setMessages → single render, // no flicker). MessageSelector wraps this in setImmediate via handleRestoreMessage. - const restoreMessageSync = useCallback((message: UserMessage) => { - rewindConversationTo(message); - const r = textForResubmit(message); - if (r) { - setInputValue(r.text); - setInputMode(r.mode); - } + const restoreMessageSync = useCallback( + (message: UserMessage) => { + rewindConversationTo(message) + + const r = textForResubmit(message) + if (r) { + setInputValue(r.text) + setInputMode(r.mode) + } - // Restore pasted images - if (Array.isArray(message.message.content) && message.message.content.some(block => block.type === 'image')) { - const imageBlocks = message.message.content.filter(block => block.type === 'image') as unknown as Array; - if (imageBlocks.length > 0) { - const newPastedContents: Record = {}; - imageBlocks.forEach((block, index) => { - if (block.source.type === 'base64') { - const id = message.imagePasteIds?.[index] ?? index + 1; - newPastedContents[id] = { - id, - type: 'image', - content: block.source.data, - mediaType: block.source.media_type - }; - } - }); - setPastedContents(newPastedContents); + // Restore pasted images + if ( + Array.isArray(message.message.content) && + message.message.content.some(block => block.type === 'image') + ) { + const imageBlocks: Array = + message.message.content.filter(block => block.type === 'image') + if (imageBlocks.length > 0) { + const newPastedContents: Record = {} + imageBlocks.forEach((block, index) => { + if (block.source.type === 'base64') { + const id = message.imagePasteIds?.[index] ?? index + 1 + newPastedContents[id] = { + id, + type: 'image', + content: block.source.data, + mediaType: block.source.media_type, + } + } + }) + setPastedContents(newPastedContents) + } } - } - }, [rewindConversationTo, setInputValue]); - restoreMessageSyncRef.current = restoreMessageSync; + }, + [rewindConversationTo, setInputValue], + ) + restoreMessageSyncRef.current = restoreMessageSync // MessageSelector path: defer via setImmediate so the "Interrupted" message // renders to static output before rewind — otherwise it remains vestigial // at the top of the screen. - const handleRestoreMessage = useCallback(async (message: UserMessage) => { - setImmediate((restore, message) => restore(message), restoreMessageSync, message); - }, [restoreMessageSync]); + const handleRestoreMessage = useCallback( + async (message: UserMessage) => { + setImmediate( + (restore, message) => restore(message), + restoreMessageSync, + message, + ) + }, + [restoreMessageSync], + ) // Not memoized — hook stores caps via ref, reads latest closure at dispatch. // 24-char prefix: deriveUUID preserves first 24, renderable uuid prefix-matches raw source. const findRawIndex = (uuid: string) => { - const prefix = uuid.slice(0, 24); - return messages.findIndex(m => m.uuid.slice(0, 24) === prefix); - }; + const prefix = uuid.slice(0, 24) + return messages.findIndex(m => m.uuid.slice(0, 24) === prefix) + } const messageActionCaps: MessageActionCaps = { copy: text => - // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only). - void setClipboard(text).then(raw => { - if (raw) process.stdout.write(raw); - addNotification({ - // Same key as text-selection copy — repeated copies replace toast, don't queue. - key: 'selection-copied', - text: 'copied', - color: 'success', - priority: 'immediate', - timeoutMs: 2000 - }); - }), + // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only). + void setClipboard(text).then(raw => { + if (raw) process.stdout.write(raw) + addNotification({ + // Same key as text-selection copy — repeated copies replace toast, don't queue. + key: 'selection-copied', + text: 'copied', + color: 'success', + priority: 'immediate', + timeoutMs: 2000, + }) + }), edit: async msg => { // Same skip-confirm check as /rewind: lossless → direct, else confirm dialog. - const rawIdx = findRawIndex(msg.uuid); - const raw = rawIdx >= 0 ? messages[rawIdx] : undefined; - if (!raw || !selectableUserMessagesFilter(raw)) return; - const noFileChanges = !(await fileHistoryHasAnyChanges(fileHistory, raw.uuid)); - const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx); + const rawIdx = findRawIndex(msg.uuid) + const raw = rawIdx >= 0 ? messages[rawIdx] : undefined + if (!raw || !selectableUserMessagesFilter(raw)) return + const noFileChanges = !(await fileHistoryHasAnyChanges( + fileHistory, + raw.uuid, + )) + const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx) if (noFileChanges && onlySynthetic) { // rewindConversationTo's setMessages races stream appends — cancel first (idempotent). - onCancel(); + onCancel() // handleRestoreMessage also restores pasted images. - void handleRestoreMessage(raw); + void handleRestoreMessage(raw) } else { // Dialog path: onPreRestore (= onCancel) fires when user CONFIRMS, not on nevermind. - setMessageSelectorPreselect(raw); - setIsMessageSelectorVisible(true); + setMessageSelectorPreselect(raw) + setIsMessageSelectorVisible(true) } - } - }; - const { - enter: enterMessageActions, - handlers: messageActionHandlers - } = useMessageActions(cursor, setCursor, cursorNavRef, messageActionCaps); + }, + } + const { enter: enterMessageActions, handlers: messageActionHandlers } = + useMessageActions(cursor, setCursor, cursorNavRef, messageActionCaps) + async function onInit() { // Always verify API key on startup, so we can show the user an error in the // bottom right corner of the screen if the API key is invalid. - void reverify(); + void reverify() // Populate readFileState with CLAUDE.md files at startup - const memoryFiles = await getMemoryFiles(); + const memoryFiles = await getMemoryFiles() if (memoryFiles.length > 0) { - const fileList = memoryFiles.map(f => ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`).join('\n'); - logForDebugging(`Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`); + const fileList = memoryFiles + .map( + f => + ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`, + ) + .join('\n') + logForDebugging( + `Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`, + ) } else { - logForDebugging('No CLAUDE.md/rules files found'); + logForDebugging('No CLAUDE.md/rules files found') } for (const file of memoryFiles) { // When the injected content doesn't match disk (stripped HTML comments, @@ -3815,33 +5084,40 @@ export function REPL({ // with isPartialView so Edit/Write require a real Read first while // getChangedFiles + nested_memory dedup still work. readFileState.current.set(file.path, { - content: file.contentDiffersFromDisk ? file.rawContent ?? file.content : file.content, + content: file.contentDiffersFromDisk + ? (file.rawContent ?? file.content) + : file.content, timestamp: Date.now(), offset: undefined, limit: undefined, - isPartialView: file.contentDiffersFromDisk - }); + isPartialView: file.contentDiffersFromDisk, + }) } // Initial message handling is done via the initialMessage effect } // Register cost summary tracker - useCostSummary(useFpsMetrics()); + useCostSummary(useFpsMetrics()) // Record transcripts locally, for debugging and conversation recovery // Don't record conversation if we only have initial messages; optimizes // the case where user resumes a conversation then quites before doing // anything else - useLogMessages(messages, messages.length === initialMessages?.length); + useLogMessages(messages, messages.length === initialMessages?.length) // REPL Bridge: replicate user/assistant messages to the bridge session // for remote access via claude.ai. No-op in external builds or when not enabled. - const { - sendBridgeResult - } = useReplBridge(messages, setMessages, abortControllerRef, commands, mainLoopModel); - sendBridgeResultRef.current = sendBridgeResult; - useAfterFirstRender(); + const { sendBridgeResult } = useReplBridge( + messages, + setMessages, + abortControllerRef, + commands, + mainLoopModel, + ) + sendBridgeResultRef.current = sendBridgeResult + + useAfterFirstRender() // Track prompt queue usage for analytics. Fire once per transition from // empty to non-empty, not on every length change -- otherwise a render loop @@ -3849,229 +5125,303 @@ export function REPL({ // ELOCKED under concurrent sessions and falls back to unlocked writes. // That write storm is the primary trigger for ~/.claude.json corruption // (GH #3117). - const hasCountedQueueUseRef = useRef(false); + const hasCountedQueueUseRef = useRef(false) useEffect(() => { if (queuedCommands.length < 1) { - hasCountedQueueUseRef.current = false; - return; + hasCountedQueueUseRef.current = false + return } - if (hasCountedQueueUseRef.current) return; - hasCountedQueueUseRef.current = true; + if (hasCountedQueueUseRef.current) return + hasCountedQueueUseRef.current = true saveGlobalConfig(current => ({ ...current, - promptQueueUseCount: (current.promptQueueUseCount ?? 0) + 1 - })); - }, [queuedCommands.length]); + promptQueueUseCount: (current.promptQueueUseCount ?? 0) + 1, + })) + }, [queuedCommands.length]) // Process queued commands when query completes and queue has items - const executeQueuedInput = useCallback(async (queuedCommands: QueuedCommand[]) => { - await handlePromptSubmit({ - helpers: { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} - }, + const executeQueuedInput = useCallback( + async (queuedCommands: QueuedCommand[]) => { + await handlePromptSubmit({ + helpers: { + setCursorOffset: () => {}, + clearBuffer: () => {}, + resetHistory: () => {}, + }, + queryGuard, + commands, + onInputChange: () => {}, + setPastedContents: () => {}, + setToolJSX, + getToolUseContext, + messages, + mainLoopModel, + ideSelection, + setUserInputOnProcessing, + setAbortController, + onQuery, + setAppState, + querySource: getQuerySourceForREPL(), + onBeforeQuery, + canUseTool, + addNotification, + setMessages, + queuedCommands, + }) + }, + [ queryGuard, commands, - onInputChange: () => {}, - setPastedContents: () => {}, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, + canUseTool, setAbortController, onQuery, + addNotification, setAppState, - querySource: getQuerySourceForREPL(), onBeforeQuery, - canUseTool, - addNotification, - setMessages, - queuedCommands - }); - }, [queryGuard, commands, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, canUseTool, setAbortController, onQuery, addNotification, setAppState, onBeforeQuery]); + ], + ) + useQueueProcessor({ executeQueuedInput, hasActiveLocalJsxUI: isShowingLocalJSXCommand, - queryGuard - }); + queryGuard, + }) // We'll use the global lastInteractionTime from state.ts // Update last interaction time when input changes. // Must be immediate because useEffect runs after the Ink render cycle flush. useEffect(() => { - activityManager.recordUserActivity(); - updateLastInteractionTime(true); - }, [inputValue, submitCount]); + activityManager.recordUserActivity() + updateLastInteractionTime(true) + }, [inputValue, submitCount]) + useEffect(() => { if (submitCount === 1) { - startBackgroundHousekeeping(); + startBackgroundHousekeeping() } - }, [submitCount]); + }, [submitCount]) // Show notification when Claude is done responding and user is idle useEffect(() => { // Don't set up notification if Claude is busy - if (isLoading) return; + if (isLoading) return // Only enable notifications after the first new interaction in this session - if (submitCount === 0) return; + if (submitCount === 0) return // No query has completed yet - if (lastQueryCompletionTime === 0) return; + if (lastQueryCompletionTime === 0) return // Set timeout to check idle state - const timer = setTimeout((lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal) => { - // Check if user has interacted since the response ended - const lastUserInteraction = getLastInteractionTime(); - if (lastUserInteraction > lastQueryCompletionTime) { - // User has interacted since Claude finished - they're not idle, don't notify - return; - } + const timer = setTimeout( + ( + lastQueryCompletionTime, + isLoading, + toolJSX, + focusedInputDialogRef, + terminal, + ) => { + // Check if user has interacted since the response ended + const lastUserInteraction = getLastInteractionTime() + + if (lastUserInteraction > lastQueryCompletionTime) { + // User has interacted since Claude finished - they're not idle, don't notify + return + } - // User hasn't interacted since response ended, check other conditions - const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime; - if (!isLoading && !toolJSX && - // Use ref to get current dialog state, avoiding stale closure - focusedInputDialogRef.current === undefined && idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs) { - void sendNotification({ - message: 'Claude is waiting for your input', - notificationType: 'idle_prompt' - }, terminal); - } - }, getGlobalConfig().messageIdleNotifThresholdMs, lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal); - return () => clearTimeout(timer); - }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal]); + // User hasn't interacted since response ended, check other conditions + const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime + if ( + !isLoading && + !toolJSX && + // Use ref to get current dialog state, avoiding stale closure + focusedInputDialogRef.current === undefined && + idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs + ) { + void sendNotification( + { + message: 'Claude is waiting for your input', + notificationType: 'idle_prompt', + }, + terminal, + ) + } + }, + getGlobalConfig().messageIdleNotifThresholdMs, + lastQueryCompletionTime, + isLoading, + toolJSX, + focusedInputDialogRef, + terminal, + ) + + return () => clearTimeout(timer) + }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal]) // Idle-return hint: show notification when idle threshold is exceeded. // Timer fires after the configured idle period; notification persists until // dismissed or the user submits. useEffect(() => { - if (lastQueryCompletionTime === 0) return; - if (isLoading) return; - const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off'); - if (willowMode !== 'hint' && willowMode !== 'hint_v2') return; - if (getGlobalConfig().idleReturnDismissed) return; - const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000); - if (getTotalInputTokens() < tokenThreshold) return; - const idleThresholdMs = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000; - const elapsed = Date.now() - lastQueryCompletionTime; - const remaining = idleThresholdMs - elapsed; - const timer = setTimeout((lqct, addNotif, msgsRef, mode, hintRef) => { - if (msgsRef.current.length === 0) return; - const totalTokens = getTotalInputTokens(); - const formattedTokens = formatTokens(totalTokens); - const idleMinutes = (Date.now() - lqct) / 60_000; - addNotif({ - key: 'idle-return-hint', - jsx: mode === 'hint_v2' ? <> + if (lastQueryCompletionTime === 0) return + if (isLoading) return + const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_willow_mode', + 'off', + ) + if (willowMode !== 'hint' && willowMode !== 'hint_v2') return + if (getGlobalConfig().idleReturnDismissed) return + + const tokenThreshold = Number( + process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000, + ) + if (getTotalInputTokens() < tokenThreshold) return + + const idleThresholdMs = + Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000 + const elapsed = Date.now() - lastQueryCompletionTime + const remaining = idleThresholdMs - elapsed + + const timer = setTimeout( + (lqct, addNotif, msgsRef, mode, hintRef) => { + if (msgsRef.current.length === 0) return + const totalTokens = getTotalInputTokens() + const formattedTokens = formatTokens(totalTokens) + const idleMinutes = (Date.now() - lqct) / 60_000 + addNotif({ + key: 'idle-return-hint', + jsx: + mode === 'hint_v2' ? ( + <> new task? /clear to save {formattedTokens} tokens - : + + ) : ( + new task? /clear to save {formattedTokens} tokens - , - priority: 'medium', - // Persist until submit — the hint fires at T+75min idle, user may - // not return for hours. removeNotification in useEffect cleanup - // handles dismissal. 0x7FFFFFFF = setTimeout max (~24.8 days). - timeoutMs: 0x7fffffff - }); - hintRef.current = mode; - logEvent('tengu_idle_return_action', { - action: 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - variant: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - idleMinutes: Math.round(idleMinutes), - messageCount: msgsRef.current.length, - totalInputTokens: totalTokens - }); - }, Math.max(0, remaining), lastQueryCompletionTime, addNotification, messagesRef, willowMode, idleHintShownRef); + + ), + priority: 'medium', + // Persist until submit — the hint fires at T+75min idle, user may + // not return for hours. removeNotification in useEffect cleanup + // handles dismissal. 0x7FFFFFFF = setTimeout max (~24.8 days). + timeoutMs: 0x7fffffff, + }) + hintRef.current = mode + logEvent('tengu_idle_return_action', { + action: + 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + variant: + mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + idleMinutes: Math.round(idleMinutes), + messageCount: msgsRef.current.length, + totalInputTokens: totalTokens, + }) + }, + Math.max(0, remaining), + lastQueryCompletionTime, + addNotification, + messagesRef, + willowMode, + idleHintShownRef, + ) + return () => { - clearTimeout(timer); - removeNotification('idle-return-hint'); - idleHintShownRef.current = false; - }; - }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification]); + clearTimeout(timer) + removeNotification('idle-return-hint') + idleHintShownRef.current = false + } + }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification]) // Submits incoming prompts from teammate messages or tasks mode as new turns // Returns true if submission succeeded, false if a query is already running - const handleIncomingPrompt = useCallback((content: string, options?: { - isMeta?: boolean; - }): boolean => { - if (queryGuard.isActive) return false; - - // Defer to user-queued commands — user input always takes priority - // over system messages (teammate messages, task list items, etc.) - // Read from the module-level store at call time (not the render-time - // snapshot) to avoid a stale closure — this callback's deps don't - // include the queue. - if (getCommandQueue().some(cmd => cmd.mode === 'prompt' || cmd.mode === 'bash')) { - return false; - } - const newAbortController = createAbortController(); - setAbortController(newAbortController); - - // Create a user message with the formatted content (includes XML wrapper) - const userMessage = createUserMessage({ - content, - isMeta: options?.isMeta ? true : undefined - }); - void onQuery([userMessage], newAbortController, true, [], mainLoopModel); - return true; - }, [onQuery, mainLoopModel, store]); + const handleIncomingPrompt = useCallback( + (content: string, options?: { isMeta?: boolean }): boolean => { + if (queryGuard.isActive) return false + + // Defer to user-queued commands — user input always takes priority + // over system messages (teammate messages, task list items, etc.) + // Read from the module-level store at call time (not the render-time + // snapshot) to avoid a stale closure — this callback's deps don't + // include the queue. + if ( + getCommandQueue().some( + cmd => cmd.mode === 'prompt' || cmd.mode === 'bash', + ) + ) { + return false + } + + const newAbortController = createAbortController() + setAbortController(newAbortController) + + // Create a user message with the formatted content (includes XML wrapper) + const userMessage = createUserMessage({ + content, + isMeta: options?.isMeta ? true : undefined, + }) + + void onQuery([userMessage], newAbortController, true, [], mainLoopModel) + return true + }, + [onQuery, mainLoopModel, store], + ) // Voice input integration (VOICE_MODE builds only) - const voice = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceIntegration({ - setInputValueRaw, - inputValueRef, - insertTextRef - }) : { - stripTrailing: () => 0, - handleKeyEvent: () => {}, - resetAnchor: () => {}, - interimRange: null - }; + const voice = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef }) + : { + stripTrailing: () => 0, + handleKeyEvent: () => {}, + resetAnchor: () => {}, + interimRange: null, + } + useInboxPoller({ enabled: isAgentSwarmsEnabled(), isLoading, focusedInputDialog, - onSubmitMessage: handleIncomingPrompt - }); - useMailboxBridge({ - isLoading, - onSubmitMessage: handleIncomingPrompt - }); + onSubmitMessage: handleIncomingPrompt, + }) + + useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt }) // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) - { - const assistantMode = store.getState().kairosEnabled; - useScheduledTasks({ - isLoading, - assistantMode, - setMessages - }); + if (feature('AGENT_TRIGGERS')) { + // Assistant mode bypasses the isLoading gate (the proactive tick → + // Sleep → tick loop would otherwise starve the scheduler). + // kairosEnabled is set once in initialState (main.tsx) and never mutated — no + // subscription needed. The tengu_kairos_cron runtime gate is checked inside + // useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic + // condition would break rules-of-hooks. + const assistantMode = store.getState().kairosEnabled + // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useScheduledTasks!({ isLoading, assistantMode, setMessages }) } // Note: Permission polling is now handled by useInboxPoller // - Workers receive permission responses via mailbox messages // - Leaders receive permission requests via mailbox messages - if ((process.env.USER_TYPE) === 'ant') { + if (process.env.USER_TYPE === 'ant') { // Tasks mode: watch for tasks and auto-process them // eslint-disable-next-line react-hooks/rules-of-hooks // biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds useTaskListWatcher({ taskListId, isLoading, - onSubmitTask: handleIncomingPrompt - }); + onSubmitTask: handleIncomingPrompt, + }) // Loop mode: auto-tick when enabled (via /job command) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -4084,281 +5434,337 @@ export function REPL({ queuedCommandsLength: queuedCommands.length, hasActiveLocalJsxUI: isShowingLocalJSXCommand, isInPlanMode: toolPermissionContext.mode === 'plan', - onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { - isMeta: true - }), - onQueueTick: (prompt: string) => enqueue({ - mode: 'prompt', - value: prompt, - isMeta: true - }) - }); + onSubmitTick: (prompt: string) => + handleIncomingPrompt(prompt, { isMeta: true }), + onQueueTick: (prompt: string) => + enqueue({ mode: 'prompt', value: prompt, isMeta: true }), + }) } // Abort the current operation when a 'now' priority message arrives // (e.g. from a chat UI client via UDS). useEffect(() => { if (queuedCommands.some(cmd => cmd.priority === 'now')) { - abortControllerRef.current?.abort('interrupt'); + abortControllerRef.current?.abort('interrupt') } - }, [queuedCommands]); + }, [queuedCommands]) // Initial load useEffect(() => { - void onInit(); + void onInit() // Cleanup on unmount return () => { - void diagnosticTracker.shutdown(); - }; + void diagnosticTracker.shutdown() + } // TODO: fix this // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []) // Listen for suspend/resume events - const { - internal_eventEmitter - } = useStdin(); - const [remountKey, setRemountKey] = useState(0); + const { internal_eventEmitter } = useStdin() + const [remountKey, setRemountKey] = useState(0) useEffect(() => { const handleSuspend = () => { // Print suspension instructions - process.stdout.write(`\nClaude Code has been suspended. Run \`fg\` to bring Claude Code back.\nNote: ctrl + z now suspends Claude Code, ctrl + _ undoes input.\n`); - }; + process.stdout.write( + `\nClaude Code has been suspended. Run \`fg\` to bring Claude Code back.\nNote: ctrl + z now suspends Claude Code, ctrl + _ undoes input.\n`, + ) + } + const handleResume = () => { // Force complete component tree replacement instead of terminal clear // Ink now handles line count reset internally on SIGCONT - setRemountKey(prev => prev + 1); - }; - internal_eventEmitter?.on('suspend', handleSuspend); - internal_eventEmitter?.on('resume', handleResume); + setRemountKey(prev => prev + 1) + } + + internal_eventEmitter?.on('suspend', handleSuspend) + internal_eventEmitter?.on('resume', handleResume) return () => { - internal_eventEmitter?.off('suspend', handleSuspend); - internal_eventEmitter?.off('resume', handleResume); - }; - }, [internal_eventEmitter]); + internal_eventEmitter?.off('suspend', handleSuspend) + internal_eventEmitter?.off('resume', handleResume) + } + }, [internal_eventEmitter]) // Derive stop hook spinner suffix from messages state const stopHookSpinnerSuffix = useMemo(() => { - if (!isLoading) return null; + if (!isLoading) return null // Find stop hook progress messages - const progressMsgs = messages.filter((m): m is ProgressMessage => m.type === 'progress' && (m.data as HookProgress).type === 'hook_progress' && ((m.data as HookProgress).hookEvent === 'Stop' || (m.data as HookProgress).hookEvent === 'SubagentStop')); - if (progressMsgs.length === 0) return null; + const progressMsgs = messages.filter( + (m): m is ProgressMessage => + m.type === 'progress' && + m.data.type === 'hook_progress' && + (m.data.hookEvent === 'Stop' || m.data.hookEvent === 'SubagentStop'), + ) + if (progressMsgs.length === 0) return null // Get the most recent stop hook execution - const currentToolUseID = progressMsgs.at(-1)?.toolUseID; - if (!currentToolUseID) return null; + const currentToolUseID = progressMsgs.at(-1)?.toolUseID + if (!currentToolUseID) return null // Check if there's already a summary message for this execution (hooks completed) - const hasSummaryForCurrentExecution = messages.some(m => m.type === 'system' && m.subtype === 'stop_hook_summary' && m.toolUseID === currentToolUseID); - if (hasSummaryForCurrentExecution) return null; - const currentHooks = progressMsgs.filter(p => p.toolUseID === currentToolUseID); - const total = currentHooks.length; + const hasSummaryForCurrentExecution = messages.some( + m => + m.type === 'system' && + m.subtype === 'stop_hook_summary' && + m.toolUseID === currentToolUseID, + ) + if (hasSummaryForCurrentExecution) return null + + const currentHooks = progressMsgs.filter( + p => p.toolUseID === currentToolUseID, + ) + const total = currentHooks.length // Count completed hooks const completedCount = count(messages, m => { - if (m.type !== 'attachment') return false; - const attachment = m.attachment; - return 'hookEvent' in attachment && (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') && 'toolUseID' in attachment && attachment.toolUseID === currentToolUseID; - }); + if (m.type !== 'attachment') return false + const attachment = m.attachment + return ( + 'hookEvent' in attachment && + (attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop') && + 'toolUseID' in attachment && + attachment.toolUseID === currentToolUseID + ) + }) // Check if any hook has a custom status message - const customMessage = currentHooks.find(p => p.data.statusMessage)?.data.statusMessage; + const customMessage = currentHooks.find(p => p.data.statusMessage)?.data + .statusMessage + if (customMessage) { // Use custom message with progress counter if multiple hooks - return total === 1 ? `${customMessage}…` : `${customMessage}… ${completedCount}/${total}`; + return total === 1 + ? `${customMessage}…` + : `${customMessage}… ${completedCount}/${total}` } // Fall back to default behavior - const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop'; - if ((process.env.USER_TYPE) === 'ant') { - const cmd = currentHooks[completedCount]?.data.command; - const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : ''; - return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`; + const hookType = + currentHooks[0]?.data.hookEvent === 'SubagentStop' + ? 'subagent stop' + : 'stop' + + if (process.env.USER_TYPE === 'ant') { + const cmd = currentHooks[completedCount]?.data.command + const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : '' + return total === 1 + ? `running ${hookType} hook${label}` + : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}` } - return total === 1 ? `running ${hookType} hook` : `running stop hooks… ${completedCount}/${total}`; - }, [messages, isLoading]); + + return total === 1 + ? `running ${hookType} hook` + : `running stop hooks… ${completedCount}/${total}` + }, [messages, isLoading]) // Callback to capture frozen state when entering transcript mode const handleEnterTranscript = useCallback(() => { setFrozenTranscriptState({ messagesLength: messages.length, - streamingToolUsesLength: streamingToolUses.length - }); - }, [messages.length, streamingToolUses.length]); + streamingToolUsesLength: streamingToolUses.length, + }) + }, [messages.length, streamingToolUses.length]) // Callback to clear frozen state when exiting transcript mode const handleExitTranscript = useCallback(() => { - setFrozenTranscriptState(null); - }, []); + setFrozenTranscriptState(null) + }, []) // Props for GlobalKeybindingHandlers component (rendered inside KeybindingSetup) - const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll; + const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll // Transcript search state. Hooks must be unconditional so they live here // (not inside the `if (screen === 'transcript')` branch below); isActive // gates the useInput. Query persists across bar open/close so n/N keep // working after Enter dismisses the bar (less semantics). - const jumpRef = useRef(null); - const [searchOpen, setSearchOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [searchCount, setSearchCount] = useState(0); - const [searchCurrent, setSearchCurrent] = useState(0); - const onSearchMatchesChange = useCallback((count: number, current: number) => { - setSearchCount(count); - setSearchCurrent(current); - }, []); - useInput((input, key, event) => { - if (key.ctrl || key.meta) return; - // No Esc handling here — less has no navigating mode. Search state - // (highlights, n/N) is just state. Esc/q/ctrl+c → transcript:exit - // (ungated). Highlights clear on exit via the screen-change effect. - if (input === '/') { - // Capture scrollTop NOW — typing is a preview, 0-matches snaps - // back here. Synchronous ref write, fires before the bar's - // mount-effect calls setSearchQuery. - jumpRef.current?.setAnchor(); - setSearchOpen(true); - event.stopImmediatePropagation(); - return; - } - // Held-key batching: tokenizer coalesces to 'nnn'. Same uniform-batch - // pattern as modalPagerAction in ScrollKeybindingHandler.tsx. Each - // repeat is a step (n isn't idempotent like g). - const c = input[0]; - if ((c === 'n' || c === 'N') && input === c.repeat(input.length) && searchCount > 0) { - const fn = c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch; - if (fn) for (let i = 0; i < input.length; i++) fn(); - event.stopImmediatePropagation(); - } - }, - // Search needs virtual scroll (jumpRef drives VirtualMessageList). [ - // kills it, so !dumpMode — after [ there's nothing to jump in. - { - isActive: screen === 'transcript' && virtualScrollActive && !searchOpen && !dumpMode - }); + const jumpRef = useRef(null) + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [searchCount, setSearchCount] = useState(0) + const [searchCurrent, setSearchCurrent] = useState(0) + const onSearchMatchesChange = useCallback( + (count: number, current: number) => { + setSearchCount(count) + setSearchCurrent(current) + }, + [], + ) + + useInput( + (input, key, event) => { + if (key.ctrl || key.meta) return + // No Esc handling here — less has no navigating mode. Search state + // (highlights, n/N) is just state. Esc/q/ctrl+c → transcript:exit + // (ungated). Highlights clear on exit via the screen-change effect. + if (input === '/') { + // Capture scrollTop NOW — typing is a preview, 0-matches snaps + // back here. Synchronous ref write, fires before the bar's + // mount-effect calls setSearchQuery. + jumpRef.current?.setAnchor() + setSearchOpen(true) + event.stopImmediatePropagation() + return + } + // Held-key batching: tokenizer coalesces to 'nnn'. Same uniform-batch + // pattern as modalPagerAction in ScrollKeybindingHandler.tsx. Each + // repeat is a step (n isn't idempotent like g). + const c = input[0] + if ( + (c === 'n' || c === 'N') && + input === c.repeat(input.length) && + searchCount > 0 + ) { + const fn = + c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch + if (fn) for (let i = 0; i < input.length; i++) fn() + event.stopImmediatePropagation() + } + }, + // Search needs virtual scroll (jumpRef drives VirtualMessageList). [ + // kills it, so !dumpMode — after [ there's nothing to jump in. + { + isActive: + screen === 'transcript' && + virtualScrollActive && + !searchOpen && + !dumpMode, + }, + ) const { setQuery: setHighlight, scanElement, - setPositions - } = useSearchHighlight(); + setPositions, + } = useSearchHighlight() // Resize → abort search. Positions are (msg, query, WIDTH)-keyed — // cached positions are stale after a width change (new layout, new // wrapping). Clearing searchQuery triggers VML's setSearchQuery('') // which clears positionsCache + setPositions(null). Bar closes. // User hits / again → fresh everything. - const transcriptCols = useTerminalSize().columns; - const prevColsRef = React.useRef(transcriptCols); + const transcriptCols = useTerminalSize().columns + const prevColsRef = React.useRef(transcriptCols) React.useEffect(() => { if (prevColsRef.current !== transcriptCols) { - prevColsRef.current = transcriptCols; + prevColsRef.current = transcriptCols if (searchQuery || searchOpen) { - setSearchOpen(false); - setSearchQuery(''); - setSearchCount(0); - setSearchCurrent(0); - jumpRef.current?.disarmSearch(); - setHighlight(''); + setSearchOpen(false) + setSearchQuery('') + setSearchCount(0) + setSearchCurrent(0) + jumpRef.current?.disarmSearch() + setHighlight('') } } - }, [transcriptCols, searchQuery, searchOpen, setHighlight]); + }, [transcriptCols, searchQuery, searchOpen, setHighlight]) // Transcript escape hatches. Bare letters in modal context (no prompt // competing for input) — same class as g/G/j/k in ScrollKeybindingHandler. - useInput((input, key, event) => { - if (key.ctrl || key.meta) return; - if (input === 'q') { - // less: q quits the pager. ctrl+o toggles; q is the lineage exit. - handleExitTranscript(); - event.stopImmediatePropagation(); - return; - } - if (input === '[' && !dumpMode) { - // Force dump-to-scrollback. Also expand + uncap — no point dumping - // a subset. Terminal/tmux cmd-F can now find anything. Guard here - // (not in isActive) so v still works post-[ — dump-mode footer at - // ~4898 wires editorStatus, confirming v is meant to stay live. - setDumpMode(true); - setShowAllInTranscript(true); - event.stopImmediatePropagation(); - } else if (input === 'v') { - // less-style: v opens the file in $VISUAL/$EDITOR. Render the full - // transcript (same path /export uses), write to tmp, hand off. - // openFileInExternalEditor handles alt-screen suspend/resume for - // terminal editors; GUI editors spawn detached. - event.stopImmediatePropagation(); - // Drop double-taps: the render is async and a second press before it - // completes would run a second parallel render (double memory, two - // tempfiles, two editor spawns). editorGenRef only guards - // transcript-exit staleness, not same-session concurrency. - if (editorRenderingRef.current) return; - editorRenderingRef.current = true; - // Capture generation + make a staleness-aware setter. Each write - // checks gen (transcript exit bumps it → late writes from the - // async render go silent). - const gen = editorGenRef.current; - const setStatus = (s: string): void => { - if (gen !== editorGenRef.current) return; - clearTimeout(editorTimerRef.current); - setEditorStatus(s); - }; - setStatus(`rendering ${deferredMessages.length} messages…`); - void (async () => { - try { - // Width = terminal minus vim's line-number gutter (4 digits + - // space + slack). Floor at 80. PassThrough has no .columns so - // without this Ink defaults to 80. Trailing-space strip: right- - // aligned timestamps still leave a flexbox spacer run at EOL. - // eslint-disable-next-line custom-rules/prefer-use-terminal-size -- one-shot at keypress time, not a reactive render dep - const w = Math.max(80, (process.stdout.columns ?? 80) - 6); - const raw = await renderMessagesToPlainText(deferredMessages, tools, w); - const text = raw.replace(/[ \t]+$/gm, ''); - const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`); - await writeFile(path, text); - const opened = openFileInExternalEditor(path); - setStatus(opened ? `opening ${path}` : `wrote ${path} · no $VISUAL/$EDITOR set`); - } catch (e) { - setStatus(`render failed: ${e instanceof Error ? e.message : String(e)}`); + useInput( + (input, key, event) => { + if (key.ctrl || key.meta) return + if (input === 'q') { + // less: q quits the pager. ctrl+o toggles; q is the lineage exit. + handleExitTranscript() + event.stopImmediatePropagation() + return + } + if (input === '[' && !dumpMode) { + // Force dump-to-scrollback. Also expand + uncap — no point dumping + // a subset. Terminal/tmux cmd-F can now find anything. Guard here + // (not in isActive) so v still works post-[ — dump-mode footer at + // ~4898 wires editorStatus, confirming v is meant to stay live. + setDumpMode(true) + setShowAllInTranscript(true) + event.stopImmediatePropagation() + } else if (input === 'v') { + // less-style: v opens the file in $VISUAL/$EDITOR. Render the full + // transcript (same path /export uses), write to tmp, hand off. + // openFileInExternalEditor handles alt-screen suspend/resume for + // terminal editors; GUI editors spawn detached. + event.stopImmediatePropagation() + // Drop double-taps: the render is async and a second press before it + // completes would run a second parallel render (double memory, two + // tempfiles, two editor spawns). editorGenRef only guards + // transcript-exit staleness, not same-session concurrency. + if (editorRenderingRef.current) return + editorRenderingRef.current = true + // Capture generation + make a staleness-aware setter. Each write + // checks gen (transcript exit bumps it → late writes from the + // async render go silent). + const gen = editorGenRef.current + const setStatus = (s: string): void => { + if (gen !== editorGenRef.current) return + clearTimeout(editorTimerRef.current) + setEditorStatus(s) } - editorRenderingRef.current = false; - if (gen !== editorGenRef.current) return; - editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus); - })(); - } - }, - // !searchOpen: typing 'v' or '[' in the search bar is search input, not - // a command. No !dumpMode here — v should work after [ (the [ handler - // guards itself inline). - { - isActive: screen === 'transcript' && virtualScrollActive && !searchOpen - }); + setStatus(`rendering ${deferredMessages.length} messages…`) + void (async () => { + try { + // Width = terminal minus vim's line-number gutter (4 digits + + // space + slack). Floor at 80. PassThrough has no .columns so + // without this Ink defaults to 80. Trailing-space strip: right- + // aligned timestamps still leave a flexbox spacer run at EOL. + // eslint-disable-next-line custom-rules/prefer-use-terminal-size -- one-shot at keypress time, not a reactive render dep + const w = Math.max(80, (process.stdout.columns ?? 80) - 6) + const raw = await renderMessagesToPlainText( + deferredMessages, + tools, + w, + ) + const text = raw.replace(/[ \t]+$/gm, '') + const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`) + await writeFile(path, text) + const opened = openFileInExternalEditor(path) + setStatus( + opened + ? `opening ${path}` + : `wrote ${path} · no $VISUAL/$EDITOR set`, + ) + } catch (e) { + setStatus( + `render failed: ${e instanceof Error ? e.message : String(e)}`, + ) + } + editorRenderingRef.current = false + if (gen !== editorGenRef.current) return + editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus) + })() + } + }, + // !searchOpen: typing 'v' or '[' in the search bar is search input, not + // a command. No !dumpMode here — v should work after [ (the [ handler + // guards itself inline). + { isActive: screen === 'transcript' && virtualScrollActive && !searchOpen }, + ) // Fresh `less` per transcript entry. Prevents stale highlights matching // unrelated normal-mode text (overlay is alt-screen-global) and avoids // surprise n/N on re-entry. Same exit resets [ dump mode — each ctrl+o // entry is a fresh instance. - const inTranscript = screen === 'transcript' && virtualScrollActive; + const inTranscript = screen === 'transcript' && virtualScrollActive useEffect(() => { if (!inTranscript) { - setSearchQuery(''); - setSearchCount(0); - setSearchCurrent(0); - setSearchOpen(false); - editorGenRef.current++; - clearTimeout(editorTimerRef.current); - setDumpMode(false); - setEditorStatus(''); + setSearchQuery('') + setSearchCount(0) + setSearchCurrent(0) + setSearchOpen(false) + editorGenRef.current++ + clearTimeout(editorTimerRef.current) + setDumpMode(false) + setEditorStatus('') } - }, [inTranscript]); + }, [inTranscript]) useEffect(() => { - setHighlight(inTranscript ? searchQuery : ''); + setHighlight(inTranscript ? searchQuery : '') // Clear the position-based CURRENT (yellow) overlay too. setHighlight // only clears the scan-based inverse. Without this, the yellow box // persists at its last screen coords after ctrl-c exits transcript. - if (!inTranscript) setPositions(null); - }, [inTranscript, searchQuery, setHighlight, setPositions]); + if (!inTranscript) setPositions(null) + }, [inTranscript, searchQuery, setHighlight, setPositions]) + const globalKeybindingProps = { screen, setScreen, @@ -4374,21 +5780,28 @@ export function REPL({ // doesn't stopPropagation, so without this gate transcript:exit // would fire on the same Esc that cancels the bar (child registers // first, fires first, bubbles). - searchBarOpen: searchOpen - }; + searchBarOpen: searchOpen, + } // Use frozen lengths to slice arrays, avoiding memory overhead of cloning - const transcriptMessages = frozenTranscriptState ? deferredMessages.slice(0, frozenTranscriptState.messagesLength) : deferredMessages; - const transcriptStreamingToolUses = frozenTranscriptState ? streamingToolUses.slice(0, frozenTranscriptState.streamingToolUsesLength) : streamingToolUses; + const transcriptMessages = frozenTranscriptState + ? deferredMessages.slice(0, frozenTranscriptState.messagesLength) + : deferredMessages + const transcriptStreamingToolUses = frozenTranscriptState + ? streamingToolUses.slice(0, frozenTranscriptState.streamingToolUsesLength) + : streamingToolUses // Handle shift+down for teammate navigation and background task management. // Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open — // otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input. useBackgroundTaskNavigation({ - onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true) - }); + onOpenBackgroundTasks: isShowingLocalJSXCommand + ? undefined + : () => setShowBashesDialog(true), + }) // Auto-exit viewing mode when teammate completes or errors - useTeammateViewAutoExit(); + useTeammateViewAutoExit() + if (screen === 'transcript') { // Virtual scroll replaces the 30-message cap: everything is scrollable // and memory is bounded by the viewport. Without it, wrapping transcript @@ -4398,81 +5811,166 @@ export function REPL({ // scrollback, 30-cap + Ctrl+E. Reusing scrollRef is safe — normal-mode // and transcript-mode are mutually exclusive (this early return), so // only one ScrollBox is ever mounted at a time. - const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined; - const transcriptMessagesElement = ; - const transcriptToolJSX = toolJSX && + const transcriptScrollRef = + isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode + ? scrollRef + : undefined + const transcriptMessagesElement = ( + + ) + const transcriptToolJSX = toolJSX && ( + {toolJSX.jsx} - ; - const transcriptReturn = - + + ) + const transcriptReturn = ( + + - {feature('VOICE_MODE') ? : null} - - {transcriptScrollRef ? - // ScrollKeybindingHandler must mount before CancelRequestHandler so - // ctrl+c-with-selection copies instead of cancelling the active task. - // Its raw useInput handler only stops propagation when a selection - // exists — without one, ctrl+c falls through to CancelRequestHandler. - jumpRef.current?.disarmSearch()} /> : null} + {feature('VOICE_MODE') ? ( + + ) : null} + + {transcriptScrollRef ? ( + // ScrollKeybindingHandler must mount before CancelRequestHandler so + // ctrl+c-with-selection copies instead of cancelling the active task. + // Its raw useInput handler only stops propagation when a selection + // exists — without one, ctrl+c falls through to CancelRequestHandler. + jumpRef.current?.disarmSearch()} + /> + ) : null} - {transcriptScrollRef ? + {transcriptScrollRef ? ( + {transcriptMessagesElement} {transcriptToolJSX} - } bottom={searchOpen ? { - // Enter — commit. 0-match guard: junk query shouldn't - // persist (badge hidden, n/N dead anyway). - setSearchQuery(searchCount > 0 ? q : ''); - setSearchOpen(false); - // onCancel path: bar unmounts before its useEffect([query]) - // can fire with ''. Without this, searchCount stays stale - // (n guard at :4956 passes) and VML's matches[] too - // (nextMatch walks the old array). Phantom nav, no - // highlight. onExit (Enter, q non-empty) still commits. - if (!q) { - setSearchCount(0); - setSearchCurrent(0); - jumpRef.current?.setSearchQuery(''); - } - }} onCancel={() => { - // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired - // with whatever was typed. searchQuery (REPL state) - // is unchanged since / (onClose = commit, didn't run). - // Two VML calls: '' restores anchor (0-match else- - // branch), then searchQuery re-scans from anchor's - // nearest. Both synchronous — one React batch. - // setHighlight explicit: REPL's sync-effect dep is - // searchQuery (unchanged), wouldn't re-fire. - setSearchOpen(false); - jumpRef.current?.setSearchQuery(''); - jumpRef.current?.setSearchQuery(searchQuery); - setHighlight(searchQuery); - }} setHighlight={setHighlight} /> : 0 ? { - current: searchCurrent, - count: searchCount - } : undefined} />} /> : <> + + } + bottom={ + searchOpen ? ( + { + // Enter — commit. 0-match guard: junk query shouldn't + // persist (badge hidden, n/N dead anyway). + setSearchQuery(searchCount > 0 ? q : '') + setSearchOpen(false) + // onCancel path: bar unmounts before its useEffect([query]) + // can fire with ''. Without this, searchCount stays stale + // (n guard at :4956 passes) and VML's matches[] too + // (nextMatch walks the old array). Phantom nav, no + // highlight. onExit (Enter, q non-empty) still commits. + if (!q) { + setSearchCount(0) + setSearchCurrent(0) + jumpRef.current?.setSearchQuery('') + } + }} + onCancel={() => { + // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired + // with whatever was typed. searchQuery (REPL state) + // is unchanged since / (onClose = commit, didn't run). + // Two VML calls: '' restores anchor (0-match else- + // branch), then searchQuery re-scans from anchor's + // nearest. Both synchronous — one React batch. + // setHighlight explicit: REPL's sync-effect dep is + // searchQuery (unchanged), wouldn't re-fire. + setSearchOpen(false) + jumpRef.current?.setSearchQuery('') + jumpRef.current?.setSearchQuery(searchQuery) + setHighlight(searchQuery) + }} + setHighlight={setHighlight} + /> + ) : ( + 0 + ? { current: searchCurrent, count: searchCount } + : undefined + } + /> + ) + } + /> + ) : ( + <> {transcriptMessagesElement} {transcriptToolJSX} - - } - ; + + + )} + + ) // The virtual-scroll branch (FullscreenLayout above) needs // 's constraint — without it, // ScrollBox's flexGrow has no ceiling, viewport = content height, @@ -4482,20 +5980,25 @@ export function REPL({ // stays entered across toggle. The 30-cap dump branch stays // unwrapped — it wants native terminal scrollback. if (transcriptScrollRef) { - return + return ( + {transcriptReturn} - ; + + ) } - return transcriptReturn; + return transcriptReturn } // Get viewed agent task (inlined from selectors for explicit data flow). // viewedAgentTask: teammate OR local_agent — drives the boolean checks // below. viewedTeammateTask: teammate-only narrowed, for teammate-specific // field access (inProgressToolUseIDs). - const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; - const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined; - const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined); + const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined + const viewedTeammateTask = + viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined + const viewedAgentTask = + viewedTeammateTask ?? + (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined) // Bypass useDeferredValue when streaming text is showing so Messages renders // the final message in the same frame streaming text clears. Also bypass when @@ -4503,10 +6006,14 @@ export function REPL({ // responsive); after the turn ends, showing messages immediately prevents a // jitter gap where the spinner is gone but the answer hasn't appeared yet. // Only reducedMotion users keep the deferred path during loading. - const usesSyncMessages = showStreamingText || !isLoading; + const usesSyncMessages = showStreamingText || !isLoading // When viewing an agent, never fall through to leader — empty until // bootstrap/stream fills. Closes the see-leader-type-agent footgun. - const displayedMessages = viewedAgentTask ? viewedAgentTask.messages ?? [] : usesSyncMessages ? messages : deferredMessages; + const displayedMessages = viewedAgentTask + ? (viewedAgentTask.messages ?? []) + : usesSyncMessages + ? messages + : deferredMessages // Show the placeholder until the real user message appears in // displayedMessages. userInputOnProcessing stays set for the whole turn // (cleared in resetLoadingState); this length check hides it once @@ -4515,20 +6022,46 @@ export function REPL({ // while deferredMessages lags behind messages. Suppressed when viewing an // agent — displayedMessages is a different array there, and onAgentSubmit // doesn't use the placeholder anyway. - const placeholderText = userInputOnProcessing && !viewedAgentTask && displayedMessages.length <= userInputBaselineRef.current ? userInputOnProcessing : undefined; - const toolPermissionOverlay = focusedInputDialog === 'tool-permission' ? setToolUseConfirmQueue(([_, ...tail]) => tail)} onReject={handleQueuedCommandOnCancel} toolUseConfirm={toolUseConfirmQueue[0]!} toolUseContext={getToolUseContext(messages, messages, abortController ?? createAbortController(), mainLoopModel)} verbose={verbose} workerBadge={toolUseConfirmQueue[0]?.workerBadge} setStickyFooter={isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined} /> : null; + const placeholderText = + userInputOnProcessing && + !viewedAgentTask && + displayedMessages.length <= userInputBaselineRef.current + ? userInputOnProcessing + : undefined + + const toolPermissionOverlay = + focusedInputDialog === 'tool-permission' ? ( + setToolUseConfirmQueue(([_, ...tail]) => tail)} + onReject={handleQueuedCommandOnCancel} + toolUseConfirm={toolUseConfirmQueue[0]!} + toolUseContext={getToolUseContext( + messages, + messages, + abortController ?? createAbortController(), + mainLoopModel, + )} + verbose={verbose} + workerBadge={toolUseConfirmQueue[0]?.workerBadge} + setStickyFooter={ + isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined + } + /> + ) : null // Narrow terminals: companion collapses to a one-liner that REPL stacks // on its own row (above input in fullscreen, below in scrollback) instead // of row-beside. Wide terminals keep the row layout with sprite on the right. - const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE; + const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE // Hide the sprite when PromptInput early-returns BackgroundTasksDialog. // The sprite sits as a row sibling of PromptInput, so the dialog's Pane // divider draws at useTerminalSize() width but only gets terminalWidth - // spriteWidth — divider stops short and dialog text wraps early. Don't // check footerSelection: pill FOCUS (arrow-down to tasks pill) must keep // the sprite visible so arrow-right can navigate to it. - const companionVisible = !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog; + const companionVisible = + !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog // In fullscreen, ALL local-jsx slash commands float in the modal slot — // FullscreenLayout wraps them in an absolute-positioned bottom-anchored @@ -4537,19 +6070,36 @@ export function REPL({ // render paths below. Commands that used to route through bottom // (immediate: /model, /mcp, /btw, ...) and scrollable (non-immediate: // /config, /theme, /diff, ...) both go here now. - const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true; - const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null; + const toolJsxCentered = + isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true + const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null // at the root: everything below is inside its // . Handlers/contexts are zero-height so ScrollBox's // flexGrow in FullscreenLayout resolves against this Box. The transcript // early return above wraps its virtual-scroll branch the same way; only // the 30-cap dump branch stays unwrapped for native terminal scrollback. - const mainReturn = - + const mainReturn = ( + + - {feature('VOICE_MODE') ? : null} - + {feature('VOICE_MODE') ? ( + + ) : null} + {/* ScrollKeybindingHandler must mount before CancelRequestHandler so ctrl+c-with-selection copies instead of cancelling the active task. Its raw useInput handler only stops propagation when a selection @@ -4558,37 +6108,156 @@ export function REPL({ the modal's inner ScrollBox is not keyboard-driven. onScroll stays suppressed while a modal is showing so scroll doesn't stamp divider/pill state. */} - - {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? : null} + + {feature('MESSAGE_ACTIONS') && + isFullscreenEnvEnabled() && + !disableMessageActions ? ( + + ) : null} - - : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { - setCursor(null); - jumpToNew(scrollRef.current); - }} scrollable={<> + + + ) : undefined + } + modal={centeredModal} + modalScrollRef={modalScrollRef} + dividerYRef={dividerYRef} + hidePill={!!viewedAgentTask} + hideSticky={!!viewedTeammateTask} + newMessageCount={unseenDivider?.count ?? 0} + onPillClick={() => { + setCursor(null) + jumpToNew(scrollRef.current) + }} + scrollable={ + <> - + {/* Hide the processing placeholder while a modal is showing — it would sit at the last visible transcript row right above the ▔ divider, showing "❯ /config" as redundant clutter (the modal IS the /config UI). Outside modals it stays so the user sees their input echoed while Claude processes. */} - {!disabled && placeholderText && !centeredModal && } - {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && + {!disabled && placeholderText && !centeredModal && ( + + )} + {toolJSX && + !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && + !toolJsxCentered && ( + {toolJSX.jsx} - } - {(process.env.USER_TYPE) === 'ant' && } - {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} + + )} + {process.env.USER_TYPE === 'ant' && } + {feature('WEB_BROWSER_TOOL') + ? WebBrowserPanelModule && ( + + ) + : null} - {showSpinner && 0} leaderIsIdle={!isLoading} />} - {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && } + {showSpinner && ( + 0} + leaderIsIdle={!isLoading} + /> + )} + {!showSpinner && + !isLoading && + !userInputOnProcessing && + !hasRunningTeammates && + isBriefOnly && + !viewedAgentTask && } {isFullscreenEnvEnabled() && } - } bottom={ - {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? : null} + + } + bottom={ + + {feature('BUDDY') && + companionNarrow && + isFullscreenEnvEnabled() && + companionVisible ? ( + + ) : null} {permissionStickyFooter} {/* Immediate local-jsx commands (/btw, /sandbox, /assistant, @@ -4600,406 +6269,781 @@ export function REPL({ stays in scrollable: the main loop is paused so no jiggle, and their tall content (DiffDetailView renders up to 400 lines with no internal scroll) needs the outer ScrollBox. */} - {toolJSX?.isLocalJSXCommand && toolJSX.isImmediate && !toolJsxCentered && + {toolJSX?.isLocalJSXCommand && + toolJSX.isImmediate && + !toolJsxCentered && ( + {toolJSX.jsx} - } - {!showSpinner && !toolJSX?.isLocalJSXCommand && showExpandedTodos && tasksV2 && tasksV2.length > 0 && + + )} + {!showSpinner && + !toolJSX?.isLocalJSXCommand && + showExpandedTodos && + tasksV2 && + tasksV2.length > 0 && ( + - } - {focusedInputDialog === 'sandbox-permission' && { - const { - allow, - persistToSettings - } = response; - const currentRequest = sandboxPermissionRequestQueue[0]; - if (!currentRequest) return; - const approvedHost = currentRequest.hostPattern.host; - if (persistToSettings) { - const update = { - type: 'addRules' as const, - rules: [{ - toolName: WEB_FETCH_TOOL_NAME, - ruleContent: `domain:${approvedHost}` - }], - behavior: (allow ? 'allow' : 'deny') as 'allow' | 'deny', - destination: 'localSettings' as const - }; - setAppState(prev => ({ - ...prev, - toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update) - })); - persistPermissionUpdate(update); - - // Immediately update sandbox in-memory config to prevent race conditions - // where pending requests slip through before settings change is detected - SandboxManager.refreshConfig(); - } - - // Resolve ALL pending requests for the same host (not just the first one) - // This handles the case where multiple parallel requests came in for the same domain - setSandboxPermissionRequestQueue(queue => { - queue.filter(item => item.hostPattern.host === approvedHost).forEach(item => item.resolvePromise(allow)); - return queue.filter(item => item.hostPattern.host !== approvedHost); - }); - - // Clean up bridge subscriptions and cancel remote prompts - // for this host since the local user already responded. - const cleanups = sandboxBridgeCleanupRef.current.get(approvedHost); - if (cleanups) { - for (const fn of cleanups) { - fn(); - } - sandboxBridgeCleanupRef.current.delete(approvedHost); - } - }} />} - {focusedInputDialog === 'prompt' && { - const item = promptQueue[0]; - if (!item) return; - item.resolve({ - prompt_response: item.request.prompt, - selected: selectedKey - }); - setPromptQueue(([, ...tail]) => tail); - }} onAbort={() => { - const item = promptQueue[0]; - if (!item) return; - item.reject(new Error('Prompt cancelled by user')); - setPromptQueue(([, ...tail]) => tail); - }} />} + + )} + {focusedInputDialog === 'sandbox-permission' && ( + { + const { allow, persistToSettings } = response + const currentRequest = sandboxPermissionRequestQueue[0] + if (!currentRequest) return + + const approvedHost = currentRequest.hostPattern.host + + if (persistToSettings) { + const update = { + type: 'addRules' as const, + rules: [ + { + toolName: WEB_FETCH_TOOL_NAME, + ruleContent: `domain:${approvedHost}`, + }, + ], + behavior: (allow ? 'allow' : 'deny') as + | 'allow' + | 'deny', + destination: 'localSettings' as const, + } + + setAppState(prev => ({ + ...prev, + toolPermissionContext: applyPermissionUpdate( + prev.toolPermissionContext, + update, + ), + })) + + persistPermissionUpdate(update) + + // Immediately update sandbox in-memory config to prevent race conditions + // where pending requests slip through before settings change is detected + SandboxManager.refreshConfig() + } + + // Resolve ALL pending requests for the same host (not just the first one) + // This handles the case where multiple parallel requests came in for the same domain + setSandboxPermissionRequestQueue(queue => { + queue + .filter( + item => item.hostPattern.host === approvedHost, + ) + .forEach(item => item.resolvePromise(allow)) + return queue.filter( + item => item.hostPattern.host !== approvedHost, + ) + }) + + // Clean up bridge subscriptions and cancel remote prompts + // for this host since the local user already responded. + const cleanups = + sandboxBridgeCleanupRef.current.get(approvedHost) + if (cleanups) { + for (const fn of cleanups) { + fn() + } + sandboxBridgeCleanupRef.current.delete(approvedHost) + } + }} + /> + )} + {focusedInputDialog === 'prompt' && ( + { + const item = promptQueue[0] + if (!item) return + item.resolve({ + prompt_response: item.request.prompt, + selected: selectedKey, + }) + setPromptQueue(([, ...tail]) => tail) + }} + onAbort={() => { + const item = promptQueue[0] + if (!item) return + item.reject(new Error('Prompt cancelled by user')) + setPromptQueue(([, ...tail]) => tail) + }} + /> + )} {/* Show pending indicator on worker while waiting for leader approval */} - {pendingWorkerRequest && } + {pendingWorkerRequest && ( + + )} {/* Show pending indicator for sandbox permission on worker side */} - {pendingSandboxRequest && } + {pendingSandboxRequest && ( + + )} {/* Worker sandbox permission requests from swarm workers */} - {focusedInputDialog === 'worker-sandbox-permission' && { - const { - allow, - persistToSettings - } = response; - const currentRequest = workerSandboxPermissions.queue[0]; - if (!currentRequest) return; - const approvedHost = currentRequest.host; - - // Send response via mailbox to the worker - void sendSandboxPermissionResponseViaMailbox(currentRequest.workerName, currentRequest.requestId, approvedHost, allow, teamContext?.teamName); - if (persistToSettings && allow) { - const update = { - type: 'addRules' as const, - rules: [{ - toolName: WEB_FETCH_TOOL_NAME, - ruleContent: `domain:${approvedHost}` - }], - behavior: 'allow' as const, - destination: 'localSettings' as const - }; - setAppState(prev => ({ - ...prev, - toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update) - })); - persistPermissionUpdate(update); - SandboxManager.refreshConfig(); - } - - // Remove from queue - setAppState(prev => ({ - ...prev, - workerSandboxPermissions: { - ...prev.workerSandboxPermissions, - queue: prev.workerSandboxPermissions.queue.slice(1) - } - })); - }} />} - {focusedInputDialog === 'elicitation' && { - const currentRequest = elicitation.queue[0]; - if (!currentRequest) return; - // Call respond callback to resolve Promise - currentRequest.respond({ - action, - content - }); - // For URL accept, keep in queue for phase 2 - const isUrlAccept = currentRequest.params.mode === 'url' && action === 'accept'; - if (!isUrlAccept) { - setAppState(prev => ({ - ...prev, - elicitation: { - queue: prev.elicitation.queue.slice(1) - } - })); - } - }} onWaitingDismiss={action => { - const currentRequest = elicitation.queue[0]; - // Remove from queue - setAppState(prev => ({ - ...prev, - elicitation: { - queue: prev.elicitation.queue.slice(1) - } - })); - currentRequest?.onWaitingDismiss?.(action); - }} />} - {focusedInputDialog === 'cost' && { - setShowCostDialog(false); - setHaveShownCostDialog(true); - saveGlobalConfig(current => ({ - ...current, - hasAcknowledgedCostThreshold: true - })); - logEvent('tengu_cost_threshold_acknowledged', {}); - }} />} - {focusedInputDialog === 'idle-return' && idleReturnPending && { - const pending = idleReturnPending; - setIdleReturnPending(null); - logEvent('tengu_idle_return_action', { - action: action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - idleMinutes: Math.round(pending.idleMinutes), - messageCount: messagesRef.current.length, - totalInputTokens: getTotalInputTokens() - }); - if (action === 'dismiss') { - setInputValue(pending.input); - return; - } - if (action === 'never') { - saveGlobalConfig(current => { - if (current.idleReturnDismissed) return current; - return { - ...current, - idleReturnDismissed: true - }; - }); - } - if (action === 'clear') { - const { - clearConversation - } = await import('../commands/clear/conversation.js'); - await clearConversation({ - setMessages, - readFileState: readFileState.current, - discoveredSkillNames: discoveredSkillNamesRef.current, - loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, - getAppState: () => store.getState(), - setAppState, - setConversationId - }); - haikuTitleAttemptedRef.current = false; - setHaikuTitle(undefined); - bashTools.current.clear(); - bashToolsProcessedIdx.current = 0; - } - skipIdleCheckRef.current = true; - void onSubmitRef.current(pending.input, { - setCursorOffset: () => {}, - clearBuffer: () => {}, - resetHistory: () => {} - }); - }} />} - {focusedInputDialog === 'ide-onboarding' && setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />} - {(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && { - setShowModelSwitchCallout(false); - if (selection === 'switch' && modelAlias) { - setAppState(prev => ({ - ...prev, - mainLoopModel: modelAlias, - mainLoopModelForSession: null - })); - } - }} />} - {(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && setShowUndercoverCallout(false)} />} - {focusedInputDialog === 'effort-callout' && { - setShowEffortCallout(false); - if (selection !== 'dismiss') { - setAppState(prev => ({ - ...prev, - effortValue: selection - })); - } - }} />} - {focusedInputDialog === 'remote-callout' && { - setAppState(prev => { - if (!prev.showRemoteCallout) return prev; - return { - ...prev, - showRemoteCallout: false, - ...(selection === 'enable' && { - replBridgeEnabled: true, - replBridgeExplicit: true, - replBridgeOutboundOnly: false - }) - }; - }); - }} />} + {focusedInputDialog === 'worker-sandbox-permission' && ( + { + const { allow, persistToSettings } = response + const currentRequest = workerSandboxPermissions.queue[0] + if (!currentRequest) return + + const approvedHost = currentRequest.host + + // Send response via mailbox to the worker + void sendSandboxPermissionResponseViaMailbox( + currentRequest.workerName, + currentRequest.requestId, + approvedHost, + allow, + teamContext?.teamName, + ) + + if (persistToSettings && allow) { + const update = { + type: 'addRules' as const, + rules: [ + { + toolName: WEB_FETCH_TOOL_NAME, + ruleContent: `domain:${approvedHost}`, + }, + ], + behavior: 'allow' as const, + destination: 'localSettings' as const, + } + + setAppState(prev => ({ + ...prev, + toolPermissionContext: applyPermissionUpdate( + prev.toolPermissionContext, + update, + ), + })) + + persistPermissionUpdate(update) + SandboxManager.refreshConfig() + } + + // Remove from queue + setAppState(prev => ({ + ...prev, + workerSandboxPermissions: { + ...prev.workerSandboxPermissions, + queue: prev.workerSandboxPermissions.queue.slice(1), + }, + })) + }} + /> + )} + {focusedInputDialog === 'elicitation' && ( + { + const currentRequest = elicitation.queue[0] + if (!currentRequest) return + // Call respond callback to resolve Promise + currentRequest.respond({ action, content }) + // For URL accept, keep in queue for phase 2 + const isUrlAccept = + currentRequest.params.mode === 'url' && + action === 'accept' + if (!isUrlAccept) { + setAppState(prev => ({ + ...prev, + elicitation: { + queue: prev.elicitation.queue.slice(1), + }, + })) + } + }} + onWaitingDismiss={action => { + const currentRequest = elicitation.queue[0] + // Remove from queue + setAppState(prev => ({ + ...prev, + elicitation: { + queue: prev.elicitation.queue.slice(1), + }, + })) + currentRequest?.onWaitingDismiss?.(action) + }} + /> + )} + {focusedInputDialog === 'cost' && ( + { + setShowCostDialog(false) + setHaveShownCostDialog(true) + saveGlobalConfig(current => ({ + ...current, + hasAcknowledgedCostThreshold: true, + })) + logEvent('tengu_cost_threshold_acknowledged', {}) + }} + /> + )} + {focusedInputDialog === 'idle-return' && idleReturnPending && ( + { + const pending = idleReturnPending + setIdleReturnPending(null) + logEvent('tengu_idle_return_action', { + action: + action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + idleMinutes: Math.round(pending.idleMinutes), + messageCount: messagesRef.current.length, + totalInputTokens: getTotalInputTokens(), + }) + if (action === 'dismiss') { + setInputValue(pending.input) + return + } + if (action === 'never') { + saveGlobalConfig(current => { + if (current.idleReturnDismissed) return current + return { ...current, idleReturnDismissed: true } + }) + } + if (action === 'clear') { + const { clearConversation } = await import( + '../commands/clear/conversation.js' + ) + await clearConversation({ + setMessages, + readFileState: readFileState.current, + discoveredSkillNames: discoveredSkillNamesRef.current, + loadedNestedMemoryPaths: + loadedNestedMemoryPathsRef.current, + getAppState: () => store.getState(), + setAppState, + setConversationId, + }) + haikuTitleAttemptedRef.current = false + setHaikuTitle(undefined) + bashTools.current.clear() + bashToolsProcessedIdx.current = 0 + } + skipIdleCheckRef.current = true + void onSubmitRef.current(pending.input, { + setCursorOffset: () => {}, + clearBuffer: () => {}, + resetHistory: () => {}, + }) + }} + /> + )} + {focusedInputDialog === 'ide-onboarding' && ( + setShowIdeOnboarding(false)} + installationStatus={ideInstallationStatus} + /> + )} + {process.env.USER_TYPE === 'ant' && + focusedInputDialog === 'model-switch' && + AntModelSwitchCallout && ( + { + setShowModelSwitchCallout(false) + if (selection === 'switch' && modelAlias) { + setAppState(prev => ({ + ...prev, + mainLoopModel: modelAlias, + mainLoopModelForSession: null, + })) + } + }} + /> + )} + {process.env.USER_TYPE === 'ant' && + focusedInputDialog === 'undercover-callout' && + UndercoverAutoCallout && ( + setShowUndercoverCallout(false)} + /> + )} + {focusedInputDialog === 'effort-callout' && ( + { + setShowEffortCallout(false) + if (selection !== 'dismiss') { + setAppState(prev => ({ + ...prev, + effortValue: selection, + })) + } + }} + /> + )} + {focusedInputDialog === 'remote-callout' && ( + { + setAppState(prev => { + if (!prev.showRemoteCallout) return prev + return { + ...prev, + showRemoteCallout: false, + ...(selection === 'enable' && { + replBridgeEnabled: true, + replBridgeExplicit: true, + replBridgeOutboundOnly: false, + }), + } + }) + }} + /> + )} {exitFlow} - {focusedInputDialog === 'plugin-hint' && hintRecommendation && } - - {focusedInputDialog === 'lsp-recommendation' && lspRecommendation && } - - {focusedInputDialog === 'desktop-upsell' && setShowDesktopUpsellStartup(false)} />} - - {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-choice' && ultraplanPendingChoice && store.getState()} setConversationId={setConversationId} /> : null} - - {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-launch' && ultraplanLaunchPending && { - const blurb = ultraplanLaunchPending.blurb; - setAppState(prev => prev.ultraplanLaunchPending ? { - ...prev, - ultraplanLaunchPending: undefined - } : prev); - if (choice === 'cancel') return; - // Command's onDone used display:'skip', so add the - // echo here — gives immediate feedback before the - // ~5s teleportToRemote resolves. - setMessages(prev => [...prev, createCommandInputMessage(formatCommandInputTags('ultraplan', blurb))]); - const appendStdout = (msg: string) => setMessages(prev => [...prev, createCommandInputMessage(`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(msg)}`)]); - // Defer the second message if a query is mid-turn - // so it lands after the assistant reply, not - // between the user's prompt and the reply. - const appendWhenIdle = (msg: string) => { - if (!queryGuard.isActive) { - appendStdout(msg); - return; - } - const unsub = queryGuard.subscribe(() => { - if (queryGuard.isActive) return; - unsub(); - // Skip if the user stopped ultraplan while we - // were waiting — avoids a stale "Monitoring - // " message for a session that's gone. - if (!store.getState().ultraplanSessionUrl) return; - appendStdout(msg); - }); - }; - void launchUltraplan({ - blurb, - getAppState: () => store.getState(), - setAppState, - signal: createAbortController().signal, - disconnectedBridge: opts?.disconnectedBridge, - onSessionReady: appendWhenIdle - }).then(appendStdout).catch(logError); - }} /> : null} + {focusedInputDialog === 'plugin-hint' && hintRecommendation && ( + + )} + + {focusedInputDialog === 'lsp-recommendation' && + lspRecommendation && ( + + )} + + {focusedInputDialog === 'desktop-upsell' && ( + setShowDesktopUpsellStartup(false)} + /> + )} + + {feature('ULTRAPLAN') + ? focusedInputDialog === 'ultraplan-choice' && + ultraplanPendingChoice && ( + store.getState()} + setConversationId={setConversationId} + /> + ) + : null} + + {feature('ULTRAPLAN') + ? focusedInputDialog === 'ultraplan-launch' && + ultraplanLaunchPending && ( + { + const blurb = ultraplanLaunchPending.blurb + setAppState(prev => + prev.ultraplanLaunchPending + ? { ...prev, ultraplanLaunchPending: undefined } + : prev, + ) + if (choice === 'cancel') return + // Command's onDone used display:'skip', so add the + // echo here — gives immediate feedback before the + // ~5s teleportToRemote resolves. + setMessages(prev => [ + ...prev, + createCommandInputMessage( + formatCommandInputTags('ultraplan', blurb), + ), + ]) + const appendStdout = (msg: string) => + setMessages(prev => [ + ...prev, + createCommandInputMessage( + `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(msg)}`, + ), + ]) + // Defer the second message if a query is mid-turn + // so it lands after the assistant reply, not + // between the user's prompt and the reply. + const appendWhenIdle = (msg: string) => { + if (!queryGuard.isActive) { + appendStdout(msg) + return + } + const unsub = queryGuard.subscribe(() => { + if (queryGuard.isActive) return + unsub() + // Skip if the user stopped ultraplan while we + // were waiting — avoids a stale "Monitoring + // " message for a session that's gone. + if (!store.getState().ultraplanSessionUrl) return + appendStdout(msg) + }) + } + void launchUltraplan({ + blurb, + getAppState: () => store.getState(), + setAppState, + signal: createAbortController().signal, + disconnectedBridge: opts?.disconnectedBridge, + onSessionReady: appendWhenIdle, + }) + .then(appendStdout) + .catch(logError) + }} + /> + ) + : null} {mrRender()} - {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && <> - {autoRunIssueReason && } - {postCompactSurvey.state !== 'closed' ? : memorySurvey.state !== 'closed' ? : } + {!toolJSX?.shouldHidePromptInput && + !focusedInputDialog && + !isExiting && + !disabled && + !cursor && ( + <> + {autoRunIssueReason && ( + + )} + {postCompactSurvey.state !== 'closed' ? ( + + ) : memorySurvey.state !== 'closed' ? ( + + ) : ( + + )} {/* Frustration-triggered transcript sharing prompt */} - {frustrationDetection.state !== 'closed' && {}} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />} + {frustrationDetection.state !== 'closed' && ( + {}} + handleTranscriptSelect={ + frustrationDetection.handleTranscriptSelect + } + inputValue={inputValue} + setInputValue={setInputValue} + /> + )} {/* Skill improvement survey - appears when improvements detected (ant-only) */} - {(process.env.USER_TYPE) === 'ant' && skillImprovementSurvey.suggestion && } + {process.env.USER_TYPE === 'ant' && + skillImprovementSurvey.suggestion && ( + + )} {showIssueFlagBanner && } - {} - - - } - {cursor && - // inputValue is REPL state; typed text survives the round-trip. - } - {focusedInputDialog === 'message-selector' && { - await fileHistoryRewind((updater: (prev: FileHistoryState) => FileHistoryState) => { - setAppState(prev => ({ - ...prev, - fileHistory: updater(prev.fileHistory) - })); - }, message.uuid); - }} onSummarize={async (message: UserMessage, feedback?: string, direction: PartialCompactDirection = 'from') => { - // Project snipped messages so the compact model - // doesn't summarize content that was intentionally removed. - const compactMessages = getMessagesAfterCompactBoundary(messages); - const messageIndex = compactMessages.indexOf(message); - if (messageIndex === -1) { - // Selected a snipped or pre-compact message that the - // selector still shows (REPL keeps full history for - // scrollback). Surface why nothing happened instead - // of silently no-oping. - setMessages(prev => [...prev, createSystemMessage('That message is no longer in the active context (snipped or pre-compact). Choose a more recent message.', 'warning')]); - return; - } - const newAbortController = createAbortController(); - const context = getToolUseContext(compactMessages, [], newAbortController, mainLoopModel); - const appState = context.getAppState(); - const defaultSysPrompt = await getSystemPrompt(context.options.tools, context.options.mainLoopModel, Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()), context.options.mcpClients); - const systemPrompt = buildEffectiveSystemPrompt({ - mainThreadAgentDefinition: undefined, - toolUseContext: context, - customSystemPrompt: context.options.customSystemPrompt, - defaultSystemPrompt: defaultSysPrompt, - appendSystemPrompt: context.options.appendSystemPrompt - }); - const [userContext, systemContext] = await Promise.all([getUserContext(), getSystemContext()]); - const result = await partialCompactConversation(compactMessages, messageIndex, context, { - systemPrompt, - userContext, - systemContext, - toolUseContext: context, - forkContextMessages: compactMessages - }, feedback, direction); - const kept = result.messagesToKeep ?? []; - const ordered = direction === 'up_to' ? [...result.summaryMessages, ...kept] : [...kept, ...result.summaryMessages]; - const postCompact = [result.boundaryMarker, ...ordered, ...result.attachments, ...result.hookResults]; - // Fullscreen 'from' keeps scrollback; 'up_to' must not - // (old[0] unchanged + grown array means incremental - // useLogMessages path, so boundary never persisted). - // Find by uuid since old is raw REPL history and snipped - // entries can shift the projected messageIndex. - if (isFullscreenEnvEnabled() && direction === 'from') { - setMessages(old => { - const rawIdx = old.findIndex(m => m.uuid === message.uuid); - return [...old.slice(0, rawIdx === -1 ? 0 : rawIdx), ...postCompact]; - }); - } else { - setMessages(postCompact); - } - // Partial compact bypasses handleMessageFromStream — clear - // the context-blocked flag so proactive ticks resume. - if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.setContextBlocked(false); - } - setConversationId(randomUUID()); - runPostCompactCleanup(context.options.querySource); - if (direction === 'from') { - const r = textForResubmit(message); - if (r) { - setInputValue(r.text); - setInputMode(r.mode); - } - } - - // Show notification with ctrl+o hint - const historyShortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); - addNotification({ - key: 'summarize-ctrl-o-hint', - text: `Conversation summarized (${historyShortcut} for history)`, - priority: 'medium', - timeoutMs: 8000 - }); - }} onRestoreMessage={handleRestoreMessage} onClose={() => { - setIsMessageSelectorVisible(false); - setMessageSelectorPreselect(undefined); - }} />} - {(process.env.USER_TYPE) === 'ant' && } + { + } + + + + )} + {cursor && ( + // inputValue is REPL state; typed text survives the round-trip. + + )} + {focusedInputDialog === 'message-selector' && ( + { + await fileHistoryRewind( + ( + updater: (prev: FileHistoryState) => FileHistoryState, + ) => { + setAppState(prev => ({ + ...prev, + fileHistory: updater(prev.fileHistory), + })) + }, + message.uuid, + ) + }} + onSummarize={async ( + message: UserMessage, + feedback?: string, + direction: PartialCompactDirection = 'from', + ) => { + // Project snipped messages so the compact model + // doesn't summarize content that was intentionally removed. + const compactMessages = + getMessagesAfterCompactBoundary(messages) + + const messageIndex = compactMessages.indexOf(message) + if (messageIndex === -1) { + // Selected a snipped or pre-compact message that the + // selector still shows (REPL keeps full history for + // scrollback). Surface why nothing happened instead + // of silently no-oping. + setMessages(prev => [ + ...prev, + createSystemMessage( + 'That message is no longer in the active context (snipped or pre-compact). Choose a more recent message.', + 'warning', + ), + ]) + return + } + + const newAbortController = createAbortController() + const context = getToolUseContext( + compactMessages, + [], + newAbortController, + mainLoopModel, + ) + + const appState = context.getAppState() + const defaultSysPrompt = await getSystemPrompt( + context.options.tools, + context.options.mainLoopModel, + Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ), + context.options.mcpClients, + ) + const systemPrompt = buildEffectiveSystemPrompt({ + mainThreadAgentDefinition: undefined, + toolUseContext: context, + customSystemPrompt: context.options.customSystemPrompt, + defaultSystemPrompt: defaultSysPrompt, + appendSystemPrompt: context.options.appendSystemPrompt, + }) + const [userContext, systemContext] = await Promise.all([ + getUserContext(), + getSystemContext(), + ]) + + const result = await partialCompactConversation( + compactMessages, + messageIndex, + context, + { + systemPrompt, + userContext, + systemContext, + toolUseContext: context, + forkContextMessages: compactMessages, + }, + feedback, + direction, + ) + + const kept = result.messagesToKeep ?? [] + const ordered = + direction === 'up_to' + ? [...result.summaryMessages, ...kept] + : [...kept, ...result.summaryMessages] + const postCompact = [ + result.boundaryMarker, + ...ordered, + ...result.attachments, + ...result.hookResults, + ] + // Fullscreen 'from' keeps scrollback; 'up_to' must not + // (old[0] unchanged + grown array means incremental + // useLogMessages path, so boundary never persisted). + // Find by uuid since old is raw REPL history and snipped + // entries can shift the projected messageIndex. + if (isFullscreenEnvEnabled() && direction === 'from') { + setMessages(old => { + const rawIdx = old.findIndex( + m => m.uuid === message.uuid, + ) + return [ + ...old.slice(0, rawIdx === -1 ? 0 : rawIdx), + ...postCompact, + ] + }) + } else { + setMessages(postCompact) + } + // Partial compact bypasses handleMessageFromStream — clear + // the context-blocked flag so proactive ticks resume. + if (feature('PROACTIVE') || feature('KAIROS')) { + proactiveModule?.setContextBlocked(false) + } + setConversationId(randomUUID()) + runPostCompactCleanup(context.options.querySource) + + if (direction === 'from') { + const r = textForResubmit(message) + if (r) { + setInputValue(r.text) + setInputMode(r.mode) + } + } + + // Show notification with ctrl+o hint + const historyShortcut = getShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + addNotification({ + key: 'summarize-ctrl-o-hint', + text: `Conversation summarized (${historyShortcut} for history)`, + priority: 'medium', + timeoutMs: 8000, + }) + }} + onRestoreMessage={handleRestoreMessage} + onClose={() => { + setIsMessageSelectorVisible(false) + setMessageSelectorPreselect(undefined) + }} + /> + )} + {process.env.USER_TYPE === 'ant' && } - {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} - } /> + {feature('BUDDY') && + !(companionNarrow && isFullscreenEnvEnabled()) && + companionVisible ? ( + + ) : null} + + } + /> - ; + + ) if (isFullscreenEnvEnabled()) { - return + return ( + {mainReturn} - ; + + ) } - return mainReturn; + return mainReturn } diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index 71f947c43..019327ff3 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -1,69 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import { dirname } from 'path'; -import React from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { getOriginalCwd, switchSession } from '../bootstrap/state.js'; -import type { Command } from '../commands.js'; -import { LogSelector } from '../components/LogSelector.js'; -import { Spinner } from '../components/Spinner.js'; -import { restoreCostStateForSession } from '../cost-tracker.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; -import type { MCPServerConnection, ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import type { Tool } from '../Tool.js'; -import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; -import { asSessionId } from '../types/ids.js'; -import type { LogOption } from '../types/logs.js'; -import type { Message } from '../types/message.js'; -import { agenticSessionSearch } from '../utils/agenticSessionSearch.js'; -import { renameRecordingForSession } from '../utils/asciicast.js'; -import { updateSessionName } from '../utils/concurrentSessions.js'; -import { loadConversationForResume } from '../utils/conversationRecovery.js'; -import { checkCrossProjectResume } from '../utils/crossProjectResume.js'; -import type { FileHistorySnapshot } from '../utils/fileHistory.js'; -import { logError } from '../utils/log.js'; -import { createSystemMessage } from '../utils/messages.js'; -import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; -import { adoptResumedSessionFile, enrichLogs, isCustomTitleEnabled, loadAllProjectsMessageLogsProgressive, loadSameRepoMessageLogsProgressive, recordContentReplacement, resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult } from '../utils/sessionStorage.js'; -import type { ThinkingConfig } from '../utils/thinking.js'; -import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'; -import { REPL } from './REPL.js'; +import { feature } from 'bun:bundle' +import { dirname } from 'path' +import React from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { getOriginalCwd, switchSession } from '../bootstrap/state.js' +import type { Command } from '../commands.js' +import { LogSelector } from '../components/LogSelector.js' +import { Spinner } from '../components/Spinner.js' +import { restoreCostStateForSession } from '../cost-tracker.js' +import { setClipboard } from '../ink/termio/osc.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import type { + MCPServerConnection, + ScopedMcpServerConfig, +} from '../services/mcp/types.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import type { Tool } from '../Tool.js' +import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import { asSessionId } from '../types/ids.js' +import type { LogOption } from '../types/logs.js' +import type { Message } from '../types/message.js' +import { agenticSessionSearch } from '../utils/agenticSessionSearch.js' +import { renameRecordingForSession } from '../utils/asciicast.js' +import { updateSessionName } from '../utils/concurrentSessions.js' +import { loadConversationForResume } from '../utils/conversationRecovery.js' +import { checkCrossProjectResume } from '../utils/crossProjectResume.js' +import type { FileHistorySnapshot } from '../utils/fileHistory.js' +import { logError } from '../utils/log.js' +import { createSystemMessage } from '../utils/messages.js' +import { + computeStandaloneAgentContext, + restoreAgentFromSession, + restoreWorktreeForResume, +} from '../utils/sessionRestore.js' +import { + adoptResumedSessionFile, + enrichLogs, + isCustomTitleEnabled, + loadAllProjectsMessageLogsProgressive, + loadSameRepoMessageLogsProgressive, + recordContentReplacement, + resetSessionFilePointer, + restoreSessionMetadata, + type SessionLogResult, +} from '../utils/sessionStorage.js' +import type { ThinkingConfig } from '../utils/thinking.js' +import type { ContentReplacementRecord } from '../utils/toolResultStorage.js' +import { REPL } from './REPL.js' + function parsePrIdentifier(value: string): number | null { - const directNumber = parseInt(value, 10); + const directNumber = parseInt(value, 10) if (!isNaN(directNumber) && directNumber > 0) { - return directNumber; + return directNumber } - const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); + const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/) if (urlMatch?.[1]) { - return parseInt(urlMatch[1], 10); + return parseInt(urlMatch[1], 10) } - return null; + return null } + type Props = { - commands: Command[]; - worktreePaths: string[]; - initialTools: Tool[]; - mcpClients?: MCPServerConnection[]; - dynamicMcpConfig?: Record; - debug: boolean; - mainThreadAgentDefinition?: AgentDefinition; - autoConnectIdeFlag?: boolean; - strictMcpConfig?: boolean; - systemPrompt?: string; - appendSystemPrompt?: string; - initialSearchQuery?: string; - disableSlashCommands?: boolean; - forkSession?: boolean; - taskListId?: string; - filterByPr?: boolean | number | string; - thinkingConfig: ThinkingConfig; - onTurnComplete?: (messages: Message[]) => void | Promise; -}; + commands: Command[] + worktreePaths: string[] + initialTools: Tool[] + mcpClients?: MCPServerConnection[] + dynamicMcpConfig?: Record + debug: boolean + mainThreadAgentDefinition?: AgentDefinition + autoConnectIdeFlag?: boolean + strictMcpConfig?: boolean + systemPrompt?: string + appendSystemPrompt?: string + initialSearchQuery?: string + disableSlashCommands?: boolean + forkSession?: boolean + taskListId?: string + filterByPr?: boolean | number | string + thinkingConfig: ThinkingConfig + onTurnComplete?: (messages: Message[]) => void | Promise +} + export function ResumeConversation({ commands, worktreePaths, @@ -82,317 +104,365 @@ export function ResumeConversation({ taskListId, filterByPr, thinkingConfig, - onTurnComplete + onTurnComplete, }: Props): React.ReactNode { - const { - rows - } = useTerminalSize(); - const agentDefinitions = useAppState(s => s.agentDefinitions); - const setAppState = useSetAppState(); - const [logs, setLogs] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [resuming, setResuming] = React.useState(false); - const [showAllProjects, setShowAllProjects] = React.useState(false); + const { rows } = useTerminalSize() + const agentDefinitions = useAppState(s => s.agentDefinitions) + const setAppState = useSetAppState() + const [logs, setLogs] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [resuming, setResuming] = React.useState(false) + const [showAllProjects, setShowAllProjects] = React.useState(false) const [resumeData, setResumeData] = React.useState<{ - messages: Message[]; - fileHistorySnapshots?: FileHistorySnapshot[]; - contentReplacements?: ContentReplacementRecord[]; - agentName?: string; - agentColor?: AgentColorName; - mainThreadAgentDefinition?: AgentDefinition; - } | null>(null); - const [crossProjectCommand, setCrossProjectCommand] = React.useState(null); - const sessionLogResultRef = React.useRef(null); + messages: Message[] + fileHistorySnapshots?: FileHistorySnapshot[] + contentReplacements?: ContentReplacementRecord[] + agentName?: string + agentColor?: AgentColorName + mainThreadAgentDefinition?: AgentDefinition + } | null>(null) + const [crossProjectCommand, setCrossProjectCommand] = React.useState< + string | null + >(null) + const sessionLogResultRef = React.useRef(null) // Mirror of logs.length so loadMoreLogs can compute value indices outside // the setLogs updater (keeping it pure per React's contract). - const logCountRef = React.useRef(0); + const logCountRef = React.useRef(0) + const filteredLogs = React.useMemo(() => { - let result = logs.filter(l => !l.isSidechain); + let result = logs.filter(l => !l.isSidechain) if (filterByPr !== undefined) { if (filterByPr === true) { - result = result.filter(l_0 => l_0.prNumber !== undefined); + result = result.filter(l => l.prNumber !== undefined) } else if (typeof filterByPr === 'number') { - result = result.filter(l_1 => l_1.prNumber === filterByPr); + result = result.filter(l => l.prNumber === filterByPr) } else if (typeof filterByPr === 'string') { - const prNumber = parsePrIdentifier(filterByPr); + const prNumber = parsePrIdentifier(filterByPr) if (prNumber !== null) { - result = result.filter(l_2 => l_2.prNumber === prNumber); + result = result.filter(l => l.prNumber === prNumber) } } } - return result; - }, [logs, filterByPr]); - const isResumeWithRenameEnabled = isCustomTitleEnabled(); + return result + }, [logs, filterByPr]) + const isResumeWithRenameEnabled = isCustomTitleEnabled() + React.useEffect(() => { - loadSameRepoMessageLogsProgressive(worktreePaths).then(result_0 => { - sessionLogResultRef.current = result_0; - logCountRef.current = result_0.logs.length; - setLogs(result_0.logs); - setLoading(false); - }).catch(error => { - logError(error); - setLoading(false); - }); - }, [worktreePaths]); + loadSameRepoMessageLogsProgressive(worktreePaths) + .then(result => { + sessionLogResultRef.current = result + logCountRef.current = result.logs.length + setLogs(result.logs) + setLoading(false) + }) + .catch(error => { + logError(error) + setLoading(false) + }) + }, [worktreePaths]) + const loadMoreLogs = React.useCallback((count: number) => { - const ref = sessionLogResultRef.current; - if (!ref || ref.nextIndex >= ref.allStatLogs.length) return; - void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result_1 => { - ref.nextIndex = result_1.nextIndex; - if (result_1.logs.length > 0) { + const ref = sessionLogResultRef.current + if (!ref || ref.nextIndex >= ref.allStatLogs.length) return + + void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => { + ref.nextIndex = result.nextIndex + if (result.logs.length > 0) { // enrichLogs returns fresh unshared objects — safe to mutate in place. // Offset comes from logCountRef so the setLogs updater stays pure. - const offset = logCountRef.current; - result_1.logs.forEach((log, i) => { - log.value = offset + i; - }); - setLogs(prev => prev.concat(result_1.logs)); - logCountRef.current += result_1.logs.length; + const offset = logCountRef.current + result.logs.forEach((log, i) => { + log.value = offset + i + }) + setLogs(prev => prev.concat(result.logs)) + logCountRef.current += result.logs.length } else if (ref.nextIndex < ref.allStatLogs.length) { - loadMoreLogs(count); + loadMoreLogs(count) } - }); - }, []); - const loadLogs = React.useCallback((allProjects: boolean) => { - setLoading(true); - const promise = allProjects ? loadAllProjectsMessageLogsProgressive() : loadSameRepoMessageLogsProgressive(worktreePaths); - promise.then(result_2 => { - sessionLogResultRef.current = result_2; - logCountRef.current = result_2.logs.length; - setLogs(result_2.logs); - }).catch(error_0 => { - logError(error_0); - }).finally(() => { - setLoading(false); - }); - }, [worktreePaths]); + }) + }, []) + + const loadLogs = React.useCallback( + (allProjects: boolean) => { + setLoading(true) + const promise = allProjects + ? loadAllProjectsMessageLogsProgressive() + : loadSameRepoMessageLogsProgressive(worktreePaths) + promise + .then(result => { + sessionLogResultRef.current = result + logCountRef.current = result.logs.length + setLogs(result.logs) + }) + .catch(error => { + logError(error) + }) + .finally(() => { + setLoading(false) + }) + }, + [worktreePaths], + ) + const handleToggleAllProjects = React.useCallback(() => { - const newValue = !showAllProjects; - setShowAllProjects(newValue); - loadLogs(newValue); - }, [showAllProjects, loadLogs]); + const newValue = !showAllProjects + setShowAllProjects(newValue) + loadLogs(newValue) + }, [showAllProjects, loadLogs]) + function onCancel() { // eslint-disable-next-line custom-rules/no-process-exit - process.exit(1); + process.exit(1) } - async function onSelect(log_0: LogOption) { - setResuming(true); - const resumeStart = performance.now(); - const crossProjectCheck = checkCrossProjectResume(log_0, showAllProjects, worktreePaths); + + async function onSelect(log: LogOption) { + setResuming(true) + const resumeStart = performance.now() + + const crossProjectCheck = checkCrossProjectResume( + log, + showAllProjects, + worktreePaths, + ) if (crossProjectCheck.isCrossProject) { if (!crossProjectCheck.isSameRepoWorktree) { - const raw = await setClipboard((crossProjectCheck as any).command); - if (raw) process.stdout.write(raw); - setCrossProjectCommand((crossProjectCheck as any).command); - return; + const raw = await setClipboard(crossProjectCheck.command) + if (raw) process.stdout.write(raw) + setCrossProjectCommand(crossProjectCheck.command) + return } } + try { - const result_3 = await loadConversationForResume(log_0, undefined); - if (!result_3) { - throw new Error('Failed to load conversation'); + const result = await loadConversationForResume(log, undefined) + if (!result) { + throw new Error('Failed to load conversation') } + if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); + const coordinatorModule = + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') /* eslint-enable @typescript-eslint/no-require-imports */ - const warning = coordinatorModule.matchSessionMode(result_3.mode); + const warning = coordinatorModule.matchSessionMode(result.mode) if (warning) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - getAgentDefinitionsWithOverrides, - getActiveAgentsFromList - } = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js'); + const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = + require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js') /* eslint-enable @typescript-eslint/no-require-imports */ - getAgentDefinitionsWithOverrides.cache.clear?.(); - const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd()); - setAppState(prev_0 => ({ - ...prev_0, + getAgentDefinitionsWithOverrides.cache.clear?.() + const freshAgentDefs = await getAgentDefinitionsWithOverrides( + getOriginalCwd(), + ) + setAppState(prev => ({ + ...prev, agentDefinitions: { ...freshAgentDefs, allAgents: freshAgentDefs.allAgents, - activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents) - } - })); - result_3.messages.push(createSystemMessage(warning, 'warning')); + activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), + }, + })) + result.messages.push(createSystemMessage(warning, 'warning')) } } - if (result_3.sessionId && !forkSession) { - switchSession(asSessionId(result_3.sessionId), log_0.fullPath ? dirname(log_0.fullPath) : null); - await renameRecordingForSession(); - await resetSessionFilePointer(); - restoreCostStateForSession(result_3.sessionId); - } else if (forkSession && result_3.contentReplacements?.length) { - await recordContentReplacement(result_3.contentReplacements); + + if (result.sessionId && !forkSession) { + switchSession( + asSessionId(result.sessionId), + log.fullPath ? dirname(log.fullPath) : null, + ) + await renameRecordingForSession() + await resetSessionFilePointer() + restoreCostStateForSession(result.sessionId) + } else if (forkSession && result.contentReplacements?.length) { + await recordContentReplacement(result.contentReplacements) } - const { - agentDefinition: resolvedAgentDef - } = restoreAgentFromSession(result_3.agentSetting, mainThreadAgentDefinition, agentDefinitions); - setAppState(prev_1 => ({ - ...prev_1, - agent: resolvedAgentDef?.agentType - })); + + const { agentDefinition: resolvedAgentDef } = restoreAgentFromSession( + result.agentSetting, + mainThreadAgentDefinition, + agentDefinitions, + ) + setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType })) + if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - saveMode - } = require('../utils/sessionStorage.js'); - const { - isCoordinatorMode - } = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); + const { saveMode } = require('../utils/sessionStorage.js') + const { isCoordinatorMode } = + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') /* eslint-enable @typescript-eslint/no-require-imports */ - saveMode(isCoordinatorMode() ? 'coordinator' : 'normal'); + saveMode(isCoordinatorMode() ? 'coordinator' : 'normal') } - const standaloneAgentContext = computeStandaloneAgentContext(result_3.agentName, result_3.agentColor); + + const standaloneAgentContext = computeStandaloneAgentContext( + result.agentName, + result.agentColor, + ) if (standaloneAgentContext) { - setAppState(prev_2 => ({ - ...prev_2, - standaloneAgentContext - })); + setAppState(prev => ({ ...prev, standaloneAgentContext })) } - void updateSessionName(result_3.agentName); - restoreSessionMetadata(forkSession ? { - ...result_3, - worktreeSession: undefined - } : result_3); + void updateSessionName(result.agentName) + + restoreSessionMetadata( + forkSession ? { ...result, worktreeSession: undefined } : result, + ) + if (!forkSession) { - restoreWorktreeForResume(result_3.worktreeSession); - if (result_3.sessionId) { - adoptResumedSessionFile(); + restoreWorktreeForResume(result.worktreeSession) + if (result.sessionId) { + adoptResumedSessionFile() } } + if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - ; - (require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js')).restoreFromEntries(result_3.contextCollapseCommits ?? [], result_3.contextCollapseSnapshot); + ;( + require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js') + ).restoreFromEntries( + result.contextCollapseCommits ?? [], + result.contextCollapseSnapshot, + ) /* eslint-enable @typescript-eslint/no-require-imports */ } + logEvent('tengu_session_resumed', { - entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: + 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); - setLogs([]); + resume_duration_ms: Math.round(performance.now() - resumeStart), + }) + + setLogs([]) setResumeData({ - messages: result_3.messages, - fileHistorySnapshots: result_3.fileHistorySnapshots, - contentReplacements: result_3.contentReplacements, - agentName: result_3.agentName, - agentColor: (result_3.agentColor === 'default' ? undefined : result_3.agentColor) as AgentColorName | undefined, - mainThreadAgentDefinition: resolvedAgentDef - }); + messages: result.messages, + fileHistorySnapshots: result.fileHistorySnapshots, + contentReplacements: result.contentReplacements, + agentName: result.agentName, + agentColor: (result.agentColor === 'default' + ? undefined + : result.agentColor) as AgentColorName | undefined, + mainThreadAgentDefinition: resolvedAgentDef, + }) } catch (e) { logEvent('tengu_session_resumed', { - entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); - logError(e as Error); - throw e; + entrypoint: + 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + logError(e as Error) + throw e } } + if (crossProjectCommand) { - return ; + return } + if (resumeData) { - return ; + return ( + + ) } + if (loading) { - return + return ( + Loading conversations… - ; + + ) } + if (resuming) { - return + return ( + Resuming conversation… - ; + + ) } + if (filteredLogs.length === 0) { - return ; - } - return loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />; -} -function NoConversationsMessage() { - const $ = _c(2); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Global" - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useKeybinding("app:interrupt", _temp, t0); - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = No conversations found to resume.Press Ctrl+C to exit and start a new conversation.; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; -} -function _temp() { - process.exit(1); -} -function CrossProjectMessage(t0) { - const $ = _c(8); - const { - command - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; + return } - React.useEffect(_temp3, t1); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = This conversation is from a different directory.; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = To resume, run:; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== command) { - t4 = {t3} {command}; - $[3] = command; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = (Command copied to clipboard); - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== t4) { - t6 = {t2}{t4}{t5}; - $[6] = t4; - $[7] = t6; - } else { - t6 = $[7]; - } - return t6; + + return ( + loadLogs(showAllProjects) : undefined + } + onLoadMore={loadMoreLogs} + initialSearchQuery={initialSearchQuery} + showAllProjects={showAllProjects} + onToggleAllProjects={handleToggleAllProjects} + onAgenticSearch={agenticSessionSearch} + /> + ) } -function _temp3() { - const timeout = setTimeout(_temp2, 100); - return () => clearTimeout(timeout); + +function NoConversationsMessage(): React.ReactNode { + useKeybinding( + 'app:interrupt', + () => { + // eslint-disable-next-line custom-rules/no-process-exit + process.exit(1) + }, + { context: 'Global' }, + ) + + return ( + + No conversations found to resume. + Press Ctrl+C to exit and start a new conversation. + + ) } -function _temp2() { - process.exit(0); + +function CrossProjectMessage({ + command, +}: { + command: string +}): React.ReactNode { + React.useEffect(() => { + const timeout = setTimeout(() => { + // eslint-disable-next-line custom-rules/no-process-exit + process.exit(0) + }, 100) + return () => clearTimeout(timeout) + }, []) + + return ( + + This conversation is from a different directory. + + To resume, run: + {command} + + (Command copied to clipboard) + + ) } diff --git a/src/services/mcp/MCPConnectionManager.tsx b/src/services/mcp/MCPConnectionManager.tsx index 3c5bb4d23..46c56b689 100644 --- a/src/services/mcp/MCPConnectionManager.tsx +++ b/src/services/mcp/MCPConnectionManager.tsx @@ -1,72 +1,74 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, type ReactNode, useContext, useMemo } from 'react'; -import type { Command } from '../../commands.js'; -import type { Tool } from '../../Tool.js'; -import type { MCPServerConnection, ScopedMcpServerConfig, ServerResource } from './types.js'; -import { useManageMCPConnections } from './useManageMCPConnections.js'; +import React, { + createContext, + type ReactNode, + useContext, + useMemo, +} from 'react' +import type { Command } from '../../commands.js' +import type { Tool } from '../../Tool.js' +import type { + MCPServerConnection, + ScopedMcpServerConfig, + ServerResource, +} from './types.js' +import { useManageMCPConnections } from './useManageMCPConnections.js' + interface MCPConnectionContextValue { reconnectMcpServer: (serverName: string) => Promise<{ - client: MCPServerConnection; - tools: Tool[]; - commands: Command[]; - resources?: ServerResource[]; - }>; - toggleMcpServer: (serverName: string) => Promise; + client: MCPServerConnection + tools: Tool[] + commands: Command[] + resources?: ServerResource[] + }> + toggleMcpServer: (serverName: string) => Promise } -const MCPConnectionContext = createContext(null); + +const MCPConnectionContext = createContext( + null, +) + export function useMcpReconnect() { - const context = useContext(MCPConnectionContext); + const context = useContext(MCPConnectionContext) if (!context) { - throw new Error("useMcpReconnect must be used within MCPConnectionManager"); + throw new Error('useMcpReconnect must be used within MCPConnectionManager') } - return context.reconnectMcpServer; + return context.reconnectMcpServer } + export function useMcpToggleEnabled() { - const context = useContext(MCPConnectionContext); + const context = useContext(MCPConnectionContext) if (!context) { - throw new Error("useMcpToggleEnabled must be used within MCPConnectionManager"); + throw new Error( + 'useMcpToggleEnabled must be used within MCPConnectionManager', + ) } - return context.toggleMcpServer; + return context.toggleMcpServer } + interface MCPConnectionManagerProps { - children: ReactNode; - dynamicMcpConfig: Record | undefined; - isStrictMcpConfig: boolean; + children: ReactNode + dynamicMcpConfig: Record | undefined + isStrictMcpConfig: boolean } // TODO (ollie): We may be able to get rid of this context by putting these function on app state -export function MCPConnectionManager(t0) { - const $ = _c(6); - const { - children, +export function MCPConnectionManager({ + children, + dynamicMcpConfig, + isStrictMcpConfig, +}: MCPConnectionManagerProps): React.ReactNode { + const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections( dynamicMcpConfig, - isStrictMcpConfig - } = t0; - const { - reconnectMcpServer, - toggleMcpServer - } = useManageMCPConnections(dynamicMcpConfig, isStrictMcpConfig); - let t1; - if ($[0] !== reconnectMcpServer || $[1] !== toggleMcpServer) { - t1 = { - reconnectMcpServer, - toggleMcpServer - }; - $[0] = reconnectMcpServer; - $[1] = toggleMcpServer; - $[2] = t1; - } else { - t1 = $[2]; - } - const value = t1; - let t2; - if ($[3] !== children || $[4] !== value) { - t2 = {children}; - $[3] = children; - $[4] = value; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; + isStrictMcpConfig, + ) + const value = useMemo( + () => ({ reconnectMcpServer, toggleMcpServer }), + [reconnectMcpServer, toggleMcpServer], + ) + + return ( + + {children} + + ) } diff --git a/src/services/mcpServerApproval.tsx b/src/services/mcpServerApproval.tsx index 9b5a8bf5e..4b92d1280 100644 --- a/src/services/mcpServerApproval.tsx +++ b/src/services/mcpServerApproval.tsx @@ -1,11 +1,11 @@ -import React from 'react'; -import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js'; -import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js'; -import type { Root } from '../ink.js'; -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; -import { AppStateProvider } from '../state/AppState.js'; -import { getMcpConfigsByScope } from './mcp/config.js'; -import { getProjectMcpServerStatus } from './mcp/utils.js'; +import React from 'react' +import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js' +import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js' +import type { Root } from '../ink.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../state/AppState.js' +import { getMcpConfigsByScope } from './mcp/config.js' +import { getProjectMcpServerStatus } from './mcp/utils.js' /** * Show MCP server approval dialogs for pending project servers. @@ -13,28 +13,37 @@ import { getProjectMcpServerStatus } from './mcp/utils.js'; * from main.tsx instead of creating a separate one). */ export async function handleMcpjsonServerApprovals(root: Root): Promise { - const { - servers: projectServers - } = getMcpConfigsByScope('project'); - const pendingServers = Object.keys(projectServers).filter(serverName => getProjectMcpServerStatus(serverName) === 'pending'); + const { servers: projectServers } = getMcpConfigsByScope('project') + const pendingServers = Object.keys(projectServers).filter( + serverName => getProjectMcpServerStatus(serverName) === 'pending', + ) + if (pendingServers.length === 0) { - return; + return } + await new Promise(resolve => { - const done = (): void => void resolve(); + const done = (): void => void resolve() if (pendingServers.length === 1 && pendingServers[0] !== undefined) { - const serverName = pendingServers[0]; - root.render( + const serverName = pendingServers[0] + root.render( + - ); + , + ) } else { - root.render( + root.render( + - + - ); + , + ) } - }); + }) } diff --git a/src/services/remoteManagedSettings/securityCheck.tsx b/src/services/remoteManagedSettings/securityCheck.tsx index c622481e1..857103408 100644 --- a/src/services/remoteManagedSettings/securityCheck.tsx +++ b/src/services/remoteManagedSettings/securityCheck.tsx @@ -1,15 +1,20 @@ -import React from 'react'; -import { getIsInteractive } from '../../bootstrap/state.js'; -import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js'; -import { extractDangerousSettings, hasDangerousSettings, hasDangerousSettingsChanged } from '../../components/ManagedSettingsSecurityDialog/utils.js'; -import { render } from '../../ink.js'; -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; -import { AppStateProvider } from '../../state/AppState.js'; -import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; -import { getBaseRenderOptions } from '../../utils/renderOptions.js'; -import type { SettingsJson } from '../../utils/settings/types.js'; -import { logEvent } from '../analytics/index.js'; -export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'; +import React from 'react' +import { getIsInteractive } from '../../bootstrap/state.js' +import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js' +import { + extractDangerousSettings, + hasDangerousSettings, + hasDangerousSettingsChanged, +} from '../../components/ManagedSettingsSecurityDialog/utils.js' +import { render } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../../state/AppState.js' +import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js' +import { getBaseRenderOptions } from '../../utils/renderOptions.js' +import type { SettingsJson } from '../../utils/settings/types.js' +import { logEvent } from '../analytics/index.js' + +export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed' /** * Check if new remote managed settings contain dangerous settings that require user approval. @@ -19,55 +24,68 @@ export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'; * @param newSettings The new settings fetched from the API * @returns 'approved' if user accepts, 'rejected' if user declines, 'no_check_needed' if no dangerous changes */ -export async function checkManagedSettingsSecurity(cachedSettings: SettingsJson | null, newSettings: SettingsJson | null): Promise { +export async function checkManagedSettingsSecurity( + cachedSettings: SettingsJson | null, + newSettings: SettingsJson | null, +): Promise { // If new settings don't have dangerous settings, no check needed - if (!newSettings || !hasDangerousSettings(extractDangerousSettings(newSettings))) { - return 'no_check_needed'; + if ( + !newSettings || + !hasDangerousSettings(extractDangerousSettings(newSettings)) + ) { + return 'no_check_needed' } // If dangerous settings haven't changed, no check needed if (!hasDangerousSettingsChanged(cachedSettings, newSettings)) { - return 'no_check_needed'; + return 'no_check_needed' } // Skip dialog in non-interactive mode (consistent with trust dialog behavior) if (!getIsInteractive()) { - return 'no_check_needed'; + return 'no_check_needed' } // Log that dialog is being shown - logEvent('tengu_managed_settings_security_dialog_shown', {}); + logEvent('tengu_managed_settings_security_dialog_shown', {}) // Show blocking dialog return new Promise(resolve => { void (async () => { - const { - unmount - } = await render( + const { unmount } = await render( + - { - logEvent('tengu_managed_settings_security_dialog_accepted', {}); - unmount(); - void resolve('approved'); - }} onReject={() => { - logEvent('tengu_managed_settings_security_dialog_rejected', {}); - unmount(); - void resolve('rejected'); - }} /> + { + logEvent('tengu_managed_settings_security_dialog_accepted', {}) + unmount() + void resolve('approved') + }} + onReject={() => { + logEvent('tengu_managed_settings_security_dialog_rejected', {}) + unmount() + void resolve('rejected') + }} + /> - , getBaseRenderOptions(false)); - })(); - }); + , + getBaseRenderOptions(false), + ) + })() + }) } /** * Handle the security check result by exiting if rejected * Returns true if we should continue, false if we should stop */ -export function handleSecurityCheckResult(result: SecurityCheckResult): boolean { +export function handleSecurityCheckResult( + result: SecurityCheckResult, +): boolean { if (result === 'rejected') { - gracefulShutdownSync(1); - return false; + gracefulShutdownSync(1) + return false } - return true; + return true } diff --git a/src/state/AppState.tsx b/src/state/AppState.tsx index e5b01fb26..783170cc3 100644 --- a/src/state/AppState.tsx +++ b/src/state/AppState.tsx @@ -1,126 +1,134 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react'; -import { MailboxProvider } from '../context/mailbox.js'; -import { useSettingsChange } from '../hooks/useSettingsChange.js'; -import { logForDebugging } from '../utils/debug.js'; -import { createDisabledBypassPermissionsContext, isBypassPermissionsModeDisabled } from '../utils/permissions/permissionSetup.js'; -import { applySettingsChange } from '../utils/settings/applySettingsChange.js'; -import type { SettingSource } from '../utils/settings/constants.js'; -import { createStore } from './store.js'; +import { feature } from 'bun:bundle' +import React, { + useContext, + useEffect, + useEffectEvent, + useState, + useSyncExternalStore, +} from 'react' +import { MailboxProvider } from '../context/mailbox.js' +import { useSettingsChange } from '../hooks/useSettingsChange.js' +import { logForDebugging } from '../utils/debug.js' +import { + createDisabledBypassPermissionsContext, + isBypassPermissionsModeDisabled, +} from '../utils/permissions/permissionSetup.js' +import { applySettingsChange } from '../utils/settings/applySettingsChange.js' +import type { SettingSource } from '../utils/settings/constants.js' +import { createStore } from './store.js' // DCE: voice context is ant-only. External builds get a passthrough. /* eslint-disable @typescript-eslint/no-require-imports */ -const VoiceProvider: (props: { - children: React.ReactNode; -}) => React.ReactNode = feature('VOICE_MODE') ? require('../context/voice.js').VoiceProvider : ({ - children -}) => children; +const VoiceProvider: (props: { children: React.ReactNode }) => React.ReactNode = + feature('VOICE_MODE') + ? require('../context/voice.js').VoiceProvider + : ({ children }) => children /* eslint-enable @typescript-eslint/no-require-imports */ -import { type AppState, type AppStateStore, getDefaultAppState } from './AppStateStore.js'; +import { + type AppState, + type AppStateStore, + getDefaultAppState, +} from './AppStateStore.js' // TODO: Remove these re-exports once all callers import directly from // ./AppStateStore.js. Kept for back-compat during migration so .ts callers // can incrementally move off the .tsx import and stop pulling React. -export { type AppState, type AppStateStore, type CompletionBoundary, getDefaultAppState, IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState } from './AppStateStore.js'; -export const AppStoreContext = React.createContext(null); +export { + type AppState, + type AppStateStore, + type CompletionBoundary, + getDefaultAppState, + IDLE_SPECULATION_STATE, + type SpeculationResult, + type SpeculationState, +} from './AppStateStore.js' + +export const AppStoreContext = React.createContext(null) + type Props = { - children: React.ReactNode; - initialState?: AppState; - onChangeAppState?: (args: { - newState: AppState; - oldState: AppState; - }) => void; -}; -const HasAppStateContext = React.createContext(false); -export function AppStateProvider(t0) { - const $ = _c(13); - const { - children, - initialState, - onChangeAppState - } = t0; - const hasAppStateContext = useContext(HasAppStateContext); + children: React.ReactNode + initialState?: AppState + onChangeAppState?: (args: { newState: AppState; oldState: AppState }) => void +} + +const HasAppStateContext = React.createContext(false) + +export function AppStateProvider({ + children, + initialState, + onChangeAppState, +}: Props): React.ReactNode { + // Don't allow nested AppStateProviders. + const hasAppStateContext = useContext(HasAppStateContext) if (hasAppStateContext) { - throw new Error("AppStateProvider can not be nested within another AppStateProvider"); - } - let t1; - if ($[0] !== initialState || $[1] !== onChangeAppState) { - t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState); - $[0] = initialState; - $[1] = onChangeAppState; - $[2] = t1; - } else { - t1 = $[2]; + throw new Error( + 'AppStateProvider can not be nested within another AppStateProvider', + ) } - const [store] = useState(t1); - let t2; - if ($[3] !== store) { - t2 = () => { - const { - toolPermissionContext - } = store.getState(); - if (toolPermissionContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) { - logForDebugging("Disabling bypass permissions mode on mount (remote settings loaded before mount)"); - store.setState(_temp); - } - }; - $[3] = store; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = []; - $[5] = t3; - } else { - t3 = $[5]; - } - useEffect(t2, t3); - let t4; - if ($[6] !== store.setState) { - t4 = source => applySettingsChange(source, store.setState); - $[6] = store.setState; - $[7] = t4; - } else { - t4 = $[7]; - } - const onSettingsChange = useEffectEvent(t4); - useSettingsChange(onSettingsChange); - let t5; - if ($[8] !== children) { - t5 = {children}; - $[8] = children; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== store || $[11] !== t5) { - t6 = {t5}; - $[10] = store; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; -} -function _temp(prev) { - return { - ...prev, - toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext) - }; + + // Store is created once and never changes -- stable context value means + // the provider never triggers re-renders. Consumers subscribe to slices + // via useSyncExternalStore in useAppState(selector). + const [store] = useState(() => + createStore( + initialState ?? getDefaultAppState(), + onChangeAppState, + ), + ) + + // Check on mount if bypass mode should be disabled + // This handles the race condition where remote settings load BEFORE this component mounts, + // meaning the settings change notification was sent when no listeners were subscribed. + // On subsequent sessions, the cached remote-settings.json is read during initial setup, + // but on the first session the remote fetch may complete before React mounts. + useEffect(() => { + const { toolPermissionContext } = store.getState() + if ( + toolPermissionContext.isBypassPermissionsModeAvailable && + isBypassPermissionsModeDisabled() + ) { + logForDebugging( + 'Disabling bypass permissions mode on mount (remote settings loaded before mount)', + ) + store.setState(prev => ({ + ...prev, + toolPermissionContext: createDisabledBypassPermissionsContext( + prev.toolPermissionContext, + ), + })) + } + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect + }, []) + + // Listen for external settings changes and sync to AppState. + // This ensures file watcher changes propagate through the app -- + // shared with the headless/SDK path via applySettingsChange. + const onSettingsChange = useEffectEvent((source: SettingSource) => + applySettingsChange(source, store.setState), + ) + useSettingsChange(onSettingsChange) + + return ( + + + + {children} + + + + ) } + function useAppStore(): AppStateStore { // eslint-disable-next-line react-hooks/rules-of-hooks - const store = useContext(AppStoreContext); + const store = useContext(AppStoreContext) if (!store) { - throw new ReferenceError('useAppState/useSetAppState cannot be called outside of an '); + throw new ReferenceError( + 'useAppState/useSetAppState cannot be called outside of an ', + ) } - return store; + return store } /** @@ -139,27 +147,23 @@ function useAppStore(): AppStateStore { * const { text, promptId } = useAppState(s => s.promptSuggestion) // good * ``` */ -export function useAppState(selector: (state: AppState) => R): R { - const $ = _c(3); - const store = useAppStore(); - let t0; - if ($[0] !== selector || $[1] !== store) { - t0 = () => { - const state = store.getState(); - const selected = selector(state); - if (false && state === selected) { - throw new Error(`Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`); - } - return selected; - }; - $[0] = selector; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; +export function useAppState(selector: (state: AppState) => T): T { + const store = useAppStore() + + const get = () => { + const state = store.getState() + const selected = selector(state) + + if (process.env.USER_TYPE === 'ant' && state === selected) { + throw new Error( + `Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`, + ) + } + + return selected } - const get = t0; - return useSyncExternalStore(store.subscribe, get, get); + + return useSyncExternalStore(store.subscribe, get, get) } /** @@ -167,33 +171,30 @@ export function useAppState(selector: (state: AppState) => R): R { * Returns a stable reference that never changes -- components using only * this hook will never re-render from state changes. */ -export function useSetAppState() { - return useAppStore().setState; +export function useSetAppState(): ( + updater: (prev: AppState) => AppState, +) => void { + return useAppStore().setState } /** * Get the store directly (for passing getState/setState to non-React code). */ -export function useAppStateStore() { - return useAppStore(); +export function useAppStateStore(): AppStateStore { + return useAppStore() } -const NOOP_SUBSCRIBE = () => () => {}; + +const NOOP_SUBSCRIBE = () => () => {} /** * Safe version of useAppState that returns undefined if called outside of AppStateProvider. * Useful for components that may be rendered in contexts where AppStateProvider isn't available. */ -export function useAppStateMaybeOutsideOfProvider(selector: (state: AppState) => R): R | undefined { - const $ = _c(3); - const store = useContext(AppStoreContext); - let t0; - if ($[0] !== selector || $[1] !== store) { - t0 = () => store ? selector(store.getState()) : undefined; - $[0] = selector; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, t0); +export function useAppStateMaybeOutsideOfProvider( + selector: (state: AppState) => T, +): T | undefined { + const store = useContext(AppStoreContext) + return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, () => + store ? selector(store.getState()) : undefined, + ) } diff --git a/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx b/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx index 6c78aa032..0fbdc1052 100644 --- a/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +++ b/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx @@ -9,14 +9,19 @@ * 4. Can be idle (waiting for work) or active (processing) */ -import { isTerminalTaskStatus, type SetAppState, type Task, type TaskStateBase } from '../../Task.js'; -import type { Message } from '../../types/message.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { createUserMessage } from '../../utils/messages.js'; -import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js'; -import { updateTaskState } from '../../utils/task/framework.js'; -import type { InProcessTeammateTaskState } from './types.js'; -import { appendCappedMessage, isInProcessTeammateTask } from './types.js'; +import { + isTerminalTaskStatus, + type SetAppState, + type Task, + type TaskStateBase, +} from '../../Task.js' +import type { Message } from '../../types/message.js' +import { logForDebugging } from '../../utils/debug.js' +import { createUserMessage } from '../../utils/messages.js' +import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js' +import { updateTaskState } from '../../utils/task/framework.js' +import type { InProcessTeammateTaskState } from './types.js' +import { appendCappedMessage, isInProcessTeammateTask } from './types.js' /** * InProcessTeammateTask - Handles in-process teammate execution. @@ -25,39 +30,48 @@ export const InProcessTeammateTask: Task = { name: 'InProcessTeammateTask', type: 'in_process_teammate', async kill(taskId, setAppState) { - killInProcessTeammate(taskId, setAppState); - } -}; + killInProcessTeammate(taskId, setAppState) + }, +} /** * Request shutdown for a teammate. */ -export function requestTeammateShutdown(taskId: string, setAppState: SetAppState): void { +export function requestTeammateShutdown( + taskId: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running' || task.shutdownRequested) { - return task; + return task } + return { ...task, - shutdownRequested: true - }; - }); + shutdownRequested: true, + } + }) } /** * Append a message to a teammate's conversation history. * Used for zoomed view to show the teammate's conversation. */ -export function appendTeammateMessage(taskId: string, message: Message, setAppState: SetAppState): void { +export function appendTeammateMessage( + taskId: string, + message: Message, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } + return { ...task, - messages: appendCappedMessage(task.messages, message) - }; - }); + messages: appendCappedMessage(task.messages, message), + } + }) } /** @@ -65,22 +79,30 @@ export function appendTeammateMessage(taskId: string, message: Message, setAppSt * Used when viewing a teammate's transcript to send typed messages to them. * Also adds the message to task.messages so it appears immediately in the transcript. */ -export function injectUserMessageToTeammate(taskId: string, message: string, setAppState: SetAppState): void { +export function injectUserMessageToTeammate( + taskId: string, + message: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { // Allow message injection when teammate is running or idle (waiting for input) // Only reject if teammate is in a terminal state if (isTerminalTaskStatus(task.status)) { - logForDebugging(`Dropping message for teammate task ${taskId}: task status is "${task.status}"`); - return task; + logForDebugging( + `Dropping message for teammate task ${taskId}: task status is "${task.status}"`, + ) + return task } + return { ...task, pendingUserMessages: [...task.pendingUserMessages, message], - messages: appendCappedMessage(task.messages, createUserMessage({ - content: message - })) - }; - }); + messages: appendCappedMessage( + task.messages, + createUserMessage({ content: message }), + ), + } + }) } /** @@ -89,29 +111,34 @@ export function injectUserMessageToTeammate(taskId: string, message: string, set * with the same agentId exist. * Returns undefined if not found. */ -export function findTeammateTaskByAgentId(agentId: string, tasks: Record): InProcessTeammateTaskState | undefined { - let fallback: InProcessTeammateTaskState | undefined; +export function findTeammateTaskByAgentId( + agentId: string, + tasks: Record, +): InProcessTeammateTaskState | undefined { + let fallback: InProcessTeammateTaskState | undefined for (const task of Object.values(tasks)) { if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) { // Prefer running tasks in case old killed tasks still exist in AppState // alongside new running ones with the same agentId if (task.status === 'running') { - return task; + return task } // Keep first match as fallback in case no running task exists if (!fallback) { - fallback = task; + fallback = task } } } - return fallback; + return fallback } /** * Get all in-process teammate tasks from AppState. */ -export function getAllInProcessTeammateTasks(tasks: Record): InProcessTeammateTaskState[] { - return Object.values(tasks).filter(isInProcessTeammateTask); +export function getAllInProcessTeammateTasks( + tasks: Record, +): InProcessTeammateTaskState[] { + return Object.values(tasks).filter(isInProcessTeammateTask) } /** @@ -120,6 +147,10 @@ export function getAllInProcessTeammateTasks(tasks: Record): InProcessTeammateTaskState[] { - return getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running').sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)); +export function getRunningTeammatesSorted( + tasks: Record, +): InProcessTeammateTaskState[] { + return getAllInProcessTeammateTasks(tasks) + .filter(t => t.status === 'running') + .sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)) } diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index 2eb9d6f9d..af26854c0 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -1,62 +1,89 @@ -import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; -import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG, WORKTREE_BRANCH_TAG, WORKTREE_PATH_TAG, WORKTREE_TAG } from '../../constants/xml.js'; -import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; -import type { AppState } from '../../state/AppState.js'; -import type { SetAppState, Task, TaskStateBase } from '../../Task.js'; -import { createTaskStateBase } from '../../Task.js'; -import type { Tools } from '../../Tool.js'; -import { findToolByName } from '../../Tool.js'; -import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js'; -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; -import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js'; -import { asAgentId } from '../../types/ids.js'; -import type { Message } from '../../types/message.js'; -import { createAbortController, createChildAbortController } from '../../utils/abortController.js'; -import { registerCleanup } from '../../utils/cleanupRegistry.js'; -import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js'; -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; -import { getAgentTranscriptPath } from '../../utils/sessionStorage.js'; -import { evictTaskOutput, getTaskOutputPath, initTaskOutputAsSymlink } from '../../utils/task/diskOutput.js'; -import { PANEL_GRACE_MS, registerTask, updateTaskState } from '../../utils/task/framework.js'; -import { emitTaskProgress } from '../../utils/task/sdkProgress.js'; -import type { TaskState } from '../types.js'; +import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js' +import { + OUTPUT_FILE_TAG, + STATUS_TAG, + SUMMARY_TAG, + TASK_ID_TAG, + TASK_NOTIFICATION_TAG, + TOOL_USE_ID_TAG, + WORKTREE_BRANCH_TAG, + WORKTREE_PATH_TAG, + WORKTREE_TAG, +} from '../../constants/xml.js' +import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js' +import type { AppState } from '../../state/AppState.js' +import type { SetAppState, Task, TaskStateBase } from '../../Task.js' +import { createTaskStateBase } from '../../Task.js' +import type { Tools } from '../../Tool.js' +import { findToolByName } from '../../Tool.js' +import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' +import { asAgentId } from '../../types/ids.js' +import type { Message } from '../../types/message.js' +import { + createAbortController, + createChildAbortController, +} from '../../utils/abortController.js' +import { registerCleanup } from '../../utils/cleanupRegistry.js' +import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js' +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' +import { getAgentTranscriptPath } from '../../utils/sessionStorage.js' +import { + evictTaskOutput, + getTaskOutputPath, + initTaskOutputAsSymlink, +} from '../../utils/task/diskOutput.js' +import { + PANEL_GRACE_MS, + registerTask, + updateTaskState, +} from '../../utils/task/framework.js' +import { emitTaskProgress } from '../../utils/task/sdkProgress.js' +import type { TaskState } from '../types.js' + export type ToolActivity = { - toolName: string; - input: Record; + toolName: string + input: Record /** Pre-computed activity description from the tool, e.g. "Reading src/foo.ts" */ - activityDescription?: string; + activityDescription?: string /** Pre-computed: true if this is a search operation (Grep, Glob, etc.) */ - isSearch?: boolean; + isSearch?: boolean /** Pre-computed: true if this is a read operation (Read, cat, etc.) */ - isRead?: boolean; -}; + isRead?: boolean +} + export type AgentProgress = { - toolUseCount: number; - tokenCount: number; - lastActivity?: ToolActivity; - recentActivities?: ToolActivity[]; - summary?: string; -}; -const MAX_RECENT_ACTIVITIES = 5; + toolUseCount: number + tokenCount: number + lastActivity?: ToolActivity + recentActivities?: ToolActivity[] + summary?: string +} + +const MAX_RECENT_ACTIVITIES = 5 + export type ProgressTracker = { - toolUseCount: number; + toolUseCount: number // Track input and output separately to avoid double-counting. // input_tokens in Claude API is cumulative per turn (includes all previous context), // so we keep the latest value. output_tokens is per-turn, so we sum those. - latestInputTokens: number; - cumulativeOutputTokens: number; - recentActivities: ToolActivity[]; -}; + latestInputTokens: number + cumulativeOutputTokens: number + recentActivities: ToolActivity[] +} + export function createProgressTracker(): ProgressTracker { return { toolUseCount: 0, latestInputTokens: 0, cumulativeOutputTokens: 0, - recentActivities: [] - }; + recentActivities: [], + } } + export function getTokenCountFromTracker(tracker: ProgressTracker): number { - return tracker.latestInputTokens + tracker.cumulativeOutputTokens; + return tracker.latestInputTokens + tracker.cumulativeOutputTokens } /** @@ -64,91 +91,120 @@ export function getTokenCountFromTracker(tracker: ProgressTracker): number { * for a given tool name and input. Used to pre-compute descriptions * from Tool.getActivityDescription() at recording time. */ -export type ActivityDescriptionResolver = (toolName: string, input: Record) => string | undefined; -export function updateProgressFromMessage(tracker: ProgressTracker, message: Message, resolveActivityDescription?: ActivityDescriptionResolver, tools?: Tools): void { +export type ActivityDescriptionResolver = ( + toolName: string, + input: Record, +) => string | undefined + +export function updateProgressFromMessage( + tracker: ProgressTracker, + message: Message, + resolveActivityDescription?: ActivityDescriptionResolver, + tools?: Tools, +): void { if (message.type !== 'assistant') { - return; + return } - const usage = message.message.usage as { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }; + const usage = message.message.usage // Keep latest input (it's cumulative in the API), sum outputs - tracker.latestInputTokens = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); - tracker.cumulativeOutputTokens += usage.output_tokens; - const contentBlocks = message.message.content as Array<{ type: string; name?: string; input?: unknown }>; - for (const content of contentBlocks) { + tracker.latestInputTokens = + usage.input_tokens + + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + tracker.cumulativeOutputTokens += usage.output_tokens + for (const content of message.message.content) { if (content.type === 'tool_use') { - tracker.toolUseCount++; + tracker.toolUseCount++ // Omit StructuredOutput from preview - it's an internal tool if (content.name !== SYNTHETIC_OUTPUT_TOOL_NAME) { - const input = content.input as Record; - const classification = tools ? getToolSearchOrReadInfo(content.name!, input, tools) : undefined; + const input = content.input as Record + const classification = tools + ? getToolSearchOrReadInfo(content.name, input, tools) + : undefined tracker.recentActivities.push({ - toolName: content.name!, + toolName: content.name, input, - activityDescription: resolveActivityDescription?.(content.name!, input), + activityDescription: resolveActivityDescription?.( + content.name, + input, + ), isSearch: classification?.isSearch, - isRead: classification?.isRead - }); + isRead: classification?.isRead, + }) } } } while (tracker.recentActivities.length > MAX_RECENT_ACTIVITIES) { - tracker.recentActivities.shift(); + tracker.recentActivities.shift() } } + export function getProgressUpdate(tracker: ProgressTracker): AgentProgress { return { toolUseCount: tracker.toolUseCount, tokenCount: getTokenCountFromTracker(tracker), - lastActivity: tracker.recentActivities.length > 0 ? tracker.recentActivities[tracker.recentActivities.length - 1] : undefined, - recentActivities: [...tracker.recentActivities] - }; + lastActivity: + tracker.recentActivities.length > 0 + ? tracker.recentActivities[tracker.recentActivities.length - 1] + : undefined, + recentActivities: [...tracker.recentActivities], + } } /** * Creates an ActivityDescriptionResolver from a tools list. * Looks up the tool by name and calls getActivityDescription if available. */ -export function createActivityDescriptionResolver(tools: Tools): ActivityDescriptionResolver { +export function createActivityDescriptionResolver( + tools: Tools, +): ActivityDescriptionResolver { return (toolName, input) => { - const tool = findToolByName(tools, toolName); - return tool?.getActivityDescription?.(input) ?? undefined; - }; + const tool = findToolByName(tools, toolName) + return tool?.getActivityDescription?.(input) ?? undefined + } } + export type LocalAgentTaskState = TaskStateBase & { - type: 'local_agent'; - agentId: string; - prompt: string; - selectedAgent?: AgentDefinition; - agentType: string; - model?: string; - abortController?: AbortController; - unregisterCleanup?: () => void; - error?: string; - result?: AgentToolResult; - progress?: AgentProgress; - retrieved: boolean; - messages?: Message[]; + type: 'local_agent' + agentId: string + prompt: string + selectedAgent?: AgentDefinition + agentType: string + model?: string + abortController?: AbortController + unregisterCleanup?: () => void + error?: string + result?: AgentToolResult + progress?: AgentProgress + retrieved: boolean + messages?: Message[] // Track what we last reported for computing deltas - lastReportedToolCount: number; - lastReportedTokenCount: number; + lastReportedToolCount: number + lastReportedTokenCount: number // Whether the task has been backgrounded (false = foreground running, true = backgrounded) - isBackgrounded: boolean; + isBackgrounded: boolean // Messages queued mid-turn via SendMessage, drained at tool-round boundaries - pendingMessages: string[]; + pendingMessages: string[] // UI is holding this task: blocks eviction, enables stream-append, triggers // disk bootstrap. Set by enterTeammateView. Separate from viewingAgentTaskId // (which is "what am I LOOKING at") — retain is "what am I HOLDING." - retain: boolean; + retain: boolean // Bootstrap has read the sidechain JSONL and UUID-merged into messages. // One-shot per retain cycle; stream appends from there. - diskLoaded: boolean; + diskLoaded: boolean // Panel visibility deadline. undefined = no deadline (running or retained); // timestamp = hide + GC-eligible after this time. Set at terminal transition // and on unselect; cleared on retain. - evictAfter?: number; -}; + evictAfter?: number +} + export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { - return typeof task === 'object' && task !== null && 'type' in task && task.type === 'local_agent'; + return ( + typeof task === 'object' && + task !== null && + 'type' in task && + task.type === 'local_agent' + ) } /** @@ -158,13 +214,18 @@ export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { * the gate changes, change it here. */ export function isPanelAgentTask(t: unknown): t is LocalAgentTaskState { - return isLocalAgentTask(t) && t.agentType !== 'main-session'; + return isLocalAgentTask(t) && t.agentType !== 'main-session' } -export function queuePendingMessage(taskId: string, msg: string, setAppState: (f: (prev: AppState) => AppState) => void): void { + +export function queuePendingMessage( + taskId: string, + msg: string, + setAppState: (f: (prev: AppState) => AppState) => void, +): void { updateTaskState(taskId, setAppState, task => ({ ...task, - pendingMessages: [...task.pendingMessages, msg] - })); + pendingMessages: [...task.pendingMessages, msg], + })) } /** @@ -173,23 +234,32 @@ export function queuePendingMessage(taskId: string, msg: string, setAppState: (f * queuePendingMessage and resumeAgentBackground route the prompt to the * agent's API input but don't touch the display. */ -export function appendMessageToLocalAgent(taskId: string, message: Message, setAppState: (f: (prev: AppState) => AppState) => void): void { +export function appendMessageToLocalAgent( + taskId: string, + message: Message, + setAppState: (f: (prev: AppState) => AppState) => void, +): void { updateTaskState(taskId, setAppState, task => ({ ...task, - messages: [...(task.messages ?? []), message] - })); + messages: [...(task.messages ?? []), message], + })) } -export function drainPendingMessages(taskId: string, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): string[] { - const task = getAppState().tasks[taskId]; + +export function drainPendingMessages( + taskId: string, + getAppState: () => AppState, + setAppState: (f: (prev: AppState) => AppState) => void, +): string[] { + const task = getAppState().tasks[taskId] if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) { - return []; + return [] } - const drained = task.pendingMessages; + const drained = task.pendingMessages updateTaskState(taskId, setAppState, t => ({ ...t, - pendingMessages: [] - })); - return drained; + pendingMessages: [], + })) + return drained } /** @@ -205,61 +275,74 @@ export function enqueueAgentNotification({ usage, toolUseId, worktreePath, - worktreeBranch + worktreeBranch, }: { - taskId: string; - description: string; - status: 'completed' | 'failed' | 'killed'; - error?: string; - setAppState: SetAppState; - finalMessage?: string; + taskId: string + description: string + status: 'completed' | 'failed' | 'killed' + error?: string + setAppState: SetAppState + finalMessage?: string usage?: { - totalTokens: number; - toolUses: number; - durationMs: number; - }; - toolUseId?: string; - worktreePath?: string; - worktreeBranch?: string; + totalTokens: number + toolUses: number + durationMs: number + } + toolUseId?: string + worktreePath?: string + worktreeBranch?: string }): void { // Atomically check and set notified flag to prevent duplicate notifications. // If the task was already marked as notified (e.g., by TaskStopTool), skip // enqueueing to avoid sending redundant messages to the model. - let shouldEnqueue = false; + let shouldEnqueue = false updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } - shouldEnqueue = true; + shouldEnqueue = true return { ...task, - notified: true - }; - }); + notified: true, + } + }) + if (!shouldEnqueue) { - return; + return } // Abort any active speculation — background task state changed, so speculated // results may reference stale task output. The prompt suggestion text is // preserved; only the pre-computed response is discarded. - abortSpeculation(setAppState); - const summary = status === 'completed' ? `Agent "${description}" completed` : status === 'failed' ? `Agent "${description}" failed: ${error || 'Unknown error'}` : `Agent "${description}" was stopped`; - const outputPath = getTaskOutputPath(taskId); - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; - const resultSection = finalMessage ? `\n${finalMessage}` : ''; - const usageSection = usage ? `\n${usage.totalTokens}${usage.toolUses}${usage.durationMs}` : ''; - const worktreeSection = worktreePath ? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}` : ''}` : ''; + abortSpeculation(setAppState) + + const summary = + status === 'completed' + ? `Agent "${description}" completed` + : status === 'failed' + ? `Agent "${description}" failed: ${error || 'Unknown error'}` + : `Agent "${description}" was stopped` + + const outputPath = getTaskOutputPath(taskId) + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' + const resultSection = finalMessage ? `\n${finalMessage}` : '' + const usageSection = usage + ? `\n${usage.totalTokens}${usage.toolUses}${usage.durationMs}` + : '' + const worktreeSection = worktreePath + ? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}` : ''}` + : '' + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>${summary}${resultSection}${usageSection}${worktreeSection} -`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** @@ -271,23 +354,24 @@ export function enqueueAgentNotification({ export const LocalAgentTask: Task = { name: 'LocalAgentTask', type: 'local_agent', + async kill(taskId, setAppState) { - killAsyncAgent(taskId, setAppState); - } -}; + killAsyncAgent(taskId, setAppState) + }, +} /** * Kill an agent task. No-op if already killed/completed. */ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { - let killed = false; + let killed = false updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - killed = true; - task.abortController?.abort(); - task.unregisterCleanup?.(); + killed = true + task.abortController?.abort() + task.unregisterCleanup?.() return { ...task, status: 'killed', @@ -295,11 +379,11 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, unregisterCleanup: undefined, - selectedAgent: undefined - }; - }); + selectedAgent: undefined, + } + }) if (killed) { - void evictTaskOutput(taskId); + void evictTaskOutput(taskId) } } @@ -307,10 +391,13 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { * Kill all running agent tasks. * Used by ESC cancellation in coordinator mode to stop all subagents. */ -export function killAllRunningAgentTasks(tasks: Record, setAppState: SetAppState): void { +export function killAllRunningAgentTasks( + tasks: Record, + setAppState: SetAppState, +): void { for (const [taskId, task] of Object.entries(tasks)) { if (task.type === 'local_agent' && task.status === 'running') { - killAsyncAgent(taskId, setAppState); + killAsyncAgent(taskId, setAppState) } } } @@ -320,16 +407,19 @@ export function killAllRunningAgentTasks(tasks: Record, setAp * Used by chat:killAgents bulk kill to suppress per-agent async notifications * when a single aggregate message is sent instead. */ -export function markAgentsNotified(taskId: string, setAppState: SetAppState): void { +export function markAgentsNotified( + taskId: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } return { ...task, - notified: true - }; - }); + notified: true, + } + }) } /** @@ -337,64 +427,70 @@ export function markAgentsNotified(taskId: string, setAppState: SetAppState): vo * Preserves the existing summary field so that background summarization * results are not clobbered by progress updates from assistant messages. */ -export function updateAgentProgress(taskId: string, progress: AgentProgress, setAppState: SetAppState): void { +export function updateAgentProgress( + taskId: string, + progress: AgentProgress, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - const existingSummary = task.progress?.summary; + + const existingSummary = task.progress?.summary return { ...task, - progress: existingSummary ? { - ...progress, - summary: existingSummary - } : progress - }; - }); + progress: existingSummary + ? { ...progress, summary: existingSummary } + : progress, + } + }) } /** * Update the background summary for an agent task. * Called by the periodic summarization service to store a 1-2 sentence progress summary. */ -export function updateAgentSummary(taskId: string, summary: string, setAppState: SetAppState): void { +export function updateAgentSummary( + taskId: string, + summary: string, + setAppState: SetAppState, +): void { let captured: { - tokenCount: number; - toolUseCount: number; - startTime: number; - toolUseId: string | undefined; - } | null = null; + tokenCount: number + toolUseCount: number + startTime: number + toolUseId: string | undefined + } | null = null + updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } + captured = { tokenCount: task.progress?.tokenCount ?? 0, toolUseCount: task.progress?.toolUseCount ?? 0, startTime: task.startTime, - toolUseId: task.toolUseId - }; + toolUseId: task.toolUseId, + } + return { ...task, progress: { ...task.progress, toolUseCount: task.progress?.toolUseCount ?? 0, tokenCount: task.progress?.tokenCount ?? 0, - summary - } - }; - }); + summary, + }, + } + }) // Emit summary to SDK consumers (e.g. VS Code subagent panel). No-op in TUI. // Gate on the SDK option so coordinator-mode sessions without the flag don't // leak summary events to consumers who didn't opt in. if (captured && getSdkAgentProgressSummariesEnabled()) { - const { - tokenCount, - toolUseCount, - startTime, - toolUseId - } = captured; + const { tokenCount, toolUseCount, startTime, toolUseId } = captured emitTaskProgress({ taskId, toolUseId, @@ -402,21 +498,26 @@ export function updateAgentSummary(taskId: string, summary: string, setAppState: startTime, totalTokens: tokenCount, toolUses: toolUseCount, - summary - }); + summary, + }) } } /** * Complete an agent task with result. */ -export function completeAgentTask(result: AgentToolResult, setAppState: SetAppState): void { - const taskId = result.agentId; +export function completeAgentTask( + result: AgentToolResult, + setAppState: SetAppState, +): void { + const taskId = result.agentId updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - task.unregisterCleanup?.(); + + task.unregisterCleanup?.() + return { ...task, status: 'completed', @@ -425,22 +526,28 @@ export function completeAgentTask(result: AgentToolResult, setAppState: SetAppSt evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, unregisterCleanup: undefined, - selectedAgent: undefined - }; - }); - void evictTaskOutput(taskId); + selectedAgent: undefined, + } + }) + void evictTaskOutput(taskId) // Note: Notification is sent by AgentTool via enqueueAgentNotification } /** * Fail an agent task with error. */ -export function failAgentTask(taskId: string, error: string, setAppState: SetAppState): void { +export function failAgentTask( + taskId: string, + error: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - task.unregisterCleanup?.(); + + task.unregisterCleanup?.() + return { ...task, status: 'failed', @@ -449,10 +556,10 @@ export function failAgentTask(taskId: string, error: string, setAppState: SetApp evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, unregisterCleanup: undefined, - selectedAgent: undefined - }; - }); - void evictTaskOutput(taskId); + selectedAgent: undefined, + } + }) + void evictTaskOutput(taskId) // Note: Notification is sent by AgentTool via enqueueAgentNotification } @@ -471,20 +578,26 @@ export function registerAsyncAgent({ selectedAgent, setAppState, parentAbortController, - toolUseId + toolUseId, }: { - agentId: string; - description: string; - prompt: string; - selectedAgent: AgentDefinition; - setAppState: SetAppState; - parentAbortController?: AbortController; - toolUseId?: string; + agentId: string + description: string + prompt: string + selectedAgent: AgentDefinition + setAppState: SetAppState + parentAbortController?: AbortController + toolUseId?: string }): LocalAgentTaskState { - void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); + void initTaskOutputAsSymlink( + agentId, + getAgentTranscriptPath(asAgentId(agentId)), + ) // Create abort controller - if parent provided, create child that auto-aborts with parent - const abortController = parentAbortController ? createChildAbortController(parentAbortController) : createAbortController(); + const abortController = parentAbortController + ? createChildAbortController(parentAbortController) + : createAbortController() + const taskState: LocalAgentTaskState = { ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), type: 'local_agent', @@ -497,27 +610,28 @@ export function registerAsyncAgent({ retrieved: false, lastReportedToolCount: 0, lastReportedTokenCount: 0, - isBackgrounded: true, - // registerAsyncAgent immediately backgrounds + isBackgrounded: true, // registerAsyncAgent immediately backgrounds pendingMessages: [], retain: false, - diskLoaded: false - }; + diskLoaded: false, + } // Register cleanup handler const unregisterCleanup = registerCleanup(async () => { - killAsyncAgent(agentId, setAppState); - }); - taskState.unregisterCleanup = unregisterCleanup; + killAsyncAgent(agentId, setAppState) + }) + + taskState.unregisterCleanup = unregisterCleanup // Register task in AppState - registerTask(taskState, setAppState); - return taskState; + registerTask(taskState, setAppState) + + return taskState } // Map of taskId -> resolve function for background signals // When backgroundAgentTask is called, it resolves the corresponding promise -const backgroundSignalResolvers = new Map void>(); +const backgroundSignalResolvers = new Map void>() /** * Register a foreground agent task that could be backgrounded later. @@ -531,25 +645,31 @@ export function registerAgentForeground({ selectedAgent, setAppState, autoBackgroundMs, - toolUseId + toolUseId, }: { - agentId: string; - description: string; - prompt: string; - selectedAgent: AgentDefinition; - setAppState: SetAppState; - autoBackgroundMs?: number; - toolUseId?: string; + agentId: string + description: string + prompt: string + selectedAgent: AgentDefinition + setAppState: SetAppState + autoBackgroundMs?: number + toolUseId?: string }): { - taskId: string; - backgroundSignal: Promise; - cancelAutoBackground?: () => void; + taskId: string + backgroundSignal: Promise + cancelAutoBackground?: () => void } { - void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); - const abortController = createAbortController(); + void initTaskOutputAsSymlink( + agentId, + getAgentTranscriptPath(asAgentId(agentId)), + ) + + const abortController = createAbortController() + const unregisterCleanup = registerCleanup(async () => { - killAsyncAgent(agentId, setAppState); - }); + killAsyncAgent(agentId, setAppState) + }) + const taskState: LocalAgentTaskState = { ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), type: 'local_agent', @@ -563,121 +683,122 @@ export function registerAgentForeground({ retrieved: false, lastReportedToolCount: 0, lastReportedTokenCount: 0, - isBackgrounded: false, - // Not yet backgrounded - running in foreground + isBackgrounded: false, // Not yet backgrounded - running in foreground pendingMessages: [], retain: false, - diskLoaded: false - }; + diskLoaded: false, + } // Create background signal promise - let resolveBackgroundSignal: () => void; + let resolveBackgroundSignal: () => void const backgroundSignal = new Promise(resolve => { - resolveBackgroundSignal = resolve; - }); - backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!); - registerTask(taskState, setAppState); + resolveBackgroundSignal = resolve + }) + backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!) + + registerTask(taskState, setAppState) // Auto-background after timeout if configured - let cancelAutoBackground: (() => void) | undefined; + let cancelAutoBackground: (() => void) | undefined if (autoBackgroundMs !== undefined && autoBackgroundMs > 0) { - const timer = setTimeout((setAppState, agentId) => { - // Mark task as backgrounded and resolve the signal - setAppState(prev => { - const prevTask = prev.tasks[agentId]; - if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) { - return prev; - } - return { - ...prev, - tasks: { - ...prev.tasks, - [agentId]: { - ...prevTask, - isBackgrounded: true - } + const timer = setTimeout( + (setAppState, agentId) => { + // Mark task as backgrounded and resolve the signal + setAppState(prev => { + const prevTask = prev.tasks[agentId] + if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) { + return prev } - }; - }); - const resolver = backgroundSignalResolvers.get(agentId); - if (resolver) { - resolver(); - backgroundSignalResolvers.delete(agentId); - } - }, autoBackgroundMs, setAppState, agentId); - cancelAutoBackground = () => clearTimeout(timer); + return { + ...prev, + tasks: { + ...prev.tasks, + [agentId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) + const resolver = backgroundSignalResolvers.get(agentId) + if (resolver) { + resolver() + backgroundSignalResolvers.delete(agentId) + } + }, + autoBackgroundMs, + setAppState, + agentId, + ) + cancelAutoBackground = () => clearTimeout(timer) } - return { - taskId: agentId, - backgroundSignal, - cancelAutoBackground - }; + + return { taskId: agentId, backgroundSignal, cancelAutoBackground } } /** * Background a specific foreground agent task. * @returns true if backgrounded successfully, false otherwise */ -export function backgroundAgentTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { - const state = getAppState(); - const task = state.tasks[taskId]; +export function backgroundAgentTask( + taskId: string, + getAppState: () => AppState, + setAppState: SetAppState, +): boolean { + const state = getAppState() + const task = state.tasks[taskId] if (!isLocalAgentTask(task) || task.isBackgrounded) { - return false; + return false } // Update state to mark as backgrounded setAppState(prev => { - const prevTask = prev.tasks[taskId]; + const prevTask = prev.tasks[taskId] if (!isLocalAgentTask(prevTask)) { - return prev; + return prev } return { ...prev, tasks: { ...prev.tasks, - [taskId]: { - ...prevTask, - isBackgrounded: true - } - } - }; - }); + [taskId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) // Resolve the background signal to interrupt the agent loop - const resolver = backgroundSignalResolvers.get(taskId); + const resolver = backgroundSignalResolvers.get(taskId) if (resolver) { - resolver(); - backgroundSignalResolvers.delete(taskId); + resolver() + backgroundSignalResolvers.delete(taskId) } - return true; + + return true } /** * Unregister a foreground agent task when the agent completes without being backgrounded. */ -export function unregisterAgentForeground(taskId: string, setAppState: SetAppState): void { +export function unregisterAgentForeground( + taskId: string, + setAppState: SetAppState, +): void { // Clean up the background signal resolver - backgroundSignalResolvers.delete(taskId); - let cleanupFn: (() => void) | undefined; + backgroundSignalResolvers.delete(taskId) + + let cleanupFn: (() => void) | undefined + setAppState(prev => { - const task = prev.tasks[taskId]; + const task = prev.tasks[taskId] // Only remove if it's a foreground task (not backgrounded) if (!isLocalAgentTask(task) || task.isBackgrounded) { - return prev; + return prev } // Capture cleanup function to call outside of updater - cleanupFn = task.unregisterCleanup; - const { - [taskId]: removed, - ...rest - } = prev.tasks; - return { - ...prev, - tasks: rest - }; - }); + cleanupFn = task.unregisterCleanup + + const { [taskId]: removed, ...rest } = prev.tasks + return { ...prev, tasks: rest } + }) // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.(); + cleanupFn?.() } diff --git a/src/tasks/LocalShellTask/LocalShellTask.tsx b/src/tasks/LocalShellTask/LocalShellTask.tsx index 595518275..22810bff1 100644 --- a/src/tasks/LocalShellTask/LocalShellTask.tsx +++ b/src/tasks/LocalShellTask/LocalShellTask.tsx @@ -1,83 +1,119 @@ -import { feature } from 'bun:bundle'; -import { stat } from 'fs/promises'; -import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG } from '../../constants/xml.js'; -import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; -import type { AppState } from '../../state/AppState.js'; -import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js'; -import { createTaskStateBase } from '../../Task.js'; -import type { AgentId } from '../../types/ids.js'; -import { registerCleanup } from '../../utils/cleanupRegistry.js'; -import { tailFile } from '../../utils/fsOperations.js'; -import { logError } from '../../utils/log.js'; -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; -import type { ShellCommand } from '../../utils/ShellCommand.js'; -import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { registerTask, updateTaskState } from '../../utils/task/framework.js'; -import { escapeXml } from '../../utils/xml.js'; -import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js'; -import { isMainSessionTask } from '../LocalMainSessionTask.js'; -import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js'; -import { killTask } from './killShellTasks.js'; +import { feature } from 'bun:bundle' +import { stat } from 'fs/promises' +import { + OUTPUT_FILE_TAG, + STATUS_TAG, + SUMMARY_TAG, + TASK_ID_TAG, + TASK_NOTIFICATION_TAG, + TOOL_USE_ID_TAG, +} from '../../constants/xml.js' +import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js' +import type { AppState } from '../../state/AppState.js' +import type { + LocalShellSpawnInput, + SetAppState, + Task, + TaskContext, + TaskHandle, +} from '../../Task.js' +import { createTaskStateBase } from '../../Task.js' +import type { AgentId } from '../../types/ids.js' +import { registerCleanup } from '../../utils/cleanupRegistry.js' +import { tailFile } from '../../utils/fsOperations.js' +import { logError } from '../../utils/log.js' +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' +import type { ShellCommand } from '../../utils/ShellCommand.js' +import { + evictTaskOutput, + getTaskOutputPath, +} from '../../utils/task/diskOutput.js' +import { registerTask, updateTaskState } from '../../utils/task/framework.js' +import { escapeXml } from '../../utils/xml.js' +import { + backgroundAgentTask, + isLocalAgentTask, +} from '../LocalAgentTask/LocalAgentTask.js' +import { isMainSessionTask } from '../LocalMainSessionTask.js' +import { + type BashTaskKind, + isLocalShellTask, + type LocalShellTaskState, +} from './guards.js' +import { killTask } from './killShellTasks.js' /** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */ -export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '; -const STALL_CHECK_INTERVAL_MS = 5_000; -const STALL_THRESHOLD_MS = 45_000; -const STALL_TAIL_BYTES = 1024; +export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command ' + +const STALL_CHECK_INTERVAL_MS = 5_000 +const STALL_THRESHOLD_MS = 45_000 +const STALL_TAIL_BYTES = 1024 // Last-line patterns that suggest a command is blocked waiting for keyboard // input. Used to gate the stall notification — we stay silent on commands that // are merely slow (git log -S, long builds) and only notify when the tail // looks like an interactive prompt the model can act on. See CC-1175. -const PROMPT_PATTERNS = [/\(y\/n\)/i, -// (Y/n), (y/N) -/\[y\/n\]/i, -// [Y/n], [y/N] -/\(yes\/no\)/i, /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, -// directed questions -/Press (any key|Enter)/i, /Continue\?/i, /Overwrite\?/i]; +const PROMPT_PATTERNS = [ + /\(y\/n\)/i, // (Y/n), (y/N) + /\[y\/n\]/i, // [Y/n], [y/N] + /\(yes\/no\)/i, + /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, // directed questions + /Press (any key|Enter)/i, + /Continue\?/i, + /Overwrite\?/i, +] + export function looksLikePrompt(tail: string): boolean { - const lastLine = tail.trimEnd().split('\n').pop() ?? ''; - return PROMPT_PATTERNS.some(p => p.test(lastLine)); + const lastLine = tail.trimEnd().split('\n').pop() ?? '' + return PROMPT_PATTERNS.some(p => p.test(lastLine)) } // Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot // notification if output stops growing and the tail looks like a prompt. -function startStallWatchdog(taskId: string, description: string, kind: BashTaskKind | undefined, toolUseId?: string, agentId?: AgentId): () => void { - if (kind === 'monitor') return () => {}; - const outputPath = getTaskOutputPath(taskId); - let lastSize = 0; - let lastGrowth = Date.now(); - let cancelled = false; +function startStallWatchdog( + taskId: string, + description: string, + kind: BashTaskKind | undefined, + toolUseId?: string, + agentId?: AgentId, +): () => void { + if (kind === 'monitor') return () => {} + const outputPath = getTaskOutputPath(taskId) + let lastSize = 0 + let lastGrowth = Date.now() + let cancelled = false + const timer = setInterval(() => { - void stat(outputPath).then(s => { - if (s.size > lastSize) { - lastSize = s.size; - lastGrowth = Date.now(); - return; - } - if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return; - void tailFile(outputPath, STALL_TAIL_BYTES).then(({ - content - }) => { - if (cancelled) return; - if (!looksLikePrompt(content)) { - // Not a prompt — keep watching. Reset so the next check is - // 45s out instead of re-reading the tail on every tick. - lastGrowth = Date.now(); - return; + void stat(outputPath).then( + s => { + if (s.size > lastSize) { + lastSize = s.size + lastGrowth = Date.now() + return } - // Latch before the async-boundary-visible side effects so an - // overlapping tick's callback sees cancelled=true and bails. - cancelled = true; - clearInterval(timer); - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; - const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`; - // No tag — print.ts treats as a terminal - // signal and an unknown value falls through to 'completed', - // falsely closing the task for SDK consumers. Statusless - // notifications are skipped by the SDK emitter (progress ping). - const message = `<${TASK_NOTIFICATION_TAG}> + if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return + void tailFile(outputPath, STALL_TAIL_BYTES).then( + ({ content }) => { + if (cancelled) return + if (!looksLikePrompt(content)) { + // Not a prompt — keep watching. Reset so the next check is + // 45s out instead of re-reading the tail on every tick. + lastGrowth = Date.now() + return + } + // Latch before the async-boundary-visible side effects so an + // overlapping tick's callback sees cancelled=true and bails. + cancelled = true + clearInterval(timer) + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' + const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input` + // No tag — print.ts treats as a terminal + // signal and an unknown value falls through to 'completed', + // falsely closing the task for SDK consumers. Statusless + // notifications are skipped by the SDK emitter (progress ping). + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${SUMMARY_TAG}>${escapeXml(summary)} @@ -85,47 +121,60 @@ function startStallWatchdog(taskId: string, description: string, kind: BashTaskK Last output: ${content.trimEnd()} -The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification', - priority: 'next', - agentId - }); - }, () => {}); - }, () => {} // File may not exist yet - ); - }, STALL_CHECK_INTERVAL_MS); - timer.unref(); +The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.` + enqueuePendingNotification({ + value: message, + mode: 'task-notification', + priority: 'next', + agentId, + }) + }, + () => {}, + ) + }, + () => {}, // File may not exist yet + ) + }, STALL_CHECK_INTERVAL_MS) + timer.unref() + return () => { - cancelled = true; - clearInterval(timer); - }; + cancelled = true + clearInterval(timer) + } } -function enqueueShellNotification(taskId: string, description: string, status: 'completed' | 'failed' | 'killed', exitCode: number | undefined, setAppState: SetAppState, toolUseId?: string, kind: BashTaskKind = 'bash', agentId?: AgentId): void { + +function enqueueShellNotification( + taskId: string, + description: string, + status: 'completed' | 'failed' | 'killed', + exitCode: number | undefined, + setAppState: SetAppState, + toolUseId?: string, + kind: BashTaskKind = 'bash', + agentId?: AgentId, +): void { // Atomically check and set notified flag to prevent duplicate notifications. // If the task was already marked as notified (e.g., by TaskStopTool), skip // enqueueing to avoid sending redundant messages to the model. - let shouldEnqueue = false; - updateTaskState(taskId, setAppState, task => { + let shouldEnqueue = false + updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } - shouldEnqueue = true; - return { - ...task, - notified: true - }; - }); + shouldEnqueue = true + return { ...task, notified: true } + }) + if (!shouldEnqueue) { - return; + return } // Abort any active speculation — background task state changed, so speculated // results may reference stale task output. The prompt suggestion text is // preserved; only the pre-computed response is discarded. - abortSpeculation(setAppState); - let summary: string; + abortSpeculation(setAppState) + + let summary: string if (feature('MONITOR_TOOL') && kind === 'monitor') { // Monitor is streaming-only (post-#22764) — the script exiting means // the stream ended, not "condition met". Distinct from the bash prefix @@ -133,73 +182,71 @@ function enqueueShellNotification(taskId: string, description: string, status: ' // completed" collapse. switch (status) { case 'completed': - summary = `Monitor "${description}" stream ended`; - break; + summary = `Monitor "${description}" stream ended` + break case 'failed': - summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`; - break; + summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}` + break case 'killed': - summary = `Monitor "${description}" stopped`; - break; + summary = `Monitor "${description}" stopped` + break } } else { switch (status) { case 'completed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`; - break; + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}` + break case 'failed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`; - break; + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}` + break case 'killed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`; - break; + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped` + break } } - const outputPath = getTaskOutputPath(taskId); - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; + + const outputPath = getTaskOutputPath(taskId) + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>${escapeXml(summary)} -`; +` + enqueuePendingNotification({ value: message, mode: 'task-notification', priority: feature('MONITOR_TOOL') ? 'next' : 'later', - agentId - }); + agentId, + }) } + export const LocalShellTask: Task = { name: 'LocalShellTask', type: 'local_bash', async kill(taskId, setAppState) { - killTask(taskId, setAppState); - } -}; -export async function spawnShellTask(input: LocalShellSpawnInput & { - shellCommand: ShellCommand; -}, context: TaskContext): Promise { - const { - command, - description, - shellCommand, - toolUseId, - agentId, - kind - } = input; - const { - setAppState - } = context; + killTask(taskId, setAppState) + }, +} + +export async function spawnShellTask( + input: LocalShellSpawnInput & { shellCommand: ShellCommand }, + context: TaskContext, +): Promise { + const { command, description, shellCommand, toolUseId, agentId, kind } = input + const { setAppState } = context // TaskOutput owns the data — use its taskId so disk writes are consistent - const { - taskOutput - } = shellCommand; - const taskId = taskOutput.taskId; + const { taskOutput } = shellCommand + const taskId = taskOutput.taskId + const unregisterCleanup = registerCleanup(async () => { - killTask(taskId, setAppState); - }); + killTask(taskId, setAppState) + }) + const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), type: 'local_bash', @@ -211,44 +258,64 @@ export async function spawnShellTask(input: LocalShellSpawnInput & { lastReportedTotalLines: 0, isBackgrounded: true, agentId, - kind - }; - registerTask(taskState, setAppState); + kind, + } + + registerTask(taskState, setAppState) // Data flows through TaskOutput automatically — no stream listeners needed. // Just transition to backgrounded state so the process keeps running. - shellCommand.background(taskId); - const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); + shellCommand.background(taskId) + + const cancelStallWatchdog = startStallWatchdog( + taskId, + description, + kind, + toolUseId, + agentId, + ) + void shellCommand.result.then(async result => { - cancelStallWatchdog(); - await flushAndCleanup(shellCommand); - let wasKilled = false; + cancelStallWatchdog() + await flushAndCleanup(shellCommand) + let wasKilled = false + updateTaskState(taskId, setAppState, task => { if (task.status === 'killed') { - wasKilled = true; - return task; + wasKilled = true + return task } + return { ...task, status: result.code === 0 ? 'completed' : 'failed', - result: { - code: result.code, - interrupted: result.interrupted - }, + result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, - endTime: Date.now() - }; - }); - enqueueShellNotification(taskId, description, wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', result.code, setAppState, toolUseId, kind, agentId); - void evictTaskOutput(taskId); - }); + endTime: Date.now(), + } + }) + + enqueueShellNotification( + taskId, + description, + wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', + result.code, + setAppState, + toolUseId, + kind, + agentId, + ) + + void evictTaskOutput(taskId) + }) + return { taskId, cleanup: () => { - unregisterCleanup(); - } - }; + unregisterCleanup() + }, + } } /** @@ -256,19 +323,19 @@ export async function spawnShellTask(input: LocalShellSpawnInput & { * Called when a bash command has been running long enough to show the BackgroundHint. * @returns taskId for the registered task */ -export function registerForeground(input: LocalShellSpawnInput & { - shellCommand: ShellCommand; -}, setAppState: SetAppState, toolUseId?: string): string { - const { - command, - description, - shellCommand, - agentId - } = input; - const taskId = shellCommand.taskOutput.taskId; +export function registerForeground( + input: LocalShellSpawnInput & { shellCommand: ShellCommand }, + setAppState: SetAppState, + toolUseId?: string, +): string { + const { command, description, shellCommand, agentId } = input + + const taskId = shellCommand.taskOutput.taskId + const unregisterCleanup = registerCleanup(async () => { - killTask(taskId, setAppState); - }); + killTask(taskId, setAppState) + }) + const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), type: 'local_bash', @@ -278,93 +345,119 @@ export function registerForeground(input: LocalShellSpawnInput & { shellCommand, unregisterCleanup, lastReportedTotalLines: 0, - isBackgrounded: false, - // Not yet backgrounded - running in foreground - agentId - }; - registerTask(taskState, setAppState); - return taskId; + isBackgrounded: false, // Not yet backgrounded - running in foreground + agentId, + } + + registerTask(taskState, setAppState) + return taskId } /** * Background a specific foreground task. * @returns true if backgrounded successfully, false otherwise */ -function backgroundTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { +function backgroundTask( + taskId: string, + getAppState: () => AppState, + setAppState: SetAppState, +): boolean { // Step 1: Get the task and shell command from current state - const state = getAppState(); - const task = state.tasks[taskId]; + const state = getAppState() + const task = state.tasks[taskId] if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) { - return false; + return false } - const shellCommand = task.shellCommand; - const description = task.description; - const { - toolUseId, - kind, - agentId - } = task; + + const shellCommand = task.shellCommand + const description = task.description + const { toolUseId, kind, agentId } = task // Transition to backgrounded — TaskOutput continues receiving data automatically if (!shellCommand.background(taskId)) { - return false; + return false } + setAppState(prev => { - const prevTask = prev.tasks[taskId]; + const prevTask = prev.tasks[taskId] if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { - return prev; + return prev } return { ...prev, tasks: { ...prev.tasks, - [taskId]: { - ...prevTask, - isBackgrounded: true - } - } - }; - }); - const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); + [taskId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) + + const cancelStallWatchdog = startStallWatchdog( + taskId, + description, + kind, + toolUseId, + agentId, + ) // Set up result handler void shellCommand.result.then(async result => { - cancelStallWatchdog(); - await flushAndCleanup(shellCommand); - let wasKilled = false; - let cleanupFn: (() => void) | undefined; + cancelStallWatchdog() + await flushAndCleanup(shellCommand) + let wasKilled = false + let cleanupFn: (() => void) | undefined + updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { - wasKilled = true; - return t; + wasKilled = true + return t } // Capture cleanup function to call outside of updater - cleanupFn = t.unregisterCleanup; + cleanupFn = t.unregisterCleanup + return { ...t, status: result.code === 0 ? 'completed' : 'failed', - result: { - code: result.code, - interrupted: result.interrupted - }, + result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, - endTime: Date.now() - }; - }); + endTime: Date.now(), + } + }) // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.(); + cleanupFn?.() + if (wasKilled) { - enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId); + enqueueShellNotification( + taskId, + description, + 'killed', + result.code, + setAppState, + toolUseId, + kind, + agentId, + ) } else { - const finalStatus = result.code === 0 ? 'completed' : 'failed'; - enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId); + const finalStatus = result.code === 0 ? 'completed' : 'failed' + enqueueShellNotification( + taskId, + description, + finalStatus, + result.code, + setAppState, + toolUseId, + kind, + agentId, + ) } - void evictTaskOutput(taskId); - }); - return true; + + void evictTaskOutput(taskId) + }) + + return true } /** @@ -378,34 +471,42 @@ function backgroundTask(taskId: string, getAppState: () => AppState, setAppState export function hasForegroundTasks(state: AppState): boolean { return Object.values(state.tasks).some(task => { if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) { - return true; + return true } // Exclude main session tasks - they display in the main view, not as foreground tasks - if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) { - return true; + if ( + isLocalAgentTask(task) && + !task.isBackgrounded && + !isMainSessionTask(task) + ) { + return true } - return false; - }); + return false + }) } -export function backgroundAll(getAppState: () => AppState, setAppState: SetAppState): void { - const state = getAppState(); + +export function backgroundAll( + getAppState: () => AppState, + setAppState: SetAppState, +): void { + const state = getAppState() // Background all foreground bash tasks const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => { - const task = state.tasks[id]; - return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand; - }); + const task = state.tasks[id] + return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand + }) for (const taskId of foregroundBashTaskIds) { - backgroundTask(taskId, getAppState, setAppState); + backgroundTask(taskId, getAppState, setAppState) } // Background all foreground agent tasks const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => { - const task = state.tasks[id]; - return isLocalAgentTask(task) && !task.isBackgrounded; - }); + const task = state.tasks[id] + return isLocalAgentTask(task) && !task.isBackgrounded + }) for (const taskId of foregroundAgentTaskIds) { - backgroundAgentTask(taskId, getAppState, setAppState); + backgroundAgentTask(taskId, getAppState, setAppState) } } @@ -417,60 +518,86 @@ export function backgroundAll(getAppState: () => AppState, setAppState: SetAppSt * already registered the task (avoiding duplicate task_started SDK events * and leaked cleanup callbacks). */ -export function backgroundExistingForegroundTask(taskId: string, shellCommand: ShellCommand, description: string, setAppState: SetAppState, toolUseId?: string): boolean { +export function backgroundExistingForegroundTask( + taskId: string, + shellCommand: ShellCommand, + description: string, + setAppState: SetAppState, + toolUseId?: string, +): boolean { if (!shellCommand.background(taskId)) { - return false; + return false } - let agentId: AgentId | undefined; + + let agentId: AgentId | undefined setAppState(prev => { - const prevTask = prev.tasks[taskId]; + const prevTask = prev.tasks[taskId] if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { - return prev; + return prev } - agentId = prevTask.agentId; + agentId = prevTask.agentId return { ...prev, tasks: { ...prev.tasks, - [taskId]: { - ...prevTask, - isBackgrounded: true - } - } - }; - }); - const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId); + [taskId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) + + const cancelStallWatchdog = startStallWatchdog( + taskId, + description, + undefined, + toolUseId, + agentId, + ) // Set up result handler (mirrors backgroundTask's handler) void shellCommand.result.then(async result => { - cancelStallWatchdog(); - await flushAndCleanup(shellCommand); - let wasKilled = false; - let cleanupFn: (() => void) | undefined; + cancelStallWatchdog() + await flushAndCleanup(shellCommand) + let wasKilled = false + let cleanupFn: (() => void) | undefined + updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { - wasKilled = true; - return t; + wasKilled = true + return t } - cleanupFn = t.unregisterCleanup; + cleanupFn = t.unregisterCleanup return { ...t, status: result.code === 0 ? 'completed' : 'failed', - result: { - code: result.code, - interrupted: result.interrupted - }, + result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, - endTime: Date.now() - }; - }); - cleanupFn?.(); - const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed'; - enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId); - void evictTaskOutput(taskId); - }); - return true; + endTime: Date.now(), + } + }) + + cleanupFn?.() + + const finalStatus = wasKilled + ? 'killed' + : result.code === 0 + ? 'completed' + : 'failed' + enqueueShellNotification( + taskId, + description, + finalStatus, + result.code, + setAppState, + toolUseId, + undefined, + agentId, + ) + + void evictTaskOutput(taskId) + }) + + return true } /** @@ -478,45 +605,47 @@ export function backgroundExistingForegroundTask(taskId: string, shellCommand: S * Used when backgrounding raced with completion — the tool result already * carries the full output, so the would be redundant. */ -export function markTaskNotified(taskId: string, setAppState: SetAppState): void { - updateTaskState(taskId, setAppState, t => t.notified ? t : { - ...t, - notified: true - }); +export function markTaskNotified( + taskId: string, + setAppState: SetAppState, +): void { + updateTaskState(taskId, setAppState, t => + t.notified ? t : { ...t, notified: true }, + ) } /** * Unregister a foreground task when the command completes without being backgrounded. */ -export function unregisterForeground(taskId: string, setAppState: SetAppState): void { - let cleanupFn: (() => void) | undefined; +export function unregisterForeground( + taskId: string, + setAppState: SetAppState, +): void { + let cleanupFn: (() => void) | undefined + setAppState(prev => { - const task = prev.tasks[taskId]; + const task = prev.tasks[taskId] // Only remove if it's a foreground task (not backgrounded) if (!isLocalShellTask(task) || task.isBackgrounded) { - return prev; + return prev } // Capture cleanup function to call outside of updater - cleanupFn = task.unregisterCleanup; - const { - [taskId]: removed, - ...rest - } = prev.tasks; - return { - ...prev, - tasks: rest - }; - }); + cleanupFn = task.unregisterCleanup + + const { [taskId]: removed, ...rest } = prev.tasks + return { ...prev, tasks: rest } + }) // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.(); + cleanupFn?.() } + async function flushAndCleanup(shellCommand: ShellCommand): Promise { try { - await shellCommand.taskOutput.flush(); - shellCommand.cleanup(); + await shellCommand.taskOutput.flush() + shellCommand.cleanup() } catch (error) { - logError(error); + logError(error) } } diff --git a/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx b/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx index 39f5ba3b2..8755837e4 100644 --- a/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx +++ b/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx @@ -1,106 +1,156 @@ -import type { ToolUseBlock } from '@anthropic-ai/sdk/resources'; -import { getRemoteSessionUrl } from '../../constants/product.js'; -import { OUTPUT_FILE_TAG, REMOTE_REVIEW_PROGRESS_TAG, REMOTE_REVIEW_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TASK_TYPE_TAG, TOOL_USE_ID_TAG, ULTRAPLAN_TAG } from '../../constants/xml.js'; -import type { SDKAssistantMessage, SDKMessage } from '../../entrypoints/agentSdkTypes.js'; -import type { SetAppState, Task, TaskContext, TaskStateBase } from '../../Task.js'; -import { createTaskStateBase, generateTaskId } from '../../Task.js'; -import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js'; -import { type BackgroundRemoteSessionPrecondition, checkBackgroundRemoteSessionEligibility } from '../../utils/background/remote/remoteSession.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { logError } from '../../utils/log.js'; -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; -import { extractTag, extractTextContent } from '../../utils/messages.js'; -import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js'; -import { deleteRemoteAgentMetadata, listRemoteAgentMetadata, type RemoteAgentMetadata, writeRemoteAgentMetadata } from '../../utils/sessionStorage.js'; -import { jsonStringify } from '../../utils/slowOperations.js'; -import { appendTaskOutput, evictTaskOutput, getTaskOutputPath, initTaskOutput } from '../../utils/task/diskOutput.js'; -import { registerTask, updateTaskState } from '../../utils/task/framework.js'; -import { fetchSession } from '../../utils/teleport/api.js'; -import { archiveRemoteSession, pollRemoteSessionEvents } from '../../utils/teleport.js'; -import type { TodoList } from '../../utils/todo/types.js'; -import type { UltraplanPhase } from '../../utils/ultraplan/ccrSession.js'; - -/** Helper to access the `message` property on SDK messages that use `[key: string]: unknown` index signatures. */ -type SDKMessageWithMessage = { message: { content: ContentBlockLike[] }; [key: string]: unknown }; -type ContentBlockLike = { type: string; text?: string; name?: string; input?: unknown; id?: string; [key: string]: unknown }; -/** Helper to access `stdout`/`subtype` on SDK system messages. */ -type SDKSystemMessageWithFields = { type: 'system'; subtype: string; stdout: string; [key: string]: unknown }; +import type { ToolUseBlock } from '@anthropic-ai/sdk/resources' +import { getRemoteSessionUrl } from '../../constants/product.js' +import { + OUTPUT_FILE_TAG, + REMOTE_REVIEW_PROGRESS_TAG, + REMOTE_REVIEW_TAG, + STATUS_TAG, + SUMMARY_TAG, + TASK_ID_TAG, + TASK_NOTIFICATION_TAG, + TASK_TYPE_TAG, + TOOL_USE_ID_TAG, + ULTRAPLAN_TAG, +} from '../../constants/xml.js' +import type { + SDKAssistantMessage, + SDKMessage, +} from '../../entrypoints/agentSdkTypes.js' +import type { + SetAppState, + Task, + TaskContext, + TaskStateBase, +} from '../../Task.js' +import { createTaskStateBase, generateTaskId } from '../../Task.js' +import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js' +import { + type BackgroundRemoteSessionPrecondition, + checkBackgroundRemoteSessionEligibility, +} from '../../utils/background/remote/remoteSession.js' +import { logForDebugging } from '../../utils/debug.js' +import { logError } from '../../utils/log.js' +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' +import { extractTag, extractTextContent } from '../../utils/messages.js' +import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js' +import { + deleteRemoteAgentMetadata, + listRemoteAgentMetadata, + type RemoteAgentMetadata, + writeRemoteAgentMetadata, +} from '../../utils/sessionStorage.js' +import { jsonStringify } from '../../utils/slowOperations.js' +import { + appendTaskOutput, + evictTaskOutput, + getTaskOutputPath, + initTaskOutput, +} from '../../utils/task/diskOutput.js' +import { registerTask, updateTaskState } from '../../utils/task/framework.js' +import { fetchSession } from '../../utils/teleport/api.js' +import { + archiveRemoteSession, + pollRemoteSessionEvents, +} from '../../utils/teleport.js' +import type { TodoList } from '../../utils/todo/types.js' +import type { UltraplanPhase } from '../../utils/ultraplan/ccrSession.js' export type RemoteAgentTaskState = TaskStateBase & { - type: 'remote_agent'; - remoteTaskType: RemoteTaskType; + type: 'remote_agent' + remoteTaskType: RemoteTaskType /** Task-specific metadata (PR number, repo, etc.). */ - remoteTaskMetadata?: RemoteTaskMetadata; - sessionId: string; // Original session ID for API calls - command: string; - title: string; - todoList: TodoList; - log: SDKMessage[]; + remoteTaskMetadata?: RemoteTaskMetadata + sessionId: string // Original session ID for API calls + command: string + title: string + todoList: TodoList + log: SDKMessage[] /** * Long-running agent that will not be marked as complete after the first `result`. */ - isLongRunning?: boolean; + isLongRunning?: boolean /** * When the local poller started watching this task (at spawn or on restore). * Review timeout clocks from here so a restore doesn't immediately time out * a task spawned >30min ago. */ - pollStartedAt: number; + pollStartedAt: number /** True when this task was created by a teleported /ultrareview command. */ - isRemoteReview?: boolean; + isRemoteReview?: boolean /** Parsed from the orchestrator's heartbeat echoes. */ reviewProgress?: { - stage?: 'finding' | 'verifying' | 'synthesizing'; - bugsFound: number; - bugsVerified: number; - bugsRefuted: number; - }; - isUltraplan?: boolean; + stage?: 'finding' | 'verifying' | 'synthesizing' + bugsFound: number + bugsVerified: number + bugsRefuted: number + } + isUltraplan?: boolean /** * Scanner-derived pill state. Undefined = running. `needs_input` when the * remote asked a clarifying question and is idle; `plan_ready` when * ExitPlanMode is awaiting browser approval. Surfaced in the pill badge * and detail dialog status line. */ - ultraplanPhase?: Exclude; -}; -const REMOTE_TASK_TYPES = ['remote-agent', 'ultraplan', 'ultrareview', 'autofix-pr', 'background-pr'] as const; -export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number]; + ultraplanPhase?: Exclude +} + +const REMOTE_TASK_TYPES = [ + 'remote-agent', + 'ultraplan', + 'ultrareview', + 'autofix-pr', + 'background-pr', +] as const +export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number] + function isRemoteTaskType(v: string | undefined): v is RemoteTaskType { - return (REMOTE_TASK_TYPES as readonly string[]).includes(v ?? ''); + return (REMOTE_TASK_TYPES as readonly string[]).includes(v ?? '') } + export type AutofixPrRemoteTaskMetadata = { - owner: string; - repo: string; - prNumber: number; -}; -export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata; + owner: string + repo: string + prNumber: number +} + +export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata /** * Called on every poll tick for tasks with a matching remoteTaskType. Return a * non-null string to complete the task (string becomes the notification text), * or null to keep polling. Checkers that hit external APIs should self-throttle. */ -export type RemoteTaskCompletionChecker = (remoteTaskMetadata: RemoteTaskMetadata | undefined) => Promise; -const completionCheckers = new Map(); +export type RemoteTaskCompletionChecker = ( + remoteTaskMetadata: RemoteTaskMetadata | undefined, +) => Promise + +const completionCheckers = new Map< + RemoteTaskType, + RemoteTaskCompletionChecker +>() /** * Register a completion checker for a remote task type. Invoked on every poll * tick; survives --resume via the sidecar's remoteTaskType + remoteTaskMetadata. */ -export function registerCompletionChecker(remoteTaskType: RemoteTaskType, checker: RemoteTaskCompletionChecker): void { - completionCheckers.set(remoteTaskType, checker); +export function registerCompletionChecker( + remoteTaskType: RemoteTaskType, + checker: RemoteTaskCompletionChecker, +): void { + completionCheckers.set(remoteTaskType, checker) } /** * Persist a remote-agent metadata entry to the session sidecar. * Fire-and-forget — persistence failures must not block task registration. */ -async function persistRemoteAgentMetadata(meta: RemoteAgentMetadata): Promise { +async function persistRemoteAgentMetadata( + meta: RemoteAgentMetadata, +): Promise { try { - await writeRemoteAgentMetadata(meta.taskId, meta); + await writeRemoteAgentMetadata(meta.taskId, meta) } catch (e) { - logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`); + logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`) } } @@ -111,82 +161,93 @@ async function persistRemoteAgentMetadata(meta: RemoteAgentMetadata): Promise { try { - await deleteRemoteAgentMetadata(taskId); + await deleteRemoteAgentMetadata(taskId) } catch (e) { - logForDebugging(`removeRemoteAgentMetadata failed: ${String(e)}`); + logForDebugging(`removeRemoteAgentMetadata failed: ${String(e)}`) } } // Precondition error result -export type RemoteAgentPreconditionResult = { - eligible: true; -} | { - eligible: false; - errors: BackgroundRemoteSessionPrecondition[]; -}; +export type RemoteAgentPreconditionResult = + | { + eligible: true + } + | { + eligible: false + errors: BackgroundRemoteSessionPrecondition[] + } /** * Check eligibility for creating a remote agent session. */ export async function checkRemoteAgentEligibility({ - skipBundle = false + skipBundle = false, }: { - skipBundle?: boolean; + skipBundle?: boolean } = {}): Promise { - const errors = await checkBackgroundRemoteSessionEligibility({ - skipBundle - }); + const errors = await checkBackgroundRemoteSessionEligibility({ skipBundle }) if (errors.length > 0) { - return { - eligible: false, - errors - }; + return { eligible: false, errors } } - return { - eligible: true - }; + return { eligible: true } } /** * Format precondition error for display. */ -export function formatPreconditionError(error: BackgroundRemoteSessionPrecondition): string { +export function formatPreconditionError( + error: BackgroundRemoteSessionPrecondition, +): string { switch (error.type) { case 'not_logged_in': - return 'Please run /login and sign in with your Claude.ai account (not Console).'; + return 'Please run /login and sign in with your Claude.ai account (not Console).' case 'no_remote_environment': - return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup'; + return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup' case 'not_in_git_repo': - return 'Background tasks require a git repository. Initialize git or run from a git repository.'; + return 'Background tasks require a git repository. Initialize git or run from a git repository.' case 'no_git_remote': - return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.'; + return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.' case 'github_app_not_installed': - return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new'; + return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new' case 'policy_blocked': - return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them."; + return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them." } } /** * Enqueue a remote task notification to the message queue. */ -function enqueueRemoteNotification(taskId: string, title: string, status: 'completed' | 'failed' | 'killed', setAppState: SetAppState, toolUseId?: string): void { +function enqueueRemoteNotification( + taskId: string, + title: string, + status: 'completed' | 'failed' | 'killed', + setAppState: SetAppState, + toolUseId?: string, +): void { // Atomically check and set notified flag to prevent duplicate notifications. - if (!markTaskNotified(taskId, setAppState)) return; - const statusText = status === 'completed' ? 'completed successfully' : status === 'failed' ? 'failed' : 'was stopped'; - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; - const outputPath = getTaskOutputPath(taskId); + if (!markTaskNotified(taskId, setAppState)) return + + const statusText = + status === 'completed' + ? 'completed successfully' + : status === 'failed' + ? 'failed' + : 'was stopped' + + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' + + const outputPath = getTaskOutputPath(taskId) const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${TASK_TYPE_TAG}>remote_agent <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>Remote task "${title}" ${statusText} -`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** @@ -194,18 +255,15 @@ function enqueueRemoteNotification(taskId: string, title: string, status: 'compl * flag (caller should enqueue), false if already notified (caller should skip). */ function markTaskNotified(taskId: string, setAppState: SetAppState): boolean { - let shouldEnqueue = false; - updateTaskState(taskId, setAppState, task => { + let shouldEnqueue = false + updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } - shouldEnqueue = true; - return { - ...task, - notified: true - }; - }); - return shouldEnqueue; + shouldEnqueue = true + return { ...task, notified: true } + }) + return shouldEnqueue } /** @@ -215,13 +273,13 @@ function markTaskNotified(taskId: string, setAppState: SetAppState): boolean { export function extractPlanFromLog(log: SDKMessage[]): string | null { // Walk backwards through assistant messages to find content for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type !== 'assistant') continue; - const fullText = extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n'); - const plan = extractTag(fullText, ULTRAPLAN_TAG); - if (plan?.trim()) return plan.trim(); + const msg = log[i] + if (msg?.type !== 'assistant') continue + const fullText = extractTextContent(msg.message.content, '\n') + const plan = extractTag(fullText, ULTRAPLAN_TAG) + if (plan?.trim()) return plan.trim() } - return null; + return null } /** @@ -229,20 +287,24 @@ export function extractPlanFromLog(log: SDKMessage[]): string | null { * this does NOT instruct the model to read the raw output file (a JSONL dump that is * useless for plan extraction). */ -export function enqueueUltraplanFailureNotification(taskId: string, sessionId: string, reason: string, setAppState: SetAppState): void { - if (!markTaskNotified(taskId, setAppState)) return; - const sessionUrl = getRemoteTaskSessionUrl(sessionId); +export function enqueueUltraplanFailureNotification( + taskId: string, + sessionId: string, + reason: string, + setAppState: SetAppState, +): void { + if (!markTaskNotified(taskId, setAppState)) return + + const sessionUrl = getRemoteTaskSessionUrl(sessionId) const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} <${TASK_TYPE_TAG}>remote_agent <${STATUS_TAG}>failed <${SUMMARY_TAG}>Ultraplan failed: ${reason} -The remote Ultraplan session did not produce a plan (${reason}). Inspect the session at ${sessionUrl} and tell the user to retry locally with plan mode.`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +The remote Ultraplan session did not produce a plan (${reason}). Inspect the session at ${sessionUrl} and tell the user to retry locally with plan mode.` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** @@ -260,33 +322,49 @@ The remote Ultraplan session did not produce a plan (${reason}). Inspect the ses */ function extractReviewFromLog(log: SDKMessage[]): string | null { for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; + const msg = log[i] // The final echo before hook exit may land in either the last // hook_progress or the terminal hook_response depending on buffering; // both have flat stdout. - if (msg?.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')) { - const tagged = extractTag((msg as SDKSystemMessageWithFields).stdout, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + if ( + msg?.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response') + ) { + const tagged = extractTag(msg.stdout, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } } + for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type !== 'assistant') continue; - const fullText = extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n'); - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + const msg = log[i] + if (msg?.type !== 'assistant') continue + const fullText = extractTextContent(msg.message.content, '\n') + const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } // Hook-stdout concat fallback: a single echo should land in one event, but // large JSON payloads can flush across two if the pipe buffer fills // mid-write. Per-message scan above misses a tag split across events. - const hookStdout = log.filter(msg => msg.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')).map(msg => (msg as SDKSystemMessageWithFields).stdout).join(''); - const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); - if (hookTagged?.trim()) return hookTagged.trim(); + const hookStdout = log + .filter( + msg => + msg.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response'), + ) + .map(msg => msg.stdout) + .join('') + const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG) + if (hookTagged?.trim()) return hookTagged.trim() // Fallback: concatenate all assistant text in chronological order. - const allText = log.filter((msg): msg is SDKAssistantMessage => msg.type === 'assistant').map(msg => extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n')).join('\n').trim(); - return allText || null; + const allText = log + .filter((msg): msg is SDKAssistantMessage => msg.type === 'assistant') + .map(msg => extractTextContent(msg.message.content, '\n')) + .join('\n') + .trim() + + return allText || null } /** @@ -302,27 +380,38 @@ function extractReviewFromLog(log: SDKMessage[]): string | null { function extractReviewTagFromLog(log: SDKMessage[]): string | null { // hook_progress / hook_response per-message scan (bughunter path) for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')) { - const tagged = extractTag((msg as SDKSystemMessageWithFields).stdout, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + const msg = log[i] + if ( + msg?.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response') + ) { + const tagged = extractTag(msg.stdout, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } } // assistant text per-message scan (prompt mode) for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type !== 'assistant') continue; - const fullText = extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n'); - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + const msg = log[i] + if (msg?.type !== 'assistant') continue + const fullText = extractTextContent(msg.message.content, '\n') + const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } // Hook-stdout concat fallback for split tags - const hookStdout = log.filter(msg => msg.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')).map(msg => (msg as SDKSystemMessageWithFields).stdout).join(''); - const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); - if (hookTagged?.trim()) return hookTagged.trim(); - return null; + const hookStdout = log + .filter( + msg => + msg.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response'), + ) + .map(msg => msg.stdout) + .join('') + const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG) + if (hookTagged?.trim()) return hookTagged.trim() + + return null } /** @@ -331,8 +420,13 @@ function extractReviewTagFromLog(log: SDKMessage[]): string | null { * turn — no file indirection, no mode change. Session is kept alive so the * claude.ai URL stays a durable record the user can revisit; TTL handles cleanup. */ -function enqueueRemoteReviewNotification(taskId: string, reviewContent: string, setAppState: SetAppState): void { - if (!markTaskNotified(taskId, setAppState)) return; +function enqueueRemoteReviewNotification( + taskId: string, + reviewContent: string, + setAppState: SetAppState, +): void { + if (!markTaskNotified(taskId, setAppState)) return + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} <${TASK_TYPE_TAG}>remote_agent @@ -341,48 +435,61 @@ function enqueueRemoteReviewNotification(taskId: string, reviewContent: string, The remote review produced the following findings: -${reviewContent}`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +${reviewContent}` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** * Enqueue a remote-review failure notification. */ -function enqueueRemoteReviewFailureNotification(taskId: string, reason: string, setAppState: SetAppState): void { - if (!markTaskNotified(taskId, setAppState)) return; +function enqueueRemoteReviewFailureNotification( + taskId: string, + reason: string, + setAppState: SetAppState, +): void { + if (!markTaskNotified(taskId, setAppState)) return + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} <${TASK_TYPE_TAG}>remote_agent <${STATUS_TAG}>failed <${SUMMARY_TAG}>Remote review failed: ${reason} -Remote review did not produce output (${reason}). Tell the user to retry /ultrareview, or use /review for a local review instead.`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +Remote review did not produce output (${reason}). Tell the user to retry /ultrareview, or use /review for a local review instead.` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** * Extract todo list from SDK messages (finds last TodoWrite tool use). */ function extractTodoListFromLog(log: SDKMessage[]): TodoList { - const todoListMessage = log.findLast((msg): msg is SDKAssistantMessage => msg.type === 'assistant' && (msg as unknown as SDKMessageWithMessage).message.content.some(block => block.type === 'tool_use' && block.name === TodoWriteTool.name)); + const todoListMessage = log.findLast( + (msg): msg is SDKAssistantMessage => + msg.type === 'assistant' && + msg.message.content.some( + block => block.type === 'tool_use' && block.name === TodoWriteTool.name, + ), + ) if (!todoListMessage) { - return []; + return [] } - const input = (todoListMessage as unknown as SDKMessageWithMessage).message.content.find(block => block.type === 'tool_use' && block.name === TodoWriteTool.name)?.input; + + const input = todoListMessage.message.content.find( + (block): block is ToolUseBlock => + block.type === 'tool_use' && block.name === TodoWriteTool.name, + )?.input if (!input) { - return []; + return [] } - const parsedInput = TodoWriteTool.inputSchema.safeParse(input); + + const parsedInput = TodoWriteTool.inputSchema.safeParse(input) if (!parsedInput.success) { - return []; + return [] } - return parsedInput.data.todos; + + return parsedInput.data.todos } /** @@ -391,22 +498,19 @@ function extractTodoListFromLog(log: SDKMessage[]): TodoList { * Callers remain responsible for custom pre-registration logic (git dialogs, transcript upload, teleport options). */ export function registerRemoteAgentTask(options: { - remoteTaskType: RemoteTaskType; - session: { - id: string; - title: string; - }; - command: string; - context: TaskContext; - toolUseId?: string; - isRemoteReview?: boolean; - isUltraplan?: boolean; - isLongRunning?: boolean; - remoteTaskMetadata?: RemoteTaskMetadata; + remoteTaskType: RemoteTaskType + session: { id: string; title: string } + command: string + context: TaskContext + toolUseId?: string + isRemoteReview?: boolean + isUltraplan?: boolean + isLongRunning?: boolean + remoteTaskMetadata?: RemoteTaskMetadata }): { - taskId: string; - sessionId: string; - cleanup: () => void; + taskId: string + sessionId: string + cleanup: () => void } { const { remoteTaskType, @@ -417,14 +521,15 @@ export function registerRemoteAgentTask(options: { isRemoteReview, isUltraplan, isLongRunning, - remoteTaskMetadata - } = options; - const taskId = generateTaskId('remote_agent'); + remoteTaskMetadata, + } = options + const taskId = generateTaskId('remote_agent') // Create the output file before registering the task. // RemoteAgentTask uses appendTaskOutput() (not TaskOutput), so // the file must exist for readers before any output arrives. - void initTaskOutput(taskId); + void initTaskOutput(taskId) + const taskState: RemoteAgentTaskState = { ...createTaskStateBase(taskId, 'remote_agent', session.title, toolUseId), type: 'remote_agent', @@ -439,9 +544,10 @@ export function registerRemoteAgentTask(options: { isUltraplan, isLongRunning, pollStartedAt: Date.now(), - remoteTaskMetadata - }; - registerTask(taskState, context.setAppState); + remoteTaskMetadata, + } + + registerTask(taskState, context.setAppState) // Persist identity to the session sidecar so --resume can reconnect to // still-running remote sessions. Status is not stored — it's fetched @@ -457,19 +563,20 @@ export function registerRemoteAgentTask(options: { isUltraplan, isRemoteReview, isLongRunning, - remoteTaskMetadata - }); + remoteTaskMetadata, + }) // Ultraplan lifecycle is owned by startDetachedPoll in ultraplan.tsx. Generic // polling still runs so session.log populates for the detail view's progress // counts; the result-lookup guard below prevents early completion. // TODO(#23985): fold ExitPlanModeScanner into this poller, drop startDetachedPoll. - const stopPolling = startRemoteSessionPolling(taskId, context); + const stopPolling = startRemoteSessionPolling(taskId, context) + return { taskId, sessionId: session.id, - cleanup: stopPolling - }; + cleanup: stopPolling, + } } /** @@ -481,21 +588,27 @@ export function registerRemoteAgentTask(options: { * removed. Must run after switchSession() so getSessionId() points at the * resumed session's sidecar directory. */ -export async function restoreRemoteAgentTasks(context: TaskContext): Promise { +export async function restoreRemoteAgentTasks( + context: TaskContext, +): Promise { try { - await restoreRemoteAgentTasksImpl(context); + await restoreRemoteAgentTasksImpl(context) } catch (e) { - logForDebugging(`restoreRemoteAgentTasks failed: ${String(e)}`); + logForDebugging(`restoreRemoteAgentTasks failed: ${String(e)}`) } } -async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise { - const persisted = await listRemoteAgentMetadata(); - if (persisted.length === 0) return; + +async function restoreRemoteAgentTasksImpl( + context: TaskContext, +): Promise { + const persisted = await listRemoteAgentMetadata() + if (persisted.length === 0) return + for (const meta of persisted) { - let remoteStatus: string; + let remoteStatus: string try { - const session = await fetchSession(meta.sessionId); - remoteStatus = session.session_status; + const session = await fetchSession(meta.sessionId) + remoteStatus = session.session_status } catch (e) { // Only 404 means the CCR session is truly gone. Auth errors (401, // missing OAuth token) are recoverable via /login — the remote @@ -503,22 +616,35 @@ async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise // 4xx (validateStatus treats <500 as success), so isTransientNetworkError // can't distinguish them; match the 404 message instead. if (e instanceof Error && e.message.startsWith('Session not found:')) { - logForDebugging(`restoreRemoteAgentTasks: dropping ${meta.taskId} (404: ${String(e)})`); - void removeRemoteAgentMetadata(meta.taskId); + logForDebugging( + `restoreRemoteAgentTasks: dropping ${meta.taskId} (404: ${String(e)})`, + ) + void removeRemoteAgentMetadata(meta.taskId) } else { - logForDebugging(`restoreRemoteAgentTasks: skipping ${meta.taskId} (recoverable: ${String(e)})`); + logForDebugging( + `restoreRemoteAgentTasks: skipping ${meta.taskId} (recoverable: ${String(e)})`, + ) } - continue; + continue } + if (remoteStatus === 'archived') { // Session ended while the local client was offline. Don't resurrect. - void removeRemoteAgentMetadata(meta.taskId); - continue; + void removeRemoteAgentMetadata(meta.taskId) + continue } + const taskState: RemoteAgentTaskState = { - ...createTaskStateBase(meta.taskId, 'remote_agent', meta.title, meta.toolUseId), + ...createTaskStateBase( + meta.taskId, + 'remote_agent', + meta.title, + meta.toolUseId, + ), type: 'remote_agent', - remoteTaskType: isRemoteTaskType(meta.remoteTaskType) ? meta.remoteTaskType : 'remote-agent', + remoteTaskType: isRemoteTaskType(meta.remoteTaskType) + ? meta.remoteTaskType + : 'remote-agent', status: 'running', sessionId: meta.sessionId, command: meta.command, @@ -530,11 +656,14 @@ async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise isLongRunning: meta.isLongRunning, startTime: meta.spawnedAt, pollStartedAt: Date.now(), - remoteTaskMetadata: meta.remoteTaskMetadata as RemoteTaskMetadata | undefined - }; - registerTask(taskState, context.setAppState); - void initTaskOutput(meta.taskId); - startRemoteSessionPolling(meta.taskId, context); + remoteTaskMetadata: meta.remoteTaskMetadata as + | RemoteTaskMetadata + | undefined, + } + + registerTask(taskState, context.setAppState) + void initTaskOutput(meta.taskId) + startRemoteSessionPolling(meta.taskId, context) } } @@ -542,71 +671,102 @@ async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise * Start polling for remote session updates. * Returns a cleanup function to stop polling. */ -function startRemoteSessionPolling(taskId: string, context: TaskContext): () => void { - let isRunning = true; - const POLL_INTERVAL_MS = 1000; - const REMOTE_REVIEW_TIMEOUT_MS = 30 * 60 * 1000; +function startRemoteSessionPolling( + taskId: string, + context: TaskContext, +): () => void { + let isRunning = true + const POLL_INTERVAL_MS = 1000 + const REMOTE_REVIEW_TIMEOUT_MS = 30 * 60 * 1000 // Remote sessions flip to 'idle' between tool turns. With 100+ rapid // turns, a 1s poll WILL catch a transient idle mid-run. Require stable // idle (no log growth for N consecutive polls) before believing it. - const STABLE_IDLE_POLLS = 5; - let consecutiveIdlePolls = 0; - let lastEventId: string | null = null; - let accumulatedLog: SDKMessage[] = []; + const STABLE_IDLE_POLLS = 5 + let consecutiveIdlePolls = 0 + let lastEventId: string | null = null + let accumulatedLog: SDKMessage[] = [] // Cached across ticks so we don't re-scan the full log. Tag appears once // at end of run; scanning only the delta (response.newEvents) is O(new). - let cachedReviewContent: string | null = null; + let cachedReviewContent: string | null = null + const poll = async (): Promise => { - if (!isRunning) return; + if (!isRunning) return + try { - const appState = context.getAppState(); - const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; + const appState = context.getAppState() + const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined if (!task || task.status !== 'running') { // Task was killed externally (TaskStopTool) or already terminal. // Session left alive so the claude.ai URL stays valid — the run_hunt.sh // post_stage() calls land as assistant events there, and the user may // want to revisit them after closing the terminal. TTL reaps it. - return; + return } - const response = await pollRemoteSessionEvents(task.sessionId, lastEventId); - lastEventId = response.lastEventId; - const logGrew = response.newEvents.length > 0; + + const response = await pollRemoteSessionEvents( + task.sessionId, + lastEventId, + ) + lastEventId = response.lastEventId + const logGrew = response.newEvents.length > 0 if (logGrew) { - accumulatedLog = [...accumulatedLog, ...response.newEvents]; - const deltaText = response.newEvents.map(msg => { - if (msg.type === 'assistant') { - return (msg as unknown as SDKMessageWithMessage).message.content.filter(block => block.type === 'text').map(block => 'text' in block ? block.text : '').join('\n'); - } - return jsonStringify(msg); - }).join('\n'); + accumulatedLog = [...accumulatedLog, ...response.newEvents] + const deltaText = response.newEvents + .map(msg => { + if (msg.type === 'assistant') { + return msg.message.content + .filter(block => block.type === 'text') + .map(block => ('text' in block ? block.text : '')) + .join('\n') + } + return jsonStringify(msg) + }) + .join('\n') if (deltaText) { - appendTaskOutput(taskId, deltaText + '\n'); + appendTaskOutput(taskId, deltaText + '\n') } } + if (response.sessionStatus === 'archived') { - updateTaskState(taskId, context.setAppState, t => t.status === 'running' ? { - ...t, - status: 'completed', - endTime: Date.now() - } : t); - enqueueRemoteNotification(taskId, task.title, 'completed', context.setAppState, task.toolUseId); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; + updateTaskState(taskId, context.setAppState, t => + t.status === 'running' + ? { ...t, status: 'completed', endTime: Date.now() } + : t, + ) + enqueueRemoteNotification( + taskId, + task.title, + 'completed', + context.setAppState, + task.toolUseId, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return } - const checker = completionCheckers.get(task.remoteTaskType); + + const checker = completionCheckers.get(task.remoteTaskType) if (checker) { - const completionResult = await checker(task.remoteTaskMetadata); + const completionResult = await checker(task.remoteTaskMetadata) if (completionResult !== null) { - updateTaskState(taskId, context.setAppState, t => t.status === 'running' ? { - ...t, - status: 'completed', - endTime: Date.now() - } : t); - enqueueRemoteNotification(taskId, completionResult, 'completed', context.setAppState, task.toolUseId); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; + updateTaskState( + taskId, + context.setAppState, + t => + t.status === 'running' + ? { ...t, status: 'completed', endTime: Date.now() } + : t, + ) + enqueueRemoteNotification( + taskId, + completionResult, + 'completed', + context.setAppState, + task.toolUseId, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return } } @@ -614,7 +774,10 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // drive completion — startDetachedPoll owns that via ExitPlanMode scan. // Long-running monitors (autofix-pr) emit result per notification cycle, // so the same skip applies. - const result = task.isUltraplan || task.isLongRunning ? undefined : accumulatedLog.findLast(msg => msg.type === 'result'); + const result = + task.isUltraplan || task.isLongRunning + ? undefined + : accumulatedLog.findLast(msg => msg.type === 'result') // For remote-review: in hook_progress stdout is the // bughunter path's completion signal. Scan only the delta to stay O(new); @@ -624,36 +787,41 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // nothing. Require STABLE_IDLE_POLLS consecutive idle polls with no log // growth. if (task.isRemoteReview && logGrew && cachedReviewContent === null) { - cachedReviewContent = extractReviewTagFromLog(response.newEvents); + cachedReviewContent = extractReviewTagFromLog(response.newEvents) } // Parse live progress counts from the orchestrator's heartbeat echoes. // hook_progress stdout is cumulative (every echo since hook start), so // each event contains all progress tags. Grab the LAST occurrence — // extractTag returns the first match which would always be the earliest // value (0/0). - let newProgress: RemoteAgentTaskState['reviewProgress']; + let newProgress: RemoteAgentTaskState['reviewProgress'] if (task.isRemoteReview && logGrew) { - const open = `<${REMOTE_REVIEW_PROGRESS_TAG}>`; - const close = ``; + const open = `<${REMOTE_REVIEW_PROGRESS_TAG}>` + const close = `` for (const ev of response.newEvents) { - if (ev.type === 'system' && ((ev as SDKSystemMessageWithFields).subtype === 'hook_progress' || (ev as SDKSystemMessageWithFields).subtype === 'hook_response')) { - const s = (ev as SDKSystemMessageWithFields).stdout; - const closeAt = s.lastIndexOf(close); - const openAt = closeAt === -1 ? -1 : s.lastIndexOf(open, closeAt); + if ( + ev.type === 'system' && + (ev.subtype === 'hook_progress' || ev.subtype === 'hook_response') + ) { + const s = ev.stdout + const closeAt = s.lastIndexOf(close) + const openAt = closeAt === -1 ? -1 : s.lastIndexOf(open, closeAt) if (openAt !== -1 && closeAt > openAt) { try { - const p = JSON.parse(s.slice(openAt + open.length, closeAt)) as { - stage?: 'finding' | 'verifying' | 'synthesizing'; - bugs_found?: number; - bugs_verified?: number; - bugs_refuted?: number; - }; + const p = JSON.parse( + s.slice(openAt + open.length, closeAt), + ) as { + stage?: 'finding' | 'verifying' | 'synthesizing' + bugs_found?: number + bugs_verified?: number + bugs_refuted?: number + } newProgress = { stage: p.stage, bugsFound: p.bugs_found ?? 0, bugsVerified: p.bugs_verified ?? 0, - bugsRefuted: p.bugs_refuted ?? 0 - }; + bugsRefuted: p.bugs_refuted ?? 0, + } } catch { // ignore malformed progress } @@ -664,13 +832,20 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // Hook events count as output only for remote-review — bughunter's // SessionStart hook produces zero assistant turns so stableIdle would // never arm without this. - const hasAnyOutput = accumulatedLog.some(msg => msg.type === 'assistant' || task.isRemoteReview && msg.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')); + const hasAnyOutput = accumulatedLog.some( + msg => + msg.type === 'assistant' || + (task.isRemoteReview && + msg.type === 'system' && + (msg.subtype === 'hook_progress' || + msg.subtype === 'hook_response')), + ) if (response.sessionStatus === 'idle' && !logGrew && hasAnyOutput) { - consecutiveIdlePolls++; + consecutiveIdlePolls++ } else { - consecutiveIdlePolls = 0; + consecutiveIdlePolls = 0 } - const stableIdle = consecutiveIdlePolls >= STABLE_IDLE_POLLS; + const stableIdle = consecutiveIdlePolls >= STABLE_IDLE_POLLS // stableIdle is a prompt-mode completion signal (Claude stops writing // → session idles → done). In bughunter mode the session is "idle" the // entire time the SessionStart hook runs; the previous guard checked @@ -685,50 +860,79 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // in prompt mode from blocking stableIdle — the code_review container // only registers SessionStart, but the 30min-hang failure mode is // worth defending against. - const hasSessionStartHook = accumulatedLog.some(m => m.type === 'system' && (m.subtype === 'hook_started' || m.subtype === 'hook_progress' || m.subtype === 'hook_response') && (m as { - hook_event?: string; - }).hook_event === 'SessionStart'); - const hasAssistantEvents = accumulatedLog.some(m => m.type === 'assistant'); - const sessionDone = task.isRemoteReview && (cachedReviewContent !== null || !hasSessionStartHook && stableIdle && hasAssistantEvents); - const reviewTimedOut = task.isRemoteReview && Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS; - const newStatus = result ? result.subtype === 'success' ? 'completed' as const : 'failed' as const : sessionDone || reviewTimedOut ? 'completed' as const : accumulatedLog.length > 0 ? 'running' as const : 'starting' as const; + const hasSessionStartHook = accumulatedLog.some( + m => + m.type === 'system' && + (m.subtype === 'hook_started' || + m.subtype === 'hook_progress' || + m.subtype === 'hook_response') && + (m as { hook_event?: string }).hook_event === 'SessionStart', + ) + const hasAssistantEvents = accumulatedLog.some( + m => m.type === 'assistant', + ) + const sessionDone = + task.isRemoteReview && + (cachedReviewContent !== null || + (!hasSessionStartHook && stableIdle && hasAssistantEvents)) + const reviewTimedOut = + task.isRemoteReview && + Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS + const newStatus = result + ? result.subtype === 'success' + ? ('completed' as const) + : ('failed' as const) + : sessionDone || reviewTimedOut + ? ('completed' as const) + : accumulatedLog.length > 0 + ? ('running' as const) + : ('starting' as const) // Update task state. Guard against terminal states — if stopTask raced // while pollRemoteSessionEvents was in-flight (status set to 'killed', // notified set to true), bail without overwriting status or proceeding to // side effects (notification, permission-mode flip). - let raceTerminated = false; - updateTaskState(taskId, context.setAppState, prevTask => { - if (prevTask.status !== 'running') { - raceTerminated = true; - return prevTask; - } - // No log growth and status unchanged → nothing to report. Return - // same ref so updateTaskState skips the spread and 18 s.tasks - // subscribers (REPL, Spinner, PromptInput, ...) don't re-render. - // newProgress only arrives via log growth (heartbeat echo is a - // hook_progress event), so !logGrew already covers no-update. - const statusUnchanged = newStatus === 'running' || newStatus === 'starting'; - if (!logGrew && statusUnchanged) { - return prevTask; - } - return { - ...prevTask, - status: newStatus === 'starting' ? 'running' : newStatus, - log: accumulatedLog, - // Only re-scan for TodoWrite when log grew — log is append-only, - // so no growth means no new tool_use blocks. Avoids findLast + - // some + find + safeParse every second when idle. - todoList: logGrew ? extractTodoListFromLog(accumulatedLog) : prevTask.todoList, - reviewProgress: newProgress ?? prevTask.reviewProgress, - endTime: result || sessionDone || reviewTimedOut ? Date.now() : undefined - }; - }); - if (raceTerminated) return; + let raceTerminated = false + updateTaskState( + taskId, + context.setAppState, + prevTask => { + if (prevTask.status !== 'running') { + raceTerminated = true + return prevTask + } + // No log growth and status unchanged → nothing to report. Return + // same ref so updateTaskState skips the spread and 18 s.tasks + // subscribers (REPL, Spinner, PromptInput, ...) don't re-render. + // newProgress only arrives via log growth (heartbeat echo is a + // hook_progress event), so !logGrew already covers no-update. + const statusUnchanged = + newStatus === 'running' || newStatus === 'starting' + if (!logGrew && statusUnchanged) { + return prevTask + } + return { + ...prevTask, + status: newStatus === 'starting' ? 'running' : newStatus, + log: accumulatedLog, + // Only re-scan for TodoWrite when log grew — log is append-only, + // so no growth means no new tool_use blocks. Avoids findLast + + // some + find + safeParse every second when idle. + todoList: logGrew + ? extractTodoListFromLog(accumulatedLog) + : prevTask.todoList, + reviewProgress: newProgress ?? prevTask.reviewProgress, + endTime: + result || sessionDone || reviewTimedOut ? Date.now() : undefined, + } + }, + ) + if (raceTerminated) return // Send notification if task completed or timed out if (result || sessionDone || reviewTimedOut) { - const finalStatus = result && result.subtype !== 'success' ? 'failed' : 'completed'; + const finalStatus = + result && result.subtype !== 'success' ? 'failed' : 'completed' // For remote-review tasks: inject the review text directly into the // message queue. No mode change, no file indirection — the local model @@ -740,50 +944,81 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // cachedReviewContent hit the tag in the delta scan. Full-log scan // catches the stableIdle path where the tag arrived in an earlier // tick but the delta scan wasn't wired yet (first poll after resume). - const reviewContent = cachedReviewContent ?? extractReviewFromLog(accumulatedLog); + const reviewContent = + cachedReviewContent ?? extractReviewFromLog(accumulatedLog) if (reviewContent && finalStatus === 'completed') { - enqueueRemoteReviewNotification(taskId, reviewContent, context.setAppState); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + enqueueRemoteReviewNotification( + taskId, + reviewContent, + context.setAppState, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } // No output or remote error — mark failed with a review-specific message. - updateTaskState(taskId, context.setAppState, t => ({ + updateTaskState(taskId, context.setAppState, t => ({ ...t, - status: 'failed' - })); - const reason = result && result.subtype !== 'success' ? 'remote session returned an error' : reviewTimedOut && !sessionDone ? 'remote session exceeded 30 minutes' : 'no review output — orchestrator may have exited early'; - enqueueRemoteReviewFailureNotification(taskId, reason, context.setAppState); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + status: 'failed', + })) + const reason = + result && result.subtype !== 'success' + ? 'remote session returned an error' + : reviewTimedOut && !sessionDone + ? 'remote session exceeded 30 minutes' + : 'no review output — orchestrator may have exited early' + enqueueRemoteReviewFailureNotification( + taskId, + reason, + context.setAppState, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } - enqueueRemoteNotification(taskId, task.title, finalStatus, context.setAppState, task.toolUseId); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + + enqueueRemoteNotification( + taskId, + task.title, + finalStatus, + context.setAppState, + task.toolUseId, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } } catch (error) { - logError(error); + logError(error) // Reset so an API error doesn't let non-consecutive idle polls accumulate. - consecutiveIdlePolls = 0; + consecutiveIdlePolls = 0 // Check review timeout even when the API call fails — without this, // persistent API errors skip the timeout check and poll forever. try { - const appState = context.getAppState(); - const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; - if (task?.isRemoteReview && task.status === 'running' && Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS) { - updateTaskState(taskId, context.setAppState, t => ({ + const appState = context.getAppState() + const task = appState.tasks?.[taskId] as + | RemoteAgentTaskState + | undefined + if ( + task?.isRemoteReview && + task.status === 'running' && + Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS + ) { + updateTaskState(taskId, context.setAppState, t => ({ ...t, status: 'failed', - endTime: Date.now() - })); - enqueueRemoteReviewFailureNotification(taskId, 'remote session exceeded 30 minutes', context.setAppState); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + endTime: Date.now(), + })) + enqueueRemoteReviewFailureNotification( + taskId, + 'remote session exceeded 30 minutes', + context.setAppState, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } } catch { // Best effort — if getAppState fails, continue polling @@ -792,17 +1027,17 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // Continue polling if (isRunning) { - setTimeout(poll, POLL_INTERVAL_MS); + setTimeout(poll, POLL_INTERVAL_MS) } - }; + } // Start polling - void poll(); + void poll() // Return cleanup function return () => { - isRunning = false; - }; + isRunning = false + } } /** @@ -816,47 +1051,52 @@ export const RemoteAgentTask: Task = { name: 'RemoteAgentTask', type: 'remote_agent', async kill(taskId, setAppState) { - let toolUseId: string | undefined; - let description: string | undefined; - let sessionId: string | undefined; - let killed = false; + let toolUseId: string | undefined + let description: string | undefined + let sessionId: string | undefined + let killed = false updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - toolUseId = task.toolUseId; - description = task.description; - sessionId = task.sessionId; - killed = true; + toolUseId = task.toolUseId + description = task.description + sessionId = task.sessionId + killed = true return { ...task, status: 'killed', notified: true, - endTime: Date.now() - }; - }); + endTime: Date.now(), + } + }) // Close the task_started bookend for SDK consumers. The poll loop's // early-return when status!=='running' won't emit a notification. if (killed) { emitTaskTerminatedSdk(taskId, 'stopped', { toolUseId, - summary: description - }); + summary: description, + }) // Archive the remote session so it stops consuming cloud resources. if (sessionId) { - void archiveRemoteSession(sessionId).catch(e => logForDebugging(`RemoteAgentTask archive failed: ${String(e)}`)); + void archiveRemoteSession(sessionId).catch(e => + logForDebugging(`RemoteAgentTask archive failed: ${String(e)}`), + ) } } - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - logForDebugging(`RemoteAgentTask ${taskId} killed, archiving session ${sessionId ?? 'unknown'}`); - } -}; + + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + logForDebugging( + `RemoteAgentTask ${taskId} killed, archiving session ${sessionId ?? 'unknown'}`, + ) + }, +} /** * Get the session URL for a remote task. */ export function getRemoteTaskSessionUrl(sessionId: string): string { - return getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL); + return getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL) } diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 709f31e66..7fbed68a4 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -1,105 +1,235 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js'; -import type { AssistantMessage, Message as MessageType, NormalizedUserMessage } from 'src/types/message.js'; -import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'; -import { z } from 'zod/v4'; -import { clearInvokedSkillsForAgent, getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; -import { enhanceSystemPromptWithEnvDetails, getSystemPrompt } from '../../constants/prompts.js'; -import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'; -import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { clearDumpState } from '../../services/api/dumpPrompts.js'; -import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { assembleToolPool } from '../../tools.js'; -import { asAgentId } from '../../types/ids.js'; -import { type SubagentContext, runWithAgentContext } from '../../utils/agentContext.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { AbortError, errorMessage, toError } from '../../utils/errors.js'; -import type { CacheSafeParams } from '../../utils/forkedAgent.js'; -import { lazySchema } from '../../utils/lazySchema.js'; -import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from '../../utils/messages.js'; -import { getAgentModel } from '../../utils/model/agent.js'; -import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js'; -import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'; -import { filterDeniedAgents, getDenyRuleForAgent } from '../../utils/permissions/permissions.js'; -import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js'; -import { writeAgentMetadata } from '../../utils/sessionStorage.js'; -import { sleep } from '../../utils/sleep.js'; -import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'; -import { asSystemPrompt } from '../../utils/systemPromptType.js'; -import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { getParentSessionId, isTeammate } from '../../utils/teammate.js'; -import { isInProcessTeammate } from '../../utils/teammateContext.js'; -import { teleportToRemote } from '../../utils/teleport.js'; -import { getAssistantMessageContentLength } from '../../utils/tokens.js'; -import { createAgentId } from '../../utils/uuid.js'; -import { createAgentWorktree, hasWorktreeChanges, removeAgentWorktree } from '../../utils/worktree.js'; -import { BASH_TOOL_NAME } from '../BashTool/toolName.js'; -import { BackgroundHint } from '../BashTool/UI.js'; -import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'; -import { spawnTeammate } from '../shared/spawnMultiAgent.js'; -import { setAgentColor } from './agentColorManager.js'; -import { agentToolResultSchema, classifyHandoffIfNeeded, emitTaskProgress, extractPartialResult, finalizeAgentTool, getLastToolUseName, runAsyncAgentLifecycle } from './agentToolUtils.js'; -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; -import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME, ONE_SHOT_BUILTIN_AGENT_TYPES } from './constants.js'; -import { buildForkedMessages, buildWorktreeNotice, FORK_AGENT, isForkSubagentEnabled, isInForkChild } from './forkSubagent.js'; -import type { AgentDefinition } from './loadAgentsDir.js'; -import { filterAgentsByMcpRequirements, hasRequiredMcpServers, isBuiltInAgent } from './loadAgentsDir.js'; -import { getPrompt } from './prompt.js'; -import { runAgent } from './runAgent.js'; -import { renderGroupedAgentToolUse, renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseTag, userFacingName, userFacingNameBackgroundColor } from './UI.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js' +import type { + Message as MessageType, + NormalizedUserMessage, +} from 'src/types/message.js' +import { getQuerySourceForAgent } from 'src/utils/promptCategory.js' +import { z } from 'zod/v4' +import { + clearInvokedSkillsForAgent, + getSdkAgentProgressSummariesEnabled, +} from '../../bootstrap/state.js' +import { + enhanceSystemPromptWithEnvDetails, + getSystemPrompt, +} from '../../constants/prompts.js' +import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js' +import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { clearDumpState } from '../../services/api/dumpPrompts.js' +import { + completeAgentTask as completeAsyncAgent, + createActivityDescriptionResolver, + createProgressTracker, + enqueueAgentNotification, + failAgentTask as failAsyncAgent, + getProgressUpdate, + getTokenCountFromTracker, + isLocalAgentTask, + killAsyncAgent, + registerAgentForeground, + registerAsyncAgent, + unregisterAgentForeground, + updateAgentProgress as updateAsyncAgentProgress, + updateProgressFromMessage, +} from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { + checkRemoteAgentEligibility, + formatPreconditionError, + getRemoteTaskSessionUrl, + registerRemoteAgentTask, +} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { assembleToolPool } from '../../tools.js' +import { asAgentId } from '../../types/ids.js' +import { runWithAgentContext } from '../../utils/agentContext.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { getCwd, runWithCwdOverride } from '../../utils/cwd.js' +import { logForDebugging } from '../../utils/debug.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { AbortError, errorMessage, toError } from '../../utils/errors.js' +import type { CacheSafeParams } from '../../utils/forkedAgent.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { + createUserMessage, + extractTextContent, + isSyntheticMessage, + normalizeMessages, +} from '../../utils/messages.js' +import { getAgentModel } from '../../utils/model/agent.js' +import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js' +import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' +import { + filterDeniedAgents, + getDenyRuleForAgent, +} from '../../utils/permissions/permissions.js' +import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js' +import { writeAgentMetadata } from '../../utils/sessionStorage.js' +import { sleep } from '../../utils/sleep.js' +import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js' +import { asSystemPrompt } from '../../utils/systemPromptType.js' +import { getTaskOutputPath } from '../../utils/task/diskOutput.js' +import { getParentSessionId, isTeammate } from '../../utils/teammate.js' +import { isInProcessTeammate } from '../../utils/teammateContext.js' +import { teleportToRemote } from '../../utils/teleport.js' +import { getAssistantMessageContentLength } from '../../utils/tokens.js' +import { createAgentId } from '../../utils/uuid.js' +import { + createAgentWorktree, + hasWorktreeChanges, + removeAgentWorktree, +} from '../../utils/worktree.js' +import { BASH_TOOL_NAME } from '../BashTool/toolName.js' +import { BackgroundHint } from '../BashTool/UI.js' +import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' +import { spawnTeammate } from '../shared/spawnMultiAgent.js' +import { setAgentColor } from './agentColorManager.js' +import { + agentToolResultSchema, + classifyHandoffIfNeeded, + emitTaskProgress, + extractPartialResult, + finalizeAgentTool, + getLastToolUseName, + runAsyncAgentLifecycle, +} from './agentToolUtils.js' +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' +import { + AGENT_TOOL_NAME, + LEGACY_AGENT_TOOL_NAME, + ONE_SHOT_BUILTIN_AGENT_TYPES, +} from './constants.js' +import { + buildForkedMessages, + buildWorktreeNotice, + FORK_AGENT, + isForkSubagentEnabled, + isInForkChild, +} from './forkSubagent.js' +import type { AgentDefinition } from './loadAgentsDir.js' +import { + filterAgentsByMcpRequirements, + hasRequiredMcpServers, + isBuiltInAgent, +} from './loadAgentsDir.js' +import { getPrompt } from './prompt.js' +import { runAgent } from './runAgent.js' +import { + renderGroupedAgentToolUse, + renderToolResultMessage, + renderToolUseErrorMessage, + renderToolUseMessage, + renderToolUseProgressMessage, + renderToolUseRejectedMessage, + renderToolUseTag, + userFacingName, + userFacingNameBackgroundColor, +} from './UI.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') as typeof import('../../proactive/index.js') : null; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? (require('../../proactive/index.js') as typeof import('../../proactive/index.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Progress display constants (for showing background hint) -const PROGRESS_THRESHOLD_MS = 2000; // Show background hint after 2 seconds +const PROGRESS_THRESHOLD_MS = 2000 // Show background hint after 2 seconds // Check if background tasks are disabled at module load time const isBackgroundTasksDisabled = -// eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load -isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); + // eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load + isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) // Auto-background agent tasks after this many ms (0 = disabled) // Enabled by env var OR GrowthBook gate (checked lazily since GB may not be ready at module load) function getAutoBackgroundMs(): number { - if (isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)) { - return 120_000; + if ( + isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || + getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false) + ) { + return 120_000 } - return 0; + return 0 } // Multi-agent type constants are defined inline inside gated blocks to enable dead code elimination // Base input schema without multi-agent parameters -const baseInputSchema = lazySchema(() => z.object({ - description: z.string().describe('A short (3-5 word) description of the task'), - prompt: z.string().describe('The task for the agent to perform'), - subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'), - model: z.enum(['sonnet', 'opus', 'haiku']).optional().describe("Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent."), - run_in_background: z.boolean().optional().describe('Set to true to run this agent in the background. You will be notified when it completes.') -})); +const baseInputSchema = lazySchema(() => + z.object({ + description: z + .string() + .describe('A short (3-5 word) description of the task'), + prompt: z.string().describe('The task for the agent to perform'), + subagent_type: z + .string() + .optional() + .describe('The type of specialized agent to use for this task'), + model: z + .enum(['sonnet', 'opus', 'haiku']) + .optional() + .describe( + "Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent.", + ), + run_in_background: z + .boolean() + .optional() + .describe( + 'Set to true to run this agent in the background. You will be notified when it completes.', + ), + }), +) // Full schema combining base + multi-agent params + isolation const fullInputSchema = lazySchema(() => { // Multi-agent parameters const multiAgentInputSchema = z.object({ - name: z.string().optional().describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), - team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), - mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).') - }); - return baseInputSchema().merge(multiAgentInputSchema).extend({ - isolation: ((process.env.USER_TYPE) === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional().describe((process.env.USER_TYPE) === 'ant' ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'), - cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".') - }); -}); + name: z + .string() + .optional() + .describe( + 'Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.', + ), + team_name: z + .string() + .optional() + .describe( + 'Team name for spawning. Uses current team context if omitted.', + ), + mode: permissionModeSchema() + .optional() + .describe( + 'Permission mode for spawned teammate (e.g., "plan" to require plan approval).', + ), + }) + + return baseInputSchema() + .merge(multiAgentInputSchema) + .extend({ + isolation: (process.env.USER_TYPE === 'ant' + ? z.enum(['worktree', 'remote']) + : z.enum(['worktree']) + ) + .optional() + .describe( + process.env.USER_TYPE === 'ant' + ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' + : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.', + ), + cwd: z + .string() + .optional() + .describe( + 'Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".', + ), + }) +}) // Strip optional fields from the schema when the backing feature is off so // the model never sees them. Done via .omit() rather than conditional spread @@ -108,9 +238,9 @@ const fullInputSchema = lazySchema(() => { // type, but call() destructures via the explicit AgentToolInput type below // which always includes all optional fields. export const inputSchema = lazySchema(() => { - const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ - cwd: true - }); + const schema = feature('KAIROS') + ? fullInputSchema() + : fullInputSchema().omit({ cwd: true }) // GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which // was removed in 906da6c723): the divergence window is one-session-per- @@ -119,61 +249,70 @@ export const inputSchema = lazySchema(() => { // by forceAsync) or "schema hides a param that would've worked" (gate // flips off mid-session: everything still runs async via memoized // forceAsync). No Zod rejection, no crash — unlike required→optional. - return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ - run_in_background: true - }) : schema; -}); -type InputSchema = ReturnType; + return isBackgroundTasksDisabled || isForkSubagentEnabled() + ? schema.omit({ run_in_background: true }) + : schema +}) +type InputSchema = ReturnType // Explicit type widens the schema inference to always include all optional // fields even when .omit() strips them for gating (cwd, run_in_background). // subagent_type is optional; call() defaults it to general-purpose when the // fork gate is off, or routes to the fork path when the gate is on. type AgentToolInput = z.infer> & { - name?: string; - team_name?: string; - mode?: z.infer>; - isolation?: 'worktree' | 'remote'; - cwd?: string; -}; + name?: string + team_name?: string + mode?: z.infer> + isolation?: 'worktree' | 'remote' + cwd?: string +} // Output schema - multi-agent spawned schema added dynamically at runtime when enabled export const outputSchema = lazySchema(() => { const syncOutputSchema = agentToolResultSchema().extend({ status: z.literal('completed'), - prompt: z.string() - }); + prompt: z.string(), + }) + const asyncOutputSchema = z.object({ status: z.literal('async_launched'), agentId: z.string().describe('The ID of the async agent'), description: z.string().describe('The description of the task'), prompt: z.string().describe('The prompt for the agent'), - outputFile: z.string().describe('Path to the output file for checking agent progress'), - canReadOutputFile: z.boolean().optional().describe('Whether the calling agent has Read/Bash tools to check progress') - }); - return z.union([syncOutputSchema, asyncOutputSchema]); -}); -type OutputSchema = ReturnType; -type Output = z.input; + outputFile: z + .string() + .describe('Path to the output file for checking agent progress'), + canReadOutputFile: z + .boolean() + .optional() + .describe( + 'Whether the calling agent has Read/Bash tools to check progress', + ), + }) + + return z.union([syncOutputSchema, asyncOutputSchema]) +}) +type OutputSchema = ReturnType +type Output = z.input // Private type for teammate spawn results - excluded from exported schema for dead code elimination // The 'teammate_spawned' status string is only included when ENABLE_AGENT_SWARMS is true type TeammateSpawnedOutput = { - status: 'teammate_spawned'; - prompt: string; - teammate_id: string; - agent_id: string; - agent_type?: string; - model?: string; - name: string; - color?: string; - tmux_session_name: string; - tmux_window_name: string; - tmux_pane_id: string; - team_name?: string; - is_splitpane?: boolean; - plan_mode_required?: boolean; -}; + status: 'teammate_spawned' + prompt: string + teammate_id: string + agent_id: string + agent_type?: string + model?: string + name: string + color?: string + tmux_session_name: string + tmux_window_name: string + tmux_pane_id: string + team_name?: string + is_splitpane?: boolean + plan_mode_required?: boolean +} // Combined output type including both public and internal types // Note: TeammateSpawnedOutput type is fine - TypeScript types are erased at compile time @@ -181,123 +320,146 @@ type TeammateSpawnedOutput = { // like TeammateSpawnedOutput for dead code elimination purposes. Exported // for UI.tsx to do proper discriminated-union narrowing instead of ad-hoc casts. export type RemoteLaunchedOutput = { - status: 'remote_launched'; - taskId: string; - sessionUrl: string; - description: string; - prompt: string; - outputFile: string; -}; -type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput; -import type { AgentToolProgress, ShellProgress } from '../../types/tools.js'; + status: 'remote_launched' + taskId: string + sessionUrl: string + description: string + prompt: string + outputFile: string +} + +type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput + +import type { AgentToolProgress, ShellProgress } from '../../types/tools.js' // AgentTool forwards both its own progress events and shell progress // events from the sub-agent so the SDK receives tool_progress updates during bash/powershell runs. -export type Progress = AgentToolProgress | ShellProgress; +export type Progress = AgentToolProgress | ShellProgress + export const AgentTool = buildTool({ - async prompt({ - agents, - tools, - getToolPermissionContext, - allowedAgentTypes - }) { - const toolPermissionContext = await getToolPermissionContext(); + async prompt({ agents, tools, getToolPermissionContext, allowedAgentTypes }) { + const toolPermissionContext = await getToolPermissionContext() // Get MCP servers that have tools available - const mcpServersWithTools: string[] = []; + const mcpServersWithTools: string[] = [] for (const tool of tools) { if (tool.name?.startsWith('mcp__')) { - const parts = tool.name.split('__'); - const serverName = parts[1]; + const parts = tool.name.split('__') + const serverName = parts[1] if (serverName && !mcpServersWithTools.includes(serverName)) { - mcpServersWithTools.push(serverName); + mcpServersWithTools.push(serverName) } } } // Filter agents: first by MCP requirements, then by permission rules - const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements(agents, mcpServersWithTools); - const filteredAgents = filterDeniedAgents(agentsWithMcpRequirementsMet, toolPermissionContext, AGENT_TOOL_NAME); + const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements( + agents, + mcpServersWithTools, + ) + const filteredAgents = filterDeniedAgents( + agentsWithMcpRequirementsMet, + toolPermissionContext, + AGENT_TOOL_NAME, + ) // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; - return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes); + const isCoordinator = feature('COORDINATOR_MODE') + ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + : false + return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes) }, name: AGENT_TOOL_NAME, searchHint: 'delegate work to a subagent', aliases: [LEGACY_AGENT_TOOL_NAME], maxResultSizeChars: 100_000, async description() { - return 'Launch a new agent'; + return 'Launch a new agent' }, get inputSchema(): InputSchema { - return inputSchema(); + return inputSchema() }, get outputSchema(): OutputSchema { - return outputSchema(); + return outputSchema() }, - async call({ - prompt, - subagent_type, - description, - model: modelParam, - run_in_background, - name, - team_name, - mode: spawnMode, - isolation, - cwd - }: AgentToolInput, toolUseContext, canUseTool, assistantMessage, onProgress?) { - const startTime = Date.now(); - const model = isCoordinatorMode() ? undefined : modelParam; + async call( + { + prompt, + subagent_type, + description, + model: modelParam, + run_in_background, + name, + team_name, + mode: spawnMode, + isolation, + cwd, + }: AgentToolInput, + toolUseContext, + canUseTool, + assistantMessage, + onProgress?, + ) { + const startTime = Date.now() + const model = isCoordinatorMode() ? undefined : modelParam // Get app state for permission mode and agent filtering - const appState = toolUseContext.getAppState(); - const permissionMode = appState.toolPermissionContext.mode; + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode // In-process teammates get a no-op setAppState; setAppStateForTasks // reaches the root store so task registration/progress/kill stay visible. - const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState; + const rootSetAppState = + toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState // Check if user is trying to use agent teams without access if (team_name && !isAgentSwarmsEnabled()) { - throw new Error('Agent Teams is not yet available on your plan.'); + throw new Error('Agent Teams is not yet available on your plan.') } // Teammates (in-process or tmux) passing `name` would trigger spawnTeammate() // below, but TeamFile.members is a flat array with one leadAgentId — nested // teammates land in the roster with no provenance and confuse the lead. - const teamName = resolveTeamName({ - team_name - }, appState); + const teamName = resolveTeamName({ team_name }, appState) if (isTeammate() && teamName && name) { - throw new Error('Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.'); + throw new Error( + 'Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.', + ) } // In-process teammates cannot spawn background agents (their lifecycle is // tied to the leader's process). Tmux teammates are separate processes and // can manage their own background agents. if (isInProcessTeammate() && teamName && run_in_background === true) { - throw new Error('In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.'); + throw new Error( + 'In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.', + ) } // Check if this is a multi-agent spawn request // Spawn is triggered when team_name is set (from param or context) and name is provided if (teamName && name) { // Set agent definition color for grouped UI display before spawning - const agentDef = subagent_type ? toolUseContext.options.agentDefinitions.activeAgents.find(a => a.agentType === subagent_type) : undefined; + const agentDef = subagent_type + ? toolUseContext.options.agentDefinitions.activeAgents.find( + a => a.agentType === subagent_type, + ) + : undefined if (agentDef?.color) { - setAgentColor(subagent_type!, agentDef.color); + setAgentColor(subagent_type!, agentDef.color) } - const result = await spawnTeammate({ - name, - prompt, - description, - team_name: teamName, - use_splitpane: true, - plan_mode_required: spawnMode === 'plan', - model: model ?? agentDef?.model, - agent_type: subagent_type, - invokingRequestId: assistantMessage?.requestId as string | undefined - }, toolUseContext); + const result = await spawnTeammate( + { + name, + prompt, + description, + team_name: teamName, + use_splitpane: true, + plan_mode_required: spawnMode === 'plan', + model: model ?? agentDef?.model, + agent_type: subagent_type, + invokingRequestId: assistantMessage?.requestId, + }, + toolUseContext, + ) // Type assertion uses TeammateSpawnedOutput (defined above) instead of any. // This type is excluded from the exported outputSchema for dead code elimination. @@ -306,22 +468,21 @@ export const AgentTool = buildTool({ const spawnResult: TeammateSpawnedOutput = { status: 'teammate_spawned' as const, prompt, - ...result.data - }; - return { - data: spawnResult - } as unknown as { - data: Output; - }; + ...result.data, + } + return { data: spawnResult } as unknown as { data: Output } } // Fork subagent experiment routing: // - subagent_type set: use it (explicit wins) // - subagent_type omitted, gate on: fork path (undefined) // - subagent_type omitted, gate off: default general-purpose - const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType); - const isForkPath = effectiveType === undefined; - let selectedAgent: AgentDefinition; + const effectiveType = + subagent_type ?? + (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType) + const isForkPath = effectiveType === undefined + + let selectedAgent: AgentDefinition if (isForkPath) { // Recursive fork guard: fork children keep the Agent tool in their // pool for cache-identical tool defs, so reject fork attempts at call @@ -329,42 +490,70 @@ export const AgentTool = buildTool({ // context.options at spawn time, survives autocompact's message // rewrite). Message-scan fallback catches any path where querySource // wasn't threaded. - if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages)) { - throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.'); + if ( + toolUseContext.options.querySource === + `agent:builtin:${FORK_AGENT.agentType}` || + isInForkChild(toolUseContext.messages) + ) { + throw new Error( + 'Fork is not available inside a forked worker. Complete your task directly using your tools.', + ) } - selectedAgent = FORK_AGENT; + selectedAgent = FORK_AGENT } else { // Filter agents to exclude those denied via Agent(AgentName) syntax - const allAgents = toolUseContext.options.agentDefinitions.activeAgents; - const { - allowedAgentTypes - } = toolUseContext.options.agentDefinitions; + const allAgents = toolUseContext.options.agentDefinitions.activeAgents + const { allowedAgentTypes } = toolUseContext.options.agentDefinitions const agents = filterDeniedAgents( - // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types - allowedAgentTypes ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) : allAgents, appState.toolPermissionContext, AGENT_TOOL_NAME); - const found = agents.find(agent => agent.agentType === effectiveType); + // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types + allowedAgentTypes + ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) + : allAgents, + appState.toolPermissionContext, + AGENT_TOOL_NAME, + ) + + const found = agents.find(agent => agent.agentType === effectiveType) if (!found) { // Check if the agent exists but is denied by permission rules - const agentExistsButDenied = allAgents.find(agent => agent.agentType === effectiveType); + const agentExistsButDenied = allAgents.find( + agent => agent.agentType === effectiveType, + ) if (agentExistsButDenied) { - const denyRule = getDenyRuleForAgent(appState.toolPermissionContext, AGENT_TOOL_NAME, effectiveType); - throw new Error(`Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`); + const denyRule = getDenyRuleForAgent( + appState.toolPermissionContext, + AGENT_TOOL_NAME, + effectiveType, + ) + throw new Error( + `Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`, + ) } - throw new Error(`Agent type '${effectiveType}' not found. Available agents: ${agents.map(a => a.agentType).join(', ')}`); + throw new Error( + `Agent type '${effectiveType}' not found. Available agents: ${agents + .map(a => a.agentType) + .join(', ')}`, + ) } - selectedAgent = found; + selectedAgent = found } // Same lifecycle constraint as the run_in_background guard above, but for // agent definitions that force background via `background: true`. Checked // here because selectedAgent is only now resolved. - if (isInProcessTeammate() && teamName && selectedAgent.background === true) { - throw new Error(`In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`); + if ( + isInProcessTeammate() && + teamName && + selectedAgent.background === true + ) { + throw new Error( + `In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`, + ) } // Capture for type narrowing — `let selectedAgent` prevents TS from // narrowing property types across the if-else assignment above. - const requiredMcpServers = selectedAgent.requiredMcpServers; + const requiredMcpServers = selectedAgent.requiredMcpServers // Check if required MCP servers have tools available // A server that's connected but not authenticated won't have any tools @@ -372,113 +561,153 @@ export const AgentTool = buildTool({ // If any required servers are still pending (connecting), wait for them // before checking tool availability. This avoids a race condition where // the agent is invoked before MCP servers finish connecting. - const hasPendingRequiredServers = appState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - let currentAppState = appState; + const hasPendingRequiredServers = appState.mcp.clients.some( + c => + c.type === 'pending' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + + let currentAppState = appState if (hasPendingRequiredServers) { - const MAX_WAIT_MS = 30_000; - const POLL_INTERVAL_MS = 500; - const deadline = Date.now() + MAX_WAIT_MS; + const MAX_WAIT_MS = 30_000 + const POLL_INTERVAL_MS = 500 + const deadline = Date.now() + MAX_WAIT_MS + while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS); - currentAppState = toolUseContext.getAppState(); + await sleep(POLL_INTERVAL_MS) + currentAppState = toolUseContext.getAppState() // Early exit: if any required server has already failed, no point // waiting for other pending servers — the check will fail regardless. - const hasFailedRequiredServer = currentAppState.mcp.clients.some(c => c.type === 'failed' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - if (hasFailedRequiredServer) break; - const stillPending = currentAppState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - if (!stillPending) break; + const hasFailedRequiredServer = currentAppState.mcp.clients.some( + c => + c.type === 'failed' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + if (hasFailedRequiredServer) break + + const stillPending = currentAppState.mcp.clients.some( + c => + c.type === 'pending' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + if (!stillPending) break } } // Get servers that actually have tools (meaning they're connected AND authenticated) - const serversWithTools: string[] = []; + const serversWithTools: string[] = [] for (const tool of currentAppState.mcp.tools) { if (tool.name?.startsWith('mcp__')) { // Extract server name from tool name (format: mcp__serverName__toolName) - const parts = tool.name.split('__'); - const serverName = parts[1]; + const parts = tool.name.split('__') + const serverName = parts[1] if (serverName && !serversWithTools.includes(serverName)) { - serversWithTools.push(serverName); + serversWithTools.push(serverName) } } } + if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) { - const missing = requiredMcpServers.filter(pattern => !serversWithTools.some(server => server.toLowerCase().includes(pattern.toLowerCase()))); - throw new Error(`Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + `Use /mcp to configure and authenticate the required MCP servers.`); + const missing = requiredMcpServers.filter( + pattern => + !serversWithTools.some(server => + server.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + throw new Error( + `Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + + `Use /mcp to configure and authenticate the required MCP servers.`, + ) } } // Initialize the color for this agent if it has a predefined one if (selectedAgent.color) { - setAgentColor(selectedAgent.agentType, selectedAgent.color); + setAgentColor(selectedAgent.agentType, selectedAgent.color) } // Resolve agent params for logging (these are already resolved in runAgent) - const resolvedAgentModel = getAgentModel(selectedAgent.model, toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode); + const resolvedAgentModel = getAgentModel( + selectedAgent.model, + toolUseContext.options.mainLoopModel, + isForkPath ? undefined : model, + permissionMode, + ) + logEvent('tengu_agent_tool_selected', { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - color: selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + color: + selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_built_in_agent: isBuiltInAgent(selectedAgent), is_resume: false, - is_async: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, - is_fork: isForkPath - }); + is_async: + (run_in_background === true || selectedAgent.background === true) && + !isBackgroundTasksDisabled, + is_fork: isForkPath, + }) // Resolve effective isolation mode (explicit param overrides agent def) - const effectiveIsolation = isolation ?? selectedAgent.isolation; + const effectiveIsolation = isolation ?? selectedAgent.isolation // Remote isolation: delegate to CCR. Gated ant-only — the guard enables // dead code elimination of the entire block for external builds. - if ((process.env.USER_TYPE) === 'ant' && effectiveIsolation === 'remote') { - const eligibility = await checkRemoteAgentEligibility(); + if (process.env.USER_TYPE === 'ant' && effectiveIsolation === 'remote') { + const eligibility = await checkRemoteAgentEligibility() if (!eligibility.eligible) { - const reasons = (eligibility as { eligible: false; errors: Parameters[0][] }).errors.map(formatPreconditionError).join('\n'); - throw new Error(`Cannot launch remote agent:\n${reasons}`); + const reasons = eligibility.errors + .map(formatPreconditionError) + .join('\n') + throw new Error(`Cannot launch remote agent:\n${reasons}`) } - let bundleFailHint: string | undefined; + + let bundleFailHint: string | undefined const session = await teleportToRemote({ initialMessage: prompt, description, signal: toolUseContext.abortController.signal, onBundleFail: msg => { - bundleFailHint = msg; - } - }); + bundleFailHint = msg + }, + }) if (!session) { - throw new Error(bundleFailHint ?? 'Failed to create remote session'); + throw new Error(bundleFailHint ?? 'Failed to create remote session') } - const { - taskId, - sessionId - } = registerRemoteAgentTask({ + + const { taskId, sessionId } = registerRemoteAgentTask({ remoteTaskType: 'remote-agent', - session: { - id: session.id, - title: session.title || description - }, + session: { id: session.id, title: session.title || description }, command: prompt, context: toolUseContext, - toolUseId: toolUseContext.toolUseId - }); + toolUseId: toolUseContext.toolUseId, + }) + logEvent('tengu_agent_tool_remote_launched', { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const remoteResult: RemoteLaunchedOutput = { status: 'remote_launched', taskId, sessionUrl: getRemoteTaskSessionUrl(sessionId), description, prompt, - outputFile: getTaskOutputPath(taskId) - }; - return { - data: remoteResult - } as unknown as { - data: Output; - }; + outputFile: getTaskOutputPath(taskId), + } + return { data: remoteResult } as unknown as { data: Output } } // System prompt + prompt messages: branch on fork path. // @@ -489,72 +718,98 @@ export const AgentTool = buildTool({ // // Normal path: build the selected agent's own system prompt with env // details, and use a simple user message for the prompt. - let enhancedSystemPrompt: string[] | undefined; - let forkParentSystemPrompt: ReturnType | undefined; - let promptMessages: MessageType[]; + let enhancedSystemPrompt: string[] | undefined + let forkParentSystemPrompt: + | ReturnType + | undefined + let promptMessages: MessageType[] + if (isForkPath) { if (toolUseContext.renderedSystemPrompt) { - forkParentSystemPrompt = toolUseContext.renderedSystemPrompt; + forkParentSystemPrompt = toolUseContext.renderedSystemPrompt } else { // Fallback: recompute. May diverge from parent's cached bytes if // GrowthBook state changed between parent turn-start and fork spawn. - const mainThreadAgentDefinition = appState.agent ? appState.agentDefinitions.activeAgents.find(a => a.agentType === appState.agent) : undefined; - const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); - const defaultSystemPrompt = await getSystemPrompt(toolUseContext.options.tools, toolUseContext.options.mainLoopModel, additionalWorkingDirectories, toolUseContext.options.mcpClients); + const mainThreadAgentDefinition = appState.agent + ? appState.agentDefinitions.activeAgents.find( + a => a.agentType === appState.agent, + ) + : undefined + const additionalWorkingDirectories = Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ) + const defaultSystemPrompt = await getSystemPrompt( + toolUseContext.options.tools, + toolUseContext.options.mainLoopModel, + additionalWorkingDirectories, + toolUseContext.options.mcpClients, + ) forkParentSystemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, toolUseContext, customSystemPrompt: toolUseContext.options.customSystemPrompt, defaultSystemPrompt, - appendSystemPrompt: toolUseContext.options.appendSystemPrompt - }); + appendSystemPrompt: toolUseContext.options.appendSystemPrompt, + }) } - promptMessages = buildForkedMessages(prompt, assistantMessage); + promptMessages = buildForkedMessages(prompt, assistantMessage) } else { try { - const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); + const additionalWorkingDirectories = Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ) // All agents have getSystemPrompt - pass toolUseContext to all - const agentPrompt = selectedAgent.getSystemPrompt({ - toolUseContext - }); + const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext }) // Log agent memory loaded event for subagents if (selectedAgent.memory) { logEvent('tengu_agent_memory_loaded', { - ...((process.env.USER_TYPE) === 'ant' && { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ...(process.env.USER_TYPE === 'ant' && { + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), - scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + scope: + selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } // Apply environment details enhancement - enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails([agentPrompt], resolvedAgentModel, additionalWorkingDirectories); + enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails( + [agentPrompt], + resolvedAgentModel, + additionalWorkingDirectories, + ) } catch (error) { - logForDebugging(`Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`); + logForDebugging( + `Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`, + ) } - promptMessages = [createUserMessage({ - content: prompt - })]; + promptMessages = [createUserMessage({ content: prompt })] } + const metadata = { prompt, resolvedAgentModel, isBuiltInAgent: isBuiltInAgent(selectedAgent), startTime, agentType: selectedAgent.agentType, - isAsync: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled - }; + isAsync: + (run_in_background === true || selectedAgent.background === true) && + !isBackgroundTasksDisabled, + } // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; + const isCoordinator = feature('COORDINATOR_MODE') + ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + : false // Fork subagent experiment: force ALL spawns async for a unified // interaction model (not just fork spawns — all of them). - const forceAsync = isForkSubagentEnabled(); + const forceAsync = isForkSubagentEnabled() // Assistant mode: force all agents async. Synchronous subagents hold the // main loop's turn open until they complete — the daemon's inputQueue @@ -563,8 +818,18 @@ export const AgentTool = buildTool({ // executeForkedSlashCommand's fire-and-forget path; the // re-entry there is handled by the else branch // below (registerAsyncAgentTask + notifyOnCompletion). - const assistantForceAsync = feature('KAIROS') ? appState.kairosEnabled : false; - const shouldRunAsync = (run_in_background === true || selectedAgent.background === true || isCoordinator || forceAsync || assistantForceAsync || (proactiveModule?.isProactiveActive() ?? false)) && !isBackgroundTasksDisabled; + const assistantForceAsync = feature('KAIROS') + ? appState.kairosEnabled + : false + + const shouldRunAsync = + (run_in_background === true || + selectedAgent.background === true || + isCoordinator || + forceAsync || + assistantForceAsync || + (proactiveModule?.isProactiveActive() ?? false)) && + !isBackgroundTasksDisabled // Assemble the worker's tool pool independently of the parent's. // Workers always get their tools from assembleToolPool with their own // permission mode, so they aren't affected by the parent's tool @@ -572,41 +837,53 @@ export const AgentTool = buildTool({ // import from tools.ts (which would create a circular dependency). const workerPermissionContext = { ...appState.toolPermissionContext, - mode: selectedAgent.permissionMode ?? 'acceptEdits' - }; - const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools); + mode: selectedAgent.permissionMode ?? 'acceptEdits', + } + const workerTools = assembleToolPool( + workerPermissionContext, + appState.mcp.tools, + ) // Create a stable agent ID early so it can be used for worktree slug - const earlyAgentId = createAgentId(); + const earlyAgentId = createAgentId() // Set up worktree isolation if requested let worktreeInfo: { - worktreePath: string; - worktreeBranch?: string; - headCommit?: string; - gitRoot?: string; - hookBased?: boolean; - } | null = null; + worktreePath: string + worktreeBranch?: string + headCommit?: string + gitRoot?: string + hookBased?: boolean + } | null = null + if (effectiveIsolation === 'worktree') { - const slug = `agent-${earlyAgentId.slice(0, 8)}`; - worktreeInfo = await createAgentWorktree(slug); + const slug = `agent-${earlyAgentId.slice(0, 8)}` + worktreeInfo = await createAgentWorktree(slug) } // Fork + worktree: inject a notice telling the child to translate paths // and re-read potentially stale files. Appended after the fork directive // so it appears as the most recent guidance the child sees. if (isForkPath && worktreeInfo) { - promptMessages.push(createUserMessage({ - content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath) - })); + promptMessages.push( + createUserMessage({ + content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath), + }), + ) } + const runAgentParams: Parameters[0] = { agentDefinition: selectedAgent, promptMessages, toolUseContext, canUseTool, isAsync: shouldRunAsync, - querySource: toolUseContext.options.querySource ?? getQuerySourceForAgent(selectedAgent.agentType, isBuiltInAgent(selectedAgent)), + querySource: + toolUseContext.options.querySource ?? + getQuerySourceForAgent( + selectedAgent.agentType, + isBuiltInAgent(selectedAgent), + ), model: isForkPath ? undefined : model, // Fork path: pass parent's system prompt AND parent's exact tool // array (cache-identical prefix). workerTools is rebuilt under @@ -619,72 +896,64 @@ export const AgentTool = buildTool({ // or explicit cwd), skip the pre-built system prompt so runAgent's // buildAgentSystemPrompt() runs inside wrapWithCwd where getCwd() // returns the override path. - override: isForkPath ? { - systemPrompt: forkParentSystemPrompt - } : enhancedSystemPrompt && !worktreeInfo && !cwd ? { - systemPrompt: asSystemPrompt(enhancedSystemPrompt) - } : undefined, + override: isForkPath + ? { systemPrompt: forkParentSystemPrompt } + : enhancedSystemPrompt && !worktreeInfo && !cwd + ? { systemPrompt: asSystemPrompt(enhancedSystemPrompt) } + : undefined, availableTools: isForkPath ? toolUseContext.options.tools : workerTools, // Pass parent conversation when the fork-subagent path needs full // context. useExactTools inherits thinkingConfig (runAgent.ts:624). forkContextMessages: isForkPath ? toolUseContext.messages : undefined, - ...(isForkPath && { - useExactTools: true - }), + ...(isForkPath && { useExactTools: true }), worktreePath: worktreeInfo?.worktreePath, - description - }; + description, + } // Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS) // takes precedence over worktree isolation path. - const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath; - const wrapWithCwd = (fn: () => T): T => cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn(); + const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath + const wrapWithCwd = (fn: () => T): T => + cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn() // Helper to clean up worktree after agent completes const cleanupWorktreeIfNeeded = async (): Promise<{ - worktreePath?: string; - worktreeBranch?: string; + worktreePath?: string + worktreeBranch?: string }> => { - if (!worktreeInfo) return {}; - const { - worktreePath, - worktreeBranch, - headCommit, - gitRoot, - hookBased - } = worktreeInfo; + if (!worktreeInfo) return {} + const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = + worktreeInfo // Null out to make idempotent — guards against double-call if code // between cleanup and end of try throws into catch - worktreeInfo = null; + worktreeInfo = null if (hookBased) { // Hook-based worktrees are always kept since we can't detect VCS changes - logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`); - return { - worktreePath - }; + logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`) + return { worktreePath } } if (headCommit) { - const changed = await hasWorktreeChanges(worktreePath, headCommit); + const changed = await hasWorktreeChanges(worktreePath, headCommit) if (!changed) { - await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot); + await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot) // Clear worktreePath from metadata so resume doesn't try to use // a deleted directory. Fire-and-forget to match runAgent's // writeAgentMetadata handling. void writeAgentMetadata(asAgentId(earlyAgentId), { agentType: selectedAgent.agentType, - description - }).catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`)); - return {}; + description, + }).catch(_err => + logForDebugging(`Failed to clear worktree metadata: ${_err}`), + ) + return {} } } - logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`); - return { - worktreePath, - worktreeBranch - }; - }; + logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`) + return { worktreePath, worktreeBranch } + } + if (shouldRunAsync) { - const asyncAgentId = earlyAgentId; + const asyncAgentId = earlyAgentId const agentBackgroundTask = registerAsyncAgent({ agentId: asyncAgentId, description, @@ -694,25 +963,22 @@ export const AgentTool = buildTool({ // Don't link to parent's abort controller -- background agents should // survive when the user presses ESC to cancel the main thread. // They are killed explicitly via chat:killAgents. - toolUseId: toolUseContext.toolUseId - }); + toolUseId: toolUseContext.toolUseId, + }) // Register name → agentId for SendMessage routing. Post-registerAsyncAgent // so we don't leave a stale entry if spawn fails. Sync agents skipped — // coordinator is blocked, so SendMessage routing doesn't apply. if (name) { rootSetAppState(prev => { - const next = new Map(prev.agentNameRegistry); - next.set(name, asAgentId(asyncAgentId)); - return { - ...prev, - agentNameRegistry: next - }; - }); + const next = new Map(prev.agentNameRegistry) + next.set(name, asAgentId(asyncAgentId)) + return { ...prev, agentNameRegistry: next } + }) } // Wrap async agent execution in agent context for analytics attribution - const asyncAgentContext: SubagentContext = { + const asyncAgentContext = { agentId: asyncAgentId, // For subagents from teammates: use team lead's session // For subagents from main REPL: undefined (no parent session) @@ -720,37 +986,50 @@ export const AgentTool = buildTool({ agentType: 'subagent' as const, subagentName: selectedAgent.agentType, isBuiltIn: isBuiltInAgent(selectedAgent), - invokingRequestId: assistantMessage?.requestId as string | undefined, + invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, - invocationEmitted: false - }; + invocationEmitted: false, + } // Workload propagation: handlePromptSubmit wraps the entire turn in // runWithWorkload (AsyncLocalStorage). ALS context is captured at // invocation time — when this `void` fires — and survives every await // inside. No capture/restore needed; the detached closure sees the // parent turn's workload automatically, isolated from its finally. - void runWithAgentContext(asyncAgentContext, () => wrapWithCwd(() => runAsyncAgentLifecycle({ - taskId: agentBackgroundTask.agentId, - abortController: agentBackgroundTask.abortController!, - makeStream: onCacheSafeParams => runAgent({ - ...runAgentParams, - override: { - ...runAgentParams.override, - agentId: asAgentId(agentBackgroundTask.agentId), - abortController: agentBackgroundTask.abortController! - }, - onCacheSafeParams - }), - metadata, - description, - toolUseContext, - rootSetAppState, - agentIdForCleanup: asyncAgentId, - enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(), - getWorktreeResult: cleanupWorktreeIfNeeded - }))); - const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); + void runWithAgentContext(asyncAgentContext, () => + wrapWithCwd(() => + runAsyncAgentLifecycle({ + taskId: agentBackgroundTask.agentId, + abortController: agentBackgroundTask.abortController!, + makeStream: onCacheSafeParams => + runAgent({ + ...runAgentParams, + override: { + ...runAgentParams.override, + agentId: asAgentId(agentBackgroundTask.agentId), + abortController: agentBackgroundTask.abortController!, + }, + onCacheSafeParams, + }), + metadata, + description, + toolUseContext, + rootSetAppState, + agentIdForCleanup: asyncAgentId, + enableSummarization: + isCoordinator || + isForkSubagentEnabled() || + getSdkAgentProgressSummariesEnabled(), + getWorktreeResult: cleanupWorktreeIfNeeded, + }), + ), + ) + + const canReadOutputFile = toolUseContext.options.tools.some( + t => + toolMatchesName(t, FILE_READ_TOOL_NAME) || + toolMatchesName(t, BASH_TOOL_NAME), + ) return { data: { isAsync: true as const, @@ -759,15 +1038,15 @@ export const AgentTool = buildTool({ description: description, prompt: prompt, outputFile: getTaskOutputPath(agentBackgroundTask.agentId), - canReadOutputFile - } - }; + canReadOutputFile, + }, + } } else { // Create an explicit agentId for sync agents - const syncAgentId = asAgentId(earlyAgentId); + const syncAgentId = asAgentId(earlyAgentId) // Set up agent context for sync execution (for analytics attribution) - const syncAgentContext: SubagentContext = { + const syncAgentContext = { agentId: syncAgentId, // For subagents from teammates: use team lead's session // For subagents from main REPL: undefined (no parent session) @@ -775,607 +1054,767 @@ export const AgentTool = buildTool({ agentType: 'subagent' as const, subagentName: selectedAgent.agentType, isBuiltIn: isBuiltInAgent(selectedAgent), - invokingRequestId: assistantMessage?.requestId as string | undefined, + invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, - invocationEmitted: false - }; + invocationEmitted: false, + } // Wrap entire sync agent execution in context for analytics attribution // and optionally in a worktree cwd override for filesystem isolation - return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => { - const agentMessages: MessageType[] = []; - const agentStartTime = Date.now(); - const syncTracker = createProgressTracker(); - const syncResolveActivity = createActivityDescriptionResolver(toolUseContext.options.tools); - - // Yield initial progress message to carry metadata (prompt) - if (promptMessages.length > 0) { - const normalizedPromptMessages = normalizeMessages(promptMessages); - const normalizedFirstMessage = normalizedPromptMessages.find((m): m is NormalizedUserMessage => m.type === 'user'); - if (normalizedFirstMessage && normalizedFirstMessage.type === 'user' && onProgress) { - onProgress({ - toolUseID: `agent_${assistantMessage.message.id}`, - data: { - message: normalizedFirstMessage, - type: 'agent_progress', - prompt, - agentId: syncAgentId - } - }); + return runWithAgentContext(syncAgentContext, () => + wrapWithCwd(async () => { + const agentMessages: MessageType[] = [] + const agentStartTime = Date.now() + const syncTracker = createProgressTracker() + const syncResolveActivity = createActivityDescriptionResolver( + toolUseContext.options.tools, + ) + + // Yield initial progress message to carry metadata (prompt) + if (promptMessages.length > 0) { + const normalizedPromptMessages = normalizeMessages(promptMessages) + const normalizedFirstMessage = normalizedPromptMessages.find( + (m): m is NormalizedUserMessage => m.type === 'user', + ) + if ( + normalizedFirstMessage && + normalizedFirstMessage.type === 'user' && + onProgress + ) { + onProgress({ + toolUseID: `agent_${assistantMessage.message.id}`, + data: { + message: normalizedFirstMessage, + type: 'agent_progress', + prompt, + agentId: syncAgentId, + }, + }) + } } - } - // Register as foreground task immediately so it can be backgrounded at any time - // Skip registration if background tasks are disabled - let foregroundTaskId: string | undefined; - // Create the background race promise once outside the loop — otherwise - // each iteration adds a new .then() reaction to the same pending - // promise, accumulating callbacks for the lifetime of the agent. - let backgroundPromise: Promise<{ - type: 'background'; - }> | undefined; - let cancelAutoBackground: (() => void) | undefined; - if (!isBackgroundTasksDisabled) { - const registration = registerAgentForeground({ - agentId: syncAgentId, - description, - prompt, - selectedAgent, - setAppState: rootSetAppState, - toolUseId: toolUseContext.toolUseId, - autoBackgroundMs: getAutoBackgroundMs() || undefined - }); - foregroundTaskId = registration.taskId; - backgroundPromise = registration.backgroundSignal.then(() => ({ - type: 'background' as const - })); - cancelAutoBackground = registration.cancelAutoBackground; - } + // Register as foreground task immediately so it can be backgrounded at any time + // Skip registration if background tasks are disabled + let foregroundTaskId: string | undefined + // Create the background race promise once outside the loop — otherwise + // each iteration adds a new .then() reaction to the same pending + // promise, accumulating callbacks for the lifetime of the agent. + let backgroundPromise: Promise<{ type: 'background' }> | undefined + let cancelAutoBackground: (() => void) | undefined + if (!isBackgroundTasksDisabled) { + const registration = registerAgentForeground({ + agentId: syncAgentId, + description, + prompt, + selectedAgent, + setAppState: rootSetAppState, + toolUseId: toolUseContext.toolUseId, + autoBackgroundMs: getAutoBackgroundMs() || undefined, + }) + foregroundTaskId = registration.taskId + backgroundPromise = registration.backgroundSignal.then(() => ({ + type: 'background' as const, + })) + cancelAutoBackground = registration.cancelAutoBackground + } - // Track if we've shown the background hint UI - let backgroundHintShown = false; - // Track if the agent was backgrounded (cleanup handled by backgrounded finally) - let wasBackgrounded = false; - // Per-scope stop function — NOT shared with the backgrounded closure. - // idempotent: startAgentSummarization's stop() checks `stopped` flag. - let stopForegroundSummarization: (() => void) | undefined; - // const capture for sound type narrowing inside the callback below - const summaryTaskId = foregroundTaskId; - - // Get async iterator for the agent - const agentIterator = runAgent({ - ...runAgentParams, - override: { - ...runAgentParams.override, - agentId: syncAgentId - }, - onCacheSafeParams: summaryTaskId && getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { - stop - } = startAgentSummarization(summaryTaskId, syncAgentId, params, rootSetAppState); - stopForegroundSummarization = stop; - } : undefined - })[Symbol.asyncIterator](); - - // Track if an error occurred during iteration - let syncAgentError: Error | undefined; - let wasAborted = false; - let worktreeResult: { - worktreePath?: string; - worktreeBranch?: string; - } = {}; - try { - while (true) { - const elapsed = Date.now() - agentStartTime; - - // Show background hint after threshold (but task is already registered) - // Skip if background tasks are disabled - if (!isBackgroundTasksDisabled && !backgroundHintShown && elapsed >= PROGRESS_THRESHOLD_MS && toolUseContext.setToolJSX) { - backgroundHintShown = true; - toolUseContext.setToolJSX({ - jsx: , - shouldHidePromptInput: false, - shouldContinueAnimation: true, - showSpinner: true - }); - } + // Track if we've shown the background hint UI + let backgroundHintShown = false + // Track if the agent was backgrounded (cleanup handled by backgrounded finally) + let wasBackgrounded = false + // Per-scope stop function — NOT shared with the backgrounded closure. + // idempotent: startAgentSummarization's stop() checks `stopped` flag. + let stopForegroundSummarization: (() => void) | undefined + // const capture for sound type narrowing inside the callback below + const summaryTaskId = foregroundTaskId + + // Get async iterator for the agent + const agentIterator = runAgent({ + ...runAgentParams, + override: { + ...runAgentParams.override, + agentId: syncAgentId, + }, + onCacheSafeParams: + summaryTaskId && getSdkAgentProgressSummariesEnabled() + ? (params: CacheSafeParams) => { + const { stop } = startAgentSummarization( + summaryTaskId, + syncAgentId, + params, + rootSetAppState, + ) + stopForegroundSummarization = stop + } + : undefined, + })[Symbol.asyncIterator]() + + // Track if an error occurred during iteration + let syncAgentError: Error | undefined + let wasAborted = false + let worktreeResult: { + worktreePath?: string + worktreeBranch?: string + } = {} + + try { + while (true) { + const elapsed = Date.now() - agentStartTime + + // Show background hint after threshold (but task is already registered) + // Skip if background tasks are disabled + if ( + !isBackgroundTasksDisabled && + !backgroundHintShown && + elapsed >= PROGRESS_THRESHOLD_MS && + toolUseContext.setToolJSX + ) { + backgroundHintShown = true + toolUseContext.setToolJSX({ + jsx: , + shouldHidePromptInput: false, + shouldContinueAnimation: true, + showSpinner: true, + }) + } - // Race between next message and background signal - // If background tasks are disabled, just await the next message directly - const nextMessagePromise = agentIterator.next(); - const raceResult = backgroundPromise ? await Promise.race([nextMessagePromise.then(r => ({ - type: 'message' as const, - result: r - })), backgroundPromise]) : { - type: 'message' as const, - result: await nextMessagePromise - }; - - // Check if we were backgrounded via backgroundAll() - // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' - // because backgroundPromise is only defined when foregroundTaskId is defined - if (raceResult.type === 'background' && foregroundTaskId) { - const appState = toolUseContext.getAppState(); - const task = appState.tasks[foregroundTaskId]; - if (isLocalAgentTask(task) && task.isBackgrounded) { - // Capture the taskId for use in the async callback - const backgroundedTaskId = foregroundTaskId; - wasBackgrounded = true; - // Stop foreground summarization; the backgrounded closure - // below owns its own independent stop function. - stopForegroundSummarization?.(); - - // Workload: inherited via ALS at `void` invocation time, - // same as the async-from-start path above. - // Continue agent in background and return async result - void runWithAgentContext(syncAgentContext, async () => { - let stopBackgroundedSummarization: (() => void) | undefined; - try { - // Clean up the foreground iterator so its finally block runs - // (releases MCP connections, session hooks, prompt cache tracking, etc.) - // Timeout prevents blocking if MCP server cleanup hangs. - // .catch() prevents unhandled rejection if timeout wins the race. - await Promise.race([agentIterator.return(undefined).catch(() => {}), sleep(1000)]); - // Initialize progress tracking from existing messages - const tracker = createProgressTracker(); - const resolveActivity2 = createActivityDescriptionResolver(toolUseContext.options.tools); - for (const existingMsg of agentMessages) { - updateProgressFromMessage(tracker, existingMsg, resolveActivity2, toolUseContext.options.tools); - } - for await (const msg of runAgent({ - ...runAgentParams, - isAsync: true, - // Agent is now running in background - override: { - ...runAgentParams.override, - agentId: asAgentId(backgroundedTaskId), - abortController: task.abortController - }, - onCacheSafeParams: getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { - stop - } = startAgentSummarization(backgroundedTaskId, asAgentId(backgroundedTaskId), params, rootSetAppState); - stopBackgroundedSummarization = stop; - } : undefined - })) { - agentMessages.push(msg); - - // Track progress for backgrounded agents - updateProgressFromMessage(tracker, msg, resolveActivity2, toolUseContext.options.tools); - updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState); - const lastToolName = getLastToolUseName(msg); - if (lastToolName) { - emitTaskProgress(tracker, backgroundedTaskId, toolUseContext.toolUseId, description, startTime, lastToolName); + // Race between next message and background signal + // If background tasks are disabled, just await the next message directly + const nextMessagePromise = agentIterator.next() + const raceResult = backgroundPromise + ? await Promise.race([ + nextMessagePromise.then(r => ({ + type: 'message' as const, + result: r, + })), + backgroundPromise, + ]) + : { + type: 'message' as const, + result: await nextMessagePromise, + } + + // Check if we were backgrounded via backgroundAll() + // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' + // because backgroundPromise is only defined when foregroundTaskId is defined + if (raceResult.type === 'background' && foregroundTaskId) { + const appState = toolUseContext.getAppState() + const task = appState.tasks[foregroundTaskId] + if (isLocalAgentTask(task) && task.isBackgrounded) { + // Capture the taskId for use in the async callback + const backgroundedTaskId = foregroundTaskId + wasBackgrounded = true + // Stop foreground summarization; the backgrounded closure + // below owns its own independent stop function. + stopForegroundSummarization?.() + + // Workload: inherited via ALS at `void` invocation time, + // same as the async-from-start path above. + // Continue agent in background and return async result + void runWithAgentContext(syncAgentContext, async () => { + let stopBackgroundedSummarization: (() => void) | undefined + try { + // Clean up the foreground iterator so its finally block runs + // (releases MCP connections, session hooks, prompt cache tracking, etc.) + // Timeout prevents blocking if MCP server cleanup hangs. + // .catch() prevents unhandled rejection if timeout wins the race. + await Promise.race([ + agentIterator.return(undefined).catch(() => {}), + sleep(1000), + ]) + // Initialize progress tracking from existing messages + const tracker = createProgressTracker() + const resolveActivity2 = + createActivityDescriptionResolver( + toolUseContext.options.tools, + ) + for (const existingMsg of agentMessages) { + updateProgressFromMessage( + tracker, + existingMsg, + resolveActivity2, + toolUseContext.options.tools, + ) } - } - const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); - - // Mark task completed FIRST so TaskOutput(block=true) - // unblocks immediately. classifyHandoffIfNeeded and - // cleanupWorktreeIfNeeded can hang — they must not gate - // the status transition (gh-20236). - completeAsyncAgent(agentResult, rootSetAppState); - - // Extract text from agent result content for the notification - let finalMessage = extractTextContent(agentResult.content, '\n'); - if (feature('TRANSCRIPT_CLASSIFIER')) { - const backgroundedAppState = toolUseContext.getAppState(); - const handoffWarning = await classifyHandoffIfNeeded({ + for await (const msg of runAgent({ + ...runAgentParams, + isAsync: true, // Agent is now running in background + override: { + ...runAgentParams.override, + agentId: asAgentId(backgroundedTaskId), + abortController: task.abortController, + }, + onCacheSafeParams: getSdkAgentProgressSummariesEnabled() + ? (params: CacheSafeParams) => { + const { stop } = startAgentSummarization( + backgroundedTaskId, + asAgentId(backgroundedTaskId), + params, + rootSetAppState, + ) + stopBackgroundedSummarization = stop + } + : undefined, + })) { + agentMessages.push(msg) + + // Track progress for backgrounded agents + updateProgressFromMessage( + tracker, + msg, + resolveActivity2, + toolUseContext.options.tools, + ) + updateAsyncAgentProgress( + backgroundedTaskId, + getProgressUpdate(tracker), + rootSetAppState, + ) + + const lastToolName = getLastToolUseName(msg) + if (lastToolName) { + emitTaskProgress( + tracker, + backgroundedTaskId, + toolUseContext.toolUseId, + description, + startTime, + lastToolName, + ) + } + } + const agentResult = finalizeAgentTool( agentMessages, - tools: toolUseContext.options.tools, - toolPermissionContext: backgroundedAppState.toolPermissionContext, - abortSignal: task.abortController!.signal, - subagentType: selectedAgent.agentType, - totalToolUseCount: agentResult.totalToolUseCount - }); - if (handoffWarning) { - finalMessage = `${handoffWarning}\n\n${finalMessage}`; + backgroundedTaskId, + metadata, + ) + + // Mark task completed FIRST so TaskOutput(block=true) + // unblocks immediately. classifyHandoffIfNeeded and + // cleanupWorktreeIfNeeded can hang — they must not gate + // the status transition (gh-20236). + completeAsyncAgent(agentResult, rootSetAppState) + + // Extract text from agent result content for the notification + let finalMessage = extractTextContent( + agentResult.content, + '\n', + ) + + if (feature('TRANSCRIPT_CLASSIFIER')) { + const backgroundedAppState = + toolUseContext.getAppState() + const handoffWarning = await classifyHandoffIfNeeded({ + agentMessages, + tools: toolUseContext.options.tools, + toolPermissionContext: + backgroundedAppState.toolPermissionContext, + abortSignal: task.abortController!.signal, + subagentType: selectedAgent.agentType, + totalToolUseCount: agentResult.totalToolUseCount, + }) + if (handoffWarning) { + finalMessage = `${handoffWarning}\n\n${finalMessage}` + } } - } - // Clean up worktree before notification so we can include it - const worktreeResult = await cleanupWorktreeIfNeeded(); - enqueueAgentNotification({ - taskId: backgroundedTaskId, - description, - status: 'completed', - setAppState: rootSetAppState, - finalMessage, - usage: { - totalTokens: getTokenCountFromTracker(tracker), - toolUses: agentResult.totalToolUseCount, - durationMs: agentResult.totalDurationMs - }, - toolUseId: toolUseContext.toolUseId, - ...worktreeResult - }); - } catch (error) { - if (error instanceof AbortError) { - // Transition status BEFORE worktree cleanup so - // TaskOutput unblocks even if git hangs (gh-20236). - killAsyncAgent(backgroundedTaskId, rootSetAppState); - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: true, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const worktreeResult = await cleanupWorktreeIfNeeded(); - const partialResult = extractPartialResult(agentMessages); + // Clean up worktree before notification so we can include it + const worktreeResult = await cleanupWorktreeIfNeeded() + + enqueueAgentNotification({ + taskId: backgroundedTaskId, + description, + status: 'completed', + setAppState: rootSetAppState, + finalMessage, + usage: { + totalTokens: getTokenCountFromTracker(tracker), + toolUses: agentResult.totalToolUseCount, + durationMs: agentResult.totalDurationMs, + }, + toolUseId: toolUseContext.toolUseId, + ...worktreeResult, + }) + } catch (error) { + if (error instanceof AbortError) { + // Transition status BEFORE worktree cleanup so + // TaskOutput unblocks even if git hangs (gh-20236). + killAsyncAgent(backgroundedTaskId, rootSetAppState) + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: true, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const worktreeResult = await cleanupWorktreeIfNeeded() + const partialResult = + extractPartialResult(agentMessages) + enqueueAgentNotification({ + taskId: backgroundedTaskId, + description, + status: 'killed', + setAppState: rootSetAppState, + toolUseId: toolUseContext.toolUseId, + finalMessage: partialResult, + ...worktreeResult, + }) + return + } + const errMsg = errorMessage(error) + failAsyncAgent( + backgroundedTaskId, + errMsg, + rootSetAppState, + ) + const worktreeResult = await cleanupWorktreeIfNeeded() enqueueAgentNotification({ taskId: backgroundedTaskId, description, - status: 'killed', + status: 'failed', + error: errMsg, setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, - finalMessage: partialResult, - ...worktreeResult - }); - return; + ...worktreeResult, + }) + } finally { + stopBackgroundedSummarization?.() + clearInvokedSkillsForAgent(syncAgentId) + clearDumpState(syncAgentId) + // Note: worktree cleanup is done before enqueueAgentNotification + // in both try and catch paths so we can include worktree info } - const errMsg = errorMessage(error); - failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState); - const worktreeResult = await cleanupWorktreeIfNeeded(); - enqueueAgentNotification({ - taskId: backgroundedTaskId, - description, - status: 'failed', - error: errMsg, - setAppState: rootSetAppState, - toolUseId: toolUseContext.toolUseId, - ...worktreeResult - }); - } finally { - stopBackgroundedSummarization?.(); - clearInvokedSkillsForAgent(syncAgentId); - clearDumpState(syncAgentId); - // Note: worktree cleanup is done before enqueueAgentNotification - // in both try and catch paths so we can include worktree info + }) + + // Return async_launched result immediately + const canReadOutputFile = toolUseContext.options.tools.some( + t => + toolMatchesName(t, FILE_READ_TOOL_NAME) || + toolMatchesName(t, BASH_TOOL_NAME), + ) + return { + data: { + isAsync: true as const, + status: 'async_launched' as const, + agentId: backgroundedTaskId, + description: description, + prompt: prompt, + outputFile: getTaskOutputPath(backgroundedTaskId), + canReadOutputFile, + }, } - }); - - // Return async_launched result immediately - const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); - return { - data: { - isAsync: true as const, - status: 'async_launched' as const, - agentId: backgroundedTaskId, - description: description, - prompt: prompt, - outputFile: getTaskOutputPath(backgroundedTaskId), - canReadOutputFile + } + } + + // Process the message from the race result + if (raceResult.type !== 'message') { + // This shouldn't happen - background case handled above + continue + } + const { result } = raceResult + if (result.done) break + const message = result.value + + agentMessages.push(message) + + // Emit task_progress for the VS Code subagent panel + updateProgressFromMessage( + syncTracker, + message, + syncResolveActivity, + toolUseContext.options.tools, + ) + if (foregroundTaskId) { + const lastToolName = getLastToolUseName(message) + if (lastToolName) { + emitTaskProgress( + syncTracker, + foregroundTaskId, + toolUseContext.toolUseId, + description, + agentStartTime, + lastToolName, + ) + // Keep AppState task.progress in sync when SDK summaries are + // enabled, so updateAgentSummary reads correct token/tool counts + // instead of zeros. + if (getSdkAgentProgressSummariesEnabled()) { + updateAsyncAgentProgress( + foregroundTaskId, + getProgressUpdate(syncTracker), + rootSetAppState, + ) } - }; + } } - } - // Process the message from the race result - if (raceResult.type !== 'message') { - // This shouldn't happen - background case handled above - continue; - } - const { - result - } = raceResult; - if (result.done) break; - const message = result.value as MessageType; - agentMessages.push(message); - - // Emit task_progress for the VS Code subagent panel - updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools); - if (foregroundTaskId) { - const lastToolName = getLastToolUseName(message); - if (lastToolName) { - emitTaskProgress(syncTracker, foregroundTaskId, toolUseContext.toolUseId, description, agentStartTime, lastToolName); - // Keep AppState task.progress in sync when SDK summaries are - // enabled, so updateAgentSummary reads correct token/tool counts - // instead of zeros. - if (getSdkAgentProgressSummariesEnabled()) { - updateAsyncAgentProgress(foregroundTaskId, getProgressUpdate(syncTracker), rootSetAppState); + // Forward bash_progress events from sub-agent to parent so the SDK + // receives tool_progress events just as it does for the main agent. + if ( + message.type === 'progress' && + (message.data.type === 'bash_progress' || + message.data.type === 'powershell_progress') && + onProgress + ) { + onProgress({ + toolUseID: message.toolUseID, + data: message.data, + }) + } + + if (message.type !== 'assistant' && message.type !== 'user') { + continue + } + + // Increment token count in spinner for assistant messages + // Subagent streaming events are filtered out in runAgent.ts, so we + // need to count tokens from completed messages here + if (message.type === 'assistant') { + const contentLength = getAssistantMessageContentLength(message) + if (contentLength > 0) { + toolUseContext.setResponseLength(len => len + contentLength) } } - } - // Forward bash_progress events from sub-agent to parent so the SDK - // receives tool_progress events just as it does for the main agent. - if (message.type === 'progress' && ((message.data as { type?: string })?.type === 'bash_progress' || (message.data as { type?: string })?.type === 'powershell_progress') && onProgress) { - onProgress({ - toolUseID: message.toolUseID as string, - data: message.data - }); + const normalizedNew = normalizeMessages([message]) + for (const m of normalizedNew) { + for (const content of m.message.content) { + if ( + content.type !== 'tool_use' && + content.type !== 'tool_result' + ) { + continue + } + + // Forward progress updates + if (onProgress) { + onProgress({ + toolUseID: `agent_${assistantMessage.message.id}`, + data: { + message: m, + type: 'agent_progress', + // prompt only needed on first progress message (UI.tsx:624 + // reads progressMessages[0]). Omit here to avoid duplication. + prompt: '', + agentId: syncAgentId, + }, + }) + } + } + } } - if (message.type !== 'assistant' && message.type !== 'user') { - continue; + } catch (error) { + // Handle errors from the sync agent loop + // AbortError should be re-thrown for proper interruption handling + if (error instanceof AbortError) { + wasAborted = true + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: false, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw error } - // Increment token count in spinner for assistant messages - // Subagent streaming events are filtered out in runAgent.ts, so we - // need to count tokens from completed messages here - if (message.type === 'assistant') { - const contentLength = getAssistantMessageContentLength(message as AssistantMessage); - if (contentLength > 0) { - toolUseContext.setResponseLength(len => len + contentLength); - } + // Log the error for debugging + logForDebugging(`Sync agent error: ${errorMessage(error)}`, { + level: 'error', + }) + + // Store the error to handle after cleanup + syncAgentError = toError(error) + } finally { + // Clear the background hint UI + if (toolUseContext.setToolJSX) { + toolUseContext.setToolJSX(null) } - const normalizedNew = normalizeMessages([message]); - for (const m of normalizedNew) { - for (const content of (m.message.content as unknown as Array<{ type: string; [key: string]: unknown }>)) { - if (content.type !== 'tool_use' && content.type !== 'tool_result') { - continue; - } - // Forward progress updates - if (onProgress) { - onProgress({ - toolUseID: `agent_${assistantMessage.message.id}`, - data: { - message: m, - type: 'agent_progress', - // prompt only needed on first progress message (UI.tsx:624 - // reads progressMessages[0]). Omit here to avoid duplication. - prompt: '', - agentId: syncAgentId - } - }); - } + // Stop foreground summarization. Idempotent — if already stopped at + // the backgrounding transition, this is a no-op. The backgrounded + // closure owns a separate stop function (stopBackgroundedSummarization). + stopForegroundSummarization?.() + + // Unregister foreground task if agent completed without being backgrounded + if (foregroundTaskId) { + unregisterAgentForeground(foregroundTaskId, rootSetAppState) + // Notify SDK consumers (e.g. VS Code subagent panel) that this + // foreground agent is done. Goes through drainSdkEvents() — does + // NOT trigger the print.ts XML task_notification parser or the LLM loop. + if (!wasBackgrounded) { + const progress = getProgressUpdate(syncTracker) + enqueueSdkEvent({ + type: 'system', + subtype: 'task_notification', + task_id: foregroundTaskId, + tool_use_id: toolUseContext.toolUseId, + status: syncAgentError + ? 'failed' + : wasAborted + ? 'stopped' + : 'completed', + output_file: '', + summary: description, + usage: { + total_tokens: progress.tokenCount, + tool_uses: progress.toolUseCount, + duration_ms: Date.now() - agentStartTime, + }, + }) } } - } - } catch (error) { - // Handle errors from the sync agent loop - // AbortError should be re-thrown for proper interruption handling - if (error instanceof AbortError) { - wasAborted = true; - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: false, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw error; - } - // Log the error for debugging - logForDebugging(`Sync agent error: ${errorMessage(error)}`, { - level: 'error' - }); - - // Store the error to handle after cleanup - syncAgentError = toError(error); - } finally { - // Clear the background hint UI - if (toolUseContext.setToolJSX) { - toolUseContext.setToolJSX(null); - } + // Clean up scoped skills so they don't accumulate in the global map + clearInvokedSkillsForAgent(syncAgentId) - // Stop foreground summarization. Idempotent — if already stopped at - // the backgrounding transition, this is a no-op. The backgrounded - // closure owns a separate stop function (stopBackgroundedSummarization). - stopForegroundSummarization?.(); - - // Unregister foreground task if agent completed without being backgrounded - if (foregroundTaskId) { - unregisterAgentForeground(foregroundTaskId, rootSetAppState); - // Notify SDK consumers (e.g. VS Code subagent panel) that this - // foreground agent is done. Goes through drainSdkEvents() — does - // NOT trigger the print.ts XML task_notification parser or the LLM loop. + // Clean up dumpState entry for this agent to prevent unbounded growth + // Skip if backgrounded — the backgrounded agent's finally handles cleanup if (!wasBackgrounded) { - const progress = getProgressUpdate(syncTracker); - enqueueSdkEvent({ - type: 'system', - subtype: 'task_notification', - task_id: foregroundTaskId, - tool_use_id: toolUseContext.toolUseId, - status: syncAgentError ? 'failed' : wasAborted ? 'stopped' : 'completed', - output_file: '', - summary: description, - usage: { - total_tokens: progress.tokenCount, - tool_uses: progress.toolUseCount, - duration_ms: Date.now() - agentStartTime - } - }); + clearDumpState(syncAgentId) } - } - // Clean up scoped skills so they don't accumulate in the global map - clearInvokedSkillsForAgent(syncAgentId); + // Cancel auto-background timer if agent completed before it fired + cancelAutoBackground?.() - // Clean up dumpState entry for this agent to prevent unbounded growth - // Skip if backgrounded — the backgrounded agent's finally handles cleanup - if (!wasBackgrounded) { - clearDumpState(syncAgentId); + // Clean up worktree if applicable (in finally to handle abort/error paths) + // Skip if backgrounded — the background continuation is still running in it + if (!wasBackgrounded) { + worktreeResult = await cleanupWorktreeIfNeeded() + } } - // Cancel auto-background timer if agent completed before it fired - cancelAutoBackground?.(); - - // Clean up worktree if applicable (in finally to handle abort/error paths) - // Skip if backgrounded — the background continuation is still running in it - if (!wasBackgrounded) { - worktreeResult = await cleanupWorktreeIfNeeded(); + // Re-throw abort errors + // TODO: Find a cleaner way to express this + const lastMessage = agentMessages.findLast( + _ => _.type !== 'system' && _.type !== 'progress', + ) + if (lastMessage && isSyntheticMessage(lastMessage)) { + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: false, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw new AbortError() } - } - // Re-throw abort errors - // TODO: Find a cleaner way to express this - const lastMessage = agentMessages.findLast(_ => _.type !== 'system' && _.type !== 'progress'); - if (lastMessage && isSyntheticMessage(lastMessage)) { - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: false, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw new AbortError(); - } + // If an error occurred during iteration, try to return a result with + // whatever messages we have. If we have no assistant messages, + // re-throw the error so it's properly handled by the tool framework. + if (syncAgentError) { + // Check if we have any assistant messages to return + const hasAssistantMessages = agentMessages.some( + msg => msg.type === 'assistant', + ) + + if (!hasAssistantMessages) { + // No messages collected, re-throw the error + throw syncAgentError + } - // If an error occurred during iteration, try to return a result with - // whatever messages we have. If we have no assistant messages, - // re-throw the error so it's properly handled by the tool framework. - if (syncAgentError) { - // Check if we have any assistant messages to return - const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant'); - if (!hasAssistantMessages) { - // No messages collected, re-throw the error - throw syncAgentError; + // We have some messages, try to finalize and return them + // This allows the parent agent to see partial progress even after an error + logForDebugging( + `Sync agent recovering from error with ${agentMessages.length} messages`, + ) } - // We have some messages, try to finalize and return them - // This allows the parent agent to see partial progress even after an error - logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`); - } - const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata); - if (feature('TRANSCRIPT_CLASSIFIER')) { - const currentAppState = toolUseContext.getAppState(); - const handoffWarning = await classifyHandoffIfNeeded({ + const agentResult = finalizeAgentTool( agentMessages, - tools: toolUseContext.options.tools, - toolPermissionContext: currentAppState.toolPermissionContext, - abortSignal: toolUseContext.abortController.signal, - subagentType: selectedAgent.agentType, - totalToolUseCount: agentResult.totalToolUseCount - }); - if (handoffWarning) { - agentResult.content = [{ - type: 'text' as const, - text: handoffWarning - }, ...agentResult.content]; + syncAgentId, + metadata, + ) + + if (feature('TRANSCRIPT_CLASSIFIER')) { + const currentAppState = toolUseContext.getAppState() + const handoffWarning = await classifyHandoffIfNeeded({ + agentMessages, + tools: toolUseContext.options.tools, + toolPermissionContext: currentAppState.toolPermissionContext, + abortSignal: toolUseContext.abortController.signal, + subagentType: selectedAgent.agentType, + totalToolUseCount: agentResult.totalToolUseCount, + }) + if (handoffWarning) { + agentResult.content = [ + { type: 'text' as const, text: handoffWarning }, + ...agentResult.content, + ] + } } - } - return { - data: { - status: 'completed' as const, - prompt, - ...agentResult, - ...worktreeResult + + return { + data: { + status: 'completed' as const, + prompt, + ...agentResult, + ...worktreeResult, + }, } - }; - })); + }), + ) } }, isReadOnly() { - return true; // delegates permission checks to its underlying tools + return true // delegates permission checks to its underlying tools }, toAutoClassifierInput(input) { - const i = input as AgentToolInput; - const tags = [i.subagent_type, i.mode ? `mode=${i.mode}` : undefined].filter((t): t is string => t !== undefined); - const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': '; - return `${prefix}${i.prompt}`; + const i = input as AgentToolInput + const tags = [ + i.subagent_type, + i.mode ? `mode=${i.mode}` : undefined, + ].filter((t): t is string => t !== undefined) + const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': ' + return `${prefix}${i.prompt}` }, isConcurrencySafe() { - return true; + return true }, userFacingName, userFacingNameBackgroundColor, getActivityDescription(input) { - return input?.description ?? 'Running task'; + return input?.description ?? 'Running task' }, async checkPermissions(input, context): Promise { - const appState = context.getAppState(); + const appState = context.getAppState() // Only route through auto mode classifier when in auto mode // In all other modes, auto-approve sub-agent generation - // Note: "external" === 'ant' guard enables dead code elimination for external builds - if ((process.env.USER_TYPE) === 'ant' && appState.toolPermissionContext.mode === 'auto') { + // Note: process.env.USER_TYPE === 'ant' guard enables dead code elimination for external builds + if ( + process.env.USER_TYPE === 'ant' && + appState.toolPermissionContext.mode === 'auto' + ) { return { behavior: 'passthrough', - message: 'Agent tool requires permission to spawn sub-agents.' - }; + message: 'Agent tool requires permission to spawn sub-agents.', + } } - return { - behavior: 'allow', - updatedInput: input - }; + + return { behavior: 'allow', updatedInput: input } }, mapToolResultToToolResultBlockParam(data, toolUseID) { // Multi-agent spawn result - const internalData = data as InternalOutput; - if (typeof internalData === 'object' && internalData !== null && 'status' in internalData && internalData.status === 'teammate_spawned') { - const spawnData = internalData as TeammateSpawnedOutput; + const internalData = data as InternalOutput + if ( + typeof internalData === 'object' && + internalData !== null && + 'status' in internalData && + internalData.status === 'teammate_spawned' + ) { + const spawnData = internalData as TeammateSpawnedOutput return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text: `Spawned successfully. + content: [ + { + type: 'text', + text: `Spawned successfully. agent_id: ${spawnData.teammate_id} name: ${spawnData.name} team_name: ${spawnData.team_name} -The agent is now running and will receive instructions via mailbox.` - }] - }; +The agent is now running and will receive instructions via mailbox.`, + }, + ], + } } if ('status' in internalData && internalData.status === 'remote_launched') { - const r = internalData; + const r = internalData return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.` - }] - }; + content: [ + { + type: 'text', + text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.`, + }, + ], + } } if (data.status === 'async_launched') { - const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`; - const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`; - const text = `${prefix}\n${instructions}`; + const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.` + const instructions = data.canReadOutputFile + ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` + : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.` + const text = `${prefix}\n${instructions}` return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text - }] - }; + content: [ + { + type: 'text', + text, + }, + ], + } } if (data.status === 'completed') { - const worktreeData = data as Record; - const worktreeInfoText = worktreeData.worktreePath ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` : ''; + const worktreeData = data as Record + const worktreeInfoText = worktreeData.worktreePath + ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` + : '' // If the subagent completes with no content, the tool_result is just the // agentId/usage trailer below — a metadata-only block at the prompt tail. // Some models read that as "nothing to act on" and end their turn // immediately. Say so explicitly so the parent has something to react to. - const contentOrMarker = data.content.length > 0 ? data.content : [{ - type: 'text' as const, - text: '(Subagent completed but returned no output.)' - }]; + const contentOrMarker = + data.content.length > 0 + ? data.content + : [ + { + type: 'text' as const, + text: '(Subagent completed but returned no output.)', + }, + ] // One-shot built-ins (Explore, Plan) are never continued via SendMessage // — the agentId hint and block are dead weight (~135 chars × // 34M Explore runs/week ≈ 1-2 Gtok/week). Telemetry doesn't parse this // block (it uses logEvent in finalizeAgentTool), so dropping is safe. // agentType is optional for resume compat — missing means show trailer. - if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText) { + if ( + data.agentType && + ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && + !worktreeInfoText + ) { return { tool_use_id: toolUseID, type: 'tool_result', - content: contentOrMarker - }; + content: contentOrMarker, + } } return { tool_use_id: toolUseID, type: 'tool_result', - content: [...contentOrMarker, { - type: 'text', - text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText} + content: [ + ...contentOrMarker, + { + type: 'text', + text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText} total_tokens: ${data.totalTokens} tool_uses: ${data.totalToolUseCount} -duration_ms: ${data.totalDurationMs}` - }] - }; +duration_ms: ${data.totalDurationMs}`, + }, + ], + } } - data satisfies never; - throw new Error(`Unexpected agent tool result status: ${(data as { - status: string; - }).status}`); + data satisfies never + throw new Error( + `Unexpected agent tool result status: ${(data as { status: string }).status}`, + ) }, renderToolResultMessage, renderToolUseMessage, @@ -1383,15 +1822,13 @@ duration_ms: ${data.totalDurationMs}` renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseErrorMessage, - renderGroupedToolUse: renderGroupedAgentToolUse -} satisfies ToolDef); -function resolveTeamName(input: { - team_name?: string; -}, appState: { - teamContext?: { - teamName: string; - }; -}): string | undefined { - if (!isAgentSwarmsEnabled()) return undefined; - return input.team_name || appState.teamContext?.teamName; + renderGroupedToolUse: renderGroupedAgentToolUse, +} satisfies ToolDef) + +function resolveTeamName( + input: { team_name?: string }, + appState: { teamContext?: { teamName: string } }, +): string | undefined { + if (!isAgentSwarmsEnabled()) return undefined + return input.team_name || appState.teamContext?.teamName } diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index ff0eb632f..aaa312e20 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -1,36 +1,57 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; -import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; -import { Byline } from 'src/components/design-system/Byline.js'; -import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js'; -import type { z } from 'zod/v4'; -import { AgentProgressLine } from '../../components/AgentProgressLine.js'; -import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; -import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js'; -import { Markdown } from '../../components/Markdown.js'; -import { Message as MessageComponent } from '../../components/Message.js'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { ToolUseLoader } from '../../components/ToolUseLoader.js'; -import { Box, Text } from '../../ink.js'; -import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; -import { findToolByName, type Tools } from '../../Tool.js'; -import type { Message, ProgressMessage } from '../../types/message.js'; -import type { AgentToolProgress } from '../../types/tools.js'; -import { count } from '../../utils/array.js'; -import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatDuration, formatNumber } from '../../utils/format.js'; -import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js'; -import type { ModelAlias } from '../../utils/model/aliases.js'; -import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js'; -import type { Theme, ThemeName } from '../../utils/theme.js'; -import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; -import { inputSchema } from './AgentTool.js'; -import { getAgentColor } from './agentColorManager.js'; -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; -const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; +import type { + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js' +import { + CtrlOToExpand, + SubAgentProvider, +} from 'src/components/CtrlOToExpand.js' +import { Byline } from 'src/components/design-system/Byline.js' +import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js' +import type { z } from 'zod/v4' +import { AgentProgressLine } from '../../components/AgentProgressLine.js' +import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js' +import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js' +import { Markdown } from '../../components/Markdown.js' +import { Message as MessageComponent } from '../../components/Message.js' +import { MessageResponse } from '../../components/MessageResponse.js' +import { ToolUseLoader } from '../../components/ToolUseLoader.js' +import { Box, Text } from '../../ink.js' +import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' +import { findToolByName, type Tools } from '../../Tool.js' +import type { Message, ProgressMessage } from '../../types/message.js' +import type { AgentToolProgress } from '../../types/tools.js' +import { count } from '../../utils/array.js' +import { + getSearchOrReadFromContent, + getSearchReadSummaryText, +} from '../../utils/collapseReadSearch.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatDuration, formatNumber } from '../../utils/format.js' +import { + buildSubagentLookups, + createAssistantMessage, + EMPTY_LOOKUPS, +} from '../../utils/messages.js' +import type { ModelAlias } from '../../utils/model/aliases.js' +import { + getMainLoopModel, + parseUserSpecifiedModel, + renderModelName, +} from '../../utils/model/model.js' +import type { Theme, ThemeName } from '../../utils/theme.js' +import type { + outputSchema, + Progress, + RemoteLaunchedOutput, +} from './AgentTool.js' +import { inputSchema } from './AgentTool.js' +import { getAgentColor } from './agentColorManager.js' +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' + +const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 /** * Guard: checks if progress data has a `message` field (agent_progress or @@ -39,10 +60,10 @@ const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; */ function hasProgressMessage(data: Progress): data is AgentToolProgress { if (!('message' in data)) { - return false; + return false } - const msg = (data as AgentToolProgress).message; - return msg != null && typeof msg === 'object' && 'type' in msg; + const msg = (data as AgentToolProgress).message + return msg != null && typeof msg === 'object' && 'type' in msg } /** @@ -52,93 +73,112 @@ function hasProgressMessage(data: Progress): data is AgentToolProgress { * For tool_result messages, uses the provided `toolUseByID` map to find the * corresponding tool_use block instead of relying on `normalizedMessages`. */ -function getSearchOrReadInfo(progressMessage: ProgressMessage, tools: Tools, toolUseByID: Map): { - isSearch: boolean; - isRead: boolean; - isREPL: boolean; -} | null { +function getSearchOrReadInfo( + progressMessage: ProgressMessage, + tools: Tools, + toolUseByID: Map, +): { isSearch: boolean; isRead: boolean; isREPL: boolean } | null { if (!hasProgressMessage(progressMessage.data)) { - return null; + return null } - const message = progressMessage.data.message; + const message = progressMessage.data.message // Check tool_use (assistant message) if (message.type === 'assistant') { - return getSearchOrReadFromContent(message.message.content[0], tools); + return getSearchOrReadFromContent(message.message.content[0], tools) } // Check tool_result (user message) - find corresponding tool use from the map if (message.type === 'user') { - const content = message.message.content[0]; + const content = message.message.content[0] if (content?.type === 'tool_result') { - const toolUse = toolUseByID.get(content.tool_use_id); + const toolUse = toolUseByID.get(content.tool_use_id) if (toolUse) { - return getSearchOrReadFromContent(toolUse, tools); + return getSearchOrReadFromContent(toolUse, tools) } } } - return null; + + return null } + type SummaryMessage = { - type: 'summary'; - searchCount: number; - readCount: number; - replCount: number; - uuid: string; - isActive: boolean; // true if still in progress (last message was tool_use, not tool_result) -}; -type ProcessedMessage = { - type: 'original'; - message: ProgressMessage; -} | SummaryMessage; + type: 'summary' + searchCount: number + readCount: number + replCount: number + uuid: string + isActive: boolean // true if still in progress (last message was tool_use, not tool_result) +} + +type ProcessedMessage = + | { type: 'original'; message: ProgressMessage } + | SummaryMessage /** * Process progress messages to group consecutive search/read operations into summaries. * For ants only - returns original messages for non-ants. * @param isAgentRunning - If true, the last group is always marked as active (in progress) */ -function processProgressMessages(messages: ProgressMessage[], tools: Tools, isAgentRunning: boolean): ProcessedMessage[] { +function processProgressMessages( + messages: ProgressMessage[], + tools: Tools, + isAgentRunning: boolean, +): ProcessedMessage[] { // Only process for ants - if ((process.env.USER_TYPE) !== 'ant') { - return messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data) && m.data.message.type !== 'user').map(m => ({ - type: 'original', - message: m - })); + if ("external" !== 'ant') { + return messages + .filter( + (m): m is ProgressMessage => + hasProgressMessage(m.data) && m.data.message.type !== 'user', + ) + .map(m => ({ type: 'original', message: m })) } - const result: ProcessedMessage[] = []; + + const result: ProcessedMessage[] = [] let currentGroup: { - searchCount: number; - readCount: number; - replCount: number; - startUuid: string; - } | null = null; + searchCount: number + readCount: number + replCount: number + startUuid: string + } | null = null + function flushGroup(isActive: boolean): void { - if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { + if ( + currentGroup && + (currentGroup.searchCount > 0 || + currentGroup.readCount > 0 || + currentGroup.replCount > 0) + ) { result.push({ type: 'summary', searchCount: currentGroup.searchCount, readCount: currentGroup.readCount, replCount: currentGroup.replCount, uuid: `summary-${currentGroup.startUuid}`, - isActive - }); + isActive, + }) } - currentGroup = null; + currentGroup = null } - const agentMessages = messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data)); + + const agentMessages = messages.filter( + (m): m is ProgressMessage => hasProgressMessage(m.data), + ) // Build tool_use lookup incrementally as we iterate - const toolUseByID = new Map(); + const toolUseByID = new Map() for (const msg of agentMessages) { // Track tool_use blocks as we see them if (msg.data.message.type === 'assistant') { for (const c of msg.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam); + toolUseByID.set(c.id, c as ToolUseBlockParam) } } } - const info = getSearchOrReadInfo(msg, tools, toolUseByID); + const info = getSearchOrReadInfo(msg, tools, toolUseByID) + if (info && (info.isSearch || info.isRead || info.isREPL)) { // This is a search/read/REPL operation - add to current group if (!currentGroup) { @@ -146,188 +186,163 @@ function processProgressMessages(messages: ProgressMessage[], tools: T searchCount: 0, readCount: 0, replCount: 0, - startUuid: msg.uuid - }; + startUuid: msg.uuid, + } } // Only count tool_result messages (not tool_use) to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - currentGroup.searchCount++; + currentGroup.searchCount++ } else if (info.isREPL) { - currentGroup.replCount++; + currentGroup.replCount++ } else if (info.isRead) { - currentGroup.readCount++; + currentGroup.readCount++ } } } else { // Non-search/read/REPL message - flush current group (completed) and add this message - flushGroup(false); + flushGroup(false) // Skip user tool_result messages — subagent progress messages lack // toolUseResult, so UserToolSuccessMessage returns null and the // height=1 Box in renderToolUseProgressMessage shows as a blank line. if (msg.data.message.type !== 'user') { - result.push({ - type: 'original', - message: msg - }); + result.push({ type: 'original', message: msg }) } } } // Flush any remaining group - it's active if the agent is still running - flushGroup(isAgentRunning); - return result; -} -const ESTIMATED_LINES_PER_TOOL = 9; -const TERMINAL_BUFFER_LINES = 7; -type Output = z.input>; -export function AgentPromptDisplay(t0) { - const $ = _c(3); - const { - prompt, - dim: t1 - } = t0; - t1 === undefined ? false : t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Prompt:; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== prompt) { - t3 = {t2}{prompt}; - $[1] = prompt; - $[2] = t3; - } else { - t3 = $[2]; - } - return t3; + flushGroup(isAgentRunning) + + return result } -export function AgentResponseDisplay(t0) { - const $ = _c(5); - const { - content - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Response:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== content) { - t2 = content.map(_temp); - $[1] = content; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t1}{t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + +const ESTIMATED_LINES_PER_TOOL = 9 +const TERMINAL_BUFFER_LINES = 7 + +type Output = z.input> + +export function AgentPromptDisplay({ + prompt, + dim: _dim = false, +}: { + prompt: string + theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally + dim?: boolean // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) +}): React.ReactNode { + return ( + + + Prompt: + + + {prompt} + + + ) } -function _temp(block, index) { - return {block.text}; + +export function AgentResponseDisplay({ + content, +}: { + content: { type: string; text: string }[] + theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally +}): React.ReactNode { + return ( + + + Response: + + {content.map((block: { type: string; text: string }, index: number) => ( + + {block.text} + + ))} + + ) } + type VerboseAgentTranscriptProps = { - progressMessages: ProgressMessage[]; - tools: Tools; - verbose: boolean; -}; -function VerboseAgentTranscript(t0) { - const $ = _c(15); - const { - progressMessages, - tools, - verbose - } = t0; - let t1; - if ($[0] !== progressMessages) { - t1 = buildSubagentLookups(progressMessages.filter(_temp2).map(_temp3)); - $[0] = progressMessages; - $[1] = t1; - } else { - t1 = $[1]; - } - const { - lookups: agentLookups, - inProgressToolUseIDs - } = t1; - let t2; - if ($[2] !== agentLookups || $[3] !== inProgressToolUseIDs || $[4] !== progressMessages || $[5] !== tools || $[6] !== verbose) { - const filteredMessages = progressMessages.filter(_temp4); - let t3; - if ($[8] !== agentLookups || $[9] !== inProgressToolUseIDs || $[10] !== tools || $[11] !== verbose) { - t3 = progressMessage => ; - $[8] = agentLookups; - $[9] = inProgressToolUseIDs; - $[10] = tools; - $[11] = verbose; - $[12] = t3; - } else { - t3 = $[12]; - } - t2 = filteredMessages.map(t3); - $[2] = agentLookups; - $[3] = inProgressToolUseIDs; - $[4] = progressMessages; - $[5] = tools; - $[6] = verbose; - $[7] = t2; - } else { - t2 = $[7]; - } - let t3; - if ($[13] !== t2) { - t3 = <>{t2}; - $[13] = t2; - $[14] = t3; - } else { - t3 = $[14]; - } - return t3; -} -function _temp4(pm_1) { - if (!hasProgressMessage(pm_1.data)) { - return false; - } - const msg = pm_1.data.message; - if (msg.type === "user" && msg.toolUseResult === undefined) { - return false; - } - return true; -} -function _temp3(pm_0) { - return pm_0.data; + progressMessages: ProgressMessage[] + tools: Tools + verbose: boolean } -function _temp2(pm) { - return hasProgressMessage(pm.data); -} -export function renderToolResultMessage(data: Output, progressMessagesForMessage: ProgressMessage[], { + +function VerboseAgentTranscript({ + progressMessages, tools, verbose, - theme, - isTranscriptMode = false -}: { - tools: Tools; - verbose: boolean; - theme: ThemeName; - isTranscriptMode?: boolean; -}): React.ReactNode { +}: VerboseAgentTranscriptProps): React.ReactNode { + const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups( + progressMessages + .filter((pm): pm is ProgressMessage => + hasProgressMessage(pm.data), + ) + .map(pm => pm.data), + ) + + // Filter out user tool_result messages that lack toolUseResult. + // Subagent progress messages don't carry the parsed tool output, + // so UserToolSuccessMessage returns null and MessageResponse renders + // a bare ⎿ with no content. + const filteredMessages = progressMessages.filter( + (pm): pm is ProgressMessage => { + if (!hasProgressMessage(pm.data)) { + return false + } + const msg = pm.data.message + if (msg.type === 'user' && msg.toolUseResult === undefined) { + return false + } + return true + }, + ) + + return ( + <> + {filteredMessages.map(progressMessage => ( + + + + ))} + + ) +} + +export function renderToolResultMessage( + data: Output, + progressMessagesForMessage: ProgressMessage[], + { + tools, + verbose, + theme, + isTranscriptMode = false, + }: { + tools: Tools + verbose: boolean + theme: ThemeName + isTranscriptMode?: boolean + }, +): React.ReactNode { // Remote-launched agents (ant-only) use a private output type not in the // public schema. Narrow via the internal discriminant. - const internal = data as Output | RemoteLaunchedOutput; + const internal = data as Output | RemoteLaunchedOutput if (internal.status === 'remote_launched') { - return + return ( + Remote agent launched{' '} @@ -336,34 +351,48 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage - ; + + ) } if (data.status === 'async_launched') { - const { - prompt - } = data; - return + const { prompt } = data + return ( + Backgrounded agent - {!isTranscriptMode && + {!isTranscriptMode && ( + {' ('} - {prompt && } + {prompt && ( + + )} {')'} - } + + )} - {isTranscriptMode && prompt && + {isTranscriptMode && prompt && ( + - } - ; + + )} + + ) } + if (data.status !== 'completed') { - return null; + return null } + const { agentId, totalDurationMs, @@ -371,501 +400,737 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage totalTokens, usage, content, - prompt - } = data; - const result = [totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs)]; - const completionMessage = `Done (${result.join(' · ')})`; + prompt, + } = data + const result = [ + totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, + formatNumber(totalTokens) + ' tokens', + formatDuration(totalDurationMs), + ] + + const completionMessage = `Done (${result.join(' · ')})` + const finalAssistantMessage = createAssistantMessage({ content: completionMessage, - usage: { - ...usage, - inference_geo: null, - iterations: null, - speed: null - } as import('@anthropic-ai/sdk/resources/beta/messages/messages.mjs').BetaUsage - }); - return - {(process.env.USER_TYPE) === 'ant' && + usage: { ...usage, inference_geo: null, iterations: null, speed: null }, + }) + + return ( + + {process.env.USER_TYPE === 'ant' && ( + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - } - {isTranscriptMode && prompt && + + )} + {isTranscriptMode && prompt && ( + - } - {isTranscriptMode ? - - : null} - {isTranscriptMode && content && content.length > 0 && + + )} + {isTranscriptMode ? ( + + + + ) : null} + {isTranscriptMode && content && content.length > 0 && ( + - } + + )} - + - {!isTranscriptMode && + {!isTranscriptMode && ( + {' '} - } - ; + + )} + + ) } + export function renderToolUseMessage({ description, - prompt + prompt, }: Partial<{ - description: string; - prompt: string; + description: string + prompt: string }>): React.ReactNode { if (!description || !prompt) { - return null; + return null } - return description; + return description } -export function renderToolUseTag(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; - model?: ModelAlias; -}>): React.ReactNode { - const tags: React.ReactNode[] = []; + +export function renderToolUseTag( + input: Partial<{ + description: string + prompt: string + subagent_type: string + model?: ModelAlias + }>, +): React.ReactNode { + const tags: React.ReactNode[] = [] + if (input.model) { - const mainModel = getMainLoopModel(); - const agentModel = parseUserSpecifiedModel(input.model); + const mainModel = getMainLoopModel() + const agentModel = parseUserSpecifiedModel(input.model) if (agentModel !== mainModel) { - tags.push( + tags.push( + {renderModelName(agentModel)} - ); + , + ) } } + if (tags.length === 0) { - return null; + return null } - return <>{tags}; + + return <>{tags} } -const INITIALIZING_TEXT = 'Initializing…'; -export function renderToolUseProgressMessage(progressMessages: ProgressMessage[], { - tools, - verbose, - terminalSize, - inProgressToolCallCount, - isTranscriptMode = false -}: { - tools: Tools; - verbose: boolean; - terminalSize?: { - columns: number; - rows: number; - }; - inProgressToolCallCount?: number; - isTranscriptMode?: boolean; -}): React.ReactNode { + +const INITIALIZING_TEXT = 'Initializing…' + +export function renderToolUseProgressMessage( + progressMessages: ProgressMessage[], + { + tools, + verbose, + terminalSize, + inProgressToolCallCount, + isTranscriptMode = false, + }: { + tools: Tools + verbose: boolean + terminalSize?: { columns: number; rows: number } + inProgressToolCallCount?: number + isTranscriptMode?: boolean + }, +): React.ReactNode { if (!progressMessages.length) { - return + return ( + {INITIALIZING_TEXT} - ; + + ) } // Checks to see if we should show a super condensed progress message summary. // This prevents flickers when the terminal size is too small to render all the dynamic content - const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; - const shouldUseCondensedMode = !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; + const toolToolRenderLinesEstimate = + (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + + TERMINAL_BUFFER_LINES + const shouldUseCondensedMode = + !isTranscriptMode && + terminalSize && + terminalSize.rows && + terminalSize.rows < toolToolRenderLinesEstimate + const getProgressStats = () => { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false; + return false } - const message = msg.data.message; - return message.message.content.some(content => content.type === 'tool_use'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; + const message = msg.data.message + return message.message.content.some( + content => content.type === 'tool_use', + ) + }) + + const latestAssistant = progressMessages.findLast( + (msg): msg is ProgressMessage => + hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', + ) + + let tokens = null if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; + const usage = latestAssistant.data.message.message.usage + tokens = + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + usage.input_tokens + + usage.output_tokens } - return { - toolUseCount, - tokens - }; - }; + + return { toolUseCount, tokens } + } + if (shouldUseCondensedMode) { - const { - toolUseCount, - tokens - } = getProgressStats(); - return + const { toolUseCount, tokens } = getProgressStats() + + return ( + In progress… · {toolUseCount} tool{' '} {toolUseCount === 1 ? 'use' : 'uses'} {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} - + - ; + + ) } // Process messages to group consecutive search/read operations into summaries (ants only) // isAgentRunning=true since this is the progress view while the agent is still running - const processedMessages = processProgressMessages(progressMessages, tools, true); + const processedMessages = processProgressMessages( + progressMessages, + tools, + true, + ) // For display, take the last few processed messages - const displayedMessages = isTranscriptMode ? processedMessages : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); + const displayedMessages = isTranscriptMode + ? processedMessages + : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW) // Count hidden tool uses specifically (not all messages) to match the // final "Done (N tool uses)" count. Each tool use generates multiple // progress messages (tool_use + tool_result + text), so counting all // hidden messages inflates the number shown to the user. - const hiddenMessages = isTranscriptMode ? [] : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); + const hiddenMessages = isTranscriptMode + ? [] + : processedMessages.slice( + 0, + Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW), + ) const hiddenToolUseCount = count(hiddenMessages, m => { if (m.type === 'summary') { - return m.searchCount + m.readCount + m.replCount > 0; + return m.searchCount + m.readCount + m.replCount > 0 } - const data = m.message.data; + const data = m.message.data if (!hasProgressMessage(data)) { - return false; + return false } - return data.message.message.content.some(content => content.type === 'tool_use'); - }); - const firstData = progressMessages[0]?.data; - const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; + return data.message.message.content.some( + content => content.type === 'tool_use', + ) + }) + + const firstData = progressMessages[0]?.data + const prompt = + firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined // After grouping, displayedMessages can be empty when the only progress so // far is an assistant tool_use for a search/read op (grouped but not yet // counted, since counts increment on tool_result). Fall back to the // initializing text so MessageResponse doesn't render a bare ⎿. if (displayedMessages.length === 0 && !(isTranscriptMode && prompt)) { - return + return ( + {INITIALIZING_TEXT} - ; + + ) } + const { lookups: subagentLookups, - inProgressToolUseIDs: collapsedInProgressIDs - } = buildSubagentLookups(progressMessages.filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)).map(pm => pm.data)); - return + inProgressToolUseIDs: collapsedInProgressIDs, + } = buildSubagentLookups( + progressMessages + .filter((pm): pm is ProgressMessage => + hasProgressMessage(pm.data), + ) + .map(pm => pm.data), + ) + + return ( + - {isTranscriptMode && prompt && + {isTranscriptMode && prompt && ( + - } + + )} {displayedMessages.map(processed => { - if (processed.type === 'summary') { - // Render summary for grouped search/read/REPL operations using shared formatting - const summaryText = getSearchReadSummaryText(processed.searchCount, processed.readCount, processed.isActive, processed.replCount); - return + if (processed.type === 'summary') { + // Render summary for grouped search/read/REPL operations using shared formatting + const summaryText = getSearchReadSummaryText( + processed.searchCount, + processed.readCount, + processed.isActive, + processed.replCount, + ) + return ( + {summaryText} - ; - } - // Render original message without height=1 wrapper so null - // content (tool not found, renderToolUseMessage returns null) - // doesn't leave a blank line. Tool call headers are single-line - // anyway so truncation isn't needed. - return ; - })} + + ) + } + // Render original message without height=1 wrapper so null + // content (tool not found, renderToolUseMessage returns null) + // doesn't leave a blank line. Tool call headers are single-line + // anyway so truncation isn't needed. + return ( + + ) + })} - {hiddenToolUseCount > 0 && + {hiddenToolUseCount > 0 && ( + +{hiddenToolUseCount} more tool{' '} {hiddenToolUseCount === 1 ? 'use' : 'uses'} - } + + )} - ; + + ) } -export function renderToolUseRejectedMessage(_input: { - description: string; - prompt: string; - subagent_type: string; -}, { - progressMessagesForMessage, - tools, - verbose, - isTranscriptMode -}: { - columns: number; - messages: Message[]; - style?: 'condensed'; - theme: ThemeName; - progressMessagesForMessage: ProgressMessage[]; - tools: Tools; - verbose: boolean; - isTranscriptMode?: boolean; -}): React.ReactNode { + +export function renderToolUseRejectedMessage( + _input: { description: string; prompt: string; subagent_type: string }, + { + progressMessagesForMessage, + tools, + verbose, + isTranscriptMode, + }: { + columns: number + messages: Message[] + style?: 'condensed' + theme: ThemeName + progressMessagesForMessage: ProgressMessage[] + tools: Tools + verbose: boolean + isTranscriptMode?: boolean + }, +): React.ReactNode { // Get agentId from progress messages if available (agent was running before rejection) - const firstData = progressMessagesForMessage[0]?.data; - const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; - return <> - {(process.env.USER_TYPE) === 'ant' && agentId && + const firstData = progressMessagesForMessage[0]?.data + const agentId = + firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined + + return ( + <> + {process.env.USER_TYPE === 'ant' && agentId && ( + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - } + + )} {renderToolUseProgressMessage(progressMessagesForMessage, { - tools, - verbose, - isTranscriptMode - })} + tools, + verbose, + isTranscriptMode, + })} - ; + + ) } -export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { - progressMessagesForMessage, - tools, - verbose, - isTranscriptMode -}: { - progressMessagesForMessage: ProgressMessage[]; - tools: Tools; - verbose: boolean; - isTranscriptMode?: boolean; -}): React.ReactNode { - return <> + +export function renderToolUseErrorMessage( + result: ToolResultBlockParam['content'], + { + progressMessagesForMessage, + tools, + verbose, + isTranscriptMode, + }: { + progressMessagesForMessage: ProgressMessage[] + tools: Tools + verbose: boolean + isTranscriptMode?: boolean + }, +): React.ReactNode { + return ( + <> {renderToolUseProgressMessage(progressMessagesForMessage, { - tools, - verbose, - isTranscriptMode - })} + tools, + verbose, + isTranscriptMode, + })} - ; + + ) } + function calculateAgentStats(progressMessages: ProgressMessage[]): { - toolUseCount: number; - tokens: number | null; + toolUseCount: number + tokens: number | null } { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false; + return false } - const message = msg.data.message; - return message.type === 'user' && message.message.content.some(content => content.type === 'tool_result'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; + const message = msg.data.message + return ( + message.type === 'user' && + message.message.content.some(content => content.type === 'tool_result') + ) + }) + + const latestAssistant = progressMessages.findLast( + (msg): msg is ProgressMessage => + hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', + ) + + let tokens = null if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; + const usage = latestAssistant.data.message.message.usage + tokens = + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + usage.input_tokens + + usage.output_tokens } - return { - toolUseCount, - tokens - }; + + return { toolUseCount, tokens } } -export function renderGroupedAgentToolUse(toolUses: Array<{ - param: ToolUseBlockParam; - isResolved: boolean; - isError: boolean; - isInProgress: boolean; - progressMessages: ProgressMessage[]; - result?: { - param: ToolResultBlockParam; - output: Output; - }; -}>, options: { - shouldAnimate: boolean; - tools: Tools; -}): React.ReactNode | null { - const { - shouldAnimate, - tools - } = options; - // Calculate stats for each agent - const agentStats = toolUses.map(({ - param, - isResolved, - isError, - progressMessages, - result - }) => { - const stats = calculateAgentStats(progressMessages); - const lastToolInfo = extractLastToolInfo(progressMessages, tools); - const parsedInput = inputSchema().safeParse(param.input); - - // teammate_spawned is not part of the exported Output type (cast through unknown - // for dead code elimination), so check via string comparison on the raw value - const isTeammateSpawn = result?.output?.status as string === 'teammate_spawned'; - - // For teammate spawns, show @name with type in parens and description as status - let agentType: string; - let description: string | undefined; - let color: keyof Theme | undefined; - let descriptionColor: keyof Theme | undefined; - let taskDescription: string | undefined; - if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { - agentType = `@${parsedInput.data.name}`; - const subagentType = parsedInput.data.subagent_type; - description = isCustomSubagentType(subagentType) ? subagentType : undefined; - taskDescription = parsedInput.data.description; - // Use the custom agent definition's color on the type, not the name - descriptionColor = isCustomSubagentType(subagentType) ? getAgentColor(subagentType) as keyof Theme | undefined : undefined; - } else { - agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; - description = parsedInput.success ? parsedInput.data.description : undefined; - color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; - taskDescription = undefined; +export function renderGroupedAgentToolUse( + toolUses: Array<{ + param: ToolUseBlockParam + isResolved: boolean + isError: boolean + isInProgress: boolean + progressMessages: ProgressMessage[] + result?: { + param: ToolResultBlockParam + output: Output } + }>, + options: { + shouldAnimate: boolean + tools: Tools + }, +): React.ReactNode | null { + const { shouldAnimate, tools } = options + + // Calculate stats for each agent + const agentStats = toolUses.map( + ({ param, isResolved, isError, progressMessages, result }) => { + const stats = calculateAgentStats(progressMessages) + const lastToolInfo = extractLastToolInfo(progressMessages, tools) + const parsedInput = inputSchema().safeParse(param.input) + + // teammate_spawned is not part of the exported Output type (cast through unknown + // for dead code elimination), so check via string comparison on the raw value + const isTeammateSpawn = + (result?.output?.status as string) === 'teammate_spawned' + + // For teammate spawns, show @name with type in parens and description as status + let agentType: string + let description: string | undefined + let color: keyof Theme | undefined + let descriptionColor: keyof Theme | undefined + let taskDescription: string | undefined + if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { + agentType = `@${parsedInput.data.name}` + const subagentType = parsedInput.data.subagent_type + description = isCustomSubagentType(subagentType) + ? subagentType + : undefined + taskDescription = parsedInput.data.description + // Use the custom agent definition's color on the type, not the name + descriptionColor = isCustomSubagentType(subagentType) + ? (getAgentColor(subagentType) as keyof Theme | undefined) + : undefined + } else { + agentType = parsedInput.success + ? userFacingName(parsedInput.data) + : 'Agent' + description = parsedInput.success + ? parsedInput.data.description + : undefined + color = parsedInput.success + ? userFacingNameBackgroundColor(parsedInput.data) + : undefined + taskDescription = undefined + } + + // Check if this was launched as a background agent OR backgrounded mid-execution + const launchedAsAsync = + parsedInput.success && + 'run_in_background' in parsedInput.data && + parsedInput.data.run_in_background === true + const outputStatus = (result?.output as { status?: string } | undefined) + ?.status + const backgroundedMidExecution = + outputStatus === 'async_launched' || outputStatus === 'remote_launched' + const isAsync = + launchedAsAsync || backgroundedMidExecution || isTeammateSpawn - // Check if this was launched as a background agent OR backgrounded mid-execution - const launchedAsAsync = parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; - const outputStatus = (result?.output as { - status?: string; - } | undefined)?.status; - const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; - const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; - const name = parsedInput.success ? parsedInput.data.name : undefined; - return { - id: param.id, - agentType, - description, - toolUseCount: stats.toolUseCount, - tokens: stats.tokens, - isResolved, - isError, - isAsync, - color, - descriptionColor, - lastToolInfo, - taskDescription, - name - }; - }); - const anyUnresolved = toolUses.some(t => !t.isResolved); - const anyError = toolUses.some(t => t.isError); - const allComplete = !anyUnresolved; + const name = parsedInput.success ? parsedInput.data.name : undefined + + return { + id: param.id, + agentType, + description, + toolUseCount: stats.toolUseCount, + tokens: stats.tokens, + isResolved, + isError, + isAsync, + color, + descriptionColor, + lastToolInfo, + taskDescription, + name, + } + }, + ) + + const anyUnresolved = toolUses.some(t => !t.isResolved) + const anyError = toolUses.some(t => t.isError) + const allComplete = !anyUnresolved // Check if all agents are the same type - const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); - const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; + const allSameType = + agentStats.length > 0 && + agentStats.every(stat => stat.agentType === agentStats[0]?.agentType) + const commonType = + allSameType && agentStats[0]?.agentType !== 'Agent' + ? agentStats[0]?.agentType + : null // Check if all resolved agents are async (background) - const allAsync = agentStats.every(stat => stat.isAsync); - return + const allAsync = agentStats.every(stat => stat.isAsync) + + return ( + - + - {allComplete ? allAsync ? <> + {allComplete ? ( + allAsync ? ( + <> {toolUses.length} background agents launched{' '} - : <> + + ) : ( + <> {toolUses.length}{' '} {commonType ? `${commonType} agents` : 'agents'} finished - : <> + + ) + ) : ( + <> Running {toolUses.length}{' '} {commonType ? `${commonType} agents` : 'agents'}… - }{' '} + + )}{' '} {!allAsync && } - {agentStats.map((stat, index) => )} - ; + {agentStats.map((stat, index) => ( + + ))} + + ) } -export function userFacingName(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; - name: string; - team_name: string; -}> | undefined): string { - if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { + +export function userFacingName( + input: + | Partial<{ + description: string + prompt: string + subagent_type: string + name: string + team_name: string + }> + | undefined, +): string { + if ( + input?.subagent_type && + input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType + ) { // Display "worker" agents as "Agent" for cleaner UI if (input.subagent_type === 'worker') { - return 'Agent'; + return 'Agent' } - return input.subagent_type; + return input.subagent_type } - return 'Agent'; + return 'Agent' } -export function userFacingNameBackgroundColor(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; -}> | undefined): keyof Theme | undefined { + +export function userFacingNameBackgroundColor( + input: + | Partial<{ description: string; prompt: string; subagent_type: string }> + | undefined, +): keyof Theme | undefined { if (!input?.subagent_type) { - return undefined; + return undefined } // Get the color for this agent - return getAgentColor(input.subagent_type) as keyof Theme | undefined; + return getAgentColor(input.subagent_type) as keyof Theme | undefined } -export function extractLastToolInfo(progressMessages: ProgressMessage[], tools: Tools): string | null { + +export function extractLastToolInfo( + progressMessages: ProgressMessage[], + tools: Tools, +): string | null { // Build tool_use lookup from all progress messages (needed for reverse iteration) - const toolUseByID = new Map(); + const toolUseByID = new Map() for (const pm of progressMessages) { if (!hasProgressMessage(pm.data)) { - continue; + continue } if (pm.data.message.type === 'assistant') { for (const c of pm.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam); + toolUseByID.set(c.id, c as ToolUseBlockParam) } } } } // Count trailing consecutive search/read operations from the end - let searchCount = 0; - let readCount = 0; + let searchCount = 0 + let readCount = 0 for (let i = progressMessages.length - 1; i >= 0; i--) { - const msg = progressMessages[i]!; + const msg = progressMessages[i]! if (!hasProgressMessage(msg.data)) { - continue; + continue } - const info = getSearchOrReadInfo(msg, tools, toolUseByID); + const info = getSearchOrReadInfo(msg, tools, toolUseByID) if (info && (info.isSearch || info.isRead)) { // Only count tool_result messages to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - searchCount++; + searchCount++ } else if (info.isRead) { - readCount++; + readCount++ } } } else { - break; + break } } + if (searchCount + readCount >= 2) { - return getSearchReadSummaryText(searchCount, readCount, true); + return getSearchReadSummaryText(searchCount, readCount, true) } // Find the last tool_result message - const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage => { - if (!hasProgressMessage(msg.data)) { - return false; - } - const message = msg.data.message; - return message.type === 'user' && message.message.content.some(c => c.type === 'tool_result'); - }); + const lastToolResult = progressMessages.findLast( + (msg): msg is ProgressMessage => { + if (!hasProgressMessage(msg.data)) { + return false + } + const message = msg.data.message + return ( + message.type === 'user' && + message.message.content.some(c => c.type === 'tool_result') + ) + }, + ) + if (lastToolResult?.data.message.type === 'user') { - const toolResultBlock = lastToolResult.data.message.message.content.find(c => c.type === 'tool_result'); + const toolResultBlock = lastToolResult.data.message.message.content.find( + c => c.type === 'tool_result', + ) + if (toolResultBlock?.type === 'tool_result') { // Look up the corresponding tool_use — already indexed above - const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); + const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id) + if (toolUseBlock) { - const tool = findToolByName(tools, toolUseBlock.name); + const tool = findToolByName(tools, toolUseBlock.name) if (!tool) { - return toolUseBlock.name; // Fallback to raw name + return toolUseBlock.name // Fallback to raw name } - const input = toolUseBlock.input as Record; - const parsedInput = tool.inputSchema.safeParse(input); + + const input = toolUseBlock.input as Record + const parsedInput = tool.inputSchema.safeParse(input) // Get user-facing tool name - const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); + const userFacingToolName = tool.userFacingName( + parsedInput.success ? parsedInput.data : undefined, + ) // Try to get summary from the tool itself if (tool.getToolUseSummary) { - const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); + const summary = tool.getToolUseSummary( + parsedInput.success ? parsedInput.data : undefined, + ) if (summary) { - return `${userFacingToolName}: ${summary}`; + return `${userFacingToolName}: ${summary}` } } // Default: just show user-facing tool name - return userFacingToolName; + return userFacingToolName } } } - return null; + + return null } -function isCustomSubagentType(subagentType: string | undefined): subagentType is string { - return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; + +function isCustomSubagentType( + subagentType: string | undefined, +): subagentType is string { + return ( + !!subagentType && + subagentType !== GENERAL_PURPOSE_AGENT.agentType && + subagentType !== 'worker' + ) } diff --git a/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx index 64da4dcc4..e71a5c665 100644 --- a/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +++ b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx @@ -1,136 +1,226 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { getAllowedChannels, getQuestionPreviewFormat } from 'src/bootstrap/state.js'; -import { MessageResponse } from 'src/components/MessageResponse.js'; -import { BLACK_CIRCLE } from 'src/constants/figures.js'; -import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; -import { z } from 'zod/v4'; -import { Box, Text } from '../../ink.js'; -import type { Tool } from '../../Tool.js'; -import { buildTool, type ToolDef } from '../../Tool.js'; -import { lazySchema } from '../../utils/lazySchema.js'; -import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT } from './prompt.js'; -const questionOptionSchema = lazySchema(() => z.object({ - label: z.string().describe('The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.'), - description: z.string().describe('Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.'), - preview: z.string().optional().describe('Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.') -})); -const questionSchema = lazySchema(() => z.object({ - question: z.string().describe('The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'), - header: z.string().describe(`Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`), - options: z.array(questionOptionSchema()).min(2).max(4).describe(`The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`), - multiSelect: z.boolean().default(false).describe('Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.') -})); +import { feature } from 'bun:bundle' +import * as React from 'react' +import { + getAllowedChannels, + getQuestionPreviewFormat, +} from 'src/bootstrap/state.js' +import { MessageResponse } from 'src/components/MessageResponse.js' +import { BLACK_CIRCLE } from 'src/constants/figures.js' +import { getModeColor } from 'src/utils/permissions/PermissionMode.js' +import { z } from 'zod/v4' +import { Box, Text } from '../../ink.js' +import type { Tool } from '../../Tool.js' +import { buildTool, type ToolDef } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { + ASK_USER_QUESTION_TOOL_CHIP_WIDTH, + ASK_USER_QUESTION_TOOL_NAME, + ASK_USER_QUESTION_TOOL_PROMPT, + DESCRIPTION, + PREVIEW_FEATURE_PROMPT, +} from './prompt.js' + +const questionOptionSchema = lazySchema(() => + z.object({ + label: z + .string() + .describe( + 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.', + ), + description: z + .string() + .describe( + 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.', + ), + preview: z + .string() + .optional() + .describe( + 'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.', + ), + }), +) + +const questionSchema = lazySchema(() => + z.object({ + question: z + .string() + .describe( + 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"', + ), + header: z + .string() + .describe( + `Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`, + ), + options: z + .array(questionOptionSchema()) + .min(2) + .max(4) + .describe( + `The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`, + ), + multiSelect: z + .boolean() + .default(false) + .describe( + 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', + ), + }), +) + const annotationsSchema = lazySchema(() => { const annotationSchema = z.object({ - preview: z.string().optional().describe('The preview content of the selected option, if the question used previews.'), - notes: z.string().optional().describe('Free-text notes the user added to their selection.') - }); - return z.record(z.string(), annotationSchema).optional().describe('Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.'); -}); + preview: z + .string() + .optional() + .describe( + 'The preview content of the selected option, if the question used previews.', + ), + notes: z + .string() + .optional() + .describe('Free-text notes the user added to their selection.'), + }) + + return z + .record(z.string(), annotationSchema) + .optional() + .describe( + 'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.', + ) +}) + const UNIQUENESS_REFINE = { check: (data: { - questions: { - question: string; - options: { - label: string; - }[]; - }[]; + questions: { question: string; options: { label: string }[] }[] }) => { - const questions = data.questions.map(q => q.question); + const questions = data.questions.map(q => q.question) if (questions.length !== new Set(questions).size) { - return false; + return false } for (const question of data.questions) { - const labels = question.options.map(opt => opt.label); + const labels = question.options.map(opt => opt.label) if (labels.length !== new Set(labels).size) { - return false; + return false } } - return true; + return true }, - message: 'Question texts must be unique, option labels must be unique within each question' -} as const; + message: + 'Question texts must be unique, option labels must be unique within each question', +} as const + const commonFields = lazySchema(() => ({ - answers: z.record(z.string(), z.string()).optional().describe('User answers collected by the permission component'), + answers: z + .record(z.string(), z.string()) + .optional() + .describe('User answers collected by the permission component'), annotations: annotationsSchema(), - metadata: z.object({ - source: z.string().optional().describe('Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.') - }).optional().describe('Optional metadata for tracking and analytics purposes. Not displayed to user.') -})); -const inputSchema = lazySchema(() => z.strictObject({ - questions: z.array(questionSchema()).min(1).max(4).describe('Questions to ask the user (1-4 questions)'), - ...commonFields() -}).refine(UNIQUENESS_REFINE.check, { - message: UNIQUENESS_REFINE.message -})); -type InputSchema = ReturnType; -const outputSchema = lazySchema(() => z.object({ - questions: z.array(questionSchema()).describe('The questions that were asked'), - answers: z.record(z.string(), z.string()).describe('The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)'), - annotations: annotationsSchema() -})); -type OutputSchema = ReturnType; + metadata: z + .object({ + source: z + .string() + .optional() + .describe( + 'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.', + ), + }) + .optional() + .describe( + 'Optional metadata for tracking and analytics purposes. Not displayed to user.', + ), +})) + +const inputSchema = lazySchema(() => + z + .strictObject({ + questions: z + .array(questionSchema()) + .min(1) + .max(4) + .describe('Questions to ask the user (1-4 questions)'), + ...commonFields(), + }) + .refine(UNIQUENESS_REFINE.check, { + message: UNIQUENESS_REFINE.message, + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + questions: z + .array(questionSchema()) + .describe('The questions that were asked'), + answers: z + .record(z.string(), z.string()) + .describe( + 'The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)', + ), + annotations: annotationsSchema(), + }), +) +type OutputSchema = ReturnType // SDK schemas are identical to internal schemas now that `preview` and // `annotations` are public (configurable via `toolConfig.askUserQuestion`). -export const _sdkInputSchema = inputSchema; -export const _sdkOutputSchema = outputSchema; -export type Question = z.infer>; -export type QuestionOption = z.infer>; -export type Output = z.infer; -function AskUserQuestionResultMessage(t0) { - const $ = _c(3); - const { - answers - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {BLACK_CIRCLE} User answered Claude's questions:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== answers) { - t2 = {t1}{Object.entries(answers).map(_temp)}; - $[1] = answers; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; -} -function _temp(t0) { - const [questionText, answer] = t0; - return · {questionText} → {answer}; +export const _sdkInputSchema = inputSchema +export const _sdkOutputSchema = outputSchema + +export type Question = z.infer> +export type QuestionOption = z.infer> +export type Output = z.infer + +function AskUserQuestionResultMessage({ + answers, +}: { + answers: Output['answers'] +}): React.ReactNode { + return ( + + + {BLACK_CIRCLE}  + User answered Claude's questions: + + + + {Object.entries(answers).map(([questionText, answer]) => ( + + · {questionText} → {answer} + + ))} + + + + ) } + export const AskUserQuestionTool: Tool = buildTool({ name: ASK_USER_QUESTION_TOOL_NAME, searchHint: 'prompt the user with a multiple-choice question', maxResultSizeChars: 100_000, shouldDefer: true, async description() { - return DESCRIPTION; + return DESCRIPTION }, async prompt() { - const format = getQuestionPreviewFormat(); + const format = getQuestionPreviewFormat() if (format === undefined) { // SDK consumer that hasn't opted into a preview format — omit preview // guidance (they may not render the field at all). - return ASK_USER_QUESTION_TOOL_PROMPT; + return ASK_USER_QUESTION_TOOL_PROMPT } - return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]; + return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format] }, get inputSchema(): InputSchema { - return inputSchema(); + return inputSchema() }, get outputSchema(): OutputSchema { - return outputSchema(); + return outputSchema() }, userFacingName() { - return ''; + return '' }, isEnabled() { // When --channels is active the user is likely on Telegram/Discord, not @@ -138,128 +228,115 @@ export const AskUserQuestionTool: Tool = buildTool({ // the keyboard. Channel permission relay already skips // requiresUserInteraction() tools (interactiveHandler.ts) so there's // no alternate approval path. - if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) { - return false; + if ( + (feature('KAIROS') || feature('KAIROS_CHANNELS')) && + getAllowedChannels().length > 0 + ) { + return false } - return true; + return true }, isConcurrencySafe() { - return true; + return true }, isReadOnly() { - return true; + return true }, toAutoClassifierInput(input) { - return input.questions.map(q => q.question).join(' | '); + return input.questions.map(q => q.question).join(' | ') }, requiresUserInteraction() { - return true; + return true }, - async validateInput({ - questions - }) { + async validateInput({ questions }) { if (getQuestionPreviewFormat() !== 'html') { - return { - result: true - }; + return { result: true } } for (const q of questions) { for (const opt of q.options) { - const err = validateHtmlPreview(opt.preview); + const err = validateHtmlPreview(opt.preview) if (err) { return { result: false, message: `Option "${opt.label}" in question "${q.question}": ${err}`, - errorCode: 1 - }; + errorCode: 1, + } } } } - return { - result: true - }; + return { result: true } }, async checkPermissions(input) { return { behavior: 'ask' as const, message: 'Answer questions?', - updatedInput: input - }; + updatedInput: input, + } }, renderToolUseMessage() { - return null; + return null }, renderToolUseProgressMessage() { - return null; + return null }, - renderToolResultMessage({ - answers - }, _toolUseID) { - return ; + renderToolResultMessage({ answers }, _toolUseID) { + return }, renderToolUseRejectedMessage() { - return + return ( + {BLACK_CIRCLE}  User declined to answer questions - ; + + ) }, renderToolUseErrorMessage() { - return null; + return null }, - async call({ - questions, - answers = {}, - annotations - }, _context) { + async call({ questions, answers = {}, annotations }, _context) { return { - data: { - questions, - answers, - ...(annotations && { - annotations - }) - } - }; + data: { questions, answers, ...(annotations && { annotations }) }, + } }, - mapToolResultToToolResultBlockParam({ - answers, - annotations - }, toolUseID) { - const answersText = Object.entries(answers).map(([questionText, answer]) => { - const annotation = annotations?.[questionText]; - const parts = [`"${questionText}"="${answer}"`]; - if (annotation?.preview) { - parts.push(`selected preview:\n${annotation.preview}`); - } - if (annotation?.notes) { - parts.push(`user notes: ${annotation.notes}`); - } - return parts.join(' '); - }).join(', '); + mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) { + const answersText = Object.entries(answers) + .map(([questionText, answer]) => { + const annotation = annotations?.[questionText] + const parts = [`"${questionText}"="${answer}"`] + if (annotation?.preview) { + parts.push(`selected preview:\n${annotation.preview}`) + } + if (annotation?.notes) { + parts.push(`user notes: ${annotation.notes}`) + } + return parts.join(' ') + }) + .join(', ') + return { type: 'tool_result', content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`, - tool_use_id: toolUseID - }; - } -} satisfies ToolDef); + tool_use_id: toolUseID, + } + }, +} satisfies ToolDef) // Lightweight HTML fragment check. Not a parser — HTML5 parsers are // error-recovering by spec and accept anything. We're checking model intent // (did it emit HTML?) and catching the specific things we told it not to do. function validateHtmlPreview(preview: string | undefined): string | null { - if (preview === undefined) return null; + if (preview === undefined) return null if (/<\s*(html|body|!doctype)\b/i.test(preview)) { - return 'preview must be an HTML fragment, not a full document (no , , or )'; + return 'preview must be an HTML fragment, not a full document (no , , or )' } // SDK consumers typically set this via innerHTML — disallow executable/style // tags so a preview can't run code or restyle the host page. Inline event // handlers (onclick etc.) are still possible; consumers should sanitize. if (/<\s*(script|style)\b/i.test(preview)) { - return 'preview must not contain