detached 04e3270

Merge branch 'main' into doc/mcpo-example

commit
04e3270fd2b2bd920d7e0d2495f3e26b97cb2ad2
author
Patric Eckhart · · 2026-07-02 05:21 UTC
committer
GitHub · · 2026-07-02 05:21 UTC
merge of
f3d4acf + 5c27086 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +647 -48
M README.md +3 -2
@@ -302,6 +302,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
302302 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
303303 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
304304 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
305+- **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line) or `lines` (separator lines above and below the input). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
305306 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
306307 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
307308 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
@@ -657,14 +658,14 @@ Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only
657658 | `alt+left`, `alt+right` | Jump one word back or forward. |
658659 | `ctrl+u`, `ctrl+k` | Delete to start or end of line. |
659660 | `ctrl+w`, `alt+backspace` | Delete the previous word. |
660-| `up`, `down` (editor non-empty) | Cycle through prompt history. |
661+| `up`, `down` | Move within multi-line input. At the top edge, `up` recalls previous prompts and `down` moves forward through prompt history. |
661662
662663 ### Chat scroll
663664
664665 | Key | Action |
665666 |---|---|
666667 | `pgup`, `pgdn` | Scroll one page up or down. |
667-| `up`, `down` (editor empty) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. |
668+| `up`, `down` (editor empty, not browsing prompt history) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. |
668669
669670 ## Extensions
670671
@@ -302,6 +302,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
302 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.302 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
303 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.303 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
304 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.304 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
305+- **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line) or `lines` (separator lines above and below the input). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
305 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.306 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
306 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).307 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
307 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.308 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
@@ -657,14 +658,14 @@ Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only
657 | `alt+left`, `alt+right` | Jump one word back or forward. |658 | `alt+left`, `alt+right` | Jump one word back or forward. |
658 | `ctrl+u`, `ctrl+k` | Delete to start or end of line. |659 | `ctrl+u`, `ctrl+k` | Delete to start or end of line. |
659 | `ctrl+w`, `alt+backspace` | Delete the previous word. |660 | `ctrl+w`, `alt+backspace` | Delete the previous word. |
660-| `up`, `down` (editor non-empty) | Cycle through prompt history. |661+| `up`, `down` | Move within multi-line input. At the top edge, `up` recalls previous prompts and `down` moves forward through prompt history. |
661 662
662 ### Chat scroll663 ### Chat scroll
663 664
664 | Key | Action |665 | Key | Action |
665 |---|---|666 |---|---|
666 | `pgup`, `pgdn` | Scroll one page up or down. |667 | `pgup`, `pgdn` | Scroll one page up or down. |
667-| `up`, `down` (editor empty) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. |668+| `up`, `down` (editor empty, not browsing prompt history) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. |
668 669
669 ## Extensions670 ## Extensions
670 671
M packages/agent/cli.go +3 -0
@@ -966,6 +966,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
966966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967967 RespectGitignore: initialCfg.RespectGitignore,
968968 CompactMode: initialCfg.CompactMode,
969+ TUIInputStyle: initialCfg.TUIInputStyle,
970+ TUIStatusPosition: initialCfg.TUIStatusPosition,
971+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
969972 ThemeName: initialCfg.Theme,
970973 FlatTools: initialCfg.FlatToolRender(),
971974 CompactUser: initialCfg.CompactUserInput(),
@@ -966,6 +966,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967 RespectGitignore: initialCfg.RespectGitignore,967 RespectGitignore: initialCfg.RespectGitignore,
968 CompactMode: initialCfg.CompactMode,968 CompactMode: initialCfg.CompactMode,
969+ TUIInputStyle: initialCfg.TUIInputStyle,
970+ TUIStatusPosition: initialCfg.TUIStatusPosition,
971+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
969 ThemeName: initialCfg.Theme,972 ThemeName: initialCfg.Theme,
970 FlatTools: initialCfg.FlatToolRender(),973 FlatTools: initialCfg.FlatToolRender(),
971 CompactUser: initialCfg.CompactUserInput(),974 CompactUser: initialCfg.CompactUserInput(),
M packages/agent/config.go +14 -0
@@ -75,6 +75,20 @@ type Config struct {
7575 // default; nil/missing means disabled. Toggle from /settings.
7676 CompactMode *bool `json:"compact_mode,omitempty"`
7777
78+ // TUIInputStyle controls the main input rendering. Supported values:
79+ // "plain" (default) and "lines".
80+ TUIInputStyle string `json:"tui_input_style,omitempty"`
81+
82+ // TUIStatusPosition controls whether model, usage, and cwd information
83+ // render above or below the main input. Supported values: "above_input"
84+ // (default) and "below_input".
85+ TUIStatusPosition string `json:"tui_status_position,omitempty"`
86+
87+ // TUIWorkingPosition controls whether the busy/working spinner renders
88+ // above or below the main input. Supported values: "above_input"
89+ // (default) and "below_input".
90+ TUIWorkingPosition string `json:"tui_working_position,omitempty"`
91+
7892 // Insecure skips TLS verification for custom inference endpoints.
7993 Insecure bool `json:"insecure,omitempty"`
8094
@@ -75,6 +75,20 @@ type Config struct {
75 // default; nil/missing means disabled. Toggle from /settings.75 // default; nil/missing means disabled. Toggle from /settings.
76 CompactMode *bool `json:"compact_mode,omitempty"`76 CompactMode *bool `json:"compact_mode,omitempty"`
77 77
78+ // TUIInputStyle controls the main input rendering. Supported values:
79+ // "plain" (default) and "lines".
80+ TUIInputStyle string `json:"tui_input_style,omitempty"`
81+
82+ // TUIStatusPosition controls whether model, usage, and cwd information
83+ // render above or below the main input. Supported values: "above_input"
84+ // (default) and "below_input".
85+ TUIStatusPosition string `json:"tui_status_position,omitempty"`
86+
87+ // TUIWorkingPosition controls whether the busy/working spinner renders
88+ // above or below the main input. Supported values: "above_input"
89+ // (default) and "below_input".
90+ TUIWorkingPosition string `json:"tui_working_position,omitempty"`
91+
78 // Insecure skips TLS verification for custom inference endpoints.92 // Insecure skips TLS verification for custom inference endpoints.
79 Insecure bool `json:"insecure,omitempty"`93 Insecure bool `json:"insecure,omitempty"`
80 94
M packages/agent/modes/btw_dialog.go +16 -2
@@ -36,6 +36,7 @@ type btwDialog struct {
3636 editor *tui.Editor
3737 loading bool
3838 compactMode bool
39+ lineInput bool
3940 cancel context.CancelFunc
4041
4142 // spin drives the same braille animation + rotating funny-line
@@ -101,15 +102,20 @@ func (d *btwDialog) Loading() bool {
101102 // auto-submitted (so /btw <text> starts a conversation right away).
102103 // invalidate, if non-nil, is called after each state change so the
103104 // host redraw loop can pick up the update without polling.
104-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, invalidate func()) {
105+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, lineInput bool, invalidate func()) {
105106 d.mu.Lock()
106107 d.active = true
107108 d.theme = th
108109 d.compactMode = compactMode
110+ d.lineInput = lineInput
109111 d.turns = nil
110112 d.loading = false
111113 d.cancel = nil
112- d.editor = tui.NewEditor(th.AccentBar(th.Accent))
114+ prompt := th.AccentBar(th.Accent)
115+ if lineInput {
116+ prompt = ""
117+ }
118+ d.editor = tui.NewEditor(prompt)
113119 d.frozenSystem = system
114120 d.frozenMsgs = agent.Messages()
115121 d.client = agent.Client
@@ -355,6 +361,10 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
355361 // Render at width-2 to match the two-cell left indent applied
356362 // below. CursorPos uses the same width so the reported cursor
357363 // column matches the wrapped layout shown on screen.
364+ d.editor.Prompt = th.AccentBar(th.Accent)
365+ if d.lineInput {
366+ d.editor.Prompt = ""
367+ }
358368 edLines, _, _ := d.editor.Render(width - 2)
359369 for _, l := range edLines {
360370 // Indent the editor body so it lines up with the side-chat
@@ -406,6 +416,10 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
406416 editorOffset++ // spinner line
407417 }
408418 editorOffset++ // pre-editor blank
419+ d.editor.Prompt = d.theme.AccentBar(d.theme.Accent)
420+ if d.lineInput {
421+ d.editor.Prompt = ""
422+ }
409423 _, eRow, eCol := d.editor.Render(width - 2)
410424 return editorOffset + eRow, eCol + 2 /* matches render indent */
411425 }
@@ -36,6 +36,7 @@ type btwDialog struct {
36 editor *tui.Editor36 editor *tui.Editor
37 loading bool37 loading bool
38 compactMode bool38 compactMode bool
39+ lineInput bool
39 cancel context.CancelFunc40 cancel context.CancelFunc
40 41
41 // spin drives the same braille animation + rotating funny-line42 // spin drives the same braille animation + rotating funny-line
@@ -101,15 +102,20 @@ func (d *btwDialog) Loading() bool {
101 // auto-submitted (so /btw <text> starts a conversation right away).102 // auto-submitted (so /btw <text> starts a conversation right away).
102 // invalidate, if non-nil, is called after each state change so the103 // invalidate, if non-nil, is called after each state change so the
103 // host redraw loop can pick up the update without polling.104 // host redraw loop can pick up the update without polling.
104-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, invalidate func()) {105+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, lineInput bool, invalidate func()) {
105 d.mu.Lock()106 d.mu.Lock()
106 d.active = true107 d.active = true
107 d.theme = th108 d.theme = th
108 d.compactMode = compactMode109 d.compactMode = compactMode
110+ d.lineInput = lineInput
109 d.turns = nil111 d.turns = nil
110 d.loading = false112 d.loading = false
111 d.cancel = nil113 d.cancel = nil
112- d.editor = tui.NewEditor(th.AccentBar(th.Accent))114+ prompt := th.AccentBar(th.Accent)
115+ if lineInput {
116+ prompt = ""
117+ }
118+ d.editor = tui.NewEditor(prompt)
113 d.frozenSystem = system119 d.frozenSystem = system
114 d.frozenMsgs = agent.Messages()120 d.frozenMsgs = agent.Messages()
115 d.client = agent.Client121 d.client = agent.Client
@@ -355,6 +361,10 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
355 // Render at width-2 to match the two-cell left indent applied361 // Render at width-2 to match the two-cell left indent applied
356 // below. CursorPos uses the same width so the reported cursor362 // below. CursorPos uses the same width so the reported cursor
357 // column matches the wrapped layout shown on screen.363 // column matches the wrapped layout shown on screen.
364+ d.editor.Prompt = th.AccentBar(th.Accent)
365+ if d.lineInput {
366+ d.editor.Prompt = ""
367+ }
358 edLines, _, _ := d.editor.Render(width - 2)368 edLines, _, _ := d.editor.Render(width - 2)
359 for _, l := range edLines {369 for _, l := range edLines {
360 // Indent the editor body so it lines up with the side-chat370 // Indent the editor body so it lines up with the side-chat
@@ -406,6 +416,10 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
406 editorOffset++ // spinner line416 editorOffset++ // spinner line
407 }417 }
408 editorOffset++ // pre-editor blank418 editorOffset++ // pre-editor blank
419+ d.editor.Prompt = d.theme.AccentBar(d.theme.Accent)
420+ if d.lineInput {
421+ d.editor.Prompt = ""
422+ }
409 _, eRow, eCol := d.editor.Render(width - 2)423 _, eRow, eCol := d.editor.Render(width - 2)
410 return editorOffset + eRow, eCol + 2 /* matches render indent */424 return editorOffset + eRow, eCol + 2 /* matches render indent */
411 }425 }
M packages/agent/modes/help.go +1 -1
@@ -23,7 +23,7 @@ var helpKeyRows = [][2]string{
2323 {"ctrl+l", "redraw the screen"},
2424 {"ctrl+o", "expand / collapse long tool results"},
2525 {"pgup / pgdn", "scroll the chat one page up / down"},
26- {"up / down", "move within multi-line input - scroll chat at input edge"},
26+ {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"},
2727 }
2828
2929 // renderHelpBlock builds the friendly /help view. Uses the shared
@@ -23,7 +23,7 @@ var helpKeyRows = [][2]string{
23 {"ctrl+l", "redraw the screen"},23 {"ctrl+l", "redraw the screen"},
24 {"ctrl+o", "expand / collapse long tool results"},24 {"ctrl+o", "expand / collapse long tool results"},
25 {"pgup / pgdn", "scroll the chat one page up / down"},25 {"pgup / pgdn", "scroll the chat one page up / down"},
26- {"up / down", "move within multi-line input - scroll chat at input edge"},26+ {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"},
27 }27 }
28 28
29 // renderHelpBlock builds the friendly /help view. Uses the shared29 // renderHelpBlock builds the friendly /help view. Uses the shared
A packages/agent/modes/input_history_test.go +93 -0
@@ -0,0 +1,93 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+ "github.com/patriceckhart/zot/packages/provider"
8+ "github.com/patriceckhart/zot/packages/tui"
9+)
10+
11+func TestInputHistoryUsesUpDown(t *testing.T) {
12+ ag := core.NewAgent(nil, "", "", nil)
13+ ag.SetMessages([]provider.Message{
14+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}},
15+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply"}}},
16+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}},
17+ })
18+ i := &Interactive{
19+ agent: ag,
20+ ed: tui.NewEditor(""),
21+ inputHistoryIndex: -1,
22+ }
23+
24+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) {
25+ t.Fatal("expected Up to enter history")
26+ }
27+ if got := i.ed.Value(); got != "second" {
28+ t.Fatalf("Up loaded %q, want newest history item", got)
29+ }
30+
31+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) {
32+ t.Fatal("expected repeated Up to stay in history")
33+ }
34+ if got := i.ed.Value(); got != "first" {
35+ t.Fatalf("second Up loaded %q, want older history item", got)
36+ }
37+
38+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) {
39+ t.Fatal("expected Down to move forward in history")
40+ }
41+ if got := i.ed.Value(); got != "second" {
42+ t.Fatalf("Down loaded %q, want newer history item", got)
43+ }
44+
45+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) {
46+ t.Fatal("expected Down at newest item to clear editor")
47+ }
48+ if got := i.ed.Value(); got != "" {
49+ t.Fatalf("Down past newest loaded %q, want empty editor", got)
50+ }
51+}
52+
53+func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {
54+ ag := core.NewAgent(nil, "", "", nil)
55+ ag.SetMessages([]provider.Message{
56+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
57+ })
58+ i := &Interactive{
59+ agent: ag,
60+ ed: tui.NewEditor(""),
61+ inputHistoryIndex: -1,
62+ }
63+
64+ if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyLeft}) {
65+ t.Fatal("Left should not browse input history")
66+ }
67+ if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyRight}) {
68+ t.Fatal("Right should not browse input history")
69+ }
70+ if got := i.ed.Value(); got != "" {
71+ t.Fatalf("left/right changed editor to %q", got)
72+ }
73+}
74+
75+func TestInputHistoryDoesNotStealNonEmptyEditor(t *testing.T) {
76+ ag := core.NewAgent(nil, "", "", nil)
77+ ag.SetMessages([]provider.Message{
78+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
79+ })
80+ i := &Interactive{
81+ agent: ag,
82+ ed: tui.NewEditor(""),
83+ inputHistoryIndex: -1,
84+ }
85+ i.ed.SetValue("draft")
86+
87+ if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) {
88+ t.Fatal("Up should not browse history while editing a draft")
89+ }
90+ if got := i.ed.Value(); got != "draft" {
91+ t.Fatalf("editor changed to %q, want draft", got)
92+ }
93+}
@@ -0,0 +1,93 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+ "github.com/patriceckhart/zot/packages/provider"
8+ "github.com/patriceckhart/zot/packages/tui"
9+)
10+
11+func TestInputHistoryUsesUpDown(t *testing.T) {
12+ ag := core.NewAgent(nil, "", "", nil)
13+ ag.SetMessages([]provider.Message{
14+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}},
15+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply"}}},
16+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}},
17+ })
18+ i := &Interactive{
19+ agent: ag,
20+ ed: tui.NewEditor(""),
21+ inputHistoryIndex: -1,
22+ }
23+
24+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) {
25+ t.Fatal("expected Up to enter history")
26+ }
27+ if got := i.ed.Value(); got != "second" {
28+ t.Fatalf("Up loaded %q, want newest history item", got)
29+ }
30+
31+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) {
32+ t.Fatal("expected repeated Up to stay in history")
33+ }
34+ if got := i.ed.Value(); got != "first" {
35+ t.Fatalf("second Up loaded %q, want older history item", got)
36+ }
37+
38+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) {
39+ t.Fatal("expected Down to move forward in history")
40+ }
41+ if got := i.ed.Value(); got != "second" {
42+ t.Fatalf("Down loaded %q, want newer history item", got)
43+ }
44+
45+ if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) {
46+ t.Fatal("expected Down at newest item to clear editor")
47+ }
48+ if got := i.ed.Value(); got != "" {
49+ t.Fatalf("Down past newest loaded %q, want empty editor", got)
50+ }
51+}
52+
53+func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {
54+ ag := core.NewAgent(nil, "", "", nil)
55+ ag.SetMessages([]provider.Message{
56+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
57+ })
58+ i := &Interactive{
59+ agent: ag,
60+ ed: tui.NewEditor(""),
61+ inputHistoryIndex: -1,
62+ }
63+
64+ if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyLeft}) {
65+ t.Fatal("Left should not browse input history")
66+ }
67+ if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyRight}) {
68+ t.Fatal("Right should not browse input history")
69+ }
70+ if got := i.ed.Value(); got != "" {
71+ t.Fatalf("left/right changed editor to %q", got)
72+ }
73+}
74+
75+func TestInputHistoryDoesNotStealNonEmptyEditor(t *testing.T) {
76+ ag := core.NewAgent(nil, "", "", nil)
77+ ag.SetMessages([]provider.Message{
78+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
79+ })
80+ i := &Interactive{
81+ agent: ag,
82+ ed: tui.NewEditor(""),
83+ inputHistoryIndex: -1,
84+ }
85+ i.ed.SetValue("draft")
86+
87+ if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) {
88+ t.Fatal("Up should not browse history while editing a draft")
89+ }
90+ if got := i.ed.Value(); got != "draft" {
91+ t.Fatalf("editor changed to %q, want draft", got)
92+ }
93+}
M packages/agent/modes/interactive.go +258 -29
@@ -75,6 +75,17 @@ type InteractiveConfig struct {
7575 // When true, it enables both flat tool calls and compact user turns.
7676 CompactMode *bool
7777
78+ // TUIInputStyle controls the main input rendering: plain or lines.
79+ TUIInputStyle string
80+
81+ // TUIStatusPosition controls whether the status block renders above
82+ // or below the main input.
83+ TUIStatusPosition string
84+
85+ // TUIWorkingPosition controls whether the busy/working spinner renders
86+ // above or below the main input.
87+ TUIWorkingPosition string
88+
7889 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
7990 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
8091 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -270,6 +281,9 @@ type SettingsStore interface {
270281 SetRecursiveFileSuggest(enabled bool) error
271282 SetRespectGitignore(enabled bool) error
272283 SetCompactMode(enabled bool) error
284+ SetTUIInputStyle(style string) error
285+ SetTUIStatusPosition(position string) error
286+ SetTUIWorkingPosition(position string) error
273287 SetReasoning(level string) error
274288 SetTheme(name string) error
275289 }
@@ -415,9 +429,8 @@ type Interactive struct {
415429 parkedTotal int
416430
417431 // inputHistoryIndex is -1 when not browsing history. When the
418- // editor is empty, Left/Right can walk previous user prompts for
419- // quick manual testing without stealing normal cursor movement in
420- // non-empty input.
432+ // editor is empty, Up/Down can walk previous user prompts without
433+ // stealing normal vertical cursor movement in non-empty input.
421434 inputHistoryIndex int
422435
423436 // lastCtrlC is when the user last pressed ctrl+c. The first press
@@ -580,7 +593,8 @@ func (i *Interactive) Run(ctx context.Context) error {
580593 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
581594 _, _ = term.Write([]byte(seq))
582595 }
583- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
596+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
597+ i.applyInputCursorColor()
584598
585599 // Streaming pacer: drains buffered text deltas at a steady rate
586600 // so typewriter feel is identical across providers regardless of
@@ -1212,13 +1226,20 @@ func (i *Interactive) redraw() {
12121226 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
12131227 ctxMax = m.ContextWindow
12141228 }
1229+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1230+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1231+ workingWithStatus := statusPosition == workingPosition
1232+ statusBusyPrefix := ""
1233+ if workingWithStatus {
1234+ statusBusyPrefix = busyPrefix
1235+ }
12151236 statusLines := tui.StatusBar(tui.StatusBarParams{
12161237 Theme: i.cfg.Theme,
12171238 Provider: i.cfg.Provider,
12181239 Model: i.cfg.Model,
12191240 Reasoning: i.cfg.Reasoning,
12201241 Busy: i.busy,
1221- BusyPrefix: busyPrefix,
1242+ BusyPrefix: statusBusyPrefix,
12221243 CWD: i.cfg.CWD,
12231244 Locked: i.cfg.Sandbox.Locked(),
12241245 NoYolo: i.cfg.NoYolo,
@@ -1230,7 +1251,21 @@ func (i *Interactive) redraw() {
12301251 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
12311252 Cols: cols,
12321253 })
1254+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1255+ i.ed.Prompt = ""
1256+ } else {
1257+ i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1258+ }
12331259 edLines, curR, curC := i.ed.Render(cols)
1260+ var workingLines []string
1261+ if busyPrefix != "" && !workingWithStatus {
1262+ workingLines = []string{" " + busyPrefix}
1263+ }
1264+ inputCursorOffset := 0
1265+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1266+ edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1267+ inputCursorOffset = 1
1268+ }
12341269
12351270 // "Sliding in" chips for messages the user typed while a turn is
12361271 // in flight. Shown directly above the status bar so they're close
@@ -1261,7 +1296,8 @@ func (i *Interactive) redraw() {
12611296 // content. The status block and editor get their own dedicated
12621297 // blanks so spacing stays consistent whether or not a dialog or
12631298 // popup is showing.
1264- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
1299+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1300+ inputStartRow := -1
12651301 if len(dialog) > 0 {
12661302 bottom = append(bottom, "")
12671303 }
@@ -1277,10 +1313,42 @@ func (i *Interactive) redraw() {
12771313 if !i.swarmDialog.Active() {
12781314 bottom = append(bottom, suggest...)
12791315 bottom = append(bottom, queue...)
1316+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
1317+ statusBelow := statusPosition == tui.StatusPositionBelowInput
1318+ workingBelow := workingPosition == tui.WorkingPositionBelowInput
1319+
1320+ var aboveInput []string
1321+ if !statusBelow {
1322+ aboveInput = append(aboveInput, statusLines...)
1323+ }
1324+ if !workingBelow {
1325+ aboveInput = append(aboveInput, workingLines...)
1326+ }
1327+ var belowInput []string
1328+ if workingBelow {
1329+ belowInput = append(belowInput, workingLines...)
1330+ }
1331+ if statusBelow {
1332+ belowInput = append(belowInput, statusLines...)
1333+ }
1334+
12801335 bottom = append(bottom, "")
1281- bottom = append(bottom, statusLines...)
1282- bottom = append(bottom, "")
1336+ bottom = append(bottom, aboveInput...)
1337+ needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1338+ if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1339+ needsGapAboveInput = true
1340+ }
1341+ if needsGapAboveInput {
1342+ bottom = append(bottom, "")
1343+ }
1344+ inputStartRow = len(bottom)
12831345 bottom = append(bottom, edLines...)
1346+ if len(belowInput) > 0 {
1347+ if !lineInput {
1348+ bottom = append(bottom, "")
1349+ }
1350+ bottom = append(bottom, belowInput...)
1351+ }
12841352 }
12851353
12861354 _, rows := i.cfg.Terminal.Size()
@@ -1437,12 +1505,7 @@ func (i *Interactive) redraw() {
14371505 if len(dialog) > 0 {
14381506 dialogLead = 1
14391507 }
1440- // +2 accounts for the blank row above statusLines (so the
1441- // status block has air above it) and the blank row between
1442- // statusLines and edLines (input breathing room). Without
1443- // these the rendered cursor would land on a blank instead of
1444- // inside the editor row.
1445- cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1508+ cursorRow := inputStartRow + inputCursorOffset + curR
14461509 cursorCol := curC
14471510 if i.btwDialog.Active() {
14481511 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
@@ -2127,16 +2190,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
21272190 i.invalidate()
21282191 return false
21292192 }
2130- i.mu.Unlock()
21312193 }
21322194 // In multi-line / wrapped input, Up first moves inside the editor.
2133- // At the editor's top edge it falls back to chat scrolling, preserving
2134- // the old single-line scroll behavior.
2195+ // At the editor's top edge, plain Up can browse input history when
2196+ // history browsing is safe/active; otherwise it falls back to chat
2197+ // scrolling, preserving the old single-line scroll behavior.
21352198 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
21362199 if i.ed.MoveVertical(-1) {
21372200 i.invalidate()
21382201 return false
21392202 }
2203+ if !k.Alt && i.handleInputHistoryKey(k) {
2204+ return false
2205+ }
21402206 i.scrollBy(+3)
21412207 return false
21422208 }
@@ -2146,6 +2212,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
21462212 i.invalidate()
21472213 return false
21482214 }
2215+ if !k.Alt && i.handleInputHistoryKey(k) {
2216+ return false
2217+ }
21492218 if i.scrollOffset > 0 {
21502219 i.scrollBy(-3)
21512220 }
@@ -2268,10 +2337,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22682337 }
22692338 }
22702339
2271- if i.handleInputHistoryKey(k) {
2272- return false
2273- }
2274- if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2340+ if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
22752341 i.inputHistoryIndex = -1
22762342 }
22772343
@@ -2407,11 +2473,11 @@ func (i *Interactive) pasteClipboard() {
24072473 }
24082474
24092475 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2410- if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2476+ if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
24112477 return false
24122478 }
2413- // Do not steal normal cursor movement. History browsing can only
2414- // start from an empty editor; once active, Left/Right keep walking
2479+ // Do not steal normal vertical cursor movement. History browsing can only
2480+ // start from an empty editor; once active, Up/Down keep walking
24152481 // the ring so repeated presses work even though the editor now
24162482 // contains the selected historical prompt.
24172483 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
@@ -2423,17 +2489,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
24232489 }
24242490
24252491 if i.inputHistoryIndex < 0 {
2426- // Start just after the newest item so Left lands on the most
2427- // recent user prompt and Right keeps the editor empty.
2492+ // Start just after the newest item so Up lands on the most
2493+ // recent user prompt. A lone Down from an empty editor is not
2494+ // history navigation; let the caller fall through to normal UI
2495+ // behavior instead.
2496+ if k.Kind != tui.KeyUp {
2497+ return false
2498+ }
24282499 i.inputHistoryIndex = len(hist)
24292500 }
24302501
24312502 switch k.Kind {
2432- case tui.KeyLeft:
2503+ case tui.KeyUp:
24332504 if i.inputHistoryIndex > 0 {
24342505 i.inputHistoryIndex--
24352506 }
2436- case tui.KeyRight:
2507+ case tui.KeyDown:
24372508 if i.inputHistoryIndex < len(hist) {
24382509 i.inputHistoryIndex++
24392510 }
@@ -2796,6 +2867,13 @@ func onOff(v bool) string {
27962867 return "disabled"
27972868 }
27982869
2870+func (i *Interactive) applyInputCursorColor() {
2871+ if i == nil || i.cfg.Terminal == nil {
2872+ return
2873+ }
2874+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
2875+}
2876+
27992877 func (i *Interactive) openSettingsDialog() {
28002878 detected := tui.DetectImageProtocol()
28012879 imgEnabled := detected != tui.ImageProtocolNone
@@ -2825,6 +2903,9 @@ func (i *Interactive) openSettingsDialog() {
28252903 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
28262904 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
28272905 compactMode := i.compactModeEnabled()
2906+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
2907+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
2908+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
28282909 quickItems := i.quickModelSettingItems()
28292910
28302911 reasoningOptions := []settingsOption{
@@ -2873,6 +2954,41 @@ func (i *Interactive) openSettingsDialog() {
28732954 }
28742955 }
28752956
2957+ inputStyleOptions := []settingsOption{
2958+ {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
2959+ {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
2960+ }
2961+ inputStyleChoice := 0
2962+ for idx, opt := range inputStyleOptions {
2963+ if opt.value == inputStyle {
2964+ inputStyleChoice = idx
2965+ break
2966+ }
2967+ }
2968+ statusPositionOptions := []settingsOption{
2969+ {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
2970+ {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
2971+ }
2972+ statusPositionChoice := 0
2973+ for idx, opt := range statusPositionOptions {
2974+ if opt.value == statusPosition {
2975+ statusPositionChoice = idx
2976+ break
2977+ }
2978+ }
2979+
2980+ workingPositionOptions := []settingsOption{
2981+ {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
2982+ {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
2983+ }
2984+ workingPositionChoice := 0
2985+ for idx, opt := range workingPositionOptions {
2986+ if opt.value == workingPosition {
2987+ workingPositionChoice = idx
2988+ break
2989+ }
2990+ }
2991+
28762992 items := []settingsItem{
28772993 {
28782994 key: "inline_images_enabled",
@@ -2908,6 +3024,34 @@ func (i *Interactive) openSettingsDialog() {
29083024 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
29093025 value: compactMode,
29103026 },
3027+ {
3028+ key: "tui_settings",
3029+ label: "tui settings",
3030+ desc: "choose input chrome and where status information appears",
3031+ children: []settingsItem{
3032+ {
3033+ key: "tui_input_style",
3034+ label: "input style",
3035+ desc: "choose between the plain prompt and a lined input area",
3036+ options: inputStyleOptions,
3037+ choice: inputStyleChoice,
3038+ },
3039+ {
3040+ key: "tui_status_position",
3041+ label: "status position",
3042+ desc: "place model, usage, and working directory above or below the input",
3043+ options: statusPositionOptions,
3044+ choice: statusPositionChoice,
3045+ },
3046+ {
3047+ key: "tui_working_position",
3048+ label: "working spinner position",
3049+ desc: "place the working spinner above or below the input",
3050+ options: workingPositionOptions,
3051+ choice: workingPositionChoice,
3052+ },
3053+ },
3054+ },
29113055 {
29123056 key: "reasoning",
29133057 label: "thinking level",
@@ -2943,6 +3087,12 @@ func (i *Interactive) applySettingChange(act settingsAction) {
29433087 i.applyReasoningSetting(act.StringValue)
29443088 case act.Key == "theme":
29453089 i.applyThemeSetting(act.StringValue)
3090+ case act.Key == "tui_input_style":
3091+ i.applyTUIInputStyleSetting(act.StringValue)
3092+ case act.Key == "tui_status_position":
3093+ i.applyTUIStatusPositionSetting(act.StringValue)
3094+ case act.Key == "tui_working_position":
3095+ i.applyTUIWorkingPositionSetting(act.StringValue)
29463096 default:
29473097 i.applySettingToggle(act.Key, act.Value)
29483098 }
@@ -3221,6 +3371,84 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
32213371 }
32223372 }
32233373
3374+func (i *Interactive) applyTUIInputStyleSetting(style string) {
3375+ defer func() {
3376+ if i.rend != nil {
3377+ i.rend.Clear()
3378+ }
3379+ i.invalidate()
3380+ }()
3381+ style = tui.NormalizeInputStyle(style)
3382+ i.cfg.TUIInputStyle = style
3383+ i.applyInputCursorColor()
3384+ if i.cfg.SettingsStore != nil {
3385+ if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
3386+ i.mu.Lock()
3387+ i.statusErr = "settings: " + err.Error()
3388+ i.mu.Unlock()
3389+ return
3390+ }
3391+ }
3392+ i.mu.Lock()
3393+ i.statusOK = "input style " + style
3394+ i.statusErr = ""
3395+ i.mu.Unlock()
3396+}
3397+
3398+func (i *Interactive) applyTUIStatusPositionSetting(position string) {
3399+ defer func() {
3400+ if i.rend != nil {
3401+ i.rend.Clear()
3402+ }
3403+ i.invalidate()
3404+ }()
3405+ position = tui.NormalizeStatusPosition(position)
3406+ i.cfg.TUIStatusPosition = position
3407+ if i.cfg.SettingsStore != nil {
3408+ if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
3409+ i.mu.Lock()
3410+ i.statusErr = "settings: " + err.Error()
3411+ i.mu.Unlock()
3412+ return
3413+ }
3414+ }
3415+ i.mu.Lock()
3416+ label := "above input"
3417+ if position == tui.StatusPositionBelowInput {
3418+ label = "below input"
3419+ }
3420+ i.statusOK = "status position " + label
3421+ i.statusErr = ""
3422+ i.mu.Unlock()
3423+}
3424+
3425+func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
3426+ defer func() {
3427+ if i.rend != nil {
3428+ i.rend.Clear()
3429+ }
3430+ i.invalidate()
3431+ }()
3432+ position = tui.NormalizeWorkingPosition(position)
3433+ i.cfg.TUIWorkingPosition = position
3434+ if i.cfg.SettingsStore != nil {
3435+ if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
3436+ i.mu.Lock()
3437+ i.statusErr = "settings: " + err.Error()
3438+ i.mu.Unlock()
3439+ return
3440+ }
3441+ }
3442+ i.mu.Lock()
3443+ label := "above input"
3444+ if position == tui.WorkingPositionBelowInput {
3445+ label = "below input"
3446+ }
3447+ i.statusOK = "working spinner position " + label
3448+ i.statusErr = ""
3449+ i.mu.Unlock()
3450+}
3451+
32243452 func (i *Interactive) applyThemeSetting(name string) {
32253453 if i.cfg.SettingsStore != nil {
32263454 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
@@ -3269,6 +3497,7 @@ func (i *Interactive) applyThemeNow(name string) {
32693497 i.view.Theme = th
32703498 i.view.InvalidateRenderCache()
32713499 i.ed.Prompt = th.AccentBar(th.Accent)
3500+ i.applyInputCursorColor()
32723501 i.spin.Configure(th)
32733502 if i.rend != nil {
32743503 i.rend.SetTheme(th)
@@ -4086,7 +4315,7 @@ func (i *Interactive) openBtwDialog(args []string) {
40864315 return
40874316 }
40884317 seed := strings.TrimSpace(strings.Join(args, " "))
4089- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.invalidate)
4318+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
40904319 i.invalidate()
40914320 }
40924321
@@ -75,6 +75,17 @@ type InteractiveConfig struct {
75 // When true, it enables both flat tool calls and compact user turns.75 // When true, it enables both flat tool calls and compact user turns.
76 CompactMode *bool76 CompactMode *bool
77 77
78+ // TUIInputStyle controls the main input rendering: plain or lines.
79+ TUIInputStyle string
80+
81+ // TUIStatusPosition controls whether the status block renders above
82+ // or below the main input.
83+ TUIStatusPosition string
84+
85+ // TUIWorkingPosition controls whether the busy/working spinner renders
86+ // above or below the main input.
87+ TUIWorkingPosition string
88+
78 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The89 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
79 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal90 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
80 // forwards Command/Super keypresses, but Ctrl is the displayed chord.91 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -270,6 +281,9 @@ type SettingsStore interface {
270 SetRecursiveFileSuggest(enabled bool) error281 SetRecursiveFileSuggest(enabled bool) error
271 SetRespectGitignore(enabled bool) error282 SetRespectGitignore(enabled bool) error
272 SetCompactMode(enabled bool) error283 SetCompactMode(enabled bool) error
284+ SetTUIInputStyle(style string) error
285+ SetTUIStatusPosition(position string) error
286+ SetTUIWorkingPosition(position string) error
273 SetReasoning(level string) error287 SetReasoning(level string) error
274 SetTheme(name string) error288 SetTheme(name string) error
275 }289 }
@@ -415,9 +429,8 @@ type Interactive struct {
415 parkedTotal int429 parkedTotal int
416 430
417 // inputHistoryIndex is -1 when not browsing history. When the431 // inputHistoryIndex is -1 when not browsing history. When the
418- // editor is empty, Left/Right can walk previous user prompts for432+ // editor is empty, Up/Down can walk previous user prompts without
419- // quick manual testing without stealing normal cursor movement in433+ // stealing normal vertical cursor movement in non-empty input.
420- // non-empty input.
421 inputHistoryIndex int434 inputHistoryIndex int
422 435
423 // lastCtrlC is when the user last pressed ctrl+c. The first press436 // lastCtrlC is when the user last pressed ctrl+c. The first press
@@ -580,7 +593,8 @@ func (i *Interactive) Run(ctx context.Context) error {
580 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {593 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
581 _, _ = term.Write([]byte(seq))594 _, _ = term.Write([]byte(seq))
582 }595 }
583- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))596+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
597+ i.applyInputCursorColor()
584 598
585 // Streaming pacer: drains buffered text deltas at a steady rate599 // Streaming pacer: drains buffered text deltas at a steady rate
586 // so typewriter feel is identical across providers regardless of600 // so typewriter feel is identical across providers regardless of
@@ -1212,13 +1226,20 @@ func (i *Interactive) redraw() {
1212 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {1226 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1213 ctxMax = m.ContextWindow1227 ctxMax = m.ContextWindow
1214 }1228 }
1229+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1230+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1231+ workingWithStatus := statusPosition == workingPosition
1232+ statusBusyPrefix := ""
1233+ if workingWithStatus {
1234+ statusBusyPrefix = busyPrefix
1235+ }
1215 statusLines := tui.StatusBar(tui.StatusBarParams{1236 statusLines := tui.StatusBar(tui.StatusBarParams{
1216 Theme: i.cfg.Theme,1237 Theme: i.cfg.Theme,
1217 Provider: i.cfg.Provider,1238 Provider: i.cfg.Provider,
1218 Model: i.cfg.Model,1239 Model: i.cfg.Model,
1219 Reasoning: i.cfg.Reasoning,1240 Reasoning: i.cfg.Reasoning,
1220 Busy: i.busy,1241 Busy: i.busy,
1221- BusyPrefix: busyPrefix,1242+ BusyPrefix: statusBusyPrefix,
1222 CWD: i.cfg.CWD,1243 CWD: i.cfg.CWD,
1223 Locked: i.cfg.Sandbox.Locked(),1244 Locked: i.cfg.Sandbox.Locked(),
1224 NoYolo: i.cfg.NoYolo,1245 NoYolo: i.cfg.NoYolo,
@@ -1230,7 +1251,21 @@ func (i *Interactive) redraw() {
1230 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),1251 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1231 Cols: cols,1252 Cols: cols,
1232 })1253 })
1254+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1255+ i.ed.Prompt = ""
1256+ } else {
1257+ i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1258+ }
1233 edLines, curR, curC := i.ed.Render(cols)1259 edLines, curR, curC := i.ed.Render(cols)
1260+ var workingLines []string
1261+ if busyPrefix != "" && !workingWithStatus {
1262+ workingLines = []string{" " + busyPrefix}
1263+ }
1264+ inputCursorOffset := 0
1265+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1266+ edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1267+ inputCursorOffset = 1
1268+ }
1234 1269
1235 // "Sliding in" chips for messages the user typed while a turn is1270 // "Sliding in" chips for messages the user typed while a turn is
1236 // in flight. Shown directly above the status bar so they're close1271 // in flight. Shown directly above the status bar so they're close
@@ -1261,7 +1296,8 @@ func (i *Interactive) redraw() {
1261 // content. The status block and editor get their own dedicated1296 // content. The status block and editor get their own dedicated
1262 // blanks so spacing stays consistent whether or not a dialog or1297 // blanks so spacing stays consistent whether or not a dialog or
1263 // popup is showing.1298 // popup is showing.
1264- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)1299+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1300+ inputStartRow := -1
1265 if len(dialog) > 0 {1301 if len(dialog) > 0 {
1266 bottom = append(bottom, "")1302 bottom = append(bottom, "")
1267 }1303 }
@@ -1277,10 +1313,42 @@ func (i *Interactive) redraw() {
1277 if !i.swarmDialog.Active() {1313 if !i.swarmDialog.Active() {
1278 bottom = append(bottom, suggest...)1314 bottom = append(bottom, suggest...)
1279 bottom = append(bottom, queue...)1315 bottom = append(bottom, queue...)
1316+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
1317+ statusBelow := statusPosition == tui.StatusPositionBelowInput
1318+ workingBelow := workingPosition == tui.WorkingPositionBelowInput
1319+
1320+ var aboveInput []string
1321+ if !statusBelow {
1322+ aboveInput = append(aboveInput, statusLines...)
1323+ }
1324+ if !workingBelow {
1325+ aboveInput = append(aboveInput, workingLines...)
1326+ }
1327+ var belowInput []string
1328+ if workingBelow {
1329+ belowInput = append(belowInput, workingLines...)
1330+ }
1331+ if statusBelow {
1332+ belowInput = append(belowInput, statusLines...)
1333+ }
1334+
1280 bottom = append(bottom, "")1335 bottom = append(bottom, "")
1281- bottom = append(bottom, statusLines...)1336+ bottom = append(bottom, aboveInput...)
1282- bottom = append(bottom, "")1337+ needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1338+ if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1339+ needsGapAboveInput = true
1340+ }
1341+ if needsGapAboveInput {
1342+ bottom = append(bottom, "")
1343+ }
1344+ inputStartRow = len(bottom)
1283 bottom = append(bottom, edLines...)1345 bottom = append(bottom, edLines...)
1346+ if len(belowInput) > 0 {
1347+ if !lineInput {
1348+ bottom = append(bottom, "")
1349+ }
1350+ bottom = append(bottom, belowInput...)
1351+ }
1284 }1352 }
1285 1353
1286 _, rows := i.cfg.Terminal.Size()1354 _, rows := i.cfg.Terminal.Size()
@@ -1437,12 +1505,7 @@ func (i *Interactive) redraw() {
1437 if len(dialog) > 0 {1505 if len(dialog) > 0 {
1438 dialogLead = 11506 dialogLead = 1
1439 }1507 }
1440- // +2 accounts for the blank row above statusLines (so the1508+ cursorRow := inputStartRow + inputCursorOffset + curR
1441- // status block has air above it) and the blank row between
1442- // statusLines and edLines (input breathing room). Without
1443- // these the rendered cursor would land on a blank instead of
1444- // inside the editor row.
1445- cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1446 cursorCol := curC1509 cursorCol := curC
1447 if i.btwDialog.Active() {1510 if i.btwDialog.Active() {
1448 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {1511 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
@@ -2127,16 +2190,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2127 i.invalidate()2190 i.invalidate()
2128 return false2191 return false
2129 }2192 }
2130- i.mu.Unlock()
2131 }2193 }
2132 // In multi-line / wrapped input, Up first moves inside the editor.2194 // In multi-line / wrapped input, Up first moves inside the editor.
2133- // At the editor's top edge it falls back to chat scrolling, preserving2195+ // At the editor's top edge, plain Up can browse input history when
2134- // the old single-line scroll behavior.2196+ // history browsing is safe/active; otherwise it falls back to chat
2197+ // scrolling, preserving the old single-line scroll behavior.
2135 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {2198 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2136 if i.ed.MoveVertical(-1) {2199 if i.ed.MoveVertical(-1) {
2137 i.invalidate()2200 i.invalidate()
2138 return false2201 return false
2139 }2202 }
2203+ if !k.Alt && i.handleInputHistoryKey(k) {
2204+ return false
2205+ }
2140 i.scrollBy(+3)2206 i.scrollBy(+3)
2141 return false2207 return false
2142 }2208 }
@@ -2146,6 +2212,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2146 i.invalidate()2212 i.invalidate()
2147 return false2213 return false
2148 }2214 }
2215+ if !k.Alt && i.handleInputHistoryKey(k) {
2216+ return false
2217+ }
2149 if i.scrollOffset > 0 {2218 if i.scrollOffset > 0 {
2150 i.scrollBy(-3)2219 i.scrollBy(-3)
2151 }2220 }
@@ -2268,10 +2337,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2268 }2337 }
2269 }2338 }
2270 2339
2271- if i.handleInputHistoryKey(k) {2340+ if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2272- return false
2273- }
2274- if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2275 i.inputHistoryIndex = -12341 i.inputHistoryIndex = -1
2276 }2342 }
2277 2343
@@ -2407,11 +2473,11 @@ func (i *Interactive) pasteClipboard() {
2407 }2473 }
2408 2474
2409 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {2475 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2410- if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {2476+ if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2411 return false2477 return false
2412 }2478 }
2413- // Do not steal normal cursor movement. History browsing can only2479+ // Do not steal normal vertical cursor movement. History browsing can only
2414- // start from an empty editor; once active, Left/Right keep walking2480+ // start from an empty editor; once active, Up/Down keep walking
2415 // the ring so repeated presses work even though the editor now2481 // the ring so repeated presses work even though the editor now
2416 // contains the selected historical prompt.2482 // contains the selected historical prompt.
2417 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {2483 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
@@ -2423,17 +2489,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2423 }2489 }
2424 2490
2425 if i.inputHistoryIndex < 0 {2491 if i.inputHistoryIndex < 0 {
2426- // Start just after the newest item so Left lands on the most2492+ // Start just after the newest item so Up lands on the most
2427- // recent user prompt and Right keeps the editor empty.2493+ // recent user prompt. A lone Down from an empty editor is not
2494+ // history navigation; let the caller fall through to normal UI
2495+ // behavior instead.
2496+ if k.Kind != tui.KeyUp {
2497+ return false
2498+ }
2428 i.inputHistoryIndex = len(hist)2499 i.inputHistoryIndex = len(hist)
2429 }2500 }
2430 2501
2431 switch k.Kind {2502 switch k.Kind {
2432- case tui.KeyLeft:2503+ case tui.KeyUp:
2433 if i.inputHistoryIndex > 0 {2504 if i.inputHistoryIndex > 0 {
2434 i.inputHistoryIndex--2505 i.inputHistoryIndex--
2435 }2506 }
2436- case tui.KeyRight:2507+ case tui.KeyDown:
2437 if i.inputHistoryIndex < len(hist) {2508 if i.inputHistoryIndex < len(hist) {
2438 i.inputHistoryIndex++2509 i.inputHistoryIndex++
2439 }2510 }
@@ -2796,6 +2867,13 @@ func onOff(v bool) string {
2796 return "disabled"2867 return "disabled"
2797 }2868 }
2798 2869
2870+func (i *Interactive) applyInputCursorColor() {
2871+ if i == nil || i.cfg.Terminal == nil {
2872+ return
2873+ }
2874+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
2875+}
2876+
2799 func (i *Interactive) openSettingsDialog() {2877 func (i *Interactive) openSettingsDialog() {
2800 detected := tui.DetectImageProtocol()2878 detected := tui.DetectImageProtocol()
2801 imgEnabled := detected != tui.ImageProtocolNone2879 imgEnabled := detected != tui.ImageProtocolNone
@@ -2825,6 +2903,9 @@ func (i *Interactive) openSettingsDialog() {
2825 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest2903 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2826 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore2904 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2827 compactMode := i.compactModeEnabled()2905 compactMode := i.compactModeEnabled()
2906+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
2907+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
2908+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
2828 quickItems := i.quickModelSettingItems()2909 quickItems := i.quickModelSettingItems()
2829 2910
2830 reasoningOptions := []settingsOption{2911 reasoningOptions := []settingsOption{
@@ -2873,6 +2954,41 @@ func (i *Interactive) openSettingsDialog() {
2873 }2954 }
2874 }2955 }
2875 2956
2957+ inputStyleOptions := []settingsOption{
2958+ {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
2959+ {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
2960+ }
2961+ inputStyleChoice := 0
2962+ for idx, opt := range inputStyleOptions {
2963+ if opt.value == inputStyle {
2964+ inputStyleChoice = idx
2965+ break
2966+ }
2967+ }
2968+ statusPositionOptions := []settingsOption{
2969+ {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
2970+ {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
2971+ }
2972+ statusPositionChoice := 0
2973+ for idx, opt := range statusPositionOptions {
2974+ if opt.value == statusPosition {
2975+ statusPositionChoice = idx
2976+ break
2977+ }
2978+ }
2979+
2980+ workingPositionOptions := []settingsOption{
2981+ {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
2982+ {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
2983+ }
2984+ workingPositionChoice := 0
2985+ for idx, opt := range workingPositionOptions {
2986+ if opt.value == workingPosition {
2987+ workingPositionChoice = idx
2988+ break
2989+ }
2990+ }
2991+
2876 items := []settingsItem{2992 items := []settingsItem{
2877 {2993 {
2878 key: "inline_images_enabled",2994 key: "inline_images_enabled",
@@ -2908,6 +3024,34 @@ func (i *Interactive) openSettingsDialog() {
2908 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",3024 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
2909 value: compactMode,3025 value: compactMode,
2910 },3026 },
3027+ {
3028+ key: "tui_settings",
3029+ label: "tui settings",
3030+ desc: "choose input chrome and where status information appears",
3031+ children: []settingsItem{
3032+ {
3033+ key: "tui_input_style",
3034+ label: "input style",
3035+ desc: "choose between the plain prompt and a lined input area",
3036+ options: inputStyleOptions,
3037+ choice: inputStyleChoice,
3038+ },
3039+ {
3040+ key: "tui_status_position",
3041+ label: "status position",
3042+ desc: "place model, usage, and working directory above or below the input",
3043+ options: statusPositionOptions,
3044+ choice: statusPositionChoice,
3045+ },
3046+ {
3047+ key: "tui_working_position",
3048+ label: "working spinner position",
3049+ desc: "place the working spinner above or below the input",
3050+ options: workingPositionOptions,
3051+ choice: workingPositionChoice,
3052+ },
3053+ },
3054+ },
2911 {3055 {
2912 key: "reasoning",3056 key: "reasoning",
2913 label: "thinking level",3057 label: "thinking level",
@@ -2943,6 +3087,12 @@ func (i *Interactive) applySettingChange(act settingsAction) {
2943 i.applyReasoningSetting(act.StringValue)3087 i.applyReasoningSetting(act.StringValue)
2944 case act.Key == "theme":3088 case act.Key == "theme":
2945 i.applyThemeSetting(act.StringValue)3089 i.applyThemeSetting(act.StringValue)
3090+ case act.Key == "tui_input_style":
3091+ i.applyTUIInputStyleSetting(act.StringValue)
3092+ case act.Key == "tui_status_position":
3093+ i.applyTUIStatusPositionSetting(act.StringValue)
3094+ case act.Key == "tui_working_position":
3095+ i.applyTUIWorkingPositionSetting(act.StringValue)
2946 default:3096 default:
2947 i.applySettingToggle(act.Key, act.Value)3097 i.applySettingToggle(act.Key, act.Value)
2948 }3098 }
@@ -3221,6 +3371,84 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3221 }3371 }
3222 }3372 }
3223 3373
3374+func (i *Interactive) applyTUIInputStyleSetting(style string) {
3375+ defer func() {
3376+ if i.rend != nil {
3377+ i.rend.Clear()
3378+ }
3379+ i.invalidate()
3380+ }()
3381+ style = tui.NormalizeInputStyle(style)
3382+ i.cfg.TUIInputStyle = style
3383+ i.applyInputCursorColor()
3384+ if i.cfg.SettingsStore != nil {
3385+ if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
3386+ i.mu.Lock()
3387+ i.statusErr = "settings: " + err.Error()
3388+ i.mu.Unlock()
3389+ return
3390+ }
3391+ }
3392+ i.mu.Lock()
3393+ i.statusOK = "input style " + style
3394+ i.statusErr = ""
3395+ i.mu.Unlock()
3396+}
3397+
3398+func (i *Interactive) applyTUIStatusPositionSetting(position string) {
3399+ defer func() {
3400+ if i.rend != nil {
3401+ i.rend.Clear()
3402+ }
3403+ i.invalidate()
3404+ }()
3405+ position = tui.NormalizeStatusPosition(position)
3406+ i.cfg.TUIStatusPosition = position
3407+ if i.cfg.SettingsStore != nil {
3408+ if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
3409+ i.mu.Lock()
3410+ i.statusErr = "settings: " + err.Error()
3411+ i.mu.Unlock()
3412+ return
3413+ }
3414+ }
3415+ i.mu.Lock()
3416+ label := "above input"
3417+ if position == tui.StatusPositionBelowInput {
3418+ label = "below input"
3419+ }
3420+ i.statusOK = "status position " + label
3421+ i.statusErr = ""
3422+ i.mu.Unlock()
3423+}
3424+
3425+func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
3426+ defer func() {
3427+ if i.rend != nil {
3428+ i.rend.Clear()
3429+ }
3430+ i.invalidate()
3431+ }()
3432+ position = tui.NormalizeWorkingPosition(position)
3433+ i.cfg.TUIWorkingPosition = position
3434+ if i.cfg.SettingsStore != nil {
3435+ if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
3436+ i.mu.Lock()
3437+ i.statusErr = "settings: " + err.Error()
3438+ i.mu.Unlock()
3439+ return
3440+ }
3441+ }
3442+ i.mu.Lock()
3443+ label := "above input"
3444+ if position == tui.WorkingPositionBelowInput {
3445+ label = "below input"
3446+ }
3447+ i.statusOK = "working spinner position " + label
3448+ i.statusErr = ""
3449+ i.mu.Unlock()
3450+}
3451+
3224 func (i *Interactive) applyThemeSetting(name string) {3452 func (i *Interactive) applyThemeSetting(name string) {
3225 if i.cfg.SettingsStore != nil {3453 if i.cfg.SettingsStore != nil {
3226 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {3454 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
@@ -3269,6 +3497,7 @@ func (i *Interactive) applyThemeNow(name string) {
3269 i.view.Theme = th3497 i.view.Theme = th
3270 i.view.InvalidateRenderCache()3498 i.view.InvalidateRenderCache()
3271 i.ed.Prompt = th.AccentBar(th.Accent)3499 i.ed.Prompt = th.AccentBar(th.Accent)
3500+ i.applyInputCursorColor()
3272 i.spin.Configure(th)3501 i.spin.Configure(th)
3273 if i.rend != nil {3502 if i.rend != nil {
3274 i.rend.SetTheme(th)3503 i.rend.SetTheme(th)
@@ -4086,7 +4315,7 @@ func (i *Interactive) openBtwDialog(args []string) {
4086 return4315 return
4087 }4316 }
4088 seed := strings.TrimSpace(strings.Join(args, " "))4317 seed := strings.TrimSpace(strings.Join(args, " "))
4089- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.invalidate)4318+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
4090 i.invalidate()4319 i.invalidate()
4091 }4320 }
4092 4321
M packages/agent/modes/swarm_dialog.go +19 -5
@@ -34,6 +34,7 @@ import (
3434 type swarmDialog struct {
3535 active bool
3636 compactMode bool
37+ lineInput bool
3738 snapshot func() []swarm.AgentSnapshot
3839 stop func(id string) error
3940 remove func(id string) error
@@ -124,6 +125,17 @@ func (d *swarmDialog) SetCompactMode(enabled bool) {
124125 d.compactMode = enabled
125126 }
126127
128+func (d *swarmDialog) SetLineInput(enabled bool) {
129+ d.lineInput = enabled
130+}
131+
132+func (d *swarmDialog) editorPrompt(th tui.Theme) string {
133+ if d.lineInput {
134+ return ""
135+ }
136+ return th.AccentBar(th.Accent)
137+}
138+
127139 func (d *swarmDialog) SetCurrentModel(model, providerID string) {
128140 d.pendingModel = model
129141 d.pendingProvider = providerID
@@ -200,6 +212,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
200212 if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {
201213 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
202214 }
215+ d.newTaskEd.Prompt = d.editorPrompt(tui.Theme{})
203216 _, eRow, eCol := d.newTaskEd.Render(width - 2)
204217 const headerRows = 1
205218 const editorBlankRow = 1
@@ -228,6 +241,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
228241 if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {
229242 popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))
230243 }
244+ d.promptEd.Prompt = d.editorPrompt(tui.Theme{})
231245 _, eRow, eCol := d.promptEd.Render(width - 2)
232246
233247 if d.viewing {
@@ -629,7 +643,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
629643 // file/dir chips, cursor movement, and the same accent-bar prompt style.
630644 func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
631645 d.spawning = true
632- d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent))
646+ d.newTaskEd = tui.NewEditor(d.editorPrompt(th))
633647 d.fileSuggest = newFileSuggester()
634648 d.fileSuggest.SetCWD(d.cwd)
635649 if d.spawnDraft != "" {
@@ -717,7 +731,7 @@ func stripSlashModelLine(buf string) string {
717731 // editing experience (paste, @-picker, drop chips) is identical.
718732 func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
719733 d.prompting = true
720- d.promptEd = tui.NewEditor(th.AccentBar(th.Accent))
734+ d.promptEd = tui.NewEditor(d.editorPrompt(th))
721735 d.promptSuggest = newFileSuggester()
722736 d.promptSuggest.SetCWD(d.cwd)
723737 d.promptTargetID = targetID
@@ -1003,7 +1017,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
10031017 d.openSpawnEditor(th)
10041018 }
10051019 if d.newTaskEd != nil {
1006- d.newTaskEd.Prompt = th.AccentBar(th.Accent)
1020+ d.newTaskEd.Prompt = d.editorPrompt(th)
10071021 }
10081022 // One-line banner reminding the user which model the
10091023 // agent-to-be will run against. Skipped when no model was
@@ -1149,7 +1163,7 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
11491163 d.openPromptEditor(th, a.ID)
11501164 }
11511165 if d.promptEd != nil {
1152- d.promptEd.Prompt = th.AccentBar(th.Accent)
1166+ d.promptEd.Prompt = d.editorPrompt(th)
11531167 }
11541168 if d.promptSuggest != nil {
11551169 d.promptSuggest.SetCWD(d.cwd)
@@ -1328,7 +1342,7 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
13281342 d.openPromptEditor(th, d.promptTargetID)
13291343 }
13301344 if d.promptEd != nil {
1331- d.promptEd.Prompt = th.AccentBar(th.Accent)
1345+ d.promptEd.Prompt = d.editorPrompt(th)
13321346 }
13331347 target := d.promptTargetID
13341348 if target == "" {
@@ -34,6 +34,7 @@ import (
34 type swarmDialog struct {34 type swarmDialog struct {
35 active bool35 active bool
36 compactMode bool36 compactMode bool
37+ lineInput bool
37 snapshot func() []swarm.AgentSnapshot38 snapshot func() []swarm.AgentSnapshot
38 stop func(id string) error39 stop func(id string) error
39 remove func(id string) error40 remove func(id string) error
@@ -124,6 +125,17 @@ func (d *swarmDialog) SetCompactMode(enabled bool) {
124 d.compactMode = enabled125 d.compactMode = enabled
125 }126 }
126 127
128+func (d *swarmDialog) SetLineInput(enabled bool) {
129+ d.lineInput = enabled
130+}
131+
132+func (d *swarmDialog) editorPrompt(th tui.Theme) string {
133+ if d.lineInput {
134+ return ""
135+ }
136+ return th.AccentBar(th.Accent)
137+}
138+
127 func (d *swarmDialog) SetCurrentModel(model, providerID string) {139 func (d *swarmDialog) SetCurrentModel(model, providerID string) {
128 d.pendingModel = model140 d.pendingModel = model
129 d.pendingProvider = providerID141 d.pendingProvider = providerID
@@ -200,6 +212,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
200 if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {212 if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {
201 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))213 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
202 }214 }
215+ d.newTaskEd.Prompt = d.editorPrompt(tui.Theme{})
203 _, eRow, eCol := d.newTaskEd.Render(width - 2)216 _, eRow, eCol := d.newTaskEd.Render(width - 2)
204 const headerRows = 1217 const headerRows = 1
205 const editorBlankRow = 1218 const editorBlankRow = 1
@@ -228,6 +241,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
228 if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {241 if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {
229 popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))242 popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))
230 }243 }
244+ d.promptEd.Prompt = d.editorPrompt(tui.Theme{})
231 _, eRow, eCol := d.promptEd.Render(width - 2)245 _, eRow, eCol := d.promptEd.Render(width - 2)
232 246
233 if d.viewing {247 if d.viewing {
@@ -629,7 +643,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
629 // file/dir chips, cursor movement, and the same accent-bar prompt style.643 // file/dir chips, cursor movement, and the same accent-bar prompt style.
630 func (d *swarmDialog) openSpawnEditor(th tui.Theme) {644 func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
631 d.spawning = true645 d.spawning = true
632- d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent))646+ d.newTaskEd = tui.NewEditor(d.editorPrompt(th))
633 d.fileSuggest = newFileSuggester()647 d.fileSuggest = newFileSuggester()
634 d.fileSuggest.SetCWD(d.cwd)648 d.fileSuggest.SetCWD(d.cwd)
635 if d.spawnDraft != "" {649 if d.spawnDraft != "" {
@@ -717,7 +731,7 @@ func stripSlashModelLine(buf string) string {
717 // editing experience (paste, @-picker, drop chips) is identical.731 // editing experience (paste, @-picker, drop chips) is identical.
718 func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {732 func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
719 d.prompting = true733 d.prompting = true
720- d.promptEd = tui.NewEditor(th.AccentBar(th.Accent))734+ d.promptEd = tui.NewEditor(d.editorPrompt(th))
721 d.promptSuggest = newFileSuggester()735 d.promptSuggest = newFileSuggester()
722 d.promptSuggest.SetCWD(d.cwd)736 d.promptSuggest.SetCWD(d.cwd)
723 d.promptTargetID = targetID737 d.promptTargetID = targetID
@@ -1003,7 +1017,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
1003 d.openSpawnEditor(th)1017 d.openSpawnEditor(th)
1004 }1018 }
1005 if d.newTaskEd != nil {1019 if d.newTaskEd != nil {
1006- d.newTaskEd.Prompt = th.AccentBar(th.Accent)1020+ d.newTaskEd.Prompt = d.editorPrompt(th)
1007 }1021 }
1008 // One-line banner reminding the user which model the1022 // One-line banner reminding the user which model the
1009 // agent-to-be will run against. Skipped when no model was1023 // agent-to-be will run against. Skipped when no model was
@@ -1149,7 +1163,7 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
1149 d.openPromptEditor(th, a.ID)1163 d.openPromptEditor(th, a.ID)
1150 }1164 }
1151 if d.promptEd != nil {1165 if d.promptEd != nil {
1152- d.promptEd.Prompt = th.AccentBar(th.Accent)1166+ d.promptEd.Prompt = d.editorPrompt(th)
1153 }1167 }
1154 if d.promptSuggest != nil {1168 if d.promptSuggest != nil {
1155 d.promptSuggest.SetCWD(d.cwd)1169 d.promptSuggest.SetCWD(d.cwd)
@@ -1328,7 +1342,7 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
1328 d.openPromptEditor(th, d.promptTargetID)1342 d.openPromptEditor(th, d.promptTargetID)
1329 }1343 }
1330 if d.promptEd != nil {1344 if d.promptEd != nil {
1331- d.promptEd.Prompt = th.AccentBar(th.Accent)1345+ d.promptEd.Prompt = d.editorPrompt(th)
1332 }1346 }
1333 target := d.promptTargetID1347 target := d.promptTargetID
1334 if target == "" {1348 if target == "" {
M packages/agent/modes/swarm_slash.go +2 -0
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/tui"
910 )
1011
1112 // runSwarm dispatches /swarm subcommands. Layout:
@@ -70,6 +71,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
7071 // via /model first (globally), or, while inside the spawn
7172 // editor, by typing /model on its own line to pop the picker.
7273 i.swarmDialog.SetCompactMode(i.compactModeEnabled())
74+ i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
7375 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
7476 if i.cfg.LoggedInProviders != nil {
7577 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/patriceckhart/zot/packages/agent/swarm"8 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/tui"
9 )10 )
10 11
11 // runSwarm dispatches /swarm subcommands. Layout:12 // runSwarm dispatches /swarm subcommands. Layout:
@@ -70,6 +71,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
70 // via /model first (globally), or, while inside the spawn71 // via /model first (globally), or, while inside the spawn
71 // editor, by typing /model on its own line to pop the picker.72 // editor, by typing /model on its own line to pop the picker.
72 i.swarmDialog.SetCompactMode(i.compactModeEnabled())73 i.swarmDialog.SetCompactMode(i.compactModeEnabled())
74+ i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
73 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)75 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
74 if i.cfg.LoggedInProviders != nil {76 if i.cfg.LoggedInProviders != nil {
75 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())77 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
M packages/agent/settings_store.go +46 -1
@@ -1,6 +1,9 @@
11 package agent
22
3-import "github.com/patriceckhart/zot/packages/provider"
3+import (
4+ "github.com/patriceckhart/zot/packages/provider"
5+ "github.com/patriceckhart/zot/packages/tui"
6+)
47
58 type configSettingsStore struct{}
69
@@ -74,6 +77,48 @@ func (configSettingsStore) SetCompactMode(enabled bool) error {
7477 return SaveConfig(cfg)
7578 }
7679
80+func (configSettingsStore) SetTUIInputStyle(style string) error {
81+ cfg, err := LoadConfig()
82+ if err != nil {
83+ return err
84+ }
85+ style = tui.NormalizeInputStyle(style)
86+ if style == tui.InputStylePlain {
87+ cfg.TUIInputStyle = ""
88+ } else {
89+ cfg.TUIInputStyle = style
90+ }
91+ return SaveConfig(cfg)
92+}
93+
94+func (configSettingsStore) SetTUIStatusPosition(position string) error {
95+ cfg, err := LoadConfig()
96+ if err != nil {
97+ return err
98+ }
99+ position = tui.NormalizeStatusPosition(position)
100+ if position == tui.StatusPositionAboveInput {
101+ cfg.TUIStatusPosition = ""
102+ } else {
103+ cfg.TUIStatusPosition = position
104+ }
105+ return SaveConfig(cfg)
106+}
107+
108+func (configSettingsStore) SetTUIWorkingPosition(position string) error {
109+ cfg, err := LoadConfig()
110+ if err != nil {
111+ return err
112+ }
113+ position = tui.NormalizeWorkingPosition(position)
114+ if position == tui.WorkingPositionAboveInput {
115+ cfg.TUIWorkingPosition = ""
116+ } else {
117+ cfg.TUIWorkingPosition = position
118+ }
119+ return SaveConfig(cfg)
120+}
121+
77122 func (configSettingsStore) SetReasoning(level string) error {
78123 cfg, err := LoadConfig()
79124 if err != nil {
@@ -1,6 +1,9 @@
1 package agent1 package agent
2 2
3-import "github.com/patriceckhart/zot/packages/provider"3+import (
4+ "github.com/patriceckhart/zot/packages/provider"
5+ "github.com/patriceckhart/zot/packages/tui"
6+)
4 7
5 type configSettingsStore struct{}8 type configSettingsStore struct{}
6 9
@@ -74,6 +77,48 @@ func (configSettingsStore) SetCompactMode(enabled bool) error {
74 return SaveConfig(cfg)77 return SaveConfig(cfg)
75 }78 }
76 79
80+func (configSettingsStore) SetTUIInputStyle(style string) error {
81+ cfg, err := LoadConfig()
82+ if err != nil {
83+ return err
84+ }
85+ style = tui.NormalizeInputStyle(style)
86+ if style == tui.InputStylePlain {
87+ cfg.TUIInputStyle = ""
88+ } else {
89+ cfg.TUIInputStyle = style
90+ }
91+ return SaveConfig(cfg)
92+}
93+
94+func (configSettingsStore) SetTUIStatusPosition(position string) error {
95+ cfg, err := LoadConfig()
96+ if err != nil {
97+ return err
98+ }
99+ position = tui.NormalizeStatusPosition(position)
100+ if position == tui.StatusPositionAboveInput {
101+ cfg.TUIStatusPosition = ""
102+ } else {
103+ cfg.TUIStatusPosition = position
104+ }
105+ return SaveConfig(cfg)
106+}
107+
108+func (configSettingsStore) SetTUIWorkingPosition(position string) error {
109+ cfg, err := LoadConfig()
110+ if err != nil {
111+ return err
112+ }
113+ position = tui.NormalizeWorkingPosition(position)
114+ if position == tui.WorkingPositionAboveInput {
115+ cfg.TUIWorkingPosition = ""
116+ } else {
117+ cfg.TUIWorkingPosition = position
118+ }
119+ return SaveConfig(cfg)
120+}
121+
77 func (configSettingsStore) SetReasoning(level string) error {122 func (configSettingsStore) SetReasoning(level string) error {
78 cfg, err := LoadConfig()123 cfg, err := LoadConfig()
79 if err != nil {124 if err != nil {
A packages/tui/layout.go +101 -0
@@ -0,0 +1,101 @@
1+package tui
2+
3+import "strings"
4+
5+const (
6+ InputStylePlain = "plain"
7+ InputStyleLines = "lines"
8+
9+ StatusPositionAboveInput = "above_input"
10+ StatusPositionBelowInput = "below_input"
11+
12+ WorkingPositionAboveInput = "above_input"
13+ WorkingPositionBelowInput = "below_input"
14+)
15+
16+func NormalizeInputStyle(v string) string {
17+ switch strings.ToLower(strings.TrimSpace(v)) {
18+ case InputStyleLines, "line", "boxed", "box":
19+ return InputStyleLines
20+ default:
21+ return InputStylePlain
22+ }
23+}
24+
25+func NormalizeStatusPosition(v string) string {
26+ switch strings.ToLower(strings.TrimSpace(v)) {
27+ case StatusPositionBelowInput, "below", "bottom":
28+ return StatusPositionBelowInput
29+ default:
30+ return StatusPositionAboveInput
31+ }
32+}
33+
34+func NormalizeWorkingPosition(v string) string {
35+ switch strings.ToLower(strings.TrimSpace(v)) {
36+ case WorkingPositionBelowInput, "below", "bottom":
37+ return WorkingPositionBelowInput
38+ default:
39+ return WorkingPositionAboveInput
40+ }
41+}
42+
43+func InputLines(th Theme, lines []string, width int) []string {
44+ if width < 1 {
45+ width = 1
46+ }
47+ rule := th.FG256(th.Muted, strings.Repeat("─", width))
48+ out := make([]string, 0, len(lines)+2)
49+ out = append(out, rule)
50+ out = append(out, lines...)
51+ out = append(out, rule)
52+ return out
53+}
54+
55+func CursorColor256(index int) string {
56+ r, g, b := xterm256RGB(index)
57+ return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07"
58+}
59+
60+func ResetCursorColor() string { return "\x1b]112\x07" }
61+
62+func CursorShapeBlock() string { return "\x1b[1 q" }
63+
64+func ResetCursorShape() string { return "\x1b[0 q" }
65+
66+func xterm256RGB(index int) (int, int, int) {
67+ if index < 0 {
68+ index = 0
69+ }
70+ if index > 255 {
71+ index = 255
72+ }
73+ ansi := [16][3]int{
74+ {0, 0, 0}, {128, 0, 0}, {0, 128, 0}, {128, 128, 0},
75+ {0, 0, 128}, {128, 0, 128}, {0, 128, 128}, {192, 192, 192},
76+ {128, 128, 128}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0},
77+ {0, 0, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255},
78+ }
79+ if index < 16 {
80+ c := ansi[index]
81+ return c[0], c[1], c[2]
82+ }
83+ if index < 232 {
84+ idx := index - 16
85+ levels := [6]int{0, 95, 135, 175, 215, 255}
86+ return levels[idx/36], levels[(idx/6)%6], levels[idx%6]
87+ }
88+ gray := 8 + (index-232)*10
89+ return gray, gray, gray
90+}
91+
92+func hexByte(v int) string {
93+ const digits = "0123456789abcdef"
94+ if v < 0 {
95+ v = 0
96+ }
97+ if v > 255 {
98+ v = 255
99+ }
100+ return string([]byte{digits[v>>4], digits[v&0xf]})
101+}
@@ -0,0 +1,101 @@
1+package tui
2+
3+import "strings"
4+
5+const (
6+ InputStylePlain = "plain"
7+ InputStyleLines = "lines"
8+
9+ StatusPositionAboveInput = "above_input"
10+ StatusPositionBelowInput = "below_input"
11+
12+ WorkingPositionAboveInput = "above_input"
13+ WorkingPositionBelowInput = "below_input"
14+)
15+
16+func NormalizeInputStyle(v string) string {
17+ switch strings.ToLower(strings.TrimSpace(v)) {
18+ case InputStyleLines, "line", "boxed", "box":
19+ return InputStyleLines
20+ default:
21+ return InputStylePlain
22+ }
23+}
24+
25+func NormalizeStatusPosition(v string) string {
26+ switch strings.ToLower(strings.TrimSpace(v)) {
27+ case StatusPositionBelowInput, "below", "bottom":
28+ return StatusPositionBelowInput
29+ default:
30+ return StatusPositionAboveInput
31+ }
32+}
33+
34+func NormalizeWorkingPosition(v string) string {
35+ switch strings.ToLower(strings.TrimSpace(v)) {
36+ case WorkingPositionBelowInput, "below", "bottom":
37+ return WorkingPositionBelowInput
38+ default:
39+ return WorkingPositionAboveInput
40+ }
41+}
42+
43+func InputLines(th Theme, lines []string, width int) []string {
44+ if width < 1 {
45+ width = 1
46+ }
47+ rule := th.FG256(th.Muted, strings.Repeat("─", width))
48+ out := make([]string, 0, len(lines)+2)
49+ out = append(out, rule)
50+ out = append(out, lines...)
51+ out = append(out, rule)
52+ return out
53+}
54+
55+func CursorColor256(index int) string {
56+ r, g, b := xterm256RGB(index)
57+ return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07"
58+}
59+
60+func ResetCursorColor() string { return "\x1b]112\x07" }
61+
62+func CursorShapeBlock() string { return "\x1b[1 q" }
63+
64+func ResetCursorShape() string { return "\x1b[0 q" }
65+
66+func xterm256RGB(index int) (int, int, int) {
67+ if index < 0 {
68+ index = 0
69+ }
70+ if index > 255 {
71+ index = 255
72+ }
73+ ansi := [16][3]int{
74+ {0, 0, 0}, {128, 0, 0}, {0, 128, 0}, {128, 128, 0},
75+ {0, 0, 128}, {128, 0, 128}, {0, 128, 128}, {192, 192, 192},
76+ {128, 128, 128}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0},
77+ {0, 0, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255},
78+ }
79+ if index < 16 {
80+ c := ansi[index]
81+ return c[0], c[1], c[2]
82+ }
83+ if index < 232 {
84+ idx := index - 16
85+ levels := [6]int{0, 95, 135, 175, 215, 255}
86+ return levels[idx/36], levels[(idx/6)%6], levels[idx%6]
87+ }
88+ gray := 8 + (index-232)*10
89+ return gray, gray, gray
90+}
91+
92+func hexByte(v int) string {
93+ const digits = "0123456789abcdef"
94+ if v < 0 {
95+ v = 0
96+ }
97+ if v > 255 {
98+ v = 255
99+ }
100+ return string([]byte{digits[v>>4], digits[v&0xf]})
101+}
M packages/tui/view.go +91 -8
@@ -747,13 +747,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
747747 // in one assistant message render as N adjacent boxes
748748 // instead of stacking unclosed top edges.
749749 if v.FlatTools || v.CompactMode {
750- lines = append(lines, flatToolHeader(v.Theme, label, width))
750+ if v.CompactMode {
751+ lines = append(lines, compactToolBlank(v.Theme, width))
752+ }
753+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
751754 if tr.IsError {
752- lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))
755+ lines = append(lines, toolBodyLine(v.Theme, v.Theme.FG256(color, " error"), width, v.CompactMode))
753756 }
754757 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
755758 _, stripped := parseImageFootprint(line)
756- lines = append(lines, flatToolBody(v.Theme, stripped))
759+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
760+ }
761+ if v.CompactMode {
762+ lines = append(lines, compactToolBlank(v.Theme, width))
757763 }
758764 continue
759765 }
@@ -829,8 +835,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
829835 for len(body) < high {
830836 body = append(body, "")
831837 }
832- lines = append(lines, flatToolHeader(v.Theme, label, width))
838+ if v.CompactMode {
839+ lines = append(lines, compactToolBlank(v.Theme, width))
840+ }
841+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
833842 lines = append(lines, body...)
843+ if v.CompactMode {
844+ lines = append(lines, compactToolBlank(v.Theme, width))
845+ }
834846 return lines
835847 }
836848 for len(body) < high {
@@ -850,7 +862,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
850862 // for no-output tools.
851863 if tc.Result == "" {
852864 if v.FlatTools || v.CompactMode {
853- lines = append(lines, flatToolHeader(v.Theme, label, width))
865+ if v.CompactMode {
866+ lines = append(lines, compactToolBlank(v.Theme, width))
867+ }
868+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
869+ if v.CompactMode {
870+ lines = append(lines, compactToolBlank(v.Theme, width))
871+ }
854872 return lines
855873 }
856874 lines = append(lines, toolBoxTop(v.Theme, label, width))
@@ -868,11 +886,17 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
868886 color = v.Theme.Error
869887 }
870888 if v.FlatTools || v.CompactMode {
871- lines = append(lines, flatToolHeader(v.Theme, label, width))
889+ if v.CompactMode {
890+ lines = append(lines, compactToolBlank(v.Theme, width))
891+ }
892+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
872893 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
873894 for _, l := range v.collapseToolBody(body, false) {
874895 _, stripped := parseImageFootprint(l)
875- lines = append(lines, flatToolBody(v.Theme, stripped))
896+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
897+ }
898+ if v.CompactMode {
899+ lines = append(lines, compactToolBlank(v.Theme, width))
876900 }
877901 return lines
878902 }
@@ -974,7 +998,7 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
974998 if v.FlatTools || v.CompactMode {
975999 for _, l := range body {
9761000 _, stripped := parseImageFootprint(l)
977- out = append(out, flatToolBody(v.Theme, stripped))
1001+ out = append(out, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
9781002 }
9791003 return out
9801004 }
@@ -1085,6 +1109,13 @@ func splitToolLabel(label string) (name, rest string) {
10851109 // name in the foreground color, and the argument summary muted. The
10861110 // line is left-aligned at the same column the box's opening corner
10871111 // used (toolBoxOuterMargin) so flat and box renders share a column.
1112+func toolHeaderLine(th Theme, label string, width int, compact bool) string {
1113+ if compact {
1114+ return compactToolHeader(th, label, width)
1115+ }
1116+ return flatToolHeader(th, label, width)
1117+}
1118+
10881119 func flatToolHeader(th Theme, label string, width int) string {
10891120 label = oneLineToolLabel(label)
10901121 name, rest := splitToolLabel(label)
@@ -1109,6 +1140,48 @@ func flatToolHeader(th Theme, label string, width int) string {
11091140 return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
11101141 }
11111142
1143+func compactToolBlank(th Theme, width int) string {
1144+ if width < 1 {
1145+ width = 1
1146+ }
1147+ return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1148+}
1149+
1150+func compactToolHeader(th Theme, label string, width int) string {
1151+ label = oneLineToolLabel(label)
1152+ name, rest := splitToolLabel(label)
1153+ pad := " "
1154+ avail := width - visibleWidth(pad)*2
1155+ if avail < 12 {
1156+ avail = 12
1157+ }
1158+ if visibleWidth(name+rest) > avail {
1159+ over := visibleWidth(name+rest) - avail
1160+ runes := []rune(rest)
1161+ if over+3 < len(runes) {
1162+ rest = string(runes[:len(runes)-over-3]) + "..."
1163+ } else if visibleWidth(name) <= avail {
1164+ rest = ""
1165+ }
1166+ }
1167+ plain := pad + name + rest
1168+ visible := visibleWidth(plain)
1169+ fill := ""
1170+ if visible < width {
1171+ fill = strings.Repeat(" ", width-visible)
1172+ }
1173+ bg := sgrBGColor(th.UserBubbleBG)
1174+ return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset
1175+}
1176+
1177+func toolBodyLine(th Theme, line string, width int, compact bool) string {
1178+ line = flatToolBody(th, line)
1179+ if compact {
1180+ return compactToolBody(th, line, width)
1181+ }
1182+ return line
1183+}
1184+
11121185 // flatToolBody indents a single body line for boxless rendering. It
11131186 // trims the same leading padding box bodies do, then prepends a fixed
11141187 // gutter indent so output sits in a readable column under the header
@@ -1119,6 +1192,16 @@ func flatToolBody(th Theme, line string) string {
11191192 return strings.Repeat(" ", toolBoxOuterMargin+2) + line
11201193 }
11211194
1195+func compactToolBody(th Theme, line string, width int) string {
1196+ visible := visibleWidth(line)
1197+ fill := ""
1198+ if visible < width {
1199+ fill = strings.Repeat(" ", width-visible)
1200+ }
1201+ bg := sgrBGColor(th.UserBubbleBG)
1202+ return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1203+}
1204+
11221205 // toolBoxBottom renders the bottom edge of a tool block:
11231206 //
11241207 // └─────────────────────────────────────────────────────────────────────────────┘
@@ -747,13 +747,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
747 // in one assistant message render as N adjacent boxes747 // in one assistant message render as N adjacent boxes
748 // instead of stacking unclosed top edges.748 // instead of stacking unclosed top edges.
749 if v.FlatTools || v.CompactMode {749 if v.FlatTools || v.CompactMode {
750- lines = append(lines, flatToolHeader(v.Theme, label, width))750+ if v.CompactMode {
751+ lines = append(lines, compactToolBlank(v.Theme, width))
752+ }
753+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
751 if tr.IsError {754 if tr.IsError {
752- lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))755+ lines = append(lines, toolBodyLine(v.Theme, v.Theme.FG256(color, " error"), width, v.CompactMode))
753 }756 }
754 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {757 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
755 _, stripped := parseImageFootprint(line)758 _, stripped := parseImageFootprint(line)
756- lines = append(lines, flatToolBody(v.Theme, stripped))759+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
760+ }
761+ if v.CompactMode {
762+ lines = append(lines, compactToolBlank(v.Theme, width))
757 }763 }
758 continue764 continue
759 }765 }
@@ -829,8 +835,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
829 for len(body) < high {835 for len(body) < high {
830 body = append(body, "")836 body = append(body, "")
831 }837 }
832- lines = append(lines, flatToolHeader(v.Theme, label, width))838+ if v.CompactMode {
839+ lines = append(lines, compactToolBlank(v.Theme, width))
840+ }
841+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
833 lines = append(lines, body...)842 lines = append(lines, body...)
843+ if v.CompactMode {
844+ lines = append(lines, compactToolBlank(v.Theme, width))
845+ }
834 return lines846 return lines
835 }847 }
836 for len(body) < high {848 for len(body) < high {
@@ -850,7 +862,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
850 // for no-output tools.862 // for no-output tools.
851 if tc.Result == "" {863 if tc.Result == "" {
852 if v.FlatTools || v.CompactMode {864 if v.FlatTools || v.CompactMode {
853- lines = append(lines, flatToolHeader(v.Theme, label, width))865+ if v.CompactMode {
866+ lines = append(lines, compactToolBlank(v.Theme, width))
867+ }
868+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
869+ if v.CompactMode {
870+ lines = append(lines, compactToolBlank(v.Theme, width))
871+ }
854 return lines872 return lines
855 }873 }
856 lines = append(lines, toolBoxTop(v.Theme, label, width))874 lines = append(lines, toolBoxTop(v.Theme, label, width))
@@ -868,11 +886,17 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
868 color = v.Theme.Error886 color = v.Theme.Error
869 }887 }
870 if v.FlatTools || v.CompactMode {888 if v.FlatTools || v.CompactMode {
871- lines = append(lines, flatToolHeader(v.Theme, label, width))889+ if v.CompactMode {
890+ lines = append(lines, compactToolBlank(v.Theme, width))
891+ }
892+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
872 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)893 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
873 for _, l := range v.collapseToolBody(body, false) {894 for _, l := range v.collapseToolBody(body, false) {
874 _, stripped := parseImageFootprint(l)895 _, stripped := parseImageFootprint(l)
875- lines = append(lines, flatToolBody(v.Theme, stripped))896+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
897+ }
898+ if v.CompactMode {
899+ lines = append(lines, compactToolBlank(v.Theme, width))
876 }900 }
877 return lines901 return lines
878 }902 }
@@ -974,7 +998,7 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
974 if v.FlatTools || v.CompactMode {998 if v.FlatTools || v.CompactMode {
975 for _, l := range body {999 for _, l := range body {
976 _, stripped := parseImageFootprint(l)1000 _, stripped := parseImageFootprint(l)
977- out = append(out, flatToolBody(v.Theme, stripped))1001+ out = append(out, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
978 }1002 }
979 return out1003 return out
980 }1004 }
@@ -1085,6 +1109,13 @@ func splitToolLabel(label string) (name, rest string) {
1085 // name in the foreground color, and the argument summary muted. The1109 // name in the foreground color, and the argument summary muted. The
1086 // line is left-aligned at the same column the box's opening corner1110 // line is left-aligned at the same column the box's opening corner
1087 // used (toolBoxOuterMargin) so flat and box renders share a column.1111 // used (toolBoxOuterMargin) so flat and box renders share a column.
1112+func toolHeaderLine(th Theme, label string, width int, compact bool) string {
1113+ if compact {
1114+ return compactToolHeader(th, label, width)
1115+ }
1116+ return flatToolHeader(th, label, width)
1117+}
1118+
1088 func flatToolHeader(th Theme, label string, width int) string {1119 func flatToolHeader(th Theme, label string, width int) string {
1089 label = oneLineToolLabel(label)1120 label = oneLineToolLabel(label)
1090 name, rest := splitToolLabel(label)1121 name, rest := splitToolLabel(label)
@@ -1109,6 +1140,48 @@ func flatToolHeader(th Theme, label string, width int) string {
1109 return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)1140 return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
1110 }1141 }
1111 1142
1143+func compactToolBlank(th Theme, width int) string {
1144+ if width < 1 {
1145+ width = 1
1146+ }
1147+ return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1148+}
1149+
1150+func compactToolHeader(th Theme, label string, width int) string {
1151+ label = oneLineToolLabel(label)
1152+ name, rest := splitToolLabel(label)
1153+ pad := " "
1154+ avail := width - visibleWidth(pad)*2
1155+ if avail < 12 {
1156+ avail = 12
1157+ }
1158+ if visibleWidth(name+rest) > avail {
1159+ over := visibleWidth(name+rest) - avail
1160+ runes := []rune(rest)
1161+ if over+3 < len(runes) {
1162+ rest = string(runes[:len(runes)-over-3]) + "..."
1163+ } else if visibleWidth(name) <= avail {
1164+ rest = ""
1165+ }
1166+ }
1167+ plain := pad + name + rest
1168+ visible := visibleWidth(plain)
1169+ fill := ""
1170+ if visible < width {
1171+ fill = strings.Repeat(" ", width-visible)
1172+ }
1173+ bg := sgrBGColor(th.UserBubbleBG)
1174+ return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset
1175+}
1176+
1177+func toolBodyLine(th Theme, line string, width int, compact bool) string {
1178+ line = flatToolBody(th, line)
1179+ if compact {
1180+ return compactToolBody(th, line, width)
1181+ }
1182+ return line
1183+}
1184+
1112 // flatToolBody indents a single body line for boxless rendering. It1185 // flatToolBody indents a single body line for boxless rendering. It
1113 // trims the same leading padding box bodies do, then prepends a fixed1186 // trims the same leading padding box bodies do, then prepends a fixed
1114 // gutter indent so output sits in a readable column under the header1187 // gutter indent so output sits in a readable column under the header
@@ -1119,6 +1192,16 @@ func flatToolBody(th Theme, line string) string {
1119 return strings.Repeat(" ", toolBoxOuterMargin+2) + line1192 return strings.Repeat(" ", toolBoxOuterMargin+2) + line
1120 }1193 }
1121 1194
1195+func compactToolBody(th Theme, line string, width int) string {
1196+ visible := visibleWidth(line)
1197+ fill := ""
1198+ if visible < width {
1199+ fill = strings.Repeat(" ", width-visible)
1200+ }
1201+ bg := sgrBGColor(th.UserBubbleBG)
1202+ return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1203+}
1204+
1122 // toolBoxBottom renders the bottom edge of a tool block:1205 // toolBoxBottom renders the bottom edge of a tool block:
1123 //1206 //
1124 // └─────────────────────────────────────────────────────────────────────────────┘1207 // └─────────────────────────────────────────────────────────────────────────────┘

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close