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 // Forward all keys to the terminal.
153 if msg.Text != "" {
154 d.term.SendText(msg.Text)
155 } else {
156 d.term.SendKey(msg)
157 }
158 return d, nil
159}
160
161func (d *Dialog) handleMouse(msg tea.MouseMsg) (util.Model, tea.Cmd) {
162 row, col := d.Position()
163
164 // Adjust coordinates for dialog position.
165 adjust := func(x, y int) (int, int) {
166 return x - col - 1, y - row - 1 - headerHeight
167 }
168
169 switch ev := msg.(type) {
170 case tea.MouseClickMsg:
171 ev.X, ev.Y = adjust(ev.X, ev.Y)
172 d.term.SendMouse(ev)
173 case tea.MouseReleaseMsg:
174 ev.X, ev.Y = adjust(ev.X, ev.Y)
175 d.term.SendMouse(ev)
176 case tea.MouseWheelMsg:
177 ev.X, ev.Y = adjust(ev.X, ev.Y)
178 d.term.SendMouse(ev)
179 case tea.MouseMotionMsg:
180 ev.X, ev.Y = adjust(ev.X, ev.Y)
181 d.term.SendMouse(ev)
182 }
183 return d, nil
184}
185
186func (d *Dialog) View() string {
187 t := styles.CurrentTheme()
188
189 var termContent string
190 if d.term.Started() {
191 termContent = d.term.Render()
192 } else {
193 termContent = d.loadingMsg
194 }
195
196 header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(d.title, d.width-2))
197 content := lipgloss.JoinVertical(lipgloss.Left, header, termContent)
198
199 dialogStyle := t.S().Base.
200 Border(lipgloss.RoundedBorder()).
201 BorderForeground(t.BorderFocus)
202
203 return dialogStyle.Render(content)
204}
205
206func (d *Dialog) Position() (int, int) {
207 if d.fullscreen {
208 return 0, 0
209 }
210
211 dialogWidth := d.width + 2
212 dialogHeight := d.height + 2
213
214 row := max((d.wHeight-dialogHeight)/2, 0)
215 col := max((d.wWidth-dialogWidth)/2, 0)
216
217 return row, col
218}
219
220func (d *Dialog) ID() dialogs.DialogID {
221 return d.id
222}
223
224func (d *Dialog) Close() tea.Cmd {
225 _ = d.term.Close()
226
227 if d.onClose != nil {
228 d.onClose()
229 }
230
231 return nil
232}