From a88fb48c3edef7073c098139012b46173f3593e6 Mon Sep 17 00:00:00 2001 From: rfprod Date: Sat, 10 Oct 2026 23:35:25 +0400 Subject: [PATCH] feat(ui): implement `ncurses` interface and refactor `ollama` client - [x] implement `UserInterface` for `ncurses`-based interaction and threaded rendering; - [x] add `Layout` struct and signal handlers for window resize and interrupts; - [x] support dual-mode interactive and non-interactive output and prompt handling; - [x] integrate `UserInterface` into `OllamaClient` via dependency injection; - [x] route logs and errors through `UserInterface` based on session state; - [x] use `std::format` for strings; - [x] extract `CURL` progress setup to a dedicated method; - [x] transition `ProgressSpinner` to `std::unique_ptr`; - [x] update `ProgressSpinner` for UI integration and completion state tracking; - [x] add `TextUtils` class with word-boundary line wrapping; - [x] add comprehensive `Doxygen` documentation and standardize `#pragma once` guards; --- .github/workflows/gh-pages.yml | 12 + CMakeLists.txt | 6 +- conanfile.py | 1 + src/main.cpp | 56 ++--- src/ollama_client.cpp | 352 +++++++++++++++++++++++---- src/ollama_client.hpp | 172 ++++++++++++- src/progress_spinner.cpp | 123 ++++++---- src/progress_spinner.hpp | 147 ++++++++++- src/text-utils.cpp | 52 ++++ src/text-utils.hpp | 46 ++++ src/user-interface.cpp | 429 +++++++++++++++++++++++++++++++++ src/user-interface.hpp | 114 +++++++++ 12 files changed, 1377 insertions(+), 133 deletions(-) create mode 100644 src/text-utils.cpp create mode 100644 src/text-utils.hpp create mode 100644 src/user-interface.cpp create mode 100644 src/user-interface.hpp diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 5819c0a..29a136b 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -50,10 +50,22 @@ jobs: GENERATE_XML = YES WARN_IF_UNDOCUMENTED = NO QUIET = YES + HAVE_DOT = YES + DOT_IMAGE_FORMAT = svg + INTERACTIVE_SVG = YES + CLASS_GRAPH = YES + COLLABORATION_GRAPH = YES + INCLUDE_GRAPH = YES + INCLUDED_BY_GRAPH = YES + UML_LOOK = YES + EOF doxygen build/Doxyfile sphinx-build -b html docs build/site + # Copy Doxygen HTML with diagrams into the Sphinx site + cp -r build/doxygen/html/* build/site/api/ || true + deactivate - name: Upload downloadable site artifact diff --git a/CMakeLists.txt b/CMakeLists.txt index faadeac..59ba546 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(cpp-workspace VERSION 0.1.1 LANGUAGES CXX) +project(cpp-workspace VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -15,14 +15,18 @@ add_executable(cpp-workspace-app src/main.cpp src/ollama_client.cpp src/progress_spinner.cpp + src/user-interface.cpp + src/text-utils.cpp ) find_package(fmt REQUIRED) find_package(CURL REQUIRED) find_package(nlohmann_json REQUIRED) +find_package(Curses REQUIRED) target_link_libraries(cpp-workspace-app PRIVATE fmt::fmt CURL::libcurl nlohmann_json::nlohmann_json + Curses::Curses ) diff --git a/conanfile.py b/conanfile.py index 7faefe6..0277c30 100644 --- a/conanfile.py +++ b/conanfile.py @@ -11,6 +11,7 @@ def requirements(self): self.requires("fmt/11.2.0") self.requires("libcurl/8.5.0") self.requires("nlohmann_json/3.11.2") + self.requires("ncurses/6.5") def layout(self): cmake_layout(self) diff --git a/src/main.cpp b/src/main.cpp index d35090b..757b2eb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,20 +1,7 @@ #include "ollama_client.hpp" +#include "user-interface.hpp" #include - -bool next_prompt(std::string* prompt) { - bool done = false; - printf("⏳ Type your prompt (q/Q to quit): "); - if (!std::getline(std::cin, *prompt, '\n')) { - fprintf(stderr, "Error reading user input.\n"); - exit(1); - } - - if (*prompt == "q" || *prompt == "Q") { - done = true; - } - - return done; -} +#include int main(int argc, const char* argv[]) { std::string model = "gemma3n:latest"; @@ -38,25 +25,32 @@ int main(int argc, const char* argv[]) { } } - printf("\n"); - printf("🛈 model = %s\n", model.c_str()); - printf("🛈 prompt = %s\n", prompt.c_str()); - printf("🛈 stream = %s\n", stream ? "true" : "false"); - printf("🛈 interactive = %s\n", interactive ? "true" : "false"); - printf("\n"); - + UserInterface ui = UserInterface(); if (interactive == true) { + printf("\nInteractive mode debug info.\n"); + printf("---\n"); + printf("isatty(0)=%d, isatty(1)=%d, isatty(2)=%d\n", isatty(0), isatty(1), isatty(2)); + printf("TERM=%s\n", getenv("TERM")); + printf("TERMINFO=%s\n", getenv("TERMINFO") ? getenv("TERMINFO") : "(not set)"); + printf("LC_ALL=%s\n", getenv("LC_ALL") ? getenv("LC_ALL") : "(not set)"); + printf("LANG=%s\n", getenv("LANG") ? getenv("LANG") : "(not set)"); + printf("---\n"); + + ui.run(); + ui.print_args(model, prompt, stream, interactive); + while (prompt.length() == 0) { - done = next_prompt(&prompt); + done = ui.next_prompt(&prompt); if (done == true) { - printf("\n🏁 The session has ended.\n"); - exit(0); + ui.end_session(EXIT_SUCCESS); } } } else { + UserInterface::print_args_noninteractive(model, prompt, stream, interactive); + if (prompt.length() == 0) { - done = next_prompt(&prompt); + done = UserInterface::next_prompt_noninteractive(&prompt); if (prompt.length() == 0) { printf("\n🏁 Empty prompt. The session has ended.\n"); @@ -70,7 +64,7 @@ int main(int argc, const char* argv[]) { } } - OllamaClient client = OllamaClient(); + OllamaClient client = OllamaClient(&ui); client.query_model_list(); @@ -83,17 +77,19 @@ int main(int argc, const char* argv[]) { if (interactive == true) { while (done == false) { - done = next_prompt(&prompt); + done = ui.next_prompt(&prompt); if (done == true) { continue; } config.prompt = prompt; client.query_model(config); + ui.request_redraw(); } + ui.end_session(EXIT_SUCCESS); + } else { + printf("\n🏁 The session has ended.\n"); } - printf("\n🏁 The session has ended.\n"); - return 0; } diff --git a/src/ollama_client.cpp b/src/ollama_client.cpp index efc9332..bf04d1f 100644 --- a/src/ollama_client.cpp +++ b/src/ollama_client.cpp @@ -1,6 +1,7 @@ #include "ollama_client.hpp" #include #include +#include #include #include #include @@ -8,48 +9,136 @@ using nlohmann::json_abi_v3_11_2::json; +/** + * CURL write callback for accumulating response data. + * + * Called by CURL whenever data is received. Appends the chunk to the + * userp string buffer. + * + * @param contents Pointer to the received data chunk. + * @param size Size of each element (always 1 for this use case). + * @param nmemb Number of elements received. + * @param userp Pointer to the std::string buffer to append data to. + * + * @return Total bytes processed (size * nmemb). + */ static size_t WriteCallback(char* contents, size_t size, size_t nmemb, std::string* userp) { userp->append(contents, size * nmemb); return size * nmemb; } -// Create an instance of OllamaClient. -OllamaClient::OllamaClient() { +/** + * Constructs an OllamaClient and initializes Ollama API endpoints. + * + * Sets up endpoint URLs for the local Ollama instance (localhost:11434), + * verifies that Ollama is installed, and creates a ProgressSpinner for + * use during network operations. + * + * Prints a newline in CLI mode for clean output formatting. + */ +OllamaClient::OllamaClient(UserInterface* ui) { this->urls = { generate_url : "http://localhost:11434/api/generate", tags_url : "http://localhost:11434/api/tags" }; this->curl = nullptr; this->headers = nullptr; + this->ui = ui; + this->spinner = std::make_unique(ui, false); this->check_ollama(); - printf("\n"); + if (ui->is_running() == false) { + printf("\n"); + } } -// Checks whether Ollama binary exists on the file system. +/** + * Verifies that the Ollama binary is available on the system. + * + * Uses the `which ollama` command to check if Ollama is in the PATH. + * - If found: displays a checkmark and continues. + * - If not found: displays an error message with installation instructions + * and exits or ends the session. + * + * Output is routed through the UserInterface (if available) or stdout/stderr. + */ void OllamaClient::check_ollama() { if (system("which ollama > /dev/null 2>&1")) { - fprintf(stderr, "Ollama binary does not exist.\nInstallation instructions: " - "https://docs.ollama.com/linux\n"); - exit(0); + std::string message = "Ollama binary does not exist.\nInstallation instructions: " + "https://docs.ollama.com/linux\n"; + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + exit(0); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } else { - printf("✅ Ollama binary exists.\n"); + std::string message = "Ollama binary exists.\n"; + if (this->ui->is_running() == false) { + printf("✅ %s", message.c_str()); + } else { + this->ui->print_success(message); + } } }; -// Initialize CURL. +/** + * Initializes the CURL library and allocates a CURL handle. + * + * On failure, displays an error message and either exits (CLI mode) or + * ends the session (interactive mode). On success, displays a checkmark. + */ void OllamaClient::init_curl() { this->curl = curl_easy_init(); if (!this->curl) { - fprintf(stderr, "Failed to initialize CURL.\n"); - exit(1); + std::string message = "Failed to initialize CURL.\n"; + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + exit(1); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } else { - printf("✅ CURL initialized.\n"); + std::string message = "CURL initialized.\n"; + if (this->ui->is_running() == false) { + printf("✅ %s", message.c_str()); + } else { + this->ui->print_success(message); + } } } -// Trims leading whitespace and trailing whitespace from a json response. +/** + * Configures CURL's progress callback for download tracking. + * + * Enables CURLOPT_NOPROGRESS and sets the progress callback function and + * data pointer. This allows the ProgressSpinner to update during network + * transfers. + * + * Does nothing if curl is nullptr. + */ +void OllamaClient::setup_progress_callback() { + if (this->curl == nullptr) { + return; + } + + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, spinner.get()); +} + +/** + * Removes leading and trailing whitespace from a string. + * + * Uses std::find_if with lambda predicates to efficiently trim both ends + * without creating unnecessary intermediate strings. + * + * @param response The input string. + * @return The trimmed string. + */ std::string OllamaClient::trim_response(std::string response) { response.erase(response.begin(), std::find_if(response.begin(), response.end(), @@ -62,21 +151,54 @@ std::string OllamaClient::trim_response(std::string response) { return response; } -// Process a response. +/** + * Parses and displays a complete JSON response from Ollama. + * + * Extracts the "response" field, trims whitespace, and displays it with + * a llama emoji header and separator. On JSON parse error, displays an + * error message and exits or ends the session. + * + * Output is routed through the UserInterface or stdout/stderr. + * + * @param response The complete JSON response from the Ollama API. + */ void OllamaClient::process_response(std::string response) { try { json response_json = json::parse(response); - std::cout << "🦙 llama:\n" - << this->trim_response(response_json["response"].get()) << "\n---\n" - << std::endl; + if (this->ui->is_running() == false) { + printf("🦙 llama:\n%s\n---\n", + this->trim_response(response_json["response"].get()).c_str()); + } else { + this->ui->print_message("🦙 llama:"); + this->ui->print_message( + this->trim_response(response_json["response"].get())); + this->ui->print_message("---"); + } } catch (const json::exception& err) { - fprintf(stderr, "JSON parse error: %s\n", err.what()); - exit(1); + std::string message = std::format("JSON parse error: {}\n", err.what()); + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + exit(1); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } } -// Process a streaming response. +/** + * Parses and displays a streamed JSON response from Ollama. + * + * Processes the response as newline-delimited JSON chunks. For each chunk: + * 1. Extracts the "response" field (text fragment) + * 2. Prints it incrementally to stdout + * 3. Checks the "done" field to determine if streaming is complete + * + * On JSON parse error, displays an error message and exits or ends the session. + * + * @param response The complete streamed response (newline-delimited JSON). + */ void OllamaClient::process_response_stream(std::string response) { std::istringstream stream(response); std::string line; @@ -96,8 +218,14 @@ void OllamaClient::process_response_stream(std::string response) { break; } } catch (const json::exception& err) { - fprintf(stderr, "JSON parse error: %s\n", err.what()); - exit(1); + std::string message = std::format("JSON parse error: {}\n", err.what()); + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + exit(1); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } } } @@ -105,11 +233,49 @@ void OllamaClient::process_response_stream(std::string response) { std::cout << std::endl; } -// Send a request to the Ollama `generate` endpoint using CURL. +/** + * Sends a prompt to an Ollama model and displays the response. + * + * Executes the following workflow: + * 1. Initializes CURL and starts the progress spinner + * 2. Formats and displays the user's prompt via the UI + * 3. Constructs a JSON request payload with model, prompt, and stream settings + * 4. Sends a POST request to the Ollama generate endpoint + * 5. Validates HTTP response status (expects 200 OK) + * 6. Processes the response as either streamed or complete JSON + * 7. Cleans up CURL resources (handle and headers list) + * + * The prompt is displayed immediately with a "💬 user:" prefix. The model's + * response is processed based on the stream setting: + * - If stream=true: Response chunks are printed incrementally as they arrive + * - If stream=false: Entire response is printed after completion + * + * @param config OllamaPromptConfig containing: + * - model: Name of the model to query (e.g., "llama2") + * - prompt: The text prompt to send + * - stream: Whether to stream the response + * + * @note Error handling depends on UI mode: + * - CLI mode: Prints error to stderr and calls exit(1) + * - Interactive mode: Calls ui->print_error() and ui->end_session(EXIT_FAILURE) + * + * @note CURL errors include network failures, connection timeouts, and invalid URLs. + * HTTP errors are reported when Ollama returns non-200 status codes. + * + * @warning The method will not return if an error occurs; it terminates the program + * or session instead. + */ void OllamaClient::query_model(OllamaPromptConfig config) { this->init_curl(); - printf("💬 prompt: %s\n", config.prompt.c_str()); + this->spinner->start(); + + std::string message = std::format("💬 user: {}\n", config.prompt.c_str()); + if (this->ui->is_running() == false) { + printf("%s", message.c_str()); + } else { + this->ui->print_message(message); + } json request_payload = { {"model", config.model}, {"prompt", config.prompt}, {"stream", config.stream}}; @@ -126,13 +292,13 @@ void OllamaClient::query_model(OllamaPromptConfig config) { curl_easy_setopt(this->curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(this->curl, CURLOPT_ERRORBUFFER, error_buffer); - curl_easy_setopt(this->curl, CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); - curl_easy_setopt(this->curl, CURLOPT_NOPROGRESS, 0L); + this->setup_progress_callback(); this->headers = curl_slist_append(this->headers, "Content-Type: application/json"); curl_easy_setopt(this->curl, CURLOPT_HTTPHEADER, this->headers); - CURLcode result = curl_easy_perform(curl); + CURLcode result = curl_easy_perform(this->curl); + this->spinner->stop(); long status = 0; curl_easy_getinfo(this->curl, CURLINFO_RESPONSE_CODE, &status); @@ -142,15 +308,32 @@ void OllamaClient::query_model(OllamaPromptConfig config) { this->headers = nullptr; if (result != CURLE_OK) { - fprintf(stderr, "CURL error: %s\n", curl_easy_strerror(result)); - if (error_buffer[0] != '\0') { - fprintf(stderr, "Details: %s\n", error_buffer); + message = std::format("CURL error: {}\n", curl_easy_strerror(result)); + if (this->ui->is_running() == false) { + fprintf(stderr, "%s", message.c_str()); + if (error_buffer[0] != '\0') { + message = std::format("Details: {}\n", error_buffer); + fprintf(stderr, "%s", message.c_str()); + } + exit(1); + } else { + this->ui->print_error(message); + if (error_buffer[0] != '\0') { + message = std::format("Details: {}\n", error_buffer); + this->ui->print_error(message); + } + this->ui->end_session(EXIT_FAILURE); } - exit(1); } if (status != 200) { - fprintf(stderr, "Ollama returned HTTP status: %ld\n", status); - exit(1); + message = std::format("Ollama returned HTTP status: {}\n", status); + if (this->ui->is_running() == false) { + fprintf(stderr, "%s", message.c_str()); + exit(1); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } if (config.stream) { @@ -160,10 +343,43 @@ void OllamaClient::query_model(OllamaPromptConfig config) { } } -// Send a request to the Ollama endpoint for listing local models using CURL. +/** + * Retrieves and displays all available models from the Ollama server. + * + * Executes the following workflow: + * 1. Initializes CURL and starts the progress spinner + * 2. Sends a GET request to the Ollama /api/tags endpoint + * 3. Validates HTTP response status (expects 200 OK) + * 4. Parses the JSON response to extract the "models" array + * 5. Displays each model with metadata in a formatted table + * 6. Cleans up CURL resources (handle and headers list) + * + * For each model, displays: + * - Last modified timestamp + * - Size in megabytes (or "size unknown" if unavailable) + * - Model name + * + * Output format: "{modified_at}\t ({size_mb}) \t{name}" + * + * @note Error handling depends on UI mode: + * - CLI mode: Prints error to stderr and calls exit(1) + * - Interactive mode: Calls ui->print_error() and ui->end_session(EXIT_FAILURE) + * + * @note Errors may occur at multiple stages: + * - CURL errors: Network failures, connection issues + * - HTTP errors: Non-200 response codes from Ollama + * - JSON errors: Malformed response or missing "models" field + * + * @warning The method will not return if an error occurs; it terminates the program + * or session instead. + * + * @see OllamaPromptConfig for model information structure + */ void OllamaClient::query_model_list() { this->init_curl(); + this->spinner->start(); + std::string response; char error_buffer[CURL_ERROR_SIZE]; @@ -173,13 +389,13 @@ void OllamaClient::query_model_list() { curl_easy_setopt(this->curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(this->curl, CURLOPT_ERRORBUFFER, error_buffer); - curl_easy_setopt(this->curl, CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); - curl_easy_setopt(this->curl, CURLOPT_NOPROGRESS, 0L); + this->setup_progress_callback(); this->headers = curl_slist_append(this->headers, "Content-Type: application/json"); curl_easy_setopt(this->curl, CURLOPT_HTTPHEADER, this->headers); CURLcode result = curl_easy_perform(this->curl); + this->spinner->stop(); long status = 0; curl_easy_getinfo(this->curl, CURLINFO_RESPONSE_CODE, &status); @@ -188,27 +404,56 @@ void OllamaClient::query_model_list() { curl_slist_free_all(this->headers); this->headers = nullptr; + std::string message{}; if (result != CURLE_OK) { - fprintf(stderr, "CURL error: %s\n", curl_easy_strerror(result)); - if (error_buffer[0] != '\0') { - fprintf(stderr, "Details: %s\n", error_buffer); + message = std::format("CURL error: {}\n", curl_easy_strerror(result)); + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + if (error_buffer[0] != '\0') { + message = std::format("Details: {}\n", error_buffer); + fprintf(stderr, "%s", message.c_str()); + } + exit(1); + } else { + this->ui->print_error(message); + if (error_buffer[0] != '\0') { + message = std::format("Details: {}\n", error_buffer); + this->ui->print_error(message); + } + this->ui->end_session(EXIT_FAILURE); } - exit(1); } if (status != 200) { - fprintf(stderr, "Ollama returned HTTP status: %ld\n", status); - exit(1); + message = std::format("Ollama returned HTTP status: {}\n", status); + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + exit(1); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } try { const json data = json::parse(response); if (!data.contains("models") || !data["models"].is_array()) { - fprintf(stderr, "Response has no models array.\n"); - exit(1); + message = "Response has no models array.\n"; + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + exit(1); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } - printf("📃 available models:\n"); + message = "📃 available models:\n"; + if (this->ui->is_running() == false) { + printf("%s", message.c_str()); + } else { + this->ui->print_message(message); + } for (const auto& model : data["models"]) { const std::string name = model.value("name", "unknown"); @@ -218,11 +463,22 @@ void OllamaClient::query_model_list() { ? std::to_string(model["size"].get() / 1024 / 1024) + " Mb" : "size unknown"; - std::cout << modified_at << "\t (" << size << ") \t" << name << "\n"; + message = std::format("{}\t ({}) \t{}\n", modified_at, size, name); + if (this->ui->is_running() == false) { + printf("%s", message.c_str()); + } else { + this->ui->print_message(message); + } } printf("\n"); } catch (const json::exception& err) { - fprintf(stderr, "JSON parse error: %s\n", err.what()); - exit(1); + message = std::format("JSON parse error: {}\n", err.what()); + if (this->ui->is_running() == false) { + fprintf(stderr, "❌ %s", message.c_str()); + exit(1); + } else { + this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); + } } } diff --git a/src/ollama_client.hpp b/src/ollama_client.hpp index de02c45..76f96f7 100644 --- a/src/ollama_client.hpp +++ b/src/ollama_client.hpp @@ -1,36 +1,204 @@ +#pragma once + #include "progress_spinner.hpp" +#include "user-interface.hpp" #include #include +/** + * Configuration for sending a prompt to an Ollama model. + * + * @param model The name of the model to query (e.g., "llama2", "neural-chat"). + * @param prompt The text prompt to send to the model. + * @param stream Whether to stream the response (true) or receive it all at once (false). + */ struct OllamaPromptConfig { std::string model; std::string prompt; bool stream; }; +/** + * URLs for Ollama API endpoints. + * + * @param generate_url The endpoint for querying a model. + * @param tags_url The endpoint for listing available models. + */ struct OllamaServerURLs { std::string generate_url; std::string tags_url; }; +/** + * Client for communicating with a local Ollama instance. + * + * OllamaClient manages HTTP requests to Ollama's REST API, handling both + * model queries and model listing. It integrates with a UserInterface for + * displaying messages and errors, and uses ProgressSpinner to provide + * visual feedback during network operations. + * + * The client verifies that the Ollama binary is installed on the system + * and initializes CURL for HTTP communication. All responses are parsed + * as JSON and formatted for display. + * + * @note Designed for local Ollama instances running on localhost:11434. + * + * @example Query a model: + * @code + * UserInterface ui; + * OllamaClient client(&ui); + * + * OllamaPromptConfig config{ + * .model = "llama2", + * .prompt = "What is machine learning?", + * .stream = false + * }; + * client.query_model(config); + * @endcode + * + * @example List available models: + * @code + * UserInterface ui; + * OllamaClient client(&ui); + * client.query_model_list(); + * @endcode + */ class OllamaClient { private: + /** + * CURL handle for HTTP requests. Initialized in init_curl(). + */ CURL* curl; + + /** + * API endpoint URLs for the Ollama server. + */ OllamaServerURLs urls; + + /** + * CURL headers list for HTTP requests (Content-Type, etc.). + * Allocated in query methods and freed after each request. + */ struct curl_slist* headers; - ProgressSpinner spinner{ProgressSpinner()}; + /** + * Pointer to the UserInterface for displaying messages and errors. + * Used for both CLI and interactive UI output. + */ + UserInterface* ui; + + /** + * Progress spinner for visual feedback during network operations. + * Managed via std::unique_ptr for automatic cleanup. + */ + std::unique_ptr spinner; + + /** + * Verifies that the Ollama binary is available on the system. + * + * Uses the `which` command to check for Ollama in the PATH. If not found, + * displays an error message and either exits (CLI mode) or ends the session + * (interactive mode). + * + * Displays a checkmark on success. + */ void check_ollama(); + + /** + * Initializes the CURL library and allocates a CURL handle. + * + * If initialization fails, displays an error message and exits or ends + * the session depending on the UI state. + * + * Displays a checkmark on success. + */ void init_curl(); + /** + * Configures the CURL progress callback for download tracking. + * + * Sets up CURLOPT_XFERINFOFUNCTION and CURLOPT_XFERINFODATA to enable + * progress tracking during HTTP transfers via the ProgressSpinner. + * + * @note Must be called before curl_easy_perform(). + */ + void setup_progress_callback(); + + /** + * Removes leading and trailing whitespace from a string. + * + * Uses std::find_if with lambda predicates to efficiently trim both ends + * without creating intermediate strings. + * + * @param response The input string to trim. + * @return The trimmed string. + */ std::string trim_response(std::string response); + /** + * Parses and displays a complete (non-streamed) JSON response from Ollama. + * + * Extracts the "response" field from the JSON and displays it formatted + * with a llama emoji. If JSON parsing fails, displays an error and exits + * or ends the session. + * + * @param response The complete JSON response from the Ollama API. + */ void process_response(std::string response); + + /** + * Parses and displays a streamed JSON response from Ollama. + * + * Processes the response as newline-delimited JSON chunks, extracting + * the "response" field from each chunk and printing it incrementally. + * Stops processing when a chunk has "done" set to true. + * + * @param response The complete streamed response (newline-delimited JSON). + */ void process_response_stream(std::string response); public: - OllamaClient(); + /** + * Constructs an OllamaClient connected to a UserInterface. + * + * Initializes the Ollama API endpoint URLs, verifies the Ollama binary + * is installed, and creates a ProgressSpinner for network operations. + * + * @param ui Pointer to the UserInterface for displaying output. Must remain + * valid for the lifetime of this OllamaClient. + * + * @note Calls check_ollama() during construction. Exits if Ollama is not found. + */ + explicit OllamaClient(UserInterface* ui); + /** + * Sends a prompt to an Ollama model and displays the response. + * + * Performs the following steps: + * 1. Initializes CURL + * 2. Starts the progress spinner + * 3. Builds a JSON request with the model, prompt, and stream settings + * 4. Sends a POST request to the generate endpoint + * 5. Processes the response (streamed or complete) + * 6. Cleans up CURL resources + * + * Displays both the user's prompt and the model's response via the UI. + * Handles both CLI and interactive UI modes. + * + * @param config Configuration containing the model name, prompt, and stream setting. + * + * @note On error, displays an error message and exits or ends the session. + */ void query_model(OllamaPromptConfig config); + + /** + * Retrieves and displays a list of available models from the Ollama server. + * + * Sends a GET request to the /api/tags endpoint and parses the response + * to extract model metadata (name, size, modification date). Displays + * each model with its information formatted as a table. + * + * @note On error, displays an error message and exits or ends the session. + */ void query_model_list(); }; diff --git a/src/progress_spinner.cpp b/src/progress_spinner.cpp index 6f216f4..03036ed 100644 --- a/src/progress_spinner.cpp +++ b/src/progress_spinner.cpp @@ -5,13 +5,12 @@ #include #include -ProgressSpinner::ProgressSpinner() { - this->active = false; +ProgressSpinner::ProgressSpinner() : ui(nullptr), active(false) { } -ProgressSpinner::ProgressSpinner(bool activate) { - if (activate == true) { - this->active = activate; +ProgressSpinner::ProgressSpinner(UserInterface* user_interface, bool activate) + : ui(user_interface), active(false) { + if (activate && ui != nullptr) { start(); } } @@ -20,54 +19,96 @@ ProgressSpinner::~ProgressSpinner() { stop(); } -// Starts the progress spinner. +/** + * Implementation details: + * - In manual mode, renders spinner characters and progress directly to stdout. + * - In interactive mode, delegates animation and progress updates to the UserInterface. + * - Resets completion_shown flag and initializes progress to 0%. + */ void ProgressSpinner::start() { - active = true; - spinner_thread = std::thread([this]() { + this->completion_shown = false; + if (this->ui != nullptr) { + this->ui->set_progress(0); + } + this->active = true; + + if (this->ui == nullptr || this->ui->is_running() == false) { + this->spinner_thread = std::thread([this]() { + const char spinner_chars[] = {'|', '/', '-', '\\'}; + int i = 0; + while (this->active) { + std::cout << "\r" << spinner_chars[i++ % 4] << " Loading..." << std::flush; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + printf("\n"); + }); + } else { + this->ui->start_spinner(); + + this->spinner_thread = std::thread([this]() { + while (this->active) { + ui->advance_spinner_frame(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + }); + } +} + +/** + * Sets active flag to false, waits for the spinner thread to finish, + * and notifies the UserInterface to stop its spinner if present. + */ +void ProgressSpinner::stop() { + this->active = false; + if (this->spinner_thread.joinable()) { + this->spinner_thread.join(); + } + if (this->ui != nullptr) { + this->ui->stop_spinner(); + } +} + +/** + * Implementation notes: + * - In manual mode, displays progress percentage if total is known, otherwise shows spinner. + * - In interactive mode, updates both spinner frame and progress bar in the UI. + * - Returns 0 to allow CURL to continue operation. + */ +int ProgressSpinner::progress_callback_impl(curl_off_t dltotal, curl_off_t dlnow) { + if (this->ui == nullptr || !this->ui->is_running()) { const char spinner_chars[] = {'|', '/', '-', '\\'}; - int i = 0; - while (active) { + + if (dltotal > 0) { + int progress = (dlnow * 100) / dltotal; + std::cout << "\r▰▰▰▱ Progress: " << progress << "% " << std::flush; + } else { + static int i = 0; std::cout << "\r" << spinner_chars[i++ % 4] << " Loading..." << std::flush; - std::this_thread::sleep_for(std::chrono::milliseconds(100)); } - std::cout << "\r✓ Complete\n" << std::endl; - }); -} + return 0; + } -// Stops the progress spinner. -void ProgressSpinner::stop() { - active = false; - if (spinner_thread.joinable()) { - spinner_thread.join(); + this->ui->advance_spinner_frame(); + + if (dltotal > 0) { + int progress = (dlnow * 100) / dltotal; + this->ui->set_progress(progress); } + + return 0; } /** - * CURL progress callback. + * Static callback adapter that casts the clientp void pointer to a ProgressSpinner + * instance and delegates to the progress_callback_impl() method. * - * @example - * ```cpp - * CURL* curl = curl_easy_init(); - * curl_easy_setopt(this->curl, CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); - * curl_easy_setopt(this->curl, CURLOPT_NOPROGRESS, 0L); - * curl_easy_perform(curl); - * ``` + * @return 0 to continue the operation, 1 if clientp is nullptr. */ int ProgressSpinner::progress_callback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow) { - if (dltotal > 0) { - // If size is known, show progress bar - int progress = (dlnow * 100) / dltotal; - if (progress == 100) { - std::cout << "\r✓ Complete " << std::flush; - } else { - std::cout << "\rProgress: " << progress << "% " << std::flush; - } - } else { - // Indeterminate - show spinner - const char spinner_chars[] = {'|', '/', '-', '\\'}; - static int i = 0; - std::cout << "\r" << spinner_chars[i++ % 4] << " Loading..." << std::flush; + ProgressSpinner* self = static_cast(clientp); + if (self == nullptr) { + return 1; } - return 0; + return self->progress_callback_impl(dltotal, dlnow); } diff --git a/src/progress_spinner.hpp b/src/progress_spinner.hpp index 5857b8d..07e659e 100644 --- a/src/progress_spinner.hpp +++ b/src/progress_spinner.hpp @@ -1,39 +1,164 @@ #pragma once -#ifndef PROGRESS_SPINNER_HPP -#define PROGRESS_SPINNER_HPP - +#include "user-interface.hpp" #include #include #include #include /** - * Progress spinner for asynchronous operations. + * Displays a progress indicator for asynchronous operations, with optional UI integration. + * + * ProgressSpinner supports two modes: + * - **Manual mode**: Displays an animated spinner to stdout for CLI applications. + * - **Interactive mode**: Integrates with a UserInterface instance for display within + * a graphical or TUI framework. + * + * The spinner runs on a separate thread and updates independently of the main operation. + * Designed to work seamlessly with CURL operations via the static progress_callback method. * - * @example manual mode - * ```cpp - * ProgressSpinner spinner = ProgressSpinner(); + * @note Thread-safe: Uses std::atomic for synchronization without locks. + * + * @example Manual mode (CLI spinner): + * @code + * ProgressSpinner spinner; * spinner.start(); * curl_easy_perform(curl_handle); * spinner.stop(); - * ``` + * @endcode + * + * @example Interactive mode (with UserInterface): + * @code + * UserInterface ui; + * ProgressSpinner spinner(&ui, true); // auto-starts + * // UI displays progress and spinner animation + * curl_easy_perform(curl_handle); + * spinner.stop(); + * @endcode + * + * @example CURL integration with progress tracking: + * @code + * ProgressSpinner spinner(&ui); + * spinner.start(); + * + * CURL* curl = curl_easy_init(); + * curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); + * curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &spinner); + * curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + * curl_easy_perform(curl); + * + * spinner.stop(); + * curl_easy_cleanup(curl); + * @endcode */ class ProgressSpinner { private: + /** + * Pointer to the associated UserInterface, or nullptr for manual mode. + */ + UserInterface* ui; + + /** + * Flag indicating whether the spinner is currently active. + */ std::atomic active{false}; + + /** + * Thread running the spinner animation loop. + */ std::thread spinner_thread; + /** + * Flag tracking whether a completion message has been displayed. + */ + std::atomic completion_shown{false}; + public: + /** + * Constructs a ProgressSpinner in manual mode. + * + * Creates a spinner that displays to stdout without UI integration. + * Must be manually started with start(). + */ ProgressSpinner(); - explicit ProgressSpinner(bool activate); + + /** + * Constructs a ProgressSpinner with optional UI integration. + * + * @param user_interface Pointer to a UserInterface instance for rendering, + * or nullptr for manual mode (CLI spinner). + * @param activate If true and user_interface is not nullptr, automatically + * starts the spinner. Default: false. + * + * @note The UserInterface pointer must remain valid for the lifetime of + * the ProgressSpinner object. + */ + explicit ProgressSpinner(UserInterface* user_interface, bool activate = false); + + /** + * Destructs the ProgressSpinner, ensuring the spinner thread is stopped. + * + * Calls stop() to cleanly shut down the spinner thread. + */ ~ProgressSpinner(); + /** + * Starts the spinner animation on a background thread. + * + * - If no UserInterface is set, displays an animated spinner (| / - \) to stdout. + * - If a UserInterface is set, delegates animation to the UI via start_spinner() + * and advance_spinner_frame(). + * + * Sets progress to 0% and resets the completion_shown flag. + * + * @note Safe to call multiple times; subsequent calls are no-ops if already running. + */ void start(); + + /** + * Stops the spinner animation and cleans up the background thread. + * + * Waits for the spinner thread to finish (via join()) and notifies the + * UserInterface to stop its spinner, if set. + * + * @note Safe to call multiple times or if start() was never called. + */ void stop(); + /** + * Internal progress callback for CURL integration. + * + * Processes download progress information and updates the spinner or UI accordingly. + * Displays a progress bar (▰▰▰▱) with percentage if total size is known. + * + * @param dltotal Total bytes to download (0 if unknown). + * @param dlnow Bytes downloaded so far. + * + * @return 0 to continue the CURL operation, non-zero to abort. + * + * @note This method is called frequently by CURL; keep it lightweight. + */ + int progress_callback_impl(curl_off_t dltotal, curl_off_t dlnow); + + /** + * Static CURL callback adapter for progress tracking. + * + * This static method serves as the callback function for CURL's + * CURLOPT_XFERINFOFUNCTION option. It casts the clientp parameter to a + * ProgressSpinner instance and delegates to progress_callback_impl(). + * + * @param clientp Opaque pointer to the ProgressSpinner instance (passed via + * CURLOPT_XFERINFODATA). + * @param dltotal Total bytes to download (0 if unknown). + * @param dlnow Bytes downloaded so far. + * @param ultotal Total bytes to upload (unused). + * @param ulnow Bytes uploaded so far (unused). + * + * @return 0 to continue the CURL operation, 1 to abort if clientp is nullptr. + * + * @note CURL calls this frequently; ensure your clientp points to a valid + * ProgressSpinner instance. + */ static int progress_callback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow); }; - -#endif diff --git a/src/text-utils.cpp b/src/text-utils.cpp new file mode 100644 index 0000000..f0d5331 --- /dev/null +++ b/src/text-utils.cpp @@ -0,0 +1,52 @@ +#include "text-utils.hpp" + +TextUtils::TextUtils() { +} + +TextUtils::~TextUtils() { +} + +/** + * @brief Implementation of wrap_text method. + * + * Algorithm: + * 1. Iteratively extract lines of up to max_width characters + * 2. Prefer breaking at word boundaries (spaces) + * 3. If no space exists within max_width, break at max_width + * 4. Remove leading whitespace from each new line + * 5. Continue until remaining text fits within max_width + */ +std::vector TextUtils::wrap_text(const std::string& text, int max_width) { + std::vector result; + std::string remaining = text; + + while (remaining.length() > max_width) { + // Find the last space within max_width. + int wrap_position = remaining.rfind(' ', max_width); + + if (wrap_position == std::string::npos) { + wrap_position = max_width; + } + + // Add the wrapped line without trailing space. + std::string line = remaining.substr(0, wrap_position); + result.push_back(line); + + // Skip the space and continue with remainder. + remaining = remaining.substr(wrap_position); + + // Trim leading spaces from next line. + size_t first_non_space = remaining.find_first_not_of(' '); + if (first_non_space != std::string::npos) { + remaining = remaining.substr(first_non_space); + } else { + break; + } + } + + if (!remaining.empty()) { + result.push_back(remaining); + } + + return result; +} diff --git a/src/text-utils.hpp b/src/text-utils.hpp new file mode 100644 index 0000000..616e9b0 --- /dev/null +++ b/src/text-utils.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +/** + * @class TextUtils + * @brief Utility class for text manipulation operations. + * + * Provides static methods for common text processing tasks, including + * line wrapping based on a specified maximum width. + */ +class TextUtils { + public: + /** + * @brief Constructs a TextUtils object. + * + * Default constructor. Since all methods are static, instantiation + * is optional but permitted. + */ + TextUtils(); + + /** + * @brief Destructs the TextUtils object. + * + * Default destructor. Performs no special cleanup. + */ + ~TextUtils(); + + /** + * @brief Wraps text to fit within a specified width. + * + * Splits the input text into multiple lines, breaking at word boundaries + * where possible to avoid exceeding the maximum width. Respects natural + * spaces and removes leading spaces from wrapped lines. + * + * @param text The input text to wrap. + * @param max_width The maximum width (in characters) for each line. + * @return A vector of strings, each representing a wrapped line. + * The last line may be shorter than max_width. + * + * @note If a single word exceeds max_width, it will be placed on its own + * line without being broken. + */ + static std::vector wrap_text(const std::string& text, int max_width); +}; diff --git a/src/user-interface.cpp b/src/user-interface.cpp new file mode 100644 index 0000000..3d4b496 --- /dev/null +++ b/src/user-interface.cpp @@ -0,0 +1,429 @@ +#include "user-interface.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +UserInterface* UserInterface::instance = nullptr; + +constexpr int COLOR_PAIR_DEFAULT = 1; +constexpr int COLOR_PAIR_INVERT = 2; + +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"); + std::cerr << "Failed to initialize ncurses with TERM=" << (term ? term : "unset") << "\n"; + std::exit(EXIT_FAILURE); + } + + this->update_layout(); + + // Setup terminal. + if (cbreak() == ERR || noecho() == ERR) { + endwin(); + std::cerr << "Terminal setup failed\n"; + std::exit(EXIT_FAILURE); + } + + // Enable function keys and arrow keys. + if (keypad(stdscr, TRUE) == ERR) { + std::cerr << "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) { + std::cerr << "Warning: cursor control not supported on this terminal\n"; + } + + if (has_colors()) { + start_color(); + init_pair(COLOR_PAIR_DEFAULT, COLOR_WHITE, COLOR_BLACK); + init_pair(COLOR_PAIR_INVERT, COLOR_BLACK, COLOR_WHITE); + bkgd(COLOR_PAIR(COLOR_PAIR_DEFAULT)); + } + + signal(SIGWINCH, UserInterface::handle_resize); + signal(SIGINT, UserInterface::handle_interrupt); +} + +void UserInterface::cleanup_ncurses(void) { + endwin(); +} + +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(); +} + +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; +} + +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); +} + +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()); +} + +UserInterface::UserInterface() : resize_flag(0), running(0), input_cursor_x(0), layout({}) { + UserInterface::instance = this; +} + +UserInterface::~UserInterface() { + this->cleanup_ncurses(); + UserInterface::instance = nullptr; +} + +bool UserInterface::is_running() { + return this->running == 1; +} + +void UserInterface::handle_resize(int sig) { + if (UserInterface::instance) { + UserInterface::instance->resize_flag = 1; + } + + signal(SIGWINCH, UserInterface::handle_resize); +} + +void UserInterface::handle_interrupt(int sig) { + if (UserInterface::instance) { + UserInterface::instance->running = 0; + endwin(); + exit(EXIT_SUCCESS); + } +} + +void UserInterface::run(void) { + this->init_ncurses(); + + this->draw_ui(); + + this->running = 1; + + this->start_render_thread(); +} + +void UserInterface::end_session(int exit_code) { + this->print("🏁 The session has ended."); + + napms(1000); + + this->stop_render_thread(); + + this->cleanup_ncurses(); + + exit(exit_code); +} + +void UserInterface::print_args(const std::string& model, const std::string& prompt, bool stream, + bool interactive) { + std::string buffer; + buffer += "🛈 model = " + model + "\n"; + buffer += "🛈 prompt = " + prompt + "\n"; + buffer += "🛈 stream = " + std::string(stream ? "true" : "false") + "\n"; + buffer += "🛈 interactive = " + std::string(interactive ? "true" : "false") + "\n"; + + this->print(buffer, UserInterface::MESSAGE_TYPE_NORMAL); +} + +void UserInterface::print_args_noninteractive(const std::string& model, const std::string& prompt, + bool stream, bool interactive) { + printf("\n"); + printf("🛈 model = %s\n", model.c_str()); + printf("🛈 prompt = %s\n", prompt.c_str()); + printf("🛈 stream = %s\n", stream ? "true" : "false"); + printf("🛈 interactive = %s\n", interactive ? "true" : "false"); + printf("\n"); +} + +bool UserInterface::next_prompt(std::string* prompt) { + bool done = false; + + this->request_redraw(); + + this->input_buffer.clear(); + int ch; + + while ((ch = getch()) != '\n') { + if (ch == KEY_BACKSPACE || ch == 127) { + if (!input_buffer.empty()) { + input_buffer.pop_back(); + input_cursor_x--; + 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++; + this->request_redraw(); + } + } + } + + *prompt = this->input_buffer; + + if (*prompt == "q" || *prompt == "Q") { + done = true; + } + + return done; +} + +bool UserInterface::next_prompt_noninteractive(std::string* prompt) { + bool done = false; + printf("⏳ Type your prompt (q/Q to quit): "); + if (!std::getline(std::cin, *prompt, '\n')) { + fprintf(stderr, "Error reading user input.\n"); + exit(1); + } + + if (*prompt == "q" || *prompt == "Q") { + done = true; + } + + return done; +} + +void UserInterface::print(const std::string& text, int type) { + { + std::lock_guard lock(this->ui_mutex); + output_buffer.push_back({text, type}); + } + this->request_redraw(); +} + +void UserInterface::print_error(const std::string& text) { + this->print(text, UserInterface::MESSAGE_TYPE_ERROR); +} + +void UserInterface::print_success(const std::string& text) { + this->print(text, UserInterface::MESSAGE_TYPE_SUCCESS); +} + +void UserInterface::print_message(const std::string& text) { + this->print(text, UserInterface::MESSAGE_TYPE_NORMAL); +} + +void UserInterface::draw_output_area() { + // Clear the output area + for (int i = 0; i < layout.output_height; i++) { + 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) { + 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) { + if (current_line <= layout.output_y) + break; + + if (type == UserInterface::MESSAGE_TYPE_ERROR) { + wattron(stdscr, COLOR_PAIR(COLOR_PAIR_INVERT) | A_BOLD); + mvprintw(current_line, 2, "❌ %s", line_it->c_str()); + wattroff(stdscr, COLOR_PAIR(COLOR_PAIR_INVERT) | 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(); +} + +void UserInterface::start_render_thread(void) { + this->thread_running = true; + this->render_thread = std::thread(&UserInterface::render_loop, this); +} + +void UserInterface::stop_render_thread(void) { + this->thread_running = false; + this->cv_redraw.notify_one(); + if (this->render_thread.joinable()) { + this->render_thread.join(); + } +} + +void UserInterface::request_redraw(void) { + this->should_redraw = true; + this->cv_redraw.notify_one(); +} + +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(); }); + + // Handle resize (under lock) + if (this->resize_flag) { + this->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(); + } +} + +void UserInterface::start_spinner() { + std::lock_guard lock(ui_mutex); + this->spinner_active = true; + this->spinner_frame = 0; + this->should_redraw = true; +} + +void UserInterface::stop_spinner() { + std::lock_guard lock(ui_mutex); + this->spinner_active = false; + this->spinner_frame = 0; + this->should_redraw = true; +} + +void UserInterface::advance_spinner_frame() { + if (this->is_spinner_active()) { + this->spinner_frame = (spinner_frame + 1) % 4; + this->should_redraw = true; + } +} + +bool UserInterface::is_spinner_active() const { + return spinner_active.load(); +} + +void UserInterface::set_progress(int percent) { + this->progress_percent = std::clamp(percent, 0, 100); + this->should_redraw = true; +} + +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(COLOR_PAIR_DEFAULT)); + + 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(COLOR_PAIR_DEFAULT)); +} diff --git a/src/user-interface.hpp b/src/user-interface.hpp new file mode 100644 index 0000000..541f42f --- /dev/null +++ b/src/user-interface.hpp @@ -0,0 +1,114 @@ +#pragma once + +#include "text-utils.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct Layout { + int input_y; // Row for input prompt. + int input_x; // Column for input start. + int output_y; // Row where output begins. + int output_height; + int status_y; // Row for status messages. + int spinner_y; // Row for the progress spinner. + int spinner_x; // Column for the progress spinner. +}; + +class UserInterface { + private: + // Message configuration constants. + static constexpr int MESSAGE_TYPE_NORMAL = 0; + static constexpr int MESSAGE_TYPE_ERROR = 1; + static constexpr int MESSAGE_TYPE_SUCCESS = 2; + + // User Interface state. + volatile sig_atomic_t resize_flag; + volatile sig_atomic_t running; + static UserInterface* 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}; + + // Spinner state. + std::atomic spinner_active{false}; + std::atomic spinner_frame{0}; + std::atomic progress_percent{0}; + + // Initialization methods. + void init_ncurses(void); + void cleanup_ncurses(void); + + // Renderer methods. + void start_render_thread(void); + void stop_render_thread(void); + void render_loop(void); + + // Layout manpulation methods. + void draw_ui(void); + void draw_spinner(void); + void update_layout(); + + // Input area methods. + void draw_input_area(); + void clear_input_area(); + + // Output area methods. + void draw_output_area(); + + public: + UserInterface(); + ~UserInterface(); + + bool is_running(); + + static void handle_resize(int sig); + static void handle_interrupt(int sig); + + void run(void); + void end_session(int exit_code); + + void print_args(const std::string& model, const std::string& prompt, bool stream, + bool interactive); + static void print_args_noninteractive(const std::string& model, const std::string& prompt, + bool stream, bool interactive); + + bool next_prompt(std::string* prompt); + static bool next_prompt_noninteractive(std::string* prompt); + + void print(const std::string& text, int type = UserInterface::MESSAGE_TYPE_NORMAL); + void print_error(const std::string& text); + void print_success(const std::string& text); + void print_message(const std::string& text); + + void request_redraw(void); + + // Spinner methods. + void start_spinner(void); + void stop_spinner(void); + void advance_spinner_frame(void); + bool is_spinner_active(void) const; + void set_progress(int percent); + // Grant friend access to ProgressSpinner + friend class ProgressSpinner; +};