detached 4694534

Add configurable TUI input layout

commit
469453474f46b985954e7890627355c6da380239
author
patriceckhart · · 2026-07-01 18:46 UTC
parent
92241f6
signature
unsigned
refs
tag: v0.2.52
9 files · +526 -28
M packages/agent/cli.go +3 -0
@@ -966,6 +966,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
966966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967967 RespectGitignore: initialCfg.RespectGitignore,
968968 CompactMode: initialCfg.CompactMode,
969+ TUIInputStyle: initialCfg.TUIInputStyle,
970+ TUIStatusPosition: initialCfg.TUIStatusPosition,
971+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
969972 ThemeName: initialCfg.Theme,
970973 FlatTools: initialCfg.FlatToolRender(),
971974 CompactUser: initialCfg.CompactUserInput(),
@@ -966,6 +966,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
967 RespectGitignore: initialCfg.RespectGitignore,967 RespectGitignore: initialCfg.RespectGitignore,
968 CompactMode: initialCfg.CompactMode,968 CompactMode: initialCfg.CompactMode,
969+ TUIInputStyle: initialCfg.TUIInputStyle,
970+ TUIStatusPosition: initialCfg.TUIStatusPosition,
971+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
969 ThemeName: initialCfg.Theme,972 ThemeName: initialCfg.Theme,
970 FlatTools: initialCfg.FlatToolRender(),973 FlatTools: initialCfg.FlatToolRender(),
971 CompactUser: initialCfg.CompactUserInput(),974 CompactUser: initialCfg.CompactUserInput(),
M packages/agent/config.go +14 -0
@@ -75,6 +75,20 @@ type Config struct {
7575 // default; nil/missing means disabled. Toggle from /settings.
7676 CompactMode *bool `json:"compact_mode,omitempty"`
7777
78+ // TUIInputStyle controls the main input rendering. Supported values:
79+ // "plain" (default) and "lines".
80+ TUIInputStyle string `json:"tui_input_style,omitempty"`
81+
82+ // TUIStatusPosition controls whether model, usage, and cwd information
83+ // render above or below the main input. Supported values: "above_input"
84+ // (default) and "below_input".
85+ TUIStatusPosition string `json:"tui_status_position,omitempty"`
86+
87+ // TUIWorkingPosition controls whether the busy/working spinner renders
88+ // above or below the main input. Supported values: "above_input"
89+ // (default) and "below_input".
90+ TUIWorkingPosition string `json:"tui_working_position,omitempty"`
91+
7892 // Insecure skips TLS verification for custom inference endpoints.
7993 Insecure bool `json:"insecure,omitempty"`
8094
@@ -75,6 +75,20 @@ type Config struct {
75 // default; nil/missing means disabled. Toggle from /settings.75 // default; nil/missing means disabled. Toggle from /settings.
76 CompactMode *bool `json:"compact_mode,omitempty"`76 CompactMode *bool `json:"compact_mode,omitempty"`
77 77
78+ // TUIInputStyle controls the main input rendering. Supported values:
79+ // "plain" (default) and "lines".
80+ TUIInputStyle string `json:"tui_input_style,omitempty"`
81+
82+ // TUIStatusPosition controls whether model, usage, and cwd information
83+ // render above or below the main input. Supported values: "above_input"
84+ // (default) and "below_input".
85+ TUIStatusPosition string `json:"tui_status_position,omitempty"`
86+
87+ // TUIWorkingPosition controls whether the busy/working spinner renders
88+ // above or below the main input. Supported values: "above_input"
89+ // (default) and "below_input".
90+ TUIWorkingPosition string `json:"tui_working_position,omitempty"`
91+
78 // Insecure skips TLS verification for custom inference endpoints.92 // Insecure skips TLS verification for custom inference endpoints.
79 Insecure bool `json:"insecure,omitempty"`93 Insecure bool `json:"insecure,omitempty"`
80 94
M packages/agent/modes/btw_dialog.go +16 -2
@@ -36,6 +36,7 @@ type btwDialog struct {
3636 editor *tui.Editor
3737 loading bool
3838 compactMode bool
39+ lineInput bool
3940 cancel context.CancelFunc
4041
4142 // spin drives the same braille animation + rotating funny-line
@@ -101,15 +102,20 @@ func (d *btwDialog) Loading() bool {
101102 // auto-submitted (so /btw <text> starts a conversation right away).
102103 // invalidate, if non-nil, is called after each state change so the
103104 // host redraw loop can pick up the update without polling.
104-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, invalidate func()) {
105+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, lineInput bool, invalidate func()) {
105106 d.mu.Lock()
106107 d.active = true
107108 d.theme = th
108109 d.compactMode = compactMode
110+ d.lineInput = lineInput
109111 d.turns = nil
110112 d.loading = false
111113 d.cancel = nil
112- d.editor = tui.NewEditor(th.AccentBar(th.Accent))
114+ prompt := th.AccentBar(th.Accent)
115+ if lineInput {
116+ prompt = ""
117+ }
118+ d.editor = tui.NewEditor(prompt)
113119 d.frozenSystem = system
114120 d.frozenMsgs = agent.Messages()
115121 d.client = agent.Client
@@ -355,6 +361,10 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
355361 // Render at width-2 to match the two-cell left indent applied
356362 // below. CursorPos uses the same width so the reported cursor
357363 // column matches the wrapped layout shown on screen.
364+ d.editor.Prompt = th.AccentBar(th.Accent)
365+ if d.lineInput {
366+ d.editor.Prompt = ""
367+ }
358368 edLines, _, _ := d.editor.Render(width - 2)
359369 for _, l := range edLines {
360370 // Indent the editor body so it lines up with the side-chat
@@ -406,6 +416,10 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
406416 editorOffset++ // spinner line
407417 }
408418 editorOffset++ // pre-editor blank
419+ d.editor.Prompt = d.theme.AccentBar(d.theme.Accent)
420+ if d.lineInput {
421+ d.editor.Prompt = ""
422+ }
409423 _, eRow, eCol := d.editor.Render(width - 2)
410424 return editorOffset + eRow, eCol + 2 /* matches render indent */
411425 }
@@ -36,6 +36,7 @@ type btwDialog struct {
36 editor *tui.Editor36 editor *tui.Editor
37 loading bool37 loading bool
38 compactMode bool38 compactMode bool
39+ lineInput bool
39 cancel context.CancelFunc40 cancel context.CancelFunc
40 41
41 // spin drives the same braille animation + rotating funny-line42 // spin drives the same braille animation + rotating funny-line
@@ -101,15 +102,20 @@ func (d *btwDialog) Loading() bool {
101 // auto-submitted (so /btw <text> starts a conversation right away).102 // auto-submitted (so /btw <text> starts a conversation right away).
102 // invalidate, if non-nil, is called after each state change so the103 // invalidate, if non-nil, is called after each state change so the
103 // host redraw loop can pick up the update without polling.104 // host redraw loop can pick up the update without polling.
104-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, invalidate func()) {105+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, lineInput bool, invalidate func()) {
105 d.mu.Lock()106 d.mu.Lock()
106 d.active = true107 d.active = true
107 d.theme = th108 d.theme = th
108 d.compactMode = compactMode109 d.compactMode = compactMode
110+ d.lineInput = lineInput
109 d.turns = nil111 d.turns = nil
110 d.loading = false112 d.loading = false
111 d.cancel = nil113 d.cancel = nil
112- d.editor = tui.NewEditor(th.AccentBar(th.Accent))114+ prompt := th.AccentBar(th.Accent)
115+ if lineInput {
116+ prompt = ""
117+ }
118+ d.editor = tui.NewEditor(prompt)
113 d.frozenSystem = system119 d.frozenSystem = system
114 d.frozenMsgs = agent.Messages()120 d.frozenMsgs = agent.Messages()
115 d.client = agent.Client121 d.client = agent.Client
@@ -355,6 +361,10 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
355 // Render at width-2 to match the two-cell left indent applied361 // Render at width-2 to match the two-cell left indent applied
356 // below. CursorPos uses the same width so the reported cursor362 // below. CursorPos uses the same width so the reported cursor
357 // column matches the wrapped layout shown on screen.363 // column matches the wrapped layout shown on screen.
364+ d.editor.Prompt = th.AccentBar(th.Accent)
365+ if d.lineInput {
366+ d.editor.Prompt = ""
367+ }
358 edLines, _, _ := d.editor.Render(width - 2)368 edLines, _, _ := d.editor.Render(width - 2)
359 for _, l := range edLines {369 for _, l := range edLines {
360 // Indent the editor body so it lines up with the side-chat370 // Indent the editor body so it lines up with the side-chat
@@ -406,6 +416,10 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
406 editorOffset++ // spinner line416 editorOffset++ // spinner line
407 }417 }
408 editorOffset++ // pre-editor blank418 editorOffset++ // pre-editor blank
419+ d.editor.Prompt = d.theme.AccentBar(d.theme.Accent)
420+ if d.lineInput {
421+ d.editor.Prompt = ""
422+ }
409 _, eRow, eCol := d.editor.Render(width - 2)423 _, eRow, eCol := d.editor.Render(width - 2)
410 return editorOffset + eRow, eCol + 2 /* matches render indent */424 return editorOffset + eRow, eCol + 2 /* matches render indent */
411 }425 }
M packages/agent/modes/interactive.go +234 -12
@@ -75,6 +75,17 @@ type InteractiveConfig struct {
7575 // When true, it enables both flat tool calls and compact user turns.
7676 CompactMode *bool
7777
78+ // TUIInputStyle controls the main input rendering: plain or lines.
79+ TUIInputStyle string
80+
81+ // TUIStatusPosition controls whether the status block renders above
82+ // or below the main input.
83+ TUIStatusPosition string
84+
85+ // TUIWorkingPosition controls whether the busy/working spinner renders
86+ // above or below the main input.
87+ TUIWorkingPosition string
88+
7889 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
7990 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
8091 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -270,6 +281,9 @@ type SettingsStore interface {
270281 SetRecursiveFileSuggest(enabled bool) error
271282 SetRespectGitignore(enabled bool) error
272283 SetCompactMode(enabled bool) error
284+ SetTUIInputStyle(style string) error
285+ SetTUIStatusPosition(position string) error
286+ SetTUIWorkingPosition(position string) error
273287 SetReasoning(level string) error
274288 SetTheme(name string) error
275289 }
@@ -580,7 +594,8 @@ func (i *Interactive) Run(ctx context.Context) error {
580594 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
581595 _, _ = term.Write([]byte(seq))
582596 }
583- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
597+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
598+ i.applyInputCursorColor()
584599
585600 // Streaming pacer: drains buffered text deltas at a steady rate
586601 // so typewriter feel is identical across providers regardless of
@@ -1212,13 +1227,20 @@ func (i *Interactive) redraw() {
12121227 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
12131228 ctxMax = m.ContextWindow
12141229 }
1230+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1231+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1232+ workingWithStatus := statusPosition == workingPosition
1233+ statusBusyPrefix := ""
1234+ if workingWithStatus {
1235+ statusBusyPrefix = busyPrefix
1236+ }
12151237 statusLines := tui.StatusBar(tui.StatusBarParams{
12161238 Theme: i.cfg.Theme,
12171239 Provider: i.cfg.Provider,
12181240 Model: i.cfg.Model,
12191241 Reasoning: i.cfg.Reasoning,
12201242 Busy: i.busy,
1221- BusyPrefix: busyPrefix,
1243+ BusyPrefix: statusBusyPrefix,
12221244 CWD: i.cfg.CWD,
12231245 Locked: i.cfg.Sandbox.Locked(),
12241246 NoYolo: i.cfg.NoYolo,
@@ -1230,7 +1252,21 @@ func (i *Interactive) redraw() {
12301252 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
12311253 Cols: cols,
12321254 })
1255+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1256+ i.ed.Prompt = ""
1257+ } else {
1258+ i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1259+ }
12331260 edLines, curR, curC := i.ed.Render(cols)
1261+ var workingLines []string
1262+ if busyPrefix != "" && !workingWithStatus {
1263+ workingLines = []string{" " + busyPrefix}
1264+ }
1265+ inputCursorOffset := 0
1266+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1267+ edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1268+ inputCursorOffset = 1
1269+ }
12341270
12351271 // "Sliding in" chips for messages the user typed while a turn is
12361272 // in flight. Shown directly above the status bar so they're close
@@ -1261,7 +1297,8 @@ func (i *Interactive) redraw() {
12611297 // content. The status block and editor get their own dedicated
12621298 // blanks so spacing stays consistent whether or not a dialog or
12631299 // popup is showing.
1264- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
1300+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1301+ inputStartRow := -1
12651302 if len(dialog) > 0 {
12661303 bottom = append(bottom, "")
12671304 }
@@ -1277,10 +1314,42 @@ func (i *Interactive) redraw() {
12771314 if !i.swarmDialog.Active() {
12781315 bottom = append(bottom, suggest...)
12791316 bottom = append(bottom, queue...)
1317+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
1318+ statusBelow := statusPosition == tui.StatusPositionBelowInput
1319+ workingBelow := workingPosition == tui.WorkingPositionBelowInput
1320+
1321+ var aboveInput []string
1322+ if !statusBelow {
1323+ aboveInput = append(aboveInput, statusLines...)
1324+ }
1325+ if !workingBelow {
1326+ aboveInput = append(aboveInput, workingLines...)
1327+ }
1328+ var belowInput []string
1329+ if workingBelow {
1330+ belowInput = append(belowInput, workingLines...)
1331+ }
1332+ if statusBelow {
1333+ belowInput = append(belowInput, statusLines...)
1334+ }
1335+
12801336 bottom = append(bottom, "")
1281- bottom = append(bottom, statusLines...)
1282- bottom = append(bottom, "")
1337+ bottom = append(bottom, aboveInput...)
1338+ needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1339+ if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1340+ needsGapAboveInput = true
1341+ }
1342+ if needsGapAboveInput {
1343+ bottom = append(bottom, "")
1344+ }
1345+ inputStartRow = len(bottom)
12831346 bottom = append(bottom, edLines...)
1347+ if len(belowInput) > 0 {
1348+ if !lineInput {
1349+ bottom = append(bottom, "")
1350+ }
1351+ bottom = append(bottom, belowInput...)
1352+ }
12841353 }
12851354
12861355 _, rows := i.cfg.Terminal.Size()
@@ -1437,12 +1506,7 @@ func (i *Interactive) redraw() {
14371506 if len(dialog) > 0 {
14381507 dialogLead = 1
14391508 }
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
1509+ cursorRow := inputStartRow + inputCursorOffset + curR
14461510 cursorCol := curC
14471511 if i.btwDialog.Active() {
14481512 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
@@ -2796,6 +2860,13 @@ func onOff(v bool) string {
27962860 return "disabled"
27972861 }
27982862
2863+func (i *Interactive) applyInputCursorColor() {
2864+ if i == nil || i.cfg.Terminal == nil {
2865+ return
2866+ }
2867+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
2868+}
2869+
27992870 func (i *Interactive) openSettingsDialog() {
28002871 detected := tui.DetectImageProtocol()
28012872 imgEnabled := detected != tui.ImageProtocolNone
@@ -2825,6 +2896,9 @@ func (i *Interactive) openSettingsDialog() {
28252896 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
28262897 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
28272898 compactMode := i.compactModeEnabled()
2899+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
2900+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
2901+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
28282902 quickItems := i.quickModelSettingItems()
28292903
28302904 reasoningOptions := []settingsOption{
@@ -2873,6 +2947,41 @@ func (i *Interactive) openSettingsDialog() {
28732947 }
28742948 }
28752949
2950+ inputStyleOptions := []settingsOption{
2951+ {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
2952+ {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
2953+ }
2954+ inputStyleChoice := 0
2955+ for idx, opt := range inputStyleOptions {
2956+ if opt.value == inputStyle {
2957+ inputStyleChoice = idx
2958+ break
2959+ }
2960+ }
2961+ statusPositionOptions := []settingsOption{
2962+ {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
2963+ {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
2964+ }
2965+ statusPositionChoice := 0
2966+ for idx, opt := range statusPositionOptions {
2967+ if opt.value == statusPosition {
2968+ statusPositionChoice = idx
2969+ break
2970+ }
2971+ }
2972+
2973+ workingPositionOptions := []settingsOption{
2974+ {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
2975+ {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
2976+ }
2977+ workingPositionChoice := 0
2978+ for idx, opt := range workingPositionOptions {
2979+ if opt.value == workingPosition {
2980+ workingPositionChoice = idx
2981+ break
2982+ }
2983+ }
2984+
28762985 items := []settingsItem{
28772986 {
28782987 key: "inline_images_enabled",
@@ -2908,6 +3017,34 @@ func (i *Interactive) openSettingsDialog() {
29083017 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
29093018 value: compactMode,
29103019 },
3020+ {
3021+ key: "tui_settings",
3022+ label: "tui settings",
3023+ desc: "choose input chrome and where status information appears",
3024+ children: []settingsItem{
3025+ {
3026+ key: "tui_input_style",
3027+ label: "input style",
3028+ desc: "choose between the plain prompt and a lined input area",
3029+ options: inputStyleOptions,
3030+ choice: inputStyleChoice,
3031+ },
3032+ {
3033+ key: "tui_status_position",
3034+ label: "status position",
3035+ desc: "place model, usage, and working directory above or below the input",
3036+ options: statusPositionOptions,
3037+ choice: statusPositionChoice,
3038+ },
3039+ {
3040+ key: "tui_working_position",
3041+ label: "working spinner position",
3042+ desc: "place the working spinner above or below the input",
3043+ options: workingPositionOptions,
3044+ choice: workingPositionChoice,
3045+ },
3046+ },
3047+ },
29113048 {
29123049 key: "reasoning",
29133050 label: "thinking level",
@@ -2943,6 +3080,12 @@ func (i *Interactive) applySettingChange(act settingsAction) {
29433080 i.applyReasoningSetting(act.StringValue)
29443081 case act.Key == "theme":
29453082 i.applyThemeSetting(act.StringValue)
3083+ case act.Key == "tui_input_style":
3084+ i.applyTUIInputStyleSetting(act.StringValue)
3085+ case act.Key == "tui_status_position":
3086+ i.applyTUIStatusPositionSetting(act.StringValue)
3087+ case act.Key == "tui_working_position":
3088+ i.applyTUIWorkingPositionSetting(act.StringValue)
29463089 default:
29473090 i.applySettingToggle(act.Key, act.Value)
29483091 }
@@ -3221,6 +3364,84 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
32213364 }
32223365 }
32233366
3367+func (i *Interactive) applyTUIInputStyleSetting(style string) {
3368+ defer func() {
3369+ if i.rend != nil {
3370+ i.rend.Clear()
3371+ }
3372+ i.invalidate()
3373+ }()
3374+ style = tui.NormalizeInputStyle(style)
3375+ i.cfg.TUIInputStyle = style
3376+ i.applyInputCursorColor()
3377+ if i.cfg.SettingsStore != nil {
3378+ if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
3379+ i.mu.Lock()
3380+ i.statusErr = "settings: " + err.Error()
3381+ i.mu.Unlock()
3382+ return
3383+ }
3384+ }
3385+ i.mu.Lock()
3386+ i.statusOK = "input style " + style
3387+ i.statusErr = ""
3388+ i.mu.Unlock()
3389+}
3390+
3391+func (i *Interactive) applyTUIStatusPositionSetting(position string) {
3392+ defer func() {
3393+ if i.rend != nil {
3394+ i.rend.Clear()
3395+ }
3396+ i.invalidate()
3397+ }()
3398+ position = tui.NormalizeStatusPosition(position)
3399+ i.cfg.TUIStatusPosition = position
3400+ if i.cfg.SettingsStore != nil {
3401+ if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
3402+ i.mu.Lock()
3403+ i.statusErr = "settings: " + err.Error()
3404+ i.mu.Unlock()
3405+ return
3406+ }
3407+ }
3408+ i.mu.Lock()
3409+ label := "above input"
3410+ if position == tui.StatusPositionBelowInput {
3411+ label = "below input"
3412+ }
3413+ i.statusOK = "status position " + label
3414+ i.statusErr = ""
3415+ i.mu.Unlock()
3416+}
3417+
3418+func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
3419+ defer func() {
3420+ if i.rend != nil {
3421+ i.rend.Clear()
3422+ }
3423+ i.invalidate()
3424+ }()
3425+ position = tui.NormalizeWorkingPosition(position)
3426+ i.cfg.TUIWorkingPosition = position
3427+ if i.cfg.SettingsStore != nil {
3428+ if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
3429+ i.mu.Lock()
3430+ i.statusErr = "settings: " + err.Error()
3431+ i.mu.Unlock()
3432+ return
3433+ }
3434+ }
3435+ i.mu.Lock()
3436+ label := "above input"
3437+ if position == tui.WorkingPositionBelowInput {
3438+ label = "below input"
3439+ }
3440+ i.statusOK = "working spinner position " + label
3441+ i.statusErr = ""
3442+ i.mu.Unlock()
3443+}
3444+
32243445 func (i *Interactive) applyThemeSetting(name string) {
32253446 if i.cfg.SettingsStore != nil {
32263447 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
@@ -3269,6 +3490,7 @@ func (i *Interactive) applyThemeNow(name string) {
32693490 i.view.Theme = th
32703491 i.view.InvalidateRenderCache()
32713492 i.ed.Prompt = th.AccentBar(th.Accent)
3493+ i.applyInputCursorColor()
32723494 i.spin.Configure(th)
32733495 if i.rend != nil {
32743496 i.rend.SetTheme(th)
@@ -4086,7 +4308,7 @@ func (i *Interactive) openBtwDialog(args []string) {
40864308 return
40874309 }
40884310 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)
4311+ 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)
40904312 i.invalidate()
40914313 }
40924314
@@ -75,6 +75,17 @@ type InteractiveConfig struct {
75 // When true, it enables both flat tool calls and compact user turns.75 // When true, it enables both flat tool calls and compact user turns.
76 CompactMode *bool76 CompactMode *bool
77 77
78+ // TUIInputStyle controls the main input rendering: plain or lines.
79+ TUIInputStyle string
80+
81+ // TUIStatusPosition controls whether the status block renders above
82+ // or below the main input.
83+ TUIStatusPosition string
84+
85+ // TUIWorkingPosition controls whether the busy/working spinner renders
86+ // above or below the main input.
87+ TUIWorkingPosition string
88+
78 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The89 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
79 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal90 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
80 // forwards Command/Super keypresses, but Ctrl is the displayed chord.91 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -270,6 +281,9 @@ type SettingsStore interface {
270 SetRecursiveFileSuggest(enabled bool) error281 SetRecursiveFileSuggest(enabled bool) error
271 SetRespectGitignore(enabled bool) error282 SetRespectGitignore(enabled bool) error
272 SetCompactMode(enabled bool) error283 SetCompactMode(enabled bool) error
284+ SetTUIInputStyle(style string) error
285+ SetTUIStatusPosition(position string) error
286+ SetTUIWorkingPosition(position string) error
273 SetReasoning(level string) error287 SetReasoning(level string) error
274 SetTheme(name string) error288 SetTheme(name string) error
275 }289 }
@@ -580,7 +594,8 @@ func (i *Interactive) Run(ctx context.Context) error {
580 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {594 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
581 _, _ = term.Write([]byte(seq))595 _, _ = term.Write([]byte(seq))
582 }596 }
583- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))597+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
598+ i.applyInputCursorColor()
584 599
585 // Streaming pacer: drains buffered text deltas at a steady rate600 // Streaming pacer: drains buffered text deltas at a steady rate
586 // so typewriter feel is identical across providers regardless of601 // so typewriter feel is identical across providers regardless of
@@ -1212,13 +1227,20 @@ func (i *Interactive) redraw() {
1212 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {1227 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1213 ctxMax = m.ContextWindow1228 ctxMax = m.ContextWindow
1214 }1229 }
1230+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1231+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1232+ workingWithStatus := statusPosition == workingPosition
1233+ statusBusyPrefix := ""
1234+ if workingWithStatus {
1235+ statusBusyPrefix = busyPrefix
1236+ }
1215 statusLines := tui.StatusBar(tui.StatusBarParams{1237 statusLines := tui.StatusBar(tui.StatusBarParams{
1216 Theme: i.cfg.Theme,1238 Theme: i.cfg.Theme,
1217 Provider: i.cfg.Provider,1239 Provider: i.cfg.Provider,
1218 Model: i.cfg.Model,1240 Model: i.cfg.Model,
1219 Reasoning: i.cfg.Reasoning,1241 Reasoning: i.cfg.Reasoning,
1220 Busy: i.busy,1242 Busy: i.busy,
1221- BusyPrefix: busyPrefix,1243+ BusyPrefix: statusBusyPrefix,
1222 CWD: i.cfg.CWD,1244 CWD: i.cfg.CWD,
1223 Locked: i.cfg.Sandbox.Locked(),1245 Locked: i.cfg.Sandbox.Locked(),
1224 NoYolo: i.cfg.NoYolo,1246 NoYolo: i.cfg.NoYolo,
@@ -1230,7 +1252,21 @@ func (i *Interactive) redraw() {
1230 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),1252 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1231 Cols: cols,1253 Cols: cols,
1232 })1254 })
1255+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1256+ i.ed.Prompt = ""
1257+ } else {
1258+ i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1259+ }
1233 edLines, curR, curC := i.ed.Render(cols)1260 edLines, curR, curC := i.ed.Render(cols)
1261+ var workingLines []string
1262+ if busyPrefix != "" && !workingWithStatus {
1263+ workingLines = []string{" " + busyPrefix}
1264+ }
1265+ inputCursorOffset := 0
1266+ if tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines {
1267+ edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1268+ inputCursorOffset = 1
1269+ }
1234 1270
1235 // "Sliding in" chips for messages the user typed while a turn is1271 // "Sliding in" chips for messages the user typed while a turn is
1236 // in flight. Shown directly above the status bar so they're close1272 // in flight. Shown directly above the status bar so they're close
@@ -1261,7 +1297,8 @@ func (i *Interactive) redraw() {
1261 // content. The status block and editor get their own dedicated1297 // content. The status block and editor get their own dedicated
1262 // blanks so spacing stays consistent whether or not a dialog or1298 // blanks so spacing stays consistent whether or not a dialog or
1263 // popup is showing.1299 // popup is showing.
1264- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)1300+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1301+ inputStartRow := -1
1265 if len(dialog) > 0 {1302 if len(dialog) > 0 {
1266 bottom = append(bottom, "")1303 bottom = append(bottom, "")
1267 }1304 }
@@ -1277,10 +1314,42 @@ func (i *Interactive) redraw() {
1277 if !i.swarmDialog.Active() {1314 if !i.swarmDialog.Active() {
1278 bottom = append(bottom, suggest...)1315 bottom = append(bottom, suggest...)
1279 bottom = append(bottom, queue...)1316 bottom = append(bottom, queue...)
1317+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
1318+ statusBelow := statusPosition == tui.StatusPositionBelowInput
1319+ workingBelow := workingPosition == tui.WorkingPositionBelowInput
1320+
1321+ var aboveInput []string
1322+ if !statusBelow {
1323+ aboveInput = append(aboveInput, statusLines...)
1324+ }
1325+ if !workingBelow {
1326+ aboveInput = append(aboveInput, workingLines...)
1327+ }
1328+ var belowInput []string
1329+ if workingBelow {
1330+ belowInput = append(belowInput, workingLines...)
1331+ }
1332+ if statusBelow {
1333+ belowInput = append(belowInput, statusLines...)
1334+ }
1335+
1280 bottom = append(bottom, "")1336 bottom = append(bottom, "")
1281- bottom = append(bottom, statusLines...)1337+ bottom = append(bottom, aboveInput...)
1282- bottom = append(bottom, "")1338+ needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1339+ if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1340+ needsGapAboveInput = true
1341+ }
1342+ if needsGapAboveInput {
1343+ bottom = append(bottom, "")
1344+ }
1345+ inputStartRow = len(bottom)
1283 bottom = append(bottom, edLines...)1346 bottom = append(bottom, edLines...)
1347+ if len(belowInput) > 0 {
1348+ if !lineInput {
1349+ bottom = append(bottom, "")
1350+ }
1351+ bottom = append(bottom, belowInput...)
1352+ }
1284 }1353 }
1285 1354
1286 _, rows := i.cfg.Terminal.Size()1355 _, rows := i.cfg.Terminal.Size()
@@ -1437,12 +1506,7 @@ func (i *Interactive) redraw() {
1437 if len(dialog) > 0 {1506 if len(dialog) > 0 {
1438 dialogLead = 11507 dialogLead = 1
1439 }1508 }
1440- // +2 accounts for the blank row above statusLines (so the1509+ 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 := curC1510 cursorCol := curC
1447 if i.btwDialog.Active() {1511 if i.btwDialog.Active() {
1448 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {1512 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
@@ -2796,6 +2860,13 @@ func onOff(v bool) string {
2796 return "disabled"2860 return "disabled"
2797 }2861 }
2798 2862
2863+func (i *Interactive) applyInputCursorColor() {
2864+ if i == nil || i.cfg.Terminal == nil {
2865+ return
2866+ }
2867+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
2868+}
2869+
2799 func (i *Interactive) openSettingsDialog() {2870 func (i *Interactive) openSettingsDialog() {
2800 detected := tui.DetectImageProtocol()2871 detected := tui.DetectImageProtocol()
2801 imgEnabled := detected != tui.ImageProtocolNone2872 imgEnabled := detected != tui.ImageProtocolNone
@@ -2825,6 +2896,9 @@ func (i *Interactive) openSettingsDialog() {
2825 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest2896 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2826 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore2897 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2827 compactMode := i.compactModeEnabled()2898 compactMode := i.compactModeEnabled()
2899+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
2900+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
2901+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
2828 quickItems := i.quickModelSettingItems()2902 quickItems := i.quickModelSettingItems()
2829 2903
2830 reasoningOptions := []settingsOption{2904 reasoningOptions := []settingsOption{
@@ -2873,6 +2947,41 @@ func (i *Interactive) openSettingsDialog() {
2873 }2947 }
2874 }2948 }
2875 2949
2950+ inputStyleOptions := []settingsOption{
2951+ {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
2952+ {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
2953+ }
2954+ inputStyleChoice := 0
2955+ for idx, opt := range inputStyleOptions {
2956+ if opt.value == inputStyle {
2957+ inputStyleChoice = idx
2958+ break
2959+ }
2960+ }
2961+ statusPositionOptions := []settingsOption{
2962+ {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
2963+ {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
2964+ }
2965+ statusPositionChoice := 0
2966+ for idx, opt := range statusPositionOptions {
2967+ if opt.value == statusPosition {
2968+ statusPositionChoice = idx
2969+ break
2970+ }
2971+ }
2972+
2973+ workingPositionOptions := []settingsOption{
2974+ {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
2975+ {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
2976+ }
2977+ workingPositionChoice := 0
2978+ for idx, opt := range workingPositionOptions {
2979+ if opt.value == workingPosition {
2980+ workingPositionChoice = idx
2981+ break
2982+ }
2983+ }
2984+
2876 items := []settingsItem{2985 items := []settingsItem{
2877 {2986 {
2878 key: "inline_images_enabled",2987 key: "inline_images_enabled",
@@ -2908,6 +3017,34 @@ func (i *Interactive) openSettingsDialog() {
2908 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",3017 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
2909 value: compactMode,3018 value: compactMode,
2910 },3019 },
3020+ {
3021+ key: "tui_settings",
3022+ label: "tui settings",
3023+ desc: "choose input chrome and where status information appears",
3024+ children: []settingsItem{
3025+ {
3026+ key: "tui_input_style",
3027+ label: "input style",
3028+ desc: "choose between the plain prompt and a lined input area",
3029+ options: inputStyleOptions,
3030+ choice: inputStyleChoice,
3031+ },
3032+ {
3033+ key: "tui_status_position",
3034+ label: "status position",
3035+ desc: "place model, usage, and working directory above or below the input",
3036+ options: statusPositionOptions,
3037+ choice: statusPositionChoice,
3038+ },
3039+ {
3040+ key: "tui_working_position",
3041+ label: "working spinner position",
3042+ desc: "place the working spinner above or below the input",
3043+ options: workingPositionOptions,
3044+ choice: workingPositionChoice,
3045+ },
3046+ },
3047+ },
2911 {3048 {
2912 key: "reasoning",3049 key: "reasoning",
2913 label: "thinking level",3050 label: "thinking level",
@@ -2943,6 +3080,12 @@ func (i *Interactive) applySettingChange(act settingsAction) {
2943 i.applyReasoningSetting(act.StringValue)3080 i.applyReasoningSetting(act.StringValue)
2944 case act.Key == "theme":3081 case act.Key == "theme":
2945 i.applyThemeSetting(act.StringValue)3082 i.applyThemeSetting(act.StringValue)
3083+ case act.Key == "tui_input_style":
3084+ i.applyTUIInputStyleSetting(act.StringValue)
3085+ case act.Key == "tui_status_position":
3086+ i.applyTUIStatusPositionSetting(act.StringValue)
3087+ case act.Key == "tui_working_position":
3088+ i.applyTUIWorkingPositionSetting(act.StringValue)
2946 default:3089 default:
2947 i.applySettingToggle(act.Key, act.Value)3090 i.applySettingToggle(act.Key, act.Value)
2948 }3091 }
@@ -3221,6 +3364,84 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3221 }3364 }
3222 }3365 }
3223 3366
3367+func (i *Interactive) applyTUIInputStyleSetting(style string) {
3368+ defer func() {
3369+ if i.rend != nil {
3370+ i.rend.Clear()
3371+ }
3372+ i.invalidate()
3373+ }()
3374+ style = tui.NormalizeInputStyle(style)
3375+ i.cfg.TUIInputStyle = style
3376+ i.applyInputCursorColor()
3377+ if i.cfg.SettingsStore != nil {
3378+ if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
3379+ i.mu.Lock()
3380+ i.statusErr = "settings: " + err.Error()
3381+ i.mu.Unlock()
3382+ return
3383+ }
3384+ }
3385+ i.mu.Lock()
3386+ i.statusOK = "input style " + style
3387+ i.statusErr = ""
3388+ i.mu.Unlock()
3389+}
3390+
3391+func (i *Interactive) applyTUIStatusPositionSetting(position string) {
3392+ defer func() {
3393+ if i.rend != nil {
3394+ i.rend.Clear()
3395+ }
3396+ i.invalidate()
3397+ }()
3398+ position = tui.NormalizeStatusPosition(position)
3399+ i.cfg.TUIStatusPosition = position
3400+ if i.cfg.SettingsStore != nil {
3401+ if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
3402+ i.mu.Lock()
3403+ i.statusErr = "settings: " + err.Error()
3404+ i.mu.Unlock()
3405+ return
3406+ }
3407+ }
3408+ i.mu.Lock()
3409+ label := "above input"
3410+ if position == tui.StatusPositionBelowInput {
3411+ label = "below input"
3412+ }
3413+ i.statusOK = "status position " + label
3414+ i.statusErr = ""
3415+ i.mu.Unlock()
3416+}
3417+
3418+func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
3419+ defer func() {
3420+ if i.rend != nil {
3421+ i.rend.Clear()
3422+ }
3423+ i.invalidate()
3424+ }()
3425+ position = tui.NormalizeWorkingPosition(position)
3426+ i.cfg.TUIWorkingPosition = position
3427+ if i.cfg.SettingsStore != nil {
3428+ if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
3429+ i.mu.Lock()
3430+ i.statusErr = "settings: " + err.Error()
3431+ i.mu.Unlock()
3432+ return
3433+ }
3434+ }
3435+ i.mu.Lock()
3436+ label := "above input"
3437+ if position == tui.WorkingPositionBelowInput {
3438+ label = "below input"
3439+ }
3440+ i.statusOK = "working spinner position " + label
3441+ i.statusErr = ""
3442+ i.mu.Unlock()
3443+}
3444+
3224 func (i *Interactive) applyThemeSetting(name string) {3445 func (i *Interactive) applyThemeSetting(name string) {
3225 if i.cfg.SettingsStore != nil {3446 if i.cfg.SettingsStore != nil {
3226 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {3447 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
@@ -3269,6 +3490,7 @@ func (i *Interactive) applyThemeNow(name string) {
3269 i.view.Theme = th3490 i.view.Theme = th
3270 i.view.InvalidateRenderCache()3491 i.view.InvalidateRenderCache()
3271 i.ed.Prompt = th.AccentBar(th.Accent)3492 i.ed.Prompt = th.AccentBar(th.Accent)
3493+ i.applyInputCursorColor()
3272 i.spin.Configure(th)3494 i.spin.Configure(th)
3273 if i.rend != nil {3495 if i.rend != nil {
3274 i.rend.SetTheme(th)3496 i.rend.SetTheme(th)
@@ -4086,7 +4308,7 @@ func (i *Interactive) openBtwDialog(args []string) {
4086 return4308 return
4087 }4309 }
4088 seed := strings.TrimSpace(strings.Join(args, " "))4310 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)4311+ 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()4312 i.invalidate()
4091 }4313 }
4092 4314
M packages/agent/modes/swarm_dialog.go +19 -5
@@ -34,6 +34,7 @@ import (
3434 type swarmDialog struct {
3535 active bool
3636 compactMode bool
37+ lineInput bool
3738 snapshot func() []swarm.AgentSnapshot
3839 stop func(id string) error
3940 remove func(id string) error
@@ -124,6 +125,17 @@ func (d *swarmDialog) SetCompactMode(enabled bool) {
124125 d.compactMode = enabled
125126 }
126127
128+func (d *swarmDialog) SetLineInput(enabled bool) {
129+ d.lineInput = enabled
130+}
131+
132+func (d *swarmDialog) editorPrompt(th tui.Theme) string {
133+ if d.lineInput {
134+ return ""
135+ }
136+ return th.AccentBar(th.Accent)
137+}
138+
127139 func (d *swarmDialog) SetCurrentModel(model, providerID string) {
128140 d.pendingModel = model
129141 d.pendingProvider = providerID
@@ -200,6 +212,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
200212 if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {
201213 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
202214 }
215+ d.newTaskEd.Prompt = d.editorPrompt(tui.Theme{})
203216 _, eRow, eCol := d.newTaskEd.Render(width - 2)
204217 const headerRows = 1
205218 const editorBlankRow = 1
@@ -228,6 +241,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
228241 if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {
229242 popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))
230243 }
244+ d.promptEd.Prompt = d.editorPrompt(tui.Theme{})
231245 _, eRow, eCol := d.promptEd.Render(width - 2)
232246
233247 if d.viewing {
@@ -629,7 +643,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
629643 // file/dir chips, cursor movement, and the same accent-bar prompt style.
630644 func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
631645 d.spawning = true
632- d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent))
646+ d.newTaskEd = tui.NewEditor(d.editorPrompt(th))
633647 d.fileSuggest = newFileSuggester()
634648 d.fileSuggest.SetCWD(d.cwd)
635649 if d.spawnDraft != "" {
@@ -717,7 +731,7 @@ func stripSlashModelLine(buf string) string {
717731 // editing experience (paste, @-picker, drop chips) is identical.
718732 func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
719733 d.prompting = true
720- d.promptEd = tui.NewEditor(th.AccentBar(th.Accent))
734+ d.promptEd = tui.NewEditor(d.editorPrompt(th))
721735 d.promptSuggest = newFileSuggester()
722736 d.promptSuggest.SetCWD(d.cwd)
723737 d.promptTargetID = targetID
@@ -1003,7 +1017,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
10031017 d.openSpawnEditor(th)
10041018 }
10051019 if d.newTaskEd != nil {
1006- d.newTaskEd.Prompt = th.AccentBar(th.Accent)
1020+ d.newTaskEd.Prompt = d.editorPrompt(th)
10071021 }
10081022 // One-line banner reminding the user which model the
10091023 // agent-to-be will run against. Skipped when no model was
@@ -1149,7 +1163,7 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
11491163 d.openPromptEditor(th, a.ID)
11501164 }
11511165 if d.promptEd != nil {
1152- d.promptEd.Prompt = th.AccentBar(th.Accent)
1166+ d.promptEd.Prompt = d.editorPrompt(th)
11531167 }
11541168 if d.promptSuggest != nil {
11551169 d.promptSuggest.SetCWD(d.cwd)
@@ -1328,7 +1342,7 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
13281342 d.openPromptEditor(th, d.promptTargetID)
13291343 }
13301344 if d.promptEd != nil {
1331- d.promptEd.Prompt = th.AccentBar(th.Accent)
1345+ d.promptEd.Prompt = d.editorPrompt(th)
13321346 }
13331347 target := d.promptTargetID
13341348 if target == "" {
@@ -34,6 +34,7 @@ import (
34 type swarmDialog struct {34 type swarmDialog struct {
35 active bool35 active bool
36 compactMode bool36 compactMode bool
37+ lineInput bool
37 snapshot func() []swarm.AgentSnapshot38 snapshot func() []swarm.AgentSnapshot
38 stop func(id string) error39 stop func(id string) error
39 remove func(id string) error40 remove func(id string) error
@@ -124,6 +125,17 @@ func (d *swarmDialog) SetCompactMode(enabled bool) {
124 d.compactMode = enabled125 d.compactMode = enabled
125 }126 }
126 127
128+func (d *swarmDialog) SetLineInput(enabled bool) {
129+ d.lineInput = enabled
130+}
131+
132+func (d *swarmDialog) editorPrompt(th tui.Theme) string {
133+ if d.lineInput {
134+ return ""
135+ }
136+ return th.AccentBar(th.Accent)
137+}
138+
127 func (d *swarmDialog) SetCurrentModel(model, providerID string) {139 func (d *swarmDialog) SetCurrentModel(model, providerID string) {
128 d.pendingModel = model140 d.pendingModel = model
129 d.pendingProvider = providerID141 d.pendingProvider = providerID
@@ -200,6 +212,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
200 if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {212 if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {
201 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))213 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
202 }214 }
215+ d.newTaskEd.Prompt = d.editorPrompt(tui.Theme{})
203 _, eRow, eCol := d.newTaskEd.Render(width - 2)216 _, eRow, eCol := d.newTaskEd.Render(width - 2)
204 const headerRows = 1217 const headerRows = 1
205 const editorBlankRow = 1218 const editorBlankRow = 1
@@ -228,6 +241,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
228 if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {241 if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {
229 popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))242 popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))
230 }243 }
244+ d.promptEd.Prompt = d.editorPrompt(tui.Theme{})
231 _, eRow, eCol := d.promptEd.Render(width - 2)245 _, eRow, eCol := d.promptEd.Render(width - 2)
232 246
233 if d.viewing {247 if d.viewing {
@@ -629,7 +643,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
629 // file/dir chips, cursor movement, and the same accent-bar prompt style.643 // file/dir chips, cursor movement, and the same accent-bar prompt style.
630 func (d *swarmDialog) openSpawnEditor(th tui.Theme) {644 func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
631 d.spawning = true645 d.spawning = true
632- d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent))646+ d.newTaskEd = tui.NewEditor(d.editorPrompt(th))
633 d.fileSuggest = newFileSuggester()647 d.fileSuggest = newFileSuggester()
634 d.fileSuggest.SetCWD(d.cwd)648 d.fileSuggest.SetCWD(d.cwd)
635 if d.spawnDraft != "" {649 if d.spawnDraft != "" {
@@ -717,7 +731,7 @@ func stripSlashModelLine(buf string) string {
717 // editing experience (paste, @-picker, drop chips) is identical.731 // editing experience (paste, @-picker, drop chips) is identical.
718 func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {732 func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
719 d.prompting = true733 d.prompting = true
720- d.promptEd = tui.NewEditor(th.AccentBar(th.Accent))734+ d.promptEd = tui.NewEditor(d.editorPrompt(th))
721 d.promptSuggest = newFileSuggester()735 d.promptSuggest = newFileSuggester()
722 d.promptSuggest.SetCWD(d.cwd)736 d.promptSuggest.SetCWD(d.cwd)
723 d.promptTargetID = targetID737 d.promptTargetID = targetID
@@ -1003,7 +1017,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
1003 d.openSpawnEditor(th)1017 d.openSpawnEditor(th)
1004 }1018 }
1005 if d.newTaskEd != nil {1019 if d.newTaskEd != nil {
1006- d.newTaskEd.Prompt = th.AccentBar(th.Accent)1020+ d.newTaskEd.Prompt = d.editorPrompt(th)
1007 }1021 }
1008 // One-line banner reminding the user which model the1022 // One-line banner reminding the user which model the
1009 // agent-to-be will run against. Skipped when no model was1023 // agent-to-be will run against. Skipped when no model was
@@ -1149,7 +1163,7 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
1149 d.openPromptEditor(th, a.ID)1163 d.openPromptEditor(th, a.ID)
1150 }1164 }
1151 if d.promptEd != nil {1165 if d.promptEd != nil {
1152- d.promptEd.Prompt = th.AccentBar(th.Accent)1166+ d.promptEd.Prompt = d.editorPrompt(th)
1153 }1167 }
1154 if d.promptSuggest != nil {1168 if d.promptSuggest != nil {
1155 d.promptSuggest.SetCWD(d.cwd)1169 d.promptSuggest.SetCWD(d.cwd)
@@ -1328,7 +1342,7 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
1328 d.openPromptEditor(th, d.promptTargetID)1342 d.openPromptEditor(th, d.promptTargetID)
1329 }1343 }
1330 if d.promptEd != nil {1344 if d.promptEd != nil {
1331- d.promptEd.Prompt = th.AccentBar(th.Accent)1345+ d.promptEd.Prompt = d.editorPrompt(th)
1332 }1346 }
1333 target := d.promptTargetID1347 target := d.promptTargetID
1334 if target == "" {1348 if target == "" {
M packages/agent/modes/swarm_slash.go +2 -0
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/tui"
910 )
1011
1112 // runSwarm dispatches /swarm subcommands. Layout:
@@ -70,6 +71,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
7071 // via /model first (globally), or, while inside the spawn
7172 // editor, by typing /model on its own line to pop the picker.
7273 i.swarmDialog.SetCompactMode(i.compactModeEnabled())
74+ i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
7375 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
7476 if i.cfg.LoggedInProviders != nil {
7577 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/patriceckhart/zot/packages/agent/swarm"8 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/tui"
9 )10 )
10 11
11 // runSwarm dispatches /swarm subcommands. Layout:12 // runSwarm dispatches /swarm subcommands. Layout:
@@ -70,6 +71,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
70 // via /model first (globally), or, while inside the spawn71 // via /model first (globally), or, while inside the spawn
71 // editor, by typing /model on its own line to pop the picker.72 // editor, by typing /model on its own line to pop the picker.
72 i.swarmDialog.SetCompactMode(i.compactModeEnabled())73 i.swarmDialog.SetCompactMode(i.compactModeEnabled())
74+ i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
73 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)75 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
74 if i.cfg.LoggedInProviders != nil {76 if i.cfg.LoggedInProviders != nil {
75 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())77 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
M packages/agent/settings_store.go +46 -1
@@ -1,6 +1,9 @@
11 package agent
22
3-import "github.com/patriceckhart/zot/packages/provider"
3+import (
4+ "github.com/patriceckhart/zot/packages/provider"
5+ "github.com/patriceckhart/zot/packages/tui"
6+)
47
58 type configSettingsStore struct{}
69
@@ -74,6 +77,48 @@ func (configSettingsStore) SetCompactMode(enabled bool) error {
7477 return SaveConfig(cfg)
7578 }
7679
80+func (configSettingsStore) SetTUIInputStyle(style string) error {
81+ cfg, err := LoadConfig()
82+ if err != nil {
83+ return err
84+ }
85+ style = tui.NormalizeInputStyle(style)
86+ if style == tui.InputStylePlain {
87+ cfg.TUIInputStyle = ""
88+ } else {
89+ cfg.TUIInputStyle = style
90+ }
91+ return SaveConfig(cfg)
92+}
93+
94+func (configSettingsStore) SetTUIStatusPosition(position string) error {
95+ cfg, err := LoadConfig()
96+ if err != nil {
97+ return err
98+ }
99+ position = tui.NormalizeStatusPosition(position)
100+ if position == tui.StatusPositionAboveInput {
101+ cfg.TUIStatusPosition = ""
102+ } else {
103+ cfg.TUIStatusPosition = position
104+ }
105+ return SaveConfig(cfg)
106+}
107+
108+func (configSettingsStore) SetTUIWorkingPosition(position string) error {
109+ cfg, err := LoadConfig()
110+ if err != nil {
111+ return err
112+ }
113+ position = tui.NormalizeWorkingPosition(position)
114+ if position == tui.WorkingPositionAboveInput {
115+ cfg.TUIWorkingPosition = ""
116+ } else {
117+ cfg.TUIWorkingPosition = position
118+ }
119+ return SaveConfig(cfg)
120+}
121+
77122 func (configSettingsStore) SetReasoning(level string) error {
78123 cfg, err := LoadConfig()
79124 if err != nil {
@@ -1,6 +1,9 @@
1 package agent1 package agent
2 2
3-import "github.com/patriceckhart/zot/packages/provider"3+import (
4+ "github.com/patriceckhart/zot/packages/provider"
5+ "github.com/patriceckhart/zot/packages/tui"
6+)
4 7
5 type configSettingsStore struct{}8 type configSettingsStore struct{}
6 9
@@ -74,6 +77,48 @@ func (configSettingsStore) SetCompactMode(enabled bool) error {
74 return SaveConfig(cfg)77 return SaveConfig(cfg)
75 }78 }
76 79
80+func (configSettingsStore) SetTUIInputStyle(style string) error {
81+ cfg, err := LoadConfig()
82+ if err != nil {
83+ return err
84+ }
85+ style = tui.NormalizeInputStyle(style)
86+ if style == tui.InputStylePlain {
87+ cfg.TUIInputStyle = ""
88+ } else {
89+ cfg.TUIInputStyle = style
90+ }
91+ return SaveConfig(cfg)
92+}
93+
94+func (configSettingsStore) SetTUIStatusPosition(position string) error {
95+ cfg, err := LoadConfig()
96+ if err != nil {
97+ return err
98+ }
99+ position = tui.NormalizeStatusPosition(position)
100+ if position == tui.StatusPositionAboveInput {
101+ cfg.TUIStatusPosition = ""
102+ } else {
103+ cfg.TUIStatusPosition = position
104+ }
105+ return SaveConfig(cfg)
106+}
107+
108+func (configSettingsStore) SetTUIWorkingPosition(position string) error {
109+ cfg, err := LoadConfig()
110+ if err != nil {
111+ return err
112+ }
113+ position = tui.NormalizeWorkingPosition(position)
114+ if position == tui.WorkingPositionAboveInput {
115+ cfg.TUIWorkingPosition = ""
116+ } else {
117+ cfg.TUIWorkingPosition = position
118+ }
119+ return SaveConfig(cfg)
120+}
121+
77 func (configSettingsStore) SetReasoning(level string) error {122 func (configSettingsStore) SetReasoning(level string) error {
78 cfg, err := LoadConfig()123 cfg, err := LoadConfig()
79 if err != nil {124 if err != nil {
A packages/tui/layout.go +101 -0
@@ -0,0 +1,101 @@
1+package tui
2+
3+import "strings"
4+
5+const (
6+ InputStylePlain = "plain"
7+ InputStyleLines = "lines"
8+
9+ StatusPositionAboveInput = "above_input"
10+ StatusPositionBelowInput = "below_input"
11+
12+ WorkingPositionAboveInput = "above_input"
13+ WorkingPositionBelowInput = "below_input"
14+)
15+
16+func NormalizeInputStyle(v string) string {
17+ switch strings.ToLower(strings.TrimSpace(v)) {
18+ case InputStyleLines, "line", "boxed", "box":
19+ return InputStyleLines
20+ default:
21+ return InputStylePlain
22+ }
23+}
24+
25+func NormalizeStatusPosition(v string) string {
26+ switch strings.ToLower(strings.TrimSpace(v)) {
27+ case StatusPositionBelowInput, "below", "bottom":
28+ return StatusPositionBelowInput
29+ default:
30+ return StatusPositionAboveInput
31+ }
32+}
33+
34+func NormalizeWorkingPosition(v string) string {
35+ switch strings.ToLower(strings.TrimSpace(v)) {
36+ case WorkingPositionBelowInput, "below", "bottom":
37+ return WorkingPositionBelowInput
38+ default:
39+ return WorkingPositionAboveInput
40+ }
41+}
42+
43+func InputLines(th Theme, lines []string, width int) []string {
44+ if width < 1 {
45+ width = 1
46+ }
47+ rule := th.FG256(th.Muted, strings.Repeat("─", width))
48+ out := make([]string, 0, len(lines)+2)
49+ out = append(out, rule)
50+ out = append(out, lines...)
51+ out = append(out, rule)
52+ return out
53+}
54+
55+func CursorColor256(index int) string {
56+ r, g, b := xterm256RGB(index)
57+ return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07"
58+}
59+
60+func ResetCursorColor() string { return "\x1b]112\x07" }
61+
62+func CursorShapeBlock() string { return "\x1b[1 q" }
63+
64+func ResetCursorShape() string { return "\x1b[0 q" }
65+
66+func xterm256RGB(index int) (int, int, int) {
67+ if index < 0 {
68+ index = 0
69+ }
70+ if index > 255 {
71+ index = 255
72+ }
73+ ansi := [16][3]int{
74+ {0, 0, 0}, {128, 0, 0}, {0, 128, 0}, {128, 128, 0},
75+ {0, 0, 128}, {128, 0, 128}, {0, 128, 128}, {192, 192, 192},
76+ {128, 128, 128}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0},
77+ {0, 0, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255},
78+ }
79+ if index < 16 {
80+ c := ansi[index]
81+ return c[0], c[1], c[2]
82+ }
83+ if index < 232 {
84+ idx := index - 16
85+ levels := [6]int{0, 95, 135, 175, 215, 255}
86+ return levels[idx/36], levels[(idx/6)%6], levels[idx%6]
87+ }
88+ gray := 8 + (index-232)*10
89+ return gray, gray, gray
90+}
91+
92+func hexByte(v int) string {
93+ const digits = "0123456789abcdef"
94+ if v < 0 {
95+ v = 0
96+ }
97+ if v > 255 {
98+ v = 255
99+ }
100+ return string([]byte{digits[v>>4], digits[v&0xf]})
101+}
@@ -0,0 +1,101 @@
1+package tui
2+
3+import "strings"
4+
5+const (
6+ InputStylePlain = "plain"
7+ InputStyleLines = "lines"
8+
9+ StatusPositionAboveInput = "above_input"
10+ StatusPositionBelowInput = "below_input"
11+
12+ WorkingPositionAboveInput = "above_input"
13+ WorkingPositionBelowInput = "below_input"
14+)
15+
16+func NormalizeInputStyle(v string) string {
17+ switch strings.ToLower(strings.TrimSpace(v)) {
18+ case InputStyleLines, "line", "boxed", "box":
19+ return InputStyleLines
20+ default:
21+ return InputStylePlain
22+ }
23+}
24+
25+func NormalizeStatusPosition(v string) string {
26+ switch strings.ToLower(strings.TrimSpace(v)) {
27+ case StatusPositionBelowInput, "below", "bottom":
28+ return StatusPositionBelowInput
29+ default:
30+ return StatusPositionAboveInput
31+ }
32+}
33+
34+func NormalizeWorkingPosition(v string) string {
35+ switch strings.ToLower(strings.TrimSpace(v)) {
36+ case WorkingPositionBelowInput, "below", "bottom":
37+ return WorkingPositionBelowInput
38+ default:
39+ return WorkingPositionAboveInput
40+ }
41+}
42+
43+func InputLines(th Theme, lines []string, width int) []string {
44+ if width < 1 {
45+ width = 1
46+ }
47+ rule := th.FG256(th.Muted, strings.Repeat("─", width))
48+ out := make([]string, 0, len(lines)+2)
49+ out = append(out, rule)
50+ out = append(out, lines...)
51+ out = append(out, rule)
52+ return out
53+}
54+
55+func CursorColor256(index int) string {
56+ r, g, b := xterm256RGB(index)
57+ return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07"
58+}
59+
60+func ResetCursorColor() string { return "\x1b]112\x07" }
61+
62+func CursorShapeBlock() string { return "\x1b[1 q" }
63+
64+func ResetCursorShape() string { return "\x1b[0 q" }
65+
66+func xterm256RGB(index int) (int, int, int) {
67+ if index < 0 {
68+ index = 0
69+ }
70+ if index > 255 {
71+ index = 255
72+ }
73+ ansi := [16][3]int{
74+ {0, 0, 0}, {128, 0, 0}, {0, 128, 0}, {128, 128, 0},
75+ {0, 0, 128}, {128, 0, 128}, {0, 128, 128}, {192, 192, 192},
76+ {128, 128, 128}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0},
77+ {0, 0, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255},
78+ }
79+ if index < 16 {
80+ c := ansi[index]
81+ return c[0], c[1], c[2]
82+ }
83+ if index < 232 {
84+ idx := index - 16
85+ levels := [6]int{0, 95, 135, 175, 215, 255}
86+ return levels[idx/36], levels[(idx/6)%6], levels[idx%6]
87+ }
88+ gray := 8 + (index-232)*10
89+ return gray, gray, gray
90+}
91+
92+func hexByte(v int) string {
93+ const digits = "0123456789abcdef"
94+ if v < 0 {
95+ v = 0
96+ }
97+ if v > 255 {
98+ v = 255
99+ }
100+ return string([]byte{digits[v>>4], digits[v&0xf]})
101+}
M packages/tui/view.go +91 -8
@@ -747,13 +747,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
747747 // in one assistant message render as N adjacent boxes
748748 // instead of stacking unclosed top edges.
749749 if v.FlatTools || v.CompactMode {
750- lines = append(lines, flatToolHeader(v.Theme, label, width))
750+ if v.CompactMode {
751+ lines = append(lines, compactToolBlank(v.Theme, width))
752+ }
753+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
751754 if tr.IsError {
752- lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))
755+ lines = append(lines, toolBodyLine(v.Theme, v.Theme.FG256(color, " error"), width, v.CompactMode))
753756 }
754757 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
755758 _, stripped := parseImageFootprint(line)
756- lines = append(lines, flatToolBody(v.Theme, stripped))
759+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
760+ }
761+ if v.CompactMode {
762+ lines = append(lines, compactToolBlank(v.Theme, width))
757763 }
758764 continue
759765 }
@@ -829,8 +835,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
829835 for len(body) < high {
830836 body = append(body, "")
831837 }
832- lines = append(lines, flatToolHeader(v.Theme, label, width))
838+ if v.CompactMode {
839+ lines = append(lines, compactToolBlank(v.Theme, width))
840+ }
841+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
833842 lines = append(lines, body...)
843+ if v.CompactMode {
844+ lines = append(lines, compactToolBlank(v.Theme, width))
845+ }
834846 return lines
835847 }
836848 for len(body) < high {
@@ -850,7 +862,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
850862 // for no-output tools.
851863 if tc.Result == "" {
852864 if v.FlatTools || v.CompactMode {
853- lines = append(lines, flatToolHeader(v.Theme, label, width))
865+ if v.CompactMode {
866+ lines = append(lines, compactToolBlank(v.Theme, width))
867+ }
868+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
869+ if v.CompactMode {
870+ lines = append(lines, compactToolBlank(v.Theme, width))
871+ }
854872 return lines
855873 }
856874 lines = append(lines, toolBoxTop(v.Theme, label, width))
@@ -868,11 +886,17 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
868886 color = v.Theme.Error
869887 }
870888 if v.FlatTools || v.CompactMode {
871- lines = append(lines, flatToolHeader(v.Theme, label, width))
889+ if v.CompactMode {
890+ lines = append(lines, compactToolBlank(v.Theme, width))
891+ }
892+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
872893 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
873894 for _, l := range v.collapseToolBody(body, false) {
874895 _, stripped := parseImageFootprint(l)
875- lines = append(lines, flatToolBody(v.Theme, stripped))
896+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
897+ }
898+ if v.CompactMode {
899+ lines = append(lines, compactToolBlank(v.Theme, width))
876900 }
877901 return lines
878902 }
@@ -974,7 +998,7 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
974998 if v.FlatTools || v.CompactMode {
975999 for _, l := range body {
9761000 _, stripped := parseImageFootprint(l)
977- out = append(out, flatToolBody(v.Theme, stripped))
1001+ out = append(out, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
9781002 }
9791003 return out
9801004 }
@@ -1085,6 +1109,13 @@ func splitToolLabel(label string) (name, rest string) {
10851109 // name in the foreground color, and the argument summary muted. The
10861110 // line is left-aligned at the same column the box's opening corner
10871111 // used (toolBoxOuterMargin) so flat and box renders share a column.
1112+func toolHeaderLine(th Theme, label string, width int, compact bool) string {
1113+ if compact {
1114+ return compactToolHeader(th, label, width)
1115+ }
1116+ return flatToolHeader(th, label, width)
1117+}
1118+
10881119 func flatToolHeader(th Theme, label string, width int) string {
10891120 label = oneLineToolLabel(label)
10901121 name, rest := splitToolLabel(label)
@@ -1109,6 +1140,48 @@ func flatToolHeader(th Theme, label string, width int) string {
11091140 return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
11101141 }
11111142
1143+func compactToolBlank(th Theme, width int) string {
1144+ if width < 1 {
1145+ width = 1
1146+ }
1147+ return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1148+}
1149+
1150+func compactToolHeader(th Theme, label string, width int) string {
1151+ label = oneLineToolLabel(label)
1152+ name, rest := splitToolLabel(label)
1153+ pad := " "
1154+ avail := width - visibleWidth(pad)*2
1155+ if avail < 12 {
1156+ avail = 12
1157+ }
1158+ if visibleWidth(name+rest) > avail {
1159+ over := visibleWidth(name+rest) - avail
1160+ runes := []rune(rest)
1161+ if over+3 < len(runes) {
1162+ rest = string(runes[:len(runes)-over-3]) + "..."
1163+ } else if visibleWidth(name) <= avail {
1164+ rest = ""
1165+ }
1166+ }
1167+ plain := pad + name + rest
1168+ visible := visibleWidth(plain)
1169+ fill := ""
1170+ if visible < width {
1171+ fill = strings.Repeat(" ", width-visible)
1172+ }
1173+ bg := sgrBGColor(th.UserBubbleBG)
1174+ return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset
1175+}
1176+
1177+func toolBodyLine(th Theme, line string, width int, compact bool) string {
1178+ line = flatToolBody(th, line)
1179+ if compact {
1180+ return compactToolBody(th, line, width)
1181+ }
1182+ return line
1183+}
1184+
11121185 // flatToolBody indents a single body line for boxless rendering. It
11131186 // trims the same leading padding box bodies do, then prepends a fixed
11141187 // gutter indent so output sits in a readable column under the header
@@ -1119,6 +1192,16 @@ func flatToolBody(th Theme, line string) string {
11191192 return strings.Repeat(" ", toolBoxOuterMargin+2) + line
11201193 }
11211194
1195+func compactToolBody(th Theme, line string, width int) string {
1196+ visible := visibleWidth(line)
1197+ fill := ""
1198+ if visible < width {
1199+ fill = strings.Repeat(" ", width-visible)
1200+ }
1201+ bg := sgrBGColor(th.UserBubbleBG)
1202+ return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1203+}
1204+
11221205 // toolBoxBottom renders the bottom edge of a tool block:
11231206 //
11241207 // └─────────────────────────────────────────────────────────────────────────────┘
@@ -747,13 +747,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
747 // in one assistant message render as N adjacent boxes747 // in one assistant message render as N adjacent boxes
748 // instead of stacking unclosed top edges.748 // instead of stacking unclosed top edges.
749 if v.FlatTools || v.CompactMode {749 if v.FlatTools || v.CompactMode {
750- lines = append(lines, flatToolHeader(v.Theme, label, width))750+ if v.CompactMode {
751+ lines = append(lines, compactToolBlank(v.Theme, width))
752+ }
753+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
751 if tr.IsError {754 if tr.IsError {
752- lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))755+ lines = append(lines, toolBodyLine(v.Theme, v.Theme.FG256(color, " error"), width, v.CompactMode))
753 }756 }
754 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {757 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
755 _, stripped := parseImageFootprint(line)758 _, stripped := parseImageFootprint(line)
756- lines = append(lines, flatToolBody(v.Theme, stripped))759+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
760+ }
761+ if v.CompactMode {
762+ lines = append(lines, compactToolBlank(v.Theme, width))
757 }763 }
758 continue764 continue
759 }765 }
@@ -829,8 +835,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
829 for len(body) < high {835 for len(body) < high {
830 body = append(body, "")836 body = append(body, "")
831 }837 }
832- lines = append(lines, flatToolHeader(v.Theme, label, width))838+ if v.CompactMode {
839+ lines = append(lines, compactToolBlank(v.Theme, width))
840+ }
841+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
833 lines = append(lines, body...)842 lines = append(lines, body...)
843+ if v.CompactMode {
844+ lines = append(lines, compactToolBlank(v.Theme, width))
845+ }
834 return lines846 return lines
835 }847 }
836 for len(body) < high {848 for len(body) < high {
@@ -850,7 +862,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
850 // for no-output tools.862 // for no-output tools.
851 if tc.Result == "" {863 if tc.Result == "" {
852 if v.FlatTools || v.CompactMode {864 if v.FlatTools || v.CompactMode {
853- lines = append(lines, flatToolHeader(v.Theme, label, width))865+ if v.CompactMode {
866+ lines = append(lines, compactToolBlank(v.Theme, width))
867+ }
868+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
869+ if v.CompactMode {
870+ lines = append(lines, compactToolBlank(v.Theme, width))
871+ }
854 return lines872 return lines
855 }873 }
856 lines = append(lines, toolBoxTop(v.Theme, label, width))874 lines = append(lines, toolBoxTop(v.Theme, label, width))
@@ -868,11 +886,17 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
868 color = v.Theme.Error886 color = v.Theme.Error
869 }887 }
870 if v.FlatTools || v.CompactMode {888 if v.FlatTools || v.CompactMode {
871- lines = append(lines, flatToolHeader(v.Theme, label, width))889+ if v.CompactMode {
890+ lines = append(lines, compactToolBlank(v.Theme, width))
891+ }
892+ lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
872 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)893 body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
873 for _, l := range v.collapseToolBody(body, false) {894 for _, l := range v.collapseToolBody(body, false) {
874 _, stripped := parseImageFootprint(l)895 _, stripped := parseImageFootprint(l)
875- lines = append(lines, flatToolBody(v.Theme, stripped))896+ lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
897+ }
898+ if v.CompactMode {
899+ lines = append(lines, compactToolBlank(v.Theme, width))
876 }900 }
877 return lines901 return lines
878 }902 }
@@ -974,7 +998,7 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
974 if v.FlatTools || v.CompactMode {998 if v.FlatTools || v.CompactMode {
975 for _, l := range body {999 for _, l := range body {
976 _, stripped := parseImageFootprint(l)1000 _, stripped := parseImageFootprint(l)
977- out = append(out, flatToolBody(v.Theme, stripped))1001+ out = append(out, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
978 }1002 }
979 return out1003 return out
980 }1004 }
@@ -1085,6 +1109,13 @@ func splitToolLabel(label string) (name, rest string) {
1085 // name in the foreground color, and the argument summary muted. The1109 // name in the foreground color, and the argument summary muted. The
1086 // line is left-aligned at the same column the box's opening corner1110 // line is left-aligned at the same column the box's opening corner
1087 // used (toolBoxOuterMargin) so flat and box renders share a column.1111 // used (toolBoxOuterMargin) so flat and box renders share a column.
1112+func toolHeaderLine(th Theme, label string, width int, compact bool) string {
1113+ if compact {
1114+ return compactToolHeader(th, label, width)
1115+ }
1116+ return flatToolHeader(th, label, width)
1117+}
1118+
1088 func flatToolHeader(th Theme, label string, width int) string {1119 func flatToolHeader(th Theme, label string, width int) string {
1089 label = oneLineToolLabel(label)1120 label = oneLineToolLabel(label)
1090 name, rest := splitToolLabel(label)1121 name, rest := splitToolLabel(label)
@@ -1109,6 +1140,48 @@ func flatToolHeader(th Theme, label string, width int) string {
1109 return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)1140 return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
1110 }1141 }
1111 1142
1143+func compactToolBlank(th Theme, width int) string {
1144+ if width < 1 {
1145+ width = 1
1146+ }
1147+ return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1148+}
1149+
1150+func compactToolHeader(th Theme, label string, width int) string {
1151+ label = oneLineToolLabel(label)
1152+ name, rest := splitToolLabel(label)
1153+ pad := " "
1154+ avail := width - visibleWidth(pad)*2
1155+ if avail < 12 {
1156+ avail = 12
1157+ }
1158+ if visibleWidth(name+rest) > avail {
1159+ over := visibleWidth(name+rest) - avail
1160+ runes := []rune(rest)
1161+ if over+3 < len(runes) {
1162+ rest = string(runes[:len(runes)-over-3]) + "..."
1163+ } else if visibleWidth(name) <= avail {
1164+ rest = ""
1165+ }
1166+ }
1167+ plain := pad + name + rest
1168+ visible := visibleWidth(plain)
1169+ fill := ""
1170+ if visible < width {
1171+ fill = strings.Repeat(" ", width-visible)
1172+ }
1173+ bg := sgrBGColor(th.UserBubbleBG)
1174+ return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset
1175+}
1176+
1177+func toolBodyLine(th Theme, line string, width int, compact bool) string {
1178+ line = flatToolBody(th, line)
1179+ if compact {
1180+ return compactToolBody(th, line, width)
1181+ }
1182+ return line
1183+}
1184+
1112 // flatToolBody indents a single body line for boxless rendering. It1185 // flatToolBody indents a single body line for boxless rendering. It
1113 // trims the same leading padding box bodies do, then prepends a fixed1186 // trims the same leading padding box bodies do, then prepends a fixed
1114 // gutter indent so output sits in a readable column under the header1187 // gutter indent so output sits in a readable column under the header
@@ -1119,6 +1192,16 @@ func flatToolBody(th Theme, line string) string {
1119 return strings.Repeat(" ", toolBoxOuterMargin+2) + line1192 return strings.Repeat(" ", toolBoxOuterMargin+2) + line
1120 }1193 }
1121 1194
1195+func compactToolBody(th Theme, line string, width int) string {
1196+ visible := visibleWidth(line)
1197+ fill := ""
1198+ if visible < width {
1199+ fill = strings.Repeat(" ", width-visible)
1200+ }
1201+ bg := sgrBGColor(th.UserBubbleBG)
1202+ return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1203+}
1204+
1122 // toolBoxBottom renders the bottom edge of a tool block:1205 // toolBoxBottom renders the bottom edge of a tool block:
1123 //1206 //
1124 // └─────────────────────────────────────────────────────────────────────────────┘1207 // └─────────────────────────────────────────────────────────────────────────────┘

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close