diff --git a/docs/CLI.md b/docs/CLI.md index 1d839157a6..b7253d7d8f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -573,7 +573,12 @@ groups instead of cutting labels in half. Visible labels and execution-setting v follow `/language`. Use `/theme auto|light|dark` to select the terminal background mode, or choose a -named accent from `/theme`. Both composer borders, the insertion cursor, +named accent from `/theme`. + +- `/theme` alone opens a picker that previews each row as the cursor reaches it. +- Enter keeps the row, Esc restores the original, and `/theme list` prints the names. + +Both composer borders, the insertion cursor, selection, scrollbar, and footer use the active CLI theme. See [Keyboard shortcuts](./GUIDE.md#keyboard-shortcuts) for transcript navigation, multiline input, rewind, and clipboard controls. diff --git a/docs/CLI.zh-CN.md b/docs/CLI.zh-CN.md index 030a6166fa..2f7bc265bc 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -492,7 +492,12 @@ reasonix -p "同时更新两个项目" \ 设定值会跟随 `/language`。 使用 `/theme auto|light|dark` 选择终端背景模式,也可以从 `/theme` 列出的命名配色中选择 -强调色。输入框上下边线、插入光标、选区、滚动条和底栏都会使用当前 CLI 主题。Transcript +强调色。 + +- 单独输入 `/theme` 会打开选择器,光标移到哪一行就即时预览。 +- Enter 保留,Esc 还原;`/theme list` 列出名称。 + +输入框上下边线、插入光标、选区、滚动条和底栏都会使用当前 CLI 主题。Transcript 导航、多行输入、rewind 和剪贴板操作见[快捷键](./GUIDE.zh-CN.md#快捷键)。 剪贴板操作按内容类型明确分开。本地 transcript 和输入框选区写入系统剪贴板,并且只有写入 @@ -515,7 +520,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键 | `/resume [n]` | 搜索最近会话并切换;`/resume ` 直接切换到列表中第 n 个会话。 | | `/status` | 显示模型、effort、cache、Git、后台任务,以及执行设定或余额信息。 | | `/preset [balanced\|delivery]` | 查看或切换 Agent 执行设定(不重建 Controller);`/work-mode` 与 `/profile` 为兼容别名,`economy`、`light` 都解析为 `balanced`。 | -| `/theme [auto\|light\|dark\|style]` | 查看或切换 CLI 背景模式和强调色。 | +| `/theme [auto\|light\|dark\|style\|list]` | 打开选择器预览并切换 CLI 背景模式和强调色。 | | `/currency [auto\|CNY\|USD]` | 查看或切换用户全局费用展示币种,并刷新当前运行时。 | | `/paste-image` | 读取剪贴板图片并插入可编辑的附件标记。 | | `/mouse` | 切换应用内鼠标选区、滚动条和滚轮处理。 | diff --git a/internal/base/i18n/messages_en.go b/internal/base/i18n/messages_en.go index 6b06f1d297..357acf638e 100644 --- a/internal/base/i18n/messages_en.go +++ b/internal/base/i18n/messages_en.go @@ -235,7 +235,7 @@ var English = Messages{ OutputStyleNone: "no output styles available", ThemeHeader: "themes:", - ThemeHint: "switch with /theme ", + ThemeHint: "pick with /theme, or switch with /theme ", ThemeChangedFmt: "theme switched to %s / %s", ThemeUnknownFmt: "unknown theme %q", LanguageHeader: "languages:", diff --git a/internal/base/i18n/messages_zh.go b/internal/base/i18n/messages_zh.go index 0950b0dae6..502e3954b4 100644 --- a/internal/base/i18n/messages_zh.go +++ b/internal/base/i18n/messages_zh.go @@ -235,7 +235,7 @@ var Chinese = Messages{ OutputStyleNone: "没有可用的输出风格", ThemeHeader: "主题:", - ThemeHint: "使用 /theme 切换", + ThemeHint: "用 /theme 选择,或用 /theme 切换", ThemeChangedFmt: "已切换主题为 %s / %s", ThemeUnknownFmt: "未知主题 %q", LanguageHeader: "语言:", diff --git a/internal/base/i18n/messages_zh_tw.go b/internal/base/i18n/messages_zh_tw.go index 115479d3bc..ea1187ccd3 100644 --- a/internal/base/i18n/messages_zh_tw.go +++ b/internal/base/i18n/messages_zh_tw.go @@ -232,7 +232,7 @@ var ChineseTraditional = Messages{ OutputStyleNone: "沒有可用的輸出風格", ThemeHeader: "主題:", - ThemeHint: "使用 /theme 切換", + ThemeHint: "用 /theme 選擇,或用 /theme 切換", ThemeChangedFmt: "已切換主題為 %s / %s", ThemeUnknownFmt: "未知主題 %q", LanguageHeader: "語言:", diff --git a/internal/frontend/termrender/theme_switch.go b/internal/frontend/termrender/theme_switch.go index e99d05253a..744212ce76 100644 --- a/internal/frontend/termrender/theme_switch.go +++ b/internal/frontend/termrender/theme_switch.go @@ -45,3 +45,30 @@ func DescribeThemes() string { } return strings.TrimRight(b.String(), "\n") } + +// RestoreTheme puts back a palette ActiveTheme returned earlier, so a preview +// that was never confirmed leaves nothing behind. +func RestoreTheme(p Palette) { + activeTheme = p + refreshCLIStyles() +} + +// ThemeChoice is one row a theme picker offers: a mode or a named style. +type ThemeChoice struct { + ID string + Description string + Active bool +} + +// ThemeChoices lists the three modes and every style, marking the active style. +func ThemeChoices() []ThemeChoice { + out := []ThemeChoice{ + {ID: "auto", Description: "follow the terminal background"}, + {ID: "light", Description: "light palette, current accent kept when it fits"}, + {ID: "dark", Description: "dark palette, current accent kept when it fits"}, + } + for _, st := range cliThemeStyles { + out = append(out, ThemeChoice{ID: st.name, Description: st.mode + " · " + st.description, Active: st.name == activeTheme.Style}) + } + return out +} diff --git a/internal/frontend/tui/misc_commands.go b/internal/frontend/tui/misc_commands.go index 7e3ba0c976..eefb3f4597 100644 --- a/internal/frontend/tui/misc_commands.go +++ b/internal/frontend/tui/misc_commands.go @@ -76,8 +76,43 @@ func (m *model) toggleDiffFold() { m.tr.AddNotice("info", i18n.M.DiffFoldDisabled) } +// openThemePicker previews each row as the cursor reaches it and puts the +// original back unless a row is confirmed. Browsing writes nothing: the +// preference is stored only through setTheme, as typing the name does. +func (m *model) openThemePicker() { + original := termrender.ActiveTheme() + restore := func() { + termrender.RestoreTheme(original) + termrender.ApplyTextareaTheme(&m.composer) + } + var items []chooseItem + for _, c := range termrender.ThemeChoices() { + items = append(items, chooseItem{ID: c.ID, Label: c.ID, Desc: c.Description, Active: c.Active}) + } + p := newQuickPicker(i18n.M.ThemeHeader, items, func(it chooseItem) tea.Cmd { + restore() + m.setTheme(it.ID) + return m.commit() + }) + p.onMove = func(it chooseItem) { + restore() + if termrender.IsThemeMode(it.ID) { + termrender.SetThemeMode(it.ID) + } else { + termrender.SetThemeStyle(it.ID) + } + termrender.ApplyTextareaTheme(&m.composer) + } + p.onCancel = restore + m.quick = p +} + func (m *model) setTheme(name string) { - if name == "" { + switch strings.ToLower(name) { + case "": + m.openThemePicker() + return + case "list": m.tr.AddNotice("info", i18n.M.ThemeHeader+"\n"+termrender.DescribeThemes()+"\n"+i18n.M.ThemeHint) return } diff --git a/internal/frontend/tui/misc_commands_test.go b/internal/frontend/tui/misc_commands_test.go index e63022b384..c563a798af 100644 --- a/internal/frontend/tui/misc_commands_test.go +++ b/internal/frontend/tui/misc_commands_test.go @@ -100,7 +100,7 @@ func TestThemeListsSwitchesAndPersists(t *testing.T) { m, k, cfgPath := miscModel(t) prev := termrender.ActiveTheme() t.Cleanup(func() { termrender.SetThemeStyle(prev.Style) }) - sendLine(m, "/theme") + sendLine(m, "/theme list") if got := notices(m); !strings.Contains(got, "graphite") || !strings.Contains(got, i18n.M.ThemeHint) { t.Fatalf("listing = %q", got) } @@ -205,3 +205,81 @@ func TestThemeAndVerboseNeverWriteIntoAProjectFile(t *testing.T) { t.Fatalf("user config missing the preferences:\n%s", raw) } } + +func themePickerModel(t *testing.T) (*model, string) { + t.Helper() + m, _, cfgPath := miscModel(t) + termrender.SetThemeStyle("graphite") + t.Cleanup(func() { termrender.SetThemeStyle("graphite") }) + sendLine(m, "/theme") + if m.quick == nil { + t.Fatal("/theme did not open the picker") + } + return m, cfgPath +} + +func TestThemePickerPreviewsWhileMovingAndWritesNothing(t *testing.T) { + m, cfgPath := themePickerModel(t) + before := termrender.ActiveTheme() + run(m, press(m, "down")) + moved := m.quick.shown()[m.quick.sel] + if got := termrender.ActiveTheme(); got.Style == before.Style && got.Name == before.Name { + t.Fatalf("moving to %q did not preview it: still %s/%s", moved.ID, got.Name, got.Style) + } + if _, err := os.Stat(cfgPath); err == nil { + t.Fatal("browsing wrote the configuration") + } + run(m, press(m, "down")) + run(m, press(m, "up")) + if _, err := os.Stat(cfgPath); err == nil { + t.Fatal("browsing wrote the configuration") + } +} + +func TestThemePickerEscapeRestoresTheOriginal(t *testing.T) { + m, cfgPath := themePickerModel(t) + before := termrender.ActiveTheme() + for range 3 { + run(m, press(m, "down")) + } + run(m, press(m, "esc")) + if m.quick != nil { + t.Fatal("Escape left the picker open") + } + if got := termrender.ActiveTheme(); got != before { + t.Fatalf("Escape left %s/%s, want %s/%s", got.Name, got.Style, before.Name, before.Style) + } + if _, err := os.Stat(cfgPath); err == nil { + t.Fatal("cancelling wrote the configuration") + } +} + +func TestThemePickerEnterKeepsTheChoiceAndStoresIt(t *testing.T) { + m, cfgPath := themePickerModel(t) + for m.quick.shown()[m.quick.sel].ID != "glacier" { + run(m, press(m, "down")) + } + run(m, press(m, "enter")) + if m.quick != nil { + t.Fatal("Enter left the picker open") + } + if got := termrender.ActiveTheme(); got.Style != "glacier" || got.Name != "light" { + t.Fatalf("active = %s/%s, want light/glacier", got.Name, got.Style) + } + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "glacier") { + t.Fatalf("choice not stored:\n%s", raw) + } +} + +func TestThemePickerFilterPreviewsTheFirstMatch(t *testing.T) { + m, _ := themePickerModel(t) + typeText(m, "glac") + if got := termrender.ActiveTheme(); got.Style != "glacier" { + t.Fatalf("filtering to glacier previewed %s", got.Style) + } + run(m, press(m, "esc")) + if got := termrender.ActiveTheme(); got.Style != "graphite" { + t.Fatalf("Escape left %s, want graphite", got.Style) + } +} diff --git a/internal/frontend/tui/quickpick.go b/internal/frontend/tui/quickpick.go index cdce7ec000..4cc25dd9a7 100644 --- a/internal/frontend/tui/quickpick.go +++ b/internal/frontend/tui/quickpick.go @@ -26,6 +26,10 @@ type quickPicker struct { query string sel int pick func(chooseItem) tea.Cmd + // onMove, when set, sees the row under the cursor each time it changes, and + // onCancel runs when the picker closes without a choice: a live preview. + onMove func(chooseItem) + onCancel func() } func newQuickPicker(title string, items []chooseItem, pick func(chooseItem) tea.Cmd) *quickPicker { @@ -61,6 +65,10 @@ func (m *model) quickKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch k := msg.String(); k { case "esc", "ctrl+c": m.quick = nil + if p.onCancel != nil { + p.onCancel() + } + return nil, true case "up", "ctrl+p": p.sel = max(p.sel-1, 0) case "down", "tab", "ctrl+n": @@ -80,6 +88,11 @@ func (m *model) quickKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { p.query, p.sel = p.query+t, 0 } } + if p.onMove != nil { + if rows := p.shown(); p.sel < len(rows) { + p.onMove(rows[p.sel]) + } + } return nil, true } diff --git a/release-notes/studio/unreleased/12985.md b/release-notes/studio/unreleased/12985.md new file mode 100644 index 0000000000..4d4f0eba7a --- /dev/null +++ b/release-notes/studio/unreleased/12985.md @@ -0,0 +1,2 @@ +- 终端界面的 `/theme` 现在打开选择器:光标移到哪一行就即时预览,Enter 保留,Esc 还原;`/theme list` 仍列出名称。关联 #12985。 +- In the terminal UI, `/theme` now opens a picker that previews each row as you move, keeps it on Enter and restores the original on Esc; `/theme list` still prints the names. Refs #12985.