From c46986c4991d41d50e1e047ae9174aee12e2516b Mon Sep 17 00:00:00 2001 From: rfprod Date: Tue, 10 Nov 2026 23:45:11 +0400 Subject: [PATCH] feat(interface): add ncurses terminal backend, refactor `user_interface` - [x] add `TerminalBackend` class with ncurses init, dark and light themes, and render loop; - [x] handle `SIGWINCH` resize and `SIGINT` interrupts via `atomic` flags; - [x] route print, spinner, progress, input buffer, and redraw calls through the backend; - [x] move `Layout` struct and message type constants (normal, error, success) into the backend; - [x] hold a `unique_ptr` to `TerminalBackend` in `UserInterface`, remove ncurses state; - [x] replace `ui_running()` with `backend_running()` across all components; - [x] use a 100 ms `getch` timeout in `next_prompt` so it can detect backend shutdown; - [x] replace `std::exit` with `end_session` and `ExitRequest`; - [x] add `ExitRequest` with an exit code, reason, and noexcept `code()` and `what()` accessors; - [x] add `utils/exit_request.hpp` so argument handlers can throw `ExitRequest`; - [x] wrap session logic in `main` in a try block that catches `ExitRequest` and returns its code; - [x] route fatal Qdrant, Ollama, CURL, and system errors through `end_session(EXIT_FAILURE)`; - [x] remove `std::exit` from `check_ollama`, `process_response`, and `query_model_list`; - [x] keep `HttpException` for non-fatal HTTP errors when `fail_on_http_error` is false; - [x] add `#pragma once` to `http_utils` to prevent duplicate definitions; - [x] replace `EXPECT_EXIT` death tests with direct calls that check the `end_session` code; - [x] update the `UserInterface` mock to implement `backend_running()` instead of `ui_running()`; --- CMakeLists.txt | 2 +- src/interface/progress_spinner.cpp | 4 +- src/interface/terminal_backend.cpp | 909 +++++++++++++++++++++++ src/interface/terminal_backend.hpp | 161 ++++ src/interface/user_interface.cpp | 947 +++++------------------- src/interface/user_interface.hpp | 117 +-- src/main.cpp | 241 +++--- src/ollama/ollama_client.cpp | 40 +- src/storage/qdrant_client.cpp | 61 +- src/utils/args_utils.cpp | 1 + src/utils/curl_utils.cpp | 52 +- src/utils/embedding_utils.cpp | 10 +- src/utils/exit_request.hpp | 61 ++ src/utils/http_utils.hpp | 2 + src/utils/retrieving_utils.cpp | 8 +- src/utils/system_utils.cpp | 7 +- tests/interface/user_interface_mock.cpp | 6 +- tests/utils/system_utils_tests.cpp | 34 +- 18 files changed, 1562 insertions(+), 1101 deletions(-) create mode 100644 src/interface/terminal_backend.cpp create mode 100644 src/interface/terminal_backend.hpp create mode 100644 src/utils/exit_request.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9590518..19ca464 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(cpp-workspace VERSION 0.4.4 LANGUAGES CXX) +project(cpp-workspace VERSION 0.4.5 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/src/interface/progress_spinner.cpp b/src/interface/progress_spinner.cpp index c0ae0d5..d02fa49 100644 --- a/src/interface/progress_spinner.cpp +++ b/src/interface/progress_spinner.cpp @@ -59,7 +59,7 @@ void ProgressSpinner::start() { } this->active = true; - if (this->ui == nullptr || UserInterface::ui_running(this->ui) == false) { + if (this->ui == nullptr || !this->ui->backend_running()) { this->spinner_thread = std::thread([this]() { const char spinner_chars[] = {'|', '/', '-', '\\'}; int i = 0; @@ -114,7 +114,7 @@ void ProgressSpinner::stop() { * @note This method is called frequently by CURL; keep it lightweight. */ int ProgressSpinner::progress_callback_impl(curl_off_t dltotal, curl_off_t dlnow) { - if (this->ui == nullptr || !UserInterface::ui_running(this->ui)) { + if (this->ui == nullptr || !this->ui->backend_running()) { const char spinner_chars[] = {'|', '/', '-', '\\'}; if (dltotal > 0) { diff --git a/src/interface/terminal_backend.cpp b/src/interface/terminal_backend.cpp new file mode 100644 index 0000000..53d7127 --- /dev/null +++ b/src/interface/terminal_backend.cpp @@ -0,0 +1,909 @@ +#include "terminal_backend.hpp" +#include "utils/exit_request.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +std::atomic TerminalBackend::interrupt_flag{false}; +std::atomic TerminalBackend::resize_flag{false}; + +static_assert(std::atomic::is_always_lock_free, "std::atomic must be lock-free for use in signal handlers"); + +/** + * Semantic color pair identifiers for terminal UI rendering. + * + * Defines named color pair constants for consistent theming across the application. + * Each constant maps to an ncurses color pair ID, enabling semantic color assignment + * for different UI contexts and message types. Values range from 1 to 9, matching + * ncurses color pair initialization indices. + * + * - DEFAULT_PAIR: Standard text color for neutral content + * - HIGHLIGHT_PAIR: Emphasis color for selected or important items + * - SUCCESS_PAIR: Color for successful operations or positive messages + * - WARNING_PAIR: Color for cautionary messages or non-critical issues + * - ERROR_PAIR: Color for error messages and critical failures + * - INFO_PAIR: Color for informational messages + * - MUTED_PAIR: Subdued color for de-emphasized content + * - ACCENT_PAIR: Decorative color for visual variety and UI separation + * - FOCUS_PAIR: Color for focused or active UI elements + */ +enum ColorPair { + DEFAULT_PAIR = 1, + HIGHLIGHT_PAIR = 2, + SUCCESS_PAIR = 3, + WARNING_PAIR = 4, + ERROR_PAIR = 5, + INFO_PAIR = 6, + MUTED_PAIR = 7, + ACCENT_PAIR = 8, + FOCUS_PAIR = 9 +}; + +/** + * Initializes ncurses color pairs with a dark theme color scheme. + * + * Enables ncurses color support via start_color(), allows the terminal's default + * background color to be used via use_default_colors(), and defines all ColorPair + * color pair constants with semantic foreground/background combinations. + * + * Most pairs use -1 as background, preserving the terminal's default background + * color. HIGHLIGHT_PAIR uses black text on white background for strong contrast, + * and FOCUS_PAIR uses white text on blue background for focused element visibility. + * + * This method should be called early during TerminalBackend initialization, before + * any colored text is rendered to the terminal. + */ +void TerminalBackend::init_dark_theme(void) { + start_color(); + use_default_colors(); + + // Purpose | Foreground | Background + init_pair(DEFAULT_PAIR, COLOR_WHITE, -1); + init_pair(HIGHLIGHT_PAIR, COLOR_BLACK, COLOR_WHITE); + init_pair(SUCCESS_PAIR, COLOR_GREEN, -1); + init_pair(WARNING_PAIR, COLOR_YELLOW, -1); + init_pair(ERROR_PAIR, COLOR_RED, -1); + init_pair(INFO_PAIR, COLOR_CYAN, -1); + init_pair(ACCENT_PAIR, COLOR_MAGENTA, -1); + init_pair(FOCUS_PAIR, COLOR_WHITE, COLOR_BLUE); +} + +/** + * Initializes ncurses color pairs with a light theme color scheme. + * + * Enables ncurses color support via start_color(), allows the terminal's default + * background color to be used via use_default_colors(), and defines all ColorPair + * color pair constants with semantic foreground/background combinations optimized + * for light terminal backgrounds. + * + * Unlike the dark theme, most pairs use white backgrounds for contrast against + * darker text. DEFAULT_PAIR uses black text on white for standard content, and + * HIGHLIGHT_PAIR inverts to white text on black for strong emphasis. FOCUS_PAIR + * uses black text on cyan background for focused element visibility. + * + * This method should be called early during TerminalBackend initialization, before + * any colored text is rendered to the terminal. Select init_dark_theme() or + * init_light_theme() based on the user's terminal background preference. + */ +void TerminalBackend::init_light_theme(void) { + start_color(); + use_default_colors(); + + // Purpose | Foreground | Background + init_pair(DEFAULT_PAIR, COLOR_BLACK, COLOR_WHITE); + init_pair(HIGHLIGHT_PAIR, COLOR_WHITE, COLOR_BLACK); + init_pair(SUCCESS_PAIR, COLOR_GREEN, COLOR_WHITE); + init_pair(WARNING_PAIR, COLOR_YELLOW, COLOR_WHITE); + init_pair(ERROR_PAIR, COLOR_RED, COLOR_WHITE); + init_pair(INFO_PAIR, COLOR_BLUE, COLOR_WHITE); + init_pair(ACCENT_PAIR, COLOR_MAGENTA, COLOR_WHITE); + init_pair(FOCUS_PAIR, COLOR_BLACK, COLOR_CYAN); +} + +/** + * Initializes the ncurses terminal UI with locale support, terminal configuration, color theming, and signal handling. + * + * Sets the locale to UTF-8 for character encoding support and establishes TERMINFO fallback to the system + * terminfo database if not already set (handles Conan ncurses builds). Initializes ncurses with initscr(), + * terminating with error diagnostics on failure. Updates window layout via update_layout(). + * + * Configures terminal I/O: enables cbreak mode (immediate input) and disables echo. Attempts to enable + * function key and arrow key support via keypad(); non-fatal warning if unsupported. Sets input blocking + * mode (getch() waits for user input). Attempts to hide the cursor; non-fatal warning if unsupported. + * + * If the terminal supports colors, initializes color pairs by calling init_dark_theme() or init_light_theme() + * based on the use_dark_theme flag, and sets the terminal background to DEFAULT_PAIR. Registers SIGWINCH + * signal handler for terminal window resize detection and SIGINT handler for interrupt cleanup. + * + * Terminates the program on critical failures (initscr failure or terminal setup failure). + * Non-critical feature failures (keypad, cursor hiding) produce warnings but do not halt initialization. + */ +void TerminalBackend::init_ncurses(void) { + std::string message{}; + + // Set up locale for UTF-8 support. + setlocale(LC_ALL, ""); + + // Set TERMINFO to system database if Conan ncurses can't find it. + if (!std::getenv("TERMINFO")) { + setenv("TERMINFO", "/usr/share/terminfo", 0); + } + + // Initialize ncurses. + const WINDOW* w = initscr(); + if (!w) { + const char* term = getenv("TERM"); + fprintf(stderr, "Failed to initialize ncurses with TERM=%s\n", term ? term : "unset"); + throw ExitRequest{EXIT_FAILURE}; + } + + this->update_layout(); + + // Setup terminal. + if (cbreak() == ERR || noecho() == ERR) { + endwin(); + fprintf(stderr, "Terminal setup failed\n"); + throw ExitRequest{EXIT_FAILURE}; + } + + // Enable function keys and arrow keys. + if (keypad(stdscr, TRUE) == ERR) { + fprintf(stderr, "Warning: keypad() not supported\n"); + } + + // Block on input (getch waits for input). + nodelay(stdscr, FALSE); + + // Cursor hiding (may not be supported). + if (curs_set(0) == ERR) { + fprintf(stderr, "Warning: cursor control not supported on this terminal\n"); + } + + if (has_colors()) { + if (this->use_dark_theme == true) { + this->init_dark_theme(); + } else { + this->init_light_theme(); + } + bkgd(COLOR_PAIR(DEFAULT_PAIR)); + } + + this->install_signal_handlers(); +} + +void TerminalBackend::install_signal_handlers() { + struct sigaction sa {}; + sigemptyset(&sa.sa_mask); + + sa.sa_handler = TerminalBackend::handle_resize; + sa.sa_flags = SA_RESTART; // don't interrupt getch() just for a resize + sigaction(SIGWINCH, &sa, nullptr); + + sa.sa_handler = TerminalBackend::handle_interrupt; + sa.sa_flags = 0; // no SA_RESTART, so a blocking read returns EINTR + sigaction(SIGINT, &sa, nullptr); +} + +void TerminalBackend::handle_resize(int sig) { + TerminalBackend::resize_flag.store(true, std::memory_order_relaxed); +} + +void TerminalBackend::handle_interrupt(int sig) { + TerminalBackend::interrupt_flag.store(true, std::memory_order_relaxed); +} + +/** + * Constructs a TerminalBackend instance and initializes member variables. + * + * Initializes input_cursor_x to 0 (cursor at leftmost position). + * Initializes layout struct to default-constructed values (all fields zeroed/default). + * Sets use_dark_theme to true, establishing dark theme as the default color scheme preference. + * + * Note: This constructor performs only member initialization and singleton registration. + * Full ncurses initialization (terminal setup, color pair configuration, input handling) is deferred + * to init_ncurses(), which must be called explicitly after construction to establish the UI environment. + */ +TerminalBackend::TerminalBackend() : input_cursor_x(0), layout({}), use_dark_theme(true) { +} + +/** + * Constructs a TerminalBackend instance with a caller-specified color theme preference. + * + * @param dark_theme: Boolean flag to select color scheme; true for dark theme, false for light theme. + * + * Initializes input_cursor_x to 0 (cursor at leftmost position). + * Initializes layout struct to default-constructed values (all fields zeroed/default). + * Sets use_dark_theme to the caller-provided dark_theme value, establishing the preferred + * color scheme that will be applied during init_ncurses(). + * + * Note: This constructor performs only member initialization and singleton registration. + * Full ncurses initialization (terminal setup, color pair configuration, input handling) is deferred + * to init_ncurses(), which must be called explicitly after construction to establish the UI environment. + */ +TerminalBackend::TerminalBackend(bool dark_theme) : input_cursor_x(0), layout({}), use_dark_theme(dark_theme) { +} + +/** + * Destroys the TerminalBackend and ends its session if it is still active. + * + * Calls end_session() with its default exit code. That call stops the render + * thread and restores the terminal, but only if terminal_restored has not + * already been set. The destructor does not acquire ui_mutex directly. + * + * @post The render thread has been stopped. + * @post The terminal has been restored, and endwin() has run exactly + * once across all calls to end_session(). + * + * @note Destruction always blocks for about one second because end_session() + * calls napms(1000) unconditionally, even when the session was already + * ended earlier. + * @warning Destructors are implicitly noexcept. If end_session() or anything it + * calls throws, std::terminate() is called. Call end_session() + * explicitly before destruction if a session needs to be ended while + * exceptions are still being handled. + */ +TerminalBackend::~TerminalBackend() { + this->end_session(); +} + +/** + * Ends the terminal session and restores the terminal state. + * + * The function sleeps for one second, stops the render thread, and then calls + * endwin() if the terminal has not been restored yet. The check and + * update of terminal_restored use exchange(), so cleanup runs at most once even + * if this function is called more than once. + * + * @post The render thread has been stopped. + * @post terminal_restored is true, and endwin() has run exactly once + * across all calls. + * + * @note The call blocks the calling thread for about one second through + * napms(1000) before doing anything else. Avoid calling it from a thread + * that must respond promptly. + */ +void TerminalBackend::end_session(int code) { + napms(500); + + this->stop_render_thread(); + + if (!this->terminal_restored.exchange(true)) { + endwin(); + } + + throw ExitRequest{code}; +} + +/** + * Initializes and starts the TerminalBackend subsystem. + * + * Performs full initialization pipeline: calls init_ncurses() to set up the ncurses + * library, terminal settings, and signal handlers; calls draw_ui() to render the initial + * full-terminal Unicode box border; and calls start_render_thread() to spawn the background rendering thread. + * + * The rendering thread continuously redraws the UI (output area, input area, spinner) + * in response to state changes and terminal resize events (resize_flag). + * + * No parameters; operates on the current TerminalBackend instance state. + * + * @return void + * + * Initialization status is communicated via successful completion (or + * program termination if init_ncurses() encounters a fatal error). + * + * Acts as the entry point for the UI system. Should be called once after TerminalBackend + * instantiation and before any event processing or input handling. + * + * Enables the interactive terminal UI with responsive rendering and asynchronous + * screen updates via the background thread, while the main thread handles user input + * and application logic. + */ +void TerminalBackend::run(void) { + this->init_ncurses(); + + this->draw_ui(); + + this->start_render_thread(); +} + +/** + * Reports whether the backend's thread is currently running. + * + * The value is read atomically, so this function does not acquire ui_mutex. + * The result can become stale as soon as the function returns. + * + * @return true if the backend thread is running, false otherwise. + * + * @note Use this for status checks only. Do not use it to decide whether to + * start or stop the thread without coordinating through another + * synchronization mechanism, because the state can change between the + * check and the action. + */ +bool TerminalBackend::is_running() { + return this->thread_running.load(); +} + +/** + * Wakes one or all threads waiting on cv_redraw. + * + * The function does not acquire ui_mutex and does not modify should_redraw or + * any other state. Notifying is the only effect. + * + * @param all true to wake all waiting threads with notify_all(), false to wake + * a single waiting thread with notify_one(). + * + * @note A waiter that checks a predicate such as should_redraw must update that + * predicate before calling this function, or a wakeup can be missed. + * @warning Calling this without first changing the state a waiter depends on + * can cause a lost wakeup. Waking one thread may also leave other + * waiters asleep if they need to act on the same change, so use + * all = true when more than one thread might need to respond. + */ +void TerminalBackend::notify_cv_redraw(bool all) { + if (all == true) { + this->cv_redraw.notify_all(); + } else { + this->cv_redraw.notify_one(); + } +} + +/** + * Moves the input cursor one column to the left or right. + * + * Only steps of -1 or +1 are accepted. The function does not check whether the + * resulting position is within the bounds of input_buffer, and it does not + * acquire ui_mutex. + * + * @param shift The step to apply to input_cursor_x. Must be -1 or 1. + * + * @throws std::invalid_argument If shift is any value other than -1 or 1. + * + * @pre input_cursor_x is within the range [0, input_buffer.length()]. + * @post input_cursor_x has been moved by shift. + * + * @warning The position is not bounds-checked after the shift. Stepping left + * at position 0 or right past the end of input_buffer leaves + * input_cursor_x out of range, which callers must avoid. + * @note Access to input_cursor_x is not synchronized. If another thread reads or + * modifies the cursor, the caller must hold ui_mutex. + */ +void TerminalBackend::shift_input_cursor_x(int shift) { + if (shift != -1 && shift != 1) { + throw std::invalid_argument("Cursor x can be shifted by -1 or +1."); + } + this->input_cursor_x += shift; +} + +/** + * Sets the flag that requests a screen redraw. + * + * The value is stored atomically, so this function does not acquire ui_mutex. + * The previous value is discarded. + * + * @param value true to request a redraw, false to clear the request. + * + * @note This function does not notify cv_redraw. Setting the flag to true does + * not wake a thread waiting on that condition variable, so a waiter may + * not see the request until it is woken by another call such as print(). + */ +void TerminalBackend::set_should_redraw(bool value) { + std::lock_guard lock(this->ui_mutex); + this->should_redraw.store(value); +} + +/** + * Sets whether the spinner is active. + * + * The value is stored atomically, so this function does not acquire ui_mutex. + * The previous value is discarded. + * + * @param value true to activate the spinner, false to deactivate it. + * + * @note This call does not set should_redraw or notify cv_redraw. Callers that + * need the spinner change displayed must trigger a redraw separately. + */ +void TerminalBackend::set_spinner_active(bool value) { + std::lock_guard lock(this->ui_mutex); + this->spinner_active.store(value); +} + +/** + * Sets the progress percentage shown by the terminal. + * + * The value is stored atomically, so this function does not acquire ui_mutex. + * The previous value is discarded. No range check is performed, so callers + * are responsible for passing a meaningful percentage. + * + * @param value The new progress percentage to store. + * + * @note Because no lock is taken, a redraw is not triggered by this call. + * Callers that need the change displayed must also update output state + * and signal cv_redraw through print(). + */ +void TerminalBackend::set_spinner_frame(int value) { + std::lock_guard lock(this->ui_mutex); + if (value >= 0 && value <= 4) { + this->spinner_frame.store(value); + } else { + this->spinner_frame.store((spinner_frame + 1) % 4); + } +} + +void TerminalBackend::set_progress_percent(int value) { + this->progress_percent.store(value); +} + +/** + * Reports whether the spinner is currently active. + * + * The value is read atomically, so this function does not acquire ui_mutex. + * The result can change immediately after the call returns if another thread + * updates spinner_active. + * + * @return true if the spinner is active, false otherwise. + */ +bool TerminalBackend::get_spinner_active() { + return this->spinner_active.load(); +} + +/** + * Draws the terminal UI frame with a Unicode box border. + * + * Clears the screen and retrieves the current terminal dimensions (max_y, max_x) via getmaxyx(). + * Draws a complete box frame using Unicode box-drawing characters: ╔ and ╗ (top corners), ╚ and ╝ + * (bottom corners), ═ (horizontal edges), and ║ (vertical edges). Positions characters using mvprintw() + * to create a full-terminal border with proper corner and edge placement. + * + * Refreshes the display with refresh() to flush buffered output and show the drawn frame on screen. + */ +void TerminalBackend::draw_ui(void) { + clear(); + + int max_y; + int max_x; + getmaxyx(stdscr, max_y, max_x); + + int i; + + // Top border + mvprintw(0, 0, "╔"); + for (i = 1; i < max_x - 1; i += 1) { + mvprintw(0, i, "═"); + } + mvprintw(0, max_x - 1, "╗"); + + // Side borders + for (i = 1; i < max_y - 1; i += 1) { + mvprintw(i, 0, "║"); + mvprintw(i, max_x - 1, "║"); + } + + // Bottom border + mvprintw(max_y - 1, 0, "╚"); + for (i = 1; i < max_x - 1; i += 1) { + mvprintw(max_y - 1, i, "═"); + } + mvprintw(max_y - 1, max_x - 1, "╝"); + + refresh(); +} + +/** + * Draws an animated spinner with optional progress percentage at the UI layout position. + * + * Returns early if the spinner is not active. Loads the current animation frame + * index and progress percentage from atomic variables (spinner_frame, progress_percent) + * in a thread-safe manner. Selects the appropriate spinner character from a rotating set + * (|, /, -, \) based on the frame index. + * + * Enables DEFAULT_PAIR color styling and renders the spinner at the layout-defined position + * (layout.spinner_y, layout.spinner_x) using mvprintw(). If progress > 0, displays "X Loading..." + * with the progress percentage; otherwise displays "X Loading..." without percentage. Disables color + * styling after rendering. + * + * Thread-safe via atomic variable loads; does not modify shared state. + */ +void TerminalBackend::draw_spinner() { + if (!this->spinner_active.load()) { + return; + } + + const char spinner_chars[] = {'|', '/', '-', '\\'}; + int frame = this->spinner_frame.load(); + int progress = this->progress_percent.load(); + + attron(COLOR_PAIR(DEFAULT_PAIR)); + + if (progress > 0) { + mvprintw(this->layout.spinner_y, this->layout.spinner_x, "%c Progress: %d%%", spinner_chars[frame], progress); + } else { + mvprintw(this->layout.spinner_y, this->layout.spinner_x, "%c Loading...", spinner_chars[frame]); + } + + attroff(COLOR_PAIR(DEFAULT_PAIR)); +} + +/** + * Recalculates UI layout positions and dimensions based on current terminal size. + * + * Retrieves the current terminal dimensions (max_y, max_x) via getmaxyx() and updates the layout + * structure to position UI elements relative to terminal boundaries. Sets status_y to the bottom row + * (max_y - 1), input area to max_y - 3 with left margin of 2 columns, and spinner to max_y - 2 + * (between input and status rows). Sets output area to start at row 1 (below top border) with height + * calculated as the vertical space between output_y and input_y minus one row for spacing. + * + * Intended to be called during initialization and in response to terminal resize events (SIGWINCH) + * to ensure all UI elements remain properly positioned and sized when the terminal is resized. + */ +void TerminalBackend::update_layout() { + int max_y, max_x; + getmaxyx(stdscr, max_y, max_x); + + layout.status_y = max_y - 1; + layout.input_y = max_y - 3; + layout.input_x = 2; + layout.spinner_y = max_y - 2; + layout.spinner_x = 2; + layout.output_y = 1; + layout.output_height = layout.input_y - layout.output_y - 1; +} + +/** + * Clears the input area rows by overwriting them with spaces. + * + * Retrieves the current terminal dimensions (max_y, max_x) via getmaxyx(). Erases two rows of the + * input area (layout.input_y and layout.input_y + 1) by drawing horizontal lines of spaces from + * column 1 to max_x - 2, leaving the left and right borders intact. + * + * Used to erase previous user input or prompts before rendering new content in the input area. + * Maintains visual consistency by preserving the terminal border structure. + */ +void TerminalBackend::clear_input_area() { + int max_y, max_x; + getmaxyx(stdscr, max_y, max_x); + + mvhline(layout.input_y, 1, ' ', max_x - 2); + mvhline(layout.input_y + 1, 1, ' ', max_x - 2); +} + +/** + * Renders the input prompt and user-typed content in the input area. + * + * Clears the input area via clear_input_area(). Returns early if the spinner is currently active, + * preventing prompt and input display while a background operation (query execution) is in progress. + * + * When the spinner is inactive, renders a static prompt "⏳ Type your prompt (q/Q to quit): " at + * the layout-defined input position (layout.input_y, layout.input_x). Renders the contents of the + * input_buffer (user-typed characters) immediately following the prompt text at a fixed column offset + * (+37 from input_x). + * + * Provides visual feedback and instructions to the user while maintaining a clean, non-overlapping + * layout during asynchronous spinner operation. + */ +void TerminalBackend::draw_input_area() { + this->clear_input_area(); + if (this->spinner_active.load()) { + return; + } + + mvprintw(layout.input_y, layout.input_x, "⏳ Type your prompt (q/Q to quit): "); + + // Draw the actual input buffer that user typed + mvprintw(layout.input_y, layout.input_x + 37, "%s", input_buffer.c_str()); +} + +/** + * Renders the message output buffer in reverse chronological order (newest first), + * from the bottom of the output area upward. + * + * Clears the entire output area by filling each row with spaces, preserving the border columns. + * Iterates backwards through output_buffer, starting from the most recently added message. + * For each message, extracts the text content and message type (error/success/default). + * + * Splits multi-line message text by newline characters. Wraps each logical line at word + * boundaries using TextUtils::wrap_text() to fit within max_content_width (COLS - 4). + * Renders wrapped lines from bottom to top (reverse order) within the allocated output area, + * decrementing current_line after each rendered line. + * + * Applies semantic color and icon styling based on message type: + * - MESSAGE_TYPE_ERROR: bold red (ERROR_PAIR) with ❌ emoji prefix. + * - MESSAGE_TYPE_SUCCESS: bold default color with ✅ emoji prefix. + * - Default: plain text without icon. + * + * Breaks iteration early if current_line reaches or falls below layout.output_y (top boundary). + * Calls refresh() to update the terminal display. + * + * Provides a scrollable-like view of the most recent messages within the constrained output area, + * with automatic wrapping and visual differentiation for errors and successful operations. + */ +void TerminalBackend::draw_output_area() { + // Clear the output area + for (int i = 0; i < layout.output_height; i += 1) { + mvhline(layout.output_y + i, 1, ' ', COLS - 2); + } + + int current_line = layout.input_y - 2; // Start from bottom + int max_content_width = COLS - 4; + + // Iterate backwards through buffer (newest first) + for (auto it = output_buffer.rbegin(); it != output_buffer.rend(); it += 1) { + if (current_line <= layout.output_y) { + break; + } + + const auto& [text, type] = *it; + std::istringstream stream(text); + std::string line; + std::vector display_lines; + + // Process each logical line from the message + while (std::getline(stream, line)) { + // Wrap long lines at word boundaries + auto wrapped = TextUtils::wrap_text(line, max_content_width); + display_lines.insert(display_lines.end(), wrapped.begin(), wrapped.end()); + } + + // Render from bottom to top (reverse order) + for (auto line_it = display_lines.rbegin(); line_it != display_lines.rend(); line_it += 1) { + if (current_line <= layout.output_y) break; + + if (type == TerminalBackend::MESSAGE_TYPE_ERROR) { + wattron(stdscr, COLOR_PAIR(ERROR_PAIR) | A_BOLD); + mvprintw(current_line, 2, "❌ %s", line_it->c_str()); + wattroff(stdscr, COLOR_PAIR(ERROR_PAIR) | A_BOLD); + } else if (type == TerminalBackend::MESSAGE_TYPE_SUCCESS) { + wattron(stdscr, A_BOLD); + mvprintw(current_line, 2, "✅ %s", line_it->c_str()); + wattroff(stdscr, A_BOLD); + } else { + mvprintw(current_line, 2, "%s", line_it->c_str()); + } + + current_line--; + } + } + + refresh(); +} + +/** + * @brief Spawns the background rendering thread and sets the thread_running flag. + * + * Sets thread_running to true and creates a new std::thread that invokes render_loop(). + * The thread is joinable and must be cleaned up via stop_render_thread() or equivalent. + * + * @note Call this after init_ncurses() to begin continuous UI rendering. + * @see render_loop(), stop_render_thread() + */ +void TerminalBackend::start_render_thread(void) { + this->thread_running.store(true); + this->render_thread = std::thread([this]() { + try { + this->render_loop(); + } catch (...) { + this->render_exception = std::current_exception(); + } + this->thread_running.store(false); + }); +} + +/** + * @brief Gracefully shuts down the background rendering thread. + * + * Sets thread_running to false and notifies the condition variable cv_redraw to + * wake the render loop. Joins the thread if it is joinable, ensuring complete cleanup. + * + * @note Call this during session shutdown or before destroying the TerminalBackend instance. + * @see start_render_thread(), render_loop() + */ +void TerminalBackend::stop_render_thread(void) { + { + std::lock_guard lock(this->ui_mutex); + this->thread_running.store(false); + } + this->cv_redraw.notify_all(); + if (this->render_thread.joinable() && this->render_thread.get_id() != std::this_thread::get_id()) { + this->render_thread.join(); + } + if (this->render_exception) { + std::exception_ptr error = std::exchange(this->render_exception, nullptr); + std::rethrow_exception(error); + } +} + +/** + * @brief Main rendering loop executed by the background render thread. + * + * Runs continuously while thread_running is true. Uses cv_redraw condition variable + * with a 1500ms timeout to wait efficiently for redraw requests. Handles terminal resize + * events (endwin(), refresh(), update_layout(), cursor recalculation). Conditionally + * redraws UI components (draw_ui, draw_output_area, draw_input_area, draw_spinner) + * when should_redraw is set. All ncurses operations are protected by ui_mutex. + * + * @note This method is the entry point for the render thread and should not be called directly. + * Call start_render_thread() to spawn it. Terminate via stop_render_thread(). + * @see start_render_thread(), stop_render_thread(), draw_ui(), draw_output_area(), draw_input_area(), draw_spinner() + */ +void TerminalBackend::render_loop() { + const int INPUT_PROMPT_WIDTH = 37; + try { + while (this->thread_running.load(std::memory_order_acquire)) { + std::unique_lock lock(ui_mutex); + + // Wake on redraw, resize, interrupt, or shutdown. The 1500 ms timeout is only a fallback. + this->cv_redraw.wait_for(lock, std::chrono::milliseconds(1500), [this] { + return this->should_redraw.load(std::memory_order_acquire) + || TerminalBackend::resize_flag.load(std::memory_order_acquire) + || TerminalBackend::interrupt_flag.load(std::memory_order_acquire) + || !this->thread_running.load(std::memory_order_acquire); + }); + + if (TerminalBackend::interrupt_flag.load(std::memory_order_acquire) + || !this->thread_running.load(std::memory_order_acquire)) { + endwin(); + break; // lock is released by the destructor; end_session runs on the main thread + } + + if (TerminalBackend::resize_flag.exchange(false, std::memory_order_acq_rel)) { + endwin(); + refresh(); + this->update_layout(); + this->input_cursor_x = layout.input_x + INPUT_PROMPT_WIDTH + input_buffer.length(); + this->should_redraw.store(true, std::memory_order_release); + } + + if (this->should_redraw.load(std::memory_order_acquire)) { + this->draw_ui(); + this->draw_output_area(); + this->draw_input_area(); + this->draw_spinner(); + refresh(); + this->should_redraw.store(false, std::memory_order_release); + } + } + } catch (...) { + std::lock_guard g(exception_mutex); + this->render_exception = std::current_exception(); + this->thread_running.store(false, std::memory_order_release); + } +} + +/** + * Adds text to the output buffer and requests a redraw. + * + * When stream is true and the output buffer is not empty, text is appended to + * the text of the last entry, and type is ignored. Otherwise, a new entry + * holding text and type is added to the end of the buffer. The buffer is + * modified while ui_mutex is held. + * + * After the buffer is updated, the function sets should_redraw to true and + * wakes one thread waiting on cv_redraw. The lock is released before the + * notification is sent. + * + * @param text The text to add to the output buffer. + * @param type The category of the new entry. It is ignored when text is + * appended to an existing entry. + * @param stream If true, text continues the most recent entry instead of + * starting a new one. + */ +void TerminalBackend::print(const std::string& text, int type, bool stream) { + { + std::lock_guard lock(this->ui_mutex); + if (stream == true && !output_buffer.empty()) { + output_buffer.back().first += text; + } else { + output_buffer.push_back({text, type}); + } + } + this->should_redraw.store(true); + this->cv_redraw.notify_one(); +} + +/** + * Removes all characters from the input buffer. + * + * The buffer is empty after the call. Calling the function on an empty buffer + * has no effect. Any view previously returned by get_input_buffer is invalid + * after this call. The function does not acquire ui_mutex, so callers that + * share input_buffer across threads must synchronize access themselves. + */ +void TerminalBackend::clear_input_buffer() { + this->input_buffer.clear(); +} + +/** + * Reports whether the input buffer holds an exit command. + * + * The buffer matches only when its entire contents are a single "q" or "Q". + * A buffer that contains either character along with other input does not + * match. The function does not acquire ui_mutex, so callers that share + * input_buffer across threads must synchronize access themselves. + * + * @return true if input_buffer is exactly "q" or "Q", false otherwise. + */ +bool TerminalBackend::exit_keys_in_input_buffer() { + return this->input_buffer == "q" || this->input_buffer == "Q"; +} + +/** + * Reports whether the input buffer leaves room for a prompt. + * + * The buffer has room for a prompt when its length is less than the terminal + * width (COLS) minus 40 columns, which is reserved for the prompt. COLS is + * read at each call, so the result follows terminal resizes. + * + * The function does not acquire ui_mutex, so callers that share input_buffer + * across threads must synchronize access themselves. + * + * @return true if input_buffer has room for a prompt, false otherwise. + */ +bool TerminalBackend::room_for_prompt_in_input_buffer() { + const int usable = COLS - 40; + return usable > 0 && this->input_buffer.length() < static_cast(usable); +} + +/** + * Reports whether the input buffer contains no characters. + * + * The function does not acquire ui_mutex. Its result can be out of date by + * the time the caller acts on it if another thread modifies input_buffer in + * the meantime. + * + * @return true if input_buffer is empty, false otherwise. + */ +bool TerminalBackend::input_buffer_is_empty() { + return this->input_buffer.empty(); +} + +/** + * Returns a non-owning view of the current input buffer contents. + * + * The view refers to the storage of input_buffer and does not copy it. It + * stays valid only until input_buffer is modified, for example by + * push_back_input_buffer or pop_back_input_buffer, which can reallocate or + * shrink the storage. It also remains valid only while this TerminalBackend + * exists. The function does not acquire ui_mutex, so callers that share + * input_buffer across threads must synchronize access themselves. + * + * @return A view of input_buffer. Copy the contents if they must outlive the + * next modification of the buffer. + */ +std::string_view TerminalBackend::get_input_buffer() const { + return this->input_buffer; +} + +/** + * Removes the last character from the input buffer. + * + * The function does not check whether the buffer is empty. Calling it when + * input_buffer is empty is undefined behavior. Like push_back_input_buffer, + * it does not acquire ui_mutex, so callers that share input_buffer across + * threads must synchronize access themselves. + * + * @pre input_buffer is not empty. + */ +void TerminalBackend::pop_back_input_buffer() { + std::lock_guard lock(this->ui_mutex); + this->input_buffer.pop_back(); +} + +/** + * Appends a character code to the end of the input buffer. + * + * The value is stored as passed, without validation or conversion. The + * function does not acquire ui_mutex, so callers that share input_buffer + * across threads must synchronize access themselves. + * + * @param ch The character code to append to input_buffer. + */ +void TerminalBackend::push_back_input_buffer(int ch) { + std::lock_guard lock(this->ui_mutex); + this->input_buffer.push_back(ch); +} diff --git a/src/interface/terminal_backend.hpp b/src/interface/terminal_backend.hpp new file mode 100644 index 0000000..a9c9dad --- /dev/null +++ b/src/interface/terminal_backend.hpp @@ -0,0 +1,161 @@ +#pragma once + +#include "terminal_backend.hpp" +#include "utils/text_utils.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief UI layout positioning configuration. + * + * Stores calculated row and column positions for all major UI elements + * relative to terminal dimensions. Updated by update_layout() when terminal + * is resized. + */ +struct Layout { + // Row for input prompt. + int input_y; + // Column for input start. + int input_x; + // Row where output begins. + int output_y; + // Output area height. + int output_height; + // Row for status messages. + int status_y; + // Row for the progress spinner. + int spinner_y; + // Column for the progress spinner. + int spinner_x; +}; + +class TerminalBackend { + private: + // Terminal backend state. + static std::atomic resize_flag; + static std::atomic interrupt_flag; + + // Layout state. + struct Layout layout; + + // Input buffer state. + std::string input_buffer; + int input_cursor_x; + + // Output buffer state. + std::vector> output_buffer; + + // Renderer state. + std::thread render_thread; + std::mutex ui_mutex; + std::mutex exception_mutex; + std::condition_variable cv_redraw; + std::atomic should_redraw{true}; + std::atomic thread_running{false}; + std::atomic terminal_restored{false}; + + // Spinner state. + std::atomic spinner_active{false}; + std::atomic spinner_frame{0}; + std::atomic progress_percent{0}; + + std::exception_ptr render_exception; + + // Color theme configuration. + bool use_dark_theme{false}; + + // Initialization methods. + + void init_dark_theme(void); + + void init_light_theme(void); + + void init_ncurses(void); + + // Signal handlers. + + void install_signal_handlers(); + static void handle_resize(int sig); + static void handle_interrupt(int sig); + + public: + TerminalBackend(); + + explicit TerminalBackend(bool dark_theme); + + ~TerminalBackend(); + + void end_session(int code = EXIT_SUCCESS); + + void run(void); + + bool is_running(); + + // Message configuration constants. + + // Plain text, default styling. + static constexpr int MESSAGE_TYPE_NORMAL = 0; + // Bold red with ❌ prefix. + static constexpr int MESSAGE_TYPE_ERROR = 1; + // Bold with ✅ prefix. + static constexpr int MESSAGE_TYPE_SUCCESS = 2; + + // Value setters + void shift_input_cursor_x(int shift); + void set_should_redraw(bool value); + void set_spinner_active(bool value); + void set_spinner_frame(int value = -1); + void set_progress_percent(int value); + + void notify_cv_redraw(bool all = false); + + // Value getters. + bool get_spinner_active(void); + + // Layout manpulation methods. + + void draw_ui(void); + + void draw_spinner(void); + + void update_layout(); + + // Input area methods. + + void clear_input_area(); + + void draw_input_area(); + + // Output area methods. + + void draw_output_area(); + + // Renderer methods. + + void start_render_thread(void); + + void stop_render_thread(void); + + void render_loop(void); + + // Output printing methods. + + void print(const std::string& text, int type, bool stream = false); + + // Input buffer access methods. + void clear_input_buffer(void); + bool exit_keys_in_input_buffer(void); + bool room_for_prompt_in_input_buffer(void); + bool input_buffer_is_empty(void); + std::string_view get_input_buffer(void) const; + void pop_back_input_buffer(void); + void push_back_input_buffer(int ch); +}; diff --git a/src/interface/user_interface.cpp b/src/interface/user_interface.cpp index 0f6b830..47a6ce4 100644 --- a/src/interface/user_interface.cpp +++ b/src/interface/user_interface.cpp @@ -1,19 +1,15 @@ #include "user_interface.hpp" +#include "utils/exit_request.hpp" #include #include #include #include #include #include -#include #include std::weak_ptr UserInterface::instance; -volatile sig_atomic_t UserInterface::resize_flag = 0; - -volatile sig_atomic_t UserInterface::interrupt_flag = 0; - /** * Creates a UserInterface instance with the light theme as the default color scheme. * @@ -49,493 +45,13 @@ std::shared_ptr UserInterface::create(bool dark_theme) { return ui; } -/** - * Semantic color pair identifiers for terminal UI rendering. - * - * Defines named color pair constants for consistent theming across the application. - * Each constant maps to an ncurses color pair ID, enabling semantic color assignment - * for different UI contexts and message types. Values range from 1 to 9, matching - * ncurses color pair initialization indices. - * - * - DEFAULT_PAIR: Standard text color for neutral content - * - HIGHLIGHT_PAIR: Emphasis color for selected or important items - * - SUCCESS_PAIR: Color for successful operations or positive messages - * - WARNING_PAIR: Color for cautionary messages or non-critical issues - * - ERROR_PAIR: Color for error messages and critical failures - * - INFO_PAIR: Color for informational messages - * - MUTED_PAIR: Subdued color for de-emphasized content - * - ACCENT_PAIR: Decorative color for visual variety and UI separation - * - FOCUS_PAIR: Color for focused or active UI elements - */ -enum ColorPair { - DEFAULT_PAIR = 1, - HIGHLIGHT_PAIR = 2, - SUCCESS_PAIR = 3, - WARNING_PAIR = 4, - ERROR_PAIR = 5, - INFO_PAIR = 6, - MUTED_PAIR = 7, - ACCENT_PAIR = 8, - FOCUS_PAIR = 9 -}; - -/** - * Initializes ncurses color pairs with a dark theme color scheme. - * - * Enables ncurses color support via start_color(), allows the terminal's default - * background color to be used via use_default_colors(), and defines all ColorPair - * color pair constants with semantic foreground/background combinations. - * - * Most pairs use -1 as background, preserving the terminal's default background - * color. HIGHLIGHT_PAIR uses black text on white background for strong contrast, - * and FOCUS_PAIR uses white text on blue background for focused element visibility. - * - * This method should be called early during UserInterface initialization, before - * any colored text is rendered to the terminal. - */ -void UserInterface::init_dark_theme(void) { - start_color(); - use_default_colors(); - - // Purpose | Foreground | Background - init_pair(DEFAULT_PAIR, COLOR_WHITE, -1); - init_pair(HIGHLIGHT_PAIR, COLOR_BLACK, COLOR_WHITE); - init_pair(SUCCESS_PAIR, COLOR_GREEN, -1); - init_pair(WARNING_PAIR, COLOR_YELLOW, -1); - init_pair(ERROR_PAIR, COLOR_RED, -1); - init_pair(INFO_PAIR, COLOR_CYAN, -1); - init_pair(ACCENT_PAIR, COLOR_MAGENTA, -1); - init_pair(FOCUS_PAIR, COLOR_WHITE, COLOR_BLUE); -} - -/** - * Initializes ncurses color pairs with a light theme color scheme. - * - * Enables ncurses color support via start_color(), allows the terminal's default - * background color to be used via use_default_colors(), and defines all ColorPair - * color pair constants with semantic foreground/background combinations optimized - * for light terminal backgrounds. - * - * Unlike the dark theme, most pairs use white backgrounds for contrast against - * darker text. DEFAULT_PAIR uses black text on white for standard content, and - * HIGHLIGHT_PAIR inverts to white text on black for strong emphasis. FOCUS_PAIR - * uses black text on cyan background for focused element visibility. - * - * This method should be called early during UserInterface initialization, before - * any colored text is rendered to the terminal. Select init_dark_theme() or - * init_light_theme() based on the user's terminal background preference. - */ -void UserInterface::init_light_theme(void) { - start_color(); - use_default_colors(); - - // Purpose | Foreground | Background - init_pair(DEFAULT_PAIR, COLOR_BLACK, COLOR_WHITE); - init_pair(HIGHLIGHT_PAIR, COLOR_WHITE, COLOR_BLACK); - init_pair(SUCCESS_PAIR, COLOR_GREEN, COLOR_WHITE); - init_pair(WARNING_PAIR, COLOR_YELLOW, COLOR_WHITE); - init_pair(ERROR_PAIR, COLOR_RED, COLOR_WHITE); - init_pair(INFO_PAIR, COLOR_BLUE, COLOR_WHITE); - init_pair(ACCENT_PAIR, COLOR_MAGENTA, COLOR_WHITE); - init_pair(FOCUS_PAIR, COLOR_BLACK, COLOR_CYAN); -} - -/** - * Initializes the ncurses terminal UI with locale support, terminal configuration, color theming, and signal handling. - * - * Sets the locale to UTF-8 for character encoding support and establishes TERMINFO fallback to the system - * terminfo database if not already set (handles Conan ncurses builds). Initializes ncurses with initscr(), - * terminating with error diagnostics on failure. Updates window layout via update_layout(). - * - * Configures terminal I/O: enables cbreak mode (immediate input) and disables echo. Attempts to enable - * function key and arrow key support via keypad(); non-fatal warning if unsupported. Sets input blocking - * mode (getch() waits for user input). Attempts to hide the cursor; non-fatal warning if unsupported. - * - * If the terminal supports colors, initializes color pairs by calling init_dark_theme() or init_light_theme() - * based on the use_dark_theme flag, and sets the terminal background to DEFAULT_PAIR. Registers SIGWINCH - * signal handler for terminal window resize detection and SIGINT handler for interrupt cleanup. - * - * Terminates the program on critical failures (initscr failure or terminal setup failure). - * Non-critical feature failures (keypad, cursor hiding) produce warnings but do not halt initialization. - */ -void UserInterface::init_ncurses(void) { - // Set up locale for UTF-8 support. - setlocale(LC_ALL, ""); - - // Set TERMINFO to system database if Conan ncurses can't find it. - if (!std::getenv("TERMINFO")) { - setenv("TERMINFO", "/usr/share/terminfo", 0); - } - - // Initialize ncurses. - const WINDOW* w = initscr(); - if (!w) { - const char* term = getenv("TERM"); - fprintf(stderr, "Failed to initialize ncurses with TERM=%s\n", term ? term : "unset"); - std::exit(EXIT_FAILURE); - } - - this->update_layout(); - - // Setup terminal. - if (cbreak() == ERR || noecho() == ERR) { - endwin(); - fprintf(stderr, "Terminal setup failed\n"); - std::exit(EXIT_FAILURE); - } - - // Enable function keys and arrow keys. - if (keypad(stdscr, TRUE) == ERR) { - fprintf(stderr, "Warning: keypad() not supported\n"); - } - - // Block on input (getch waits for input). - nodelay(stdscr, FALSE); - - // Cursor hiding (may not be supported). - if (curs_set(0) == ERR) { - fprintf(stderr, "Warning: cursor control not supported on this terminal\n"); - } - - if (has_colors()) { - if (this->use_dark_theme == true) { - this->init_dark_theme(); - } else { - this->init_light_theme(); - } - bkgd(COLOR_PAIR(DEFAULT_PAIR)); - } - - signal(SIGWINCH, UserInterface::handle_resize); - signal(SIGINT, UserInterface::handle_interrupt); -} - -/** - * Cleans up ncurses and restores the terminal to its normal state. - * - * Calls endwin() to tear down the ncurses library, which restores terminal settings (echo, cbreak mode, - * cursor visibility, color state) and clears the screen. Must be called before program termination to - * prevent terminal corruption. - */ -void UserInterface::cleanup_ncurses(void) { - endwin(); -} - -/** - * Draws the terminal UI frame with a Unicode box border. - * - * Clears the screen and retrieves the current terminal dimensions (max_y, max_x) via getmaxyx(). - * Draws a complete box frame using Unicode box-drawing characters: ╔ and ╗ (top corners), ╚ and ╝ - * (bottom corners), ═ (horizontal edges), and ║ (vertical edges). Positions characters using mvprintw() - * to create a full-terminal border with proper corner and edge placement. - * - * Refreshes the display with refresh() to flush buffered output and show the drawn frame on screen. - */ -void UserInterface::draw_ui(void) { - clear(); - - int max_y; - int max_x; - getmaxyx(stdscr, max_y, max_x); - - int i; - - // Top border - mvprintw(0, 0, "╔"); - for (i = 1; i < max_x - 1; i += 1) { - mvprintw(0, i, "═"); - } - mvprintw(0, max_x - 1, "╗"); - - // Side borders - for (i = 1; i < max_y - 1; i += 1) { - mvprintw(i, 0, "║"); - mvprintw(i, max_x - 1, "║"); - } - - // Bottom border - mvprintw(max_y - 1, 0, "╚"); - for (i = 1; i < max_x - 1; i += 1) { - mvprintw(max_y - 1, i, "═"); - } - mvprintw(max_y - 1, max_x - 1, "╝"); - - refresh(); -} - -/** - * Draws an animated spinner with optional progress percentage at the UI layout position. - * - * Returns early if the spinner is not active (checked via is_spinner_active()). Loads the current - * animation frame index and progress percentage from atomic variables (spinner_frame, progress_percent) - * in a thread-safe manner. Selects the appropriate spinner character from a rotating set (|, /, -, \) - * based on the frame index. - * - * Enables DEFAULT_PAIR color styling and renders the spinner at the layout-defined position - * (layout.spinner_y, layout.spinner_x) using mvprintw(). If progress > 0, displays "X Loading..." - * with the progress percentage; otherwise displays "X Loading..." without percentage. Disables color - * styling after rendering. - * - * Thread-safe via atomic variable loads; does not modify shared state. - */ -void UserInterface::draw_spinner() { - if (!this->is_spinner_active()) { - return; - } - - const char spinner_chars[] = {'|', '/', '-', '\\'}; - int frame = this->spinner_frame.load(); - int progress = this->progress_percent.load(); - - attron(COLOR_PAIR(DEFAULT_PAIR)); - - if (progress > 0) { - mvprintw(this->layout.spinner_y, this->layout.spinner_x, "%c Progress: %d%%", spinner_chars[frame], progress); - } else { - mvprintw(this->layout.spinner_y, this->layout.spinner_x, "%c Loading...", spinner_chars[frame]); - } - - attroff(COLOR_PAIR(DEFAULT_PAIR)); -} - -/** - * Recalculates UI layout positions and dimensions based on current terminal size. - * - * Retrieves the current terminal dimensions (max_y, max_x) via getmaxyx() and updates the layout - * structure to position UI elements relative to terminal boundaries. Sets status_y to the bottom row - * (max_y - 1), input area to max_y - 3 with left margin of 2 columns, and spinner to max_y - 2 - * (between input and status rows). Sets output area to start at row 1 (below top border) with height - * calculated as the vertical space between output_y and input_y minus one row for spacing. - * - * Intended to be called during initialization and in response to terminal resize events (SIGWINCH) - * to ensure all UI elements remain properly positioned and sized when the terminal is resized. - */ -void UserInterface::update_layout() { - int max_y, max_x; - getmaxyx(stdscr, max_y, max_x); - - layout.status_y = max_y - 1; - layout.input_y = max_y - 3; - layout.input_x = 2; - layout.spinner_y = max_y - 2; - layout.spinner_x = 2; - layout.output_y = 1; - layout.output_height = layout.input_y - layout.output_y - 1; -} - -/** - * Clears the input area rows by overwriting them with spaces. - * - * Retrieves the current terminal dimensions (max_y, max_x) via getmaxyx(). Erases two rows of the - * input area (layout.input_y and layout.input_y + 1) by drawing horizontal lines of spaces from - * column 1 to max_x - 2, leaving the left and right borders intact. - * - * Used to erase previous user input or prompts before rendering new content in the input area. - * Maintains visual consistency by preserving the terminal border structure. - */ -void UserInterface::clear_input_area() { - int max_y, max_x; - getmaxyx(stdscr, max_y, max_x); - - mvhline(layout.input_y, 1, ' ', max_x - 2); - mvhline(layout.input_y + 1, 1, ' ', max_x - 2); -} - -/** - * Renders the input prompt and user-typed content in the input area. - * - * Clears the input area via clear_input_area(). Returns early if the spinner is currently active, - * preventing prompt and input display while a background operation (query execution) is in progress. - * - * When the spinner is inactive, renders a static prompt "⏳ Type your prompt (q/Q to quit): " at - * the layout-defined input position (layout.input_y, layout.input_x). Renders the contents of the - * input_buffer (user-typed characters) immediately following the prompt text at a fixed column offset - * (+37 from input_x). - * - * Provides visual feedback and instructions to the user while maintaining a clean, non-overlapping - * layout during asynchronous spinner operation. - */ -void UserInterface::draw_input_area() { - this->clear_input_area(); - if (this->is_spinner_active()) { - return; - } - - mvprintw(layout.input_y, layout.input_x, "⏳ Type your prompt (q/Q to quit): "); - - // Draw the actual input buffer that user typed - mvprintw(layout.input_y, layout.input_x + 37, "%s", input_buffer.c_str()); -} - -/** - * Renders the message output buffer in reverse chronological order (newest first), - * from the bottom of the output area upward. - * - * Clears the entire output area by filling each row with spaces, preserving the border columns. - * Iterates backwards through output_buffer, starting from the most recently added message. - * For each message, extracts the text content and message type (error/success/default). - * - * Splits multi-line message text by newline characters. Wraps each logical line at word - * boundaries using TextUtils::wrap_text() to fit within max_content_width (COLS - 4). - * Renders wrapped lines from bottom to top (reverse order) within the allocated output area, - * decrementing current_line after each rendered line. - * - * Applies semantic color and icon styling based on message type: - * - MESSAGE_TYPE_ERROR: bold red (ERROR_PAIR) with ❌ emoji prefix. - * - MESSAGE_TYPE_SUCCESS: bold default color with ✅ emoji prefix. - * - Default: plain text without icon. - * - * Breaks iteration early if current_line reaches or falls below layout.output_y (top boundary). - * Calls refresh() to update the terminal display. - * - * Provides a scrollable-like view of the most recent messages within the constrained output area, - * with automatic wrapping and visual differentiation for errors and successful operations. - */ -void UserInterface::draw_output_area() { - // Clear the output area - for (int i = 0; i < layout.output_height; i += 1) { - mvhline(layout.output_y + i, 1, ' ', COLS - 2); - } - - int current_line = layout.input_y - 2; // Start from bottom - int max_content_width = COLS - 4; - - // Iterate backwards through buffer (newest first) - for (auto it = output_buffer.rbegin(); it != output_buffer.rend(); it += 1) { - if (current_line <= layout.output_y) { - break; - } - - const auto& [text, type] = *it; - std::istringstream stream(text); - std::string line; - std::vector display_lines; - - // Process each logical line from the message - while (std::getline(stream, line)) { - // Wrap long lines at word boundaries - auto wrapped = TextUtils::wrap_text(line, max_content_width); - display_lines.insert(display_lines.end(), wrapped.begin(), wrapped.end()); - } - - // Render from bottom to top (reverse order) - for (auto line_it = display_lines.rbegin(); line_it != display_lines.rend(); line_it += 1) { - if (current_line <= layout.output_y) break; - - if (type == UserInterface::MESSAGE_TYPE_ERROR) { - wattron(stdscr, COLOR_PAIR(ERROR_PAIR) | A_BOLD); - mvprintw(current_line, 2, "❌ %s", line_it->c_str()); - wattroff(stdscr, COLOR_PAIR(ERROR_PAIR) | A_BOLD); - } else if (type == UserInterface::MESSAGE_TYPE_SUCCESS) { - wattron(stdscr, A_BOLD); - mvprintw(current_line, 2, "✅ %s", line_it->c_str()); - wattroff(stdscr, A_BOLD); - } else { - mvprintw(current_line, 2, "%s", line_it->c_str()); - } - - current_line--; - } - } - - refresh(); -} - -/** - * @brief Spawns the background rendering thread and sets the thread_running flag. - * - * Sets thread_running to true and creates a new std::thread that invokes render_loop(). - * The thread is joinable and must be cleaned up via stop_render_thread() or equivalent. - * - * @note Call this after init_ncurses() to begin continuous UI rendering. - * @see render_loop(), stop_render_thread() - */ -void UserInterface::start_render_thread(void) { - this->thread_running = true; - this->render_thread = std::thread(&UserInterface::render_loop, this); -} - -/** - * @brief Gracefully shuts down the background rendering thread. - * - * Sets thread_running to false and notifies the condition variable cv_redraw to - * wake the render loop. Joins the thread if it is joinable, ensuring complete cleanup. - * - * @note Call this during session shutdown or before destroying the UserInterface instance. - * @see start_render_thread(), render_loop() - */ -void UserInterface::stop_render_thread(void) { - this->thread_running = false; - this->cv_redraw.notify_one(); - if (this->render_thread.joinable() && this->render_thread.get_id() != std::this_thread::get_id()) { - this->render_thread.join(); - } -} - -/** - * @brief Main rendering loop executed by the background render thread. - * - * Runs continuously while thread_running is true. Uses cv_redraw condition variable - * with a 1500ms timeout to wait efficiently for redraw requests. Handles terminal resize - * events (endwin(), refresh(), update_layout(), cursor recalculation). Conditionally - * redraws UI components (draw_ui, draw_output_area, draw_input_area, draw_spinner) - * when should_redraw is set. All ncurses operations are protected by ui_mutex. - * - * @note This method is the entry point for the render thread and should not be called directly. - * Call start_render_thread() to spawn it. Terminate via stop_render_thread(). - * @see start_render_thread(), stop_render_thread(), draw_ui(), draw_output_area(), draw_input_area(), draw_spinner() - */ -void UserInterface::render_loop(void) { - while (this->thread_running) { - std::unique_lock lock(this->ui_mutex); - - // Wait for redraw request or timeout (100ms) - this->cv_redraw.wait_for(lock, std::chrono::milliseconds(1500), [this] { return this->should_redraw.load(); }); - - if (UserInterface::interrupt_flag) { - this->end_session(); - break; - } - - // Handle resize (under lock) - if (UserInterface::resize_flag) { - UserInterface::resize_flag = 0; - endwin(); - refresh(); - this->update_layout(); - - // Recalculate cursor position after resize - input_cursor_x = layout.input_x + 37 + input_buffer.length(); - this->should_redraw = true; // Force redraw after resize - } - - // Redraw UI (all ncurses calls under lock) - if (this->should_redraw) { - this->draw_ui(); - this->draw_output_area(); - this->draw_input_area(); - this->draw_spinner(); - - refresh(); - this->should_redraw = false; - } - - lock.unlock(); - } -} - /** * Constructs a UserInterface instance and initializes member variables. * - * Initializes input_cursor_x to 0 (cursor at leftmost position). - * Initializes layout struct to default-constructed values (all fields zeroed/default). - * Sets use_dark_theme to true, establishing dark theme as the default color scheme preference. - * - * Note: This constructor performs only member initialization and singleton registration. - * Full ncurses initialization (terminal setup, color pair configuration, input handling) is deferred - * to init_ncurses(), which must be called explicitly after construction to establish the UI environment. + * Initializes the TerminalBackend. */ -UserInterface::UserInterface() : input_cursor_x(0), layout({}), use_dark_theme(true) { +UserInterface::UserInterface() { + this->backend = std::make_unique(); } /** @@ -543,188 +59,73 @@ UserInterface::UserInterface() : input_cursor_x(0), layout({}), use_dark_theme(t * * @param dark_theme: Boolean flag to select color scheme; true for dark theme, false for light theme. * - * Initializes input_cursor_x to 0 (cursor at leftmost position). - * Initializes layout struct to default-constructed values (all fields zeroed/default). - * Sets use_dark_theme to the caller-provided dark_theme value, establishing the preferred - * color scheme that will be applied during init_ncurses(). - * - * Note: This constructor performs only member initialization and singleton registration. - * Full ncurses initialization (terminal setup, color pair configuration, input handling) is deferred - * to init_ncurses(), which must be called explicitly after construction to establish the UI environment. + * Initializes the TerminalBackend. */ -UserInterface::UserInterface(bool dark_theme) : input_cursor_x(0), layout({}), use_dark_theme(dark_theme) { +UserInterface::UserInterface(bool dark_theme) { + this->backend = std::make_unique(dark_theme); } /** * Destructs a UserInterface instance and releases ncurses resources. - * - * Calls cleanup_ncurses() to restore terminal settings and release ncurses state. - * This operation calls endwin() to exit ncurses mode and prevent terminal corruption upon program exit. - * - * Nullifies the static instance pointer (UserInterface::instance = nullptr) to clear the singleton reference. - * This prevents accidental use-after-free from static methods or signal handlers attempting to access - * a destroyed UI instance. - * - * Ensures proper terminal state restoration and safe cleanup of the singleton pattern. - * - * Note: Should be called when the UI environment is no longer needed, typically before program termination. */ UserInterface::~UserInterface() { this->end_session(); } /** - * Queries the current running state of the UI application. - * - * @return Boolean indicating whether the rendering thread is actively running. - * - * - true if thread_running == 1 (active/running); - * - * - false if thread_running == 0 (inactive/stopped). - * - * Performs a thread-safe atomic read of the running flag, allowing safe concurrent - * queries from multiple threads without synchronization overhead. - * - * Typically used to determine what output method to use for debug output. - */ -bool UserInterface::is_running() { - return this->thread_running == 1; -} - -/** - * Checks whether a UserInterface is attached and currently running. - * - * Used throughout the application to choose between UI-based output and - * non-interactive output (stdout and stderr). Returns false when no - * UserInterface is set, so callers take the non-interactive path. - * - * @param user_interface The UserInterface to check. May be nullptr. - * - * @return true if user_interface is non-null and its UI is running, false otherwise. - */ -bool UserInterface::ui_running(const std::shared_ptr& user_interface) { - return user_interface != nullptr && user_interface->is_running(); -} - -/** - * Static signal handler for SIGWINCH (terminal resize) events. + * Reports whether the backend's thread is currently running. * - * @param sig Signal number (SIGWINCH); unused but required by signal handler signature. + * The function returns the result of backend->is_running(). It does not + * acquire ui_mutex, and the value can change immediately after the call + * returns. * - * Checks if the singleton UserInterface instance exists. If it does, sets the atomic - * resize_flag to 1, signaling to the main event loop that a terminal resize has occurred - * and the layout must be recalculated. + * @return true if the backend thread is running, false otherwise. * - * Re-registers this handler with signal(SIGWINCH, ...) to ensure continued handling of - * subsequent resize events. This re-registration is necessary on systems where signal handlers - * are one-time (non-persistent) and must be re-armed after each invocation. + * @pre backend is not null. * - * Uses atomic flag-setting rather than directly invoking layout recalculation, deferring - * resize handling to the main event loop to avoid complex operations within signal context. - * Provides defensive null-check of the singleton instance in case the handler is invoked - * after the UI is destroyed. - * - * Enables responsive terminal resizing without blocking signal delivery or causing - * signal-safe function violations. + * @note Use this for status checks only. Do not use it to decide whether to + * start or stop the backend without coordinating through another + * synchronization mechanism, because the state can change between the + * check and the action. */ -void UserInterface::handle_resize(int sig) { - UserInterface::resize_flag = 1; - - signal(SIGWINCH, UserInterface::handle_resize); +bool UserInterface::backend_running() { + return this->backend->is_running(); } /** - * Static signal handler for SIGINT (interrupt) signal. - * - * @param sig Signal number (SIGINT); unused but required by signal handler signature. - * - * Requests a graceful shutdown when the user presses Ctrl+C. The handler only sets the - * volatile sig_atomic_t interrupt_flag, which is the only operation guaranteed to be safe - * inside a signal handler. It does not call ncurses functions, lock mutexes, touch the - * singleton instance, or call exit(). - * - * The render thread checks interrupt_flag on each iteration, sets thread_running to false, - * and exits its loop. The thread then ends before the UserInterface destructor runs, which - * performs the ncurses cleanup (endwin()) and restores the terminal to its original state. - * - * Because the handler does not terminate the process, destructors run normally and resources - * are released through the usual RAII path. - * - * Does not return a value and does not exit the process. - */ -void UserInterface::handle_interrupt(int sig) { - UserInterface::interrupt_flag = 1; -} - -/** - * Initializes and starts the UserInterface subsystem. - * - * Performs full initialization pipeline: calls init_ncurses() to set up the ncurses - * library, terminal settings, and signal handlers; calls draw_ui() to render the initial - * full-terminal Unicode box border; and calls start_render_thread() to spawn the background rendering thread. - * - * The rendering thread continuously redraws the UI (output area, input area, spinner) - * in response to state changes and terminal resize events (resize_flag). - * - * No parameters; operates on the current UserInterface instance state. - * - * @return void + * Runs the user interface by delegating to the backend. * - * Initialization status is communicated via successful completion (or - * program termination if init_ncurses() encounters a fatal error). + * The function calls backend->run() and does nothing else. The behavior, + * including whether and when the call returns, is defined by + * TerminalBackend::run(). * - * Acts as the entry point for the UI system. Should be called once after UserInterface - * instantiation and before any event processing or input handling. + * @pre backend is not null. * - * Enables the interactive terminal UI with responsive rendering and asynchronous - * screen updates via the background thread, while the main thread handles user input - * and application logic. + * @note Any blocking, error handling, or session termination happens inside + * TerminalBackend::run(). Refer to that method's documentation for those + * details. */ void UserInterface::run(void) { - this->init_ncurses(); - - this->draw_ui(); - - this->start_render_thread(); + this->backend->run(); } /** - * Gracefully terminates the UserInterface subsystem and exits the application. - * - * @param code Exit status code passed to exit(); typically EXIT_SUCCESS (0) or EXIT_FAILURE (1). - * - * Performs an orderly shutdown sequence: prints a farewell message ("🏁 The session has ended."), - * sleeps for 1000 milliseconds to ensure the message is visible on screen, stops the background - * render thread via stop_render_thread(), restores terminal settings via cleanup_ncurses(), - * and exits the process with the provided exit_code. - * - * The delay (napms) between printing and thread shutdown allows the message to be displayed - * before the render thread is terminated and the screen is cleared, ensuring the user sees - * the farewell notification. - * - * Guarantees terminal restoration even when exiting via this controlled path, preventing - * the terminal from being left in a corrupted ncurses state. The cleanup_ncurses() call - * ensures endwin() is invoked to restore terminal settings (cursor, echo, etc.). - * - * Acts as the primary exit point for controlled application termination. Should be called - * in response to user quit command (q/Q) or successful completion of application logic. + * Shows a session-ended message, ends the backend session. * - * Terminates the process immediately with exit(); does not return to caller. + * The function calls print_message() with the end-of-session message, calls + * backend->end_session() to stop the render thread and restore the terminal, + * and then calls std::exit(code). The function does not return. * - * Enables clean, user-facing shutdown with guaranteed terminal restoration and thread cleanup. + * @pre backend is not null. */ void UserInterface::end_session(int code) { - this->print("🏁 The session has ended."); + this->print_message("🏁 The session has ended."); - napms(1000); - - this->stop_render_thread(); - - if (!this->terminal_restored.exchange(true)) { - this->cleanup_ncurses(); + if (this->backend->is_running()) { + this->backend->end_session(code); + } else { + throw ExitRequest{code}; } - - std::exit(code); } /** @@ -744,7 +145,7 @@ void UserInterface::end_session(int code) { * Boolean parameters are converted to human-readable "true" or "false" strings using conditional * operators, then concatenated into the buffer. * - * Prints the formatted buffer via this->print() with MESSAGE_TYPE_NORMAL styling, displaying + * Prints the formatted buffer via this->print_message(), displaying * the configuration summary in the output area for user visibility. * * @return void @@ -770,7 +171,7 @@ void UserInterface::print_args(const std::string& model, const std::string& embe buffer += "🛈 dark_theme = " + std::string(dark_theme ? "true" : "false") + "\n"; buffer += "🛈 skip_embedding = " + std::string(skip_embedding ? "true" : "false") + "\n"; - this->print(buffer, UserInterface::MESSAGE_TYPE_NORMAL); + this->print_message(buffer); } /** @@ -837,15 +238,13 @@ void UserInterface::print_args_noninteractive(const std::string& model, const st * "[index] model_name" on a separate line, where index is zero-based and automatically * converted from the loop counter using std::to_string(). * - * Outputs the constructed buffer to the ncurses UI using print() with MESSAGE_TYPE_NORMAL - * styling, ensuring the model list appears in the output area and is preserved in the UI's + * Outputs the constructed buffer to the ncurses UI using print_message() styling, + * ensuring the model list appears in the output area and is preserved in the UI's * output_buffer for proper rendering and scrolling. * * @note This method assumes models is not empty; calling with an empty vector will display * only the header message without any selectable options. Typically called after successfully * retrieving the model list from the Ollama server via query_model_list(). - * - * @see print(), MESSAGE_TYPE_NORMAL */ void UserInterface::print_model_list(const std::vector& models) { std::string buffer; @@ -854,7 +253,7 @@ void UserInterface::print_model_list(const std::vector& models) { buffer += "[" + std::to_string(i) + "] " + models[i] + "\n"; } - this->print(buffer, UserInterface::MESSAGE_TYPE_NORMAL); + this->print_message(buffer); } /** @@ -902,35 +301,35 @@ void UserInterface::select_model(const std::vector& models, std::st if (models.size() == 0) { this->print_error("There are no available models to select from.\n"); - std::exit(EXIT_FAILURE); + this->end_session(EXIT_FAILURE); } this->request_redraw(); - this->input_buffer.clear(); + this->backend->clear_input_buffer(); int ch; while ((ch = getch()) != '\n') { if (ch == KEY_BACKSPACE || ch == 127) { - if (!input_buffer.empty()) { - input_buffer.pop_back(); - input_cursor_x--; + if (!this->backend->input_buffer_is_empty()) { + this->backend->pop_back_input_buffer(); + this->backend->shift_input_cursor_x(-1); this->request_redraw(); } } else if (ch != ERR && isprint(ch)) { - if (input_buffer.length() < COLS - 40) { // Leave room for prompt - input_buffer.push_back(ch); - input_cursor_x++; + if (this->backend->room_for_prompt_in_input_buffer()) { + this->backend->push_back_input_buffer(ch); + this->backend->shift_input_cursor_x(1); this->request_redraw(); } } } - if (this->input_buffer == "q" || this->input_buffer == "Q") { + if (this->backend->exit_keys_in_input_buffer()) { this->end_session(EXIT_SUCCESS); } - index = stoi(this->input_buffer); + index = stoi(std::string(this->backend->get_input_buffer())); if (index < 0 || index >= models.size()) { std::string message = std::format("Model index {} does not exist.\n", index); @@ -964,7 +363,9 @@ void UserInterface::select_model_noninteractive(const std::vector& if (models.size() == 0) { UserInterface::print_error_noninteractive("There are no available models to select from.\n"); - std::exit(EXIT_FAILURE); + if (auto ui = instance.lock()) { + ui->end_session(EXIT_FAILURE); + } } while (read_result != 1 || index < 0 || index >= models.size()) { @@ -979,44 +380,62 @@ void UserInterface::select_model_noninteractive(const std::vector& } /** - * @brief Captures the next user prompt via interactive keyboard input and detects quit commands. + * Reads one line of user input into the prompt and reports whether it is an + * exit request. * - * @param prompt A pointer to a string where the captured prompt will be stored. + * The function requests a redraw, clears the input buffer, and then reads keys + * with getch() until Enter ('\n') is pressed. Backspace removes the last + * character and moves the cursor left. A printable character is appended only + * if room_for_prompt_in_input_buffer() is true, and it moves the cursor right. + * Each change requests a redraw. When Enter is pressed, the buffer contents are + * copied into *prompt. The function returns true if the text is exactly "q" or + * "Q". * - * @return bool True if the user entered "q" or "Q" (quit command); false otherwise. + * @param prompt Output string that receives the entered text. Must not be null. * - * Clears the input buffer and enters a getch() loop to capture keyboard input, supporting - * backspace (KEY_BACKSPACE or 127) and printable characters with length constraints. - * Input terminates on newline (Enter key). Copies the input buffer to prompt and returns - * true if the prompt is a quit command, signaling session termination. + * @return true if the entered text is "q" or "Q", otherwise false. * - * @note Input buffer length is capped at COLS - 40. Requests a UI redraw on each keypress. + * @pre prompt is not null. + * @post *prompt holds the text the user entered. The input buffer still holds + * the same text. + * + * @note getch() blocks until a key is pressed. Non-printable keys, such as + * arrow keys, are ignored. An ERR return is also ignored, but only + * because it fails the isprint() test. In non-blocking mode, this loop + * would busy-wait. + * @note The input buffer is modified without holding ui_mutex. The render thread + * can read it at the same time, so this is a data race unless the caller + * guarantees that the render thread is not reading the buffer. */ bool UserInterface::next_prompt(std::string* prompt) { bool done = false; this->request_redraw(); - this->input_buffer.clear(); + this->backend->clear_input_buffer(); + + timeout(100); // getch() returns ERR after 100 ms if no key is pressed int ch; while ((ch = getch()) != '\n') { if (ch == KEY_BACKSPACE || ch == 127) { - if (!input_buffer.empty()) { - input_buffer.pop_back(); - input_cursor_x--; + if (!this->backend->input_buffer_is_empty()) { + this->backend->pop_back_input_buffer(); + this->backend->shift_input_cursor_x(-1); this->request_redraw(); } } else if (ch != ERR && isprint(ch)) { - if (input_buffer.length() < COLS - 40) { - input_buffer.push_back(ch); - input_cursor_x++; + if (this->backend->room_for_prompt_in_input_buffer()) { + this->backend->push_back_input_buffer(ch); + this->backend->shift_input_cursor_x(1); this->request_redraw(); } + } else if (ch == ERR && !this->backend->is_running()) { + return true; } } - *prompt = this->input_buffer; + *prompt = this->backend->get_input_buffer(); if (*prompt == "q" || *prompt == "Q") { done = true; @@ -1044,7 +463,9 @@ bool UserInterface::next_prompt_noninteractive(std::string* prompt) { printf("⏳ Type your prompt (q/Q to quit): "); if (!std::getline(std::cin, *prompt, '\n')) { fprintf(stderr, "Error reading user input.\n"); - std::exit(EXIT_FAILURE); + if (auto ui = instance.lock()) { + ui->end_session(EXIT_FAILURE); + } } if (*prompt == "q" || *prompt == "Q") { @@ -1054,52 +475,22 @@ bool UserInterface::next_prompt_noninteractive(std::string* prompt) { return done; } -/** - * @brief Queues a typed message to the output buffer and requests a UI redraw. - * - * @param text The message content to be displayed. - * @param type The message type (MESSAGE_TYPE_NORMAL, MESSAGE_TYPE_ERROR, MESSAGE_TYPE_SUCCESS) that controls styling. - * Defaults to MESSAGE_TYPE_NORMAL if not specified. - * @param stream In the stream mode, every chunk of text after the first one is appended to the last buffer entry. - * Defaults to 'false'. - * - * Thread-safe: acquires ui_mutex before adding the message to output_buffer. - * Automatically requests a redraw via request_redraw() to display the new message. - * - * @see draw_output_area() - */ -void UserInterface::print(const std::string& text, int type, bool stream) { - { - std::lock_guard lock(this->ui_mutex); - if (stream == true && !output_buffer.empty()) { - output_buffer.back().first += text; - } else { - output_buffer.push_back({text, type}); - } - } - this->request_redraw(); -} - /** * @brief Queues an error message (bold red with ❌ prefix) and requests a redraw. * * @param text The error message content. - * - * @see print() */ void UserInterface::print_error(const std::string& text) { - this->print(text, UserInterface::MESSAGE_TYPE_ERROR); + this->backend->print(text, TerminalBackend::MESSAGE_TYPE_ERROR); } /** * @brief Queues a success message (bold with ✅ prefix) and requests a redraw. * * @param text The success message content. - * - * @see print() */ void UserInterface::print_success(const std::string& text) { - this->print(text, UserInterface::MESSAGE_TYPE_SUCCESS); + this->backend->print(text, TerminalBackend::MESSAGE_TYPE_SUCCESS); } /** @@ -1108,11 +499,9 @@ void UserInterface::print_success(const std::string& text) { * @param text The message content. * @param stream In the stream mode, every chunk of text after the first one is appended to the last buffer entry. * Defaults to 'false'. - * - * @see print() */ void UserInterface::print_message(const std::string& text, bool stream) { - this->print(text, UserInterface::MESSAGE_TYPE_NORMAL, stream); + this->backend->print(text, TerminalBackend::MESSAGE_TYPE_NORMAL, stream); } /** @@ -1166,92 +555,130 @@ void UserInterface::print_message_noninteractive(const std::string& text) { } /** - * @brief Signals the render thread to perform a UI redraw on the next cycle. + * Requests a redraw and wakes a thread waiting on cv_redraw. + * + * The function sets should_redraw to true through set_should_redraw(true), then + * calls notify_cv_redraw() with its default argument. The method does not + * acquire ui_mutex. * - * Sets should_redraw to true and notifies cv_redraw to wake the render loop, - * ensuring a timely UI update without waiting for the 1500ms timeout. + * @pre backend is not null. + * @post should_redraw is true, and a waiter on cv_redraw has been notified. * - * @note Thread-safe: uses atomic operations and condition variable signaling. - * @see render_loop() + * @note The flag is set before the notification, and there is no lock between + * them. A waiting thread can wake, see the flag, and proceed before the + * notification is sent. The waiter can then miss the signal and go back + * to sleep, or it can process the flag without needing the notification. + * @warning The waiter's predicate must be checked under ui_mutex. Otherwise a + * notification sent between the waiter's check and its wait can be + * lost. Whether one or all waiters are woken depends on the default + * argument of notify_cv_redraw(), which should be documented at that + * declaration. */ void UserInterface::request_redraw(void) { - this->should_redraw = true; - this->cv_redraw.notify_one(); + this->backend->set_should_redraw(true); + this->backend->notify_cv_redraw(); } /** - * @brief Activates the spinner animation and triggers a UI redraw. + * Starts the spinner from frame 0 and requests a redraw. * - * Acquires ui_mutex, sets spinner_active to true, resets spinner_frame to 0, - * and sets should_redraw to true to display the spinner on the next render cycle. + * The function sets spinner_active to true, resets the spinner frame to 0 + * through set_spinner_frame(0), and sets should_redraw to true. Each setter is + * atomic on its own, but the three updates are not performed as a group and + * ui_mutex is not held. cv_redraw is not notified. * - * @note Thread-safe: acquires exclusive lock via lock_guard before modifying state. - * @see stop_spinner(), draw_spinner() + * @pre backend is not null. + * @post get_spinner_active() returns true, the spinner frame is 0, and + * should_redraw is true. + * + * @note A render thread can read the spinner state between the updates and see + * a partial result, such as an active spinner that still shows the + * previous frame. + * @warning If the three updates must appear together, hold ui_mutex across all + * of them with a single scoped lock. Call notify_cv_redraw() afterward + * if a waiting thread needs to wake up. */ void UserInterface::start_spinner() { - std::lock_guard lock(ui_mutex); - this->spinner_active = true; - this->spinner_frame = 0; - this->should_redraw = true; + this->backend->set_spinner_active(true); + this->backend->set_spinner_frame(0); + this->backend->set_should_redraw(true); } /** - * @brief Deactivates the spinner animation and triggers a UI redraw. + * Stops the spinner, resets its frame to 0, and requests a redraw. + * + * The function sets spinner_active to false, resets the spinner frame to 0 + * through set_spinner_frame(0), and sets should_redraw to true. Each setter is + * atomic on its own, but the three updates are not performed as a group and + * ui_mutex is not held. cv_redraw is not notified. * - * Acquires ui_mutex, sets spinner_active to false, resets spinner_frame to 0, - * and sets should_redraw to true to clear the spinner on the next render cycle. + * @pre backend is not null. + * @post get_spinner_active() returns false, the spinner frame is 0, and + * should_redraw is true. * - * @note Thread-safe: acquires exclusive lock via lock_guard before modifying state. - * @see start_spinner(), draw_spinner() + * @note A render thread can read the spinner state between the updates and see + * a partial result, such as an inactive spinner with the previous frame + * still set. + * @warning If the three updates must appear together, hold ui_mutex across all + * of them with a single scoped lock. Call notify_cv_redraw() afterward + * if a waiting thread needs to wake up. */ void UserInterface::stop_spinner() { - std::lock_guard lock(ui_mutex); - this->spinner_active = false; - this->spinner_frame = 0; - this->should_redraw = true; + this->backend->set_spinner_active(false); + this->backend->set_spinner_frame(0); + this->backend->set_should_redraw(true); } /** - * @brief Increments the spinner frame counter if the spinner is active. + * Advances the spinner to its next frame and requests a redraw, if the spinner + * is active. * - * If spinner_active is true, advances spinner_frame with modulo 4 (cycling - * through frames 0–3 to rotate the spinner characters |, /, -, \) and signals - * a redraw to display the new frame. + * The function first checks get_spinner_active(). If the spinner is running, it + * advances the frame through set_spinner_frame() and then sets should_redraw. + * Nothing happens if the spinner is inactive. The check and the update are not + * performed under a lock, and the method does not notify cv_redraw. * - * @note Called by render loop to update spinner animation each cycle. - * @see is_spinner_active(), draw_spinner() + * @pre backend is not null. + * @post If the spinner was active at the check, its frame has advanced by one + * step and should_redraw is true. + * + * @note The check and the update are not atomic together. If the spinner is + * deactivated between them, the frame can still advance once after + * deactivation. This is usually harmless for a visual effect. + * @warning Because cv_redraw is not notified, a waiting thread may not redraw + * until it is woken by another call. Use notify_cv_redraw() if an + * immediate redraw is needed. */ void UserInterface::advance_spinner_frame() { - if (this->is_spinner_active()) { - this->spinner_frame = (spinner_frame + 1) % 4; - this->should_redraw = true; + if (this->backend->get_spinner_active()) { + this->backend->set_spinner_frame(); + this->backend->set_should_redraw(true); } } /** - * @brief Atomically queries whether the spinner animation is currently active. + * Updates the displayed progress percentage and requests a redraw. * - * Returns the current value of the spinner_active atomic flag. + * The value is clamped to the range [0, 100] before being stored in the backend. + * The progress value and the redraw flag are set through two separate atomic + * operations, so the change is not applied as a single unit. This method does + * not acquire ui_mutex and does not notify cv_redraw. * - * @return true if spinner is active, false otherwise. - * @note Thread-safe: uses atomic load for safe concurrent access. - * @see start_spinner(), stop_spinner() - */ -bool UserInterface::is_spinner_active() const { - return spinner_active.load(); -} - -/** - * @brief Updates the progress percentage displayed with the spinner. + * @param percent The requested progress percentage. Values below 0 are stored + * as 0, and values above 100 are stored as 100. * - * Clamps the provided percent value to the range [0, 100] and assigns it to - * progress_percent, then signals a redraw to reflect the updated progress. + * @pre backend is not null. + * @post The backend's progress value is between 0 and 100, inclusive. + * @post should_redraw is set to true. * - * @param percent Progress value to set; automatically clamped to [0, 100]. - * @note Clipping prevents invalid percentage values from being displayed. - * @see draw_spinner() + * @note A thread that reads the progress value between the two calls can see + * the new percentage before the redraw request is set. This is usually + * harmless but can cause a brief inconsistency. + * @warning Because cv_redraw is not notified, a thread waiting on it may not + * wake up to redraw. Callers that need an immediate redraw must notify + * it separately with notify_cv_redraw(). */ void UserInterface::set_progress(int percent) { - this->progress_percent = std::clamp(percent, 0, 100); - this->should_redraw = true; + this->backend->set_progress_percent(std::clamp(percent, 0, 100)); + this->backend->set_should_redraw(true); } diff --git a/src/interface/user_interface.hpp b/src/interface/user_interface.hpp index e0df115..d478986 100644 --- a/src/interface/user_interface.hpp +++ b/src/interface/user_interface.hpp @@ -1,6 +1,6 @@ #pragma once -#include "utils/text_utils.hpp" +#include "terminal_backend.hpp" #include #include #include @@ -11,30 +11,6 @@ #include #include -/** - * @brief UI layout positioning configuration. - * - * Stores calculated row and column positions for all major UI elements - * relative to terminal dimensions. Updated by update_layout() when terminal - * is resized. - */ -struct Layout { - // Row for input prompt. - int input_y; - // Column for input start. - int input_x; - // Row where output begins. - int output_y; - // Output area height. - int output_height; - // Row for status messages. - int status_y; - // Row for the progress spinner. - int spinner_y; - // Column for the progress spinner. - int spinner_x; -}; - /** * @brief ncurses-based terminal user interface for interactive prompting and output display. * @@ -55,84 +31,11 @@ struct Layout { */ class UserInterface { private: - // Message configuration constants. - - // Plain text, default styling. - static constexpr int MESSAGE_TYPE_NORMAL = 0; - // Bold red with ❌ prefix. - static constexpr int MESSAGE_TYPE_ERROR = 1; - // Bold with ✅ prefix. - static constexpr int MESSAGE_TYPE_SUCCESS = 2; - - // User Interface state. - - static volatile sig_atomic_t resize_flag; - static volatile sig_atomic_t interrupt_flag; // A pointer to the user interface instance. static std::weak_ptr instance; - // Layout state. - struct Layout layout; - - // Input buffer state. - std::string input_buffer; - int input_cursor_x; - - // Output buffer state. - std::vector> output_buffer; - - // Renderer state. - std::thread render_thread; - std::mutex ui_mutex; - std::condition_variable cv_redraw; - std::atomic should_redraw{true}; - std::atomic thread_running{false}; - - std::atomic terminal_restored{false}; - - // Spinner state. - std::atomic spinner_active{false}; - std::atomic spinner_frame{0}; - std::atomic progress_percent{0}; - - // Color theme configuration. - bool use_dark_theme = true; - - // Initialization methods. - - void init_dark_theme(void); - - void init_light_theme(void); - - void init_ncurses(void); - - void cleanup_ncurses(void); - - // Layout manpulation methods. - - void draw_ui(void); - - void draw_spinner(void); - - void update_layout(); - - // Input area methods. - - void clear_input_area(); - - void draw_input_area(); - - // Output area methods. - - void draw_output_area(); - - // Renderer methods. - - void start_render_thread(void); - - void stop_render_thread(void); - - void render_loop(void); + // A pointer to the terminal backend instance. + std::unique_ptr backend; public: UserInterface(); @@ -145,13 +48,7 @@ class UserInterface { static std::shared_ptr create(bool dark_theme); - bool is_running(); - - static bool ui_running(const std::shared_ptr& user_interface); - - static void handle_resize(int sig); - - static void handle_interrupt(int sig); + bool backend_running(); void run(void); @@ -176,8 +73,6 @@ class UserInterface { static bool next_prompt_noninteractive(std::string* prompt); - void print(const std::string& text, int type = UserInterface::MESSAGE_TYPE_NORMAL, bool stream = false); - void print_error(const std::string& text); static void print_error_noninteractive(const std::string& text); @@ -190,7 +85,7 @@ class UserInterface { static void print_message_noninteractive(const std::string& text); - // Rendered methods + // Renderer methods void request_redraw(void); @@ -202,8 +97,6 @@ class UserInterface { void advance_spinner_frame(void); - bool is_spinner_active(void) const; - void set_progress(int percent); // Grant friend access to ProgressSpinner diff --git a/src/main.cpp b/src/main.cpp index 49822f9..34a9b13 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include "utils/args_utils.hpp" #include "utils/curl_utils.hpp" #include "utils/embedding_utils.hpp" +#include "utils/exit_request.hpp" #include "utils/retrieving_utils.hpp" #include "utils/system_utils.hpp" #include "utils/text_utils.hpp" @@ -12,8 +13,6 @@ #include int main(int argc, const char* argv[]) { - bool done = false; - std::string message{}; ArgsUtils args_utils; struct ParsedArgs parsed_args = args_utils.parse_args(argc, argv); @@ -22,160 +21,170 @@ int main(int argc, const char* argv[]) { args_utils.print_help(); } - auto ui = UserInterface::create(parsed_args.dark_theme); - if (parsed_args.interactive == true) { - message = std::format("\nInteractive mode debug info.\n" - "---\n" - "isatty(0)={}, isatty(1)={}, isatty(2)={}\n" - "TERM={}\n" - "TERMINFO={}\n" - "LC_ALL={}\n" - "LANG={}\n" - "---\n", - isatty(0), isatty(1), isatty(2), getenv("TERM") ? getenv("TERM") : "(not set)", - getenv("TERMINFO") ? getenv("TERMINFO") : "(not set)", - getenv("LC_ALL") ? getenv("LC_ALL") : "(not set)", - getenv("LANG") ? getenv("LANG") : "(not set)"); - - UserInterface::print_message_noninteractive(message); - - ui->run(); - ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, - parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, parsed_args.skip_embedding); - - while (parsed_args.prompt.length() == 0) { - done = ui->next_prompt(&parsed_args.prompt); - - if (done == true) { - ui->end_session(EXIT_SUCCESS); - } - } - } else { - UserInterface::print_args_noninteractive(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, - parsed_args.stream, parsed_args.rag, parsed_args.interactive, - parsed_args.dark_theme, parsed_args.skip_embedding); + try { + bool done = false; + std::string message{}; - if (parsed_args.prompt.length() == 0) { - done = UserInterface::next_prompt_noninteractive(&parsed_args.prompt); + auto ui = UserInterface::create(parsed_args.dark_theme); - if (parsed_args.prompt.length() == 0) { - UserInterface::print_message_noninteractive("\n🏁 Empty prompt. The session has ended.\n"); - std::exit(EXIT_SUCCESS); - } + if (parsed_args.interactive == true) { + message = std::format("\nInteractive mode debug info.\n" + "---\n" + "isatty(0)={}, isatty(1)={}, isatty(2)={}\n" + "TERM={}\n" + "TERMINFO={}\n" + "LC_ALL={}\n" + "LANG={}\n" + "---\n", + isatty(0), isatty(1), isatty(2), getenv("TERM") ? getenv("TERM") : "(not set)", + getenv("TERMINFO") ? getenv("TERMINFO") : "(not set)", + getenv("LC_ALL") ? getenv("LC_ALL") : "(not set)", + getenv("LANG") ? getenv("LANG") : "(not set)"); + + UserInterface::print_message_noninteractive(message); - if (done == true) { - UserInterface::print_message_noninteractive("\n🏁 The session has ended.\n"); - std::exit(EXIT_SUCCESS); + ui->run(); + ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, + parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, + parsed_args.skip_embedding); + + while (parsed_args.prompt.length() == 0) { + done = ui->next_prompt(&parsed_args.prompt); + + if (done == true) { + ui->end_session(EXIT_SUCCESS); + } } - } - } + } else { + UserInterface::print_args_noninteractive(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, + parsed_args.stream, parsed_args.rag, parsed_args.interactive, + parsed_args.dark_theme, parsed_args.skip_embedding); - auto curl = std::make_shared(ui); + if (parsed_args.prompt.length() == 0) { + done = UserInterface::next_prompt_noninteractive(&parsed_args.prompt); - auto system_utils = std::make_shared(ui); + if (parsed_args.prompt.length() == 0) { + UserInterface::print_message_noninteractive("\n🏁 Empty prompt. The session has ended.\n"); + throw ExitRequest{EXIT_FAILURE}; + } - auto storage_client = std::make_shared(ui, curl, system_utils); + if (done == true) { + UserInterface::print_message_noninteractive("\n🏁 The session has ended.\n"); + throw ExitRequest{EXIT_FAILURE}; + } + } + } - auto ollama_config = std::make_shared(); + auto curl = std::make_shared(ui); - auto ollama_client = std::make_shared(ollama_config, ui, curl, system_utils); + auto system_utils = std::make_shared(ui); - auto embedding_utils = std::make_shared(ui, curl, ollama_client, storage_client); + auto storage_client = std::make_shared(ui, curl, system_utils); - auto retrieving_config = std::make_shared(); + auto ollama_config = std::make_shared(); - auto retrieving_utils = - std::make_shared(retrieving_config, ui, curl, ollama_client, storage_client); + auto ollama_client = std::make_shared(ollama_config, ui, curl, system_utils); - std::vector models = ollama_client->query_model_list(); - bool force_model_selection = true; - for (size_t i = 0; i < models.size(); i += 1) { - if (models[i] == parsed_args.model) { - force_model_selection = false; - } - } + auto embedding_utils = std::make_shared(ui, curl, ollama_client, storage_client); - if (force_model_selection == true) { - message = "⚠ A Language model is not selected.\n"; - if (parsed_args.interactive == true) { - ui->print_message(message); - ui->print_model_list(models); - ui->select_model(models, &parsed_args.model); - ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, - parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, - parsed_args.skip_embedding); - } else { - UserInterface::print_message_noninteractive(message); - UserInterface::print_model_list_noninteractive(models); - UserInterface::select_model_noninteractive(models, &parsed_args.model); - UserInterface::print_args_noninteractive(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, - parsed_args.stream, parsed_args.rag, parsed_args.interactive, - parsed_args.dark_theme, parsed_args.skip_embedding); - } - } + auto retrieving_config = std::make_shared(); - if (parsed_args.skip_embedding == false) { - bool force_embedding_model_selection = true; + auto retrieving_utils = + std::make_shared(retrieving_config, ui, curl, ollama_client, storage_client); + + std::vector models = ollama_client->query_model_list(); + bool force_model_selection = true; for (size_t i = 0; i < models.size(); i += 1) { - if (models[i] == parsed_args.embedding_model) { - force_embedding_model_selection = false; + if (models[i] == parsed_args.model) { + force_model_selection = false; } } - if (force_embedding_model_selection == true) { - message = "⚠ An Embedding model is not selected.\n"; + + if (force_model_selection == true) { + message = "⚠ A Language model is not selected.\n"; if (parsed_args.interactive == true) { ui->print_message(message); ui->print_model_list(models); - ui->select_model(models, &parsed_args.embedding_model); + ui->select_model(models, &parsed_args.model); ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, parsed_args.skip_embedding); } else { UserInterface::print_message_noninteractive(message); UserInterface::print_model_list_noninteractive(models); - UserInterface::select_model_noninteractive(models, &parsed_args.embedding_model); + UserInterface::select_model_noninteractive(models, &parsed_args.model); UserInterface::print_args_noninteractive( parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, parsed_args.skip_embedding); } } - } - ollama_config->model = parsed_args.model; - ollama_config->embedding_model = parsed_args.embedding_model; - ollama_config->prompt = parsed_args.prompt; - ollama_config->stream = parsed_args.stream; + if (parsed_args.skip_embedding == false) { + bool force_embedding_model_selection = true; + for (size_t i = 0; i < models.size(); i += 1) { + if (models[i] == parsed_args.embedding_model) { + force_embedding_model_selection = false; + } + } + if (force_embedding_model_selection == true) { + message = "⚠ An Embedding model is not selected.\n"; + if (parsed_args.interactive == true) { + ui->print_message(message); + ui->print_model_list(models); + ui->select_model(models, &parsed_args.embedding_model); + ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, + parsed_args.stream, parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, + parsed_args.skip_embedding); + } else { + UserInterface::print_message_noninteractive(message); + UserInterface::print_model_list_noninteractive(models); + UserInterface::select_model_noninteractive(models, &parsed_args.embedding_model); + UserInterface::print_args_noninteractive( + parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, + parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, parsed_args.skip_embedding); + } + } + } - if (parsed_args.skip_embedding == false) { - std::vector urls = { - "https://raw.githubusercontent.com/rfprod/nx-ng-starter/refs/heads/main/README.md"}; - embedding_utils->create_embedings(urls); - } + ollama_config->model = parsed_args.model; + ollama_config->embedding_model = parsed_args.embedding_model; + ollama_config->prompt = parsed_args.prompt; + ollama_config->stream = parsed_args.stream; - if (parsed_args.rag == false) { - ollama_client->query_model(); - } else { - retrieving_utils->run(); - } + if (parsed_args.skip_embedding == false) { + std::vector urls = { + "https://raw.githubusercontent.com/rfprod/nx-ng-starter/refs/heads/main/README.md"}; + embedding_utils->create_embedings(urls); + } - if (parsed_args.interactive == true) { - while (done == false) { - done = ui->next_prompt(&parsed_args.prompt); - if (done == true) { - continue; - } + if (parsed_args.rag == false) { + ollama_client->query_model(); + } else { + retrieving_utils->run(); + } - ollama_config->prompt = parsed_args.prompt; - if (parsed_args.rag == false) { - ollama_client->query_model(); - } else { - retrieving_utils->run(); + if (parsed_args.interactive == true) { + while (done == false) { + done = ui->next_prompt(&parsed_args.prompt); + if (done == true) { + continue; + } + + ollama_config->prompt = parsed_args.prompt; + if (parsed_args.rag == false) { + ollama_client->query_model(); + } else { + retrieving_utils->run(); + } + ui->request_redraw(); } - ui->request_redraw(); + ui->end_session(EXIT_SUCCESS); + } else { + UserInterface::print_message_noninteractive("\n🏁 The session has ended.\n"); } - ui->end_session(EXIT_SUCCESS); - } else { + } catch (const ExitRequest& req) { UserInterface::print_message_noninteractive("\n🏁 The session has ended.\n"); + return req.code(); } return EXIT_SUCCESS; diff --git a/src/ollama/ollama_client.cpp b/src/ollama/ollama_client.cpp index 23707d8..244072c 100644 --- a/src/ollama/ollama_client.cpp +++ b/src/ollama/ollama_client.cpp @@ -34,7 +34,7 @@ OllamaClient::OllamaClient(std::shared_ptr config, std::shar this->check_ollama(); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive("\n"); } } @@ -143,16 +143,15 @@ void OllamaClient::check_ollama() { if (system("which ollama > /dev/null 2>&1")) { message = "Ollama binary does not exist.\nInstallation instructions: " "https://docs.ollama.com/linux\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } else { message = "Ollama binary exists.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -194,7 +193,7 @@ void OllamaClient::process_response(std::string response) { try { json response_json = json::parse(response); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { std::string message = std ::format( "🦙 llama:\n{}\n---\n", this->trim_response(response_json["response"].get()).c_str()); UserInterface::print_message_noninteractive(message); @@ -205,13 +204,12 @@ void OllamaClient::process_response(std::string response) { } } catch (const json::exception& err) { std::string message = std::format("JSON parse error: {}\n", err.what()); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } } @@ -236,7 +234,7 @@ void OllamaClient::process_response(std::string response) { */ void OllamaClient::process_response_stream(const std::string& line) { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] process_response_stream called with line: {}\n", line.substr(0, 100))); } @@ -251,7 +249,7 @@ void OllamaClient::process_response_stream(const std::string& line) { bool is_streaming = !first_chunk; first_chunk = false; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(text); } else { this->ui->print_message(text, is_streaming); @@ -298,7 +296,7 @@ std::vector> OllamaClient::generate_embeddings(const std::vec std::string response = this->curl->fetch_web_content(this->urls.embed_url, post_data); #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] generate_embeddings response {}\n", response)); } @@ -326,7 +324,7 @@ std::vector> OllamaClient::generate_embeddings(const std::vec void OllamaClient::query_model(std::optional config_opt) { std::string message = std::format("💬 user: {}\n", config_opt != std::nullopt ? config_opt->prompt.c_str() : config->prompt.c_str()); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -349,7 +347,7 @@ void OllamaClient::query_model(std::optional config_opt) { this->process_response_stream(chunk); }); message = std::format("Stream successful: {}, Status: {}\n", response.success, response.status); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -386,17 +384,16 @@ std::vector OllamaClient::query_model_list() { if (!data.contains("models") || !data["models"].is_array()) { message = "Response has no models array.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } message = "📃 available models:\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -414,7 +411,7 @@ std::vector OllamaClient::query_model_list() { } message = std::format("{}\t ({}) \t{}\n", modified_at, size, name); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -423,13 +420,12 @@ std::vector OllamaClient::query_model_list() { UserInterface::print_message_noninteractive("\n"); } catch (const json::exception& err) { message = std::format("JSON parse error: {}\n", err.what()); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } return models; diff --git a/src/storage/qdrant_client.cpp b/src/storage/qdrant_client.cpp index 6448847..4ce2714 100644 --- a/src/storage/qdrant_client.cpp +++ b/src/storage/qdrant_client.cpp @@ -32,7 +32,7 @@ QdrantClient::QdrantClient(std::shared_ptr ui, std::shared_ptrcheck_qdrant(); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive("\n"); } } @@ -49,7 +49,7 @@ QdrantClient::~QdrantClient() { std::string message{}; message = "🛈 Qdrant docker container cleanup...\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -65,7 +65,7 @@ QdrantClient::~QdrantClient() { this->system_utils->catch_error(status); message = "Done.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -116,7 +116,7 @@ void QdrantClient::setup() { status = system("docker ps | grep -q qdrant"); if (status == 0) { message = "Qdrant is already running. Skipping docker run.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -125,7 +125,7 @@ void QdrantClient::setup() { } message = "🛈 Starting Qdrant container...\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -136,24 +136,22 @@ void QdrantClient::setup() { if (!qdrant_key || std::string(qdrant_key).empty()) { message = "QDRANT_API_KEY not set.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } if (!qdrant_key_readonly || std::string(qdrant_key_readonly).empty()) { message = "QDRANT_API_KEY_READ_ONLY not set.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } const std::string data_dir = std::string(std::getenv("HOME")) + "/qdrant"; @@ -172,24 +170,23 @@ void QdrantClient::setup() { if (system(docker_run_cmd.c_str()) != 0) { message = "Failed to start Qdrant container.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } message = std::format("🛈 Qdrant container started with data dir {}\n", data_dir.c_str()); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); } message = "🛈 Qdrant dashboard is available at http://localhost:6333/dashboard\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -211,21 +208,21 @@ void QdrantClient::check_qdrant() { if (status == 200) { message = "Qdrant is healthy.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); } } else if (status == -1) { message = "Failed to connect to Qdrant.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); } } else { message = std::format("⚠️ Qdrant returned {}.\n", status); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -249,7 +246,7 @@ bool QdrantClient::collection_exists(const std::string& collection_name) { try { this->curl->fetch_web_content(url, std::nullopt, "GET", headers, false); message = std::format("Collection '{}' exists", collection_name); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -257,7 +254,7 @@ bool QdrantClient::collection_exists(const std::string& collection_name) { return true; } catch (...) { message = std::format("⚠️ Collection '{}' does not exist", collection_name); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -280,7 +277,7 @@ void QdrantClient::delete_collection(const std::string& collection_name) { this->curl->fetch_web_content(url, std::nullopt, "DELETE", headers); std::string message = std::format("Deleted collection '{}'\n", collection_name); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -288,7 +285,7 @@ void QdrantClient::delete_collection(const std::string& collection_name) { } catch (const HttpException& e) { std::string message = std::format("[{}] Failed to delete collection '{}': {}\n", e.get_status_code(), collection_name, e.what()); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -316,7 +313,7 @@ void QdrantClient::create_collection(const std::string& collection_name, size_t std::string message = std::format("Created collection '{}' with vector size {}\n", collection_name, vector_size); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -324,7 +321,7 @@ void QdrantClient::create_collection(const std::string& collection_name, size_t } catch (const HttpException& e) { std::string message = std::format("[{}] Failed to create collection '{}': {}\n", e.get_status_code(), collection_name, e.what()); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -349,7 +346,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto if (texts.empty() || embeddings.empty()) { message = "Cannot store: texts or embeddings are empty\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -359,7 +356,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto if (texts.size() != embeddings.size()) { message = std::format("Mismatch: {} texts but {} embeddings\n", texts.size(), embeddings.size()); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -370,7 +367,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto size_t vector_size = embeddings[0].size(); #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Checking collection '{}'\n", collection_name)); } @@ -378,7 +375,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto if (this->collection_exists(collection_name)) { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Collection '{}' exists, deleting...\n", collection_name)); } @@ -387,7 +384,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto } #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Creating collection '{}' with vector size {}\n", collection_name, vector_size)); } @@ -408,7 +405,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto std::map headers{{"api-key", qdrant_key}}; #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Storing {} embeddings to URL: {}\n", texts.size(), url)); } @@ -416,7 +413,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto std::string response = this->curl->fetch_web_content(url, post_data, "PUT", headers); message = std::format("Stored {} embeddings in Qdrant.\nResponse: {}\n", texts.size(), response); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); diff --git a/src/utils/args_utils.cpp b/src/utils/args_utils.cpp index 539d49b..4c2917c 100644 --- a/src/utils/args_utils.cpp +++ b/src/utils/args_utils.cpp @@ -1,4 +1,5 @@ #include "args_utils.hpp" +#include "utils/exit_request.hpp" /** * Initializes the ArgsUtils instance with command-line argument handlers. diff --git a/src/utils/curl_utils.cpp b/src/utils/curl_utils.cpp index cb1cbf7..5290cea 100644 --- a/src/utils/curl_utils.cpp +++ b/src/utils/curl_utils.cpp @@ -51,16 +51,15 @@ void CurlUtils::initialize() { std::string message{}; if (!this->curl) { message = "Failed to initialize CURL.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } else { message = "CURL is initialized.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -85,7 +84,7 @@ void CurlUtils::initialize() { void CurlUtils::cleanup() { if (this->curl) { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] curl pointer before cleanup: {}\n", static_cast(this->curl.get()))); } @@ -94,7 +93,7 @@ void CurlUtils::cleanup() { } if (this->headers) { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(std::format( "\t[DEBUG] curl headers pointer before cleanup: {}\n", static_cast(this->headers.get()))); } @@ -102,7 +101,7 @@ void CurlUtils::cleanup() { this->headers.reset(); } std::string message = "CURL cleanup is complete.\n"; - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -174,7 +173,7 @@ size_t CurlUtils::curl_streaming_write_callback(char* contents, size_t size, siz CurlUtils* curl_obj = static_cast(userp); #ifdef DEBUG - if (UserInterface::ui_running(curl_obj->ui) == false) { + if (curl_obj->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] 🔗 curl_streaming_write_callback called, size: {}\n", realsize)); } @@ -185,7 +184,7 @@ size_t CurlUtils::curl_streaming_write_callback(char* contents, size_t size, siz curl_obj->process_chunk(chunk); } catch (const std::exception& e) { std::string message = std::format("Exception in curl_streaming_write_callback: {}", e.what()); - if (UserInterface::ui_running(curl_obj->ui) == false) { + if (curl_obj->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { curl_obj->ui->print_error(message); @@ -217,7 +216,7 @@ size_t CurlUtils::curl_streaming_write_callback(char* contents, size_t size, siz */ void CurlUtils::process_chunk(const std::string& chunk) { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] 🔥 process_chunk called with {}\n", chunk.size())); } @@ -237,7 +236,7 @@ void CurlUtils::process_chunk(const std::string& chunk) { } #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(std::format( "\t[DEBUG] 🔥 Extracted line: {} {}\n", line.substr(0, 80), (line.size() > 80 ? "..." : ""))); } @@ -245,7 +244,7 @@ void CurlUtils::process_chunk(const std::string& chunk) { if (line.empty() || line[0] != '{') { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] 🔥 Skipping non-JSON line.\n")); } #endif @@ -255,14 +254,14 @@ void CurlUtils::process_chunk(const std::string& chunk) { // Pass only valid JSON lines to the callback. if (stream_callback) { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] 🔥 Calling stream_callback.\n")); } #endif stream_callback(line); } else { #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive("⚠️ stream_callback is NULL!\n"); } #endif @@ -326,7 +325,7 @@ long CurlUtils::check_service_status(const std::string& url, const std::mapui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -357,7 +356,7 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -368,7 +367,7 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -379,7 +378,7 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -466,26 +465,25 @@ std::string CurlUtils::fetch_web_content(const std::string& url, std::optionalui) == false) { + if (this->ui->backend_running() == false) { if (content.error_buffer[0] != '\0') { message += std::format("Details: {}\n", content.error_buffer); } UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); if (content.error_buffer[0] != '\0') { message = std::format("Details: {}\n", content.error_buffer); this->ui->print_error(message); } - this->ui->end_session(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } else if (content.status != 200) { message = std::format("Request HTTP status: {}\n", content.status); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); if (fail_on_http_error == true) { - std::exit(EXIT_FAILURE); + this->ui->end_session(EXIT_FAILURE); } else { throw HttpException(content.status, url); } @@ -588,12 +586,12 @@ WebContentStream CurlUtils::fetch_web_content_stream(const std::string& url, std content.success = false; content.error_message = curl_easy_strerror(result); message = std::format("CURL error: {}\n", curl_easy_strerror(result)); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { if (content.error_buffer[0] != '\0') { message += std::format("Details: {}\n", content.error_buffer); } UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); + this->ui->end_session(EXIT_FAILURE); } else { this->ui->print_error(message); if (content.error_buffer[0] != '\0') { @@ -606,10 +604,10 @@ WebContentStream CurlUtils::fetch_web_content_stream(const std::string& url, std content.success = false; content.error_message = std::format("HTTP {}", content.status); message = std::format("Request HTTP status: {}\n", content.status); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_error_noninteractive(message); if (fail_on_http_error == true) { - std::exit(EXIT_FAILURE); + this->ui->end_session(EXIT_FAILURE); } else { throw HttpException(content.status, url); } diff --git a/src/utils/embedding_utils.cpp b/src/utils/embedding_utils.cpp index 3d4790a..6709850 100644 --- a/src/utils/embedding_utils.cpp +++ b/src/utils/embedding_utils.cpp @@ -46,7 +46,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { std::string message{}; message = "⏳ Fetching documents from URLs...\n"; - if (UserInterface::ui_running(this->ui) == true) { + if (this->ui->backend_running() == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -59,7 +59,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { } message = "⏳ Splitting documents into chunks...\n"; - if (UserInterface::ui_running(this->ui) == true) { + if (this->ui->backend_running() == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -72,7 +72,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { } message = "⏳ Generating embeddings...\n"; - if (UserInterface::ui_running(this->ui) == true) { + if (this->ui->backend_running() == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -80,7 +80,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { auto embeddings = this->ollama_client->generate_embeddings(document_chunks); message = "⏳ Storing embeddings in Qdrant...\n"; - if (UserInterface::ui_running(this->ui) == true) { + if (this->ui->backend_running() == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -88,7 +88,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { this->storage_client->store(document_chunks, embeddings); message = "Embeddings created.\n"; - if (UserInterface::ui_running(this->ui) == true) { + if (this->ui->backend_running() == true) { this->ui->print_success(message); } else { UserInterface::print_success_noninteractive(message); diff --git a/src/utils/exit_request.hpp b/src/utils/exit_request.hpp new file mode 100644 index 0000000..1c2ff34 --- /dev/null +++ b/src/utils/exit_request.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include + +/** + * Exception thrown to request that the application exit with a specific code. + * + * Throwing an ExitRequest signals that the program should shut down in an + * orderly way. The exit code travels with the exception, so the top-level + * handler can pass it to the operating system. Because the class derives from + * std::exception, a catch block for std::exception will also match it, so + * handlers that must let it through should catch ExitRequest first. + * + * Catch it by const reference. The constructor, code(), and what() are all + * noexcept, so the object can be created and inspected without risk of + * throwing a second exception. + */ +class ExitRequest : public std::exception { + private: + /** The exit code the application should return to the operating system. */ + const int exit_code; + + std::string exit_reason{}; + + public: + /** + * Constructs an exit request with the given exit code. + * + * The constructor is explicit, so an int cannot be implicitly converted to + * an ExitRequest. The exit request must be created deliberately. + * + * @param code The exit code to report when the application terminates. + */ + explicit ExitRequest(const int code, const std::string& reason = "application exit requested") noexcept + : exit_code(code), exit_reason(reason) { + } + + /** + * Returns the exit code carried by this request. + * + * @return The exit code passed to the constructor. This function does not + * throw. + */ + int code() const noexcept { + return this->exit_code; + } + + /** + * Returns a description of the exception. + * + * The message is fixed and does not include the exit code. Use code() to + * retrieve the code. The returned pointer refers to a string literal with + * static storage duration, so it remains valid for the lifetime of the + * program. + * + * @return A null-terminated string, "application exit requested". + */ + const char* what() const noexcept override { + return this->exit_reason.c_str(); + } +}; diff --git a/src/utils/http_utils.hpp b/src/utils/http_utils.hpp index 48fa791..1c3c7eb 100644 --- a/src/utils/http_utils.hpp +++ b/src/utils/http_utils.hpp @@ -1,3 +1,5 @@ +#pragma once + #include #include #include diff --git a/src/utils/retrieving_utils.cpp b/src/utils/retrieving_utils.cpp index 012b8b3..622b3c7 100644 --- a/src/utils/retrieving_utils.cpp +++ b/src/utils/retrieving_utils.cpp @@ -63,7 +63,7 @@ std::vector RetrievingUtils::retrieve(const std::string& question) std::string response = this->curl->fetch_web_content(this->ollama_client->get_url_value("embed_url"), post_data); #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] question embed response {}\n", response)); } #endif @@ -85,7 +85,7 @@ std::vector RetrievingUtils::retrieve(const std::string& question) response = this->curl->fetch_web_content(url, post_data, "POST", headers); #ifdef DEBUG - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] search response {}\n", response)); } #endif @@ -156,7 +156,7 @@ Documents: )" + document_texts this->ollama_client->get_url_value("generate_url"), post_data, [this](const std::string& chunk) { this->ollama_client->process_response_stream(chunk); }); std::string message = std::format("Stream successful: {}, Status: {}\n", response.success, response.status); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -192,7 +192,7 @@ void RetrievingUtils::run() { const std::string& prompt = this->ollama_client->get_config_value("prompt"); std::string message = std::format("💬 user: {}\n", prompt); - if (UserInterface::ui_running(this->ui) == false) { + if (this->ui->backend_running() == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); diff --git a/src/utils/system_utils.cpp b/src/utils/system_utils.cpp index 16930c5..f84573d 100644 --- a/src/utils/system_utils.cpp +++ b/src/utils/system_utils.cpp @@ -27,13 +27,12 @@ SystemUtils::SystemUtils(std::shared_ptr ui) : ui(ui) { * returning. If it does return, report_failure returns to its caller. */ void SystemUtils::report_failure(const std::string& message) { - if (UserInterface::ui_running(ui)) { - ui->print_error(message); - ui->end_session(EXIT_FAILURE); + if (this->ui->backend_running()) { + this->ui->print_error(message); } else { UserInterface::print_error_noninteractive(message); - std::exit(EXIT_FAILURE); } + this->ui->end_session(EXIT_FAILURE); } /** diff --git a/tests/interface/user_interface_mock.cpp b/tests/interface/user_interface_mock.cpp index ecf0afe..da1cf37 100644 --- a/tests/interface/user_interface_mock.cpp +++ b/tests/interface/user_interface_mock.cpp @@ -17,15 +17,15 @@ inline Calls calls; // Test code resets and inspects this. } // namespace fake_ui -UserInterface::UserInterface() : input_cursor_x(0), layout({}), use_dark_theme(true) { +UserInterface::UserInterface() { } -UserInterface::UserInterface(bool dark_theme) : input_cursor_x(0), layout({}), use_dark_theme(dark_theme) { +UserInterface::UserInterface(bool dark_theme) { } UserInterface::~UserInterface() = default; -bool UserInterface::ui_running(const std::shared_ptr&) { +bool UserInterface::backend_running() { return fake_ui::calls.running; } diff --git a/tests/utils/system_utils_tests.cpp b/tests/utils/system_utils_tests.cpp index 9998442..da23452 100644 --- a/tests/utils/system_utils_tests.cpp +++ b/tests/utils/system_utils_tests.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include "../tests/interface/user_interface_mock.hpp" @@ -22,12 +21,6 @@ constexpr int stopped_by(int sig) { return 0x7f | (sig << 8); } -// Used by death tests so the whole call runs in the child process. -void run_catch_error(int status) { - SystemUtils su(std::make_shared()); - su.catch_error(status); -} - } // namespace class SystemUtils_catch_error : public ::testing::Test { @@ -76,11 +69,16 @@ TEST_F(SystemUtils_catch_error, non_zero_exit_interactive_reports_and_ends_sessi EXPECT_TRUE(fake_ui::calls.noninteractive.empty()); } -TEST_F(SystemUtils_catch_error, non_zero_exit_non_interactive_reports_and_exits) { +TEST_F(SystemUtils_catch_error, non_zero_exit_non_interactive_reports_and_ends_session) { fake_ui::calls.running = false; + SystemUtils su(std::make_shared()); - EXPECT_EXIT(run_catch_error(exited_with(3)), ::testing::ExitedWithCode(EXIT_FAILURE), - "Command failed with exit code 3\\."); + su.catch_error(exited_with(3)); + + ASSERT_EQ(fake_ui::calls.noninteractive.size(), 1u); + EXPECT_EQ(fake_ui::calls.noninteractive[0], "Command failed with exit code 3.\n"); + EXPECT_EQ(fake_ui::calls.end_codes, std::vector{EXIT_FAILURE}); + EXPECT_TRUE(fake_ui::calls.errors.empty()); } TEST_F(SystemUtils_catch_error, exit_code_255_is_reported_exactly) { @@ -107,10 +105,15 @@ TEST_F(SystemUtils_catch_error, killed_by_signal_interactive_reports_abnormal_te EXPECT_EQ(fake_ui::calls.end_codes, std::vector{EXIT_FAILURE}); } -TEST_F(SystemUtils_catch_error, killed_by_signal_non_interactive_exits) { +TEST_F(SystemUtils_catch_error, killed_by_signal_non_interactive_reports_and_ends_session) { fake_ui::calls.running = false; + SystemUtils su(std::make_shared()); + + su.catch_error(SIGKILL); - EXPECT_EXIT(run_catch_error(SIGKILL), ::testing::ExitedWithCode(EXIT_FAILURE), "Command terminated abnormally\\."); + ASSERT_EQ(fake_ui::calls.noninteractive.size(), 1u); + EXPECT_EQ(fake_ui::calls.noninteractive[0], "Command terminated abnormally.\n"); + EXPECT_EQ(fake_ui::calls.end_codes, std::vector{EXIT_FAILURE}); } TEST_F(SystemUtils_catch_error, stopped_process_is_treated_as_abnormal) { @@ -139,6 +142,11 @@ TEST_F(SystemUtils_catch_error, system_failure_interactive_reports_only_once) { TEST_F(SystemUtils_catch_error, system_failure_non_interactive_reports_only_once) { fake_ui::calls.running = false; + SystemUtils su(std::make_shared()); - EXPECT_EXIT(run_catch_error(-1), ::testing::ExitedWithCode(EXIT_FAILURE), "system\\(\\) failed\\."); + su.catch_error(-1); + + ASSERT_EQ(fake_ui::calls.noninteractive.size(), 1u); + EXPECT_EQ(fake_ui::calls.noninteractive[0], "system() failed.\n"); + EXPECT_EQ(fake_ui::calls.end_codes, std::vector{EXIT_FAILURE}); }