@hncode/hncode 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # hncode — AI Coding Agent CLI
1
+ # hncode — AI Coding Agent TUI
2
2
 
3
- English | [简体中文](./README_CN.md)
3
+ English | [简体中文](https://github.com/NiceHello666/hncode/blob/main/README_CN.md) | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)
4
4
 
5
5
  > **This is a product of my Vibe Coding session. It may not be perfect, but if you have any suggestions, feel free to open an issue.**
6
6
 
7
- A cyan-blue TUI coding agent inspired by [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), with OpenAI + Anthropic support, MCP-style toolbelt, and intelligent context management.
7
+ A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](https://github.com/MoonshotAI/kimi-code), with OpenAI + Anthropic support, an MCP-style toolbelt, and intelligent context management.
8
8
 
9
9
  ---
10
10
 
@@ -39,125 +39,14 @@ hncode
39
39
  npx @hncode/hncode
40
40
  ```
41
41
 
42
- This will download and run the latest version without installing it globally.
43
-
44
- ---
45
-
46
- ## ⚙️ Configuration
47
-
48
- Create `~/.hncode/config.toml`:
49
-
50
- ```toml
51
- default_model = "gpt-4o-mini"
52
-
53
- [providers.openai]
54
- base_url = "https://api.openai.com/v1"
55
- api_key = "your-api-key-here"
56
-
57
- [models.gpt-4o-mini]
58
- provider = "openai"
59
- model = "gpt-4o-mini"
60
- ```
61
-
62
- Or set environment variables:
63
- ```bash
64
- export HNCODE_API_KEY="your-key"
65
- export HNCODE_PROVIDER="openai"
66
- export HNCODE_MODEL="gpt-4o-mini"
67
- ```
68
-
69
42
  ---
70
43
 
71
- ## 📖 Usage
72
-
73
- ### Basic Commands
74
-
75
- | Command | Description |
76
- |---------|-------------|
77
- | `/compact [ratio]` | Compact conversation (AI summary + keep last 20%) |
78
- | `/model <name>` | Switch LLM model |
79
- | `/sessions` | Browse and resume past conversations |
80
- | `/move <path>` | Move current session to another directory |
81
- | `/auto` / `/ask` / `/yolo` | Toggle permission mode |
82
- | `/plan` / `/focus` | Toggle research-only or full-edit mode |
83
- | `/calm-mode` | Terse replies (skip narration) |
84
- | `/help` | Show all commands |
85
-
86
- ### Tab Completion
87
-
88
- - Type `/` → command menu
89
- - Type `@` → file/folder completion
90
- - Use Tab to navigate, Enter to select
91
-
92
- ### Auto Compaction
93
-
94
- - Triggers automatically when context usage reaches 85%
95
- - AI summarizes dropped messages instead of simply deleting them
96
- - Keeps the most recent 20% of conversation to maintain context coherence
44
+ ## 📖 Documentation
97
45
 
98
- ---
99
-
100
- ## 🔧 Plugin System
101
-
102
- ### Creating Plugins
103
-
104
- Create `.js` or `.mjs` files in `~/.hncode/plugins/`:
105
-
106
- ```javascript
107
- // my-plugin.mjs
108
- export function install(api) {
109
- // Register new tool
110
- api.registerTool({
111
- name: 'MyTool',
112
- description: '...',
113
- parameters: { /* schema */ },
114
- async execute(args, ctx) { return result; }
115
- });
116
-
117
- // Register command
118
- api.registerCommand({
119
- name: 'mycmd',
120
- description: '...',
121
- run: (arg, ctx) => { /* ... */ }
122
- });
123
-
124
- // Register hook
125
- api.registerHook('onTurnEnd', (turnInfo) => { /* ... */ });
126
- }
127
-
128
- export default { name: 'my-plugin', version: '1.0.0' };
129
- ```
130
-
131
- ### View Loaded Plugins
132
-
133
- ```bash
134
- hncode /plugins
135
- ```
136
-
137
- ---
138
-
139
- ## 🛠 Tools
140
-
141
- Built-in tools include:
142
- - `Read`, `Write`, `Edit` - File operations
143
- - `Bash` - Execute shell commands
144
- - `Glob`, `Grep` - Search files
145
- - `TodoList`, `TaskOutput` - Task management
146
- - `FetchURL`, `WebSearch` - Internet access
147
- - `ReadMediaFile`, `FileLines` - Media handling
148
-
149
- ---
150
-
151
- ## 🎨 Themes
152
-
153
- Supported themes: `auto`, `dark`, `light`, or custom RGB colors.
154
-
155
- Set via config:
156
- ```toml
157
- theme = "dark"
158
- ```
46
+ This file is a quick overview. For the **full documentation** — every command, config option, architecture notes, and the plugin/development guide — see the Wiki:
159
47
 
160
- Or use `/theme <name>` in TUI.
48
+ - **[Wiki (English)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)**
49
+ - **[Wiki (简体中文)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)**
161
50
 
162
51
  ---
163
52
 
@@ -176,7 +65,7 @@ This project was created through Vibe Coding. While it may not be perfect, I wel
176
65
 
177
66
  hncode draws inspiration from several excellent projects:
178
67
 
179
- - **[Kimi Code CLI](https://github.com/MoonshotAI/kimi-code)** - The original cyan-blue TUI coding agent that inspired this project's design and approach
68
+ - **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - The original cyan-blue TUI coding agent that inspired this project's design and approach
180
69
  - **[Claude Code](https://github.com/anthropics/claude-code)** - The conversation flow and tool interaction patterns
181
70
 
182
71
  Special thanks to the communities behind these projects for pushing the boundaries of AI-assisted coding.
@@ -185,7 +74,7 @@ Special thanks to the communities behind these projects for pushing the boundari
185
74
 
186
75
  ## 📄 License
187
76
 
188
- This project is licensed under the [GNU General Public License v3.0](LICENSE).
77
+ This project is licensed under the [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE).
189
78
 
190
79
  **In short:**
191
80
  1. ✅ You can freely use, modify, and share this code
package/README_CN.md CHANGED
@@ -1,24 +1,25 @@
1
- # hncode — AI 编程助手命令行工具
1
+ # hncode — AI 编程助手 TUI
2
2
 
3
- [English](./README.md) | 简体中文
3
+ [English](https://github.com/NiceHello666/hncode/blob/main/README.md) | 简体中文 | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)
4
4
 
5
5
  > **这是我 Vibe Coding 的产物,可能不是特别好,但是如果有什么建议的话也欢迎提 issue**
6
6
 
7
- 一个基于命令行终端的 AI 编程助手,灵感源自 [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code)。它提供青色蓝调 (Cyan-blue) 的 TUI 界面,支持 OpenAI 和 Anthropic 双协议,配备 MCP 风格的工具集和智能上下文管理。
7
+ 一个基于终端用户界面(TUI)的 AI 编程助手,灵感源自 [Kimi Code](https://github.com/MoonshotAI/kimi-code)。它提供青色蓝调 (Cyan-blue) 的 TUI 界面,支持 OpenAI 和 Anthropic 双协议,配备 MCP 风格的工具集和智能上下文管理。
8
+
9
+ ---
8
10
 
9
11
  ## 🌟 核心特性
10
12
 
11
- - **原生 TUI 渲染**: 使用原始终端序列号渲染,完美兼容 Windows Terminal,解决 blessed 等库在 Windows 上的兼容性问题
13
+ - **原生 TUI 渲染**: 使用原始终端序列渲染,完美兼容 Windows Terminal,解决 blessed 等库在 Windows 上的兼容性问题
12
14
  - **双协议支持**: 同时支持 OpenAI-compatible API 和 Anthropic API
13
- - **智能上下文管理**:
14
- - 85% 使用率时自动触发压缩
15
- - AI 摘要保留的历史对话,而非简单丢弃
16
- - 手动 `/compact` 命令支持自定义压缩比例
15
+ - **智能上下文管理**: 85% 使用率时自动压缩,AI 摘要保留历史对话
17
16
  - **MCP 风格工具集**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, FetchURL 等
18
17
  - **插件系统**: 可扩展的工具、命令和生命周期钩子
19
18
  - **会话持久化**: 跨会话恢复对话
20
19
  - **权限模式**: Ask, YOLO, Auto 三种工作流模式
21
20
 
21
+ ---
22
+
22
23
  ## 🚀 安装
23
24
 
24
25
  ### 全局安装(推荐)
@@ -38,121 +39,16 @@ hncode
38
39
  npx @hncode/hncode
39
40
  ```
40
41
 
41
- 这将下载并运行最新版本,而无需全局安装。
42
-
43
- ### 本地开发安装
44
-
45
- ```bash
46
- npm install @hncode/hncode
47
- ```
48
-
49
- ## ⚙️ 配置
50
-
51
- 创建 `~/.hncode/config.toml`:
52
-
53
- ```toml
54
- default_model = "gpt-4o-mini"
55
-
56
- [providers.openai]
57
- base_url = "https://api.openai.com/v1"
58
- api_key = "your-api-key-here"
59
-
60
- [models.gpt-4o-mini]
61
- provider = "openai"
62
- model = "gpt-4o-mini"
63
- ```
64
-
65
- 或使用环境变量:
66
- ```bash
67
- export HNCODE_API_KEY="your-key"
68
- export HNCODE_PROVIDER="openai"
69
- export HNCODE_MODEL="gpt-4o-mini"
70
- ```
71
-
72
- ## 📖 使用
73
-
74
- ### 基本命令
75
-
76
- | 命令 | 描述 |
77
- |------|------|
78
- | `/compact [ratio]` | 压缩对话(AI 摘要 + 保留最近 20%) |
79
- | `/model <name>` | 切换 LLM 模型 |
80
- | `/sessions` | 浏览和恢复过往对话 |
81
- | `/move <path>` | 将当前会话迁移到另一个目录 |
82
- | `/auto` / `/ask` / `/yolo` | 切换权限模式 |
83
- | `/plan` / `/focus` | 切换仅研究或完整编辑模式 |
84
- | `/calm-mode` | 简洁回复(跳过叙述) |
85
- | `/help` | 显示所有命令 |
86
-
87
- ### Tab 补全
88
-
89
- - 输入 `/` → 命令菜单
90
- - 输入 `@` → 文件/文件夹补全
91
- - 使用 Tab 导航,Enter 选择
92
-
93
- ### 自动压缩
94
-
95
- - 当上下文使用率达到 85% 时自动触发
96
- - AI 会摘要被丢弃的消息,而不是简单删除
97
- - 保留最近的 20% 对话以保持上下文连贯性
98
-
99
- ## 🔧 插件系统
100
-
101
- ### 创建插件
102
-
103
- 在 `~/.hncode/plugins/` 目录下创建 `.js` 或 `.mjs` 文件:
104
-
105
- ```javascript
106
- // my-plugin.mjs
107
- export function install(api) {
108
- // 注册新工具
109
- api.registerTool({
110
- name: 'MyTool',
111
- description: '...',
112
- parameters: { /* schema */ },
113
- async execute(args, ctx) { return result; }
114
- });
115
-
116
- // 注册命令
117
- api.registerCommand({
118
- name: 'mycmd',
119
- description: '...',
120
- run: (arg, ctx) => { /* ... */ }
121
- });
122
-
123
- // 注册钩子
124
- api.registerHook('onTurnEnd', (turnInfo) => { /* ... */ });
125
- }
126
-
127
- export default { name: 'my-plugin', version: '1.0.0' };
128
- ```
129
-
130
- ### 查看已加载插件
131
-
132
- ```bash
133
- hncode /plugins
134
- ```
135
-
136
- ## 🛠 工具集
137
-
138
- 内置工具包括:
139
- - `Read`, `Write`, `Edit` - 文件操作
140
- - `Bash` - 执行 Shell 命令
141
- - `Glob`, `Grep` - 搜索文件
142
- - `TodoList`, `TaskOutput` - 任务管理
143
- - `FetchURL`, `WebSearch` - 网络访问
144
- - `ReadMediaFile`, `FileLines` - 媒体处理
42
+ ---
145
43
 
146
- ## 🎨 主题
44
+ ## 📖 文档
147
45
 
148
- 支持的 theme: `auto`, `dark`, `light`, 或自定义 RGB 颜色。
46
+ 本文件只是简要介绍。**完整文档**(所有命令、配置项、架构说明、插件与开发指南)请见 Wiki:
149
47
 
150
- 通过配置设置:
151
- ```toml
152
- theme = "dark"
153
- ```
48
+ - **[Wiki(英文)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)**
49
+ - **[Wiki(简体中文)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)**
154
50
 
155
- 或在 TUI 中使用 `/theme <name>`。
51
+ ---
156
52
 
157
53
  ## 🤝 贡献
158
54
 
@@ -163,18 +59,22 @@ theme = "dark"
163
59
  - Fork 并创建拉取请求
164
60
  - 分享您使用 hncode 的体验
165
61
 
62
+ ---
63
+
166
64
  ## 🙏 致谢
167
65
 
168
66
  hncode 从以下几个优秀项目中汲取了灵感:
169
67
 
170
- - **[Kimi Code CLI](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
68
+ - **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
171
69
  - **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
172
70
 
173
71
  特别感谢这些项目背后的社区,它们推动了 AI 辅助编程的边界。
174
72
 
73
+ ---
74
+
175
75
  ## 📄 许可证
176
76
 
177
- 本项目的许可受 [GNU General Public License v3.0](LICENSE) 保护。
77
+ 本项目的许可受 [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
178
78
 
179
79
  **简单来说:**
180
80
  1. ✅ 你可以自由使用、修改和分享此代码
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/agent.js CHANGED
@@ -24,35 +24,26 @@ export function nudgeMessage(why) {
24
24
  // Final instruction appended to the system prompt (see SYSTEM_PROMPT).
25
25
  // (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
26
26
 
27
- export const SYSTEM_PROMPT = `You are hncode, a coding agent running. You must monitor your context usage. When you detect that you're approaching the context limit (see your last messages about token counts), you should proactively call /compact to reduce the conversation history. in the user's terminal on ${process.platform}.
28
- You operate inside a workspace directory. You can inspect files, edit code, run commands, and track tasks using the tools available.
27
+ export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
28
+ You work inside a workspace directory using the tools provided.
29
29
 
30
30
  Tools:
31
+ - Read a file before editing it; never guess contents or edit code you haven't read.
31
32
  - Prefer Edit over Write for existing files.
32
- - Read before you edit; never guess file contents. Never propose changes to code you haven't read.
33
- - If a tool fails, diagnose why before switching tactics. Read the error; don't guess.
34
- After 2 failed attempts on the same goal, STOP and report the blocker.
35
- - Independent tool calls can run in parallel, but never parallelize calls with dependencies.
33
+ - If a tool fails, read the error and fix your approach. After 2 failed attempts on the same goal, stop and report the blocker.
34
+ - Independent tool calls may run in parallel; never parallelize dependent ones.
36
35
 
37
36
  Scope:
38
- - Do what was asked. Do not refactor, rename, or "improve" unrelated code.
39
- - If the request is ambiguous, ask one short clarifying question before acting.
40
- - If you make an assumption, state it explicitly.
37
+ - Do only what was asked. Do not refactor or "improve" unrelated code.
38
+ - If the request is ambiguous, ask one short clarifying question.
39
+ - State any assumption you make.
41
40
 
42
41
  Finishing:
43
- - Never stop silently after a tool call.
44
- - End every turn with a short report in the user's language:
45
- Done: what changed (files / commands / result), and how you verified it.
46
- Not done: what failed, what you tried, and what you need from the user.
47
- - Never claim success without evidence. Cite file_path:line_number for any code you
48
- reference. If you didn't run a command or read back the file, you don't know it worked.
49
- - Be brief. No filler, no restating the request.
50
- - After tools succeed, don't re-explain what the tool did. Report only the outcome.
51
-
52
- Finishing a turn:
53
- - End every turn with a written answer. Never stop right after a tool call, and never
54
- stop with reasoning only: either keep calling tools until the task is done, or write
55
- the summary. A turn that ends without an answer is flagged back to you with a reminder.
42
+ - Never stop silently after a tool call; end every turn with a written answer in the user's language.
43
+ - Report briefly: what changed, and how you verified it. If something failed, say what you tried and what you need.
44
+ - Never claim success without evidence. Cite file:line for referenced code.
45
+ - Be concise. No filler, no restating the request or explaining tool output that already spoke for itself.
46
+ - If context is running low, call /compact to shrink history.
56
47
  `;
57
48
 
58
49
  export class Agent {
package/src/clipboard.js CHANGED
@@ -9,6 +9,9 @@
9
9
  // Non-Windows paths (pbcopy/pbpaste/xclip) are cheap enough to run per call.
10
10
 
11
11
  import cp from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
12
15
 
13
16
  const SENT = '@@HNCODE_CLIP@@';
14
17
  let helper = null; // long-lived PowerShell child
@@ -59,10 +62,10 @@ function helperRun(script, timeoutMs) {
59
62
  warmClipboard();
60
63
  if (!helper) return Promise.resolve(null);
61
64
  return new Promise((resolve) => {
62
- const timer = setTimeout(() => { resolve(null); }, timeoutMs);
65
+ const timer = setTimeout(() => { helperBusy = false; resolve(null); }, timeoutMs);
63
66
  helperQueue.push((out) => { clearTimeout(timer); resolve(out); });
64
67
  try { helper.stdin.write(script + `\n'${SENT}'\n`); }
65
- catch { clearTimeout(timer); resolve(null); }
68
+ catch { clearTimeout(timer); helperBusy = false; resolve(null); }
66
69
  });
67
70
  }
68
71
 
@@ -98,17 +101,117 @@ export async function readText(timeoutMs = 3000) {
98
101
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
99
102
  }
100
103
 
101
- // Read clipboard as image (base64 PNG). Returns null if clipboard has no image.
102
- export function readImage() {
103
- if (!win()) return null;
104
+ // Unified clipboard read: returns text to insert into the composer, handling
105
+ // three clipboard shapes:
106
+ // 1. copied FILES -> absolute path(s)
107
+ // 2. plain TEXT -> the text itself
108
+ // 3. an IMAGE (screenshot) -> saved to a temp .png, returns its path
109
+ //
110
+ // SPEED: a cold PowerShell costs ~2.9s on Windows (measured), while a command on
111
+ // the already-warm, long-lived helper (see warmClipboard) costs ~10ms. The TUI
112
+ // pre-warms the helper at start, so we run this probe on THAT shell. Only if the
113
+ // helper is unavailable do we fall back to a one-shot PowerShell.
114
+ //
115
+ // The probe prints a tagged result that can be parsed cheaply:
116
+ // F::<path> a copied file path (one line per file)
117
+ // T::<base64> base64-encoded clipboard text
118
+ // I::<path> a clipboard image saved to a temp png
119
+ // (empty) nothing usable on the clipboard
120
+ //
121
+ // NOTE: no `-join` here. A backtick-n inside the JS string produced a broken
122
+ // PowerShell expression (silently returned nothing), which made FILE pastes fall
123
+ // back to the slow cold spawn. Emitting one F:: line per file is simpler and
124
+ // also supports multi-file copies.
125
+ const CLIP_PROBE = [
126
+ "$ErrorActionPreference='SilentlyContinue'",
127
+ "$f=Get-Clipboard -Format FileDropList",
128
+ "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_.FullName};return}",
129
+ "$t=Get-Clipboard -Raw",
130
+ "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t));return}",
131
+ "$i=Get-Clipboard -Image",
132
+ "if($null -ne $i){$p=Join-Path $env:TEMP ('hncode-paste-'+[guid]::NewGuid().ToString('N')+'.png');$i.Save($p,[System.Drawing.Imaging.ImageFormat]::Png);'I::'+$p}",
133
+ ].join('; ');
134
+
135
+ // Parse the probe output into { text, via }. Handles one-or-more F:: lines.
136
+ function parseClipResult(out) {
137
+ const lines = String(out == null ? '' : out).replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
138
+ if (!lines.length) return null;
139
+ const fileLines = lines.filter((l) => l.startsWith('F::'));
140
+ if (fileLines.length) return { text: fileLines.map((l) => l.slice(3).trim()).join('\n'), via: 'files' };
141
+ const imgLine = lines.find((l) => l.startsWith('I::'));
142
+ if (imgLine) return { text: imgLine.slice(3).trim(), via: 'image' };
143
+ const txtLine = lines.find((l) => l.startsWith('T::'));
144
+ if (txtLine) {
145
+ try { return { text: Buffer.from(txtLine.slice(3).trim(), 'base64').toString('utf8'), via: 'text' }; }
146
+ catch { return null; }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // Run the probe on the warm helper (fast path). Resolves null if it is not ready.
152
+ async function readViaHelper() {
153
+ warmClipboard();
154
+ if (!helper) return null;
155
+ // Wait briefly for any in-flight command (e.g. a concurrent readText) to drain,
156
+ // so we still hit the warm shell instead of falling back to a cold spawn.
157
+ for (let i = 0; i < 20 && helperBusy; i++) {
158
+ await new Promise((r) => setTimeout(r, 25));
159
+ }
160
+ if (helperBusy) return null;
161
+ helperBusy = true;
162
+ const out = await helperRun(CLIP_PROBE, 3000);
163
+ return out == null ? null : parseClipResult(out);
164
+ }
165
+
166
+ export function readClipboardContent() {
167
+ if (!win()) {
168
+ try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
169
+ }
170
+ // Synchronous API is required by the caller, so use a one-shot PowerShell here
171
+ // and let the async path (readClipboardContentAsync) use the warm helper.
172
+ const scriptFile = clipScriptPath();
173
+ if (!scriptFile) return { text: '', via: 'none' };
104
174
  try {
105
- // PowerShell: Get-Clipboard -Image, save to temp file, read as base64
106
- const b64 = cp.execSync(
107
- 'powershell -NoProfile -Command "$img = Get-Clipboard -Image; if ($null -ne $img) { $path = [System.IO.Path]::GetTempFileName() + \'.png\'; $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); $bytes = [System.IO.File]::ReadAllBytes($path); Remove-Item $path; Write-Host ([Convert]::ToBase64String($bytes)) }"',
108
- { encoding: 'ascii', windowsHide: true, timeout: 8000 },
175
+ const out = cp.execSync(
176
+ `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptFile}"`,
177
+ { encoding: 'utf8', windowsHide: true, timeout: 8000 },
109
178
  );
110
- const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
111
- if (s.length > 0) return s;
112
- return null;
113
- } catch { return null; }
179
+ return parseClipResult(out) || { text: '', via: 'none' };
180
+ } catch { return { text: '', via: 'none' }; }
181
+ }
182
+
183
+ // Async version that prefers the warm helper (~10ms) over a cold spawn (~2.9s).
184
+ export async function readClipboardContentAsync() {
185
+ if (!win()) {
186
+ try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
187
+ }
188
+ const viaHelper = await readViaHelper();
189
+ if (viaHelper) return viaHelper;
190
+ return readClipboardContent();
191
+ }
192
+
193
+ // The PowerShell logic for the one-shot fallback lives in a temp .ps1 file:
194
+ // passing a multi-line script via -Command "..." mangles its own quotes on
195
+ // Windows, so we write the script once and re-use it by path.
196
+ let _clipScript = null;
197
+ function clipScriptPath() {
198
+ if (_clipScript) return _clipScript;
199
+ const p = path.join(os.tmpdir(), 'hncode-clipread.ps1');
200
+ const script = [
201
+ "$ErrorActionPreference = 'SilentlyContinue'",
202
+ '$files = Get-Clipboard -Format FileDropList',
203
+ 'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_.FullName }; exit }',
204
+ '$txt = Get-Clipboard -Raw',
205
+ 'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
206
+ '$img = Get-Clipboard -Image',
207
+ 'if ($null -ne $img) {',
208
+ ' $path = Join-Path $env:TEMP ("hncode-paste-" + [guid]::NewGuid().ToString("N") + ".png")',
209
+ ' $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)',
210
+ ' "I::" + $path',
211
+ '}',
212
+ '',
213
+ ].join('\n');
214
+ try { fs.writeFileSync(p, script, 'utf8'); } catch { return null; }
215
+ _clipScript = p;
216
+ return p;
114
217
  }
package/src/colors.js CHANGED
@@ -81,11 +81,14 @@ export const C = {
81
81
  selBg: TRUECOLOR ? brgb(0, 70, 84) : '\x1b[48;5;23m', // Original dark cyan background
82
82
  scrollTrack: '\x1b[90m', // dim gutter
83
83
  scrollThumb: TRUECOLOR ? rgb(0, 184, 219) : '\x1b[38;5;37m', // brand cyan-blue
84
- // Scrollbar interaction states. hover: a touch whiter/brighter than the brand
85
- // colour; active (pressed/dragging): a touch darker. Idle returns to
86
- // scrollThumb (the brand colour) — the caller swaps back automatically.
84
+ // Scrollbar interaction states:
85
+ // hover -> a touch whiter/brighter than the brand colour
86
+ // active -> BOLD + the brand colour (pressed/dragging)
87
+ // The pressed state used to be a DARKER colour, which on a dark background
88
+ // read as "the thumb disappeared". Making it bold-and-bright means a press is
89
+ // always unmistakably visible. Idle swaps back to scrollThumb automatically.
87
90
  scrollThumbHover: TRUECOLOR ? rgb(120, 226, 245) : '\x1b[38;5;123m',
88
- scrollThumbActive: TRUECOLOR ? rgb(0, 140, 168) : '\x1b[38;5;31m',
91
+ scrollThumbActive: '\x1b[1m' + (TRUECOLOR ? rgb(0, 220, 255) : '\x1b[38;5;51m'),
89
92
  // Hover highlight for clickable rows (menu items, picker options, form
90
93
  // fields, composer rows). An explicit bright foreground + BOLD rather than a
91
94
  // bare BOLD: BOLD only brightens the 16-colour palette (it does nothing to a
@@ -129,12 +132,17 @@ export function setTheme(name) {
129
132
  C.bgPanel = brgb(...t.bg);
130
133
  C.selBg = brgb(...t.selBg);
131
134
 
132
- // Scrollbar colors - adjust based on theme brightness
133
- const isLight = t.bg[0] > 128; // Simple brightness check
134
- C.scrollTrack = isLight ? '\x1b[90m' : '\x1b[90m'; // dim gutter (works for both)
135
+ // Scrollbar colours. The active (pressed/dragging) thumb is BOLD + a brighter
136
+ // shade of the theme colour. It used to be a DARKER shade, which on a dark
137
+ // background read as "the thumb vanished" mid-drag.
138
+ C.scrollTrack = '\x1b[90m'; // dim gutter
135
139
  C.scrollThumb = rgb(...t.teal);
136
140
  C.scrollThumbHover = rgb(...t.hover);
137
- C.scrollThumbActive = rgb(t.teal[0] * 0.7, t.teal[1] * 0.7, t.teal[2] * 0.7);
141
+ C.scrollThumbActive = '\x1b[1m' + rgb(
142
+ Math.min(255, Math.round(t.teal[0] * 0.6 + 255 * 0.4)),
143
+ Math.min(255, Math.round(t.teal[1] * 0.6 + 255 * 0.4)),
144
+ Math.min(255, Math.round(t.teal[2] * 0.6 + 255 * 0.4)),
145
+ );
138
146
 
139
147
  // Hover stays visible on this theme's background (bright on dark, dark on light).
140
148
  C.hover = '\x1b[1m' + rgb(...(t.hover || [190, 245, 255]));
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ import { setToolsList } from './llm.js';
11
11
 
12
12
  export const VERSION = '0.1.0';
13
13
 
14
- const HELP = `hncode ${VERSION} — a kimi-code-cli-style coding agent.
14
+ const HELP = `hncode ${VERSION} — a Kimi Code-style coding agent.
15
15
 
16
16
  Usage: hncode [options] [command]
17
17
 
package/src/tools/grep.js CHANGED
@@ -144,9 +144,12 @@ function emitContext(out, file, idx, lines, a) {
144
144
 
145
145
  function paginate(raw, args) {
146
146
  if (raw === '' || raw === undefined || raw === null) return 'No matches found.';
147
- let lines = raw.split('\n').filter((l) => l.length > 0);
147
+ // Split on either newline style and drop the trailing empty element a final
148
+ // CRLF leaves behind — `rg` always ends with one, and it rendered as a stray
149
+ // blank line under the last match.
150
+ let lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
148
151
  if (args.offset) lines = lines.slice(args.offset);
149
152
  if (args.head_limit) lines = lines.slice(0, args.head_limit);
150
153
  if (lines.length === 0) return 'No matches found.';
151
- return lines.join('\n');
154
+ return lines.join('\n').replace(/\r/g, '');
152
155
  }
package/src/tui.js CHANGED
@@ -20,7 +20,7 @@ import cp from 'node:child_process';
20
20
  import {
21
21
  C, blockCursor, showCursor, hideCursor, alternateScreen, clearScreen, clearAndSetBg, setTheme, THEME_NAMES, lerpColor,
22
22
  } from './colors.js';
23
- import { copyText, readText, warmClipboard, readImage } from './clipboard.js';
23
+ import { copyText, readText, warmClipboard, readClipboardContentAsync } from './clipboard.js';
24
24
  import { visualWidth, estimateTokens, estimateMessagesTokens, expandTabs } from './term.js';
25
25
  import { Agent, SYSTEM_PROMPT } from './agent.js';
26
26
  import { LLM } from './llm.js';
@@ -60,7 +60,6 @@ export const COMMANDS = [
60
60
  { name: 'usage', desc: 'Show session tokens + context window', priority: 60 },
61
61
  { name: 'mcp', desc: 'Show MCP server status', priority: 60 },
62
62
  { name: 'mcp-config', desc: 'Configure MCP servers (list / add / remove)', priority: 60 },
63
- { name: 'theme', desc: 'Set the terminal UI theme', priority: 60, argumentHint: '[auto|dark|light|<name>]' },
64
63
  { name: 'statusline', desc: 'Configure which items appear in the status line', priority: 60 },
65
64
 
66
65
  { name: 'export-md', aliases: ['export'], desc: 'Export current session as a Markdown file', priority: 40, argumentHint: '[output-path]' },
@@ -140,7 +139,6 @@ export const TIPS = [
140
139
  '/compact [ratio] AI-summarizes older history and keeps the most recent 20%',
141
140
  '/tasks lists background Bash jobs started with run_in_background',
142
141
  '/status prints the current model, provider, endpoint, and session id',
143
- '/theme switches between the auto / dark / light colour palettes',
144
142
  '/statusline toggles which items appear in the bottom status bar',
145
143
  '/add-dir grants the agent access to another directory for this session',
146
144
  '/reload re-reads config.toml without restarting hncode',
@@ -210,74 +208,90 @@ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇',
210
208
  // Rotating status messages for the "Working..." line — all mean "the agent is
211
209
  // thinking / working", but vary the wording so it doesn't look stuck.
212
210
  const WORKING_MESSAGES = [
213
- 'Thinking…', 'Working…', 'Crunching…', 'Processing…', 'Analyzing…',
214
- 'Pondering…', 'Mulling…', 'Considering…', 'Reasoning…', 'Deliberating…',
215
- 'Reflecting…', 'Ruminating…', 'Planning…', 'Mapping…', 'Sketching…',
216
- 'Outlining…', 'Structuring…', 'Organizing…', 'Sorting…', 'Ordering…',
217
- 'Prioritizing…', 'Focusing…', 'Concentrating…', 'Diving…', 'Digging…',
218
- 'Delving…', 'Probing…', 'Examining…', 'Inspecting…', 'Studying…',
219
- 'Reviewing…', 'Checking…', 'Verifying…', 'Validating…', 'Confirming…',
220
- 'Building…', 'Assembling…', 'Constructing…', 'Composing…', 'Crafting…',
221
- 'Creating…', 'Shaping…', 'Forming…', 'Forging…', 'Welding…',
222
- 'Stitching…', 'Weaving…', 'Knitting…', 'Threading…', 'Patching…',
223
- 'Fixing…', 'Repairing…', 'Rebuilding…', 'Reworking…', 'Refactoring…',
224
- 'Restructuring…', 'Solving…', 'Untangling…', 'Unraveling…', 'Detangling…',
225
- 'Deciphering…', 'Decoding…', 'Cracking…', 'Piecing…', 'Fitting…',
226
- 'Hunting…', 'Chasing…', 'Tracking…', 'Tracing…', 'Sniffing…',
227
- 'Writing…', 'Drafting…', 'Editing…', 'Revising…', 'Rewriting…',
228
- 'Proofreading…', 'Polishing…', 'Refining…', 'Honing…', 'Tuning…',
229
- 'Sharpening…', 'Tightening…', 'Trimming…', 'Pruning…', 'Cutting…',
230
- 'Merging…', 'Splicing…', 'Searching…', 'Scanning…', 'Sifting…',
231
- 'Filtering…', 'Collecting…', 'Gathering…', 'Compiling…', 'Indexing…',
232
- 'Fetching…', 'Retrieving…', 'Loading…', 'Unpacking…', 'Computing…',
233
- 'Calculating…', 'Measuring…', 'Estimating…', 'Calibrating…', 'Aligning…',
234
- 'Balancing…', 'Optimizing…', 'Streamlining…', 'Smoothing…', 'Cleaning…',
235
- 'Sweeping…', 'Tidying…', 'Clearing…', 'Expanding…', 'Growing…',
236
- 'Blooming…', 'Walking…', 'Strolling…', 'Marching…', 'Hiking…',
237
- 'Trekking…', 'Journeying…', 'Sailing…', 'Cruising…', 'Gliding…',
238
- 'Flying…', 'Soaring…', 'Floating…', 'Drifting…', 'Wandering…',
239
- 'Exploring…', 'Roaming…', 'Venturing…', 'Discovering…', 'Uncovering…',
240
- 'Unveiling…', 'Revealing…', 'Exposing…', 'Spinning…', 'Whirling…',
241
- 'Twirling…', 'Rotating…', 'Swirling…', 'Churning…', 'Turning…',
242
- 'Rolling…', 'Rocking…', 'Swaying…', 'Flowing…', 'Streaming…',
243
- 'Cascading…', 'Rippling…', 'Bubbling…', 'Sparkling…', 'Cooking…',
244
- 'Simmering…', 'Boiling…', 'Stewing…', 'Roasting…', 'Baking…',
245
- 'Grilling…', 'Frying…', 'Whisking…', 'Beating…', 'Stirring…',
246
- 'Mixing…', 'Blending…', 'Kneading…', 'Proofing…', 'Rising…',
247
- 'Fermenting…', 'Brewing…', 'Steeping…', 'Infusing…', 'Seasoning…',
248
- 'Garnishing…', 'Plating…', 'Serving…', 'Tasting…', 'Adjusting…',
249
- 'Perfecting…', 'Glazing…', 'Painting…', 'Drawing…', 'Designing…',
250
- 'Orchestrating…', 'Conducting…', 'Dancing…', 'Jigging…', 'Jiving…',
251
- 'Boogying…', 'Shimmying…', 'Grooving…', 'Bouncing…', 'Hopping…',
252
- 'Skipping…', 'Nurturing…', 'Tending…', 'Gardening…', 'Planting…',
253
- 'Seeding…', 'Sprouting…', 'Rooting…', 'Branching…', 'Watering…',
254
- 'Flourishing…', 'Thriving…', 'Sneaking…', 'Tiptoeing…', 'Creeping…',
255
- 'Gliding…', 'Charging…', 'Gunning…', 'Ramping…', 'Revving…',
256
- 'Warming…', 'Powering…', 'Igniting…', 'Kindling…', 'Sparking…',
257
- 'Blazing…', 'Burning…', 'Glowing…', 'Illuminating…', 'Enlightening…',
258
- 'Brightening…', 'Dawning…', 'Breaking…', 'Leaping…', 'Jumping…',
259
- 'Bounding…', 'Springing…', 'Launching…', 'Propelling…', 'Accelerating…',
260
- 'Waiting…', 'Holding…', 'Steadying…', 'Progressing…', 'Advancing…',
261
- 'Continuing…', 'Persisting…', 'Persevering…', 'Standing…', 'Readying…',
262
- 'Preparing…', 'Sussing…', 'Sleuthing…', 'Orienting…', 'Locating…',
263
- 'Targeting…', 'Acquiring…', 'Syncing…', 'Harmonizing…', 'Lining…',
264
- 'Wrapping…', 'Finalizing…', 'Landing…', 'Nailing…', 'Sealing…',
265
- 'Rethinking…', 'Reassessing…', 'Revisiting…', 'Retracing…', 'Replaying…',
266
- 'Rehearsing…', 'Simulating…', 'Modeling…', 'Prototyping…', 'Testing…',
267
- 'Iterating…', 'Rendering…', 'Repainting…', 'Compositing…', 'Wiring…',
268
- 'Linking…', 'Bundling…', 'Packing…', 'Shipping…', 'Deploying…',
269
- 'Calculating…', 'Crunching…', 'Juggling…', 'Balancing…', 'Weighing…',
270
- 'Distilling…', 'Extracting…', 'Parsing…', 'Formatting…', 'Normalizing…',
271
- 'Sanitizing…', 'Validating…', 'Locking…', 'Securing…', 'Guarding…',
272
- 'Watching…', 'Monitoring…', 'Surveying…', 'Scouting…', 'Reconnoitering…',
273
- 'Brewing…', 'Concocting…', 'Devising…', 'Inventing…', 'Imagining…',
274
- 'Envisioning…', 'Visualizing…', 'Picturing…', 'Dreaming…', 'Wondering…',
275
- 'Marveling…', 'Admiring…', 'Appreciating…', 'Enjoying…', 'Savoring…',
276
- 'Relishing…', 'Loving…', 'Cherishing…', 'Treasure-hunting…', 'Questing…',
277
- 'Seeking…', 'Striving…', 'Endeavoring…', 'Attempting…', 'Trying…',
278
- 'Experimenting…', 'Innovating…', 'Pioneering…', 'Trailblazing…', 'Pathfinding…',
279
- 'Navigating…', 'Steering…', 'Guiding…', 'Directing…', 'Captaining…',
280
- 'Piloting…', 'Commanding…', 'Leading…', 'Spearheading…', 'Championing…',
211
+ 'Thinking...', 'Working...', 'Crunching...', 'Processing...', 'Analyzing...', 'Pondering...', 'Mulling...', 'Considering...', 'Reasoning...', 'Deliberating...',
212
+ 'Reflecting...', 'Ruminating...', 'Planning...', 'Mapping...', 'Sketching...', 'Outlining...', 'Structuring...', 'Organizing...', 'Sorting...', 'Ordering...',
213
+ 'Prioritizing...', 'Focusing...', 'Concentrating...', 'Diving...', 'Digging...', 'Delving...', 'Probing...', 'Examining...', 'Inspecting...', 'Studying...',
214
+ 'Reviewing...', 'Checking...', 'Verifying...', 'Validating...', 'Confirming...', 'Building...', 'Assembling...', 'Constructing...', 'Composing...', 'Crafting...',
215
+ 'Creating...', 'Shaping...', 'Forming...', 'Forging...', 'Welding...', 'Stitching...', 'Weaving...', 'Knitting...', 'Threading...', 'Patching...',
216
+ 'Fixing...', 'Repairing...', 'Rebuilding...', 'Reworking...', 'Refactoring...', 'Restructuring...', 'Solving...', 'Untangling...', 'Unraveling...', 'Detangling...',
217
+ 'Deciphering...', 'Decoding...', 'Cracking...', 'Piecing...', 'Fitting...', 'Hunting...', 'Chasing...', 'Tracking...', 'Tracing...', 'Sniffing...',
218
+ 'Writing...', 'Drafting...', 'Editing...', 'Revising...', 'Rewriting...', 'Proofreading...', 'Polishing...', 'Refining...', 'Honing...', 'Tuning...',
219
+ 'Sharpening...', 'Tightening...', 'Trimming...', 'Pruning...', 'Cutting...', 'Merging...', 'Splicing...', 'Searching...', 'Scanning...', 'Sifting...',
220
+ 'Filtering...', 'Collecting...', 'Gathering...', 'Compiling...', 'Indexing...', 'Fetching...', 'Retrieving...', 'Loading...', 'Unpacking...', 'Computing...',
221
+ 'Calculating...', 'Measuring...', 'Estimating...', 'Calibrating...', 'Aligning...', 'Balancing...', 'Optimizing...', 'Streamlining...', 'Smoothing...', 'Cleaning...',
222
+ 'Sweeping...', 'Tidying...', 'Clearing...', 'Expanding...', 'Growing...', 'Blooming...', 'Walking...', 'Strolling...', 'Marching...', 'Hiking...',
223
+ 'Trekking...', 'Journeying...', 'Sailing...', 'Cruising...', 'Gliding...', 'Flying...', 'Soaring...', 'Floating...', 'Drifting...', 'Wandering...',
224
+ 'Exploring...', 'Roaming...', 'Venturing...', 'Discovering...', 'Uncovering...', 'Unveiling...', 'Revealing...', 'Exposing...', 'Spinning...', 'Whirling...',
225
+ 'Twirling...', 'Rotating...', 'Swirling...', 'Churning...', 'Turning...', 'Rolling...', 'Rocking...', 'Swaying...', 'Flowing...', 'Streaming...',
226
+ 'Cascading...', 'Rippling...', 'Bubbling...', 'Sparkling...', 'Cooking...', 'Simmering...', 'Boiling...', 'Stewing...', 'Roasting...', 'Baking...',
227
+ 'Grilling...', 'Frying...', 'Whisking...', 'Beating...', 'Stirring...', 'Mixing...', 'Blending...', 'Kneading...', 'Proofing...', 'Rising...',
228
+ 'Fermenting...', 'Brewing...', 'Steeping...', 'Infusing...', 'Seasoning...', 'Garnishing...', 'Plating...', 'Serving...', 'Tasting...', 'Adjusting...',
229
+ 'Perfecting...', 'Glazing...', 'Painting...', 'Drawing...', 'Designing...', 'Orchestrating...', 'Conducting...', 'Dancing...', 'Jigging...', 'Jiving...',
230
+ 'Boogying...', 'Shimmying...', 'Grooving...', 'Bouncing...', 'Hopping...', 'Skipping...', 'Nurturing...', 'Tending...', 'Gardening...', 'Planting...',
231
+ 'Seeding...', 'Sprouting...', 'Rooting...', 'Branching...', 'Watering...', 'Flourishing...', 'Thriving...', 'Sneaking...', 'Tiptoeing...', 'Creeping...',
232
+ 'Charging...', 'Gunning...', 'Ramping...', 'Revving...', 'Warming...', 'Powering...', 'Igniting...', 'Kindling...', 'Sparking...', 'Blazing...',
233
+ 'Burning...', 'Glowing...', 'Illuminating...', 'Enlightening...', 'Brightening...', 'Dawning...', 'Breaking...', 'Leaping...', 'Jumping...', 'Bounding...',
234
+ 'Springing...', 'Launching...', 'Propelling...', 'Accelerating...', 'Waiting...', 'Holding...', 'Steadying...', 'Progressing...', 'Advancing...', 'Continuing...',
235
+ 'Persisting...', 'Persevering...', 'Standing...', 'Readying...', 'Preparing...', 'Sussing...', 'Sleuthing...', 'Orienting...', 'Locating...', 'Targeting...',
236
+ 'Acquiring...', 'Syncing...', 'Harmonizing...', 'Lining...', 'Wrapping...', 'Finalizing...', 'Landing...', 'Nailing...', 'Sealing...', 'Rethinking...',
237
+ 'Reassessing...', 'Revisiting...', 'Retracing...', 'Replaying...', 'Rehearsing...', 'Simulating...', 'Modeling...', 'Prototyping...', 'Testing...', 'Iterating...',
238
+ 'Rendering...', 'Repainting...', 'Compositing...', 'Wiring...', 'Linking...', 'Bundling...', 'Packing...', 'Shipping...', 'Deploying...', 'Juggling...',
239
+ 'Weighing...', 'Distilling...', 'Extracting...', 'Parsing...', 'Formatting...', 'Normalizing...', 'Sanitizing...', 'Locking...', 'Securing...', 'Guarding...',
240
+ 'Watching...', 'Monitoring...', 'Surveying...', 'Scouting...', 'Reconnoitering...', 'Concocting...', 'Devising...', 'Inventing...', 'Imagining...', 'Envisioning...',
241
+ 'Visualizing...', 'Picturing...', 'Dreaming...', 'Wondering...', 'Marveling...', 'Admiring...', 'Appreciating...', 'Enjoying...', 'Savoring...', 'Relishing...',
242
+ 'Loving...', 'Cherishing...', 'Treasure-hunting...', 'Questing...', 'Seeking...', 'Striving...', 'Endeavoring...', 'Attempting...', 'Trying...', 'Experimenting...',
243
+ 'Innovating...', 'Pioneering...', 'Trailblazing...', 'Pathfinding...', 'Navigating...', 'Steering...', 'Guiding...', 'Directing...', 'Captaining...', 'Piloting...',
244
+ 'Commanding...', 'Leading...', 'Spearheading...', 'Championing...', 'Absorbing...', 'Accenting...', 'Acclaiming...', 'Accommodating...', 'Accounting...', 'Accrediting...',
245
+ 'Accumulating...', 'Achieving...', 'Acknowledging...', 'Activating...', 'Adapting...', 'Adding...', 'Addressing...', 'Adhering...', 'Administering...', 'Adopting...',
246
+ 'Adorning...', 'Advising...', 'Affirming...', 'Aggregating...', 'Agreing...', 'Aiming...', 'Airbrushing...', 'Alerting...', 'Allocating...', 'Allowing...',
247
+ 'Altering...', 'Amalgamating...', 'Amending...', 'Amplifying...', 'Amusing...', 'Anchoring...', 'Animating...', 'Annexing...', 'Announcing...', 'Answering...',
248
+ 'Anticipating...', 'Appealing...', 'Appending...', 'Applying...', 'Appointing...', 'Appraising...', 'Approaching...', 'Approving...', 'Archiving...', 'Arguing...',
249
+ 'Arming...', 'Arranging...', 'Arraying...', 'Arresting...', 'Arriving...', 'Articulating...', 'Ascending...', 'Asserting...', 'Assessing...', 'Assigning...',
250
+ 'Assimilating...', 'Assisting...', 'Assuring...', 'Astonishing...', 'Attaching...', 'Attacking...', 'Attaining...', 'Attending...', 'Attesting...', 'Attracting...',
251
+ 'Auditing...', 'Augmenting...', 'Authenticating...', 'Authoring...', 'Authorizing...', 'Automating...', 'Averting...', 'Awakening...', 'Awarding...', 'Babbling...',
252
+ 'Backing...', 'Backtracking...', 'Badging...', 'Baging...', 'Baiting...', 'Ballooning...', 'Banding...', 'Banking...', 'Bartering...', 'Basing...',
253
+ 'Batching...', 'Bearing...', 'Bedazzling...', 'Befriending...', 'Begining...', 'Beholding...', 'Believing...', 'Belonging...', 'Bending...', 'Benefiting...',
254
+ 'Beseeching...', 'Bestowing...', 'Beting...', 'Biding...', 'Billowing...', 'Binding...', 'Blasting...', 'Bleaching...', 'Bleeding...', 'Bleeping...',
255
+ 'Blessing...', 'Blinking...', 'Blistering...', 'Blocking...', 'Bloting...', 'Blowing...', 'Bluring...', 'Boarding...', 'Boasting...', 'Bobing...',
256
+ 'Bolstering...', 'Bombarding...', 'Bonding...', 'Booming...', 'Boosting...', 'Bootstraping...', 'Bordering...', 'Borrowing...', 'Botching...', 'Bottling...',
257
+ 'Bowing...', 'Bowling...', 'Boxing...', 'Bracing...', 'Brainstorming...', 'Braising...', 'Branding...', 'Braving...', 'Breaching...', 'Breathing...',
258
+ 'Breezing...', 'Bricking...', 'Bridging...', 'Briefing...', 'Bringing...', 'Bristling...', 'Broadening...', 'Brokering...', 'Bronzing...', 'Brooding...',
259
+ 'Browsing...', 'Brushing...', 'Buckling...', 'Budgeting...', 'Buffering...', 'Buffing...', 'Bulking...', 'Bulletproofing...', 'Bumping...', 'Bunching...',
260
+ 'Buoying...', 'Bursting...', 'Burying...', 'Busting...', 'Buttering...', 'Buzzing...', 'Caching...', 'Cadencing...', 'Cajoling...', 'Caking...',
261
+ 'Calling...', 'Calming...', 'Camping...', 'Canceling...', 'Canoodling...', 'Canvasing...', 'Capitalizing...', 'Captioning...', 'Capturing...', 'Carbonizing...',
262
+ 'Caring...', 'Carving...', 'Cashing...', 'Casting...', 'Cataloging...', 'Catapulting...', 'Catching...', 'Categorizing...', 'Catering...', 'Cautioning...',
263
+ 'Ceasing...', 'Celebrating...', 'Cementing...', 'Censoring...', 'Centralizing...', 'Certifying...', 'Chaining...', 'Chairing...', 'Chalking...', 'Challenging...',
264
+ 'Channeling...', 'Chanting...', 'Charting...', 'Chating...', 'Cheering...', 'Chewing...', 'Chilling...', 'Chiming...', 'Chiping...', 'Chirping...',
265
+ 'Chiseling...', 'Choosing...', 'Choping...', 'Choreographing...', 'Chronicling...', 'Chuging...', 'Ciphering...', 'Circling...', 'Circulating...', 'Citing...',
266
+ 'Civilizing...', 'Clamping...', 'Clanging...', 'Claping...', 'Clarifying...', 'Clashing...', 'Classifying...', 'Clawing...', 'Cleansing...', 'Cleaving...',
267
+ 'Climbing...', 'Clinching...', 'Cliping...', 'Cloaking...', 'Clocking...', 'Cloning...', 'Closing...', 'Clouding...', 'Clustering...', 'Clutching...',
268
+ 'Coaching...', 'Coalescing...', 'Coating...', 'Coaxing...', 'Cocooning...', 'Codifying...', 'Coercing...', 'Coexisting...', 'Cogitating...', 'Cohering...',
269
+ 'Coiling...', 'Coinciding...', 'Collaborating...', 'Collapsing...', 'Collating...', 'Colliding...', 'Colonizing...', 'Coloring...', 'Combing...', 'Combining...',
270
+ 'Comforting...', 'Commemorating...', 'Commencing...', 'Commenting...', 'Commissioning...', 'Commiting...', 'Communing...', 'Communicating...', 'Commuting...', 'Compacting...',
271
+ 'Comparing...', 'Compassing...', 'Compeling...', 'Compensating...', 'Competing...', 'Complementing...', 'Completing...', 'Complicating...', 'Complimenting...', 'Comprehending...',
272
+ 'Compressing...', 'Comprising...', 'Compromising...', 'Concealing...', 'Conceding...', 'Conceiving...', 'Conceptualizing...', 'Concerning...', 'Concluding...', 'Concuring...',
273
+ 'Condensing...', 'Conditioning...', 'Condoning...', 'Confering...', 'Confessing...', 'Configuring...', 'Confining...', 'Confiscating...', 'Conflating...', 'Confronting...',
274
+ 'Confusing...', 'Congealing...', 'Congratulating...', 'Conjuring...', 'Connecting...', 'Conquering...', 'Consecrating...', 'Consenting...', 'Conserving...', 'Consigning...',
275
+ 'Consisting...', 'Consoling...', 'Consolidating...', 'Conspiring...', 'Constituting...', 'Constraining...', 'Consulting...', 'Consuming...', 'Contacting...', 'Containing...',
276
+ 'Contemplating...', 'Contending...', 'Contenting...', 'Contesting...', 'Contracting...', 'Contrasting...', 'Contributing...', 'Contriving...', 'Controling...', 'Convening...',
277
+ 'Converging...', 'Conversing...', 'Converting...', 'Conveying...', 'Convincing...', 'Convoying...', 'Cooling...', 'Cooperating...', 'Coordinating...', 'Copying...',
278
+ 'Coring...', 'Corking...', 'Correlating...', 'Corresponding...', 'Corroborating...', 'Corraling...', 'Correcting...', 'Corrugating...', 'Cosseting...', 'Counseling...',
279
+ 'Counting...', 'Coupling...', 'Coursing...', 'Covering...', 'Coveting...', 'Cradling...', 'Cranking...', 'Crashing...', 'Crawling...', 'Creasing...',
280
+ 'Crediting...', 'Cresting...', 'Critiquing...', 'Crocheting...', 'Crooning...', 'Crossing...', 'Crowding...', 'Crowning...', 'Crumbing...', 'Crusading...',
281
+ 'Crystallizing...', 'Cubing...', 'Cuddling...', 'Culling...', 'Cultivating...', 'Curbing...', 'Curating...', 'Curling...', 'Currying...', 'Cushioning...',
282
+ 'Customizing...', 'Cuting...', 'Cycling...', 'Dabbling...', 'Daming...', 'Dampening...', 'Daring...', 'Darting...', 'Dashing...', 'Dating...',
283
+ 'Dawdling...', 'Dazzling...', 'Deactivating...', 'Debuging...', 'Debuting...', 'Decanting...', 'Decelerating...', 'Decentralizing...', 'Decking...', 'Declaring...',
284
+ 'Declining...', 'Decomposing...', 'Decorating...', 'Decoupling...', 'Decreasing...', 'Dedicating...', 'Deducing...', 'Deepening...', 'Defeating...', 'Defending...',
285
+ 'Defering...', 'Defining...', 'Deflecting...', 'Deforming...', 'Defragmenting...', 'Defusing...', 'Degreasing...', 'Dehydrating...', 'Delegating...', 'Deleting...',
286
+ 'Delivering...', 'Demanding...', 'Demarcating...', 'Demisting...', 'Demystifying...', 'Denoting...', 'Denouncing...', 'Densifying...', 'Departing...', 'Depending...',
287
+ 'Depositing...', 'Depressurizing...', 'Deputing...', 'Deriving...', 'Descending...', 'Describing...', 'Deserting...', 'Desiring...', 'Despatching...', 'Detecting...',
288
+ 'Detering...', 'Detoxing...', 'Devaluing...', 'Developing...', 'Deviating...', 'Devoting...', 'Diping...', 'Disabling...', 'Disarming...', 'Disassembling...',
289
+ 'Disbursing...', 'Discarding...', 'Discerning...', 'Discharging...', 'Disciplining...', 'Disclosing...', 'Disconnecting...', 'Discontinuing...', 'Discounting...', 'Discoursing...',
290
+ 'Discrediting...', 'Discriminating...', 'Discussing...', 'Disembarking...', 'Disentangling...', 'Disguising...', 'Disinfecting...', 'Disliking...', 'Dismantling...', 'Dismissing...',
291
+ 'Dispatching...', 'Dispeling...', 'Dispensing...', 'Dispersing...', 'Displaying...', 'Disposing...', 'Disproving...', 'Dissecting...', 'Disseminating...', 'Dissipating...',
292
+ 'Dissolving...', 'Distinguishing...', 'Distracting...', 'Distributing...', 'Disturbing...', 'Diverting...', 'Divining...', 'Dividing...', 'Divulging...', 'Docking...',
293
+ 'Documenting...', 'Dodging...', 'Domesticating...', 'Dominating...', 'Donating...', 'Doodling...', 'Dosing...', 'Doting...', 'Doubling...', 'Doubting...',
294
+ 'Downgrading...', 'Downloading...', 'Draging...', 'Draining...',
281
295
  ];
282
296
 
283
297
  // ---- ANSI helpers ----
@@ -696,15 +710,46 @@ export function composerTextIndexAt(layout, rowIdx, colInside) {
696
710
  // A Bash result is a FAILURE when the command exited non-zero or the spawn
697
711
  // itself errored. Detected from the tool-result text (the only place the code
698
712
  // survives for the UI, since the exit-code line is hidden from display).
699
- export function isFailureResult(text) {
713
+ // Failure prefixes that the built-in tools actually return (verified against
714
+ // src/tools/*.js). Any result starting with one of these is a FAILED call:
715
+ // "Error: old_string not found in …" (Edit)
716
+ // "Error reading/writing <path>: …" (Read/Write/Edit)
717
+ // "Cannot read: <path> appears to be …" (Read)
718
+ // "Edit rejected: …" (Edit staleness / read guards)
719
+ // "Command cannot be empty." (Bash)
720
+ // resolvePath throws are surfaced verbatim as `e.message`, e.g.
721
+ // "Path outside workspace: …" / "ENOENT: no such file or directory …".
722
+ const FAIL_PREFIX = /^(Error\b|\[error:|Cannot read:|Edit rejected:|Command cannot be empty\b|Path outside workspace\b|ENOENT\b|EACCES\b|EPERM\b|EISDIR\b|ENOTDIR\b)/i;
723
+
724
+ export function isFailureResult(text, toolName) {
700
725
  const s = String(text == null ? '' : text);
701
- if (/^\[error:/m.test(s.trim())) return true;
726
+ const trimmed = s.trim();
727
+ if (FAIL_PREFIX.test(trimmed)) return true;
728
+ // A non-zero Bash exit code marks the call as failed (red status bullet). The
729
+ // `[exit code: N]` LINE itself is never shown to the user — it is bookkeeping
730
+ // for the model only (see messageLines).
702
731
  const m = /\[exit code:\s*([^\]]+)\]/.exec(s);
703
732
  if (!m) return false;
704
733
  const v = m[1].trim();
705
734
  return v !== '0';
706
735
  }
707
736
 
737
+ // Extract the human-readable failure reason from a tool result, or '' when the
738
+ // result should not render an extra "why it failed" line. The reason is shown
739
+ // in red under the tool call for non-Bash tools (Edit / Read / Write / …); Bash
740
+ // failures are already obvious from the command's own output, so they return ''.
741
+ export function failureReason(text, toolName) {
742
+ const name = String(toolName || '').toLowerCase();
743
+ if (name === 'bash') return '';
744
+ const s = String(text == null ? '' : text);
745
+ const trimmed = s.trim();
746
+ if (!FAIL_PREFIX.test(trimmed)) return '';
747
+ // Collapse to a single line and drop the `[error: …]` wrapper.
748
+ let line = trimmed.split('\n')[0].trim();
749
+ line = line.replace(/^\[error:\s*/i, '').replace(/\]$/, '').trim();
750
+ return line;
751
+ }
752
+
708
753
  function msgColorFor(role, text) {
709
754
  if (role === 'system' && typeof text === 'string' && text.startsWith('[turn took')) return C.gray;
710
755
  if (role === 'system') return C.teal;
@@ -1228,9 +1273,10 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1228
1273
  let m = /^(\s*)(#{1,6})\s+(.*)$/.exec(t);
1229
1274
  if (m && !isContinuation) {
1230
1275
  const hashCount = m[2].length;
1276
+ // h1 is bold white, h2 is plain white, h3+ is gray. Only h1 carries BOLD.
1231
1277
  const size = hashCount === 1 ? C.white + C.bold : hashCount === 2 ? C.white : C.gray;
1232
1278
  for (const w of wrapWords(m[3], width)) {
1233
- out.push('\x1b[1m' + (hashCount === 1 ? C.bold : '') + w + '\x1b[0m');
1279
+ out.push(col(w, size));
1234
1280
  }
1235
1281
  return out;
1236
1282
  }
@@ -1254,7 +1300,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1254
1300
  if (task && !isContinuation) {
1255
1301
  const checked = task[3] !== ' ';
1256
1302
  const box = checked ? col('✓', C.green) : col('○', C.gray);
1257
- const content = col(' ' + inlineMarkdown(task[4], codeFg), fg);
1303
+ const content = col(' ' + inlineMarkdown(task[4], codeFg, fg), fg);
1258
1304
  for (const w of wrapWords(task[4], Math.max(1, width - 4))) {
1259
1305
  out.push(col(' ', C.gray) + box + (w === task[4] ? content : col(' ' + w, fg)));
1260
1306
  }
@@ -1266,7 +1312,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1266
1312
  const bullet = isOrdered ? col(li[2], C.cyan) : col('•', C.cyan);
1267
1313
  const pad = isOrdered ? ' '.repeat(visualCol(li[2])) + ' ' : ' ';
1268
1314
  let first = true;
1269
- for (const w of wrapWords(inlineMarkdown(li[3], codeFg), Math.max(1, width - 2))) {
1315
+ for (const w of wrapWords(inlineMarkdown(li[3], codeFg, fg), Math.max(1, width - 2))) {
1270
1316
  out.push((first ? col('', C.gray) + bullet + ' ' : col(pad, C.gray)) + col(w, fg));
1271
1317
  first = false;
1272
1318
  }
@@ -1275,7 +1321,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1275
1321
  }
1276
1322
  // Inline styles: **bold**, *italic*, `code`, [text](url)
1277
1323
  if (/[*`\[]/.test(t)) {
1278
- for (const w of wrapWords(t, width)) out.push(col(inlineMarkdown(w, codeFg), fg));
1324
+ for (const w of wrapWords(t, width)) out.push(col(inlineMarkdown(w, codeFg, fg), fg));
1279
1325
  return out;
1280
1326
  }
1281
1327
  // Plain - apply default foreground color
@@ -1285,18 +1331,23 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1285
1331
 
1286
1332
  // Apply inline markdown to a single line: `code`, **bold**, *italic*, ~~strike~~,
1287
1333
  // [text](url). Order matters so inner tokens are handled safely.
1288
- function inlineMarkdown(s, codeFg) {
1334
+ // `base` is the surrounding foreground colour; every inline style re-asserts it
1335
+ // after its own reset, otherwise the bare `\e[0m` would cancel the base colour
1336
+ // for the REST of the line (plain text after a **bold** run used to lose its
1337
+ // colour and fall back to the terminal default).
1338
+ function inlineMarkdown(s, codeFg, base) {
1339
+ const fb = base || '';
1289
1340
  let r = s;
1290
1341
  // Inline code first so it is not mangled by * [] ~.
1291
- r = r.replace(/`([^`]+)`/g, (_, c) => col(c, codeFg));
1342
+ r = r.replace(/`([^`]+)`/g, (_, c) => col(c, codeFg) + fb);
1292
1343
  // Bold
1293
- r = r.replace(/\*\*([^*]+)\*\*/g, (_, c) => C.bold + c + C.reset);
1344
+ r = r.replace(/\*\*([^*]+)\*\*/g, (_, c) => C.bold + c + C.reset + fb);
1294
1345
  // Strikethrough
1295
1346
  r = r.replace(/~~([^~]+)~~/g, (_, c) => '\x1b[9m' + c + '\x1b[29m');
1296
1347
  // Italic
1297
1348
  r = r.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => pre + '\x1b[3m' + c + '\x1b[23m');
1298
1349
  // Links: [text](url) -> text (cyan); autolink bare http(s) urls
1299
- r = r.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, txt) => col(txt, C.cyan));
1350
+ r = r.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, txt) => col(txt, C.cyan) + fb);
1300
1351
  r = r.replace(/(^|\s)(https?:\/\/[^\s]+)/g, (_, pre, url) => pre + col(url, C.cyan));
1301
1352
  return r;
1302
1353
  }
@@ -1714,6 +1765,8 @@ export function composeFrame(state, cols, rows) {
1714
1765
  const hint = ed.hint || 'Ctrl+S save · Esc cancel · Enter newline';
1715
1766
  lines.push(col(hint, C.gray));
1716
1767
  lines.push('');
1768
+ // Viewport height for the editor body
1769
+ const viewH = Math.max(1, bodyH - (ed.notice ? 6 : 5));
1717
1770
  // Keep the caret row in view.
1718
1771
  let top = Math.max(0, ed.top || 0);
1719
1772
  if (ed.caretRow < top) top = ed.caretRow;
@@ -1723,6 +1776,8 @@ export function composeFrame(state, cols, rows) {
1723
1776
  const idx = top + i;
1724
1777
  const txt = idx < textLines.length ? expandTabs(textLines[idx]) : '';
1725
1778
  lines.push(col(fitAnsi(txt, w), C.white));
1779
+ // Mouse click on this body row moves the caret to that line.
1780
+ addHit(lines.length - 1, 0, w - 1, { kind: 'editor', row: idx });
1726
1781
  }
1727
1782
  lines.push(col(fitAnsi(` line ${ed.caretRow + 1}/${textLines.length} · ${textLines.length} lines`, w), C.gray));
1728
1783
  if (ed.notice) lines.push(col(fitAnsi(' ' + ed.notice, w), ed.noticeKind === 'error' ? C.red : C.green));
@@ -1975,13 +2030,23 @@ export function composeFrame(state, cols, rows) {
1975
2030
  if (state.finishAnim) {
1976
2031
  const DUR = 500;
1977
2032
  const t = Math.min(1, (Date.now() - state.finishAnim.start) / DUR);
1978
- const from = state.finishAnim.from;
1979
- const to = state.finishAnim.to;
1980
- const n = Math.max(from.length, to.length);
1981
- const head = Math.round(t * n); // how many chars are "new"
1982
- let mixStr = '';
1983
- for (let i = 0; i < n; i++) {
1984
- mixStr += (i < head) ? (to[i] ?? '') : (from[i] ?? '');
2033
+ const wordFrom = state.finishAnim.wordFrom || '';
2034
+ const wordTo = state.finishAnim.wordTo || 'turn took';
2035
+ const tail = state.finishAnim.tail || ''; // ` <duration>]`, never changes
2036
+ // SWEEP: start from the LIVE word (`Working...`), pad it on the right to
2037
+ // the wider of the two words, then overwrite it left → right, one
2038
+ // character per step. Characters the sweep has not reached keep their OLD
2039
+ // value, so each `.` disappears on its own turn:
2040
+ // [Working... 59s] → [turn to... 59s] → [turn too.. 59s] → [turn took 59s]
2041
+ // Width = the LONGER word, otherwise a longer old word gets truncated and
2042
+ // its trailing dots vanish at once.
2043
+ const width = Math.max(wordFrom.length, wordTo.length);
2044
+ const from = wordFrom.padEnd(width, '.');
2045
+ const toPadded = wordTo.padEnd(width, '');
2046
+ const done = t >= 1 ? width : Math.floor(t * width); // chars overwritten so far
2047
+ let word = '';
2048
+ for (let i = 0; i < width; i++) {
2049
+ word += (i < done) ? (toPadded[i] || '') : from[i];
1985
2050
  }
1986
2051
  // Colour: orange → grey over the animation.
1987
2052
  const ORANGE = [255, 140, 0];
@@ -1990,7 +2055,7 @@ export function composeFrame(state, cols, rows) {
1990
2055
  const g = Math.round(ORANGE[1] + (GREY[1] - ORANGE[1]) * t);
1991
2056
  const b = Math.round(ORANGE[2] + (GREY[2] - ORANGE[2]) * t);
1992
2057
  const animColor = lerpColor(r, g, b, r, g, b, 0);
1993
- lines.push(col(frame, animColor) + ' ' + col(mixStr, animColor) + elapsed);
2058
+ lines.push(col(frame, animColor) + ' ' + col(`[${word}${tail}`, animColor));
1994
2059
  } else {
1995
2060
 
1996
2061
  // Timing (spinner ticks every 80ms, so 0.5s ≈ 6 ticks):
@@ -2009,7 +2074,7 @@ export function composeFrame(state, cols, rows) {
2009
2074
  // Colours as plain RGB triples (never ANSI strings).
2010
2075
  const ORANGE = [255, 140, 0];
2011
2076
  const YELLOW = [255, 240, 120];
2012
- const RED = [255, 85, 20]; // midway between pure red and the warm orange-red
2077
+ const RED = [255, 0, 0]; // pure red
2013
2078
  const target = phase === 0 ? YELLOW : RED;
2014
2079
 
2015
2080
  const chars = workMsg;
@@ -3210,47 +3275,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3210
3275
  return;
3211
3276
  }
3212
3277
 
3213
- case 'theme': {
3214
- const applyTheme = (name) => {
3215
- if (!setTheme(name)) return false;
3216
- state.theme = name;
3217
- try {
3218
- const f = hncodeConfigFile();
3219
- const txt = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
3220
- const next = /^theme\s*=.*$/m.test(txt)
3221
- ? txt.replace(/^theme\s*=.*$/m, `theme = "${name}"`)
3222
- : `theme = "${name}"\n` + txt;
3223
- fs.writeFileSync(f, next, 'utf8');
3224
- } catch { }
3225
-
3226
- // Force a complete screen refresh with new theme colors
3227
- // We need to access stdout from the host context
3228
- if (host && typeof host.renderFrame === 'function') {
3229
- // Trigger immediate re-render which will use new theme colors
3230
- host.renderFrame();
3231
- } else {
3232
- // Fallback: just show message and let normal flow handle it
3233
- app(`Theme set to "${name}".`);
3234
- return true;
3235
- }
3236
-
3237
- app(`Theme set to "${name}".`);
3238
- return true;
3239
- };
3240
- if (raw) {
3241
- if (!THEME_NAMES.includes(raw)) { appErr(`Theme "${raw}" could not be loaded. Expected: ${THEME_NAMES.join(' / ')}`); return; }
3242
- applyTheme(raw); return;
3243
- }
3244
- openPicker({
3245
- title: 'Set the terminal UI theme',
3246
- items: THEME_NAMES.map((t) => ({ label: t, sub: t === 'auto' ? 'track the terminal' : `${t} palette`, current: (state.theme || 'auto') === t })),
3247
- sel: Math.max(0, THEME_NAMES.indexOf(state.theme || 'auto')),
3248
- searchable: false,
3249
- onPick: (it) => { applyTheme(it.label); return true; },
3250
- });
3251
- return;
3252
- }
3253
- case 'statusline':
3278
+ case 'statusline':
3254
3279
  openPicker({
3255
3280
  title: 'Status line items',
3256
3281
  items: [
@@ -4346,34 +4371,23 @@ export async function startTUI(opts) {
4346
4371
  // PowerShell took ~3s, which read as "the shortcut does nothing".
4347
4372
  async function pasteFromClipboard(fastPathOnly = false) {
4348
4373
  notice('Pasting…', 'info');
4349
- const imgBase64 = readImage();
4350
- if (imgBase64) {
4351
- state.pendingImage = imgBase64;
4352
- insertComposerPaste('[Image pasted - will send as multimodal]');
4353
- notice('Image pasted', 'info');
4374
+ // One probe handles every clipboard shape: copied FILES (→ paths), plain
4375
+ // TEXT (→ text), or an IMAGE (→ a temp .png path). The async version runs it
4376
+ // on the PRE-WARMED helper (~10ms) instead of a cold spawn (~2.9s).
4377
+ const { text, via } = await readClipboardContentAsync();
4378
+ if (text) {
4379
+ insertComposerPaste(text);
4380
+ notice(via === 'text' ? 'Pasted' : `Pasted ${via} as path`, 'info');
4354
4381
  renderFrame();
4355
4382
  return;
4356
4383
  }
4357
-
4358
- // Always try fast path first (readOnce is instant)
4359
- try {
4360
- const text = readOnce();
4361
- if (text) {
4362
- insertComposerPaste(text);
4363
- notice('Pasted instantly!', 'info');
4364
- renderFrame();
4365
- return;
4366
- }
4367
- } catch {}
4368
-
4369
- // Fast path failed - only use slow path for Ctrl+Shift+V, not for right-click
4384
+ // Fast path failed — only fall back to the slow warm-helper reader when this
4385
+ // is a full paste (Ctrl+Shift+V), not a right-click quick paste.
4370
4386
  if (fastPathOnly) {
4371
- notice('Clipboard empty or unavailable (fast path only)', 'error');
4387
+ notice('Clipboard empty or unavailable', 'error');
4372
4388
  renderFrame();
4373
4389
  return;
4374
4390
  }
4375
-
4376
- // Slow path for Ctrl+Shift+V
4377
4391
  const result = await readText();
4378
4392
  if (!result.text) { notice('Clipboard empty or unavailable', 'error'); renderFrame(); return; }
4379
4393
  insertComposerPaste(result.text);
@@ -4564,18 +4578,18 @@ export async function startTUI(opts) {
4564
4578
  }
4565
4579
  }
4566
4580
 
4567
- if (t.key === 'c-s') {
4581
+ if (t.key === 'c-s' && !state.editor) {
4568
4582
  steerAll();
4569
4583
  return;
4570
4584
  }
4571
4585
 
4572
- if (t.key === 'c-t') {
4586
+ if (t.key === 'c-t' && !state.editor) {
4573
4587
  state.todosExpanded = !state.todosExpanded;
4574
4588
  renderFrame();
4575
4589
  return;
4576
4590
  }
4577
4591
 
4578
- if (t.key === 'c-o') {
4592
+ if (t.key === 'c-o' && !state.editor) {
4579
4593
  state.expanded = !state.expanded;
4580
4594
  notice(state.expanded ? 'Expanded tool output' : 'Collapsed tool output', 'info');
4581
4595
  renderFrame();
@@ -4588,7 +4602,7 @@ export async function startTUI(opts) {
4588
4602
  // agent is running, so the idle-only branch that used to hold this was
4589
4603
  // unreachable dead code and ↑ scrolled the chat instead.
4590
4604
  {
4591
- const overlay = state.picker || state.form || state.panel || state.menuOpen;
4605
+ const overlay = state.picker || state.form || state.panel || state.menuOpen || state.editor;
4592
4606
  const cur = state.input || '';
4593
4607
  if (!overlay && cur === '' && t.key === 'up' && (state.queued || []).length) {
4594
4608
  recallQueued();
@@ -4623,7 +4637,7 @@ export async function startTUI(opts) {
4623
4637
  }
4624
4638
  else {
4625
4639
  if (t.key === 'up' || t.key === 'down') {
4626
- const overlay = state.picker || state.form || state.panel || state.menuOpen;
4640
+ const overlay = state.picker || state.form || state.panel || state.menuOpen || state.editor;
4627
4641
  const cur = state.input || '';
4628
4642
  if (!overlay && cur === '' && state.history.length) {
4629
4643
  if (state.historyIdx === -1) state.historyIdx = state.history.length;
@@ -5216,6 +5230,10 @@ export async function startTUI(opts) {
5216
5230
 
5217
5231
  async function runAgent(text) {
5218
5232
  addChat({ role: 'user', text });
5233
+ // A message the user JUST SENT should always bring the newest content into
5234
+ // view, even if they were scrolled up reading history. (Queue/steer do NOT
5235
+ // come through here, so they keep the view exactly where the user left it.)
5236
+ state.scroll = 0;
5219
5237
  state.running = true;
5220
5238
  // Pick the Working… wording ONCE for this turn, so it does not change while
5221
5239
  // the gradient loops. The next turn picks a new one.
@@ -5433,9 +5451,18 @@ export async function startTUI(opts) {
5433
5451
  || [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.toolName === e.name);
5434
5452
  // A failed run must be visible on the TOOL line too: the status bullet
5435
5453
  // turns red (see messageLines).
5436
- const failedRun = isFailureResult(e.content);
5454
+ const failedRun = isFailureResult(e.content, e.name);
5437
5455
  if (entry) { entry.pending = false; entry.id = undefined; entry.failed = failedRun; }
5438
- if (e.name !== 'Edit' && e.name !== 'Write') addChat({ role: 'tool_result', text: e.content, failed: failedRun });
5456
+ const reason = failureReason(e.content, e.name);
5457
+ const isEditLike = e.name === 'Edit' || e.name === 'Write';
5458
+ if (!isEditLike) {
5459
+ // Non-Edit tools show their raw result underneath.
5460
+ addChat({ role: 'tool_result', text: e.content, failed: failedRun });
5461
+ } else if (reason) {
5462
+ // Edit/Write hide their body (the diff already shows the change), so
5463
+ // surface a failure reason as its own red line under the tool call.
5464
+ addChat({ role: 'tool_result', text: reason, failed: true });
5465
+ }
5439
5466
  // NOTE: queued input is deliberately NOT drained into the running turn
5440
5467
  // here. A message typed while the agent works is a NEW turn, so it waits
5441
5468
  // for this turn to finish (see the drain after `agent.run()`). Ctrl-S
@@ -5470,16 +5497,17 @@ export async function startTUI(opts) {
5470
5497
  session.focus = !!state.focus;
5471
5498
  sess.saveSession(session);
5472
5499
  const turnMs = state.turnStart ? Date.now() - state.turnStart : 0;
5473
- const finalText = `[turn took ${fmtDuration(turnMs)}]`;
5474
-
5475
- // Play the 0.5s morph before the Working row disappears. Keep `running`
5476
- // true during the animation so `workingH` stays 1. Repaint every 20ms so
5477
- // the morph is smooth (~25 frames over 500ms) rather than the 80ms spinner
5478
- // tick (which would give only ~6 chunky steps).
5500
+ const dur = fmtDuration(turnMs);
5501
+ // The live row is `<phrase> <gray>[<dur>]`. At the end we keep the same row
5502
+ // shape — `[<word> <dur>]` — and only rewrite the word, so the brackets and
5503
+ // the duration stay put and the time is never repeated. `…` is part of the
5504
+ // phrase and is preserved.
5505
+ const wordFrom = String(state.workMsg || WORKING_MESSAGES[0]);
5479
5506
  state.finishAnim = {
5480
5507
  start: Date.now(),
5481
- from: (state.workMsg || WORKING_MESSAGES[0]),
5482
- to: finalText,
5508
+ wordFrom,
5509
+ wordTo: 'turn took',
5510
+ tail: ` ${dur}]`, // fixed right-hand side, e.g. " 59s]"
5483
5511
  };
5484
5512
  const animStart = Date.now();
5485
5513
  while (Date.now() - animStart < 520) {
@@ -5493,7 +5521,8 @@ export async function startTUI(opts) {
5493
5521
  state.lastTurnMs = turnMs;
5494
5522
  session.lastTurnMs = turnMs;
5495
5523
  sess.saveSession(session);
5496
- addChat({ role: 'system', text: finalText });
5524
+ // Same shape the sweep animated into: `[turn took <dur>]`.
5525
+ addChat({ role: 'system', text: `[turn took ${dur}]` });
5497
5526
  }
5498
5527
  renderFrame();
5499
5528
  if (state.queued.length) {
@@ -5587,7 +5616,13 @@ export async function startTUI(opts) {
5587
5616
  }
5588
5617
 
5589
5618
  function normalizeMsg(m) {
5590
- return { role: m && m.role ? m.role : 'system', text: m && m.text != null ? String(m.text) : '' };
5619
+ // Keep `failed`: a failed tool result must render in RED. Dropping it here
5620
+ // made every failure look like ordinary gray output.
5621
+ return {
5622
+ role: m && m.role ? m.role : 'system',
5623
+ text: m && m.text != null ? String(m.text) : '',
5624
+ failed: !!(m && m.failed),
5625
+ };
5591
5626
  }
5592
5627
 
5593
5628
  let stopped = false;