@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 +9 -120
- package/README_CN.md +21 -121
- package/package.json +1 -1
- package/src/agent.js +13 -22
- package/src/clipboard.js +116 -13
- package/src/colors.js +16 -8
- package/src/index.js +1 -1
- package/src/tools/grep.js +5 -2
- package/src/tui.js +204 -169
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# hncode — AI Coding Agent
|
|
1
|
+
# hncode — AI Coding Agent TUI
|
|
2
2
|
|
|
3
|
-
English | [简体中文](
|
|
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
|
|
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
|
-
## 📖
|
|
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
|
-
|
|
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
|
|
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](
|
|
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
|
-
|
|
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 渲染**:
|
|
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
|
-
|
|
46
|
+
本文件只是简要介绍。**完整文档**(所有命令、配置项、架构说明、插件与开发指南)请见 Wiki:
|
|
149
47
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
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
|
|
28
|
-
You
|
|
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
|
-
-
|
|
33
|
-
-
|
|
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
|
|
39
|
-
- If the request is ambiguous, ask one short clarifying question
|
|
40
|
-
-
|
|
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
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
-
|
|
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
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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,
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
214
|
-
'
|
|
215
|
-
'
|
|
216
|
-
'
|
|
217
|
-
'
|
|
218
|
-
'
|
|
219
|
-
'
|
|
220
|
-
'
|
|
221
|
-
'
|
|
222
|
-
'
|
|
223
|
-
'
|
|
224
|
-
'
|
|
225
|
-
'
|
|
226
|
-
'
|
|
227
|
-
'
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
233
|
-
'
|
|
234
|
-
'
|
|
235
|
-
'
|
|
236
|
-
'
|
|
237
|
-
'
|
|
238
|
-
'
|
|
239
|
-
'
|
|
240
|
-
'
|
|
241
|
-
'
|
|
242
|
-
'
|
|
243
|
-
'
|
|
244
|
-
'
|
|
245
|
-
'
|
|
246
|
-
'
|
|
247
|
-
'
|
|
248
|
-
'
|
|
249
|
-
'
|
|
250
|
-
'
|
|
251
|
-
'
|
|
252
|
-
'
|
|
253
|
-
'
|
|
254
|
-
'
|
|
255
|
-
'
|
|
256
|
-
'
|
|
257
|
-
'
|
|
258
|
-
'
|
|
259
|
-
'
|
|
260
|
-
'
|
|
261
|
-
'
|
|
262
|
-
'
|
|
263
|
-
'
|
|
264
|
-
'
|
|
265
|
-
'
|
|
266
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'
|
|
269
|
-
'
|
|
270
|
-
'
|
|
271
|
-
'
|
|
272
|
-
'
|
|
273
|
-
'
|
|
274
|
-
'
|
|
275
|
-
'
|
|
276
|
-
'
|
|
277
|
-
'
|
|
278
|
-
'
|
|
279
|
-
'
|
|
280
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1979
|
-
const
|
|
1980
|
-
const
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
5474
|
-
|
|
5475
|
-
//
|
|
5476
|
-
//
|
|
5477
|
-
//
|
|
5478
|
-
|
|
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
|
-
|
|
5482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|