@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/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} (Read so far: lines ${ranges}). Read the target lines first (Read with line_offset / n_lines), then Edit.`;
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
- let lines = raw.split('\n').filter((l) => l.length > 0);
147
+ // Split on either newline style and drop the trailing empty element a final
148
+ // CRLF leaves behind — `rg` always ends with one, and it rendered as a stray
149
+ // blank line under the last match.
150
+ let lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
148
151
  if (args.offset) lines = lines.slice(args.offset);
149
152
  if (args.head_limit) lines = lines.slice(0, args.head_limit);
150
153
  if (lines.length === 0) return 'No matches found.';
151
- return lines.join('\n');
154
+ return lines.join('\n').replace(/\r/g, '');
152
155
  }
package/src/tui.js CHANGED
@@ -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
- ? '…' + text.slice(text.length - (MAX_ARG - 1))
799
- : text.slice(0, MAX_ARG - 1) + '…';
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) + ' ' + col(name, C.cyan) + argText + col(sizeStr, C.gray) + diffStr;
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
- function renderQueuePanel(state, w) {
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
- const out = [col('─'.repeat(w), C.border)];
922
- for (const text of queued) {
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
- const hint = state.running ? '↑ to edit · ctrl-s to steer immediately' : '↑ to edit · will send now';
928
- out.push(col(' ' + hint, C.gray));
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
- // Height (in rows) the queue pane will occupy, for layout math.
933
- function queuePanelHeight(state) {
934
- const n = (state.queued || []).length;
935
- return n ? n + 2 : 0; // rule + items + hint
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 ? 1 : 0}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
1456
- + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`;
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 + 'year' + (year > 1 ? 's' : ''));
1537
- if (mon) parts.push(mon + 'mon' + (mon > 1 ? 's' : '') + ' ');
1538
- if (day) parts.push(day + 'day' + (day > 1 ? 's' : '') + ' ');
1539
- if (hour) parts.push(hour + 'hour' + (hour > 1 ? 's' : '') + ' ');
1540
- if (min) parts.push(min + 'min' + (min > 1 ? 's' : '') + ' ');
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-finish animation ----
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(`[${word}${tail}`, animColor));
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
- for (const l of renderQueuePanel(state, w)) lines.push(l);
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
- app('Loading provider list…');
2782
- fetchCatalog().then((catalog) => {
2783
- const entries = catalog
2784
- ? Object.values(catalog)
2785
- .filter((p) => p && p.id && p.api)
2786
- .map((p) => ({ id: p.id, name: p.name || p.id, api: p.api, doc: p.doc || '' }))
2787
- .sort((a, b) => String(a.name).localeCompare(String(b.name)))
2788
- : [];
2789
- const items = entries.map((p) => ({
2790
- label: p.name,
2791
- sub: p.api.replace(/^https?:\/\//, ''),
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: `Edit ${pit.label}`,
2967
+ title: 'Add A Custom Provider',
2824
2968
  fields: [
2825
- { key: 'name', label: 'Provider Name', value: pit.name },
2826
- { key: 'base_url', label: 'Base URL', value: pit.base_url },
2827
- { key: 'api_key', label: 'API Key', kind: 'mask', value: pit.api_key || '' },
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: async (values) => {
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
- const protocol = values.type === 'Anthropic' ? 'anthropic' : 'openai';
2835
- registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, values.type);
2836
-
2837
- // Auto-fetch models and configure context/thinking settings
2838
- try {
2839
- const models = await fetchModels({
2840
- baseUrl: values.base_url,
2841
- apiKey: values.api_key,
2842
- protocol
2843
- });
2844
-
2845
- if (models.length > 0) {
2846
- // Add each model with its configuration
2847
- for (const m of models) {
2848
- const key = modelKey(values.name, m.id);
2849
- if (!cfg.raw.models[key]) {
2850
- addModel(values.name, m.id, {
2851
- display_name: m.display,
2852
- contextLength: m.contextLength,
2853
- maxTokens: m.maxTokens,
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
- // Configure model entry in raw config
2857
- if (!cfg.raw.models) cfg.raw.models = {};
2858
- cfg.raw.models[key] = {
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
- app(`Added ${models.length} model(s) with auto-configured context windows and thinking capabilities.`);
2871
- } else {
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
- return true;
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) state.turnStart = Date.now();
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) {