feat(cli): add vi command mode to the composer prompt

Add an opt-in vi command mode to the composer prompt ([ui].commandmode =
"vi"). At the prompt Esc enters command mode for navigation and editing
rather than interrupting a turn; command mode owns the keys h l j k 0 $ ^
i a x I A p D and the mode switch.

The composer lives in internal/frontend/tui, so the mode is implemented
there against its textarea: vimode.go carries the command-mode keys and
the mode switch, input.go routes keys through it, and ask.go and
prompts.go keep Esc from dismissing an open question card or deciding an
approval card. Command mode consumes any key carrying printable text, so
a CJK/IME character is eaten rather than inserted.

Because the interrupt key now depends on the composer mode, the
running-turn hint can no longer name a fixed key: with ui.commandmode off
Esc stops a running turn, while in vi mode only ^C interrupts, since Esc
is a mode switch whether or not a turn runs. The thinking
status line therefore names the key that actually interrupts — "(Esc
cancels)" or "(Ctrl+C cancels)" — across en/zh/zh-TW, and the footer names
the composer's input mode, NORMAL in command mode and INSERT otherwise.
The cancelling line drops its "Ctrl+C exits" hint in vi mode, where Ctrl+C
re-cancels instead of exiting, and the approval, ask and plan card hints —
plus the plan choice list — drop their Esc mention there, where Esc is
ignored.

At the prompt, ^C with a non-empty prompt clears it and leaves shell mode,
in vi mode and out. In vi mode it first saves the trimmed draft to the
cmdline history when there is one, as send() does, so an accidental
interrupt never loses it (Up recalls it); a whitespace-only prompt is
cleared without being saved. In vi mode Esc no longer decides an approval
or ask card, where it is pressed out of habit; a row key or Ctrl+C decides
instead, and Ctrl+C still cancels a running turn even while an ask answer
is being typed.

[ui].commandmode is user-global only: a project reasonix.toml cannot set
it, so a cloned repo never rebinds the user's keys.
This commit is contained in:
Stephen R. van den Berg committed 2026-09-29 01:01:58 +02:00
1 parent 562cdd3f7e
commit ee786a1ca6
21 files changed
+980 -29

No files matched your search

+1
View File
@@ -783,6 +783,7 @@ Mode and display shortcuts:
| `Shift+Tab` | Cycles Ask → Auto → Plan → Ask | YOLO remains outside this composer-mode cycle; the footer shows the active mode. |
| `Ctrl+Y` | Toggles YOLO on/off | Turning YOLO off restores the previous Ask/Auto base when known. Terminals that forward Command/Super may also send `Cmd+Y`, but `Ctrl+Y` is the reliable terminal shortcut. |
| `--yolo`, `--dangerously-skip-permissions` | Starts chat in YOLO | Same runtime mode as `Ctrl+Y`. |
| `[ui].commandmode = "vi"` | Opts the composer into a vi command mode | User/global only; a project `reasonix.toml` cannot set it, so a cloned repo never rebinds the user's keys. `Esc` enters command mode, a running turn or not; only `Ctrl+C` interrupts. `Ctrl+C` with a non-empty prompt clears it and leaves shell mode, in vi mode and out; in vi mode it first saves the trimmed draft to the prompt history, so `Up` recalls it. Unset or `""` keeps the default, where `Esc` stops a running turn. The footer names the current input mode, `NORMAL` in command mode and `INSERT` otherwise. |
| `/preset [balanced|delivery]` | Shows or switches the current session's execution setting (执行设定) | `/work-mode` and `/profile` are compatibility aliases (`economy` → `light`). Switching updates the execution setting in place without rebuilding the controller; blocked while a turn, approval, or background job is active. |
| `/theme [auto|light|dark|style]` | Shows or switches the CLI theme | Bare `/theme` lists background modes and named accent palettes. The choice is saved to the user config; `REASONIX_THEME` and `REASONIX_THEME_STYLE` can override it for one run. |
| `Ctrl+O` | Toggles verbose reasoning display | Also available through `/verbose`. |
+1
View File
@@ -613,6 +613,7 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u
| `Shift+Tab` | 按 Ask → Auto → Plan → Ask 循环 | YOLO 不进入这个输入模式循环;底部状态栏会显示当前模式。 |
| `Ctrl+Y` | 切换 YOLO 开/关 | 关闭 YOLO 时会尽量恢复之前的 Ask/Auto 基底。终端若能转发 Command/Super,也可能识别 `Cmd+Y`,但稳定可用的是 `Ctrl+Y`。 |
| `--yolo`、`--dangerously-skip-permissions` | 启动时进入 YOLO | 和 `Ctrl+Y` 是同一个运行时模式。 |
| `[ui].commandmode = "vi"` | 让输入框进入 vi 命令模式 | 仅限用户全局配置;项目 `reasonix.toml` 无法设置,克隆的仓库因此不会改写用户的按键。无论回合是否在运行,`Esc` 都进入命令模式;只有 `Ctrl+C` 会中断。`Ctrl+C` 在提示符非空时会清空输入框并退出 shell 模式,vi 模式与默认模式一致;vi 模式下会先把去掉首尾空白的草稿存入提示历史,可用 `Up` 召回。未设置或 `""` 保持默认,即 `Esc` 中止正在运行的回合。页脚会显示当前的输入模式:命令模式为 `NORMAL`,否则为 `INSERT`。 |
| `/preset [light|balanced|delivery]` | 查看或切换当前会话的执行设定 | `/work-mode` 与 `/profile` 是兼容别名(`economy` → `light`)。切换就地更新执行设定、不重建 Controller;有回合、审批或后台任务时会拒绝。 |
| `/theme [auto|light|dark|style]` | 查看或切换 CLI 主题 | 不带参数会列出背景模式和命名配色。选择会保存到用户配置;单次运行可用 `REASONIX_THEME` 和 `REASONIX_THEME_STYLE` 覆盖。 |
| `Ctrl+O` | 切换详细 reasoning 显示 | 也可通过 `/verbose` 使用。 |
+15 -10
View File
@@ -84,7 +84,7 @@ type Messages struct {
// chat TUI status line / approval banner.
ChatThinking string // live reasoning marker label, e.g. "thinking…"
ChatThoughtForFmt string // collapsed reasoning summary, "%d" = elapsed s
ChatStatusThinkingFmt string // "%s thinking… (%ds · <cancel hint>)" — %s = spinner, %d = elapsed s
ChatStatusThinkingFmt string // "%s thinking… (%ds · %s cancels)" — %s = spinner, %d = elapsed s, %s = interrupt key
TurnPhaseWorking string // host turn_phase label: working
TurnPhaseChecking string // host turn_phase label: checking
TurnPhaseVerifying string // host turn_phase label: verifying
@@ -106,6 +106,7 @@ type Messages struct {
ChatSubagentPreviewLabel string // verbose preview marker ("▎")
ChatStatusRetryingFmt string // "%s retrying (%d/%d)…" — %s = spinner, %d/%d = attempt/max
ChatStatusCancellingFmt string // "%s stopping… (%ds · Ctrl+C exits)" — %s = spinner, %d = elapsed s
ChatStatusCancellingViFmt string // "%s stopping… (%ds)" — vi mode: Ctrl+C re-cancels instead of exiting, so the hint drops the exit key
ChatStatusIdle string // shortcuts hint when idle
ChatStatusYoloIdle string // shortcuts hint when idle in YOLO/bypass mode
ChatStatusCycleHint string // plan-toggle shortcut hint shown when no modal prompt owns the status row
@@ -123,10 +124,13 @@ type Messages struct {
ChatStatusCacheNowFmt string // cache status tag, "%s" = latest-turn hit rate with percent sign
ChatStatusCacheAvgFmt string // cache status tag, "%s" = session-average hit rate with percent sign
ChatStatusPlanApproval string // shortcuts hint while a plan is pending
ChatStatusPlanApprovalVi string // vi mode: Esc is ignored on the plan card, so the hint drops it
PlanApprovalPrompt string // one-line "plan above is ready" banner shown above the input
ApprovalChoiceHint string // keys under an approval's choices
PlanApprovalChoices string // start / revise / exit-without-executing choice list
PlanApprovalChoicesVi string // vi mode: Esc is ignored on the plan card, so the hint drops it
ChatStatusToolApproval string // shortcuts hint while a tool call awaits approval
ChatStatusToolApprovalVi string // vi mode: Esc is ignored on the approval card, so the hint drops it
ToolApprovalPromptFmt string // approval banner — tool, subject suffix, source/intent detail, choices
ToolApprovalChoices string // standard approval choice list
BashPrefixChoices string // approval choice list when a bash prefix can be granted
@@ -182,15 +186,16 @@ type Messages struct {
DiffFoldDisabled string // notice when /diff-fold disables folding (shows all lines)
// `ask` tool question card.
AskTypeSomething string // the "type your own answer" option label
AskTypingHint string // shown on that row while entering free text
AskNoteHint string // shown under a single-choice pick while typing its note
AskChatInstead string // the "don't pick, just chat" option label
ChatStatusQuestion string // shortcuts hint while a question card is open
StatusResumePicker string // status tag while the resume picker is open (e.g. "select session")
AskSubmitTitle string // submit-tab title in the ask tool question card
AskUnanswered string // placeholder for an unanswered ask question
AskSubmitHint string // submit-tab keyboard hint
AskTypeSomething string // the "type your own answer" option label
AskTypingHint string // shown on that row while entering free text
AskNoteHint string // shown under a single-choice pick while typing its note
AskChatInstead string // the "don't pick, just chat" option label
ChatStatusQuestion string // shortcuts hint while a question card is open
ChatStatusQuestionVi string // vi mode: Esc is ignored on the ask card, so the hint names Ctrl+C
StatusResumePicker string // status tag while the resume picker is open (e.g. "select session")
AskSubmitTitle string // submit-tab title in the ask tool question card
AskUnanswered string // placeholder for an unanswered ask question
AskSubmitHint string // submit-tab keyboard hint
// output style listing (/output-style).
OutputStyleNone string // no styles available
+6 -1
View File
@@ -58,7 +58,7 @@ var English = Messages{
ChatThinking: "thinking…",
ChatThoughtForFmt: "thought for %ds",
ChatStatusThinkingFmt: "%s thinking… (%ds · Esc cancels)",
ChatStatusThinkingFmt: "%s thinking… (%ds · %s cancels)",
TurnPhaseWorking: "working",
TurnPhaseChecking: "checking",
TurnPhaseVerifying: "verifying",
@@ -80,6 +80,7 @@ var English = Messages{
ChatSubagentPreviewLabel: "▎",
ChatStatusRetryingFmt: "%s retrying (%d/%d)… (Esc cancels)",
ChatStatusCancellingFmt: "%s stopping… (%ds · Ctrl+C exits)",
ChatStatusCancellingViFmt: "%s stopping… (%ds)",
ChatStatusIdle: "ready",
ChatStatusYoloIdle: "tool approvals skipped",
ChatStatusCycleHint: "Shift+Tab ask/auto/plan · Ctrl+Y YOLO",
@@ -97,15 +98,19 @@ var English = Messages{
ChatStatusCacheNowFmt: "turn hit %s",
ChatStatusCacheAvgFmt: "avg %s",
ChatStatusPlanApproval: "1 execute · 2 revise · 3 exit without executing · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls",
ChatStatusPlanApprovalVi: "1 execute · 2 revise · 3 exit without executing · n keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls",
PlanApprovalPrompt: "Plan ready above — choose what to do next",
ApprovalChoiceHint: "↑/↓ navigate · Enter select · y/a/p/n shortcuts",
PlanApprovalChoices: "1. Start execution\n2. Revise plan (keep planning)\n3. Exit without executing\nChoose [1/2/3] (y starts; n/Esc keeps planning)",
PlanApprovalChoicesVi: "1. Start execution\n2. Revise plan (keep planning)\n3. Exit without executing\nChoose [1/2/3] (y starts; n keeps planning)",
ChatStatusToolApproval: "1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn",
ChatStatusToolApprovalVi: "1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n deny · Ctrl-C cancels turn",
AskTypeSomething: "Type something else",
AskTypingHint: "type below, Enter to confirm",
AskNoteHint: "note for this choice below, Enter to confirm",
AskChatInstead: "None — just chat",
ChatStatusQuestion: "↑/↓ move · number to pick · space multi · Tab pick + note · Enter confirm · ←/→ switch · Esc cancel",
ChatStatusQuestionVi: "↑/↓ move · number to pick · space multi · Tab pick + note · Enter confirm · ←/→ switch · Ctrl+C cancel",
StatusResumePicker: "↑/↓ move · Enter resume · Esc cancel",
AskSubmitTitle: "Submit answers",
AskUnanswered: "(unanswered)",
+6 -1
View File
@@ -59,7 +59,7 @@ var Chinese = Messages{
ChatThinking: "思考中…",
ChatThoughtForFmt: "思考了 %d 秒",
ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)",
ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · %s 取消)",
TurnPhaseWorking: "工作中",
TurnPhaseChecking: "检查中",
TurnPhaseVerifying: "验证中",
@@ -81,6 +81,7 @@ var Chinese = Messages{
ChatSubagentPreviewLabel: "▎",
ChatStatusRetryingFmt: "%s 正在重试 (%d/%d)… (Esc 取消)",
ChatStatusCancellingFmt: "%s 正在停止… (%d 秒 · Ctrl+C 退出)",
ChatStatusCancellingViFmt: "%s 正在停止… (%d 秒)",
ChatStatusIdle: "就绪",
ChatStatusYoloIdle: "已跳过工具批准",
ChatStatusCycleHint: "Shift+Tab 循环询问/自动/计划 · Ctrl+Y YOLO",
@@ -98,15 +99,19 @@ var Chinese = Messages{
ChatStatusCacheNowFmt: "本次命中 %s",
ChatStatusCacheAvgFmt: "平均 %s",
ChatStatusPlanApproval: "1 开始执行 · 2 修改计划 · 3 暂不执行并退出 · n/Esc 继续规划 · PgUp/PgDn/Ctrl+Home/End 滚动",
ChatStatusPlanApprovalVi: "1 开始执行 · 2 修改计划 · 3 暂不执行并退出 · n 继续规划 · PgUp/PgDn/Ctrl+Home/End 滚动",
PlanApprovalPrompt: "计划已生成(见上方)— 请选择下一步操作",
ApprovalChoiceHint: "↑/↓ 选择 · Enter 确认 · y/a/p/n 快捷键",
PlanApprovalChoices: "1. 开始执行\n2. 修改计划(继续规划)\n3. 暂不执行,退出计划模式\n选择 [1/2/3](y 开始执行;n/Esc 继续规划)",
PlanApprovalChoicesVi: "1. 开始执行\n2. 修改计划(继续规划)\n3. 暂不执行,退出计划模式\n选择 [1/2/3](y 开始执行;n 继续规划)",
ChatStatusToolApproval: "1 本次允许 · 2 本会话允许此范围 · 提供时 3/4 为前缀或保存 · n/Esc 拒绝 · Ctrl-C 取消本轮",
ChatStatusToolApprovalVi: "1 本次允许 · 2 本会话允许此范围 · 提供时 3/4 为前缀或保存 · n 拒绝 · Ctrl-C 取消本轮",
AskTypeSomething: "自己输入",
AskTypingHint: "输入后按 Enter 确认",
AskNoteHint: "在下方为此项补充说明,Enter 确认",
AskChatInstead: "先不选择,直接回复",
ChatStatusQuestion: "↑/↓ 选 · 数字快选 · 空格多选 · Tab 选中并补充说明 · Enter 确认 · ←/→ 切换问题 · Esc 取消",
ChatStatusQuestionVi: "↑/↓ 选 · 数字快选 · 空格多选 · Tab 选中并补充说明 · Enter 确认 · ←/→ 切换问题 · Ctrl+C 取消",
StatusResumePicker: "↑/↓ 移动 · Enter 恢复 · Esc 取消",
AskSubmitTitle: "提交答案",
AskUnanswered: "(未答)",
+6 -1
View File
@@ -55,7 +55,7 @@ var ChineseTraditional = Messages{
ChatThinking: "思考中…",
ChatThoughtForFmt: "思考了 %d 秒",
ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)",
ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · %s 取消)",
TurnPhaseWorking: "工作中",
TurnPhaseChecking: "檢查中",
TurnPhaseVerifying: "驗證中",
@@ -77,6 +77,7 @@ var ChineseTraditional = Messages{
ChatSubagentPreviewLabel: "▎",
ChatStatusRetryingFmt: "%s 正在重試 (%d/%d)… (Esc 取消)",
ChatStatusCancellingFmt: "%s 正在停止… (%d 秒 · Ctrl+C 退出)",
ChatStatusCancellingViFmt: "%s 正在停止… (%d 秒)",
ChatStatusIdle: "就緒",
ChatStatusYoloIdle: "已跳過核准",
ChatStatusCycleHint: "Shift+Tab 循環詢問/自動/計畫 · Ctrl+Y YOLO",
@@ -94,15 +95,19 @@ var ChineseTraditional = Messages{
ChatStatusCacheNowFmt: "本次命中 %s",
ChatStatusCacheAvgFmt: "平均 %s",
ChatStatusPlanApproval: "1 開始執行 · 2 修改計畫 · 3 暫不執行並退出 · n/Esc 繼續規劃 · PgUp/PgDn/Ctrl+Home/End 捲動",
ChatStatusPlanApprovalVi: "1 開始執行 · 2 修改計畫 · 3 暫不執行並退出 · n 繼續規劃 · PgUp/PgDn/Ctrl+Home/End 捲動",
PlanApprovalPrompt: "計畫已生成(見上方)— 請選擇下一步操作",
ApprovalChoiceHint: "↑/↓ 選擇 · Enter 確認 · y/a/p/n 快速鍵",
PlanApprovalChoices: "1. 開始執行\n2. 修改計畫(繼續規劃)\n3. 暫不執行,退出計畫模式\n選擇 [1/2/3](y 開始執行;n/Esc 繼續規劃)",
PlanApprovalChoicesVi: "1. 開始執行\n2. 修改計畫(繼續規劃)\n3. 暫不執行,退出計畫模式\n選擇 [1/2/3](y 開始執行;n 繼續規劃)",
ChatStatusToolApproval: "1 本次允許 · 2 本會話允許此範圍 · 提供時 3/4 為前綴或儲存 · n/Esc 拒絕 · Ctrl-C 取消本輪",
ChatStatusToolApprovalVi: "1 本次允許 · 2 本會話允許此範圍 · 提供時 3/4 為前綴或儲存 · n 拒絕 · Ctrl-C 取消本輪",
AskTypeSomething: "自己輸入",
AskTypingHint: "輸入後按 Enter 確認",
AskNoteHint: "在下方為此項補充說明,Enter 確認",
AskChatInstead: "先不選擇,直接回覆",
ChatStatusQuestion: "↑/↓ 選 · 數字快選 · 空格多選 · Tab 選中並補充說明 · Enter 確認 · ←/→ 切換問題 · Esc 取消",
ChatStatusQuestionVi: "↑/↓ 選 · 數字快選 · 空格多選 · Tab 選中並補充說明 · Enter 確認 · ←/→ 切換問題 · Ctrl+C 取消",
StatusResumePicker: "↑/↓ 移動 · Enter 恢復 · Esc 取消",
AskSubmitTitle: "提交答案",
AskUnanswered: "(未答)",
@@ -0,0 +1,41 @@
package config
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func loadWithCommandMode(t *testing.T, user, project string) *Config {
t.Helper()
home := testenv.TempDir(t)
root := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
if user != "" {
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[ui]\ncommandmode = \""+user+"\"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[ui]\ncommandmode = \""+project+"\"\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadForRootReadOnly(root)
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestProjectCommandModeIsIgnored(t *testing.T) {
if loadWithCommandMode(t, "", "vi").UICommandMode() {
t.Fatal("a project-only commandmode enabled vi mode; the user's key bindings must win")
}
}
func TestUserCommandModeWinsOverProject(t *testing.T) {
if !loadWithCommandMode(t, "vi", "").UICommandMode() {
t.Fatal("the user's commandmode = vi did not survive the project merge")
}
}
+8
View File
@@ -250,6 +250,7 @@ type UIConfig struct {
ShowReasoning bool `toml:"show_reasoning"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed
ShowTurnUsage bool `toml:"show_turn_usage"` // show per-request token/cost receipts in the CLI/TUI transcript
CursorShape string `toml:"cursor_shape"` // block|underline|bar; empty defaults to bar
CommandMode string `toml:"commandmode"` // ""|vi; vi gives the composer a vi command mode (empty = insert-always)
}
// CLIConfig controls user-global native CLI behavior. It is separate from
@@ -329,6 +330,13 @@ func (c *Config) UICursorShape() string {
}
}
// UICommandMode reports whether the composer should use a vi-style command
// mode. Only the value "vi" enables it; any other value keeps the default
// insert-always editing.
func (c *Config) UICommandMode() bool {
return strings.ToLower(strings.TrimSpace(c.UI.CommandMode)) == "vi"
}
func normalizeThemeStyle(style string) string {
switch strings.ToLower(strings.TrimSpace(style)) {
case "graphite", "aurora", "slate", "carbon", "nocturne", "amber", "ember", "midnight", "sandstone", "porcelain", "linen", "glacier":
+19
View File
@@ -134,6 +134,25 @@ func TestUICursorShapeNormalizes(t *testing.T) {
}
}
func TestUICommandMode(t *testing.T) {
c := Default()
for _, tt := range []struct {
in string
want bool
}{
{"", false},
{"VI", true},
{" vi ", true},
{"emacs", false},
{"unknown", false},
} {
c.UI.CommandMode = tt.in
if got := c.UICommandMode(); got != tt.want {
t.Errorf("UICommandMode(%q) = %v, want %v", tt.in, got, tt.want)
}
}
}
func TestUICloseBehaviorNormalizes(t *testing.T) {
c := Default()
for _, tt := range []struct {
+5 -5
View File
@@ -128,7 +128,7 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
globalDesktopLanguage := cfg.Desktop.Language
globalPricingCurrency := cfg.Desktop.Currency
globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency
globalTelemetry, globalStatusline, globalProgressWatch := cfg.Telemetry, cfg.Statusline, cfg.ProgressWatch
globalTelemetry, globalStatusline, globalProgressWatch, globalUICommandMode := cfg.Telemetry, cfg.Statusline, cfg.ProgressWatch, cfg.UI.CommandMode
tomlSources = append(tomlSources, projectTOML)
projectMeta, err := mergeTOML(cfg, projectTOML)
@@ -163,10 +163,10 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
cfg.Desktop.Language = globalDesktopLanguage
cfg.Desktop.Currency = globalPricingCurrency
cfg.Billing.DisplayCurrency = globalBillingDisplayCurrency
// Telemetry is a user-global privacy choice and a statusline a command the TUI
// runs unprompted: project config sets neither, even with no global value.
// The progress watch decides when the user's own runs pause, so it is theirs.
cfg.Telemetry, cfg.Statusline, cfg.ProgressWatch = globalTelemetry, globalStatusline, globalProgressWatch
// Telemetry, statusline and progress watch are user-global: a project sets
// none, even with no global value. The composer's key bindings are the user's
// too, so a cloned repo cannot rebind Esc through a project [ui] commandmode.
cfg.Telemetry, cfg.Statusline, cfg.ProgressWatch, cfg.UI.CommandMode = globalTelemetry, globalStatusline, globalProgressWatch, globalUICommandMode
// TOML decoding replaces [[plugins]] wholesale, so cfg.Plugins now holds
// only the last file's. Re-merge by name across all sources (later wins) so a
// project reasonix.toml doesn't drop the global config's MCP servers.
+1
View File
@@ -119,6 +119,7 @@ func runTUI(args []string, version string) int {
PickAmong: ambiguousSessionPaths(ambiguous),
Inline: *f.inline,
HideTurnUsage: cfg != nil && !cfg.UI.ShowTurnUsage,
CommandMode: cfg != nil && cfg.UICommandMode(),
Statusline: statuslineRunner(cfg),
})
if err != nil {
+10
View File
@@ -60,6 +60,11 @@ func (m *model) answerAsk(it *Item, k string) (tea.Cmd, bool) {
return m.typeAnswer(it, k)
}
if k == "esc" {
// vi mode ignores Esc on the ask card: it does not dismiss. Only ^C
// cancels the ask.
if m.viActive() {
return nil, true
}
return m.declineAsk(it), true
}
tabs := len(qs)
@@ -156,6 +161,11 @@ func (m *model) typeAnswer(it *Item, k string) (tea.Cmd, bool) {
st := m.ask
switch k {
case "esc":
// vi mode ignores Esc while typing an ask answer: it does not back out
// and discard the draft. Only ^C cancels the ask.
if m.viActive() {
return nil, true
}
st.entry = entryNone
case "enter":
text := strings.TrimSpace(m.composer.Value())
+8 -2
View File
@@ -22,6 +22,8 @@ var (
tagPlanColor = termrender.NewColor("#2563eb", 27)
tagYoloColor = termrender.NewColor("#e5484d", 167)
tagShellColor = termrender.NewColor("#16a34a", 71)
tagViNormal = termrender.NewColor("#475569", 240)
tagViInsert = termrender.NewColor("#0d9488", 30)
tagLight = termrender.NewColor("#ffffff", 231)
tagDark = termrender.NewColor("#111827", 234)
)
@@ -101,11 +103,15 @@ func (m *model) workingLine() string {
var line string
switch label := phaseLabel(m.tr.Phase); {
case m.cancelling:
line = fmt.Sprintf(" "+i18n.M.ChatStatusCancellingFmt, mark, secs)
stop := i18n.M.ChatStatusCancellingFmt
if m.viActive() {
stop = i18n.M.ChatStatusCancellingViFmt
}
line = fmt.Sprintf(" "+stop, mark, secs)
case label != "":
line = fmt.Sprintf(" %s %s · %ds", mark, label, secs)
default:
line = fmt.Sprintf(" "+i18n.M.ChatStatusThinkingFmt, mark, secs)
line = fmt.Sprintf(" "+i18n.M.ChatStatusThinkingFmt, mark, secs, m.interruptKey())
}
if m.tr.TurnOut > 0 {
line += " · ↓" + shortTokens(m.tr.TurnOut)
+9 -4
View File
@@ -29,7 +29,12 @@ func footerMetric(label, value string) string {
// model on the right, a quiet rule, then the session's telemetry.
func (m *model) statusBlock() []string {
width := max(m.width-1, 1)
first := layoutSides(footerIndent+m.modeTag()+" · "+m.stateText(), m.modelGroup(), width)
left := m.modeTag()
if vi := m.viModeTag(); vi != "" {
left += " · " + vi
}
left += " · " + m.stateText()
first := layoutSides(footerIndent+left, m.modelGroup(), width)
rows := strings.Split(first, "\n")
groups := m.telemetry()
if m.statusline != "" {
@@ -88,11 +93,11 @@ func (m *model) stateText() string {
case m.flashText() != "":
return termrender.Green(m.flashText()) + tag
case open != nil && open.Kind == ItemAsk:
return footerLabel(i18n.M.ChatStatusQuestion)
return footerLabel(m.viHint(i18n.M.ChatStatusQuestion, i18n.M.ChatStatusQuestionVi))
case open != nil && open.Approval.Kind == "plan":
return footerLabel(i18n.M.ChatStatusPlanApproval)
return footerLabel(m.viHint(i18n.M.ChatStatusPlanApproval, i18n.M.ChatStatusPlanApprovalVi))
case open != nil:
return footerLabel(i18n.M.ChatStatusToolApproval)
return footerLabel(m.viHint(i18n.M.ChatStatusToolApproval, i18n.M.ChatStatusToolApprovalVi))
case !m.quitArmedAt.IsZero() && time.Since(m.quitArmedAt) < quitArmWindow:
return i18n.M.CtrlCQuitHint
case m.shell:
+6 -1
View File
@@ -82,6 +82,9 @@ func (m *model) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if cmd, handled := m.menuKey(msg.String()); handled {
return m, cmd
}
if cmd, handled := m.viKey(msg); handled {
return m, cmd
}
empty := m.composer.Value() == ""
switch msg.String() {
case "tab":
@@ -106,6 +109,7 @@ func (m *model) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, m.call("cancel", m.client.Cancel)
case !empty:
m.composer.Reset()
m.shell = false
return m, nil
case time.Since(m.quitArmedAt) < quitArmWindow:
return m, tea.Quit
@@ -202,7 +206,7 @@ func (m *model) promptKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
if cmd, handled := answer(open, msg.String()); handled {
return cmd, true
}
if open.Kind == ItemAsk && m.ask != nil && m.ask.entering() {
if open.Kind == ItemAsk && m.ask != nil && m.ask.entering() && msg.String() != "ctrl+c" {
var cmd tea.Cmd
m.composer, cmd = m.composer.Update(msg)
return cmd, true
@@ -238,6 +242,7 @@ func (m *model) send(steer bool) tea.Cmd {
m.history = append(m.history, display)
m.histAt = len(m.history)
m.composer.Reset()
m.viCmd = false
if m.shell {
m.shell = false
display, text = "! "+display, "!"+text
+4
View File
@@ -33,6 +33,9 @@ type Options struct {
Inline bool
// HideTurnUsage keeps each request's token and cost receipt off the transcript.
HideTurnUsage bool
// CommandMode gives the composer a vi command mode: Esc enters command
// mode, a running turn or not, and only Ctrl+C interrupts.
CommandMode bool
// Statusline, when set, turns the footer's context JSON into one line
// that replaces the telemetry row; "" keeps the built-in row.
Statusline func(ctx context.Context, stdin string) string
@@ -71,6 +74,7 @@ type model struct {
history []string
histAt int
draft string // what the composer held when a history walk began
viCmd bool // idle composer is in vi command (normal) mode, not insert
pastes pasteStore
status Status
quitArmedAt time.Time
+29
View File
@@ -274,6 +274,35 @@ func TestCtrlCTwiceQuitsWhenIdle(t *testing.T) {
}
}
// ^C clears the prompt and leaves shell mode, matching send(): the next input is
// not silently still a `!` shell command.
func TestCtrlCLeavesShellModeWhenNotVi(t *testing.T) {
m, _ := testModel(t)
m.shell = true
m.composer.SetValue("ls")
press(m, "ctrl+c")
if got := m.composer.Value(); got != "" {
t.Fatalf("prompt after ^C = %q, want empty", got)
}
if m.shell {
t.Fatal("^C cleared the prompt but left shell mode on")
}
}
// ^C cancels a running turn even while an ask answer is being typed, where Esc
// is ignored in vi mode: the composer must not swallow the universal interrupt.
func TestCtrlCCancelsWhileTypingAnAskAnswer(t *testing.T) {
m, k := testModel(t)
apply(m, eventwire.Event{Kind: "turn_started"})
apply(m, askEvent())
m.openAsk(m.tr.OpenPrompt())
m.ask.entry = entryAnswer
run(m, press(m, "ctrl+c"))
if calls := strings.Join(k.seen(), "\n"); !strings.Contains(calls, "POST /cancel") {
t.Fatalf("^C while typing an ask answer did not cancel:\n%s", calls)
}
}
func askEvent() eventwire.Event {
return eventwire.Event{Kind: "ask_request", Ask: &eventwire.Ask{ID: "ask1", Questions: []eventwire.AskQuestion{
{ID: "q1", Prompt: "Which database?", Options: []eventwire.AskOption{{Label: "Postgres"}, {Label: "SQLite"}}},
+9 -4
View File
@@ -24,9 +24,9 @@ type approvalChoice struct {
// approvalChoices lists the answers the host said it will honour, in the
// catalog's order, labelled from the catalog.
func approvalChoices(a *eventwire.Approval) []approvalChoice {
func (m *model) approvalChoices(a *eventwire.Approval) []approvalChoice {
if a.Kind == "plan" {
l := choiceLabels(i18n.M.PlanApprovalChoices, 3)
l := choiceLabels(m.viHint(i18n.M.PlanApprovalChoices, i18n.M.PlanApprovalChoicesVi), 3)
return []approvalChoice{
{label: l[0], key: "y", verdict: "start_execution"},
{label: l[1], key: "n", verdict: "revise_plan"},
@@ -87,7 +87,7 @@ func (m *model) approvalRow(it *Item) int {
// answerApproval takes the keys an approval panel owns: the cursor, enter,
// a row's number, and each row's shortcut. Esc answers as the last row does.
func (m *model) answerApproval(it *Item, k string) (tea.Cmd, bool) {
choices := approvalChoices(it.Approval)
choices := m.approvalChoices(it.Approval)
row := m.approvalRow(it)
switch {
case k == "up":
@@ -99,6 +99,11 @@ func (m *model) answerApproval(it *Item, k string) (tea.Cmd, bool) {
case k == "enter":
return m.decideApproval(it, choices[row]), true
case k == "esc":
// vi mode ignores Esc on the approval card: a user hitting it out of
// habit must not reject the call. A row key or ^C decides instead.
if m.viActive() {
return nil, true
}
if it.Approval.Kind == "plan" {
return m.decideApproval(it, choices[1]), true
}
@@ -160,7 +165,7 @@ func (m *model) approvalPanel(it *Item) []string {
}
}
row := m.approvalRow(it)
for i, c := range approvalChoices(a) {
for i, c := range m.approvalChoices(a) {
text = append(text, rowLine(i == row, i+1, "", c.label, false))
}
text = append(text, termrender.Dim(i18n.M.ApprovalChoiceHint))
+293
View File
@@ -0,0 +1,293 @@
package tui
import (
"strings"
"unicode"
tea "charm.land/bubbletea/v2"
"reasonix/internal/frontend/termrender"
)
// viActive reports whether the composer uses the vi command mode, enabled by
// ui.commandmode = "vi". When off the composer edits insert-only as before.
func (m *model) viActive() bool {
return m.opts.CommandMode
}
// viInCommand reports whether the composer is currently in vi command (normal)
// mode rather than insert mode.
func (m *model) viInCommand() bool {
return m.viActive() && m.viCmd
}
// viModeTag is the footer's vi mode marker: NORMAL while command mode owns the
// keys, INSERT while the composer edits text. Empty when vi mode is off.
func (m *model) viModeTag() string {
if !m.viActive() {
return ""
}
if m.viInCommand() {
return termrender.Badge(tagViNormal, tagLight, "NORMAL")
}
return termrender.Badge(tagViInsert, tagLight, "INSERT")
}
// interruptKey names the key that cancels a running turn: Ctrl+C in vi mode,
// where Esc enters command mode instead, and Esc otherwise.
func (m *model) interruptKey() string {
if m.viActive() {
return "Ctrl+C"
}
return "Esc"
}
// viHint picks the card hint that names the keys which actually work: in vi
// mode Esc is ignored on the approval, ask and plan cards, so their hints drop
// it (or name Ctrl+C) and the plain string serves the default mode.
func (m *model) viHint(plain, vi string) string {
if m.viActive() {
return vi
}
return plain
}
// viKey takes the keys vi command mode owns: the interrupt keys whose meaning
// differs from insert mode, and the single printable keys that drive command
// mode. It reports whether the key was handled; with vi off it takes nothing.
func (m *model) viKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
if !m.viActive() {
return nil, false
}
empty := m.composer.Value() == ""
switch msg.String() {
case "esc":
// vi: Esc never interrupts a stream; it enters command mode and steps
// the caret one rune left, as leaving insert mode does in vim. Only ^C
// interrupts, a running turn or not.
if m.shell && empty {
m.shell = false
return nil, true
}
if !m.viCmd {
m.viMoveLeft()
}
m.viCmd = true
return nil, true
case "ctrl+c":
// vi: Ctrl+C interrupts a stream. With any non-empty text it clears the
// prompt and leaves shell mode, saving a trimmed draft when there is
// one; truly empty, it leaves command mode.
switch {
case m.tr.Running:
m.cancelling = true
return m.call("cancel", m.client.Cancel), true
case !empty:
if draft := strings.TrimSpace(m.composer.Value()); draft != "" {
m.history = append(m.history, draft)
m.histAt = len(m.history)
}
m.composer.Reset()
m.shell = false
m.viCmd = false
return nil, true
case m.viCmd:
m.viCmd = false
}
return nil, true
case "ctrl+d":
// vi: Ctrl+D exits only while insert-mode editing a truly empty prompt.
if empty && !m.tr.Running && !m.viCmd {
return tea.Quit, true
}
return nil, true
}
// Command-mode keys drive the caret and editing instead of inserting text;
// unrecognized ones are ignored. Every key carrying printable text is one,
// so a multi-byte character (CJK/IME) is consumed rather than inserted.
if m.viInCommand() && viPrintable(msg) {
return m.viCommandRune(msg), true
}
return nil, false
}
// viPrintable reports whether the key carries text the composer would insert,
// which command mode consumes instead. msg.Text is empty for special keys and
// modifier combinations, and holds the rune(s) for a printable key.
func viPrintable(msg tea.KeyPressMsg) bool {
for _, r := range msg.Text {
if unicode.IsPrint(r) {
return true
}
}
return false
}
// viLines splits the composer into its logical lines so command mode can move a
// caret across pasted multi-line content with rune-accurate columns.
func (m *model) viLines() [][]rune {
raw := strings.Split(m.composer.Value(), "\n")
lines := make([][]rune, 0, len(raw))
for _, l := range raw {
lines = append(lines, []rune(l))
}
return lines
}
// viMoveLeft moves the caret one rune left, crossing to the end of the previous
// logical line when it is at the very start.
func (m *model) viMoveLeft() {
col := m.composer.Column()
if col > 0 {
m.composer.SetCursorColumn(col - 1)
return
}
if m.composer.Line() <= 0 {
return
}
m.composer.CursorUp()
lines := m.viLines()
if r := m.composer.Line(); r < len(lines) {
m.composer.SetCursorColumn(len(lines[r]))
}
}
// viMoveRight moves the caret one rune right, crossing to the start of the next
// logical line when it is at the very end.
func (m *model) viMoveRight() {
lines := m.viLines()
row := m.composer.Line()
if row >= len(lines) {
return
}
if m.composer.Column() < len(lines[row]) {
m.composer.SetCursorColumn(m.composer.Column() + 1)
return
}
if row+1 < len(lines) {
m.composer.CursorDown()
m.composer.SetCursorColumn(0)
}
}
// viMoveFirstNonBlank puts the caret on the first non-whitespace rune of the
// current line (the vi "^"), falling back to column 0 on blank lines.
func (m *model) viMoveFirstNonBlank() {
lines := m.viLines()
row := m.composer.Line()
if row >= len(lines) {
return
}
line := lines[row]
col := 0
for col < len(line) && unicode.IsSpace(line[col]) {
col++
}
m.composer.SetCursorColumn(col)
}
// viCommandRune handles a single printable key while the composer is in vi
// command mode. Command-mode keys are consumed and acted on; any other key is
// ignored rather than inserting text.
func (m *model) viCommandRune(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "h":
m.viMoveLeft()
case "l":
m.viMoveRight()
case "j":
m.composer.CursorDown()
case "k":
m.composer.CursorUp()
case "0":
m.composer.SetCursorColumn(0)
case "$":
m.composer.CursorEnd()
case "^":
m.viMoveFirstNonBlank()
case "i":
m.viCmd = false
case "a":
m.viMoveRight()
m.viCmd = false
case "x":
m.viDeleteChar()
case "D":
m.viDeleteToEnd()
case "I":
m.viMoveFirstNonBlank()
m.viCmd = false
case "A":
m.composer.CursorEnd()
m.viCmd = false
case "p":
return m.pasteClipboardText()
}
return nil
}
// viDeleteChar removes the character under the caret (the rune to its right) in
// command mode, the vi "x". If the caret sits past the end of a line there is
// nothing to delete and the text is left untouched.
func (m *model) viDeleteChar() {
lines := m.viLines()
row := m.composer.Line()
if row < 0 || row >= len(lines) {
return
}
line := lines[row]
col := m.composer.Column()
if col >= len(line) {
return
}
lines[row] = append(line[:col], line[col+1:]...)
m.setViLines(lines, row, col)
}
// viDeleteToEnd removes from the caret to the end of the current line (the vi
// "D"), leaving the caret at the resulting end of line. With the caret already
// past the last rune there is nothing to delete and the text is left untouched.
func (m *model) viDeleteToEnd() {
lines := m.viLines()
row := m.composer.Line()
if row < 0 || row >= len(lines) {
return
}
line := lines[row]
col := m.composer.Column()
if col >= len(line) {
return
}
lines[row] = line[:col]
m.setViLines(lines, row, col)
}
// setViLines rewrites the composer from its logical lines and places the caret
// at (row, col), clamping col to the resulting line length.
func (m *model) setViLines(lines [][]rune, row, col int) {
value := strings.Builder{}
for i, l := range lines {
if i > 0 {
value.WriteByte('\n')
}
value.WriteString(string(l))
}
m.composer.SetValue(value.String())
if row < 0 || row >= len(lines) {
return
}
if col > len(lines[row]) {
col = len(lines[row])
}
m.viSetCursor(row, col)
}
// viSetCursor places the composer caret at logical (row, col) by walking down
// from the start, since the textarea exposes no absolute cursor setter.
func (m *model) viSetCursor(row, col int) {
m.composer.MoveToBegin()
for m.composer.Line() < row {
m.composer.CursorDown()
}
m.composer.SetCursorColumn(col)
}
+502
View File
@@ -0,0 +1,502 @@
package tui
import (
"fmt"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/eventwire"
)
// newViModel builds an idle TUI whose composer uses the vi command mode.
func newViModel(t *testing.T) (*model, *recordingKernel) {
t.Helper()
m, k := testModel(t)
m.opts.CommandMode = true
return m, k
}
func viKey(r rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: r, Text: string(r)}
}
var (
viCtrlC = tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
viCtrlD = tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
)
func TestViEscEntersCommandModeAndInsertLeavesIt(t *testing.T) {
m, _ := newViModel(t)
if m.viInCommand() {
t.Fatal("should start in insert mode")
}
press(m, "esc")
if !m.viInCommand() {
t.Fatal("esc should enter command mode")
}
// A command-mode key (i) returns to insert editing.
m.Update(viKey('i'))
if m.viInCommand() {
t.Fatal("i should leave command mode")
}
}
func TestViEscLeavingInsertStepsCaretLeft(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("abc")
m.composer.SetCursorColumn(3) // after the last char, as after typing "abc"
press(m, "esc")
if !m.viInCommand() {
t.Fatal("esc should enter command mode")
}
if got := m.composer.Column(); got != 2 {
t.Fatalf("caret after esc from insert = %d, want 2 (one rune left of end)", got)
}
// A second Esc while already in command mode must not move the caret.
press(m, "esc")
if !m.viInCommand() {
t.Fatal("esc must keep command mode")
}
if got := m.composer.Column(); got != 2 {
t.Fatalf("caret after second esc = %d, want 2 (no-op)", got)
}
}
func TestViDeleteCharX(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("abc")
m.composer.SetCursorColumn(0)
press(m, "esc")
if !m.viInCommand() {
t.Fatal("esc should enter command mode")
}
m.Update(viKey('x'))
if got := m.composer.Value(); got != "bc" {
t.Fatalf("x at col 0 = %q, want bc", got)
}
if !m.viInCommand() {
t.Fatal("x must stay in command mode")
}
// Pressing an unrecognized command-mode key must not insert text.
m.Update(viKey('q'))
if got := m.composer.Value(); got != "bc" {
t.Fatalf("command mode ignored 'q' but value = %q, want bc", got)
}
if !m.viInCommand() {
t.Fatal("must remain in command mode after ignored key")
}
}
func TestViDeleteCharAtEndOfLineNoop(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("abc")
m.composer.SetCursorColumn(3) // caret past last char
press(m, "esc")
// Esc from insert steps left; move back to the true end in command mode.
m.Update(viKey('$'))
if got := m.composer.Column(); got != 3 {
t.Fatalf("$ caret col = %d, want 3", got)
}
m.Update(viKey('x'))
if got := m.composer.Value(); got != "abc" {
t.Fatalf("x at end-of-line = %q, want abc unchanged", got)
}
}
func TestViDeleteToEndD(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("hello world")
m.composer.SetCursorColumn(11) // caret after the last char
press(m, "esc") // command mode, caret steps to col 10
m.Update(viKey('0')) // back to the start
m.Update(viKey('l')) // caret on 'e'
m.Update(viKey('D'))
if got := m.composer.Value(); got != "h" {
t.Fatalf("D from col 1 = %q, want h", got)
}
if got := m.composer.Column(); got != 1 {
t.Fatalf("caret after D = %d, want 1", got)
}
if !m.viInCommand() {
t.Fatal("D must stay in command mode")
}
}
// TestViDeleteToEndOnlyCurrentLine: D stops at the end of the caret's line and
// leaves the following lines untouched.
func TestViDeleteToEndOnlyCurrentLine(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("first\nsecond")
press(m, "esc") // command mode; SetValue left the caret on the last line
m.Update(viKey('k')) // up to the first line
m.Update(viKey('0'))
m.Update(viKey('l')) // caret on 'i' of "first"
m.Update(viKey('D'))
if got := m.composer.Value(); got != "f\nsecond" {
t.Fatalf("D on the first line = %q, want f\\nsecond", got)
}
}
func TestViDeleteToEndAtEndOfLineNoop(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("abc")
m.composer.SetCursorColumn(3)
press(m, "esc")
m.Update(viKey('$')) // caret past the last char
m.Update(viKey('D'))
if got := m.composer.Value(); got != "abc" {
t.Fatalf("D at end-of-line = %q, want abc unchanged", got)
}
}
func TestViInsertAfterLastChar(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("abc")
m.composer.SetCursorColumn(1) // caret after 'a'
press(m, "esc")
m.Update(viKey('A')) // append after last char
if m.viInCommand() {
t.Fatal("A should enter insert mode")
}
if got := m.composer.Column(); got != 3 {
t.Fatalf("A caret col = %d, want 3 (end of 'abc')", got)
}
m.Update(viKey('d'))
if got := m.composer.Value(); got != "abcd" {
t.Fatalf("typing after A = %q, want abcd", got)
}
}
func TestViInsertBeforeFirstChar(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("bc")
m.composer.SetCursorColumn(2) // caret at end
press(m, "esc")
m.Update(viKey('I')) // insert before first char
if m.viInCommand() {
t.Fatal("I should enter insert mode")
}
m.Update(viKey('a'))
if got := m.composer.Value(); got != "abc" {
t.Fatalf("typing after I = %q, want abc", got)
}
}
// TestViFooterModeMarker: the footer names the composer's input mode while vi
// mode is on, INSERT in insert mode and NORMAL once Esc enters command mode.
func TestViFooterModeMarker(t *testing.T) {
m, _ := newViModel(t)
if got := strings.Join(m.statusBlock(), "\n"); !strings.Contains(got, "INSERT") {
t.Fatalf("insert-mode footer lacks the INSERT marker:\n%s", got)
}
press(m, "esc")
if got := strings.Join(m.statusBlock(), "\n"); !strings.Contains(got, "NORMAL") {
t.Fatalf("command-mode footer lacks the NORMAL marker:\n%s", got)
}
}
// TestViFooterModeMarkerAbsentWithoutViMode: with vi mode off the footer carries
// no mode marker, leaving the first row exactly as before.
func TestViFooterModeMarkerAbsentWithoutViMode(t *testing.T) {
m, _ := testModel(t)
got := strings.Join(m.statusBlock(), "\n")
if strings.Contains(got, "NORMAL") || strings.Contains(got, "INSERT") {
t.Fatalf("non-vi footer carries a vi mode marker:\n%s", got)
}
}
// TestViCommandModeConsumesMultiByteText: a CJK/IME character is one key, not
// one byte, so command mode must consume it instead of inserting it.
func TestViCommandModeConsumesMultiByteText(t *testing.T) {
m, _ := newViModel(t)
m.Update(viKey('中'))
if got := m.composer.Value(); got != "中" {
t.Fatalf("insert mode dropped the character: %q", got)
}
m.composer.Reset()
press(m, "esc") // command mode
m.Update(viKey('中'))
if got := m.composer.Value(); got != "" {
t.Fatalf("command mode inserted a multi-byte character: %q", got)
}
if !m.viInCommand() {
t.Fatal("a multi-byte character left command mode")
}
}
// TestViEscDoesNotDenyApproval: a vi user hitting Esc out of habit must not
// reject a waiting call; the ask card is guarded the same way.
func TestViEscDoesNotDenyApproval(t *testing.T) {
m, k := newViModel(t)
apply(m, eventwire.Event{Kind: "approval_request", Approval: &eventwire.Approval{ID: "ap1", Tool: "bash", Subject: "rm x"}})
if m.tr.OpenPrompt() == nil {
t.Fatal("setup: expected an open approval")
}
run(m, press(m, "esc"))
if m.tr.OpenPrompt() == nil {
t.Fatal("vi mode: Esc must not decide the approval")
}
if calls := strings.Join(k.seen(), "\n"); strings.Contains(calls, "POST /approve") {
t.Fatal("vi mode: Esc rejected the approval")
}
}
func TestEscStillDeniesApprovalWhenNotVi(t *testing.T) {
m, k := testModel(t)
apply(m, eventwire.Event{Kind: "approval_request", Approval: &eventwire.Approval{ID: "ap1", Tool: "bash", Subject: "rm x"}})
run(m, press(m, "esc"))
if m.tr.OpenPrompt() != nil {
t.Fatal("non-vi: Esc must still decide the approval")
}
if calls := strings.Join(k.seen(), "\n"); !strings.Contains(calls, "POST /approve") {
t.Fatalf("non-vi: Esc did not decide the approval:\n%s", calls)
}
}
func TestViCtrlDQuitOnlyInInsertModeEmptyPrompt(t *testing.T) {
// Insert mode, truly empty prompt: quits.
m, _ := newViModel(t)
_, cmd := m.Update(viCtrlD)
if cmd == nil {
t.Fatal("^D in insert mode on empty prompt should request shutdown")
}
if _, quit := cmd().(tea.QuitMsg); !quit {
t.Fatal("^D on an empty prompt did not request shutdown")
}
// Insert mode but with content (even a single space): no quit.
m, _ = newViModel(t)
m.composer.SetValue(" ")
if _, cmd := m.Update(viCtrlD); cmd != nil {
t.Fatal("^D with a space must not quit in vi mode")
}
// Command mode on the empty prompt: no quit.
m, _ = newViModel(t)
press(m, "esc")
if _, cmd := m.Update(viCtrlD); cmd != nil {
t.Fatal("^D in command mode must not quit")
}
}
func TestViCtrlCLeavesCommandModeButNeverQuits(t *testing.T) {
m, _ := newViModel(t)
press(m, "esc")
if !m.viInCommand() {
t.Fatal("setup: expected command mode")
}
_, cmd := m.Update(viCtrlC)
if m.viInCommand() {
t.Fatal("^C should leave command mode back into insert")
}
if cmd != nil {
if _, quit := cmd().(tea.QuitMsg); quit {
t.Fatal("^C on idle must not quit")
}
}
}
// TestViCtrlCSavesDraftToHistoryAndClears: pressing ^C at the prompt with typed
// text must save the draft to the cmdline history and clear the prompt, so an
// accidental interrupt never loses it.
func TestViCtrlCSavesDraftToHistoryAndClears(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue("half-written draft")
m.Update(viCtrlC)
if got := m.composer.Value(); got != "" {
t.Fatalf("prompt after ^C = %q, want empty", got)
}
if len(m.history) == 0 || m.history[len(m.history)-1] != "half-written draft" {
t.Fatalf("draft not saved to cmdline history: %v", m.history)
}
}
// TestViCtrlCSavesTrimmedDraftAndLeavesShellMode: the saved draft follows
// send()'s rule (trimmed, whitespace-only skipped), and clearing the prompt also
// leaves shell mode, as send() does.
func TestViCtrlCSavesTrimmedDraftAndLeavesShellMode(t *testing.T) {
m, _ := newViModel(t)
m.shell = true
m.composer.SetValue(" ls -la ")
m.Update(viCtrlC)
if got := m.composer.Value(); got != "" {
t.Fatalf("prompt after ^C = %q, want empty", got)
}
if len(m.history) != 1 || m.history[0] != "ls -la" {
t.Fatalf("history = %v, want [ls -la]", m.history)
}
if m.shell {
t.Fatal("^C cleared the prompt but left shell mode on")
}
}
// TestViCtrlCClearsWhitespaceOnlyDraftWithoutSaving: a whitespace-only prompt
// is empty to send(), so ^C clears it as the default mode does but saves
// nothing to the cmdline history.
func TestViCtrlCClearsWhitespaceOnlyDraftWithoutSaving(t *testing.T) {
m, _ := newViModel(t)
m.composer.SetValue(" ")
m.Update(viCtrlC)
if len(m.history) != 0 {
t.Fatalf("whitespace-only draft was saved: %v", m.history)
}
if got := m.composer.Value(); got != "" {
t.Fatalf("whitespace-only prompt was not cleared: %q", got)
}
}
// TestThinkingHintNamesTheInterruptKeyPerMode: the thinking line names the key
// that actually cancels — Esc by default, Ctrl+C in vi mode, where Esc enters
// command mode instead.
func TestThinkingHintNamesTheInterruptKeyPerMode(t *testing.T) {
frame := spinFrames[0]
plain, _ := testModel(t)
apply(plain, eventwire.Event{Kind: "turn_started"})
plain.runSince = time.Now()
if got, want := ansi.Strip(plain.workingLine()), fmt.Sprintf(" "+i18n.M.ChatStatusThinkingFmt, frame, 0, "Esc"); got != want {
t.Fatalf("default thinking line = %q, want %q", got, want)
}
vi, _ := newViModel(t)
apply(vi, eventwire.Event{Kind: "turn_started"})
vi.runSince = time.Now()
if got, want := ansi.Strip(vi.workingLine()), fmt.Sprintf(" "+i18n.M.ChatStatusThinkingFmt, frame, 0, "Ctrl+C"); got != want {
t.Fatalf("vi thinking line = %q, want %q", got, want)
}
}
// TestCancellingHintOmitsExitKeyInViMode: vi mode consumes Ctrl+C to re-cancel
// instead of exiting, so the cancelling line drops the "Ctrl+C exits" clause
// there and keeps it for the default mode.
func TestCancellingHintOmitsExitKeyInViMode(t *testing.T) {
frame := spinFrames[0]
vi, _ := newViModel(t)
apply(vi, eventwire.Event{Kind: "turn_started"})
vi.cancelling, vi.runSince = true, time.Now()
if got, want := ansi.Strip(vi.workingLine()), fmt.Sprintf(" "+i18n.M.ChatStatusCancellingViFmt, frame, 0); got != want {
t.Fatalf("vi cancelling line = %q, want %q", got, want)
}
plain, _ := testModel(t)
apply(plain, eventwire.Event{Kind: "turn_started"})
plain.cancelling, plain.runSince = true, time.Now()
if got, want := ansi.Strip(plain.workingLine()), fmt.Sprintf(" "+i18n.M.ChatStatusCancellingFmt, frame, 0); got != want {
t.Fatalf("non-vi cancelling line = %q, want %q", got, want)
}
}
// TestViFooterHintsDropEscOnCards: the tool-approval, ask and plan card hints
// name Esc in the default mode, where Esc answers them; in vi mode Esc is
// ignored there, so the hint drops it (the ask card names Ctrl+C instead).
func TestViFooterHintsDropEscOnCards(t *testing.T) {
tool := eventwire.Event{Kind: "approval_request", Approval: &eventwire.Approval{ID: "ap1", Tool: "bash", Subject: "rm x"}}
plan := eventwire.Event{Kind: "approval_request", Approval: &eventwire.Approval{ID: "ap2", Kind: "plan"}}
for _, tc := range []struct {
name string
ev eventwire.Event
}{
{"tool approval", tool},
{"plan approval", plan},
{"ask", askEvent()},
} {
plain, _ := testModel(t)
apply(plain, tc.ev)
if got := ansi.Strip(plain.stateText()); !strings.Contains(got, "Esc") {
t.Fatalf("%s: default footer hint should name Esc:\n%s", tc.name, got)
}
vi, _ := newViModel(t)
apply(vi, tc.ev)
if got := ansi.Strip(vi.stateText()); strings.Contains(got, "Esc") {
t.Fatalf("%s: vi footer hint still names Esc:\n%s", tc.name, got)
}
}
}
func TestViAskIgnoresEsc(t *testing.T) {
// vi mode: Esc must not dismiss the ask card.
m, _ := newViModel(t)
apply(m, askEvent())
if m.tr.OpenPrompt() == nil {
t.Fatal("setup: expected an open ask")
}
press(m, "esc")
if m.tr.OpenPrompt() == nil {
t.Fatal("vi mode: Esc must not dismiss the ask card")
}
// vi mode: Esc while typing an answer must not back out / discard.
m2, _ := newViModel(t)
apply(m2, askEvent())
m2.openAsk(m2.tr.OpenPrompt())
m2.ask.entry = entryAnswer
m2.composer.SetValue("draft")
press(m2, "esc")
if m2.ask == nil || !m2.ask.entering() {
t.Fatal("vi mode: Esc while typing an ask answer must not back out")
}
if got := m2.composer.Value(); got != "draft" {
t.Fatalf("vi mode: Esc discarded the draft, value = %q, want draft", got)
}
}
// TestViCtrlCCancelsWhileTypingAnAskAnswer: Esc is ignored while an ask answer
// is being typed, so ^C must still cancel the running turn rather than being
// swallowed by the composer.
func TestViCtrlCCancelsWhileTypingAnAskAnswer(t *testing.T) {
m, k := newViModel(t)
apply(m, eventwire.Event{Kind: "turn_started"})
apply(m, askEvent())
m.openAsk(m.tr.OpenPrompt())
m.ask.entry = entryAnswer
run(m, press(m, "ctrl+c"))
if calls := strings.Join(k.seen(), "\n"); !strings.Contains(calls, "POST /cancel") {
t.Fatalf("^C while typing an ask answer did not cancel:\n%s", calls)
}
}
func TestAskEscStillDismissesWhenNotVi(t *testing.T) {
m, _ := testModel(t) // commandmode empty → not vi
if m.viActive() {
t.Fatal("setup: expected non-vi mode")
}
apply(m, askEvent())
run(m, press(m, "esc"))
if m.tr.OpenPrompt() != nil {
t.Fatal("non-vi: Esc must still dismiss the ask card")
}
}
// TestViEscEntersCommandModeWhileTurnRuns keeps vi mode's Esc a pure mode
// switch: while a turn runs, Esc enters command mode and never cancels — only
// ^C interrupts.
func TestViEscEntersCommandModeWhileTurnRuns(t *testing.T) {
m, k := newViModel(t)
apply(m, eventwire.Event{Kind: "turn_started"})
if !m.tr.Running {
t.Fatal("setup: expected a running turn")
}
press(m, "esc")
if !m.tr.Running {
t.Fatal("vi mode: Esc must not cancel a running turn")
}
if !m.viInCommand() {
t.Fatal("vi mode: Esc while a turn runs should enter command mode")
}
if calls := strings.Join(k.seen(), "\n"); strings.Contains(calls, "POST /cancel") {
t.Fatal("vi mode: Esc cancelled the turn")
}
}
+1
View File
@@ -12,6 +12,7 @@ theme = "auto" # auto|dark|light; controls CLI colors only; REASONIX_THEME can
# theme_style = "graphite" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
# shortcut_layout = "desktop" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO
# cursor_shape = "bar" # block|underline|bar; text input cursor shape; slim default avoids covering CJK characters
# commandmode = "vi" # ""|vi; "vi" gives the CLI prompt a vi command mode (Esc enters command mode, ^C interrupts a stream, ^D exits)
show_turn_usage = true # CLI/TUI: show per-request token and cost receipts in transcript scrollback
[desktop]