Merge branch 'main' into doc/mcpo-example
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 | |||
| 302 | 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 | 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 | 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 | 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 | 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 | 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 | 658 | | `alt+left`, `alt+right` | Jump one word back or forward. | | |
| 658 | 659 | | `ctrl+u`, `ctrl+k` | Delete to start or end of line. | | |
| 659 | 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 | 663 | ### Chat scroll | |
| 663 | 664 | ||
| 664 | 665 | | Key | Action | | |
| 665 | 666 | |---|---| | |
| 666 | 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 | 670 | ## Extensions | |
| 670 | 671 | ||
| @@ -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 scroll | 663 | ### 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 | ## Extensions | 670 | ## Extensions | ||
| 670 | 671 | ||||
M packages/agent/cli.go +3 -0
| @@ -966,6 +966,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 966 | 966 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 967 | 967 | RespectGitignore: initialCfg.RespectGitignore, | |
| 968 | 968 | CompactMode: initialCfg.CompactMode, | |
| 969 | + | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 970 | + | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 971 | + | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 969 | 972 | ThemeName: initialCfg.Theme, | |
| 970 | 973 | FlatTools: initialCfg.FlatToolRender(), | |
| 971 | 974 | 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 { | |||
| 75 | 75 | // default; nil/missing means disabled. Toggle from /settings. | |
| 76 | 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 | 92 | // Insecure skips TLS verification for custom inference endpoints. | |
| 79 | 93 | Insecure bool `json:"insecure,omitempty"` | |
| 80 | 94 | ||
| @@ -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 { | |||
| 36 | 36 | editor *tui.Editor | |
| 37 | 37 | loading bool | |
| 38 | 38 | compactMode bool | |
| 39 | + | lineInput bool | |
| 39 | 40 | cancel context.CancelFunc | |
| 40 | 41 | ||
| 41 | 42 | // spin drives the same braille animation + rotating funny-line | |
| @@ -101,15 +102,20 @@ func (d *btwDialog) Loading() bool { | |||
| 101 | 102 | // auto-submitted (so /btw <text> starts a conversation right away). | |
| 102 | 103 | // invalidate, if non-nil, is called after each state change so the | |
| 103 | 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 | 106 | d.mu.Lock() | |
| 106 | 107 | d.active = true | |
| 107 | 108 | d.theme = th | |
| 108 | 109 | d.compactMode = compactMode | |
| 110 | + | d.lineInput = lineInput | |
| 109 | 111 | d.turns = nil | |
| 110 | 112 | d.loading = false | |
| 111 | 113 | 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 | 119 | d.frozenSystem = system | |
| 114 | 120 | d.frozenMsgs = agent.Messages() | |
| 115 | 121 | d.client = agent.Client | |
| @@ -355,6 +361,10 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||
| 355 | 361 | // Render at width-2 to match the two-cell left indent applied | |
| 356 | 362 | // below. CursorPos uses the same width so the reported cursor | |
| 357 | 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 | 368 | edLines, _, _ := d.editor.Render(width - 2) | |
| 359 | 369 | for _, l := range edLines { | |
| 360 | 370 | // 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 | 416 | editorOffset++ // spinner line | |
| 407 | 417 | } | |
| 408 | 418 | editorOffset++ // pre-editor blank | |
| 419 | + | d.editor.Prompt = d.theme.AccentBar(d.theme.Accent) | |
| 420 | + | if d.lineInput { | |
| 421 | + | d.editor.Prompt = "" | |
| 422 | + | } | |
| 409 | 423 | _, eRow, eCol := d.editor.Render(width - 2) | |
| 410 | 424 | return editorOffset + eRow, eCol + 2 /* matches render indent */ | |
| 411 | 425 | } | |
| @@ -36,6 +36,7 @@ type btwDialog struct { | |||||
| 36 | editor *tui.Editor | 36 | editor *tui.Editor | ||
| 37 | loading bool | 37 | loading bool | ||
| 38 | compactMode bool | 38 | compactMode bool | ||
| 39 | + | lineInput bool | |||
| 39 | cancel context.CancelFunc | 40 | cancel context.CancelFunc | ||
| 40 | 41 | ||||
| 41 | // spin drives the same braille animation + rotating funny-line | 42 | // 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 the | 103 | // 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 = true | 107 | d.active = true | ||
| 107 | d.theme = th | 108 | d.theme = th | ||
| 108 | d.compactMode = compactMode | 109 | d.compactMode = compactMode | ||
| 110 | + | d.lineInput = lineInput | |||
| 109 | d.turns = nil | 111 | d.turns = nil | ||
| 110 | d.loading = false | 112 | d.loading = false | ||
| 111 | d.cancel = nil | 113 | 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 = system | 119 | d.frozenSystem = system | ||
| 114 | d.frozenMsgs = agent.Messages() | 120 | d.frozenMsgs = agent.Messages() | ||
| 115 | d.client = agent.Client | 121 | 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 applied | 361 | // Render at width-2 to match the two-cell left indent applied | ||
| 356 | // below. CursorPos uses the same width so the reported cursor | 362 | // 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-chat | 370 | // 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 line | 416 | editorOffset++ // spinner line | ||
| 407 | } | 417 | } | ||
| 408 | editorOffset++ // pre-editor blank | 418 | 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{ | |||
| 23 | 23 | {"ctrl+l", "redraw the screen"}, | |
| 24 | 24 | {"ctrl+o", "expand / collapse long tool results"}, | |
| 25 | 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 | 29 | // 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 shared | 29 | // 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 { | |||
| 75 | 75 | // When true, it enables both flat tool calls and compact user turns. | |
| 76 | 76 | 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 | 89 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | |
| 79 | 90 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |
| 80 | 91 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| @@ -270,6 +281,9 @@ type SettingsStore interface { | |||
| 270 | 281 | SetRecursiveFileSuggest(enabled bool) error | |
| 271 | 282 | SetRespectGitignore(enabled bool) error | |
| 272 | 283 | SetCompactMode(enabled bool) error | |
| 284 | + | SetTUIInputStyle(style string) error | |
| 285 | + | SetTUIStatusPosition(position string) error | |
| 286 | + | SetTUIWorkingPosition(position string) error | |
| 273 | 287 | SetReasoning(level string) error | |
| 274 | 288 | SetTheme(name string) error | |
| 275 | 289 | } | |
| @@ -415,9 +429,8 @@ type Interactive struct { | |||
| 415 | 429 | parkedTotal int | |
| 416 | 430 | ||
| 417 | 431 | // 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. | |
| 421 | 434 | inputHistoryIndex int | |
| 422 | 435 | ||
| 423 | 436 | // 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 | 593 | if seq := tui.ReportCWD(i.cfg.CWD); seq != "" { | |
| 581 | 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 | 599 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| 586 | 600 | // so typewriter feel is identical across providers regardless of | |
| @@ -1212,13 +1226,20 @@ func (i *Interactive) redraw() { | |||
| 1212 | 1226 | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 1213 | 1227 | 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 | 1236 | statusLines := tui.StatusBar(tui.StatusBarParams{ | |
| 1216 | 1237 | Theme: i.cfg.Theme, | |
| 1217 | 1238 | Provider: i.cfg.Provider, | |
| 1218 | 1239 | Model: i.cfg.Model, | |
| 1219 | 1240 | Reasoning: i.cfg.Reasoning, | |
| 1220 | 1241 | Busy: i.busy, | |
| 1221 | - | BusyPrefix: busyPrefix, | |
| 1242 | + | BusyPrefix: statusBusyPrefix, | |
| 1222 | 1243 | CWD: i.cfg.CWD, | |
| 1223 | 1244 | Locked: i.cfg.Sandbox.Locked(), | |
| 1224 | 1245 | NoYolo: i.cfg.NoYolo, | |
| @@ -1230,7 +1251,21 @@ func (i *Interactive) redraw() { | |||
| 1230 | 1251 | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | |
| 1231 | 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 | 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 | 1270 | // "Sliding in" chips for messages the user typed while a turn is | |
| 1236 | 1271 | // in flight. Shown directly above the status bar so they're close | |
| @@ -1261,7 +1296,8 @@ func (i *Interactive) redraw() { | |||
| 1261 | 1296 | // content. The status block and editor get their own dedicated | |
| 1262 | 1297 | // blanks so spacing stays consistent whether or not a dialog or | |
| 1263 | 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 | 1301 | if len(dialog) > 0 { | |
| 1266 | 1302 | bottom = append(bottom, "") | |
| 1267 | 1303 | } | |
| @@ -1277,10 +1313,42 @@ func (i *Interactive) redraw() { | |||
| 1277 | 1313 | if !i.swarmDialog.Active() { | |
| 1278 | 1314 | bottom = append(bottom, suggest...) | |
| 1279 | 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 | 1335 | 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) | |
| 1283 | 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 | 1354 | _, rows := i.cfg.Terminal.Size() | |
| @@ -1437,12 +1505,7 @@ func (i *Interactive) redraw() { | |||
| 1437 | 1505 | if len(dialog) > 0 { | |
| 1438 | 1506 | dialogLead = 1 | |
| 1439 | 1507 | } | |
| 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 | |
| 1446 | 1509 | cursorCol := curC | |
| 1447 | 1510 | if i.btwDialog.Active() { | |
| 1448 | 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 | 2190 | i.invalidate() | |
| 2128 | 2191 | return false | |
| 2129 | 2192 | } | |
| 2130 | - | i.mu.Unlock() | |
| 2131 | 2193 | } | |
| 2132 | 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, 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. | |
| 2135 | 2198 | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 2136 | 2199 | if i.ed.MoveVertical(-1) { | |
| 2137 | 2200 | i.invalidate() | |
| 2138 | 2201 | return false | |
| 2139 | 2202 | } | |
| 2203 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 2204 | + | return false | |
| 2205 | + | } | |
| 2140 | 2206 | i.scrollBy(+3) | |
| 2141 | 2207 | return false | |
| 2142 | 2208 | } | |
| @@ -2146,6 +2212,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2146 | 2212 | i.invalidate() | |
| 2147 | 2213 | return false | |
| 2148 | 2214 | } | |
| 2215 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 2216 | + | return false | |
| 2217 | + | } | |
| 2149 | 2218 | if i.scrollOffset > 0 { | |
| 2150 | 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) { | |
| 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 { | |
| 2275 | 2341 | i.inputHistoryIndex = -1 | |
| 2276 | 2342 | } | |
| 2277 | 2343 | ||
| @@ -2407,11 +2473,11 @@ func (i *Interactive) pasteClipboard() { | |||
| 2407 | 2473 | } | |
| 2408 | 2474 | ||
| 2409 | 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 | 2477 | return false | |
| 2412 | 2478 | } | |
| 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 | |
| 2415 | 2481 | // the ring so repeated presses work even though the editor now | |
| 2416 | 2482 | // contains the selected historical prompt. | |
| 2417 | 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 | 2491 | 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 | + | } | |
| 2428 | 2499 | i.inputHistoryIndex = len(hist) | |
| 2429 | 2500 | } | |
| 2430 | 2501 | ||
| 2431 | 2502 | switch k.Kind { | |
| 2432 | - | case tui.KeyLeft: | |
| 2503 | + | case tui.KeyUp: | |
| 2433 | 2504 | if i.inputHistoryIndex > 0 { | |
| 2434 | 2505 | i.inputHistoryIndex-- | |
| 2435 | 2506 | } | |
| 2436 | - | case tui.KeyRight: | |
| 2507 | + | case tui.KeyDown: | |
| 2437 | 2508 | if i.inputHistoryIndex < len(hist) { | |
| 2438 | 2509 | i.inputHistoryIndex++ | |
| 2439 | 2510 | } | |
| @@ -2796,6 +2867,13 @@ func onOff(v bool) string { | |||
| 2796 | 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 | 2877 | func (i *Interactive) openSettingsDialog() { | |
| 2800 | 2878 | detected := tui.DetectImageProtocol() | |
| 2801 | 2879 | imgEnabled := detected != tui.ImageProtocolNone | |
| @@ -2825,6 +2903,9 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2825 | 2903 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 2826 | 2904 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 2827 | 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 | 2909 | quickItems := i.quickModelSettingItems() | |
| 2829 | 2910 | ||
| 2830 | 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 | 2992 | items := []settingsItem{ | |
| 2877 | 2993 | { | |
| 2878 | 2994 | key: "inline_images_enabled", | |
| @@ -2908,6 +3024,34 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2908 | 3024 | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | |
| 2909 | 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 | 3056 | key: "reasoning", | |
| 2913 | 3057 | label: "thinking level", | |
| @@ -2943,6 +3087,12 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||
| 2943 | 3087 | i.applyReasoningSetting(act.StringValue) | |
| 2944 | 3088 | case act.Key == "theme": | |
| 2945 | 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 | 3096 | default: | |
| 2947 | 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 | 3452 | func (i *Interactive) applyThemeSetting(name string) { | |
| 3225 | 3453 | if i.cfg.SettingsStore != nil { | |
| 3226 | 3454 | if err := i.cfg.SettingsStore.SetTheme(name); err != nil { | |
| @@ -3269,6 +3497,7 @@ func (i *Interactive) applyThemeNow(name string) { | |||
| 3269 | 3497 | i.view.Theme = th | |
| 3270 | 3498 | i.view.InvalidateRenderCache() | |
| 3271 | 3499 | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 3500 | + | i.applyInputCursorColor() | |
| 3272 | 3501 | i.spin.Configure(th) | |
| 3273 | 3502 | if i.rend != nil { | |
| 3274 | 3503 | i.rend.SetTheme(th) | |
| @@ -4086,7 +4315,7 @@ func (i *Interactive) openBtwDialog(args []string) { | |||
| 4086 | 4315 | return | |
| 4087 | 4316 | } | |
| 4088 | 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 | 4319 | i.invalidate() | |
| 4091 | 4320 | } | |
| 4092 | 4321 | ||
| @@ -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 *bool | 76 | 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. The | 89 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | ||
| 79 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | 90 | // 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) error | 281 | SetRecursiveFileSuggest(enabled bool) error | ||
| 271 | SetRespectGitignore(enabled bool) error | 282 | SetRespectGitignore(enabled bool) error | ||
| 272 | SetCompactMode(enabled bool) error | 283 | SetCompactMode(enabled bool) error | ||
| 284 | + | SetTUIInputStyle(style string) error | |||
| 285 | + | SetTUIStatusPosition(position string) error | |||
| 286 | + | SetTUIWorkingPosition(position string) error | |||
| 273 | SetReasoning(level string) error | 287 | SetReasoning(level string) error | ||
| 274 | SetTheme(name string) error | 288 | SetTheme(name string) error | ||
| 275 | } | 289 | } | ||
| @@ -415,9 +429,8 @@ type Interactive struct { | |||||
| 415 | parkedTotal int | 429 | parkedTotal int | ||
| 416 | 430 | ||||
| 417 | // inputHistoryIndex is -1 when not browsing history. When the | 431 | // inputHistoryIndex is -1 when not browsing history. When the | ||
| 418 | - | // editor is empty, Left/Right can walk previous user prompts for | 432 | + | // editor is empty, Up/Down can walk previous user prompts without |
| 419 | - | // quick manual testing without stealing normal cursor movement in | 433 | + | // stealing normal vertical cursor movement in non-empty input. |
| 420 | - | // non-empty input. | |||
| 421 | inputHistoryIndex int | 434 | inputHistoryIndex int | ||
| 422 | 435 | ||||
| 423 | // lastCtrlC is when the user last pressed ctrl+c. The first press | 436 | // 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 rate | 599 | // Streaming pacer: drains buffered text deltas at a steady rate | ||
| 586 | // so typewriter feel is identical across providers regardless of | 600 | // 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.ContextWindow | 1227 | 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 is | 1270 | // "Sliding in" chips for messages the user typed while a turn is | ||
| 1236 | // in flight. Shown directly above the status bar so they're close | 1271 | // 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 dedicated | 1296 | // content. The status block and editor get their own dedicated | ||
| 1262 | // blanks so spacing stays consistent whether or not a dialog or | 1297 | // 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 = 1 | 1506 | dialogLead = 1 | ||
| 1439 | } | 1507 | } | ||
| 1440 | - | // +2 accounts for the blank row above statusLines (so the | 1508 | + | 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 := curC | 1509 | 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 false | 2191 | 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, preserving | 2195 | + | // 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 false | 2201 | 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 false | 2207 | 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 false | 2213 | 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 = -1 | 2341 | 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 false | 2477 | return false | ||
| 2412 | } | 2478 | } | ||
| 2413 | - | // Do not steal normal cursor movement. History browsing can only | 2479 | + | // Do not steal normal vertical cursor movement. History browsing can only |
| 2414 | - | // start from an empty editor; once active, Left/Right keep walking | 2480 | + | // start from an empty editor; once active, Up/Down keep walking |
| 2415 | // the ring so repeated presses work even though the editor now | 2481 | // 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 most | 2492 | + | // 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.ImageProtocolNone | 2879 | imgEnabled := detected != tui.ImageProtocolNone | ||
| @@ -2825,6 +2903,9 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2825 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | 2903 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | ||
| 2826 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | 2904 | 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 = th | 3497 | 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 | return | 4315 | 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 ( | |||
| 34 | 34 | type swarmDialog struct { | |
| 35 | 35 | active bool | |
| 36 | 36 | compactMode bool | |
| 37 | + | lineInput bool | |
| 37 | 38 | snapshot func() []swarm.AgentSnapshot | |
| 38 | 39 | stop func(id string) error | |
| 39 | 40 | remove func(id string) error | |
| @@ -124,6 +125,17 @@ func (d *swarmDialog) SetCompactMode(enabled bool) { | |||
| 124 | 125 | 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 | 139 | func (d *swarmDialog) SetCurrentModel(model, providerID string) { | |
| 128 | 140 | d.pendingModel = model | |
| 129 | 141 | d.pendingProvider = providerID | |
| @@ -200,6 +212,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||
| 200 | 212 | if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) { | |
| 201 | 213 | popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width)) | |
| 202 | 214 | } | |
| 215 | + | d.newTaskEd.Prompt = d.editorPrompt(tui.Theme{}) | |
| 203 | 216 | _, eRow, eCol := d.newTaskEd.Render(width - 2) | |
| 204 | 217 | const headerRows = 1 | |
| 205 | 218 | const editorBlankRow = 1 | |
| @@ -228,6 +241,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||
| 228 | 241 | if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) { | |
| 229 | 242 | popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width)) | |
| 230 | 243 | } | |
| 244 | + | d.promptEd.Prompt = d.editorPrompt(tui.Theme{}) | |
| 231 | 245 | _, eRow, eCol := d.promptEd.Render(width - 2) | |
| 232 | 246 | ||
| 233 | 247 | if d.viewing { | |
| @@ -629,7 +643,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri | |||
| 629 | 643 | // file/dir chips, cursor movement, and the same accent-bar prompt style. | |
| 630 | 644 | func (d *swarmDialog) openSpawnEditor(th tui.Theme) { | |
| 631 | 645 | d.spawning = true | |
| 632 | - | d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 646 | + | d.newTaskEd = tui.NewEditor(d.editorPrompt(th)) | |
| 633 | 647 | d.fileSuggest = newFileSuggester() | |
| 634 | 648 | d.fileSuggest.SetCWD(d.cwd) | |
| 635 | 649 | if d.spawnDraft != "" { | |
| @@ -717,7 +731,7 @@ func stripSlashModelLine(buf string) string { | |||
| 717 | 731 | // editing experience (paste, @-picker, drop chips) is identical. | |
| 718 | 732 | func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) { | |
| 719 | 733 | d.prompting = true | |
| 720 | - | d.promptEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 734 | + | d.promptEd = tui.NewEditor(d.editorPrompt(th)) | |
| 721 | 735 | d.promptSuggest = newFileSuggester() | |
| 722 | 736 | d.promptSuggest.SetCWD(d.cwd) | |
| 723 | 737 | d.promptTargetID = targetID | |
| @@ -1003,7 +1017,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |||
| 1003 | 1017 | d.openSpawnEditor(th) | |
| 1004 | 1018 | } | |
| 1005 | 1019 | if d.newTaskEd != nil { | |
| 1006 | - | d.newTaskEd.Prompt = th.AccentBar(th.Accent) | |
| 1020 | + | d.newTaskEd.Prompt = d.editorPrompt(th) | |
| 1007 | 1021 | } | |
| 1008 | 1022 | // One-line banner reminding the user which model the | |
| 1009 | 1023 | // 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 | 1163 | d.openPromptEditor(th, a.ID) | |
| 1150 | 1164 | } | |
| 1151 | 1165 | if d.promptEd != nil { | |
| 1152 | - | d.promptEd.Prompt = th.AccentBar(th.Accent) | |
| 1166 | + | d.promptEd.Prompt = d.editorPrompt(th) | |
| 1153 | 1167 | } | |
| 1154 | 1168 | if d.promptSuggest != nil { | |
| 1155 | 1169 | d.promptSuggest.SetCWD(d.cwd) | |
| @@ -1328,7 +1342,7 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) | |||
| 1328 | 1342 | d.openPromptEditor(th, d.promptTargetID) | |
| 1329 | 1343 | } | |
| 1330 | 1344 | if d.promptEd != nil { | |
| 1331 | - | d.promptEd.Prompt = th.AccentBar(th.Accent) | |
| 1345 | + | d.promptEd.Prompt = d.editorPrompt(th) | |
| 1332 | 1346 | } | |
| 1333 | 1347 | target := d.promptTargetID | |
| 1334 | 1348 | if target == "" { | |
| @@ -34,6 +34,7 @@ import ( | |||||
| 34 | type swarmDialog struct { | 34 | type swarmDialog struct { | ||
| 35 | active bool | 35 | active bool | ||
| 36 | compactMode bool | 36 | compactMode bool | ||
| 37 | + | lineInput bool | |||
| 37 | snapshot func() []swarm.AgentSnapshot | 38 | snapshot func() []swarm.AgentSnapshot | ||
| 38 | stop func(id string) error | 39 | stop func(id string) error | ||
| 39 | remove func(id string) error | 40 | remove func(id string) error | ||
| @@ -124,6 +125,17 @@ func (d *swarmDialog) SetCompactMode(enabled bool) { | |||||
| 124 | d.compactMode = enabled | 125 | 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 = model | 140 | d.pendingModel = model | ||
| 129 | d.pendingProvider = providerID | 141 | 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 = 1 | 217 | const headerRows = 1 | ||
| 205 | const editorBlankRow = 1 | 218 | 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 = true | 645 | 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 = true | 733 | 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 = targetID | 737 | 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 the | 1022 | // One-line banner reminding the user which model the | ||
| 1009 | // agent-to-be will run against. Skipped when no model was | 1023 | // 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.promptTargetID | 1347 | target := d.promptTargetID | ||
| 1334 | if target == "" { | 1348 | if target == "" { | ||
M packages/agent/modes/swarm_slash.go +2 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 9 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // runSwarm dispatches /swarm subcommands. Layout: | |
| @@ -70,6 +71,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 70 | 71 | // via /model first (globally), or, while inside the spawn | |
| 71 | 72 | // editor, by typing /model on its own line to pop the picker. | |
| 72 | 73 | i.swarmDialog.SetCompactMode(i.compactModeEnabled()) | |
| 74 | + | i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | |
| 73 | 75 | i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |
| 74 | 76 | if i.cfg.LoggedInProviders != nil { | |
| 75 | 77 | 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 spawn | 71 | // 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 @@ | |||
| 1 | 1 | 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 | 8 | type configSettingsStore struct{} | |
| 6 | 9 | ||
| @@ -74,6 +77,48 @@ func (configSettingsStore) SetCompactMode(enabled bool) error { | |||
| 74 | 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 | 122 | func (configSettingsStore) SetReasoning(level string) error { | |
| 78 | 123 | cfg, err := LoadConfig() | |
| 79 | 124 | if err != nil { | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package agent | 1 | 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 | |||
| 747 | 747 | // in one assistant message render as N adjacent boxes | |
| 748 | 748 | // instead of stacking unclosed top edges. | |
| 749 | 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 | 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 | 757 | for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) { | |
| 755 | 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 | 764 | continue | |
| 759 | 765 | } | |
| @@ -829,8 +835,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 829 | 835 | for len(body) < high { | |
| 830 | 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 | 842 | lines = append(lines, body...) | |
| 843 | + | if v.CompactMode { | |
| 844 | + | lines = append(lines, compactToolBlank(v.Theme, width)) | |
| 845 | + | } | |
| 834 | 846 | return lines | |
| 835 | 847 | } | |
| 836 | 848 | for len(body) < high { | |
| @@ -850,7 +862,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 850 | 862 | // for no-output tools. | |
| 851 | 863 | if tc.Result == "" { | |
| 852 | 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 | 872 | return lines | |
| 855 | 873 | } | |
| 856 | 874 | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| @@ -868,11 +886,17 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 868 | 886 | color = v.Theme.Error | |
| 869 | 887 | } | |
| 870 | 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 | 893 | body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color) | |
| 873 | 894 | for _, l := range v.collapseToolBody(body, false) { | |
| 874 | 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 | 901 | return lines | |
| 878 | 902 | } | |
| @@ -974,7 +998,7 @@ func (v *View) wrapLiveBody(body []string, width int) []string { | |||
| 974 | 998 | if v.FlatTools || v.CompactMode { | |
| 975 | 999 | for _, l := range body { | |
| 976 | 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 | 1003 | return out | |
| 980 | 1004 | } | |
| @@ -1085,6 +1109,13 @@ func splitToolLabel(label string) (name, rest string) { | |||
| 1085 | 1109 | // name in the foreground color, and the argument summary muted. The | |
| 1086 | 1110 | // line is left-aligned at the same column the box's opening corner | |
| 1087 | 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 | 1119 | func flatToolHeader(th Theme, label string, width int) string { | |
| 1089 | 1120 | label = oneLineToolLabel(label) | |
| 1090 | 1121 | name, rest := splitToolLabel(label) | |
| @@ -1109,6 +1140,48 @@ func flatToolHeader(th Theme, label string, width int) string { | |||
| 1109 | 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 | 1185 | // flatToolBody indents a single body line for boxless rendering. It | |
| 1113 | 1186 | // trims the same leading padding box bodies do, then prepends a fixed | |
| 1114 | 1187 | // gutter indent so output sits in a readable column under the header | |
| @@ -1119,6 +1192,16 @@ func flatToolBody(th Theme, line string) string { | |||
| 1119 | 1192 | 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 | 1205 | // toolBoxBottom renders the bottom edge of a tool block: | |
| 1123 | 1206 | // | |
| 1124 | 1207 | // └─────────────────────────────────────────────────────────────────────────────┘ | |
| @@ -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 boxes | 747 | // 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 | continue | 764 | 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 lines | 846 | 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 lines | 872 | 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.Error | 886 | 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 lines | 901 | 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 out | 1003 | 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. The | 1109 | // name in the foreground color, and the argument summary muted. The | ||
| 1086 | // line is left-aligned at the same column the box's opening corner | 1110 | // 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. It | 1185 | // flatToolBody indents a single body line for boxless rendering. It | ||
| 1113 | // trims the same leading padding box bodies do, then prepends a fixed | 1186 | // trims the same leading padding box bodies do, then prepends a fixed | ||
| 1114 | // gutter indent so output sits in a readable column under the header | 1187 | // 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) + line | 1192 | 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 | // └─────────────────────────────────────────────────────────────────────────────┘ | ||