1package tui
2
3import (
4 "io"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 "time"
10 "unicode"
11
12 "golang.org/x/term"
13)
14
15// Terminal abstracts the real terminal for tests.
16type Terminal interface {
17 io.Writer
18 // Size returns (cols, rows).
19 Size() (int, int)
20 // OnResize registers a callback invoked on SIGWINCH (best effort).
21 OnResize(func())
22 // EnterRaw puts the tty into raw mode. Returns a restore func.
23 EnterRaw() (restore func() error, err error)
24 // ReadByte reads one byte of input. Blocks.
25 ReadByte() (byte, error)
26 // PeekByteTimeout reads one byte of input but returns (0, false, nil)
27 // if no byte arrives within the timeout. Used to disambiguate bare
28 // Esc from the start of an escape sequence.
29 PeekByteTimeout(time.Duration) (byte, bool, error)
30 // SetNonblock sets stdin to non-blocking mode (used by paste handling).
31 // May be a no-op on some platforms.
32 SetNonblock(bool) error
33}
34
35// ProcTerm is a Terminal bound to the current process's tty.
36type ProcTerm struct {
37 out *os.File
38 in *os.File
39 resizeCBs []func()
40 writeMu sync.Mutex
41}
42
43// NewProcTerm returns a Terminal bound to stdin/stdout.
44func NewProcTerm() *ProcTerm {
45 return &ProcTerm{out: os.Stdout, in: os.Stdin}
46}
47
48func (p *ProcTerm) Write(b []byte) (int, error) {
49 p.writeMu.Lock()
50 defer p.writeMu.Unlock()
51 return p.out.Write(b)
52}
53
54// WriteBell emits one standalone terminal alert character. Callers should
55// route it through the same output writer used for the rest of the terminal
56// so redraws and alerts share the host's output boundary.
57func WriteBell(w io.Writer) error {
58 _, err := io.WriteString(w, "\a")
59 return err
60}
61
62// SetTitle returns an OSC 0 sequence that sets the terminal's tab/window
63// title. Control characters are removed from the payload so user- or
64// model-provided text cannot inject another terminal control sequence.
65func SetTitle(title string) string {
66 var safe strings.Builder
67 for _, r := range title {
68 if unicode.IsControl(r) || r == 0x7f {
69 continue
70 }
71 safe.WriteRune(r)
72 }
73 return "\x1b]0;" + safe.String() + "\x07"
74}
75
76func (p *ProcTerm) Size() (int, int) {
77 w, h, err := term.GetSize(int(p.out.Fd()))
78 if err != nil || w <= 0 || h <= 0 {
79 return 80, 24
80 }
81 return w, h
82}
83
84func (p *ProcTerm) OnResize(fn func()) {
85 p.resizeCBs = append(p.resizeCBs, fn)
86 p.installResizeHandler()
87}
88
89func (p *ProcTerm) EnterRaw() (func() error, error) {
90 fd := int(p.in.Fd())
91 if !term.IsTerminal(fd) {
92 return func() error { return nil }, nil
93 }
94 st, err := term.MakeRaw(fd)
95 if err != nil {
96 return nil, err
97 }
98 return func() error { return term.Restore(fd, st) }, nil
99}
100
101func (p *ProcTerm) ReadByte() (byte, error) {
102 var b [1]byte
103 _, err := p.in.Read(b[:])
104 return b[0], err
105}
106
107// PeekByteTimeout uses a platform-specific non-blocking read to decide
108// whether another byte is available within d. If not, returns (0, false, nil).
109func (p *ProcTerm) PeekByteTimeout(d time.Duration) (byte, bool, error) {
110 return peekStdin(p.in, d)
111}
112
113// ---- terminal control sequences ----
114
115// HideCursor, ShowCursor, ClearScreen, BracketedPasteOn/Off, etc.
116const (
117 SeqHideCursor = "\x1b[?25l"
118 SeqShowCursor = "\x1b[?25h"
119 SeqClearScreen = "\x1b[2J\x1b[H"
120 // SeqClearScreenNoHome is SeqClearScreen without the trailing
121 // cursor-home (\x1b[H). Use it whenever a MoveTo(...) follows
122 // immediately. VS Code's integrated terminal interprets a bare
123 // CUP-no-args during a clear as "snap the viewport scrollbar to
124 // row 0", which makes the user's scroll position jump on every
125 // repaint. The explicit MoveTo we emit afterwards positions the
126 // cursor identically without triggering that snap.
127 SeqClearScreenNoHome = "\x1b[2J"
128 SeqClearScrollback = "\x1b[3J"
129 // SeqCursorHome moves to the top-left of the visible viewport.
130 // SeqClearToEnd erases from the cursor to the end of the screen
131 // without scrolling content into scrollback (unlike \x1b[2J on
132 // xterm.js). Together they clear the visible frame in place, which
133 // the VS Code terminal needs for duplicate-free full repaints.
134 SeqCursorHome = "\x1b[H"
135 SeqClearToEnd = "\x1b[0J"
136 SeqClearLine = "\x1b[2K"
137 SeqResetScrollRegion = "\x1b[r"
138 SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"
139 SeqBracketedPasteOn = "\x1b[?2004h"
140 SeqBracketedPasteOff = "\x1b[?2004l"
141 // Request enhanced keyboard reporting where supported. Kitty-style
142 // keyboard protocol covers Ghostty, Kitty, VS Code's integrated
143 // terminal, and recent xterm.js builds. Xterm modifyOtherKeys is a
144 // useful fallback for terminals/tmux configurations that expose
145 // modified Enter as CSI 27;<mod>;<code>~.
146 SeqEnhancedKeyboardOn = "\x1b[>1u\x1b[>4;2m"
147 SeqEnhancedKeyboardOff = "\x1b[<u\x1b[>4m"
148 // Basic mouse tracking + SGR extended coordinates. Used only
149 // when explicitly enabled by the interactive mode (currently VS
150 // Code terminal) so terminals with good native scrolling, like
151 // Ghostty, are left alone.
152 SeqMouseOn = "\x1b[?1000h\x1b[?1006h"
153 SeqMouseOff = "\x1b[?1000l\x1b[?1006l"
154 SeqAltScreenOn = "\x1b[?1049h"
155 SeqAltScreenOff = "\x1b[?1049l"
156 SeqSynchronizedOn = "\x1b[?2026h"
157 SeqSynchronizedOff = "\x1b[?2026l"
158 // SeqSaveCursor / SeqRestoreCursor use DECSC/DECRC. All terminals
159 // we target adjust the saved row when natural scrolling occurs, so
160 // these survive scroll-on-write inside the bottom band.
161 SeqSaveCursor = "\x1b7"
162 SeqRestoreCursor = "\x1b8"
163 // SeqEraseToEnd erases from the cursor to the end of the screen.
164 SeqEraseToEnd = "\x1b[J"
165)
166
167// ReportCWD returns the OSC 7 sequence that tells the terminal the
168// current working directory, formatted as a file URL. Terminals such
169// as kitty, WezTerm, iTerm2, and GNOME Terminal use this to open new
170// tabs / splits in the same directory. Returns "" for an empty path so
171// callers can write it unconditionally. The path is percent-encoded
172// per RFC 3986 (only unreserved characters and the path separator are
173// left bare) and prefixed with the local hostname.
174func ReportCWD(dir string) string {
175 if dir == "" {
176 return ""
177 }
178 abs, err := filepath.Abs(dir)
179 if err != nil {
180 abs = dir
181 }
182 host, _ := os.Hostname()
183 return "\x1b]7;file://" + host + osc7EscapePath(abs) + "\x07"
184}
185
186// osc7EscapePath percent-encodes a filesystem path for an OSC 7 file
187// URL. Forward slashes (the URL path separator) and the unreserved set
188// (A-Z a-z 0-9 - _ . ~) pass through; everything else is %XX-encoded.
189// On Windows the backslash separators are normalised to forward
190// slashes and a leading slash is added so "C:\foo" becomes "/C:/foo".
191func osc7EscapePath(p string) string {
192 p = filepath.ToSlash(p)
193 if len(p) > 1 && p[1] == ':' {
194 p = "/" + p
195 }
196 const hex = "0123456789ABCDEF"
197 var b []byte
198 for i := 0; i < len(p); i++ {
199 c := p[i]
200 unreserved := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
201 (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' || c == '/'
202 if unreserved {
203 b = append(b, c)
204 continue
205 }
206 b = append(b, '%', hex[c>>4], hex[c&0x0f])
207 }
208 return string(b)
209}
210
211// SetScrollRegion returns the DECSTBM sequence to set the terminal's
212// scroll region (1-indexed, inclusive). Lines that scroll out of this
213// region still flow into the terminal's scrollback, but content above
214// or below the region is not affected by scroll-induced movement.
215func SetScrollRegion(top, bottom int) string {
216 return "\x1b[" + itoa(top) + ";" + itoa(bottom) + "r"
217}
218
219// MoveTo moves the cursor to 1-indexed (row, col).
220func MoveTo(row, col int) string {
221 return "\x1b[" + itoa(row) + ";" + itoa(col) + "H"
222}