pkg/ui/terminal.go (211 lines of code) (raw):
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"slices"
"strings"
"github.com/GoogleCloudPlatform/kubectl-ai/pkg/journal"
"github.com/charmbracelet/glamour"
"k8s.io/klog/v2"
)
type TerminalUI struct {
journal journal.Recorder
markdownRenderer *glamour.TermRenderer
subscription io.Closer
// currentBlock is the block we are rendering
currentBlock Block
// currentBlockText is text of the currentBlock that we have already rendered to the screen
currentBlockText string
// This is useful in cases where stdin is already been used for providing the input to the agent (caller in this case)
// in such cases, stdin is already consumed and closed and reading input results in IO error.
// In such cases, we open /dev/tty and use it for taking input.
useTTYForInput bool
}
var _ UI = &TerminalUI{}
func NewTerminalUI(doc *Document, journal journal.Recorder, useTTYForInput bool) (*TerminalUI, error) {
mdRenderer, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithPreservedNewLines(),
glamour.WithEmoji(),
)
if err != nil {
return nil, fmt.Errorf("error initializing the markdown renderer: %w", err)
}
u := &TerminalUI{markdownRenderer: mdRenderer, journal: journal, useTTYForInput: useTTYForInput}
subscription := doc.AddSubscription(u)
u.subscription = subscription
return u, nil
}
func (u *TerminalUI) Close() error {
var errs []error
if u.subscription != nil {
if err := u.subscription.Close(); err != nil {
errs = append(errs, err)
} else {
u.subscription = nil
}
}
return errors.Join(errs...)
}
func (u *TerminalUI) DocumentChanged(doc *Document, block Block) {
blockIndex := doc.IndexOf(block)
if blockIndex != doc.NumBlocks()-1 {
klog.Warningf("update to blocks other than the last block is not supported in terminal mode")
return
}
if u.currentBlock != block {
u.currentBlock = block
if u.currentBlockText != "" {
fmt.Printf("\n")
}
u.currentBlockText = ""
}
text := ""
streaming := false
var styleOptions []StyleOption
switch block := block.(type) {
case *ErrorBlock:
styleOptions = append(styleOptions, Foreground(ColorRed))
text = block.Text()
case *FunctionCallRequestBlock:
styleOptions = append(styleOptions, Foreground(ColorGreen))
text = block.Text()
case *AgentTextBlock:
styleOptions = append(styleOptions, RenderMarkdown())
if block.Color != "" {
styleOptions = append(styleOptions, Foreground(block.Color))
}
text = block.Text()
streaming = block.Streaming()
case *InputTextBlock:
fmt.Print("\n>>> ")
var reader *bufio.Reader
if u.useTTYForInput {
// Stdin was used for piped data, open the terminal directly
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
block.Observable().Set("", err)
return
}
defer tty.Close()
reader = bufio.NewReader(tty)
} else {
reader = bufio.NewReader(os.Stdin)
}
query, err := reader.ReadString('\n')
if err != nil {
block.Observable().Set("", err)
} else {
block.Observable().Set(query, nil)
}
return
case *InputOptionBlock:
fmt.Printf("%s\n", block.Prompt)
var reader *bufio.Reader
if u.useTTYForInput {
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
block.Observable().Set("", err)
return
}
defer tty.Close()
reader = bufio.NewReader(tty)
} else {
reader = bufio.NewReader(os.Stdin)
}
for {
fmt.Print(" Enter your choice (number): ")
var response string
response, err := reader.ReadString('\n')
if err != nil {
block.Observable().Set("", err)
break
}
choice := strings.TrimSpace(response)
if slices.Contains(block.Options, choice) {
block.Observable().Set(choice, nil)
break
}
// If not returned, the choice was invalid
fmt.Printf(" Invalid choice. Please enter one of: %s\n", strings.Join(block.Options, ", "))
continue
}
return
}
computedStyle := &style{}
for _, opt := range styleOptions {
opt(computedStyle)
}
if streaming && computedStyle.renderMarkdown {
// Because we can't render markdown incrementally,
// we "hold back" the text if we are streaming markdown until streaming is done
text = ""
}
printText := text
if computedStyle.renderMarkdown && printText != "" {
out, err := u.markdownRenderer.Render(printText)
if err != nil {
klog.Errorf("Error rendering markdown: %v", err)
} else {
printText = out
}
}
if u.currentBlockText != "" {
if strings.HasPrefix(text, u.currentBlockText) {
printText = strings.TrimPrefix(printText, u.currentBlockText)
} else {
klog.Warningf("text did not match text already rendered; text %q; currentBlockText %q", text, u.currentBlockText)
}
}
u.currentBlockText = text
reset := ""
switch computedStyle.foreground {
case ColorRed:
fmt.Printf("\033[31m")
reset += "\033[0m"
case ColorGreen:
fmt.Printf("\033[32m")
reset += "\033[0m"
case ColorWhite:
fmt.Printf("\033[37m")
reset += "\033[0m"
case "":
default:
klog.Info("foreground color not supported by TerminalUI", "color", computedStyle.foreground)
}
fmt.Printf("%s%s", printText, reset)
}
func (u *TerminalUI) RenderOutput(ctx context.Context, s string, styleOptions ...StyleOption) {
log := klog.FromContext(ctx)
u.journal.Write(ctx, &journal.Event{
Action: journal.ActionUIRender,
Payload: map[string]any{
"text": s,
},
})
computedStyle := &style{}
for _, opt := range styleOptions {
opt(computedStyle)
}
if computedStyle.renderMarkdown {
out, err := u.markdownRenderer.Render(s)
if err != nil {
log.Error(err, "Error rendering markdown")
}
s = out
}
reset := ""
switch computedStyle.foreground {
case ColorRed:
fmt.Printf("\033[31m")
reset += "\033[0m"
case ColorGreen:
fmt.Printf("\033[32m")
reset += "\033[0m"
case ColorWhite:
fmt.Printf("\033[37m")
reset += "\033[0m"
case "":
default:
log.Info("foreground color not supported by TerminalUI", "color", computedStyle.foreground)
}
fmt.Printf("%s%s", s, reset)
}
func (u *TerminalUI) ClearScreen() {
fmt.Print("\033[H\033[2J")
}