@hncode/hncode 0.1.9 → 0.1.11
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 +87 -87
- package/README_CN.md +87 -87
- package/package.json +1 -1
- package/src/tools/edit.js +1 -1
- package/src/tools/grep.js +5 -2
- package/src/tui.js +350 -120
package/README.md
CHANGED
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
# hncode — AI Coding Agent TUI
|
|
2
|
-
|
|
3
|
-
English | [简体中文](https://github.com/NiceHello666/hncode/blob/main/README_CN.md) | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## 🌟 Features
|
|
12
|
-
|
|
13
|
-
- **Cyan-blue TUI**: Raw terminal renderer for perfect Windows Terminal compatibility
|
|
14
|
-
- **Dual Protocol Support**: Works with both OpenAI-compatible APIs and Anthropic
|
|
15
|
-
- **Smart Context Management**: Auto-compaction at 85% usage, AI-summarized history retention
|
|
16
|
-
- **MCP-style Toolbelt**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, etc.
|
|
17
|
-
- **Plugin System**: Extend with custom tools, commands, and hooks
|
|
18
|
-
- **Session Persistence**: Resume conversations across sessions
|
|
19
|
-
- **Permission Modes**: Ask, YOLO, or Auto modes for different workflows
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## 🚀 Installation
|
|
24
|
-
|
|
25
|
-
### Global Installation (Recommended)
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
npm install -g @hncode/hncode
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Then run:
|
|
32
|
-
```bash
|
|
33
|
-
hncode
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
### Using npx (No installation required)
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
npx @hncode/hncode
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
## 📖 Documentation
|
|
45
|
-
|
|
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:
|
|
47
|
-
|
|
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)**
|
|
50
|
-
|
|
51
|
-
---
|
|
52
|
-
|
|
53
|
-
## 🤝 Contributing
|
|
54
|
-
|
|
55
|
-
This project was created through Vibe Coding. While it may not be perfect, I welcome your feedback!
|
|
56
|
-
|
|
57
|
-
**Feel free to:**
|
|
58
|
-
- Submit issues for bugs or suggestions
|
|
59
|
-
- Fork and create pull requests
|
|
60
|
-
- Share your experience using hncode
|
|
61
|
-
|
|
62
|
-
---
|
|
63
|
-
|
|
64
|
-
## 🙏 Acknowledgments
|
|
65
|
-
|
|
66
|
-
hncode draws inspiration from several excellent projects:
|
|
67
|
-
|
|
68
|
-
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - The original cyan-blue TUI coding agent that inspired this project's design and approach
|
|
69
|
-
- **[Claude Code](https://github.com/anthropics/claude-code)** - The conversation flow and tool interaction patterns
|
|
70
|
-
|
|
71
|
-
Special thanks to the communities behind these projects for pushing the boundaries of AI-assisted coding.
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## 📄 License
|
|
76
|
-
|
|
77
|
-
This project is licensed under the [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE).
|
|
78
|
-
|
|
79
|
-
**In short:**
|
|
80
|
-
1. ✅ You can freely use, modify, and share this code
|
|
81
|
-
2. ✅ Must retain author attribution (NiceHello666) and link back to this repository
|
|
82
|
-
3. ❌ Cannot be used for commercial purposes (e.g., selling, as part of paid products)
|
|
83
|
-
|
|
84
|
-
---
|
|
85
|
-
|
|
86
|
-
**NPM Package**: [@hncode/hncode](https://www.npmjs.com/package/@hncode/hncode)
|
|
87
|
-
**GitHub**: [NiceHello666/hncode](https://github.com/NiceHello666/hncode)
|
|
1
|
+
# hncode — AI Coding Agent TUI
|
|
2
|
+
|
|
3
|
+
English | [简体中文](https://github.com/NiceHello666/hncode/blob/main/README_CN.md) | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)
|
|
4
|
+
|
|
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
|
+
|
|
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
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🌟 Features
|
|
12
|
+
|
|
13
|
+
- **Cyan-blue TUI**: Raw terminal renderer for perfect Windows Terminal compatibility
|
|
14
|
+
- **Dual Protocol Support**: Works with both OpenAI-compatible APIs and Anthropic
|
|
15
|
+
- **Smart Context Management**: Auto-compaction at 85% usage, AI-summarized history retention
|
|
16
|
+
- **MCP-style Toolbelt**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, etc.
|
|
17
|
+
- **Plugin System**: Extend with custom tools, commands, and hooks
|
|
18
|
+
- **Session Persistence**: Resume conversations across sessions
|
|
19
|
+
- **Permission Modes**: Ask, YOLO, or Auto modes for different workflows
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 🚀 Installation
|
|
24
|
+
|
|
25
|
+
### Global Installation (Recommended)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @hncode/hncode
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then run:
|
|
32
|
+
```bash
|
|
33
|
+
hncode
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Using npx (No installation required)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx @hncode/hncode
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 📖 Documentation
|
|
45
|
+
|
|
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:
|
|
47
|
+
|
|
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)**
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 🤝 Contributing
|
|
54
|
+
|
|
55
|
+
This project was created through Vibe Coding. While it may not be perfect, I welcome your feedback!
|
|
56
|
+
|
|
57
|
+
**Feel free to:**
|
|
58
|
+
- Submit issues for bugs or suggestions
|
|
59
|
+
- Fork and create pull requests
|
|
60
|
+
- Share your experience using hncode
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 🙏 Acknowledgments
|
|
65
|
+
|
|
66
|
+
hncode draws inspiration from several excellent projects:
|
|
67
|
+
|
|
68
|
+
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - The original cyan-blue TUI coding agent that inspired this project's design and approach
|
|
69
|
+
- **[Claude Code](https://github.com/anthropics/claude-code)** - The conversation flow and tool interaction patterns
|
|
70
|
+
|
|
71
|
+
Special thanks to the communities behind these projects for pushing the boundaries of AI-assisted coding.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 📄 License
|
|
76
|
+
|
|
77
|
+
This project is licensed under the [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE).
|
|
78
|
+
|
|
79
|
+
**In short:**
|
|
80
|
+
1. ✅ You can freely use, modify, and share this code
|
|
81
|
+
2. ✅ Must retain author attribution (NiceHello666) and link back to this repository
|
|
82
|
+
3. ❌ Cannot be used for commercial purposes (e.g., selling, as part of paid products)
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
**NPM Package**: [@hncode/hncode](https://www.npmjs.com/package/@hncode/hncode)
|
|
87
|
+
**GitHub**: [NiceHello666/hncode](https://github.com/NiceHello666/hncode)
|
package/README_CN.md
CHANGED
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
# hncode — AI 编程助手 TUI
|
|
2
|
-
|
|
3
|
-
[English](https://github.com/NiceHello666/hncode/blob/main/README.md) | 简体中文 | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)
|
|
4
|
-
|
|
5
|
-
> **这是我 Vibe Coding 的产物,可能不是特别好,但是如果有什么建议的话也欢迎提 issue**
|
|
6
|
-
|
|
7
|
-
一个基于终端用户界面(TUI)的 AI 编程助手,灵感源自 [Kimi Code](https://github.com/MoonshotAI/kimi-code)。它提供青色蓝调 (Cyan-blue) 的 TUI 界面,支持 OpenAI 和 Anthropic 双协议,配备 MCP 风格的工具集和智能上下文管理。
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## 🌟 核心特性
|
|
12
|
-
|
|
13
|
-
- **原生 TUI 渲染**: 使用原始终端序列渲染,完美兼容 Windows Terminal,解决 blessed 等库在 Windows 上的兼容性问题
|
|
14
|
-
- **双协议支持**: 同时支持 OpenAI-compatible API 和 Anthropic API
|
|
15
|
-
- **智能上下文管理**: 85% 使用率时自动压缩,AI 摘要保留历史对话
|
|
16
|
-
- **MCP 风格工具集**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, FetchURL 等
|
|
17
|
-
- **插件系统**: 可扩展的工具、命令和生命周期钩子
|
|
18
|
-
- **会话持久化**: 跨会话恢复对话
|
|
19
|
-
- **权限模式**: Ask, YOLO, Auto 三种工作流模式
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## 🚀 安装
|
|
24
|
-
|
|
25
|
-
### 全局安装(推荐)
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
npm install -g @hncode/hncode
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
然后运行:
|
|
32
|
-
```bash
|
|
33
|
-
hncode
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
### 使用 npx(无需安装)
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
npx @hncode/hncode
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
## 📖 文档
|
|
45
|
-
|
|
46
|
-
本文件只是简要介绍。**完整文档**(所有命令、配置项、架构说明、插件与开发指南)请见 Wiki:
|
|
47
|
-
|
|
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)**
|
|
50
|
-
|
|
51
|
-
---
|
|
52
|
-
|
|
53
|
-
## 🤝 贡献
|
|
54
|
-
|
|
55
|
-
这个项目是通过 Vibe Coding 创建的。虽然它可能不够完美,但我欢迎您的反馈!
|
|
56
|
-
|
|
57
|
-
**欢迎:**
|
|
58
|
-
- 提交问题报告 bug 或提出建议
|
|
59
|
-
- Fork 并创建拉取请求
|
|
60
|
-
- 分享您使用 hncode 的体验
|
|
61
|
-
|
|
62
|
-
---
|
|
63
|
-
|
|
64
|
-
## 🙏 致谢
|
|
65
|
-
|
|
66
|
-
hncode 从以下几个优秀项目中汲取了灵感:
|
|
67
|
-
|
|
68
|
-
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
69
|
-
- **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
|
|
70
|
-
|
|
71
|
-
特别感谢这些项目背后的社区,它们推动了 AI 辅助编程的边界。
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## 📄 许可证
|
|
76
|
-
|
|
77
|
-
本项目的许可受 [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
|
|
78
|
-
|
|
79
|
-
**简单来说:**
|
|
80
|
-
1. ✅ 你可以自由使用、修改和分享此代码
|
|
81
|
-
2. ✅ 必须保留原作者署名(NiceHello666)并链接回此仓库
|
|
82
|
-
3. ❌ 不得用于商业目的(例如出售、作为付费产品的一部分)
|
|
83
|
-
|
|
84
|
-
---
|
|
85
|
-
|
|
86
|
-
**NPM 包**: [@hncode/hncode](https://www.npmjs.com/package/@hncode/hncode)
|
|
87
|
-
**GitHub**: [NiceHello666/hncode](https://github.com/NiceHello666/hncode)
|
|
1
|
+
# hncode — AI 编程助手 TUI
|
|
2
|
+
|
|
3
|
+
[English](https://github.com/NiceHello666/hncode/blob/main/README.md) | 简体中文 | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)
|
|
4
|
+
|
|
5
|
+
> **这是我 Vibe Coding 的产物,可能不是特别好,但是如果有什么建议的话也欢迎提 issue**
|
|
6
|
+
|
|
7
|
+
一个基于终端用户界面(TUI)的 AI 编程助手,灵感源自 [Kimi Code](https://github.com/MoonshotAI/kimi-code)。它提供青色蓝调 (Cyan-blue) 的 TUI 界面,支持 OpenAI 和 Anthropic 双协议,配备 MCP 风格的工具集和智能上下文管理。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🌟 核心特性
|
|
12
|
+
|
|
13
|
+
- **原生 TUI 渲染**: 使用原始终端序列渲染,完美兼容 Windows Terminal,解决 blessed 等库在 Windows 上的兼容性问题
|
|
14
|
+
- **双协议支持**: 同时支持 OpenAI-compatible API 和 Anthropic API
|
|
15
|
+
- **智能上下文管理**: 85% 使用率时自动压缩,AI 摘要保留历史对话
|
|
16
|
+
- **MCP 风格工具集**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, FetchURL 等
|
|
17
|
+
- **插件系统**: 可扩展的工具、命令和生命周期钩子
|
|
18
|
+
- **会话持久化**: 跨会话恢复对话
|
|
19
|
+
- **权限模式**: Ask, YOLO, Auto 三种工作流模式
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 🚀 安装
|
|
24
|
+
|
|
25
|
+
### 全局安装(推荐)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @hncode/hncode
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
然后运行:
|
|
32
|
+
```bash
|
|
33
|
+
hncode
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 使用 npx(无需安装)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx @hncode/hncode
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 📖 文档
|
|
45
|
+
|
|
46
|
+
本文件只是简要介绍。**完整文档**(所有命令、配置项、架构说明、插件与开发指南)请见 Wiki:
|
|
47
|
+
|
|
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)**
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 🤝 贡献
|
|
54
|
+
|
|
55
|
+
这个项目是通过 Vibe Coding 创建的。虽然它可能不够完美,但我欢迎您的反馈!
|
|
56
|
+
|
|
57
|
+
**欢迎:**
|
|
58
|
+
- 提交问题报告 bug 或提出建议
|
|
59
|
+
- Fork 并创建拉取请求
|
|
60
|
+
- 分享您使用 hncode 的体验
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 🙏 致谢
|
|
65
|
+
|
|
66
|
+
hncode 从以下几个优秀项目中汲取了灵感:
|
|
67
|
+
|
|
68
|
+
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
69
|
+
- **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
|
|
70
|
+
|
|
71
|
+
特别感谢这些项目背后的社区,它们推动了 AI 辅助编程的边界。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 📄 许可证
|
|
76
|
+
|
|
77
|
+
本项目的许可受 [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
|
|
78
|
+
|
|
79
|
+
**简单来说:**
|
|
80
|
+
1. ✅ 你可以自由使用、修改和分享此代码
|
|
81
|
+
2. ✅ 必须保留原作者署名(NiceHello666)并链接回此仓库
|
|
82
|
+
3. ❌ 不得用于商业目的(例如出售、作为付费产品的一部分)
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
**NPM 包**: [@hncode/hncode](https://www.npmjs.com/package/@hncode/hncode)
|
|
87
|
+
**GitHub**: [NiceHello666/hncode](https://github.com/NiceHello666/hncode)
|
package/package.json
CHANGED
package/src/tools/edit.js
CHANGED
|
@@ -129,7 +129,7 @@ function checkStale(p, normContent, normOld, ctx) {
|
|
|
129
129
|
// (no region covers it). Tell the AI to read the target lines first.
|
|
130
130
|
if (curFileHash === entry.fileHash) {
|
|
131
131
|
const ranges = entry.regions.map((r) => `${r.start}-${r.end}`).join(', ') || 'none';
|
|
132
|
-
return `Edit rejected: you have not read the lines you are editing in ${p}
|
|
132
|
+
return `Edit rejected: you have not read the lines you are editing in ${p}. Read the target lines first (Read with line_offset / n_lines), then Edit.`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
// (3) The file changed on disk since it was Read and the edit target is not in
|
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
|
@@ -795,8 +795,8 @@ function keyArgument(name, args, workspace) {
|
|
|
795
795
|
}
|
|
796
796
|
if (text.length > MAX_ARG) {
|
|
797
797
|
text = (k === 'path' || k === 'file_path')
|
|
798
|
-
|
|
799
|
-
|
|
798
|
+
? '…' + text.slice(text.length - (MAX_ARG - 1))
|
|
799
|
+
: text.slice(0, MAX_ARG - 1) + '…';
|
|
800
800
|
}
|
|
801
801
|
// Display paths with forward slashes (consistent across platforms).
|
|
802
802
|
if (k === 'path' || k === 'file_path') text = text.replace(/\\/g, '/');
|
|
@@ -807,10 +807,74 @@ function keyArgument(name, args, workspace) {
|
|
|
807
807
|
// "● Using Read (src/tui.js)" while running, "● Used Read (src/tui.js)" when done.
|
|
808
808
|
// The verb is plain (white) / green once finished; the tool NAME is always the
|
|
809
809
|
// theme (cyan) colour; the key argument is dim/gray.
|
|
810
|
-
export function formatToolLine(msg, workspace) {
|
|
810
|
+
export function formatToolLine(msg, workspace, spin) {
|
|
811
811
|
const name = msg.toolName || '';
|
|
812
812
|
const arg = keyArgument(name, msg.toolArgs, workspace);
|
|
813
813
|
const verb = msg.pending ? 'Using' : 'Used';
|
|
814
|
+
// Pending tool calls have a sweep animation (like "Working...")
|
|
815
|
+
// Once the tool finishes (pending=false) the name shows in plain cyan.
|
|
816
|
+
let nameDisplay;
|
|
817
|
+
if (msg.pending) {
|
|
818
|
+
// Multi-stage sweep animation for tool names (like the "Working..." line):
|
|
819
|
+
// 0.5s l→c sweep (pale cyan → cyan), 0.5s c→l fade back
|
|
820
|
+
// 0.5s c→b sweep (cyan → cyan-blue), 0.5s b→c fade back
|
|
821
|
+
// → loops straight back to l→c. ~2s per full cycle.
|
|
822
|
+
const SWEEP = 6; // 0.5s
|
|
823
|
+
const HALF = SWEEP + SWEEP; // 12 ticks per colour pair (sweep + sweep back)
|
|
824
|
+
const CYCLE = HALF * 2; // 24 ticks for the full loop (~2s)
|
|
825
|
+
const inCycle = spin % CYCLE;
|
|
826
|
+
const inHalf = inCycle % HALF; // 0..23 within this colour pair's half
|
|
827
|
+
const phase = inCycle < HALF ? 0 : 1; // 0 = l↔c, 1 = c↔b
|
|
828
|
+
|
|
829
|
+
// Colours as plain RGB triples (mirroring Working's orange→yellow/red pattern):
|
|
830
|
+
// Base cyan (c), pale cyan (l = c + 25% toward white), cyan-blue (b = c with reduced green)
|
|
831
|
+
const c = [0, 215, 255]; // pure cyan (like ORANGE base)
|
|
832
|
+
const l = [64, 239, 255]; // pale cyan (like YELLOW: c + white)
|
|
833
|
+
const b = [0, 180, 255]; // cyan-blue (like RED: c with less green)
|
|
834
|
+
const base = c; // always cyan, like Working's constant ORANGE
|
|
835
|
+
const target = phase === 0 ? l : b; // pale cyan, then cyan-blue
|
|
836
|
+
|
|
837
|
+
const chars = name;
|
|
838
|
+
const n = chars.length;
|
|
839
|
+
|
|
840
|
+
// A character fades over a band this many characters wide.
|
|
841
|
+
const BAND_CHARS = Math.max(2, Math.min(4, n / 3));
|
|
842
|
+
const BAND = BAND_CHARS / n;
|
|
843
|
+
|
|
844
|
+
// Interpolate base ↔ target by `t` (0 = base, 1 = target).
|
|
845
|
+
const mix = (t) => {
|
|
846
|
+
const k = Math.max(0, Math.min(1, t));
|
|
847
|
+
return lerpColor(
|
|
848
|
+
base[0] + (target[0] - base[0]) * k,
|
|
849
|
+
base[1] + (target[1] - base[1]) * k,
|
|
850
|
+
base[2] + (target[2] - base[2]) * k,
|
|
851
|
+
base[0] + (target[0] - base[0]) * k,
|
|
852
|
+
base[1] + (target[1] - base[1]) * k,
|
|
853
|
+
base[2] + (target[2] - base[2]) * k,
|
|
854
|
+
0,
|
|
855
|
+
);
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const out = [];
|
|
859
|
+
for (let i = 0; i < n; i++) {
|
|
860
|
+
const charAt = n > 0 ? i / n : 0;
|
|
861
|
+
let t; // 0 = base, 1 = target
|
|
862
|
+
if (inHalf < SWEEP) {
|
|
863
|
+
// Sweep in: base → target, left to right.
|
|
864
|
+
const head = (inHalf + 1) / SWEEP; // 1/SWEEP … 1
|
|
865
|
+
t = (head - charAt) / BAND;
|
|
866
|
+
} else {
|
|
867
|
+
// Sweep back: target → base, left to right.
|
|
868
|
+
const head = (inHalf - SWEEP + 1) / SWEEP; // 1/SWEEP … 1
|
|
869
|
+
t = 1 - (head - charAt) / BAND;
|
|
870
|
+
}
|
|
871
|
+
out.push(col(chars[i], mix(t)));
|
|
872
|
+
}
|
|
873
|
+
nameDisplay = out.join('');
|
|
874
|
+
} else {
|
|
875
|
+
// Non-pending: show in plain cyan
|
|
876
|
+
nameDisplay = col(name, C.cyan);
|
|
877
|
+
}
|
|
814
878
|
// Show streamed size for Write tool
|
|
815
879
|
let sizeStr = '';
|
|
816
880
|
if (name === 'Write' && typeof msg.streamContent === 'string' && msg.streamContent.length) {
|
|
@@ -832,7 +896,7 @@ export function formatToolLine(msg, workspace) {
|
|
|
832
896
|
// so the verb itself stays plain white and the tool name keeps the theme
|
|
833
897
|
// colour.
|
|
834
898
|
const argText = arg ? ' ' + col(`(${arg})`, C.gray) : '';
|
|
835
|
-
return col(verb, C.white) + ' ' +
|
|
899
|
+
return col(verb, C.white) + ' ' + nameDisplay + argText + col(sizeStr, C.gray) + diffStr;
|
|
836
900
|
}
|
|
837
901
|
|
|
838
902
|
// ---- live TODO panel (kimi-code's todo-panel) ----
|
|
@@ -915,24 +979,63 @@ function renderTodoPanel(state, w, hoverTop, dragTop) {
|
|
|
915
979
|
// One rule line, one line per queued message, one dim hint line. Queued input is
|
|
916
980
|
// held while the agent is streaming and is injected by Ctrl-S (or by the agent
|
|
917
981
|
// itself at the next tool boundary).
|
|
918
|
-
|
|
982
|
+
// Supports auto-collapse and drag-to-resize like the todo panel.
|
|
983
|
+
const QUEUE_MAX_VISIBLE = 5;
|
|
984
|
+
function renderQueuePanel(state, w, hoverTop, dragTop) {
|
|
919
985
|
const queued = state.queued || [];
|
|
920
986
|
if (!queued.length) return [];
|
|
921
|
-
|
|
922
|
-
|
|
987
|
+
|
|
988
|
+
const out = [];
|
|
989
|
+
const ruleColor = dragTop ? C.scrollThumbActive : (hoverTop ? C.hover : C.border);
|
|
990
|
+
out.push(col('─'.repeat(w), ruleColor));
|
|
991
|
+
out.push(col(' Queue', C.cyan + C.bold));
|
|
992
|
+
|
|
993
|
+
const want = queueRowCount(state);
|
|
994
|
+
const rows = queued.slice(0, want);
|
|
995
|
+
for (const text of rows) {
|
|
923
996
|
// Collapse to a single line, like kimi's queue pane does.
|
|
924
997
|
const single = String(text).replace(/\s+/g, ' ').trim();
|
|
925
998
|
out.push(col(' ', C.cyan) + col('❯ ', C.cyan) + col(fitAnsi(single, Math.max(1, w - 6)), C.white));
|
|
926
999
|
}
|
|
927
|
-
|
|
928
|
-
|
|
1000
|
+
|
|
1001
|
+
const hidden = queued.length - rows.length;
|
|
1002
|
+
if (hidden > 0) {
|
|
1003
|
+
const hint = state.running
|
|
1004
|
+
? `… +${hidden} more · ↑ to edit · ctrl-s to steer immediately · drag top rule to resize`
|
|
1005
|
+
: `… +${hidden} more · ↑ to edit · will send now · drag top rule to resize`;
|
|
1006
|
+
out.push(col(' ' + hint, C.gray));
|
|
1007
|
+
} else {
|
|
1008
|
+
const hint = state.running ? '↑ to edit · ctrl-s to steer immediately' : '↑ to edit · will send now';
|
|
1009
|
+
out.push(col(' ' + hint, C.gray));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
929
1012
|
return out;
|
|
930
1013
|
}
|
|
931
1014
|
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1015
|
+
// How many queue ROWS the panel shows.
|
|
1016
|
+
// * `state.queueRows` (set by dragging the top rule) wins when present;
|
|
1017
|
+
// * otherwise show all items up to QUEUE_MAX_VISIBLE.
|
|
1018
|
+
// Always clamped to [1, queued.length]: at least ONE row, never more than exist.
|
|
1019
|
+
export function queueRowCount(state) {
|
|
1020
|
+
const queued = state.queued || [];
|
|
1021
|
+
if (!queued.length) return 0;
|
|
1022
|
+
const manual = state.queueRows;
|
|
1023
|
+
let rows;
|
|
1024
|
+
if (typeof manual === 'number' && Number.isFinite(manual)) {
|
|
1025
|
+
rows = Math.round(manual);
|
|
1026
|
+
} else {
|
|
1027
|
+
rows = Math.min(queued.length, QUEUE_MAX_VISIBLE);
|
|
1028
|
+
}
|
|
1029
|
+
return Math.max(1, Math.min(queued.length, rows));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Height (in rows) the queue panel will occupy, for layout math.
|
|
1033
|
+
export function queuePanelHeight(state) {
|
|
1034
|
+
const queued = state.queued || [];
|
|
1035
|
+
if (!queued.length) return 0;
|
|
1036
|
+
const rows = queueRowCount(state);
|
|
1037
|
+
const extra = queued.length > rows ? 1 : 0; // the "… +N more" hint
|
|
1038
|
+
return 2 + rows + extra; // rule + heading + rows (+ hint)
|
|
936
1039
|
}
|
|
937
1040
|
|
|
938
1041
|
// How many todo ROWS the panel shows.
|
|
@@ -963,11 +1066,11 @@ export function todoPanelHeight(state) {
|
|
|
963
1066
|
|
|
964
1067
|
// Render one chat message into display lines.
|
|
965
1068
|
// Returns { lines: [{text, ind, color}] } (ind = indent string for continuation).
|
|
966
|
-
function messageLines(msg, width, workspace, expanded) {
|
|
1069
|
+
function messageLines(msg, width, workspace, expanded, spin) {
|
|
967
1070
|
// Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
|
|
968
1071
|
// The body already carries its own ANSI colours, so mark it pre-colored.
|
|
969
1072
|
if (msg.role === 'tool') {
|
|
970
|
-
const body = formatToolLine(msg, workspace);
|
|
1073
|
+
const body = formatToolLine(msg, workspace, spin);
|
|
971
1074
|
// Red bullet for a failed call (a Bash non-zero exit / [error: …]).
|
|
972
1075
|
const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
|
|
973
1076
|
const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
|
|
@@ -1452,11 +1555,11 @@ function renderChatLines(state, w) {
|
|
|
1452
1555
|
// The row WIDTH is keyed (not `w`) because the rows are laid out to it:
|
|
1453
1556
|
// `boxText` for a bordered message (inside the box padding), `innerW` otherwise.
|
|
1454
1557
|
const rowW = bordered ? boxText : innerW;
|
|
1455
|
-
const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${msg.pending ?
|
|
1456
|
-
|
|
1558
|
+
const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${msg.pending ? (state.spin || 0) : 0}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
|
|
1559
|
+
+ `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`;
|
|
1457
1560
|
let cached = msg._cache;
|
|
1458
1561
|
if (!cached || cached.key !== key) {
|
|
1459
|
-
const rows = messageLines(msg, rowW, state.cwd, expanded);
|
|
1562
|
+
const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin);
|
|
1460
1563
|
cached = { key, rows: rows.map(rowToLine) };
|
|
1461
1564
|
msg._cache = cached;
|
|
1462
1565
|
}
|
|
@@ -1533,11 +1636,11 @@ function fmtDuration(ms) {
|
|
|
1533
1636
|
const mon = Math.floor(ms / 2592000000) % 12;
|
|
1534
1637
|
const year = Math.floor(ms / 31536000000);
|
|
1535
1638
|
const parts = [];
|
|
1536
|
-
if (year) parts.push(year + '
|
|
1537
|
-
if (mon) parts.push(mon + '
|
|
1538
|
-
if (day) parts.push(day + '
|
|
1539
|
-
if (hour) parts.push(hour + '
|
|
1540
|
-
if (min) parts.push(min + '
|
|
1639
|
+
if (year) parts.push(year + 'y' + (year > 1 ? 's' : ''));
|
|
1640
|
+
if (mon) parts.push(mon + 'm' + (mon > 1 ? 's' : '') + ' ');
|
|
1641
|
+
if (day) parts.push(day + 'd' + (day > 1 ? 's' : '') + ' ');
|
|
1642
|
+
if (hour) parts.push(hour + 'h' + (hour > 1 ? 's' : '') + ' ');
|
|
1643
|
+
if (min) parts.push(min + 'm' + (min > 1 ? 's' : '') + ' ');
|
|
1541
1644
|
// Seconds are always shown (at least "0s").
|
|
1542
1645
|
parts.push(sec + 's');
|
|
1543
1646
|
return parts.join('');
|
|
@@ -2023,11 +2126,66 @@ export function composeFrame(state, cols, rows) {
|
|
|
2023
2126
|
const workMsg = state.workMsg || WORKING_MESSAGES[0];
|
|
2024
2127
|
const spin = state.spin || 0;
|
|
2025
2128
|
|
|
2026
|
-
// ---- turn-
|
|
2129
|
+
// ---- turn-start animation ----
|
|
2130
|
+
// 0.5s: type out "Working..." in grey (chars appear left→right)
|
|
2131
|
+
// 0.5s: fill with orange (chars change colour left→right)
|
|
2132
|
+
// After 1s total: start the normal pulse animation (yellow/red cycle)
|
|
2133
|
+
if (state.startAnim) {
|
|
2134
|
+
// 1s total: 0.5s types `Working... [0s]` out in grey, then 0.5s sweeps the
|
|
2135
|
+
// PHRASE grey → orange. The `[0s]` tail types out WITH the phrase but is
|
|
2136
|
+
// EXCLUDED from the colour sweep — it stays grey the whole time.
|
|
2137
|
+
const DUR = 1000;
|
|
2138
|
+
const t = Math.min(1, (Date.now() - state.startAnim.start) / DUR);
|
|
2139
|
+
const phase = t < 0.5 ? 'type' : 'color';
|
|
2140
|
+
const phaseT = t < 0.5 ? t * 2 : (t - 0.5) * 2; // 0→1 within each phase
|
|
2141
|
+
|
|
2142
|
+
const phrase = workMsg;
|
|
2143
|
+
const tailText = state.turnStart ? ` [${fmtDuration(Date.now() - state.turnStart)}]` : '';
|
|
2144
|
+
const totalChars = phrase.length + tailText.length;
|
|
2145
|
+
const out = [];
|
|
2146
|
+
|
|
2147
|
+
if (phase === 'type') {
|
|
2148
|
+
// Type the WHOLE row (phrase + tail) left → right, all grey.
|
|
2149
|
+
const typedCount = Math.floor(phaseT * totalChars);
|
|
2150
|
+
for (let i = 0; i < phrase.length; i++) {
|
|
2151
|
+
out.push(i < typedCount ? col(phrase[i], C.gray) : ' ');
|
|
2152
|
+
}
|
|
2153
|
+
const tailTyped = Math.max(0, typedCount - phrase.length);
|
|
2154
|
+
for (let i = 0; i < tailText.length; i++) {
|
|
2155
|
+
out.push(i < tailTyped ? col(tailText[i], C.gray) : ' ');
|
|
2156
|
+
}
|
|
2157
|
+
// The tail is already inside `out`, so do NOT append `elapsed` here.
|
|
2158
|
+
lines.push(col(frame, C.orange) + ' ' + out.join(''));
|
|
2159
|
+
} else {
|
|
2160
|
+
// Colour sweep: only the PHRASE goes grey → orange. The tail stays grey
|
|
2161
|
+
// (it is appended as the colored `elapsed` chunk after `out`).
|
|
2162
|
+
const BAND_CHARS = Math.max(2, Math.min(4, phrase.length / 3));
|
|
2163
|
+
const BAND = BAND_CHARS / phrase.length;
|
|
2164
|
+
// head sweeps from just left of the phrase (0) to just right of it (1),
|
|
2165
|
+
// extended by BAND on both ends so every char crosses the band smoothly.
|
|
2166
|
+
const head = -BAND + phaseT * (1 + 2 * BAND);
|
|
2167
|
+
const GREY = [150, 150, 150];
|
|
2168
|
+
const ORANGE = [255, 140, 0];
|
|
2169
|
+
for (let i = 0; i < phrase.length; i++) {
|
|
2170
|
+
const charAt = i / phrase.length;
|
|
2171
|
+
let tChar = (head - charAt) / BAND;
|
|
2172
|
+
tChar = Math.max(0, Math.min(1, tChar));
|
|
2173
|
+
const r = Math.round(GREY[0] + (ORANGE[0] - GREY[0]) * tChar);
|
|
2174
|
+
const g = Math.round(GREY[1] + (ORANGE[1] - GREY[1]) * tChar);
|
|
2175
|
+
const b = Math.round(GREY[2] + (ORANGE[2] - GREY[2]) * tChar);
|
|
2176
|
+
out.push(col(phrase[i], lerpColor(r, g, b, r, g, b, 0)));
|
|
2177
|
+
}
|
|
2178
|
+
lines.push(col(frame, C.orange) + ' ' + out.join('') + elapsed);
|
|
2179
|
+
}
|
|
2180
|
+
if ((Date.now() - state.startAnim.start) >= 1000) {
|
|
2181
|
+
state.startAnim = null;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
// ---- turn-finish animation ----
|
|
2027
2185
|
// 0.5s morph: `[turn took 1s]` grows in from the LEFT, covering the working
|
|
2028
2186
|
// phrase one character at a time; once the phrase is fully covered, the
|
|
2029
2187
|
// rest of the final text is simply appended. The whole row fades to grey.
|
|
2030
|
-
if (state.finishAnim) {
|
|
2188
|
+
else if (state.finishAnim) {
|
|
2031
2189
|
const DUR = 500;
|
|
2032
2190
|
const t = Math.min(1, (Date.now() - state.finishAnim.start) / DUR);
|
|
2033
2191
|
const wordFrom = state.finishAnim.wordFrom || '';
|
|
@@ -2055,7 +2213,7 @@ export function composeFrame(state, cols, rows) {
|
|
|
2055
2213
|
const g = Math.round(ORANGE[1] + (GREY[1] - ORANGE[1]) * t);
|
|
2056
2214
|
const b = Math.round(ORANGE[2] + (GREY[2] - ORANGE[2]) * t);
|
|
2057
2215
|
const animColor = lerpColor(r, g, b, r, g, b, 0);
|
|
2058
|
-
lines.push(col(frame, animColor) + ' ' + col(
|
|
2216
|
+
lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail, C.gray));
|
|
2059
2217
|
} else {
|
|
2060
2218
|
|
|
2061
2219
|
// Timing (spinner ticks every 80ms, so 0.5s ≈ 6 ticks):
|
|
@@ -2122,7 +2280,7 @@ export function composeFrame(state, cols, rows) {
|
|
|
2122
2280
|
}
|
|
2123
2281
|
}
|
|
2124
2282
|
|
|
2125
|
-
if (state.todos && state.todos.length) {
|
|
2283
|
+
if (!dialog && state.todos && state.todos.length) {
|
|
2126
2284
|
// The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
|
|
2127
2285
|
// the mouse handler can hover/press it; the row offset is corrected to final
|
|
2128
2286
|
// screen coordinates by composeFrame's hitbox pass (like every other hit).
|
|
@@ -2133,7 +2291,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
2133
2291
|
|
|
2134
2292
|
// Queued (not yet sent) messages, directly above the composer: these are what
|
|
2135
2293
|
// Ctrl-S steers into the running turn. See renderQueuePanel.
|
|
2136
|
-
|
|
2294
|
+
if (!dialog && state.queued && state.queued.length) {
|
|
2295
|
+
// The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
|
|
2296
|
+
// the mouse handler can hover/press it.
|
|
2297
|
+
const queueTopRow = lines.length;
|
|
2298
|
+
addHit(queueTopRow, 0, w - 1, { kind: 'queueResize' });
|
|
2299
|
+
for (const l of renderQueuePanel(state, w, state.queueResizeHover, state.queueResizeDrag)) lines.push(l);
|
|
2300
|
+
}
|
|
2137
2301
|
|
|
2138
2302
|
// Approval pending overlay - same width as the composer, placed above it.
|
|
2139
2303
|
// The border characters are coloured INDIVIDUALLY and each content row is
|
|
@@ -2441,6 +2605,16 @@ export function makeState({ cfg, session, opts }) {
|
|
|
2441
2605
|
todoResizeHover: false,
|
|
2442
2606
|
todoResizeDrag: false,
|
|
2443
2607
|
todoResizeStart: null,
|
|
2608
|
+
// Queue panel resize: the top rule is a drag handle. `queueRows` is the
|
|
2609
|
+
// manual row count (undefined = automatic), and the two flags drive its
|
|
2610
|
+
// hover/press styling.
|
|
2611
|
+
queueRows: undefined,
|
|
2612
|
+
queueResizeHover: false,
|
|
2613
|
+
queueResizeDrag: false,
|
|
2614
|
+
queueResizeStart: null,
|
|
2615
|
+
// Turn-start animation: while non-null, the Working row renders the typing
|
|
2616
|
+
// and color-fill animation for 1s before starting the normal pulse cycle.
|
|
2617
|
+
startAnim: null, // { start }
|
|
2444
2618
|
hoverHit: null,
|
|
2445
2619
|
input: '',
|
|
2446
2620
|
caret: 0,
|
|
@@ -2778,107 +2952,124 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
2778
2952
|
},
|
|
2779
2953
|
onPick: (it) => {
|
|
2780
2954
|
if (it.action === 'add') {
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
providerId: p.id,
|
|
2793
|
-
base_url: p.api,
|
|
2794
|
-
action: 'pickKnown',
|
|
2795
|
-
}));
|
|
2796
|
-
items.push({ label: '+ Custom provider…', sub: 'enter base URL, key and protocol yourself', action: 'custom' });
|
|
2797
|
-
openPicker({
|
|
2798
|
-
title: 'Add a provider',
|
|
2799
|
-
items,
|
|
2800
|
-
searchable: true,
|
|
2801
|
-
hint: '↑↓ navigate · type to search · Enter select · Esc cancel',
|
|
2802
|
-
onPick: (pit) => {
|
|
2803
|
-
if (pit.action === 'custom') {
|
|
2804
|
-
openForm({
|
|
2805
|
-
title: 'Add A Custom Provider',
|
|
2806
|
-
fields: [
|
|
2807
|
-
{ key: 'name', label: 'Provider Name' },
|
|
2808
|
-
{ key: 'base_url', label: 'Base URL' },
|
|
2809
|
-
{ key: 'api_key', label: 'API Key', kind: 'mask' },
|
|
2810
|
-
],
|
|
2811
|
-
type: null,
|
|
2812
|
-
hint: 'Tab next field · ↑/↓ field · ←/→ type · Enter save · Esc cancel',
|
|
2813
|
-
onSubmit: (values, type) => {
|
|
2814
|
-
if (!values.name) { appErr('Cancelled: provider name is required.'); return; }
|
|
2815
|
-
if (!type) { appErr('Cancelled: choose a Type (OpenAI / Anthropic).'); return; }
|
|
2816
|
-
const protocol = type === 'Anthropic' ? 'anthropic' : 'openai';
|
|
2817
|
-
registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, type);
|
|
2818
|
-
},
|
|
2819
|
-
});
|
|
2820
|
-
return true;
|
|
2821
|
-
}
|
|
2955
|
+
// Show a submenu to choose between known providers and custom
|
|
2956
|
+
openPicker({
|
|
2957
|
+
title: 'Add a provider',
|
|
2958
|
+
items: [
|
|
2959
|
+
{ label: 'Known provider', sub: 'browse models.dev catalog', action: 'known' },
|
|
2960
|
+
{ label: 'Custom provider', sub: 'enter base URL, key and protocol yourself', action: 'custom' },
|
|
2961
|
+
],
|
|
2962
|
+
searchable: false,
|
|
2963
|
+
hint: '↑↓ navigate · Enter select · Esc cancel',
|
|
2964
|
+
onPick: (subIt) => {
|
|
2965
|
+
if (subIt.action === 'custom') {
|
|
2822
2966
|
openForm({
|
|
2823
|
-
title:
|
|
2967
|
+
title: 'Add A Custom Provider',
|
|
2824
2968
|
fields: [
|
|
2825
|
-
{ key: 'name', label: 'Provider Name'
|
|
2826
|
-
{ key: 'base_url', label: 'Base URL'
|
|
2827
|
-
{ key: 'api_key', label: 'API Key', kind: 'mask'
|
|
2828
|
-
{ key: 'type', label: 'Type', options: ['OpenAI', 'Anthropic'], value: pit.type || (pit.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') },
|
|
2969
|
+
{ key: 'name', label: 'Provider Name' },
|
|
2970
|
+
{ key: 'base_url', label: 'Base URL' },
|
|
2971
|
+
{ key: 'api_key', label: 'API Key', kind: 'mask' },
|
|
2829
2972
|
],
|
|
2830
2973
|
type: null,
|
|
2831
|
-
hint: 'Tab next field · Enter save · Esc cancel',
|
|
2832
|
-
onSubmit:
|
|
2974
|
+
hint: 'Tab next field · ↑/↓ field · ←/→ type · Enter save · Esc cancel',
|
|
2975
|
+
onSubmit: (values, type) => {
|
|
2833
2976
|
if (!values.name) { appErr('Cancelled: provider name is required.'); return; }
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2977
|
+
if (!type) { appErr('Cancelled: choose a Type (OpenAI / Anthropic).'); return; }
|
|
2978
|
+
const protocol = type === 'Anthropic' ? 'anthropic' : 'openai';
|
|
2979
|
+
registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, type);
|
|
2980
|
+
},
|
|
2981
|
+
});
|
|
2982
|
+
return true;
|
|
2983
|
+
}
|
|
2984
|
+
// Known provider: load catalog and show list
|
|
2985
|
+
app('Loading provider list…');
|
|
2986
|
+
fetchCatalog().then((catalog) => {
|
|
2987
|
+
const entries = catalog
|
|
2988
|
+
? Object.values(catalog)
|
|
2989
|
+
.filter((p) => p && p.id && p.api)
|
|
2990
|
+
.map((p) => ({ id: p.id, name: p.name || p.id, api: p.api, doc: p.doc || '' }))
|
|
2991
|
+
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
2992
|
+
: [];
|
|
2993
|
+
const items = entries.map((p) => ({
|
|
2994
|
+
label: p.name,
|
|
2995
|
+
sub: p.api.replace(/^https?:\/\//, ''),
|
|
2996
|
+
providerId: p.id,
|
|
2997
|
+
base_url: p.api,
|
|
2998
|
+
action: 'pickKnown',
|
|
2999
|
+
}));
|
|
3000
|
+
if (!items.length) {
|
|
3001
|
+
appErr('No known providers found in catalog.');
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
openPicker({
|
|
3005
|
+
title: 'Select a known provider',
|
|
3006
|
+
items,
|
|
3007
|
+
searchable: true,
|
|
3008
|
+
hint: '↑↓ navigate · type to search · Enter select · Esc cancel',
|
|
3009
|
+
onPick: (pit) => {
|
|
3010
|
+
openForm({
|
|
3011
|
+
title: `Edit ${pit.label}`,
|
|
3012
|
+
fields: [
|
|
3013
|
+
{ key: 'name', label: 'Provider Name', value: pit.name },
|
|
3014
|
+
{ key: 'base_url', label: 'Base URL', value: pit.base_url },
|
|
3015
|
+
{ key: 'api_key', label: 'API Key', kind: 'mask', value: pit.api_key || '' },
|
|
3016
|
+
{ key: 'type', label: 'Type', options: ['OpenAI', 'Anthropic'], value: pit.type || (pit.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') },
|
|
3017
|
+
],
|
|
3018
|
+
type: null,
|
|
3019
|
+
hint: 'Tab next field · Enter save · Esc cancel',
|
|
3020
|
+
onSubmit: async (values) => {
|
|
3021
|
+
if (!values.name) { appErr('Cancelled: provider name is required.'); return; }
|
|
3022
|
+
const protocol = values.type === 'Anthropic' ? 'anthropic' : 'openai';
|
|
3023
|
+
registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, values.type);
|
|
3024
|
+
|
|
3025
|
+
// Auto-fetch models and configure context/thinking settings
|
|
3026
|
+
try {
|
|
3027
|
+
const models = await fetchModels({
|
|
3028
|
+
baseUrl: values.base_url,
|
|
3029
|
+
apiKey: values.api_key,
|
|
3030
|
+
protocol
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
if (models.length > 0) {
|
|
3034
|
+
// Add each model with its configuration
|
|
3035
|
+
for (const m of models) {
|
|
3036
|
+
const key = modelKey(values.name, m.id);
|
|
3037
|
+
if (!cfg.raw.models[key]) {
|
|
3038
|
+
addModel(values.name, m.id, {
|
|
3039
|
+
display_name: m.display,
|
|
3040
|
+
contextLength: m.contextLength,
|
|
3041
|
+
maxTokens: m.maxTokens,
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
// Configure model entry in raw config
|
|
3045
|
+
if (!cfg.raw.models) cfg.raw.models = {};
|
|
3046
|
+
cfg.raw.models[key] = {
|
|
3047
|
+
provider: values.name,
|
|
3048
|
+
model: m.id,
|
|
3049
|
+
display_name: m.display,
|
|
3050
|
+
context_length: m.contextLength,
|
|
3051
|
+
max_tokens: m.maxTokens,
|
|
3052
|
+
reasoning: m.reasoning,
|
|
3053
|
+
efforts: m.efforts,
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
2855
3057
|
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
provider: values.name,
|
|
2860
|
-
model: m.id,
|
|
2861
|
-
display_name: m.display,
|
|
2862
|
-
context_length: m.contextLength,
|
|
2863
|
-
max_tokens: m.maxTokens,
|
|
2864
|
-
reasoning: m.reasoning,
|
|
2865
|
-
efforts: m.efforts,
|
|
2866
|
-
};
|
|
3058
|
+
app(`Added ${models.length} model(s) with auto-configured context windows and thinking capabilities.`);
|
|
3059
|
+
} else {
|
|
3060
|
+
app(`Provider added. No models found at this endpoint.`);
|
|
2867
3061
|
}
|
|
3062
|
+
} catch (error) {
|
|
3063
|
+
app(`Provider added. Failed to fetch models: ${error.message}`);
|
|
2868
3064
|
}
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
app(`Provider added. No models found at this endpoint.`);
|
|
2873
|
-
}
|
|
2874
|
-
} catch (error) {
|
|
2875
|
-
app(`Provider added. Failed to fetch models: ${error.message}`);
|
|
2876
|
-
}
|
|
3065
|
+
},
|
|
3066
|
+
});
|
|
3067
|
+
return true;
|
|
2877
3068
|
},
|
|
2878
3069
|
});
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
}
|
|
3070
|
+
});
|
|
3071
|
+
return true;
|
|
3072
|
+
},
|
|
2882
3073
|
});
|
|
2883
3074
|
return true;
|
|
2884
3075
|
}
|
|
@@ -4186,6 +4377,14 @@ export async function startTUI(opts) {
|
|
|
4186
4377
|
renderFrame();
|
|
4187
4378
|
return;
|
|
4188
4379
|
}
|
|
4380
|
+
// The queue panel's top rule is also a resize handle.
|
|
4381
|
+
const hitQueue = hitAt(t);
|
|
4382
|
+
if (hitQueue && hitQueue.kind === 'queueResize') {
|
|
4383
|
+
state.queueResizeDrag = true;
|
|
4384
|
+
state.queueResizeStart = { row: t.row, rows: queueRowCount(state) };
|
|
4385
|
+
renderFrame();
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4189
4388
|
// Don't start selection on single click, wait for drag.
|
|
4190
4389
|
const cell = mouseToCell(t);
|
|
4191
4390
|
// Pressing the padding above the transcript must NOT arm a selection:
|
|
@@ -4207,6 +4406,9 @@ export async function startTUI(opts) {
|
|
|
4207
4406
|
// The todo resize handle gets its own hover flag so its rule lights up.
|
|
4208
4407
|
const onTodoRule = !!(hb && hb.kind === 'todoResize');
|
|
4209
4408
|
if (onTodoRule !== state.todoResizeHover) { state.todoResizeHover = onTodoRule; renderFrame(); }
|
|
4409
|
+
// The queue resize handle also gets a hover flag.
|
|
4410
|
+
const onQueueRule = !!(hb && hb.kind === 'queueResize');
|
|
4411
|
+
if (onQueueRule !== state.queueResizeHover) { state.queueResizeHover = onQueueRule; renderFrame(); }
|
|
4210
4412
|
// The composer rows stay click targets (mousedown places the caret), but
|
|
4211
4413
|
// they are NOT highlighted on hover — tinting the prompt you are typing
|
|
4212
4414
|
// is noise, not feedback.
|
|
@@ -4229,6 +4431,16 @@ export async function startTUI(opts) {
|
|
|
4229
4431
|
if (clamped !== state.todoRows) { state.todoRows = clamped; renderFrame(); }
|
|
4230
4432
|
return;
|
|
4231
4433
|
}
|
|
4434
|
+
// Dragging the queue rule resizes the panel: moving UP shows more rows.
|
|
4435
|
+
if (state.queueResizeDrag) {
|
|
4436
|
+
const start = state.queueResizeStart || { row: t.row, rows: queueRowCount(state) };
|
|
4437
|
+
const delta = start.row - (t.row || 1); // up = positive = more rows
|
|
4438
|
+
const want = start.rows + delta;
|
|
4439
|
+
const total = (state.queued || []).length;
|
|
4440
|
+
const clamped = Math.max(1, Math.min(total || 1, want));
|
|
4441
|
+
if (clamped !== state.queueRows) { state.queueRows = clamped; renderFrame(); }
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4232
4444
|
// Composer text selection: drag from a composer mousedown.
|
|
4233
4445
|
if (state._composerMouseDown) {
|
|
4234
4446
|
const hb = hitAt(t);
|
|
@@ -4271,6 +4483,14 @@ export async function startTUI(opts) {
|
|
|
4271
4483
|
renderFrame();
|
|
4272
4484
|
return;
|
|
4273
4485
|
}
|
|
4486
|
+
if (state.queueResizeDrag) {
|
|
4487
|
+
// Finish the resize. Keep the hover flag in sync with where the pointer is.
|
|
4488
|
+
state.queueResizeDrag = false;
|
|
4489
|
+
state.queueResizeStart = null;
|
|
4490
|
+
state.queueResizeHover = !!(hitAt(t) && hitAt(t).kind === 'queueResize');
|
|
4491
|
+
renderFrame();
|
|
4492
|
+
return;
|
|
4493
|
+
}
|
|
4274
4494
|
if (state.sbDrag) {
|
|
4275
4495
|
state.sbDrag = false;
|
|
4276
4496
|
state.sbHover = isOnScrollbar(t);
|
|
@@ -5201,7 +5421,11 @@ export async function startTUI(opts) {
|
|
|
5201
5421
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
5202
5422
|
state.historyIdx = -1;
|
|
5203
5423
|
if (!text) { renderFrame(); return; }
|
|
5204
|
-
if (!state.running)
|
|
5424
|
+
if (!state.running) {
|
|
5425
|
+
state.turnStart = Date.now();
|
|
5426
|
+
// Start animation: 0.5s type out + 0.5s fill with orange = 1s total
|
|
5427
|
+
state.startAnim = { start: Date.now() };
|
|
5428
|
+
}
|
|
5205
5429
|
|
|
5206
5430
|
if (state.history[state.history.length - 1] !== text) state.history.push(text);
|
|
5207
5431
|
// Commands are handled immediately, never queued: queuing them delayed a
|
|
@@ -5230,6 +5454,10 @@ export async function startTUI(opts) {
|
|
|
5230
5454
|
|
|
5231
5455
|
async function runAgent(text) {
|
|
5232
5456
|
addChat({ role: 'user', text });
|
|
5457
|
+
// A message the user JUST SENT should always bring the newest content into
|
|
5458
|
+
// view, even if they were scrolled up reading history. (Queue/steer do NOT
|
|
5459
|
+
// come through here, so they keep the view exactly where the user left it.)
|
|
5460
|
+
state.scroll = 0;
|
|
5233
5461
|
state.running = true;
|
|
5234
5462
|
// Pick the Working… wording ONCE for this turn, so it does not change while
|
|
5235
5463
|
// the gradient loops. The next turn picks a new one.
|
|
@@ -5511,6 +5739,8 @@ export async function startTUI(opts) {
|
|
|
5511
5739
|
await new Promise((r) => setTimeout(r, 20));
|
|
5512
5740
|
}
|
|
5513
5741
|
state.finishAnim = null;
|
|
5742
|
+
// Also clear startAnim if it's still active (should be cleared already by composeFrame)
|
|
5743
|
+
state.startAnim = null;
|
|
5514
5744
|
state.running = false;
|
|
5515
5745
|
|
|
5516
5746
|
if (turnMs > 0) {
|