package tui import ( "context" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" _ "embed" // for embedding config.sh "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/ddworken/hishtory/client/ai" "github.com/ddworken/hishtory/client/data" "github.com/ddworken/hishtory/client/hctx" "github.com/ddworken/hishtory/client/lib" "github.com/ddworken/hishtory/client/table" "github.com/ddworken/hishtory/client/tui/keybindings" "github.com/ddworken/hishtory/shared" "github.com/muesli/termenv" "golang.org/x/term" ) const TABLE_HEIGHT = 20 const PADDED_NUM_ENTRIES = TABLE_HEIGHT * 5 var CURRENT_QUERY_FOR_HIGHLIGHTING string = "" var SELECTED_COMMAND string = "" // Globally shared monotonically increasing IDs used to prevent race conditions in handling async queries. // If the user types 'l' and then 's', two queries will be dispatched: One for 'l' and one for 'ls'. These // counters are used to ensure that we don't process the query results for 'ls' and then promptly overwrite // them with the results for 'l'. var LAST_DISPATCHED_QUERY_ID = 0 var LAST_DISPATCHED_QUERY_TIMESTAMP time.Time var LAST_PROCESSED_QUERY_ID = -1 type SelectStatus int64 const ( NotSelected SelectStatus = iota Selected SelectedWithChangeDir ) var loadedKeyBindings keybindings.KeyMap = keybindings.DefaultKeyMap type model struct { // context ctx context.Context // Model for the loading spinner. spinner spinner.Model // Whether data is still loading and the spinner should still be displayed. isLoading bool // Model for the help bar at the bottom of the page help help.Model // Whether the TUI is quitting. quitting bool // The table used for displaying search results. Nil if the initial search query hasn't returned yet. table *table.Model // The entries in the table tableEntries []*data.HistoryEntry // Whether the user has hit enter to select an entry and the TUI is thus about to quit. selected SelectStatus // The search box for the query queryInput textinput.Model // The query to run. Reset to nil after it was run. runQuery *string // The previous query that was run. lastQuery string // Unrecoverable error. fatalErr error // An error while searching. Recoverable and displayed as a warning message. searchErr error // Whether the device is offline. If so, a warning will be displayed. isOffline bool // A banner from the backend to be displayed. Generally an empty string. banner string // The currently executing shell. Defaults to bash if not specified. Used for more precise AI suggestions. shellName string // Compacting TUI config compactTUIflag bool } type doneDownloadingMsg struct{} type offlineMsg struct{} type bannerMsg struct { banner string } type asyncQueryFinishedMsg struct { // The query ID finished running. Used to ensure that we only process this message if it is the latest query to finish. queryId int // The table rows and entries rows []table.Row entries []*data.HistoryEntry // An error from searching, if one occurred searchErr error // Whether to force a full refresh of the table forceUpdateTable bool // Whether to maintain the cursor position maintainCursor bool // An updated search query. May be used for initial queries when they're invalid. overriddenSearchQuery *string } func initialModel(ctx context.Context, shellName, initialQuery string) model { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) queryInput := textinput.New() cfg := hctx.GetConf(ctx) defaultFilter := cfg.DefaultFilter if defaultFilter != "" { queryInput.Prompt = "[" + defaultFilter + "] " } queryInput.PromptStyle = queryInput.PlaceholderStyle if defaultFilter == "" { queryInput.Placeholder = "ls" } queryInput.Focus() queryInput.CharLimit = 200 width, _, err := getTerminalSize() if err == nil { queryInput.Width = width } else { hctx.GetLogger().Infof("getTerminalSize() return err=%#v, defaulting queryInput to a width of 50", err) queryInput.Width = 50 } if initialQuery != "" { queryInput.SetValue(initialQuery) } CURRENT_QUERY_FOR_HIGHLIGHTING = initialQuery return model{ctx: ctx, spinner: s, isLoading: true, table: nil, tableEntries: []*data.HistoryEntry{}, runQuery: &initialQuery, queryInput: queryInput, help: help.New(), shellName: shellName, compactTUIflag: cfg.ForceCompactMode} } func (m model) Init() tea.Cmd { return m.spinner.Tick } func updateTable(m model, rows []table.Row, entries []*data.HistoryEntry, searchErr error, forceUpdateTable, maintainCursor bool) model { if m.runQuery == nil { m.runQuery = &m.lastQuery } m.searchErr = searchErr if searchErr != nil { return m } m.tableEntries = entries initialCursor := 0 if m.table != nil { initialCursor = m.table.Cursor() } if forceUpdateTable || m.table == nil { t, err := makeTable(m.ctx, m.shellName, rows) if err != nil { m.fatalErr = err return m } m.table = &t } m.table.SetRows(rows) if maintainCursor { m.table.SetCursor(initialCursor) } else { m.table.SetCursor(0) } m.lastQuery = *m.runQuery m.runQuery = nil preventTableOverscrolling(m) return m } func preventTableOverscrolling(m model) { if m.table != nil { if m.table.Cursor() >= len(m.tableEntries) { // Ensure that we can't scroll past the end of the table m.table.SetCursor(len(m.tableEntries) - 1) } } } func runQueryAndUpdateTable(m model, forceUpdateTable, maintainCursor bool) tea.Cmd { if (m.runQuery != nil && *m.runQuery != m.lastQuery) || forceUpdateTable || m.searchErr != nil { query := m.lastQuery if m.runQuery != nil { query = *m.runQuery } LAST_DISPATCHED_QUERY_ID++ queryId := LAST_DISPATCHED_QUERY_ID LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now() return func() tea.Msg { conf := hctx.GetConf(m.ctx) defaultFilter := conf.DefaultFilter if m.queryInput.Prompt == "" { // The default filter was cleared for this session, so don't apply it defaultFilter = "" } rows, entries, searchErr := getRows(m.ctx, conf.DisplayedColumns, m.shellName, defaultFilter, query, PADDED_NUM_ENTRIES) return asyncQueryFinishedMsg{queryId, rows, entries, searchErr, forceUpdateTable, maintainCursor, nil} } } return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, loadedKeyBindings.Quit): m.quitting = true return m, tea.Quit case key.Matches(msg, loadedKeyBindings.SelectEntry): if len(m.tableEntries) != 0 && m.table != nil { m.selected = Selected } return m, tea.Quit case key.Matches(msg, loadedKeyBindings.SelectEntryAndChangeDir): if len(m.tableEntries) != 0 && m.table != nil { m.selected = SelectedWithChangeDir } return m, tea.Quit case key.Matches(msg, loadedKeyBindings.DeleteEntry): if m.table == nil { return m, nil } err := deleteHistoryEntry(m.ctx, *m.tableEntries[m.table.Cursor()]) if err != nil { m.fatalErr = err return m, nil } cmd := runQueryAndUpdateTable(m, true, true) preventTableOverscrolling(m) return m, cmd case key.Matches(msg, loadedKeyBindings.Help): m.help.ShowAll = !m.help.ShowAll return m, nil case key.Matches(msg, loadedKeyBindings.JumpStartOfInput): m.queryInput.SetCursor(0) return m, nil case key.Matches(msg, loadedKeyBindings.JumpEndOfInput): m.queryInput.SetCursor(len(m.queryInput.Value())) return m, nil case key.Matches(msg, loadedKeyBindings.WordLeft): wordBoundaries := calculateWordBoundaries(m.queryInput.Value()) lastBoundary := 0 for _, boundary := range wordBoundaries { if boundary >= m.queryInput.Position() { m.queryInput.SetCursor(lastBoundary) break } lastBoundary = boundary } return m, nil case key.Matches(msg, loadedKeyBindings.WordRight): wordBoundaries := calculateWordBoundaries(m.queryInput.Value()) for _, boundary := range wordBoundaries { if boundary > m.queryInput.Position() { m.queryInput.SetCursor(boundary) break } } return m, nil default: pendingCommands := tea.Batch() if m.table != nil { t, cmd1 := m.table.Update(msg) m.table = &t if strings.HasPrefix(msg.String(), "alt+") { return m, tea.Batch(cmd1) } pendingCommands = tea.Batch(pendingCommands, cmd1) } forceUpdateTable := false if msg.String() == "backspace" && (m.queryInput.Value() == "" || m.queryInput.Position() == 0) { // Handle deleting the default filter just for this TUI instance m.queryInput.Prompt = "" forceUpdateTable = true } i, cmd2 := m.queryInput.Update(msg) m.queryInput = i searchQuery := m.queryInput.Value() m.runQuery = &searchQuery CURRENT_QUERY_FOR_HIGHLIGHTING = searchQuery cmd3 := runQueryAndUpdateTable(m, forceUpdateTable, false) preventTableOverscrolling(m) return m, tea.Batch(pendingCommands, cmd2, cmd3) } case tea.WindowSizeMsg: m.help.Width = msg.Width m.queryInput.Width = msg.Width cmd := runQueryAndUpdateTable(m, true, true) return m, cmd case offlineMsg: m.isOffline = true return m, nil case bannerMsg: m.banner = msg.banner return m, nil case doneDownloadingMsg: m.isLoading = false return m, nil case asyncQueryFinishedMsg: if msg.queryId > LAST_PROCESSED_QUERY_ID { LAST_PROCESSED_QUERY_ID = msg.queryId m = updateTable(m, msg.rows, msg.entries, msg.searchErr, msg.forceUpdateTable, msg.maintainCursor) if msg.overriddenSearchQuery != nil { m.queryInput.SetValue(*msg.overriddenSearchQuery) } } return m, nil default: var cmd tea.Cmd if m.isLoading { m.spinner, cmd = m.spinner.Update(msg) return m, cmd } else { if m.table != nil { t, cmd := m.table.Update(msg) m.table = &t return m, cmd } return m, nil } } } func calculateWordBoundaries(input string) []int { ret := make([]int, 0) ret = append(ret, 0) prevWasBreaking := false for idx, char := range input { if char == ' ' || char == '-' { if !prevWasBreaking { ret = append(ret, idx) } prevWasBreaking = true } else { prevWasBreaking = false } } if !prevWasBreaking { ret = append(ret, len(input)) } return ret } func (m model) View() string { if m.fatalErr != nil { return fmt.Sprintf("An unrecoverable error occured: %v\n", m.fatalErr) } if m.selected == Selected || m.selected == SelectedWithChangeDir { SELECTED_COMMAND = m.tableEntries[m.table.Cursor()].Command if m.selected == SelectedWithChangeDir { changeDir := m.tableEntries[m.table.Cursor()].CurrentWorkingDirectory if strings.HasPrefix(changeDir, "~/") { homedir, err := os.UserHomeDir() if err != nil { hctx.GetLogger().Infof("UserHomeDir() return err=%v, skipping replacing ~/", err) } else { strippedChangeDir, _ := strings.CutPrefix(changeDir, "~/") changeDir = filepath.Join(homedir, strippedChangeDir) } } SELECTED_COMMAND = "cd \"" + changeDir + "\" && " + SELECTED_COMMAND } return "" } if m.quitting { return "" } additionalMessages := make([]string, 0) if m.isLoading { additionalMessages = append(additionalMessages, fmt.Sprintf("%s Loading hishtory entries from other devices...", m.spinner.View())) } if m.isOffline { additionalMessages = append(additionalMessages, "Warning: failed to contact the hishtory backend (are you offline?), so some results may be stale") } if m.searchErr != nil { additionalMessages = append(additionalMessages, fmt.Sprintf("Warning: failed to search: %v", m.searchErr)) } if LAST_PROCESSED_QUERY_ID < LAST_DISPATCHED_QUERY_ID && time.Since(LAST_DISPATCHED_QUERY_TIMESTAMP) > time.Second { additionalMessages = append(additionalMessages, fmt.Sprintf("%s Executing search query...", m.spinner.View())) } additionalMessagesStr := strings.Join(additionalMessages, "\n") + "\n" if isExtraCompactHeightMode() || m.compactTUIflag { additionalMessagesStr = "\n" } helpView := m.help.View(loadedKeyBindings) if isExtraCompactHeightMode() || m.compactTUIflag { helpView = "" } additionalSpacing := "\n" if isCompactHeightMode() || m.compactTUIflag { additionalSpacing = "" } return fmt.Sprintf("%s%s%s%sSearch Query: %s\n%s%s\n", additionalSpacing, additionalMessagesStr, m.banner, additionalSpacing, m.queryInput.View(), additionalSpacing, renderNullableTable(m, helpView)) + helpView } func isExtraCompactHeightMode() bool { _, height, err := getTerminalSize() if err != nil { hctx.GetLogger().Infof("got err=%v when retrieving terminal dimensions, assuming the terminal is reasonably tall", err) return false } return height < 15 } func isCompactHeightMode() bool { _, height, err := getTerminalSize() if err != nil { hctx.GetLogger().Infof("got err=%v when retrieving terminal dimensions, assuming the terminal is reasonably tall", err) return false } return height < 25 } func getBaseStyle(config hctx.ClientConfig) lipgloss.Style { return lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(config.ColorScheme.BorderColor)) } func renderNullableTable(m model, helpText string) string { if m.table == nil { return strings.Repeat("\n", TABLE_HEIGHT+3) } helpTextLen := strings.Count(helpText, "\n") baseStyle := getBaseStyle(*hctx.GetConf(m.ctx)) if isCompactHeightMode() && helpTextLen > 1 { // If the help text is expanded, and this is a small window, then we truncate the table so that the help text displays on top of it lines := strings.Split(baseStyle.Render(m.table.View()), "\n") truncated := lines[:len(lines)-helpTextLen] return strings.Join(truncated, "\n") } return baseStyle.Render(m.table.View()) } func getRowsFromAiSuggestions(ctx context.Context, columnNames []string, shellName, query string) ([]table.Row, []*data.HistoryEntry, error) { suggestions, err := ai.DebouncedGetAiSuggestions(ctx, shellName, strings.TrimPrefix(query, "?"), 5) if err != nil { hctx.GetLogger().Infof("failed to get AI query suggestions: %v", err) return nil, nil, fmt.Errorf("failed to get AI query suggestions: %w", err) } var rows []table.Row var entries []*data.HistoryEntry for _, suggestion := range suggestions { entry := data.HistoryEntry{ LocalUsername: "OpenAI", Hostname: "OpenAI", Command: suggestion, CurrentWorkingDirectory: "N/A", HomeDirectory: "N/A", ExitCode: 0, StartTime: time.Unix(0, 0).UTC(), EndTime: time.Unix(0, 0).UTC(), DeviceId: "OpenAI", EntryId: "OpenAI", } entries = append(entries, &entry) row, err := lib.BuildTableRow(ctx, columnNames, entry, func(s string) string { return s }) if err != nil { return nil, nil, fmt.Errorf("failed to build row for entry=%#v: %w", entry, err) } rows = append(rows, row) } hctx.GetLogger().Infof("getRowsFromAiSuggestions(%#v) ==> %#v", query, suggestions) return rows, entries, nil } func getRows(ctx context.Context, columnNames []string, shellName, defaultFilter, query string, numEntries int) ([]table.Row, []*data.HistoryEntry, error) { db := hctx.GetDb(ctx) config := hctx.GetConf(ctx) if config.AiCompletion && !config.IsOffline && strings.HasPrefix(query, "?") && len(query) > 1 { return getRowsFromAiSuggestions(ctx, columnNames, shellName, query) } searchResults, err := lib.Search(ctx, db, defaultFilter+" "+query, numEntries) if err != nil { return nil, nil, err } var rows []table.Row var filteredData []*data.HistoryEntry var seenCommands = make(map[string]bool) for i := 0; i < numEntries; i++ { if i < len(searchResults) { entry := searchResults[i] if config.FilterDuplicateCommands && entry != nil { cmd := strings.TrimSpace(entry.Command) if seenCommands[cmd] { continue } seenCommands[cmd] = true } row, err := lib.BuildTableRow(ctx, columnNames, *entry, commandEscaper) if err != nil { return nil, nil, fmt.Errorf("failed to build row for entry=%#v: %w", entry, err) } rows = append(rows, row) filteredData = append(filteredData, entry) } else { rows = append(rows, table.Row{}) } } return rows, filteredData, nil } func commandEscaper(cmd string) string { if !strings.Contains(cmd, "\n") && !strings.Contains(cmd, "\t") { // No special escaping necessary return cmd } return fmt.Sprintf("%#v", cmd) } func calculateColumnWidths(rows []table.Row, numColumns int) []int { neededColumnWidth := make([]int, numColumns) for _, row := range rows { for i, v := range row { neededColumnWidth[i] = max(neededColumnWidth[i], len(v)) } } return neededColumnWidth } func getTerminalSize() (int, int, error) { return term.GetSize(2) } var bigQueryResults []table.Row func makeTableColumns(ctx context.Context, shellName string, columnNames []string, rows []table.Row) ([]table.Column, error) { // Handle an initial query with no results if len(rows) == 0 || len(rows[0]) == 0 { allRows, _, err := getRows(ctx, columnNames, shellName, hctx.GetConf(ctx).DefaultFilter, "", 25) if err != nil { return nil, err } if len(allRows) == 0 || len(allRows[0]) == 0 { // There are truly zero history entries. Let's still display a table in this case rather than erroring out. allRows = make([]table.Row, 0) row := make([]string, 0) for range columnNames { row = append(row, " ") } allRows = append(allRows, row) } return makeTableColumns(ctx, shellName, columnNames, allRows) } // Calculate the minimum amount of space that we need for each column for the current actual search columnWidths := calculateColumnWidths(rows, len(columnNames)) totalWidth := (len(columnWidths) + 1) * 2 // The amount of space needed for the table padding for i, name := range columnNames { columnWidths[i] = max(columnWidths[i], len(name)) totalWidth += columnWidths[i] } // Calculate the maximum column width that is useful for each column if we search for the empty string if bigQueryResults == nil { bigRows, _, err := getRows(ctx, columnNames, shellName, "", "", 1000) if err != nil { return nil, err } bigQueryResults = bigRows } maximumColumnWidths := calculateColumnWidths(bigQueryResults, len(columnNames)) // Get the actual terminal width. If we're below this, opportunistically add some padding aiming for the maximum column widths terminalWidth, _, err := getTerminalSize() if err != nil { return nil, fmt.Errorf("failed to get terminal size: %w", err) } for totalWidth < (terminalWidth - len(columnNames)) { prevTotalWidth := totalWidth for i := range columnNames { if columnWidths[i] < maximumColumnWidths[i]+5 { columnWidths[i] += 1 totalWidth += 1 } } if totalWidth == prevTotalWidth { break } } // And if we are too large from the initial query, let's shrink things to make the table fit. We'll use the heuristic of always shrinking the widest column. for totalWidth > terminalWidth { largestColumnIdx := -1 largestColumnSize := -1 for i := range columnNames { if columnWidths[i] > largestColumnSize { largestColumnIdx = i largestColumnSize = columnWidths[i] } } columnWidths[largestColumnIdx] -= 1 totalWidth -= 1 } // And finally, create some actual columns! columns := make([]table.Column, 0) for i, name := range columnNames { columns = append(columns, table.Column{Title: name, Width: columnWidths[i]}) } return columns, nil } func max(a, b int) int { if a > b { return a } return b } func min(a, b int) int { if a < b { return a } return b } func makeTable(ctx context.Context, shellName string, rows []table.Row) (table.Model, error) { config := hctx.GetConf(ctx) columns, err := makeTableColumns(ctx, shellName, config.DisplayedColumns, rows) if err != nil { return table.Model{}, err } km := table.KeyMap{ LineUp: loadedKeyBindings.Up, LineDown: loadedKeyBindings.Down, PageUp: loadedKeyBindings.PageUp, PageDown: loadedKeyBindings.PageDown, GotoTop: key.NewBinding( key.WithKeys("home"), key.WithHelp("home", "go to start"), ), GotoBottom: key.NewBinding( key.WithKeys("end"), key.WithHelp("end", "go to end"), ), MoveLeft: loadedKeyBindings.TableLeft, MoveRight: loadedKeyBindings.TableRight, } _, terminalHeight, err := getTerminalSize() if err != nil { return table.Model{}, err } tuiSize := 12 if isCompactHeightMode() { tuiSize -= 2 } if isExtraCompactHeightMode() { tuiSize -= 3 } tableHeight := min(TABLE_HEIGHT, terminalHeight-tuiSize) t := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(true), table.WithHeight(tableHeight), table.WithKeyMap(km), ) s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(config.ColorScheme.BorderColor)). BorderBottom(true). Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color(config.ColorScheme.SelectedText)). Background(lipgloss.Color(config.ColorScheme.SelectedBackground)). Bold(false) if config.HighlightMatches { MATCH_NOTHING_REGEXP := regexp.MustCompile("a^") s.RenderCell = func(model table.Model, value string, position table.CellPosition) string { var re *regexp.Regexp CURRENT_QUERY_FOR_HIGHLIGHTING = strings.TrimSpace(CURRENT_QUERY_FOR_HIGHLIGHTING) if CURRENT_QUERY_FOR_HIGHLIGHTING == "" { // If there is no search query, then there is nothing to highlight re = MATCH_NOTHING_REGEXP } else { queryRegex := lib.MakeRegexFromQuery(CURRENT_QUERY_FOR_HIGHLIGHTING) r, err := regexp.Compile(queryRegex) if err != nil { // Failed to compile the regex for highlighting matches, this should never happen. In this // case, just use a regexp that matches nothing to ensure that the TUI doesn't crash. hctx.GetLogger().Infof("Failed to compile regex %#v for query %#v, disabling highlighting of matches", queryRegex, CURRENT_QUERY_FOR_HIGHLIGHTING) re = MATCH_NOTHING_REGEXP } else { re = r } } // func to render a given chunk of `value`. `isMatching` is whether `v` matches the search query (and // thus needs to be highlighted). `isLeftMost` and `isRightMost` determines whether additional // padding is added (to reproduce the padding that `s.Cell` normally adds). renderChunk := func(v string, isMatching, isLeftMost, isRightMost bool) string { chunkStyle := lipgloss.NewStyle() if position.IsRowSelected { // Apply the selected style as the base style if this is the highlighted row of the table chunkStyle = s.Selected.Copy() } if isLeftMost { chunkStyle = chunkStyle.PaddingLeft(1) } if isRightMost { chunkStyle = chunkStyle.PaddingRight(1) } if isMatching { chunkStyle = chunkStyle.Bold(true) } return chunkStyle.Render(v) } matches := re.FindAllStringIndex(value, -1) if len(matches) == 0 { // No matches, so render the entire value return renderChunk(value /*isMatching = */, false /*isLeftMost = */, true /*isRightMost = */, true) } // Iterate through the chunks of the value and highlight the relevant pieces ret := "" lastIncludedIdx := 0 for _, match := range re.FindAllStringIndex(value, -1) { matchStartIdx := match[0] matchEndIdx := match[1] beforeMatch := value[lastIncludedIdx:matchStartIdx] match := value[matchStartIdx:matchEndIdx] if beforeMatch != "" { ret += renderChunk(beforeMatch, false, lastIncludedIdx == 0, lastIncludedIdx+1 == len(value)) } if match != "" { ret += renderChunk(match, true, matchStartIdx == 0, matchEndIdx == len(value)) } lastIncludedIdx = matchEndIdx } if lastIncludedIdx != len(value) { ret += renderChunk(value[lastIncludedIdx:], false, false, true) } return ret } } t.SetStyles(s) t.Focus() return t, nil } func deleteHistoryEntry(ctx context.Context, entry data.HistoryEntry) error { db := hctx.GetDb(ctx) // Delete locally r := db.Model(&data.HistoryEntry{}).Where("device_id = ? AND end_time = ?", entry.DeviceId, entry.EndTime).Delete(&data.HistoryEntry{}) if r.Error != nil { return r.Error } // Delete remotely config := hctx.GetConf(ctx) if config.IsOffline { return nil } dr := shared.DeletionRequest{ UserId: data.UserId(hctx.GetConf(ctx).UserSecret), SendTime: time.Now(), } dr.Messages.Ids = append(dr.Messages.Ids, shared.MessageIdentifier{DeviceId: entry.DeviceId, EndTime: entry.EndTime, EntryId: entry.EntryId}, ) return lib.SendDeletionRequest(ctx, dr) } func configureColorProfile(ctx context.Context) { if hctx.GetConf(ctx).ColorScheme == hctx.GetDefaultColorScheme() { // Set termenv.ANSI for the default color scheme, so that we preserve // the true default color scheme of hishtory which was initially // configured with termenv.ANSI (even though we want to support // full colors) for custom color schemes. lipgloss.SetColorProfile(termenv.ANSI) return } if os.Getenv("HISHTORY_TEST") != "" { // We also set termenv.ANSI for tests so as to ensure that all our // test environments behave the same (by default, github actions // ubuntu and macos have different termenv support). lipgloss.SetColorProfile(termenv.ANSI) return } // When the shell launches control-R it isn't hooked up to the main TTY, // which means that termenv isn't able to accurately detect color support // in the current terminal. We set the environment variable _hishtory_tui_color // to an int representing the termenv. If it is unset or set to 0, then we don't // know the current color support, and we have to guess it. This means we // risk either: // * Choosing too high of a color support, and breaking hishtory colors // in certain terminals // * Choosing too low of a color support, and ending up with truncating // customized colors // // The default terminal app on MacOS only supports termenv.ANSI256 (8 bit // colors), which means we likely shouldn't default to TrueColor. From // my own digging, I can't find any modern terminals that don't support // termenv.ANSI256, so it seems like a reasonable default here. colorProfileStr := os.Getenv("_hishtory_tui_color") if colorProfileStr == "" { // Fall back to the default lipgloss.SetColorProfile(termenv.ANSI256) return } colorProfileInt, err := strconv.Atoi(colorProfileStr) if err != nil { colorProfileInt = 0 } // The int mappings for this are defined in query.go switch colorProfileInt { case 1: lipgloss.SetColorProfile(termenv.TrueColor) case 2: lipgloss.SetColorProfile(termenv.ANSI256) case 3: lipgloss.SetColorProfile(termenv.ANSI) case 4: lipgloss.SetColorProfile(termenv.Ascii) default: fallthrough case 0: // Unknown, so fall back to the default lipgloss.SetColorProfile(termenv.ANSI256) } } func TuiQuery(ctx context.Context, shellName, initialQuery string) error { loadedKeyBindings = hctx.GetConf(ctx).KeyBindings.ToKeyMap() configureColorProfile(ctx) p := tea.NewProgram(initialModel(ctx, shellName, initialQuery), tea.WithOutput(os.Stderr)) // Async: Get the initial set of rows go func() { LAST_DISPATCHED_QUERY_ID++ queryId := LAST_DISPATCHED_QUERY_ID LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now() conf := hctx.GetConf(ctx) rows, entries, err := getRows(ctx, conf.DisplayedColumns, shellName, conf.DefaultFilter, initialQuery, PADDED_NUM_ENTRIES) if err == nil || initialQuery == "" { p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: nil}) } else { // initialQuery is likely invalid in some way, let's just drop it emptyQuery := "" rows, entries, err := getRows(ctx, hctx.GetConf(ctx).DisplayedColumns, shellName, conf.DefaultFilter, emptyQuery, PADDED_NUM_ENTRIES) p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: &emptyQuery}) } }() // Async: Retrieve additional entries from the backend go func() { err := lib.RetrieveAdditionalEntriesFromRemote(ctx, "tui") if err != nil { p.Send(err) } p.Send(doneDownloadingMsg{}) }() // Async: Process deletion requests go func() { err := lib.ProcessDeletionRequests(ctx) if err != nil { p.Send(err) } }() // Async: Check for any banner from the server go func() { banner, err := lib.GetBanner(ctx) if err != nil { if lib.IsOfflineError(ctx, err) { p.Send(offlineMsg{}) } else { p.Send(err) } } p.Send(bannerMsg{banner: string(banner)}) }() // Blocking: Start the TUI _, err := p.Run() if err != nil { return err } if SELECTED_COMMAND == "" && os.Getenv("HISHTORY_TERM_INTEGRATION") != "" { // Print out the initialQuery instead so that we don't clear the terminal SELECTED_COMMAND = initialQuery } fmt.Printf("%s\n", SELECTED_COMMAND) return nil } // TODO: support custom key bindings // TODO: make the help page wrap