1// Package termdialog provides a reusable dialog component for embedding
2// terminal applications in the TUI.
3package termdialog
4
5import (
6 tea "charm.land/bubbletea/v2"
7 "charm.land/lipgloss/v2"
8
9 "github.com/charmbracelet/crush/internal/terminal"
10 "github.com/charmbracelet/crush/internal/tui/components/core"
11 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/util"
14)
15
16const (
17 // headerHeight is the height of the dialog header (title + padding).
18 headerHeight = 2
19 // fullscreenWidthBreakpoint is the width below which the dialog goes
20 // fullscreen. Matches CompactModeWidthBreakpoint in chat.go.
21 fullscreenWidthBreakpoint = 120
22)
23
24// Config holds configuration for a terminal dialog.
25type Config struct {
26 // ID is the unique identifier for this dialog.
27 ID dialogs.DialogID
28 // Title is displayed in the dialog header.
29 Title string
30 // LoadingMsg is shown while the terminal is starting.
31 LoadingMsg string
32 // Term is the terminal to embed.
33 Term *terminal.Terminal
34 // OnClose is called when the dialog is closed (optional).
35 OnClose func()
36}
37
38// Dialog is a dialog that embeds a terminal application.
39type Dialog struct {
40 id dialogs.DialogID
41 title string
42 loadingMsg string
43 term *terminal.Terminal
44 onClose func()
45
46 wWidth int
47 wHeight int
48 width int
49 height int
50 fullscreen bool
51}
52
53// New creates a new terminal dialog with the given configuration.
54func New(cfg Config) *Dialog {
55 loadingMsg := cfg.LoadingMsg
56 if loadingMsg == "" {
57 loadingMsg = "Starting..."
58 }
59
60 return &Dialog{
61 id: cfg.ID,
62 title: cfg.Title,
63 loadingMsg: loadingMsg,
64 term: cfg.Term,
65 onClose: cfg.OnClose,
66 }
67}
68
69func (d *Dialog) Init() tea.Cmd {
70 return nil
71}
72
73func (d *Dialog) Update(msg tea.Msg) (util.Model, tea.Cmd) {
74 switch msg := msg.(type) {
75 case tea.WindowSizeMsg:
76 return d.handleResize(msg)
77
78 case terminal.ExitMsg:
79 return d, util.CmdHandler(dialogs.CloseDialogMsg{})
80
81 case terminal.OutputMsg:
82 if d.term.Closed() {
83 return d, nil
84 }
85 return d, d.term.RefreshCmd()
86
87 case tea.KeyPressMsg:
88 return d.handleKey(msg)
89
90 case tea.PasteMsg:
91 d.term.SendPaste(msg.Content)
92 return d, nil
93
94 case tea.MouseMsg:
95 return d.handleMouse(msg)
96 }
97
98 return d, nil
99}
100
101func (d *Dialog) handleResize(msg tea.WindowSizeMsg) (util.Model, tea.Cmd) {
102 d.wWidth = msg.Width
103 d.wHeight = msg.Height
104
105 // Go fullscreen when window is below compact mode breakpoint.
106 d.fullscreen = msg.Width < fullscreenWidthBreakpoint
107
108 var outerWidth, outerHeight int
109 if d.fullscreen {
110 outerWidth = msg.Width
111 outerHeight = msg.Height
112 } else {
113 // Dialog takes up 85% of the screen to show it's embedded.
114 outerWidth = int(float64(msg.Width) * 0.85)
115 outerHeight = int(float64(msg.Height) * 0.85)
116
117 // Cap at reasonable maximums.
118 if outerWidth > msg.Width-6 {
119 outerWidth = msg.Width - 6
120 }
121 if outerHeight > msg.Height-4 {
122 outerHeight = msg.Height - 4
123 }
124 }
125
126 // Inner dimensions = outer - border (1 char each side = 2 total).
127 d.width = max(outerWidth-2, 40)
128 d.height = max(outerHeight-2, 10)
129
130 // Terminal height excludes the header.
131 termHeight := max(d.height-headerHeight, 5)
132
133 // Start the terminal if not started.
134 if !d.term.Started() && d.width > 0 && termHeight > 0 {
135 if err := d.term.Resize(d.width, termHeight); err != nil {
136 return d, util.ReportError(err)
137 }
138 if err := d.term.Start(); err != nil {
139 return d, util.ReportError(err)
140 }
141 return d, tea.Batch(d.term.WaitCmd(), d.term.RefreshCmd())
142 }
143
144 // Resize existing terminal.
145 if err := d.term.Resize(d.width, termHeight); err != nil {
146 return d, util.ReportError(err)
147 }
148 return d, nil
149}
150
151func (d *Dialog) handleKey(msg tea.KeyPressMsg) (util.Model, tea.Cmd) {
152 if msg.Text != "" {
153 d.term.SendText(msg.Text)
154 } else {
155 d.term.SendKey(msg)
156 }
157 return d, nil
158}
159
160func (d *Dialog) handleMouse(msg tea.MouseMsg) (util.Model, tea.Cmd) {
161 row, col := d.Position()
162
163 // Adjust coordinates for dialog position.
164 adjust := func(x, y int) (int, int) {
165 return x - col - 1, y - row - 1 - headerHeight
166 }
167
168 switch ev := msg.(type) {
169 case tea.MouseClickMsg:
170 ev.X, ev.Y = adjust(ev.X, ev.Y)
171 d.term.SendMouse(ev)
172 case tea.MouseReleaseMsg:
173 ev.X, ev.Y = adjust(ev.X, ev.Y)
174 d.term.SendMouse(ev)
175 case tea.MouseWheelMsg:
176 ev.X, ev.Y = adjust(ev.X, ev.Y)
177 d.term.SendMouse(ev)
178 case tea.MouseMotionMsg:
179 ev.X, ev.Y = adjust(ev.X, ev.Y)
180 d.term.SendMouse(ev)
181 }
182 return d, nil
183}
184
185func (d *Dialog) View() string {
186 t := styles.CurrentTheme()
187
188 var termContent string
189 if d.term.Started() {
190 termContent = d.term.Render()
191 } else {
192 termContent = d.loadingMsg
193 }
194
195 header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(d.title, d.width-2))
196 content := lipgloss.JoinVertical(lipgloss.Left, header, termContent)
197
198 dialogStyle := t.S().Base.
199 Border(lipgloss.RoundedBorder()).
200 BorderForeground(t.BorderFocus)
201
202 return dialogStyle.Render(content)
203}
204
205func (d *Dialog) Position() (int, int) {
206 if d.fullscreen {
207 return 0, 0
208 }
209
210 dialogWidth := d.width + 2
211 dialogHeight := d.height + 2
212
213 row := max((d.wHeight-dialogHeight)/2, 0)
214 col := max((d.wWidth-dialogWidth)/2, 0)
215
216 return row, col
217}
218
219func (d *Dialog) ID() dialogs.DialogID {
220 return d.id
221}
222
223// Cursor returns the cursor position adjusted for the dialog's screen position.
224// Returns nil if the terminal cursor is hidden or not available.
225func (d *Dialog) Cursor() *tea.Cursor {
226 x, y := d.term.CursorPosition()
227 if x < 0 || y < 0 {
228 return nil
229 }
230
231 t := styles.CurrentTheme()
232 row, col := d.Position()
233 cursor := tea.NewCursor(x, y)
234 cursor.X += col + 1
235 cursor.Y += row + 1 + headerHeight
236 cursor.Color = t.Secondary
237 cursor.Shape = tea.CursorBlock
238 cursor.Blink = true
239 return cursor
240}
241
242func (d *Dialog) Close() tea.Cmd {
243 _ = d.term.Close()
244
245 if d.onClose != nil {
246 d.onClose()
247 }
248
249 return nil
250}