diff --git a/cli/any.go b/cli/any.go new file mode 100644 index 0000000..88f6a37 --- /dev/null +++ b/cli/any.go @@ -0,0 +1,45 @@ +package cli + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +var ( + anyTypeStyle = lipgloss.NewStyle().Foreground(grayColor) + anyNilStyle = lipgloss.NewStyle().Foreground(grayColor) +) + +func anyString(any *anypb.Any) string { + if any == nil { + return anyNilStyle.Render("nil") + } + switch any.TypeUrl { + case "type.googleapis.com/google.protobuf.BytesValue": + if s, err := anyBytesString(any); err == nil && s != "" { + return s + } + // Suppress the error; render the type only. + } + return anyTypeStyle.Render(fmt.Sprintf("<%s>", any.TypeUrl)) + +} + +func anyBytesString(any *anypb.Any) (string, error) { + m, err := anypb.UnmarshalNew(any, proto.UnmarshalOptions{}) + if err != nil { + return "", err + } + bv, ok := m.(*wrapperspb.BytesValue) + if !ok { + return "", fmt.Errorf("invalid bytes value: %T", m) + } + b := bv.Value + + // TODO: support unpacking other types of serialized values + return pythonPickleString(b) +} diff --git a/cli/color.go b/cli/color.go index 4b79f9f..b58c4c2 100644 --- a/cli/color.go +++ b/cli/color.go @@ -7,7 +7,7 @@ var ( // See https://www.hackitu.de/termcolor256/ grayColor = lipgloss.ANSIColor(102) - redColor = lipgloss.ANSIColor(124) + redColor = lipgloss.ANSIColor(160) greenColor = lipgloss.ANSIColor(34) yellowColor = lipgloss.ANSIColor(142) magentaColor = lipgloss.ANSIColor(127) diff --git a/cli/python.go b/cli/python.go new file mode 100644 index 0000000..4e9a39b --- /dev/null +++ b/cli/python.go @@ -0,0 +1,224 @@ +package cli + +import ( + "bytes" + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/nlpodyssey/gopickle/pickle" + "github.com/nlpodyssey/gopickle/types" +) + +var ( + kwargStyle = lipgloss.NewStyle().Foreground(grayColor) +) + +func pythonPickleString(b []byte) (string, error) { + u := pickle.NewUnpickler(bytes.NewReader(b)) + u.FindClass = findPythonClass + + value, err := u.Load() + if err != nil { + return "", err + } + return pythonValueString(value) +} + +func pythonValueString(value interface{}) (string, error) { + switch v := value.(type) { + case nil: + return "None", nil + case bool: + if v { + return "True", nil + } + return "False", nil + case string: + return fmt.Sprintf("%q", v), nil + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, float32, float64: + return fmt.Sprintf("%v", v), nil + case *types.List: + return pythonListString(v) + case *types.Tuple: + return pythonTupleString(v) + case *types.Dict: + return pythonDictString(v) + case *types.Set: + return pythonSetString(v) + case *pythonArgumentsObject: + return pythonArgumentsString(v) + case *types.GenericClass: + return fmt.Sprintf("%s.%s", v.Module, v.Name), nil + case *types.GenericObject: + s, _ := pythonValueString(v.Class) + return fmt.Sprintf("%s(?)", s), nil + default: + return "", fmt.Errorf("unsupported Python value: %T", value) + } +} + +func pythonListString(list *types.List) (string, error) { + var b strings.Builder + b.WriteByte('[') + for i, entry := range *list { + if i > 0 { + b.WriteString(", ") + } + s, err := pythonValueString(entry) + if err != nil { + return "", err + } + b.WriteString(s) + } + b.WriteByte(']') + return b.String(), nil +} + +func pythonTupleString(tuple *types.Tuple) (string, error) { + var b strings.Builder + b.WriteByte('(') + for i, entry := range *tuple { + if i > 0 { + b.WriteString(", ") + } + s, err := pythonValueString(entry) + if err != nil { + return "", err + } + b.WriteString(s) + } + b.WriteByte(')') + return b.String(), nil +} + +func pythonDictString(dict *types.Dict) (string, error) { + var b strings.Builder + b.WriteByte('{') + for i, entry := range *dict { + if i > 0 { + b.WriteString(", ") + } + keyStr, err := pythonValueString(entry.Key) + if err != nil { + return "", err + } + b.WriteString(keyStr) + b.WriteString(": ") + + valueStr, err := pythonValueString(entry.Value) + if err != nil { + return "", err + } + b.WriteString(valueStr) + } + b.WriteByte('}') + return b.String(), nil +} + +func pythonSetString(set *types.Set) (string, error) { + var b strings.Builder + b.WriteByte('{') + var i int + for entry := range *set { + if i > 0 { + b.WriteString(", ") + } + s, err := pythonValueString(entry) + if err != nil { + return "", err + } + b.WriteString(s) + i++ + } + b.WriteByte('}') + return b.String(), nil +} + +func pythonArgumentsString(a *pythonArgumentsObject) (string, error) { + var b strings.Builder + b.WriteByte('(') + + var argsLen int + if a.args != nil { + argsLen = a.args.Len() + for i := 0; i < argsLen; i++ { + if i > 0 { + b.WriteString(", ") + } + arg := a.args.Get(i) + s, err := pythonValueString(arg) + if err != nil { + return "", err + } + b.WriteString(s) + } + } + + if a.kwargs != nil { + for i, entry := range *a.kwargs { + if i > 0 || argsLen > 0 { + b.WriteString(", ") + } + var keyStr string + if s, ok := entry.Key.(string); ok { + keyStr = s + } else { + var err error + keyStr, err = pythonValueString(entry.Key) + if err != nil { + return "", err + } + } + b.WriteString(kwargStyle.Render(keyStr + "=")) + + valueStr, err := pythonValueString(entry.Value) + if err != nil { + return "", err + } + b.WriteString(valueStr) + } + } + + b.WriteByte(')') + return b.String(), nil + +} + +func findPythonClass(module, name string) (interface{}, error) { + // https://github.com/dispatchrun/dispatch-py/blob/0a482491/src/dispatch/proto.py#L175 + if module == "dispatch.proto" && name == "Arguments" { + return &pythonArgumentsClass{}, nil + } + return types.NewGenericClass(module, name), nil +} + +type pythonArgumentsClass struct{} + +func (a *pythonArgumentsClass) PyNew(args ...interface{}) (interface{}, error) { + return &pythonArgumentsObject{}, nil +} + +type pythonArgumentsObject struct { + args *types.Tuple + kwargs *types.Dict +} + +var _ types.PyDictSettable = (*pythonArgumentsObject)(nil) + +func (a *pythonArgumentsObject) PyDictSet(key, value interface{}) error { + var ok bool + switch key { + case "args": + if a.args, ok = value.(*types.Tuple); !ok { + return fmt.Errorf("invalid Arguments.args: %T", value) + } + case "kwargs": + if a.kwargs, ok = value.(*types.Dict); !ok { + return fmt.Errorf("invalid Arguments.kwargs: %T", value) + } + default: + return fmt.Errorf("unexpected key: %v", key) + } + return nil +} diff --git a/cli/run.go b/cli/run.go index f9438ca..0ebfa2c 100644 --- a/cli/run.go +++ b/cli/run.go @@ -371,7 +371,7 @@ func poll(ctx context.Context, client *http.Client, url string) (string, *http.R type FunctionCallObserver interface { // ObserveRequest observes a RunRequest as it passes from the API through // the CLI to the local application. - ObserveRequest(*sdkv1.RunRequest) + ObserveRequest(time.Time, *sdkv1.RunRequest) // ObserveResponse observes a response to the RunRequest. // @@ -383,7 +383,7 @@ type FunctionCallObserver interface { // // ObserveResponse always comes after a call to ObserveRequest for any given // RunRequest. - ObserveResponse(*sdkv1.RunRequest, error, *http.Response, *sdkv1.RunResponse) + ObserveResponse(time.Time, *sdkv1.RunRequest, error, *http.Response, *sdkv1.RunResponse) } func invoke(ctx context.Context, client *http.Client, url, requestID string, bridgeGetRes *http.Response, observer FunctionCallObserver) error { @@ -430,7 +430,7 @@ func invoke(ctx context.Context, client *http.Client, url, requestID string, bri logger.Info("resuming function", "function", runRequest.Function) } if observer != nil { - observer.ObserveRequest(&runRequest) + observer.ObserveRequest(time.Now(), &runRequest) } // The RequestURI field must be cleared for client.Do() to @@ -442,9 +442,10 @@ func invoke(ctx context.Context, client *http.Client, url, requestID string, bri endpointReq.URL.Scheme = "http" endpointReq.URL.Host = LocalEndpoint endpointRes, err := client.Do(endpointReq) + now := time.Now() if err != nil { if observer != nil { - observer.ObserveResponse(&runRequest, err, nil, nil) + observer.ObserveResponse(now, &runRequest, err, nil, nil) } return fmt.Errorf("failed to contact local application endpoint (%s): %v. Please check that -e,--endpoint is correct.", LocalEndpoint, err) } @@ -458,7 +459,7 @@ func invoke(ctx context.Context, client *http.Client, url, requestID string, bri endpointRes.Body.Close() if err != nil { if observer != nil { - observer.ObserveResponse(&runRequest, err, endpointRes, nil) + observer.ObserveResponse(now, &runRequest, err, endpointRes, nil) } return fmt.Errorf("failed to read response from local application endpoint (%s): %v", LocalEndpoint, err) } @@ -470,7 +471,7 @@ func invoke(ctx context.Context, client *http.Client, url, requestID string, bri var runResponse sdkv1.RunResponse if err := proto.Unmarshal(endpointResBody.Bytes(), &runResponse); err != nil { if observer != nil { - observer.ObserveResponse(&runRequest, err, endpointRes, nil) + observer.ObserveResponse(now, &runRequest, err, endpointRes, nil) } return fmt.Errorf("invalid response from local application endpoint (%s): %v", LocalEndpoint, err) } @@ -491,13 +492,13 @@ func invoke(ctx context.Context, client *http.Client, url, requestID string, bri logger.Warn("function call failed", "function", runRequest.Function, "status", statusString(runResponse.Status), "error_type", err.GetType(), "error_message", err.GetMessage()) } if observer != nil { - observer.ObserveResponse(&runRequest, nil, endpointRes, &runResponse) + observer.ObserveResponse(now, &runRequest, nil, endpointRes, &runResponse) } } else { // The response might indicate some other issue, e.g. it could be a 404 if the function can't be found logger.Warn("function call failed", "function", runRequest.Function, "http_status", endpointRes.StatusCode) if observer != nil { - observer.ObserveResponse(&runRequest, nil, endpointRes, nil) + observer.ObserveResponse(now, &runRequest, nil, endpointRes, nil) } } diff --git a/cli/style.go b/cli/style.go index 1879b6c..4e8bb05 100644 --- a/cli/style.go +++ b/cli/style.go @@ -10,9 +10,6 @@ import ( ) var ( - green = lipgloss.Color("#00FF00") - red = lipgloss.Color("#FF0000") - dialogBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#874BFD")). @@ -22,11 +19,9 @@ var ( BorderRight(true). BorderBottom(true) - successStyle = lipgloss.NewStyle(). - Foreground(green) + successStyle = lipgloss.NewStyle().Foreground(greenColor) - failureStyle = lipgloss.NewStyle(). - Foreground(red) + failureStyle = lipgloss.NewStyle().Foreground(redColor) ) type errMsg struct{ error } diff --git a/cli/text.go b/cli/text.go new file mode 100644 index 0000000..054cebb --- /dev/null +++ b/cli/text.go @@ -0,0 +1,69 @@ +package cli + +import ( + "strings" + + "github.com/muesli/reflow/ansi" +) + +func whitespace(width int) string { + return strings.Repeat(" ", width) +} + +func padding(width int, s string) int { + return width - ansi.PrintableRuneWidth(s) +} + +func truncate(width int, s string) string { + var truncated bool + for len(s) > 0 && ansi.PrintableRuneWidth(s) > width { + s = s[:len(s)-1] + truncated = true + } + if truncated { + s = s + "\033[0m" + } + return s +} + +func right(width int, s string) string { + if ansi.PrintableRuneWidth(s) > width { + return truncate(width-3, s) + "..." + } + return whitespace(padding(width, s)) + s +} + +func left(width int, s string) string { + if ansi.PrintableRuneWidth(s) > width { + return truncate(width-3, s) + "..." + } + return s + whitespace(padding(width, s)) +} + +func join(rows ...string) string { + var b strings.Builder + for i, row := range rows { + if i > 0 { + b.WriteByte(' ') + } + b.WriteString(row) + } + return b.String() +} + +func clearANSI(s string) string { + var isANSI bool + var b strings.Builder + for _, c := range s { + if c == ansi.Marker { + isANSI = true + } else if isANSI { + if ansi.IsTerminator(c) { + isANSI = false + } + } else { + b.WriteRune(c) + } + } + return b.String() +} diff --git a/cli/tui.go b/cli/tui.go index 21960ea..e4ea3c6 100644 --- a/cli/tui.go +++ b/cli/tui.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "math" "net/http" "strconv" "strings" @@ -13,6 +14,7 @@ import ( sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -24,6 +26,12 @@ const ( underscoreBlinkInterval = time.Second / 2 ) +const ( + pendingIcon = "•" // U+2022 + successIcon = "✔" // U+2714 + failureIcon = "✗" // U+2718 +) + var ( // Style for the viewport that contains everything. viewportStyle = lipgloss.NewStyle().Margin(1, 2) @@ -34,35 +42,32 @@ var ( // Style for the table of function calls. tableHeaderStyle = lipgloss.NewStyle().Foreground(defaultColor).Bold(true) + selectedStyle = lipgloss.NewStyle().Background(magentaColor) // Styles for function names and statuses in the table. - pendingStyle = lipgloss.NewStyle().Foreground(grayColor) - retryStyle = lipgloss.NewStyle().Foreground(yellowColor) - errorStyle = lipgloss.NewStyle().Foreground(redColor) - okStyle = lipgloss.NewStyle().Foreground(greenColor) + pendingStyle = lipgloss.NewStyle().Foreground(grayColor) + suspendedStyle = lipgloss.NewStyle().Foreground(grayColor) + retryStyle = lipgloss.NewStyle().Foreground(yellowColor) + errorStyle = lipgloss.NewStyle().Foreground(redColor) + okStyle = lipgloss.NewStyle().Foreground(greenColor) // Styles for other components inside the table. treeStyle = lipgloss.NewStyle().Foreground(grayColor) -) -const ( - pendingIcon = "•" // U+2022 - successIcon = "✔" // U+2714 - failureIcon = "✗" // U+2718 + // Styles for the function call detail tab. + detailHeaderStyle = lipgloss.NewStyle().Foreground(grayColor) + detailLowPriorityStyle = lipgloss.NewStyle().Foreground(grayColor) ) -type DispatchID string - type TUI struct { ticks uint64 - // Storage for the function call hierarchies. Each function call - // has a "root" node, and nodes can have zero or more children. + // Storage for the function call hierarchies. // // FIXME: we never clean up items from these maps roots map[DispatchID]struct{} orderedRoots []DispatchID - nodes map[DispatchID]node + calls map[DispatchID]functionCall // Storage for logs. logs bytes.Buffer @@ -70,13 +75,19 @@ type TUI struct { // TUI models / options / flags, used to display the information // above. viewport viewport.Model + selection textinput.Model help help.Model ready bool activeTab tab - logHelp string - functionCallHelp string - tail bool + selectMode bool + tailMode bool + logoHelp string + logsTabHelp string + functionsTabHelp string + detailTabHelp string + selectHelp string windowHeight int + selected *DispatchID mu sync.Mutex } @@ -86,45 +97,53 @@ type tab int const ( functionsTab tab = iota logsTab + detailTab ) -const tabCount = 2 +const tabCount = 3 -var keyMap = []key.Binding{ - key.NewBinding( +var ( + tabKey = key.NewBinding( key.WithKeys("tab"), - key.WithHelp("tab", "switch tabs"), - ), - key.NewBinding( + key.WithHelp("tab", "switch tab"), + ) + + selectModeKey = key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "select"), + ) + + tailKey = key.NewBinding( key.WithKeys("t"), key.WithHelp("t", "tail"), - ), - key.NewBinding( + ) + + quitKey = key.NewBinding( key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("q", "quit"), - ), -} - -type node struct { - function string - - failures int - responses int + ) - status sdkv1.Status - error error + selectKey = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select function"), + ) - running bool - suspended bool - done bool + exitSelectKey = key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "exit select"), + ) - creationTime time.Time - expirationTime time.Time - doneTime time.Time + quitInSelectKey = key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ) - children map[DispatchID]struct{} - orderedChildren []DispatchID -} + logoKeyMap = []key.Binding{tabKey, quitKey} + functionsTabKeyMap = []key.Binding{tabKey, selectModeKey, quitKey} + detailTabKeyMap = []key.Binding{tabKey, quitKey} + logsTabKeyMap = []key.Binding{tabKey, tailKey, quitKey} + selectKeyMap = []key.Binding{selectKey, exitSelectKey, tabKey, quitInSelectKey} +) type tickMsg struct{} @@ -141,17 +160,28 @@ func tick() tea.Cmd { }) } +type focusSelectMsg struct{} + +func focusSelect() tea.Msg { + return focusSelectMsg{} +} + func (t *TUI) Init() tea.Cmd { - t.help = help.New() // Note that t.viewport is initialized on the first tea.WindowSizeMsg. + t.help = help.New() - t.tail = true - t.activeTab = functionsTab + t.selection = textinput.New() + t.selection.Focus() // input is visibile iff t.selectMode == true - // Only show tab+quit in default function call view. - // Show tab+tail+quit when viewing logs. - t.logHelp = t.help.ShortHelpView(keyMap) - t.functionCallHelp = t.help.ShortHelpView(append(keyMap[0:1:1], keyMap[len(keyMap)-1])) + t.selectMode = false + t.tailMode = true + + t.activeTab = functionsTab + t.logoHelp = t.help.ShortHelpView(logoKeyMap) + t.logsTabHelp = t.help.ShortHelpView(logsTabKeyMap) + t.functionsTabHelp = t.help.ShortHelpView(functionsTabKeyMap) + t.detailTabHelp = t.help.ShortHelpView(detailTabKeyMap) + t.selectHelp = t.help.ShortHelpView(selectKeyMap) return tick() } @@ -166,8 +196,12 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tickMsg: t.ticks++ cmds = append(cmds, tick()) + + case focusSelectMsg: + t.selectMode = true + t.selection.SetValue("") + case tea.WindowSizeMsg: - // Initialize or resize the viewport. t.windowHeight = msg.Height height := msg.Height - 1 // reserve space for status bar width := msg.Width @@ -179,75 +213,111 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.viewport.Width = width t.viewport.Height = height } + case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": - return t, tea.Quit - case "t": - t.tail = true - case "tab": - t.activeTab = (t.activeTab + 1) % tabCount - case "up", "down", "left", "right", "pgup", "pgdown", "ctrl+u", "ctrl+d": - t.tail = false + if t.selectMode { + switch msg.String() { + case "esc": + t.selectMode = false + case "tab": + t.selectMode = false + t.activeTab = functionsTab + case "enter": + if t.selected != nil { + t.selectMode = false + t.activeTab = detailTab + } + case "ctrl+c": + return t, tea.Quit + } + } else { + switch msg.String() { + case "esc": + if t.activeTab == detailTab { + t.activeTab = functionsTab + } else { + return t, tea.Quit + } + case "ctrl+c", "q": + return t, tea.Quit + case "s": + if len(t.calls) > 0 { + // Don't accept s/select until at least one function + // call has been received. + cmds = append(cmds, focusSelect, textinput.Blink) + } + case "t": + t.tailMode = true + case "tab": + t.selectMode = false + t.activeTab = (t.activeTab + 1) % tabCount + if t.activeTab == detailTab && t.selected == nil { + t.activeTab = functionsTab + } + case "up", "down", "left", "right", "pgup", "pgdown", "ctrl+u", "ctrl+d": + t.tailMode = false + } } } + + // Forward messages to the text input in select mode. + if t.selectMode { + t.selection, cmd = t.selection.Update(msg) + cmds = append(cmds, cmd) + } + // Forward messages to the viewport, e.g. for scroll-back support. t.viewport, cmd = t.viewport.Update(msg) cmds = append(cmds, cmd) - return t, tea.Batch(cmds...) -} - -// https://patorjk.com/software/taag/ (Ogre) -var dispatchAscii = []string{ - ` _ _ _ _`, - ` __| (_)___ _ __ __ _| |_ ___| |__`, - ` / _' | / __| '_ \ / _' | __/ __| '_ \`, - `| (_| | \__ \ |_) | (_| | || (__| | | |`, - ` \__,_|_|___/ .__/ \__,_|\__\___|_| |_|`, - ` |_|`, -} -var underscoreAscii = []string{ - " _____", - "|_____|", + return t, tea.Batch(cmds...) } -const underscoreIndex = 3 - func (t *TUI) View() string { t.mu.Lock() defer t.mu.Unlock() var viewportContent string var statusBarContent string - helpContent := t.functionCallHelp + var helpContent string if !t.ready { viewportContent = t.logoView() statusBarContent = "Initializing..." + helpContent = t.logoHelp } else { switch t.activeTab { case functionsTab: if len(t.roots) == 0 { viewportContent = t.logoView() statusBarContent = "Waiting for function calls..." + helpContent = t.logoHelp } else { - viewportContent = t.functionCallsView(time.Now()) - if len(t.nodes) == 1 { + viewportContent = t.functionsView(time.Now()) + if len(t.calls) == 1 { statusBarContent = "1 total function call" } else { - statusBarContent = fmt.Sprintf("%d total function calls", len(t.nodes)) + statusBarContent = fmt.Sprintf("%d total function calls", len(t.calls)) } var inflightCount int - for _, n := range t.nodes { + for _, n := range t.calls { if !n.done { inflightCount++ } } statusBarContent += fmt.Sprintf(", %d in-flight", inflightCount) + helpContent = t.functionsTabHelp + } + if t.selectMode { + statusBarContent = t.selection.View() + helpContent = t.selectHelp } + case detailTab: + id := *t.selected + viewportContent = t.detailView(id) + helpContent = t.detailTabHelp case logsTab: viewportContent = t.logs.String() - helpContent = t.logHelp + helpContent = t.logsTabHelp } } @@ -255,7 +325,7 @@ func (t *TUI) View() string { // Tail the output, unless the user has tried // to scroll back (e.g. with arrow keys). - if t.tail { + if t.tailMode { t.viewport.GotoBottom() } @@ -275,6 +345,23 @@ func (t *TUI) View() string { return b.String() } +// https://patorjk.com/software/taag/ (Ogre) +var dispatchAscii = []string{ + ` _ _ _ _`, + ` __| (_)___ _ __ __ _| |_ ___| |__`, + ` / _' | / __| '_ \ / _' | __/ __| '_ \`, + `| (_| | \__ \ |_) | (_| | || (__| | | |`, + ` \__,_|_|___/ .__/ \__,_|\__\___|_| |_|`, + ` |_|`, +} + +var underscoreAscii = []string{ + " _____", + "|_____|", +} + +const underscoreIndex = 3 + func (t *TUI) logoView() string { showUnderscore := (t.ticks/uint64(underscoreBlinkInterval/refreshInterval))%2 == 0 @@ -291,230 +378,226 @@ func (t *TUI) logoView() string { return b.String() } -func (t *TUI) ObserveRequest(req *sdkv1.RunRequest) { - // ObserveRequest is part of the FunctionCallObserver interface. - // It's called after a request has been received from the Dispatch API, - // and before the request has been sent to the local application. +func (t *TUI) functionsView(now time.Time) string { + t.selected = nil - t.mu.Lock() - defer t.mu.Unlock() + // Render function calls in a hybrid table/tree view. + var b strings.Builder + var rows rowBuffer + for i, rootID := range t.orderedRoots { + if i > 0 { + b.WriteByte('\n') + } - if t.roots == nil { - t.roots = map[DispatchID]struct{}{} - } - if t.nodes == nil { - t.nodes = map[DispatchID]node{} - } + // Buffer rows in memory. + t.buildRows(now, rootID, nil, &rows) - rootID := t.parseID(req.RootDispatchId) - parentID := t.parseID(req.ParentDispatchId) - id := t.parseID(req.DispatchId) + // Dynamically size the function call tree column. + maxFunctionWidth := 0 + for i := range rows.rows { + maxFunctionWidth = max(maxFunctionWidth, ansi.PrintableRuneWidth(rows.rows[i].function)) + } + functionColumnWidth := max(9, min(50, maxFunctionWidth)) - // Upsert the root. - if _, ok := t.roots[rootID]; !ok { - t.roots[rootID] = struct{}{} - t.orderedRoots = append(t.orderedRoots, rootID) - } - root, ok := t.nodes[rootID] - if !ok { - root = node{} - } - t.nodes[rootID] = root + // Render the table. + b.WriteString(t.tableHeaderView(functionColumnWidth)) + for i := range rows.rows { + b.WriteString(t.tableRowView(&rows.rows[i], functionColumnWidth)) + } - // Upsert the node. - n, ok := t.nodes[id] - if !ok { - n = node{} - } - n.function = req.Function - n.running = true - n.suspended = false - if req.CreationTime != nil { - n.creationTime = req.CreationTime.AsTime() - } - if n.creationTime.IsZero() { - n.creationTime = time.Now() - } - if req.ExpirationTime != nil { - n.expirationTime = req.ExpirationTime.AsTime() + rows.reset() } - t.nodes[id] = n + return b.String() +} - // Upsert the parent and link its child, if applicable. - if parentID != "" { - parent, ok := t.nodes[parentID] - if !ok { - parent = node{} - if parentID != rootID { - panic("not implemented") - } - } - if parent.children == nil { - parent.children = map[DispatchID]struct{}{} - } - if _, ok := parent.children[id]; !ok { - parent.children[id] = struct{}{} - parent.orderedChildren = append(parent.orderedChildren, id) - } - t.nodes[parentID] = parent +func (t *TUI) tableHeaderView(functionColumnWidth int) string { + columns := []string{ + left(functionColumnWidth, tableHeaderStyle.Render("Function")), + right(8, tableHeaderStyle.Render("Attempt")), + right(10, tableHeaderStyle.Render("Duration")), + left(1, pendingIcon), + left(35, tableHeaderStyle.Render("Status")), } + if t.selectMode { + idWidth := int(math.Log10(float64(len(t.calls)))) + 1 + columns = append([]string{left(idWidth, strings.Repeat("#", idWidth))}, columns...) + } + return join(columns...) + "\n" } -func (t *TUI) ObserveResponse(req *sdkv1.RunRequest, err error, httpRes *http.Response, res *sdkv1.RunResponse) { - // ObserveResponse is part of the FunctionCallObserver interface. - // It's called after a response has been received from the local - // application, and before the response has been sent to Dispatch. - - t.mu.Lock() - defer t.mu.Unlock() - - id := t.parseID(req.DispatchId) - n := t.nodes[id] +func (t *TUI) tableRowView(r *row, functionColumnWidth int) string { + attemptStr := strconv.Itoa(r.attempt) - n.responses++ - n.error = nil - n.status = 0 - n.running = false + var durationStr string + if r.duration > 0 { + durationStr = r.duration.String() + } else { + durationStr = "?" + } - if res != nil { - switch res.Status { - case sdkv1.Status_STATUS_OK: - // noop - case sdkv1.Status_STATUS_INCOMPATIBLE_STATE: - n = node{function: n.function} // reset - default: - n.failures++ - } + values := []string{ + left(functionColumnWidth, r.function), + right(8, attemptStr), + right(10, durationStr), + left(1, r.icon), + left(35, r.status), + } - switch d := res.Directive.(type) { - case *sdkv1.RunResponse_Exit: - n.status = res.Status - n.done = terminalStatus(res.Status) - if d.Exit.TailCall != nil { - n = node{function: d.Exit.TailCall.Function} // reset - } else if res.Status != sdkv1.Status_STATUS_OK && d.Exit.Result != nil { - if e := d.Exit.Result.Error; e != nil && e.Type != "" { - if e.Message == "" { - n.error = fmt.Errorf("%s", e.Type) - } else { - n.error = fmt.Errorf("%s: %s", e.Type, e.Message) - } - } - } - case *sdkv1.RunResponse_Poll: - n.suspended = true + id := strconv.Itoa(r.index) + var selected bool + if t.selectMode { + idWidth := int(math.Log10(float64(len(t.calls)))) + 1 + paddedID := left(idWidth, id) + if input := strings.TrimSpace(t.selection.Value()); input != "" && id == input { + selected = true + t.selected = &r.id } - } else if httpRes != nil { - n.failures++ - n.error = fmt.Errorf("unexpected HTTP status code %d", httpRes.StatusCode) - n.done = terminalHTTPStatusCode(httpRes.StatusCode) - } else if err != nil { - n.failures++ - n.error = err + values = append([]string{paddedID}, values...) } - - if n.done && n.doneTime.IsZero() { - n.doneTime = time.Now() + result := join(values...) + if selected { + result = selectedStyle.Render(clearANSI(result)) } - - t.nodes[id] = n + return result + "\n" } -func (t *TUI) Write(b []byte) (int, error) { - t.mu.Lock() - defer t.mu.Unlock() - - return t.logs.Write(b) -} +func (t *TUI) detailView(id DispatchID) string { + now := time.Now() -func (t *TUI) Read(b []byte) (int, error) { - t.mu.Lock() - defer t.mu.Unlock() + n := t.calls[id] - return t.logs.Read(b) -} + style, _, status := n.status(now) -func (t *TUI) parseID(id string) DispatchID { - return DispatchID(id) -} + var view strings.Builder -func whitespace(width int) string { - return strings.Repeat(" ", width) -} + add := func(name, value string) { + const padding = 16 + view.WriteString(right(padding, detailHeaderStyle.Render(name+":"))) + view.WriteByte(' ') + view.WriteString(value) + view.WriteByte('\n') + } -func padding(width int, s string) int { - return width - ansi.PrintableRuneWidth(s) -} + const timestampFormat = "2006-01-02T15:04:05.000" -func truncate(width int, s string) string { - var truncated bool - for len(s) > 0 && ansi.PrintableRuneWidth(s) > width { - s = s[:len(s)-1] - truncated = true + add("ID", detailLowPriorityStyle.Render(string(id))) + add("Function", n.function()) + add("Status", style.Render(status)) + add("Creation time", detailLowPriorityStyle.Render(n.creationTime.Local().Format(timestampFormat))) + if !n.expirationTime.IsZero() && !n.done { + add("Expiration time", detailLowPriorityStyle.Render(n.expirationTime.Local().Format(timestampFormat))) } - if truncated { - s = s + "\033[0m" - } - return s -} + add("Duration", n.duration(now).String()) + add("Attempts", strconv.Itoa(n.attempt())) + add("Requests", strconv.Itoa(len(n.timeline))) -func right(width int, s string) string { - if ansi.PrintableRuneWidth(s) > width { - return truncate(width-3, s) + "..." - } - return whitespace(padding(width, s)) + s -} + var result strings.Builder + result.WriteString(view.String()) -func left(width int, s string) string { - if ansi.PrintableRuneWidth(s) > width { - return truncate(width-3, s) + "..." - } - return s + whitespace(padding(width, s)) -} + for _, rt := range n.timeline { + view.Reset() -func (t *TUI) functionCallsView(now time.Time) string { - // Render function calls in a hybrid table/tree view. - var b strings.Builder - var rows rowBuffer - for i, rootID := range t.orderedRoots { - if i > 0 { - b.WriteByte('\n') - } + result.WriteByte('\n') - // Buffer rows in memory. - t.buildRows(now, rootID, nil, &rows) + // TODO: show request # and/or attempt #? - // Dynamically size the function call tree column. - maxFunctionWidth := 0 - for i := range rows.rows { - maxFunctionWidth = max(maxFunctionWidth, ansi.PrintableRuneWidth(rows.rows[i].function)) - } - functionColumnWidth := max(9, min(50, maxFunctionWidth)) + add("Timestamp", detailLowPriorityStyle.Render(rt.request.ts.Local().Format(timestampFormat))) + req := rt.request.proto + switch d := req.Directive.(type) { + case *sdkv1.RunRequest_Input: + if rt.request.input == "" { + rt.request.input = anyString(d.Input) + } + add("Input", rt.request.input) - // Render the table. - b.WriteString(tableHeaderView(functionColumnWidth)) - for i := range rows.rows { - b.WriteString(tableRowView(&rows.rows[i], functionColumnWidth)) + case *sdkv1.RunRequest_PollResult: + add("Input", detailLowPriorityStyle.Render(fmt.Sprintf("<%d bytes of state>", len(d.PollResult.CoroutineState)))) + // TODO: show call results + // TODO: show poll error } - rows.reset() + if rt.response.ts.IsZero() { + add("Status", "Running") + } else { + if res := rt.response.proto; res != nil { + switch d := res.Directive.(type) { + case *sdkv1.RunResponse_Exit: + var statusStyle lipgloss.Style + if res.Status == sdkv1.Status_STATUS_OK { + statusStyle = okStyle + } else if terminalStatus(res.Status) { + statusStyle = errorStyle + } else { + statusStyle = retryStyle + } + add("Status", statusStyle.Render(statusString(res.Status))) + + if result := d.Exit.Result; result != nil { + if rt.response.output == "" { + rt.response.output = anyString(result.Output) + } + add("Output", rt.response.output) + + if result.Error != nil { + errorMessage := result.Error.Type + if result.Error.Message != "" { + errorMessage += ": " + result.Error.Message + } + add("Error", statusStyle.Render(errorMessage)) + } + } + if tailCall := d.Exit.TailCall; tailCall != nil { + add("Tail call", tailCall.Function) + } + + case *sdkv1.RunResponse_Poll: + add("Status", suspendedStyle.Render("Suspended")) + add("Output", detailLowPriorityStyle.Render(fmt.Sprintf("<%d bytes of state>", len(d.Poll.CoroutineState)))) + + if len(d.Poll.Calls) > 0 { + var calls strings.Builder + for i, call := range d.Poll.Calls { + if i > 0 { + calls.WriteString(", ") + } + calls.WriteString(call.Function) + } + add("Calls", truncate(50, calls.String())) + } + } + } else if c := rt.response.httpStatus; c != 0 { + add("Error", errorStyle.Render(fmt.Sprintf("%d %s", c, http.StatusText(c)))) + } else if rt.response.err != nil { + add("Error", errorStyle.Render(rt.response.err.Error())) + } + + latency := rt.response.ts.Sub(rt.request.ts) + add("Latency", latency.String()) + } + result.WriteString(view.String()) } - return b.String() + + return result.String() } type row struct { + id DispatchID + index int function string - attempts int - elapsed time.Duration + attempt int + duration time.Duration icon string status string } type rowBuffer struct { rows []row + seq int } func (b *rowBuffer) add(r row) { + b.seq++ + r.index = b.seq b.rows = append(b.rows, r) } @@ -522,51 +605,8 @@ func (b *rowBuffer) reset() { b.rows = b.rows[:0] } -func tableHeaderView(functionColumnWidth int) string { - return join( - left(functionColumnWidth, tableHeaderStyle.Render("Function")), - right(8, tableHeaderStyle.Render("Attempts")), - right(10, tableHeaderStyle.Render("Duration")), - left(1, pendingIcon), - left(40, tableHeaderStyle.Render("Status")), - ) -} - -func tableRowView(r *row, functionColumnWidth int) string { - attemptsStr := strconv.Itoa(r.attempts) - - var elapsedStr string - if r.elapsed > 0 { - elapsedStr = r.elapsed.String() - } else { - elapsedStr = "?" - } - - return join( - left(functionColumnWidth, r.function), - right(8, attemptsStr), - right(10, elapsedStr), - left(1, r.icon), - left(40, r.status), - ) -} - -func join(rows ...string) string { - var b strings.Builder - for i, row := range rows { - if i > 0 { - b.WriteByte(' ') - } - b.WriteString(row) - } - b.WriteByte('\n') - return b.String() -} - func (t *TUI) buildRows(now time.Time, id DispatchID, isLast []bool, rows *rowBuffer) { - // t.mu must be locked! - - n := t.nodes[id] + n := t.calls[id] // Render the tree prefix. var function strings.Builder @@ -589,13 +629,84 @@ func (t *TUI) buildRows(now time.Time, id DispatchID, isLast []bool, rows *rowBu function.WriteByte(' ') } - // Determine what to print, based on the status of the function call. - var style lipgloss.Style - icon := pendingIcon - if n.running || n.suspended { + style, icon, status := n.status(now) + + function.WriteString(style.Render(n.function())) + + rows.add(row{ + id: id, + function: function.String(), + attempt: n.attempt(), + duration: n.duration(now), + icon: style.Render(icon), + status: style.Render(status), + }) + + // Recursively render children. + for i, id := range n.orderedChildren { + last := i == len(n.orderedChildren)-1 + t.buildRows(now, id, append(isLast[:len(isLast):len(isLast)], last), rows) + } +} + +type DispatchID string + +type functionCall struct { + lastFunction string + lastStatus sdkv1.Status + lastError error + + failures int + polls int + + running bool + suspended bool + done bool + + creationTime time.Time + expirationTime time.Time + doneTime time.Time + + children map[DispatchID]struct{} + orderedChildren []DispatchID + + timeline []*roundtrip +} + +type roundtrip struct { + request runRequest + response runResponse +} + +type runRequest struct { + ts time.Time + proto *sdkv1.RunRequest + input string +} + +type runResponse struct { + ts time.Time + proto *sdkv1.RunResponse + httpStatus int + err error + output string +} + +func (n *functionCall) function() string { + if n.lastFunction != "" { + return n.lastFunction + } + return "(?)" +} + +func (n *functionCall) status(now time.Time) (style lipgloss.Style, icon, status string) { + icon = pendingIcon + if n.running { style = pendingStyle + } else if n.suspended { + style = suspendedStyle } else if n.done { - if n.status == sdkv1.Status_STATUS_OK { + if n.lastStatus == sdkv1.Status_STATUS_OK { style = okStyle icon = successIcon } else { @@ -603,7 +714,7 @@ func (t *TUI) buildRows(now time.Time, id DispatchID, isLast []bool, rows *rowBu icon = failureIcon } } else if !n.expirationTime.IsZero() && n.expirationTime.Before(now) { - n.error = errors.New("Expired") + n.lastError = errors.New("Expired") style = errorStyle n.done = true n.doneTime = n.expirationTime @@ -614,65 +725,201 @@ func (t *TUI) buildRows(now time.Time, id DispatchID, isLast []bool, rows *rowBu style = pendingStyle } - // Render the function name. - if n.function != "" { - function.WriteString(style.Render(n.function)) - } else { - function.WriteString(style.Render("(?)")) - } - - // Render the status and icon. - var status string if n.running { status = "Running" } else if n.suspended { status = "Suspended" - } else if n.error != nil { - status = n.error.Error() - } else if n.status != sdkv1.Status_STATUS_UNSPECIFIED { - status = statusString(n.status) + } else if n.lastError != nil { + status = n.lastError.Error() + } else if n.lastStatus != sdkv1.Status_STATUS_UNSPECIFIED { + status = statusString(n.lastStatus) } else { status = "Pending" } - status = style.Render(status) - icon = style.Render(icon) - attempts := n.failures - if n.running { - attempts++ - } else if n.done && n.status == sdkv1.Status_STATUS_OK { - attempts++ - } else if n.responses > n.failures { - attempts++ + return +} + +func (n *functionCall) attempt() int { + attempt := len(n.timeline) - n.polls + if n.suspended { + attempt++ } - attempts = max(attempts, 1) + return attempt +} - var elapsed time.Duration +func (n *functionCall) duration(now time.Time) time.Duration { + var duration time.Duration if !n.creationTime.IsZero() { - var tail time.Time + var start time.Time + if !n.creationTime.IsZero() && n.creationTime.Before(n.timeline[0].request.ts) { + start = n.creationTime + } else { + start = n.timeline[0].request.ts + } + var end time.Time if !n.done { - tail = now + end = now } else { - tail = n.doneTime + end = n.doneTime } - elapsed = tail.Sub(n.creationTime).Truncate(time.Millisecond) + duration = end.Sub(start).Truncate(time.Millisecond) } + return max(duration, 0) +} - rows.add(row{ - function: function.String(), - attempts: attempts, - elapsed: elapsed, - icon: icon, - status: status, - }) +func (t *TUI) ObserveRequest(now time.Time, req *sdkv1.RunRequest) { + // ObserveRequest is part of the FunctionCallObserver interface. + // It's called after a request has been received from the Dispatch API, + // and before the request has been sent to the local application. - // Recursively render children. - for i, id := range n.orderedChildren { - last := i == len(n.orderedChildren)-1 - t.buildRows(now, id, append(isLast[:len(isLast):len(isLast)], last), rows) + t.mu.Lock() + defer t.mu.Unlock() + + if t.roots == nil { + t.roots = map[DispatchID]struct{}{} + } + if t.calls == nil { + t.calls = map[DispatchID]functionCall{} + } + + rootID := DispatchID(req.RootDispatchId) + parentID := DispatchID(req.ParentDispatchId) + id := DispatchID(req.DispatchId) + + // Upsert the root. + if _, ok := t.roots[rootID]; !ok { + t.roots[rootID] = struct{}{} + t.orderedRoots = append(t.orderedRoots, rootID) + } + root, ok := t.calls[rootID] + if !ok { + root = functionCall{} + } + t.calls[rootID] = root + + // Upsert the function call. + n, ok := t.calls[id] + if !ok { + n = functionCall{} + } + n.lastFunction = req.Function + n.running = true + n.suspended = false + if req.CreationTime != nil { + n.creationTime = req.CreationTime.AsTime() + } + if n.creationTime.IsZero() { + n.creationTime = now + } + if req.ExpirationTime != nil { + n.expirationTime = req.ExpirationTime.AsTime() + } + n.timeline = append(n.timeline, &roundtrip{request: runRequest{ts: now, proto: req}}) + t.calls[id] = n + + // Upsert the parent and link its child, if applicable. + if parentID != "" { + parent, ok := t.calls[parentID] + if !ok { + parent = functionCall{} + if parentID != rootID { + panic("not implemented") + } + } + if parent.children == nil { + parent.children = map[DispatchID]struct{}{} + } + if _, ok := parent.children[id]; !ok { + parent.children[id] = struct{}{} + parent.orderedChildren = append(parent.orderedChildren, id) + } + t.calls[parentID] = parent } } +func (t *TUI) ObserveResponse(now time.Time, req *sdkv1.RunRequest, err error, httpRes *http.Response, res *sdkv1.RunResponse) { + // ObserveResponse is part of the FunctionCallObserver interface. + // It's called after a response has been received from the local + // application, and before the response has been sent to Dispatch. + + t.mu.Lock() + defer t.mu.Unlock() + + id := DispatchID(req.DispatchId) + n := t.calls[id] + + rt := n.timeline[len(n.timeline)-1] + rt.response.ts = now + rt.response.proto = res + rt.response.err = err + if res == nil && httpRes != nil { + rt.response.httpStatus = httpRes.StatusCode + } + + n.lastError = nil + n.lastStatus = 0 + n.running = false + + if res != nil { + switch res.Status { + case sdkv1.Status_STATUS_OK: + // noop + case sdkv1.Status_STATUS_INCOMPATIBLE_STATE: + n = functionCall{lastFunction: n.lastFunction} // reset + default: + n.failures++ + } + + switch d := res.Directive.(type) { + case *sdkv1.RunResponse_Exit: + n.lastStatus = res.Status + n.done = terminalStatus(res.Status) + if d.Exit.TailCall != nil { + n = functionCall{lastFunction: d.Exit.TailCall.Function} // reset + } else if res.Status != sdkv1.Status_STATUS_OK && d.Exit.Result != nil { + if e := d.Exit.Result.Error; e != nil && e.Type != "" { + if e.Message == "" { + n.lastError = fmt.Errorf("%s", e.Type) + } else { + n.lastError = fmt.Errorf("%s: %s", e.Type, e.Message) + } + } + } + case *sdkv1.RunResponse_Poll: + n.suspended = true + n.polls++ + } + } else if httpRes != nil { + n.failures++ + n.lastError = fmt.Errorf("unexpected HTTP status code %d", httpRes.StatusCode) + n.done = terminalHTTPStatusCode(httpRes.StatusCode) + } else if err != nil { + n.failures++ + n.lastError = err + } + + if n.done && n.doneTime.IsZero() { + n.doneTime = now + } + + t.calls[id] = n +} + +func (t *TUI) Write(b []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + + return t.logs.Write(b) +} + +func (t *TUI) Read(b []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + + return t.logs.Read(b) +} + func statusString(status sdkv1.Status) string { switch status { case sdkv1.Status_STATUS_OK: diff --git a/go.mod b/go.mod index c8b443d..f08899a 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/lipgloss v0.9.1 github.com/google/uuid v1.6.0 github.com/muesli/reflow v0.3.0 + github.com/nlpodyssey/gopickle v0.3.0 github.com/pelletier/go-toml/v2 v2.2.0 github.com/spf13/cobra v1.8.0 golang.org/x/term v0.19.0 @@ -17,6 +18,7 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20231115204500-e097f827e652.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -31,5 +33,5 @@ require ( github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 2cde6a5..8ab7ad0 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-2023111520450 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20231115204500-e097f827e652.1/go.mod h1:Tgn5bgL220vkFOI0KPStlcClPeOJzAv4uT+V8JXGUnw= buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go v1.33.0-20240429010127-639d52c5db75.1 h1:C8qLXgA5Y2iK8VkXsNHi8E4qU3aUsZQ0mFqwgxIH7KI= buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go v1.33.0-20240429010127-639d52c5db75.1/go.mod h1:yJei/TBJwZBJ8ZUWCKVKceUHk/8gniSGs812SZA9TEE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= @@ -40,6 +42,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nlpodyssey/gopickle v0.3.0 h1:BLUE5gxFLyyNOPzlXxt6GoHEMMxD0qhsE4p0CIQyoLw= +github.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -70,8 +74,8 @@ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=