feat(tui): preview each theme as the /theme picker moves

A bare /theme opens the quick picker; moving repaints, Enter keeps and stores the
row, Escape restores the original. /theme list keeps the old listing.
This commit is contained in:
esengine committed 2026-10-13 19:29:07 -07:00
1 parent 903aa09cdc
commit b5a79cda33
10 files changed
+173 -8

No files matched your search

+6 -1
View File
@@ -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.
+7 -2
View File
@@ -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>` 直接切换到列表中第 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` | 切换应用内鼠标选区、滚动条和滚轮处理。 |
+1 -1
View File
@@ -235,7 +235,7 @@ var English = Messages{
OutputStyleNone: "no output styles available",
ThemeHeader: "themes:",
ThemeHint: "switch with /theme <auto|light|dark|style>",
ThemeHint: "pick with /theme, or switch with /theme <auto|light|dark|style>",
ThemeChangedFmt: "theme switched to %s / %s",
ThemeUnknownFmt: "unknown theme %q",
LanguageHeader: "languages:",
+1 -1
View File
@@ -235,7 +235,7 @@ var Chinese = Messages{
OutputStyleNone: "没有可用的输出风格",
ThemeHeader: "主题:",
ThemeHint: "使用 /theme <auto|light|dark|style> 切换",
ThemeHint: "用 /theme 选择,或用 /theme <auto|light|dark|style> 切换",
ThemeChangedFmt: "已切换主题为 %s / %s",
ThemeUnknownFmt: "未知主题 %q",
LanguageHeader: "语言:",
+1 -1
View File
@@ -232,7 +232,7 @@ var ChineseTraditional = Messages{
OutputStyleNone: "沒有可用的輸出風格",
ThemeHeader: "主題:",
ThemeHint: "使用 /theme <auto|light|dark|style> 切換",
ThemeHint: "用 /theme 選擇,或用 /theme <auto|light|dark|style> 切換",
ThemeChangedFmt: "已切換主題為 %s / %s",
ThemeUnknownFmt: "未知主題 %q",
LanguageHeader: "語言:",
@@ -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
}
+36 -1
View File
@@ -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
}
+79 -1
View File
@@ -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)
}
}
+13
View File
@@ -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
}
+2
View File
@@ -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.