//go:build !windows package main import ( "fmt" "io" "math" "os" "os/signal" "strings" "sync" "syscall" "time" "unicode/utf8" gotty "github.com/mattn/go-tty" ) const splitInputVisibleQueueRows = 3 type splitInputReadRuneFunc func() (rune, error) // splitInput manages the persistent input line at the bottom of the terminal // during agent turns. It uses ANSI scroll regions to pin a bottom band while // output scrolls above. type splitInput struct { mainTTY *gotty.TTY out io.Writer mu sync.Mutex prompt string complete func(fields []string) (completionSet []string, listingSet []string) width, height int scrollEnd int bandStart int active bool inputTTY *gotty.TTY buf []rune queue []string completions []string outputStarted bool deferredLFs int winchCh chan os.Signal done chan struct{} pulseStart time.Time } func newSplitInput(t *gotty.TTY, out io.Writer, prompt string, complete func(fields []string) ([]string, []string)) *splitInput { return &splitInput{ mainTTY: t, out: out, prompt: prompt, complete: complete, } } func (s *splitInput) maxQueueRows() int { rows := max(s.height/3, 1) if rows > splitInputVisibleQueueRows { return splitInputVisibleQueueRows } return rows } func (s *splitInput) reservedRowsLocked() int { rows := 2 bodyRows := s.promptBodyRowsLocked() + len(s.completions) if bodyRows > 0 { rows += 1 + bodyRows } if rows > s.height-2 { rows = s.height - 2 } if rows < 2 { rows = 2 } return rows } func (s *splitInput) recalcLayoutLocked() { s.scrollEnd = max(s.height-s.reservedRowsLocked(), 1) s.bandStart = s.scrollEnd + 1 } func (s *splitInput) promptBodyRowsLocked() int { return len(s.queue) } func resizeRedrawStart(oldHeight, oldBandStart, newHeight, newBandStart int) int { if newBandStart < 1 { newBandStart = 1 } if oldHeight <= 0 || oldBandStart <= 0 || newHeight <= 0 { return newBandStart } oldReserved := max(oldHeight-oldBandStart+1, 1) shiftedOldBandStart := min(max(newHeight-oldReserved+1, 1), newHeight) return min(shiftedOldBandStart, newBandStart) } func (s *splitInput) redrawLayoutLocked(clearFrom int) { if clearFrom < 1 { clearFrom = 1 } fmt.Fprintf(s.out, "\033[1;%dr", s.scrollEnd) for row := clearFrom; row <= s.height; row++ { fmt.Fprintf(s.out, "\033[%d;1H\033[2K", row) } s.drawBottomAreaLocked() fmt.Fprintf(s.out, "\033[%d;1H", s.scrollEnd) } func (s *splitInput) prepareSubmittedPromptGapLocked() { if s.height <= 0 { return } fmt.Fprintf(s.out, "\033[%d;1H\n", s.height) } func (s *splitInput) Enter() io.Writer { w, h, err := s.mainTTY.Size() if err != nil || h < 6 { return nil } inputTTY, err := gotty.Open() if err != nil { return nil } s.mu.Lock() s.width = w s.height = h s.active = true s.inputTTY = inputTTY s.buf = s.buf[:0] s.queue = s.queue[:0] s.completions = nil s.outputStarted = false s.deferredLFs = 0 s.pulseStart = time.Now() s.done = make(chan struct{}) s.recalcLayoutLocked() fmt.Fprint(s.out, "\033[?25l") s.prepareSubmittedPromptGapLocked() s.redrawLayoutLocked(s.bandStart) s.mu.Unlock() s.winchCh = make(chan os.Signal, 1) signal.Notify(s.winchCh, syscall.SIGWINCH) go s.watchResize() go s.captureKeys(inputTTY) go s.pulseSeparator() return &splitWriter{s: s} } func (s *splitInput) Exit() (queued []string, pending string) { s.mu.Lock() if !s.active { s.mu.Unlock() return nil, "" } s.active = false close(s.done) inputTTY := s.inputTTY s.inputTTY = nil queued = s.queue s.queue = nil s.completions = nil if len(s.buf) > 0 { pending = string(s.buf) s.buf = s.buf[:0] } s.flushDeferredLFsForExitLocked() fmt.Fprint(s.out, "\033[r") s.clearBandLocked() fmt.Fprint(s.out, "\033[?25h") fmt.Fprintf(s.out, "\033[%d;1H", s.height) s.mu.Unlock() if inputTTY != nil { inputTTY.Close() } signal.Stop(s.winchCh) return queued, pending } func (s *splitInput) SetPrompt(prompt string) { s.mu.Lock() s.prompt = prompt if s.active { fmt.Fprint(s.out, "\033[s") s.clearBandLocked() s.drawBottomAreaLocked() s.restoreOverlayCursorLocked(s.height) } s.mu.Unlock() } func (s *splitInput) PopQueue() (string, bool) { s.mu.Lock() defer s.mu.Unlock() if len(s.queue) == 0 { return "", false } item := s.queue[0] s.queue = s.queue[1:] s.completions = nil oldBandStart := s.bandStart s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) return item, true } func (s *splitInput) runQueuedCommand(cmd queuedCommand) string { s.mu.Lock() defer s.mu.Unlock() var cmdMsg string s.queue, cmdMsg = runQueuedCommand(s.queue, cmd) if s.active { oldBandStart := s.bandStart s.completions = nil s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } return cmdMsg } func (s *splitInput) EchoQueuedInput(input string) { s.mu.Lock() defer s.mu.Unlock() if !s.active { return } s.flushDeferredLFsLocked() fmt.Fprintf(s.out, "%s%s\n", s.prompt, input) s.completions = nil s.outputStarted = false } func (s *splitInput) clearBandLocked() { for row := s.bandStart; row <= s.height; row++ { fmt.Fprintf(s.out, "\033[%d;1H\033[2K", row) } } func (s *splitInput) renderPulseSepLocked() string { const ( baseGray = 238 peakGray = 252 sigma = 3.0 ) period := 14.0 * float64(s.width-1) / 139.0 phase := time.Since(s.pulseStart).Seconds() * 2 * math.Pi / period pos := (math.Sin(phase) + 1) / 2 * float64(s.width-1) var b strings.Builder for i := range s.width { d := float64(i) - pos gray := baseGray + int(float64(peakGray-baseGray)*math.Exp(-d*d/(2*sigma*sigma))) fmt.Fprintf(&b, "\033[38;5;%dm─", gray) } b.WriteString("\033[0m") return b.String() } func (s *splitInput) redrawBottomSepLocked() { sepRow := s.height - 1 if sepRow < s.bandStart { return } fmt.Fprintf(s.out, "\033[s\033[%d;1H\033[2K%s", sepRow, s.renderPulseSepLocked()) s.restoreOverlayCursorLocked(sepRow) } func (s *splitInput) drawBottomAreaLocked() { h := s.height inputRow := h queueRows := len(s.queue) bodyRows := queueRows + len(s.completions) pulseSep := s.renderPulseSepLocked() if bodyRows == 0 { sepRow := inputRow - 1 if sepRow >= s.bandStart { fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", sepRow, pulseSep) } } else { topSepRow := inputRow - bodyRows - 2 bottomSepRow := inputRow - 1 if topSepRow >= s.bandStart { sep := strings.Repeat("─", s.width) fmt.Fprintf(s.out, "\033[%d;1H\033[2K\033[2m%s\033[0m", topSepRow, sep) } row := inputRow - bodyRows - 1 for i, q := range s.queue { currentRow := row + i if currentRow >= s.bandStart { display := truncateToWidth(q, s.width-4) fmt.Fprintf(s.out, "\033[%d;1H\033[2K\033[2m ◆ %s\033[0m", currentRow, display) } } row += queueRows for i, item := range s.completions { currentRow := row + i if currentRow >= s.bandStart { display := truncateToWidth(item, s.width) fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", currentRow, display) } } if bottomSepRow >= s.bandStart { fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", bottomSepRow, pulseSep) } } fmt.Fprintf(s.out, "\033[%d;1H\033[2K%s", inputRow, s.renderInputBufferLocked()) } func (s *splitInput) captureKeys(inputTTY *gotty.TTY) { if inputTTY == nil { return } var pendingTokens []string for { token, rest, err := readSplitInputToken(inputTTY.ReadRune, pendingTokens) pendingTokens = rest if err != nil { return } if token == "" { continue } select { case <-s.done: return default: } s.mu.Lock() if !s.active { s.mu.Unlock() return } switch { case token == "\t": s.completeLocked() case token == "\r" || token == "\n": if len(s.buf) > 0 && s.handleLocalCommandLocked(strings.TrimSpace(string(s.buf))) { break } if len(s.buf) > 0 && len(s.queue) < s.maxQueueRows() { oldBandStart := s.bandStart s.completions = nil s.queue = append(s.queue, string(s.buf)) s.buf = s.buf[:0] s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } case token == "\x7f" || token == "\b": if len(s.buf) > 0 { oldBandStart := s.bandStart cleared := s.clearCompletionsLocked() s.buf = s.buf[:len(s.buf)-1] if cleared { s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } else { s.redrawInputLineLocked() } } case token == "\x03": // Ctrl-C — signal handler deals with it case token == "\x15": if len(s.buf) > 0 { oldBandStart := s.bandStart cleared := s.clearCompletionsLocked() s.buf = s.buf[:0] if cleared { s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } else { s.redrawInputLineLocked() } } case token == "\x17": oldBandStart := s.bandStart cleared := s.clearCompletionsLocked() s.deleteWordLocked() if cleared { s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } else { s.redrawInputLineLocked() } default: if isPrintableSplitInputToken(token) { oldBandStart := s.bandStart cleared := s.clearCompletionsLocked() s.buf = append(s.buf, []rune(token)...) if cleared { s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } else { s.redrawInputLineLocked() } } } s.mu.Unlock() } } func readSplitInputToken(readRune splitInputReadRuneFunc, pending []string) (string, []string, error) { if len(pending) > 0 { return pending[0], pending[1:], nil } token, err := readSplitInputRawToken(readRune) if err != nil { return "", nil, err } if token != BracketedPasteStart { return token, nil, nil } raw := token for !strings.Contains(raw, BracketedPasteEnd) { next, err := readSplitInputRawToken(readRune) if err != nil { return "", nil, err } if len(raw)+len(next) > maxBracketedPasteBytes { return raw, nil, nil } raw += next } tokens := tokenizeSplitInputPaste(raw) if len(tokens) == 0 { return "", nil, nil } return tokens[0], tokens[1:], nil } func readSplitInputRawToken(readRune splitInputReadRuneFunc) (string, error) { r, err := readRune() if err != nil { return "", err } if r != '\x1b' { return string(r), nil } var b strings.Builder b.WriteRune(r) next, err := readRune() if err != nil { return b.String(), nil } b.WriteRune(next) if next != '[' && next != 'O' { return b.String(), nil } for { r, err := readRune() if err != nil { return b.String(), nil } b.WriteRune(r) if r >= 0x40 && r <= 0x7e { return b.String(), nil } } } func tokenizeSplitInputPaste(raw string) []string { tokens := TokenizeBracketedPaste(raw) out := make([]string, 0, len(tokens)) needSpace := false for _, token := range tokens { if token == string(GoqPasteNewlineKey) { needSpace = true continue } token = normalizeSplitInputPasteText(token) if token == "" { continue } if needSpace { if len(out) > 0 && !strings.HasSuffix(out[len(out)-1], " ") && !strings.HasPrefix(token, " ") { out = append(out, " ") } needSpace = false } out = append(out, token) } return out } func normalizeSplitInputPasteText(token string) string { var b strings.Builder for _, r := range token { switch r { case '\t', '\v', '\f': b.WriteByte(' ') default: if r < 32 || r == utf8.RuneError { continue } b.WriteRune(r) } } return b.String() } func isPrintableSplitInputToken(token string) bool { if token == "" { return false } for _, r := range token { if r < 32 || r == utf8.RuneError { return false } } return true } func (s *splitInput) deleteWordLocked() { for len(s.buf) > 0 && s.buf[len(s.buf)-1] == ' ' { s.buf = s.buf[:len(s.buf)-1] } for len(s.buf) > 0 && s.buf[len(s.buf)-1] != ' ' { s.buf = s.buf[:len(s.buf)-1] } } func (s *splitInput) redrawInputLineLocked() { fmt.Fprintf(s.out, "\033[s\033[%d;1H\033[2K%s", s.height, s.renderInputBufferLocked()) s.restoreOverlayCursorLocked(s.height) } func (s *splitInput) restoreOverlayCursorLocked(row int) { if row < 1 { row = 1 } fmt.Fprintf(s.out, "\033[%d;1H\033[u", row) } func (s *splitInput) clearCompletionsLocked() bool { if len(s.completions) == 0 { return false } s.completions = nil return true } func (s *splitInput) completeLocked() { if s.complete == nil { return } buf := string(s.buf) fields := completionFieldsForBuffer(buf) if len(fields) == 0 { return } completionSet, listingSet := s.complete(fields) prefixStart := lastTokenStart(buf) prefix := buf[prefixStart:] matches := filterCompletionMatches(completionSet, prefix) if len(matches) == 0 { return } if len(matches) == 1 { s.setCompletionTokenLocked(prefixStart, matches[0], true) return } common := longestCommonPrefix(matches) if len(common) > len(prefix) { s.setCompletionTokenLocked(prefixStart, common, false) return } listing := filterCompletionMatches(listingSet, prefix) if len(listing) == 0 { listing = matches } s.showCompletionListingLocked(listing) } func (s *splitInput) setCompletionTokenLocked(prefixStart int, replacement string, appendSpace bool) { oldBandStart := s.bandStart s.completions = nil if appendSpace && replacement != "" && !strings.HasSuffix(replacement, "/") && !strings.HasSuffix(replacement, " ") { replacement += " " } s.buf = []rune(string(s.buf)[:prefixStart] + replacement) s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } func (s *splitInput) showCompletionListingLocked(listing []string) { if len(listing) == 0 { return } oldBandStart := s.bandStart s.completions = s.limitCompletionListingLocked(listing) s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) } func (s *splitInput) limitCompletionListingLocked(listing []string) []string { maxRows := s.height - 5 - s.promptBodyRowsLocked() if maxRows <= 0 { return nil } if maxRows > 10 { maxRows = 10 } if len(listing) <= maxRows { return append([]string(nil), listing...) } if maxRows == 1 { return []string{"..."} } limited := append([]string(nil), listing[:maxRows-1]...) return append(limited, "...") } func (s *splitInput) watchResize() { for { select { case <-s.done: return case _, ok := <-s.winchCh: if !ok { return } s.handleResize() } } } func (s *splitInput) pulseSeparator() { ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() for { select { case <-s.done: return case <-ticker.C: s.mu.Lock() if !s.active { s.mu.Unlock() return } s.redrawBottomSepLocked() s.mu.Unlock() } } } func (s *splitInput) handleResize() { w, h, err := s.mainTTY.Size() if err != nil || h < 6 { return } s.mu.Lock() defer s.mu.Unlock() if !s.active { return } oldHeight := s.height oldBandStart := s.bandStart s.width = w s.height = h s.recalcLayoutLocked() s.redrawLayoutLocked(resizeRedrawStart(oldHeight, oldBandStart, s.height, s.bandStart)) } type splitWriter struct { s *splitInput } func (w *splitWriter) Write(p []byte) (int, error) { w.s.mu.Lock() defer w.s.mu.Unlock() if len(w.s.completions) > 0 { oldBandStart := w.s.bandStart w.s.completions = nil w.s.recalcLayoutLocked() w.s.redrawLayoutLocked(min(oldBandStart, w.s.bandStart)) } if len(p) > 0 && !w.s.outputStarted { w.s.writeScrollbackSeparatorLocked() w.s.outputStarted = true } w.s.flushDeferredLFsLocked() trailingLFs := trailingLFCount(p) body := p[:len(p)-trailingLFs] if len(body) > 0 { if _, err := w.s.out.Write(body); err != nil { return 0, err } } w.s.deferredLFs += trailingLFs return len(p), nil } func (s *splitInput) writeScrollbackSeparatorLocked() { sep := strings.Repeat("─", s.width) fmt.Fprintf(s.out, "\n\033[2m%s\033[0m\n", sep) } func (s *splitInput) flushDeferredLFsLocked() { if s.deferredLFs == 0 { return } fmt.Fprint(s.out, strings.Repeat("\n", s.deferredLFs)) s.deferredLFs = 0 } func (s *splitInput) flushDeferredLFsForExitLocked() { if s.deferredLFs <= 1 { s.deferredLFs = 0 return } fmt.Fprint(s.out, strings.Repeat("\n", s.deferredLFs-1)) s.deferredLFs = 0 } func trailingLFCount(p []byte) int { n := 0 for i := len(p) - 1; i >= 0 && p[i] == '\n'; i-- { n++ } return n } func (s *splitInput) renderInputBufferLocked() string { promptWidth := visibleWidth(s.prompt) maxInputWidth := s.width - promptWidth - 1 display := truncateToWidthFromEnd(string(s.buf), maxInputWidth) return s.prompt + display + "\033[7m \033[0m" } func (s *splitInput) handleLocalCommandLocked(input string) bool { return s.handleQueuedCommandLocked(input) } func (s *splitInput) handleQueuedCommandLocked(input string) bool { cmd, ok, err := parseQueuedCommandLine(input) if !ok { return false } oldBandStart := s.bandStart s.buf = s.buf[:0] s.completions = nil if err != nil { s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) s.writeLocalStatusMessageLocked(err.Error()) return true } s.queue, input = runQueuedCommand(s.queue, cmd) s.recalcLayoutLocked() s.redrawLayoutLocked(min(oldBandStart, s.bandStart)) s.writeLocalStatusMessageLocked(input) return true } func (s *splitInput) writeLocalStatusMessageLocked(msg string) { if msg == "" { return } s.flushDeferredLFsLocked() for line := range strings.SplitSeq(msg, "\n") { fmt.Fprintf(s.out, "\033[2m%s\033[0m\n", line) } } // --- completion helpers --- func completionFieldsForBuffer(buf string) []string { fields := splitFields(buf) if len(buf) == 0 { return fields } if containsByte(" \t\r\n\v\f", buf[len(buf)-1]) { return append(fields, "") } return fields } func lastTokenStart(buf string) int { for i := len(buf) - 1; i >= 0; i-- { if containsByte(" \t\r\n\v\f", buf[i]) { return i + 1 } } return 0 } func filterCompletionMatches(candidates []string, prefix string) []string { if len(candidates) == 0 { return nil } if prefix == "" { return append([]string(nil), candidates...) } matches := make([]string, 0, len(candidates)) for _, candidate := range candidates { if strings.HasPrefix(candidate, prefix) { matches = append(matches, candidate) } } if len(matches) > 0 { return matches } lowerPrefix := strings.ToLower(prefix) for _, candidate := range candidates { if strings.HasPrefix(strings.ToLower(candidate), lowerPrefix) { matches = append(matches, candidate) } } return matches } func longestCommonPrefix(values []string) string { if len(values) == 0 { return "" } prefix := values[0] for _, value := range values[1:] { for !strings.HasPrefix(value, prefix) && prefix != "" { prefix = prefix[:len(prefix)-1] } if prefix == "" { return "" } } return prefix } func splitFields(line string) []string { const spaces = " \t\r\n\v\f" var fields []string for len(line) > 0 { i := 0 for i < len(line) && containsByte(spaces, line[i]) { i++ } line = line[i:] if len(line) == 0 { break } i = 0 for i < len(line) && !containsByte(spaces, line[i]) { i++ } fields = append(fields, line[:i]) line = line[i:] } return fields } func containsByte(set string, b byte) bool { return strings.IndexByte(set, b) >= 0 }