diff --git a/TODO.md b/TODO.md
index f34b45f..5361bcf 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,145 +2,174 @@
## 当前定位
-TinyTUI 现在已经不是空项目,已经具备一个最小可运行的 C# TUI 类库骨架
+TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入输出、输入缓冲、按键解析、运行时、组件树、差分渲染、overlay、基础组件和示例都已经成型
-当前目标应该从“搭架子”切换到“补真实终端能力和组件细节”
+后续 TODO 不再按单个控件的小功能罗列,而是对齐 `tmp/tui` 的完整框架能力,优先补齐会影响整个 TUI 可用性、稳定性、可扩展性和可验证性的部分
-参考项目是 `tmp/tui`,它已经覆盖了更成熟的终端输入、差分渲染、overlay、编辑器、选择列表、Markdown、虚拟终端测试和图像能力
+## 框架级缺口
-## 已完成
+### 1. 终端会话能力
-- 已定义核心模块边界:STDIO、STDOUT、Input、Runtime、Renderer、Text、Component、Overlay、Example
-- 已实现 console 输入输出封装,包括输入事件、窗口尺寸和基础生命周期
-- 已实现默认输入解析器,支持普通文本、常见方向键、功能键、回车、退格、Esc、bracketed paste
-- 已实现组件树运行时,支持根组件、焦点组件、输入分发、重新渲染
-- 已实现全量渲染器
-- 已实现差分渲染器,支持按行更新和硬件光标定位
-- 已实现文本宽度模块,能处理 ANSI、OSC、APC、中文、emoji、截断
-- 已实现基础组件:`Text`、`Container`、`Box`、`Input`
-- 已实现第一版高级组件:`Editor`、`SelectList`、`Markdown`、`Loader`
-- 已实现输入缓冲器 `StdinBuffer`,并接入默认输入解析器
-- 已扩展组合键解析,支持 `ctrl+x`、`alt+x`、`shift+tab`、`ctrl+left`、`ctrl+right` 这类按键名称
-- 已实现可配置 Overlay 管理器,支持宽度、最小宽度、最大高度、anchor、row、column、margin、visible、non-capturing 和 overlay handle 焦点控制
-- 已补充 Editor 的历史记录浏览、Home/End、词级移动、词级删除、undo / redo、Ctrl+C 清空、提交取消事件和软换行
-- 已补充 SelectList 的 item model、过滤、描述列、循环选择、滚动提示和 selection changed
-- 已更新 Example,启动后能直接验证 Loader、Input、overlay 定位、non-capturing overlay、选择列表过滤和编辑器快捷键
+目标:把当前 console 封装升级成完整的终端会话抽象,负责启动、停止、协议协商和状态恢复
-## 当前缺口
+- 管理 bracketed paste、光标显示隐藏、清屏、窗口标题、进度指示等终端模式
+- 启动时保存原始终端状态,停止时稳定恢复,避免 raw mode、paste mode、键盘协议泄漏到父 shell
+- 支持 Kitty keyboard protocol 探测和启用,失败时 fallback 到 modifyOtherKeys
+- Windows 下处理 Virtual Terminal Input,保证 Shift+Tab、组合方向键等输入不会丢失修饰键
+- 停止前 drain stdin,避免慢 SSH 或终端延迟导致的按键释放事件泄漏
+- 提供可替换的 Terminal 接口,为真实终端和测试终端使用同一套运行时
-- 输入层还缺 Kitty keyboard protocol / modifyOtherKeys 这类高级键盘协议协商,组合键识别还不够完整
-- Overlay 合成还比较简单,需要继续处理 ANSI/OSC 样式文本、宽字符边界、终端边缘覆盖和底层样式泄漏
-- Editor 还缺自动补全、粘贴摘要和更完整的光标布局
-- SelectList 还缺主题样式、更多布局配置和更完整的键位策略
-- Markdown 目前只是轻量纯文本转换,还缺真正 token 解析、行内样式、代码块、链接、引用块、列表缩进、文本换行和缓存
-- Text Width 还需要补 grapheme cluster 级别处理,尤其是 ZWJ emoji、regional indicator、variation selector、tab、ANSI 包裹换行
-- 渲染层还缺虚拟终端测试和复杂回归场景,比如 viewport 覆盖、短内容覆盖长内容、样式泄漏、宽字符边界
-- 示例目前只有一个 Example,后续需要拆成多个真实场景示例
-- 暂时不做测试项目,当前阶段先用 Example 手动验证功能方向,等功能形态稳定后再补测试
+参考:`tmp/tui/src/terminal.ts`
-## 下一阶段优先级
+本次推进:
-### 1. 输入缓冲和按键解析
+- 新增 `ITerminalSession`,把输入、输出、尺寸、启动停止和终端控制序列收敛为一个可替换的会话抽象
+- 新增 `ConsoleTerminalSession`,统一管理 bracketed paste、光标显示隐藏、窗口标题恢复、OSC 9;4 进度指示、Windows Virtual Terminal Input、退出前输入 drain 和停止时模式恢复
+- `TuiRuntime` 改为依赖终端会话,保留旧 `ITerminalInput` 构造函数用于兼容现有调用方
+- Example 改为用同一个 terminal session 同时提供 renderer 输出和 runtime 生命周期
-状态:已完成第一阶段,后续只保留高级键盘协议
+为什么先做:
-先做这个,因为后续 Editor、SelectList、Overlay 都依赖稳定输入
+- `tmp/tui/src/terminal.ts` 的能力不是单个输出方法,而是完整生命周期对象;先把 C# 侧入口统一起来,后续 Kitty protocol、modifyOtherKeys、虚拟终端测试和渲染管线才能接在同一个抽象上
+- 当前实现把 Runtime 从“直接控制 input 生命周期”推进到“只协调组件和会话”,终端状态恢复责任更清晰
-- 新增 `StdinBuffer`,负责把 stdin chunk 拆成完整输入序列
-- 支持完整 CSI、OSC、DCS、APC、SS3、Meta key 序列判断
-- 支持 bracketed paste 跨 chunk 收集
-- 避免 ESC 被误判,给不完整 escape sequence 设置短超时或明确 fallback
-- 补充组合键名称表达,例如 `ctrl+x`、`alt+x`、`shift+tab`
-- 参考 `tmp/tui/src/stdin-buffer.ts`、`tmp/tui/src/keys.ts`、`tmp/tui/test/stdin-buffer.test.ts`、`tmp/tui/test/keys.test.ts`
+当前更好的点:
-### 2. Overlay 选项和焦点策略
+- C# 侧接口直接继承 `ITerminalOutput`,Renderer 和 Runtime 可以共享同一个 terminal session,避免真实终端和测试终端各自维护两套输出入口
+- 保留旧构造函数降低迁移成本,现有使用 `ITerminalInput` 的代码不会立刻断裂
-状态:已完成第一阶段,后续继续补合成正确性
+后续仍需补齐:
-Overlay 是菜单、提示、选择列表、编辑器弹窗的基础
+- Kitty keyboard protocol 目前只预留 `KittyProtocolActive` 和停止恢复序列,还没有实现查询、响应解析、启用和 fallback 到 modifyOtherKeys
+- Windows VT input 已用 P/Invoke 处理 console mode,但还需要结合真实终端验证 Shift+Tab、Ctrl+方向键、Alt 组合键在不同宿主中的表现
+- `DrainInput` 目前是基于 `Console.KeyAvailable` 的同步排空,后续要和 stdin buffer / keyboard protocol release 事件联动,避免吞掉应由应用处理的 late input
+- 标题恢复当前只在 Windows 通过 `Console.Title` 尝试保存;Unix 终端通常无法可靠读取原始标题,后续可以增加可选 title stack 或由调用方提供恢复标题
+- `ITerminalSession` 还缺键盘协议协商事件和测试 fake terminal,实现虚拟终端回归测试前需要继续抽象可观测输出和模拟输入
-- 新增 `OverlayOptions`
-- 支持宽度百分比、最小宽度、最大高度
-- 支持 anchor 定位:center、top-left、top-right、bottom-left、bottom-right、top-center、bottom-center、left-center、right-center
-- 支持 row / column 绝对值和百分比定位
-- 支持 margin
-- 支持 visible 条件
-- 支持 nonCapturing overlay,不抢焦点但参与渲染
-- 支持 overlay handle 的 `Hide`、`SetHidden`、`Focus`、`Unfocus`、`IsFocused`
-- 参考 `tmp/tui/src/tui.ts`、`tmp/tui/test/overlay-options.test.ts`、`tmp/tui/test/overlay-non-capturing.test.ts`
+### 2. 输入协议和 Keybinding 系统
-### 3. Editor 升级
+目标:把按键解析从“组件里判断具体 key name”升级为“协议解析 + 动作映射”的框架能力
-状态:已完成历史记录、Home/End、词级移动删除、undo / redo、Ctrl+C 清空、提交取消事件和软换行
+- 完整处理 Kitty keyboard protocol 的 press、repeat、release 和 alternate key 信息
+- 支持 key release 过滤,并允许组件声明是否接收 release 事件
+- 建立统一 KeyId 表达和 `MatchesKey` 能力,避免组件分散处理 escape sequence
+- 建立全局 keybinding registry,按动作名绑定默认快捷键,并允许外部覆盖
+- 提供 keybinding 冲突检测,避免多个动作抢同一个快捷键
+- 输入 listener 支持全局拦截和 consume,用于 debug、退出、快捷命令等框架级输入
-Editor 是后续交互体验的核心组件
+参考:`tmp/tui/src/keys.ts`、`tmp/tui/src/keybindings.ts`
-- 继续细化软换行下的光标边界处理
-- 支持粘贴大段文本时生成 paste marker 或摘要
-- 支持 autocomplete provider 和补全列表 overlay
-- 参考 `tmp/tui/src/components/editor.ts`、`tmp/tui/src/undo-stack.ts`、`tmp/tui/src/word-navigation.ts`、`tmp/tui/src/autocomplete.ts`
+### 3. 渲染管线和视口模型
-### 4. SelectList 升级
+目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线
-状态:已完成 item model、filter、描述列、滚动提示、循环选择和选择变化事件
+- 使用 synchronized output 包裹每次渲染,降低闪烁和中间态显示
+- 维护 viewport top、工作区高度、硬件光标位置和上一帧尺寸,避免内容增长或缩短时滚动错位
+- 区分首次渲染、宽度变化、视口外变化、普通差分更新、内容缩短清理等策略
+- 每行输出前统一追加 SGR reset 和 OSC 8 reset,避免 ANSI 样式或超链接泄漏到后续行
+- 在渲染层校验每行可见宽度不超过终端宽度,错误时提供可定位的调试信息
+- 增加可选写出日志和重绘调试日志,方便定位真实终端中的闪烁、残影和错位问题
-SelectList 应该从简单字符串列表升级成可用于命令菜单
+参考:`tmp/tui/src/tui.ts`
-- 继续补主题样式
-- 继续补更多布局配置
-- 继续补更完整的键位策略
-- 参考 `tmp/tui/src/components/select-list.ts`、`tmp/tui/test/select-list.test.ts`
+### 4. Overlay 合成正确性
-### 5. Markdown 升级
+目标:让 overlay 成为菜单、弹窗、自动补全、设置面板等上层交互的稳定基础
-Markdown 暂时只做展示,不要急着做完整渲染器,但需要比当前纯字符串替换更可靠
+- 合成时保留 overlay 右侧的底层内容,而不是简单截断整行
+- 处理 ANSI、OSC 8 超链接、APC、Kitty image sequence 等不可见序列
+- 使用按列切片和宽字符边界保护,避免中文、emoji、regional indicator 在 overlay 边界被切坏
+- overlay 前后插入样式 reset,避免底层样式污染 overlay 或 overlay 样式污染底层
+- 完善多层 overlay 的焦点恢复策略,处理 overlay 被临时隐藏、释放焦点、被其他组件短暂抢焦点后的恢复
+- 支持 overlay 显示区域与底部视口对齐,避免在内容超过终端高度时覆盖位置偏移
-- 支持标题层级
-- 支持无序列表和有序列表
-- 支持引用块
-- 支持代码块和行内代码
-- 支持链接文本和 URL 展示
-- 支持粗体、斜体、删除线、下划线的 ANSI 样式
-- 支持按终端宽度换行
-- 支持缓存,避免每帧重复解析
-- 参考 `tmp/tui/src/components/markdown.ts`、`tmp/tui/test/markdown.test.ts`
+参考:`tmp/tui/src/tui.ts`、`tmp/tui/test/tui-overlay-style-leak.test.ts`、`tmp/tui/test/overlay-short-content.test.ts`
-### 6. 文本宽度和换行
+### 5. 文本模型和 ANSI 感知工具
-这是渲染正确性的底座,需要在测试前先稳定实现
+目标:把文本宽度、截断、切片、换行作为框架底座,而不是散落在组件里的局部逻辑
-- 用 grapheme cluster 而不是单个 Rune 计算宽度
-- 处理 ZWJ emoji、肤色、variation selector、regional indicator
-- 处理 tab 宽度
-- 提供 `VisibleWidth`、`TruncateToWidth`、`SliceByColumn`、`WrapTextWithAnsi`
-- 截断和换行时保持 ANSI / OSC 序列不被破坏
-- 参考 `tmp/tui/src/utils.ts`、`tmp/tui/test/truncate-to-width.test.ts`、`tmp/tui/test/wrap-ansi.test.ts`、`tmp/tui/test/regression-regional-indicator-width.test.ts`
+- 用 grapheme cluster 计算宽度,覆盖 ZWJ emoji、肤色、variation selector、regional indicator
+- 提供 ANSI / OSC 感知的 `VisibleWidth`、`TruncateToWidth`、`SliceByColumn`、`WrapTextWithAnsi`
+- 支持 tab 宽度配置
+- 截断和换行时保持 ANSI 样式闭合,并在换行后恢复必要样式
+- 抽象 terminal output normalization,统一清理和补齐行尾 reset
+- 所有组件渲染统一依赖这套文本工具,减少宽度计算不一致导致的渲染 bug
-### 7. 渲染回归场景
+参考:`tmp/tui/src/utils.ts`
-等输入、overlay、文本宽度更稳定后再做测试项目
+### 6. 组件基础设施
-- 引入虚拟终端或 fake terminal output
-- 覆盖全量渲染和差分渲染
-- 覆盖短行覆盖长行
-- 覆盖 resize 后 full redraw
-- 覆盖 cursor marker 提取
-- 覆盖 overlay 覆盖 styled base content
-- 覆盖宽字符在边界截断
-- 参考 `tmp/tui/test/virtual-terminal.ts`、`tmp/tui/test/tui-render.test.ts`、`tmp/tui/test/viewport-overwrite-repro.ts`
+目标:补齐组件体系的横向能力,而不是继续只补单个组件的小功能
+
+- 给组件接口增加 `Invalidate` 语义,用于主题变化、缓存失效和强制重绘
+- 建立 Focusable 模型,由运行时统一设置焦点状态,组件只负责在渲染中输出 cursor marker
+- 支持硬件光标定位配置,兼容 IME 候选窗定位
+- 引入主题接口和默认主题,让组件样式可配置且跨组件一致
+- 增加通用组件:`Spacer`、`TruncatedText`、`SettingsList`、`CancellableLoader`
+- 组件渲染缓存统一由宽度、内容和主题状态驱动,避免每帧重复解析 Markdown 或重算复杂布局
+
+参考:`tmp/tui/src/components/*`
+
+### 7. 自动补全和命令交互基础
+
+目标:把自动补全从 Editor 局部功能升级为可复用的交互能力
+
+- 定义 autocomplete provider 接口,支持同步和异步候选
+- 支持 slash command 候选、文件路径候选、特殊前缀候选
+- 使用 overlay 呈现补全列表,并复用 SelectList 的选择、过滤和滚动能力
+- 将补全确认、取消、预览、应用文本变更抽象成独立流程
+- 让 keybinding 系统负责 Tab、Enter、Escape 等动作触发,避免补全逻辑和具体按键强绑定
+
+参考:`tmp/tui/src/autocomplete.ts`、`tmp/tui/src/components/editor.ts`
+
+### 8. 图像和特殊终端内容
+
+目标:为 Kitty / iTerm2 图像和特殊终端序列预留完整框架入口
+
+- 检测终端图像能力,选择 Kitty graphics protocol、iTerm2 inline image 或文本 fallback
+- 解析图片尺寸并按终端 cell 尺寸计算显示区域
+- 渲染差分时追踪已显示的 Kitty image id,在内容变化或清屏时删除旧图像
+- 合成和截断逻辑识别 image line,避免把图像序列当普通文本切坏
+- 在终端 resize 或 cell size 变化后刷新图像布局
+
+参考:`tmp/tui/src/terminal-image.ts`、`tmp/tui/src/components/image.ts`
+
+### 9. 测试基础设施
+
+目标:引入可以验证真实终端行为的测试层,而不是只靠 Example 手动看效果
+
+- 建立虚拟终端或 fake terminal output,捕获 ANSI 输出并模拟窗口尺寸
+- 覆盖输入缓冲、按键协议、keybinding、文本宽度、ANSI 换行和截断
+- 覆盖差分渲染、内容缩短清理、resize full redraw、viewport 覆盖和硬件光标定位
+- 覆盖 overlay 样式泄漏、短内容覆盖长内容、宽字符边界和多层焦点恢复
+- 覆盖 Markdown、SelectList、Editor、Autocomplete 等组件的框架行为
+- 示例仍用于人工验收,但不能替代回归测试
+
+参考:`tmp/tui/test/virtual-terminal.ts`、`tmp/tui/test/*.test.ts`
+
+### 10. 工程化和发布完整性
+
+目标:让 TinyTUI 从本地骨架变成可维护、可发布、可集成的类库
+
+- 完善 README,覆盖快速开始、核心 API、组件接口、overlay、keybinding、文本工具和自定义组件约束
+- 补 XML documentation 和包元数据,为 NuGet 发布做准备
+- 增加 CI:restore、build、format、test、pack
+- 增加 benchmark 或压力示例,用于观察大文本、频繁刷新、overlay 合成和 Markdown 缓存性能
+- 拆分 Example 为多个真实场景:基础输入、overlay 菜单、聊天界面、Markdown 浏览、设置面板、图像 fallback
+- 明确公开 API 和内部实现边界,避免后续重构时破坏使用者代码
+
+## 建议执行顺序
+
+1. 先补终端会话能力、输入协议和 keybinding,因为它们会影响所有交互组件
+2. 再升级文本模型、渲染管线和 overlay 合成正确性,因为它们决定整体显示稳定性
+3. 然后补组件基础设施、自动补全和主题,让上层组件可以复用统一能力
+4. 接着引入虚拟终端测试和关键回归用例,把已修过的问题固化下来
+5. 最后处理图像、benchmark、README、CI、NuGet 等发布和扩展能力
## 暂缓
-- Kitty 图像协议和 terminal image
-- 主题系统
-- Benchmark
-- 正式测试项目
-- NuGet 包发布配置
-
-## 近期执行顺序
-
-1. 继续补 Editor 的粘贴摘要和补全 overlay
-2. 升级 Markdown 和 Text Width
-3. 处理 Overlay 合成的 ANSI/OSC 样式文本、宽字符边界和底层样式泄漏
-4. 继续完善 SelectList 主题样式和布局配置
-5. 功能形态稳定后再引入测试项目
+- 不继续列举 Editor、SelectList、Markdown 的零散小功能
+- 不优先追求所有组件与 `tmp/tui` 逐项一致
+- 图像能力可以等终端、渲染、文本和测试基础稳定后再做
+- NuGet 发布可以等公开 API 基本稳定后再做
diff --git a/src/Example/Program.cs b/src/Example/Program.cs
index 5a2d13f..b52c726 100644
--- a/src/Example/Program.cs
+++ b/src/Example/Program.cs
@@ -4,16 +4,14 @@ using TinyTUI.Input;
using TinyTUI.Overlay;
using TinyTUI.Rendering;
using TinyTUI.Runtime;
-using TinyTUI.Stdio;
-using TinyTUI.Stdout;
+using TinyTUI.Terminal;
using TinyTUI.Text;
-using var terminalInput = new ConsoleTerminalInput();
-var terminalOutput = new ConsoleTerminalOutput();
+using var terminal = new ConsoleTerminalSession();
var parser = new DefaultInputParser();
var textMeasurer = new TerminalTextMeasurer();
-var renderer = new DifferentialRenderer(terminalOutput, textMeasurer);
-using var runtime = new TuiRuntime(terminalInput, parser, renderer);
+var renderer = new DifferentialRenderer(terminal, textMeasurer);
+using var runtime = new TuiRuntime(terminal, parser, renderer);
var done = new ManualResetEventSlim();
var eventsText = new Text("最近事件:\n- 无");
@@ -109,7 +107,6 @@ AddAll(
runtime.Add(page);
runtime.SetFocus(input);
-terminalOutput.ShowCursor();
var loaderTask = RunLoaderAsync(loader, runtime, loaderCancellation.Token);
@@ -122,9 +119,8 @@ finally
{
StopLoader(loaderCancellation, loaderTask);
runtime.Stop();
- terminalOutput.ShowCursor();
- terminalOutput.Write("\r\n");
- terminalOutput.Flush();
+ terminal.Write("\r\n");
+ terminal.Flush();
}
static async Task RunLoaderAsync(Loader loader, ITuiRuntime runtime, CancellationToken cancellationToken)
diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs
index 38ab493..4e9f53b 100644
--- a/src/TinyTUI/Runtime/TuiRuntime.cs
+++ b/src/TinyTUI/Runtime/TuiRuntime.cs
@@ -3,6 +3,7 @@ using TinyTUI.Input;
using TinyTUI.Overlay;
using TinyTUI.Rendering;
using TinyTUI.Stdio;
+using TinyTUI.Terminal;
namespace TinyTUI.Runtime;
@@ -11,7 +12,7 @@ namespace TinyTUI.Runtime;
///
public sealed class TuiRuntime : ITuiRuntime
{
- private readonly ITerminalInput _terminalInput;
+ private readonly ITerminalSession _terminal;
private readonly IInputParser _inputParser;
private readonly IRenderer _renderer;
private readonly OverlayManager _overlayManager = new();
@@ -25,14 +26,22 @@ public sealed class TuiRuntime : ITuiRuntime
///
/// 创建默认 TUI 运行时
///
- public TuiRuntime(ITerminalInput terminalInput, IInputParser inputParser, IRenderer renderer)
+ public TuiRuntime(ITerminalSession terminal, IInputParser inputParser, IRenderer renderer)
{
- _terminalInput = terminalInput;
+ _terminal = terminal;
_inputParser = inputParser;
_renderer = renderer;
_overlayManager.Removed += OnOverlayRemoved;
}
+ ///
+ /// 创建兼容旧输入接口的 TUI 运行时
+ ///
+ public TuiRuntime(ITerminalInput terminalInput, IInputParser inputParser, IRenderer renderer)
+ : this(new TerminalInputSessionAdapter(terminalInput), inputParser, renderer)
+ {
+ }
+
///
public void Add(IComponent component)
{
@@ -60,7 +69,7 @@ public sealed class TuiRuntime : ITuiRuntime
lock (_renderLock)
{
- var size = _terminalInput.CurrentSize;
+ var size = _terminal.CurrentSize;
var lines = _root.Render(size.Columns);
// overlay 在虚拟行阶段合成 后续仍复用同一个 renderer 做差分刷新
lines = [.. _overlayManager.Compose(lines, size)];
@@ -101,9 +110,9 @@ public sealed class TuiRuntime : ITuiRuntime
if (_started) return;
_started = true;
- _terminalInput.DataReceived += OnDataReceived;
- _terminalInput.Resized += OnResized;
- _terminalInput.Start();
+ _terminal.DataReceived += OnDataReceived;
+ _terminal.Resized += OnResized;
+ _terminal.Start();
_renderer.Reset();
RequestRender();
}
@@ -114,16 +123,16 @@ public sealed class TuiRuntime : ITuiRuntime
if (!_started) return;
_started = false;
- _terminalInput.DataReceived -= OnDataReceived;
- _terminalInput.Resized -= OnResized;
- _terminalInput.Stop();
+ _terminal.DataReceived -= OnDataReceived;
+ _terminal.Resized -= OnResized;
+ _terminal.Stop();
}
///
public void Dispose()
{
Stop();
- _terminalInput.Dispose();
+ _terminal.Dispose();
}
private void OnDataReceived(object? sender, string data)
@@ -193,4 +202,56 @@ public sealed class TuiRuntime : ITuiRuntime
if (_focusedComponent == component)
_focusedComponent = _overlayManager.TopFocusableComponent ?? restoreFocus;
}
+
+ ///
+ /// 将旧输入接口适配为 Runtime 需要的终端会话 仅用于兼容现有调用方
+ ///
+ private sealed class TerminalInputSessionAdapter(ITerminalInput input) : ITerminalSession
+ {
+ public TerminalSize CurrentSize => input.CurrentSize;
+
+ public bool KittyProtocolActive => false;
+
+ public event EventHandler? DataReceived
+ {
+ add => input.DataReceived += value;
+ remove => input.DataReceived -= value;
+ }
+
+ public event EventHandler? Resized
+ {
+ add => input.Resized += value;
+ remove => input.Resized -= value;
+ }
+
+ public void Start() => input.Start();
+
+ public void Stop() => input.Stop();
+
+ public void DrainInput(TimeSpan? maxDuration = null, TimeSpan? idleDuration = null) { }
+
+ public void Write(string value) { }
+
+ public void Flush() { }
+
+ public void ClearScreen() { }
+
+ public void HideCursor() { }
+
+ public void ShowCursor() { }
+
+ public void MoveCursorTo(int row, int column) { }
+
+ public void MoveCursorBy(int lines) { }
+
+ public void ClearLine() { }
+
+ public void ClearFromCursor() { }
+
+ public void SetTitle(string title) { }
+
+ public void SetProgress(bool active) { }
+
+ public void Dispose() => input.Dispose();
+ }
}
diff --git a/src/TinyTUI/Terminal/ConsoleTerminalSession.cs b/src/TinyTUI/Terminal/ConsoleTerminalSession.cs
new file mode 100644
index 0000000..31d1d57
--- /dev/null
+++ b/src/TinyTUI/Terminal/ConsoleTerminalSession.cs
@@ -0,0 +1,322 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using TinyTUI.Stdio;
+using TinyTUI.Stdout;
+
+namespace TinyTUI.Terminal;
+
+///
+/// 基于 System.Console 的真实终端会话实现
+///
+public sealed class ConsoleTerminalSession : ITerminalSession
+{
+ private const string BracketedPasteEnable = "\e[?2004h";
+ private const string BracketedPasteDisable = "\e[?2004l";
+ private const string KittyKeyboardProtocolDisable = "\e[
+ /// 创建使用 Console 输入输出的终端会话
+ ///
+ public ConsoleTerminalSession()
+ : this(new ConsoleTerminalInput(), new ConsoleTerminalOutput())
+ {
+ }
+
+ ///
+ /// 创建使用指定输入输出实现的终端会话
+ ///
+ public ConsoleTerminalSession(ITerminalInput input, ITerminalOutput output)
+ {
+ _input = input;
+ _output = output;
+ }
+
+ ///
+ public TerminalSize CurrentSize => _input.CurrentSize;
+
+ ///
+ public bool KittyProtocolActive { get; private set; }
+
+ ///
+ public event EventHandler? DataReceived;
+
+ ///
+ public event EventHandler? Resized;
+
+ ///
+ public void Start()
+ {
+ if (_started) return;
+
+ _started = true;
+ _previousTitle = TryReadConsoleTitle();
+
+ _input.DataReceived += OnInputDataReceived;
+ _input.Resized += OnResized;
+ _input.Start();
+
+ // Windows 控制台需要显式打开 VT input 才能尽量保留 Shift+Tab 等组合键序列
+ _windowsVirtualTerminalInputMode.Enable();
+
+ HideCursor();
+ Write(BracketedPasteEnable);
+ Flush();
+ }
+
+ ///
+ public void Stop()
+ {
+ if (!_started) return;
+
+ _started = false;
+ _input.DataReceived -= OnInputDataReceived;
+ _input.Resized -= OnResized;
+ _input.Stop();
+
+ DrainInput();
+ ClearProgress();
+
+ // 这些恢复序列保持幂等 即使当前终端未启用对应模式也不会破坏后续 shell
+ Write(BracketedPasteDisable);
+ Write(KittyKeyboardProtocolDisable);
+ Write(ModifyOtherKeysDisable);
+ KittyProtocolActive = false;
+
+ _windowsVirtualTerminalInputMode.Restore();
+ RestoreTitle();
+ ShowCursor();
+ Flush();
+ }
+
+ ///
+ public void DrainInput(TimeSpan? maxDuration = null, TimeSpan? idleDuration = null)
+ {
+ if (Console.IsInputRedirected)
+ return;
+
+ var max = maxDuration ?? DefaultDrainMaxDuration;
+ var idle = idleDuration ?? DefaultDrainIdleDuration;
+ var stopwatch = Stopwatch.StartNew();
+ var lastInput = stopwatch.Elapsed;
+
+ while (stopwatch.Elapsed < max)
+ {
+ if (TryReadPendingKey())
+ {
+ lastInput = stopwatch.Elapsed;
+ continue;
+ }
+
+ if (stopwatch.Elapsed - lastInput >= idle)
+ break;
+
+ Thread.Sleep(TimeSpan.FromMilliseconds(5));
+ }
+ }
+
+ ///
+ public void Write(string value) => _output.Write(value);
+
+ ///
+ public void Flush() => _output.Flush();
+
+ ///
+ public void ClearScreen() => _output.ClearScreen();
+
+ ///
+ public void HideCursor() => _output.HideCursor();
+
+ ///
+ public void ShowCursor() => _output.ShowCursor();
+
+ ///
+ public void MoveCursorTo(int row, int column) => _output.MoveCursorTo(row, column);
+
+ ///
+ public void MoveCursorBy(int lines)
+ {
+ if (lines > 0)
+ Write($"\e[{lines}B");
+ else if (lines < 0)
+ Write($"\e[{-lines}A");
+ }
+
+ ///
+ public void ClearLine() => Write("\e[K");
+
+ ///
+ public void ClearFromCursor() => Write("\e[J");
+
+ ///
+ public void SetTitle(string title) => Write($"\e]0;{title}\a");
+
+ ///
+ public void SetProgress(bool active)
+ {
+ if (active)
+ {
+ Write(ProgressActiveSequence);
+ _progressActive = true;
+ _progressTimer ??= new Timer(_ => Write(ProgressActiveSequence), null, ProgressKeepAliveInterval, ProgressKeepAliveInterval);
+ return;
+ }
+
+ ClearProgress();
+ }
+
+ ///
+ public void Dispose()
+ {
+ Stop();
+ _progressTimer?.Dispose();
+ _input.Dispose();
+ }
+
+ private void OnInputDataReceived(object? sender, string data) => DataReceived?.Invoke(this, data);
+
+ private void OnResized(object? sender, TerminalSize size) => Resized?.Invoke(this, size);
+
+ ///
+ /// 读取一个待处理按键并吞掉它 Drain 阶段不再把按键派发给组件
+ ///
+ private static bool TryReadPendingKey()
+ {
+ try
+ {
+ if (!Console.KeyAvailable)
+ return false;
+
+ Console.ReadKey(intercept: true);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// 尝试读取当前窗口标题 非交互环境不支持时直接忽略
+ ///
+ private static string? TryReadConsoleTitle()
+ {
+ if (!OperatingSystem.IsWindows())
+ return null;
+
+ try
+ {
+ return Console.Title;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// 恢复启动前窗口标题 避免 TUI 自定义标题泄漏到父 shell
+ ///
+ private void RestoreTitle()
+ {
+ if (_previousTitle is null || !OperatingSystem.IsWindows())
+ return;
+
+ try
+ {
+ Console.Title = _previousTitle;
+ }
+ catch
+ {
+ // 标题恢复不是关键路径 非交互环境失败时忽略
+ }
+ }
+
+ ///
+ /// 停止进度 keepalive 定时器并返回之前是否处于 active 状态
+ ///
+ private bool ClearProgress()
+ {
+ var wasActive = _progressActive;
+ _progressActive = false;
+
+ _progressTimer?.Dispose();
+ _progressTimer = null;
+
+ if (wasActive)
+ Write(ProgressClearSequence);
+
+ return wasActive;
+ }
+
+ ///
+ /// 管理 Windows 控制台 ENABLE_VIRTUAL_TERMINAL_INPUT 标志
+ ///
+ private sealed class WindowsVirtualTerminalInputMode
+ {
+ private const int StandardInputHandle = -10;
+ private const uint EnableVirtualTerminalInput = 0x0200;
+
+ private uint _previousMode;
+ private bool _enabled;
+
+ ///
+ /// 保存当前 console mode 并打开 VT input
+ ///
+ public void Enable()
+ {
+ if (!OperatingSystem.IsWindows())
+ return;
+
+ var handle = GetStdHandle(StandardInputHandle);
+ if (handle == IntPtr.Zero || handle == new IntPtr(-1))
+ return;
+
+ if (!GetConsoleMode(handle, out var mode))
+ return;
+
+ _previousMode = mode;
+ _enabled = true;
+ SetConsoleMode(handle, mode | EnableVirtualTerminalInput);
+ }
+
+ ///
+ /// 恢复启动前的 Windows console mode
+ ///
+ public void Restore()
+ {
+ if (!_enabled || !OperatingSystem.IsWindows())
+ return;
+
+ var handle = GetStdHandle(StandardInputHandle);
+ if (handle != IntPtr.Zero && handle != new IntPtr(-1))
+ SetConsoleMode(handle, _previousMode);
+
+ _enabled = false;
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr GetStdHandle(int nStdHandle);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
+ }
+}
diff --git a/src/TinyTUI/Terminal/ITerminalSession.cs b/src/TinyTUI/Terminal/ITerminalSession.cs
new file mode 100644
index 0000000..e74d863
--- /dev/null
+++ b/src/TinyTUI/Terminal/ITerminalSession.cs
@@ -0,0 +1,69 @@
+using TinyTUI.Stdout;
+
+namespace TinyTUI.Terminal;
+
+///
+/// 表示一次可启动和停止的终端会话
+///
+public interface ITerminalSession : ITerminalOutput, IDisposable
+{
+ ///
+ /// 获取最近一次记录的终端视口尺寸
+ ///
+ TerminalSize CurrentSize { get; }
+
+ ///
+ /// 获取 Kitty keyboard protocol 是否已经启用
+ ///
+ bool KittyProtocolActive { get; }
+
+ ///
+ /// 在终端收到原始输入数据时触发
+ ///
+ event EventHandler? DataReceived;
+
+ ///
+ /// 在终端视口尺寸变化时触发
+ ///
+ event EventHandler? Resized;
+
+ ///
+ /// 启动终端会话并应用 TUI 需要的终端模式
+ ///
+ void Start();
+
+ ///
+ /// 停止终端会话并恢复启动前的终端状态
+ ///
+ void Stop();
+
+ ///
+ /// 在退出前排空尚未处理的输入 避免释放按键等延迟事件泄漏到父 shell
+ ///
+ void DrainInput(TimeSpan? maxDuration = null, TimeSpan? idleDuration = null);
+
+ ///
+ /// 按相对行数移动硬件光标 正数向下 负数向上
+ ///
+ void MoveCursorBy(int lines);
+
+ ///
+ /// 清理从当前光标到行尾的内容
+ ///
+ void ClearLine();
+
+ ///
+ /// 清理从当前光标到屏幕末尾的内容
+ ///
+ void ClearFromCursor();
+
+ ///
+ /// 设置终端窗口标题
+ ///
+ void SetTitle(string title);
+
+ ///
+ /// 设置终端进度指示
+ ///
+ void SetProgress(bool active);
+}