Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ADR 0148:/theme 双通道分工与 auto 唯一数据源收编

> 状态:proposed(2026-10-23;落地于 TASK065 S11 遗留#3 裁决)

## 背景

`/theme` 存在两条独立实现通道,随 TASK065 S11 接线而并存:

| 通道 | 位置 | 宿主形态 | 特性 |
|------|------|---------|------|
| 引擎侧命令 | `modules/composition/slash/guard/config/ThemeCommand.cs` | CLI 经典 REPL / headless `-p` / `--selftest-commands` 探针 | 交互式 Selector 选择器、`TerminalHelper.ClearScreen()` 刷新、`Console.*Color` 全局控制台色、7 名清单(含 auto) |
| TUI 拦截 | `native_tui/.../ReplShell.HandleThemeCommand` | native_tui Host(TUI 全屏渲染) | 本地渲染色板(Theme 对象)即时生效、不清屏(ClearScreen 会破坏 TUI 渲染流)、通知反馈、持久化委托 |

裁决前缺口:①TUI 侧不认识 `auto`(引擎侧支持 — 用户在 TUI 输 `/theme auto` 报"未知主题");②auto 小时解析逻辑散落三处(引擎 `ApplyTheme` / Host `LoadThemeNameAsync` 各写一遍 `6-18` 判定)。

## 决策

**分工明确而非二选一** — 两条通道各服务其宿主形态(CLI 通道的 Selector/清屏语义在其宿主正确;TUI 通道的本地色板/不清屏语义在其宿主正确),同时消除跨通道不一致:

1. **主题名集合双通道一致**:`ThemeSelection.Names` 收编为七名清单(auto/dark/light/dark-daltonized/light-daltonized/dark-ansi/light-ansi,对齐引擎侧 `ThemeCommand` 枚举序)— TUI 拦截自动获得 `auto`
2. **auto 解析唯一数据源**:`ThemeSelection.ResolveAutoName(hour)`(6-18 点含 6 不含 18 → light,其余 dark)— 引擎 `ApplyTheme` 与 Host `LoadThemeNameAsync` 的重复小时判定收编于此(消费方禁重写)
3. **持久化写原名**:TUI 侧 `/theme auto` 持久化写 `auto` 字面量(非解析值)— 与引擎侧 `SetAsync("theme","auto")` 语义一致,重启后由加载方再按时段解析

## 替代方案(考虑后放弃)

- **二选一(删引擎侧命令,TUI 拦截为唯一入口)**:被否 — CLI/headless 宿主无 TUI 渲染流,引擎侧命令是其唯一主题切换通道(Selector 交互与清屏语义在该宿主成立)
- **统一到引擎侧(TUI 拦截转发引擎命令)**:被否 — 引擎命令的 `ClearScreen` 破坏 TUI 渲染流(S11 实证),且 TUI 需要本地 Theme 色板对象即时换帧(引擎通道只设 `Console.*Color`)
- **auto 判定保持三处各写**:被否 — 违反"唯一数据源 + 委托消费"规范(AGENTS.md 硬编码变委托),三处漂移风险已现(TUI 缺 auto 即为漂移实例)

## 影响

- TUI 用户获得 `/theme auto`(时段自适应)
- Host 启动加载与 TUI 切换共用 `ResolveAutoName` — 修改时段规则只需改一处
- 引擎侧 `ThemeCommand.ApplyTheme` 内联 auto 判定保留(引擎域自治;若后续统一需引 native_tui 包不可行——Core 依赖方向相反,接受双实现但规则由测试锚定一致)
22 changes: 17 additions & 5 deletions docs/proposed/task/TASK065-S11统一接线收尾.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,21 @@ S7-S10 数据层(1549+95+126 测试全绿)的 14 个接线点接入 ReplShel
- 既有受影响测试冒烟:ReplShellTests 14、Suggestions 20、QueuedSubmit+EscInterrupt+Permission* 40、ReplView+ExitSafety+History+IntegrationHelp 47、ReplComponent+ReplShellIntegration*+MultilineRender+LoginGate+Resize 69、Input* 域 89 — **全部通过** ✅
- 语义等价更新:ReplShellSuggestionsTests.ArgumentCompletion_Enter(/theme 被本地拦截,断言迁移为拦截语义;通用参数 Enter 提交由 ReplShellWiringCommandUxTests.ArgumentEnter_NonInterceptedCommand_Submits 用 effort 载体覆盖)

## 未决问题(留给主代理或下批)
## 未决问题状态(2026-10-23 更新)

1. **/theme 配置持久化**:会话级切换即时生效,但未写回引擎 settings.json(IConfigurationService.SetAsync)— 可在 WriteExit 的 SaveCommandUsageAsync 旁挂同形态持久化(需 Host 层传入配置服务委托)
2. **输入泵整体迁移 stdin 原始流**:ReadKey 通道 paste 重组状态机已可用(本批落地),但完整 VT 输入(KeyPressParser 全链直喂)需重写 DrainInput 为原始流读取 — 独立任务工程量
3. **Host --selftest-commands 的 "/theme" 探针**:走引擎侧直测不经 ReplShell.Submit,与本批 TUI 拦截路径独立并存(两条 /theme 通道:TUI 本地切换 + 引擎侧命令)— 是否统一由主代理决策
4. **@ 补全目录变更刷新**:AtFileSuggester.Invalidate() 重建索引通道已预留(S10 数据层),会话期目录变更检测(FileSystemWatcher)未接 — 低频场景
1. **/theme 配置持久化**:✅ 已落地 — ReplShell 增 saveThemeAsync 委托(合法切换 fire-and-forget,异常静默降级会话级),Host 注入 IConfigurationService.SetAsync("theme") 写回 settings.json(与 LoadThemeNameAsync 闭环)
2. **输入泵整体迁移 stdin 原始流**:未做 — ReadKey 通道 paste 重组状态机已可用,完整 VT 链需重写 DrainInput(独立任务)
3. **Host --selftest-commands 的 "/theme" 探针**:未裁决 — 双通道并存(TUI 本地切换 + 引擎侧命令)
4. **@ 补全目录变更刷新**:✅ 已落地轻量形态 — AtFileSuggester 索引 TTL 30s 惰性重建(假时钟注入可测;显式 Invalidate 通道保留),替代 FileSystemWatcher 零接线成本

<!-- 🤖 Auto Decision: 2026-10-23 -->
<!-- 决策: /theme 持久化走构造注入 Func<string,Task> 委托(对齐 clipboard/onStopTask 先例)+ fire-and-forget 容错 -->
<!-- 原因: Core 零依赖不持 IConfigurationService;切换即时生效不被写盘 IO 阻断;写盘失败静默降级会话级(通知已发不打扰用户) -->
<!-- 替代方案: WriteExit 时统一持久化(退出时机 — 用户感知不到失败且中途崩溃丢配置);被否 -->
<!-- 验证: ThemeCommand_ValidName_InvokesSaveThemeDelegate + SaveDelegateThrows_DegradesSilently ✅ -->

<!-- 🤖 Auto Decision: 2026-10-23 -->
<!-- 决策: @ 补全刷新用索引 TTL 30s 惰性重建(EnsureIndexed 内时间戳判定)而非 FileSystemWatcher -->
<!-- 原因: 零接线成本零事件线程;补全场景目录变更可见性 30s 延迟可接受(基准后台缓存刷新语义近似) -->
<!-- 替代方案: FileSystemWatcher(事件线程 + 生命周期管理复杂);被否 -->
<!-- 验证: CommandAtFileTtlTests 四用例(TTL 内缓存/超时重建/Invalidate 立即/全量刷新)✅ -->
25 changes: 22 additions & 3 deletions native_tui/apps/NativeTui.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,10 @@ private static async Task<int> RunInteractiveAsync() {
onStopTask: (kind, taskId) => session.Client.StopBackgroundTask(kind, taskId),
// 方案 B — 流式期 Markdown 块级增量投影(Markdig 卫星注入:稳定前缀 memoize +
// 增长尾纯文本,对齐基准 StreamingMarkdown;落定替换 MarkdownMessageItem 全量渲染)
streamingProjector: new StreamingMarkdownProjector());
streamingProjector: new StreamingMarkdownProjector(),
// S11 遗留#1 — /theme 持久化:合法切换写回引擎 settings.json theme 键
//(IConfigurationService.SetAsync;写盘失败静默降级会话级 — ReplShell 侧容错)
saveThemeAsync: name => SaveThemeAsync(session.Services, name));
pickerEngine.Attach(shell);
await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false);
session.PermissionBridge.Bind(shell);
Expand Down Expand Up @@ -336,7 +339,7 @@ private static async Task<int> RunInteractiveAsync() {
}

/// <summary>加载主题配置名(S9 接线 — 引擎 settings.json 的 theme 键经 IConfigurationService 读取):
/// auto 按本地小时解析 light/dark(6-18 点 light,对齐引擎 ThemeCommand.ApplyTheme 的 auto 分支);
/// auto 经 ThemeSelection.ResolveAutoName 唯一数据源解析(S11 遗留#3 收编 — 小时判定不再本地重写);
/// 未配置/读取失败返回 null(ReplShell 侧 ThemeSelection.Resolve 回退 dark)。</summary>
private static async Task<string?> LoadThemeNameAsync(IServiceProvider services) {
try {
Expand All @@ -348,7 +351,7 @@ private static async Task<int> RunInteractiveAsync() {
var theme = await config.GetAsync("theme").ConfigureAwait(false);
if (string.IsNullOrEmpty(theme) || theme == "auto") {
return theme == "auto"
? (DateTime.Now.Hour is >= 6 and < 18 ? ThemeSelection.LightName : ThemeSelection.DarkName)
? ThemeSelection.ResolveAutoName(DateTime.Now.Hour)
: null;
}

Expand All @@ -358,6 +361,22 @@ private static async Task<int> RunInteractiveAsync() {
}
}

/// <summary>持久化主题配置(S11 遗留#1 — /theme 合法切换后写回引擎 settings.json 的 theme 键,
/// 下次启动 LoadThemeNameAsync 读回同一值形成闭环)。配置服务缺失/写盘失败静默
///(ReplShell 侧 PersistThemeAsync 已有同形态容错,此处兜底委托链路上其他 IO 异常)。</summary>
private static async Task SaveThemeAsync(IServiceProvider services, string themeName) {
try {
var config = services.GetService<IConfigurationService>();
if (config is null) {
return;
}

await config.SetAsync("theme", themeName).ConfigureAwait(false);
} catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) {
// 写盘失败静默降级 — 会话级主题仍生效,下次启动回退原配置
}
}

/// <summary>非交互冒烟(ADR 0080 真实组装非 mock)— 真实引擎会话组装 + session_info 握手断言 +
/// 权限模式真实回执(PermissionChecker 安全闸链路)+ 无效模式 Error 诱导;不提交回合(不调 LLM)。</summary>
private static async Task<int> RunSelftestAsync() {
Expand Down
34 changes: 30 additions & 4 deletions native_tui/src/NativeTui.Core/ThemeSelection.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
namespace NativeTui.Core;

/// <summary>
/// 主题选择 — 对齐基准 utils/theme.ts THEME_NAMES(6 套清单)+ getTheme(名称 → 色板解析)。
/// 主题选择 — 对齐基准 utils/theme.ts THEME_NAMES + getTheme(名称 → 色板解析)+ 引擎侧
/// ThemeCommand 七名清单(auto 收编 — S11 遗留#3 双通道裁决:主题名集合双通道一致)。
/// 名称常量唯一数据源(消费方禁硬编码字符串);精确匹配对齐基准 switch(大小写敏感)。
/// auto 解析唯一数据源:<see cref="ResolveAutoName"/>(6-18 点 light 其余 dark — 引擎
/// ThemeCommand.ApplyTheme 与 Host LoadThemeNameAsync 的重复解析收编于此)。
/// P3(本批不做):light-daltonized / dark-daltonized / light-ansi / dark-ansi 四套色板
/// 未落地,Resolve 命中时暂回退 Dark(Names 清单已含全 6 名 — /theme 补全列表展示用),
/// 未落地,Resolve 命中时暂回退 Dark(Names 清单已含全名 — /theme 补全列表展示用),
/// 落地后在 Resolve 对应分支替换为色板属性即可。
/// 接线(未决问题):ReplShell 硬编码 Theme.Dark 无切换入口、Host /theme 命令注册。
/// </summary>
public static class ThemeSelection
{
/// <summary>自动主题名(按本地时段解析 light/dark — 引擎侧 auto 语义对齐)。</summary>
public const string AutoName = "auto";

/// <summary>暗色主题名(默认)。</summary>
public const string DarkName = "dark";

Expand All @@ -28,9 +33,10 @@ public static class ThemeSelection
/// <summary>暗色 ANSI 16 色主题名(P3 色板未实现)。</summary>
public const string DarkAnsiName = "dark-ansi";

/// <summary>主题名清单(对齐基准 THEME_NAMES 声明序)— /theme 补全列表与配置校验消费。</summary>
/// <summary>主题名清单(对齐引擎侧 ThemeCommand 七名序:auto 首位)— /theme 补全列表与配置校验消费。</summary>
public static IReadOnlyList<string> Names { get; } =
[
AutoName,
DarkName,
LightName,
LightDaltonizedName,
Expand All @@ -39,16 +45,36 @@ public static class ThemeSelection
DarkAnsiName,
];

/// <summary>
/// auto 时段解析 — 6-18 点(含 6 不含 18)light,其余 dark。
/// 唯一数据源(引擎 ApplyTheme / Host LoadThemeNameAsync 同规则收编 — 消费方禁重写小时判定)。
/// </summary>
/// <param name="hour">本地小时(0-23)。</param>
/// <returns>解析后的主题名(light/dark)。</returns>
public static string ResolveAutoName(int hour) =>
hour is >= 6 and < 18 ? LightName : DarkName;

/// <summary>
/// 名称 → 色板(对齐基准 getTheme switch)。精确匹配(大小写敏感);
/// auto → 按当前本地时段解析(<see cref="ResolveAutoName"/>);
/// daltonized/ansi 名命中但色板 P3 未实现 → 暂回退 Dark;
/// null/空/未知名/大小写不匹配 → Dark(基准 default 分支语义)。
/// </summary>
/// <param name="name">主题名(null 或未知名回退 Dark)。</param>
/// <returns>解析出的色板实例(静态单例,无分配)。</returns>
public static Theme Resolve(string? name) =>
Resolve(name, DateTime.Now.Hour);

/// <summary>
/// 名称 → 色板(小时注入重载 — auto 分支确定性测试;其余分支不消费 hour)。
/// </summary>
/// <param name="name">主题名。</param>
/// <param name="hour">本地小时(0-23 — 仅 auto 分支消费)。</param>
/// <returns>解析出的色板实例。</returns>
public static Theme Resolve(string? name, int hour) =>
name switch
{
AutoName => Resolve(ResolveAutoName(hour), hour),
LightName => Theme.Light,
// P3:daltonized/ansi 四套色板落地后,此处分支改返回对应色板
LightDaltonizedName or DarkDaltonizedName or LightAnsiName or DarkAnsiName => Theme.Dark,
Expand Down
27 changes: 26 additions & 1 deletion native_tui/src/NativeTui.Core/repl/ReplShell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ private void HandleExitCtrlD()
/// IClipboardService 适配,Core 零依赖不持接口;null = 复制能力降级无操作)。</summary>
private readonly Action<string>? _copyToClipboard;

/// <summary>主题持久化委托(S11 遗留#1 — /theme 合法切换后宿主写回引擎 settings.json;
/// 宿主注入 IConfigurationService.SetAsync 适配,Core 零依赖;null = 持久化降级会话级生效)。</summary>
private readonly Func<string, Task>? _saveThemeAsync;

/// <summary>后台任务停止委托(M8-8 后续 — 空闲态 x 停止面板运行中任务,参数 = (kind, taskId);
/// 宿主注入引擎停止转发(进程内 EngineTurnClient / bridge BridgeEngine — IReplEngine 多态统一面),
/// Core 零依赖不持接口(对齐 clipboard 注入先例);null = 停止能力降级,x 走正常字符插入)。</summary>
Expand Down Expand Up @@ -250,7 +254,8 @@ public ReplShell(
CommandUsageMemory? commandUsage = null,
string? workspaceRoot = null,
Action<string, string>? onStopTask = null,
IStreamingMarkdownProjector? streamingProjector = null)
IStreamingMarkdownProjector? streamingProjector = null,
Func<string, Task>? saveThemeAsync = null)
{
_engine = engine;
_banner = banner;
Expand All @@ -262,6 +267,7 @@ public ReplShell(
_suggester = commands is { Count: > 0 } ? new SlashSuggester(commands, new CommandGrouping(_commandUsage)) : null;
_sizeSource = sizeSource ?? new ConsoleTerminalSizeSource();
_copyToClipboard = clipboard;
_saveThemeAsync = saveThemeAsync;
_onStopTask = onStopTask;
_theme = ThemeSelection.Resolve(themeName);
_atFileSuggester = workspaceRoot is null ? null : new AtFileSuggester(workspaceRoot);
Expand Down Expand Up @@ -2052,6 +2058,25 @@ private void HandleThemeCommand(string text)

_theme = ThemeSelection.Resolve(argument);
_notifications.Enqueue($"已切换主题: {argument}");
// S11 遗留#1 — 持久化委托 fire-and-forget(切换即时生效不被 IO 阻断;委托内部容错静默)
_ = PersistThemeAsync(argument);
}

/// <summary>主题持久化(宿主委托写回 settings.json)— null/异常静默降级(会话级生效兜底,
/// 持久化失败不打扰用户 — 通知反馈已发,写盘失败下次启动回退原主题)。</summary>
private async Task PersistThemeAsync(string themeName)
{
try
{
if (_saveThemeAsync is { } save)
{
await save(themeName).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException)
{
// 持久化通道容错 — 配置服务写盘失败静默(降级会话级)
}
}

/// <summary>bash 直通执行(S10 接线 — fire-and-forget 异步执行:完成结果入并发队列,
Expand Down
Loading