// /futriis/internal/cli/prompt.go // Пакет cli реализует интерактивное приглашение командной строки с поддержкой истории. // Обеспечивает редактирование строки ввода, навигацию по истории стрелками, // поддержку Unicode (включая кириллицу) и управление курсором терминала. // Использует raw режим терминала для обработки специальных клавиш. package cli import ( "bufio" "fmt" "os" "strings" "futriis/pkg/utils" "github.com/mattn/go-runewidth" "golang.org/x/term" ) // ANSI escape sequences для управления курсором const ( ansiHideCursor = "\033[?25l" ansiShowCursor = "\033[?25h" ansiClearLine = "\033[2K" ansiCarriageReturn = "\r" ansiSaveCursor = "\033[s" ansiRestoreCursor = "\033[u" ) // Prompt представляет интерактивное приглашение type Prompt struct { history *History buffer []rune pos int searchMode bool searchQuery []rune searchResults []string searchPos int savedBuffer []rune // Сохраненный буфер для возврата из режима поиска savedPos int // Сохраненная позиция для возврата из режима поиска } // NewPrompt создаёт новое приглашение func NewPrompt() *Prompt { return &Prompt{ history: NewHistory(1000), buffer: make([]rune, 0), pos: 0, searchMode: false, searchQuery: make([]rune, 0), searchResults: make([]string, 0), searchPos: -1, savedBuffer: make([]rune, 0), savedPos: 0, } } // readEscapeSequence читает полную escape-последовательность func (p *Prompt) readEscapeSequence(reader *bufio.Reader) (string, error) { seq := make([]byte, 0) seq = append(seq, 27) // ESC // Читаем до тех пор, пока не получим полную последовательность for { b, err := reader.ReadByte() if err != nil { return string(seq), err } seq = append(seq, b) // Проверяем, является ли это концом последовательности // Обычно escape-последовательности заканчиваются буквой if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '~' { break } } return string(seq), nil } // ReadLine читает строку с поддержкой истории и редактирования func (p *Prompt) ReadLine() (string, error) { // Проверяем, является ли stdin терминалом if !term.IsTerminal(int(os.Stdin.Fd())) { return p.readSimple() } oldState, err := term.MakeRaw(int(os.Stdin.Fd())) if err != nil { return p.readSimple() } defer term.Restore(int(os.Stdin.Fd()), oldState) // Скрываем курсор в начале строки fmt.Print(ansiHideCursor) // Очищаем буфер p.buffer = make([]rune, 0) p.pos = 0 p.searchMode = false p.searchQuery = make([]rune, 0) p.searchResults = make([]string, 0) p.searchPos = -1 p.savedBuffer = make([]rune, 0) p.savedPos = 0 // Сбрасываем позицию истории p.history.Reset() // Показываем приглашение promptStr := utils.ColorPrompt + "futriis:~> " + utils.ColorReset fmt.Print(promptStr) // Показываем курсор только после приглашения (в месте ввода) fmt.Print(ansiShowCursor) reader := bufio.NewReader(os.Stdin) for { r, _, err := reader.ReadRune() if err != nil { return "", err } switch r { case 3: // Ctrl+C fmt.Println("^C") return "", nil case 4: // Ctrl+D if len(p.buffer) == 0 { fmt.Println() return "exit", nil } case 13, 10: // Enter // Скрываем курсор перед завершением fmt.Print(ansiHideCursor) fmt.Println() cmd := string(p.buffer) if cmd != "" { p.history.Add(cmd) } p.history.Reset() // Логируем команду if logger := utils.GetLogger(); logger != nil && cmd != "" { logger.Log("CMD", cmd) } return cmd, nil case 127, 8: // Backspace (127 для большинства терминалов, 8 для некоторых) if p.searchMode { if len(p.searchQuery) > 0 { p.searchQuery = p.searchQuery[:len(p.searchQuery)-1] p.performSearch() } else { // Выходим из режима поиска p.searchMode = false p.searchResults = make([]string, 0) p.searchPos = -1 // Восстанавливаем сохраненный буфер p.buffer = make([]rune, len(p.savedBuffer)) copy(p.buffer, p.savedBuffer) p.pos = p.savedPos } } else if p.pos > 0 { // Удаляем символ перед курсором p.buffer = append(p.buffer[:p.pos-1], p.buffer[p.pos:]...) p.pos-- } p.refreshLine() case 18: // Ctrl+R - поиск по истории if !p.searchMode { // Сохраняем текущий буфер перед входом в режим поиска p.savedBuffer = make([]rune, len(p.buffer)) copy(p.savedBuffer, p.buffer) p.savedPos = p.pos } p.searchMode = true p.searchQuery = make([]rune, 0) p.searchResults = p.history.Search("") p.searchPos = -1 p.buffer = make([]rune, 0) p.pos = 0 p.refreshLine() case 27: // Escape sequence (стрелки и другие специальные клавиши) // Читаем полную escape-последовательность seq, err := p.readEscapeSequence(reader) if err != nil { continue } // Обрабатываем различные escape-последовательности switch seq { case "\033[A", "\033OA": // Up arrow if p.searchMode { // Навигация по результатам поиска вверх if len(p.searchResults) > 0 && p.searchPos < len(p.searchResults)-1 { p.searchPos++ p.buffer = []rune(p.searchResults[p.searchPos]) p.pos = len(p.buffer) } } else { // Обычная навигация по истории prevCmd := p.history.GetPrevious() if prevCmd != "" { p.buffer = []rune(prevCmd) p.pos = len(p.buffer) } } p.refreshLine() case "\033[B", "\033OB": // Down arrow if p.searchMode { // Навигация по результатам поиска вниз if p.searchPos > 0 { p.searchPos-- p.buffer = []rune(p.searchResults[p.searchPos]) p.pos = len(p.buffer) } else if p.searchPos == 0 { p.searchPos = -1 p.buffer = make([]rune, len(p.searchQuery)) copy(p.buffer, p.searchQuery) p.pos = len(p.buffer) } } else { // Обычная навигация по истории nextCmd := p.history.GetNext() if nextCmd != "" { p.buffer = []rune(nextCmd) p.pos = len(p.buffer) } else { // Достигли конца истории, очищаем буфер p.buffer = make([]rune, 0) p.pos = 0 } } p.refreshLine() case "\033[C", "\033OC": // Right arrow if !p.searchMode && p.pos < len(p.buffer) { p.pos++ p.refreshLine() } case "\033[D", "\033OD": // Left arrow if !p.searchMode && p.pos > 0 { p.pos-- p.refreshLine() } case "\033[H", "\033OH": // Home if !p.searchMode { p.pos = 0 p.refreshLine() } case "\033[F", "\033OF": // End if !p.searchMode { p.pos = len(p.buffer) p.refreshLine() } case "\033[3~": // Delete if !p.searchMode && p.pos < len(p.buffer) { p.buffer = append(p.buffer[:p.pos], p.buffer[p.pos+1:]...) p.refreshLine() } } default: // Добавляем символ (поддержка Unicode, включая русский) if r >= 32 { // Печатные символы if p.searchMode { // Режим поиска - добавляем символ в поисковый запрос p.searchQuery = append(p.searchQuery, r) p.performSearch() } else { // Обычный режим ввода if p.pos == len(p.buffer) { p.buffer = append(p.buffer, r) } else { // Вставляем символ в позицию курсора newBuffer := make([]rune, len(p.buffer)+1) copy(newBuffer, p.buffer[:p.pos]) newBuffer[p.pos] = r copy(newBuffer[p.pos+1:], p.buffer[p.pos:]) p.buffer = newBuffer } p.pos++ } p.refreshLine() } } } } // performSearch выполняет поиск по истории команд func (p *Prompt) performSearch() { query := string(p.searchQuery) p.searchResults = p.history.Search(query) if len(p.searchResults) > 0 { // Показываем последний найденный результат p.searchPos = len(p.searchResults) - 1 p.buffer = []rune(p.searchResults[p.searchPos]) p.pos = len(p.buffer) } else { // Если ничего не найдено, показываем сам запрос p.buffer = make([]rune, len(p.searchQuery)) copy(p.buffer, p.searchQuery) p.pos = len(p.buffer) p.searchPos = -1 } } // refreshLine обновляет текущую строку с правильным позиционированием курсора func (p *Prompt) refreshLine() { // Скрываем курсор во время перерисовки fmt.Print(ansiHideCursor) // Возврат в начало строки и очистка fmt.Print(ansiCarriageReturn + ansiClearLine) if p.searchMode { // Режим поиска - показываем специальное приглашение searchPrompt := fmt.Sprintf("(reverse-i-search)`%s': ", string(p.searchQuery)) fmt.Print(searchPrompt) // Печатаем текущий буфер (найденный результат или запрос) if len(p.buffer) > 0 { // Подсвечиваем найденную подстроку queryStr := string(p.searchQuery) bufferStr := string(p.buffer) if queryStr != "" && strings.Contains(strings.ToLower(bufferStr), strings.ToLower(queryStr)) { // Находим позицию первого вхождения lowerBuffer := strings.ToLower(bufferStr) lowerQuery := strings.ToLower(queryStr) idx := strings.Index(lowerBuffer, lowerQuery) if idx >= 0 { // Выводим часть до совпадения fmt.Print(bufferStr[:idx]) // Выводим совпадение инвертированными цветами fmt.Print(utils.ColorReverse + bufferStr[idx:idx+len(queryStr)] + utils.ColorReset) // Выводим остаток fmt.Print(bufferStr[idx+len(queryStr):]) } else { fmt.Print(bufferStr) } } else { fmt.Print(bufferStr) } } // Вычисляем ширину поискового приглашения promptWidth := runewidth.StringWidth(searchPrompt) // Вычисляем позицию курсора cursorPos := promptWidth if len(p.searchQuery) > 0 { // Курсор должен быть после поискового запроса cursorPos += runewidth.StringWidth(string(p.searchQuery)) } else { // Если запрос пустой, курсор в конце приглашения cursorPos = promptWidth } // Перемещаем курсор на правильную позицию fmt.Printf("\033[%dG", cursorPos+1) } else { // Обычный режим - показываем стандартное приглашение promptStr := utils.ColorPrompt + "futriis:~> " + utils.ColorReset fmt.Print(promptStr) // Печатаем текущий буфер if len(p.buffer) > 0 { fmt.Print(string(p.buffer)) } // Вычисляем ширину приглашения promptWidth := runewidth.StringWidth("futriis:~> ") // Вычисляем позицию курсора cursorPos := promptWidth for i := 0; i < p.pos; i++ { cursorPos += runewidth.RuneWidth(p.buffer[i]) } // Перемещаем курсор на правильную позицию fmt.Printf("\033[%dG", cursorPos+1) } // Показываем курсор fmt.Print(ansiShowCursor) } // readSimple читает строку без специальной обработки (fallback) func (p *Prompt) readSimple() (string, error) { fmt.Print(utils.GetPrompt()) reader := bufio.NewReader(os.Stdin) cmd, err := reader.ReadString('\n') if err == nil { cmd = strings.TrimRight(cmd, "\r\n") // Логируем команду if logger := utils.GetLogger(); logger != nil && cmd != "" { logger.Log("CMD", cmd) } } // Добавляем команду в историю if cmd != "" { p.history.Add(cmd) } return cmd, err }