From 81d1a6f0a65c87bb5c127641681480356b2f8732 Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 19:47:09 +0900 Subject: [PATCH 01/14] build: establish shared native terminal contract and VT dependency --- docs/NATIVE_TERMINAL_SPEC.md | 41 +++++++++++++ include/axyne/terminal.h | 90 ++++++++++++++++++++++++++++ src/features/terminal/CMakeLists.txt | 29 +++++++++ 3 files changed, 160 insertions(+) create mode 100644 docs/NATIVE_TERMINAL_SPEC.md create mode 100644 include/axyne/terminal.h create mode 100644 src/features/terminal/CMakeLists.txt diff --git a/docs/NATIVE_TERMINAL_SPEC.md b/docs/NATIVE_TERMINAL_SPEC.md new file mode 100644 index 0000000..6786db4 --- /dev/null +++ b/docs/NATIVE_TERMINAL_SPEC.md @@ -0,0 +1,41 @@ +# Real integrated terminal + +## Decisions + +| Decision | Source | Status | +| --- | --- | --- | +| Real terminal on macOS and Windows, no separate command entry field | USER | CONFIRMED | +| macOS zsh with PTY and a single terminal input/output surface | USER | CONFIRMED | +| Windows ConPTY, retain existing cmd.exe default | DELEGATED | ASSUMED | +| VT screen/keyboard processing through SHA-256-pinned MIT libvterm 0.3.3 release archive | IMPLEMENTATION | CONFIRMED | +| One shell per workspace view, lazy startup on terminal activation | DELEGATED | ASSUMED | +| Use selected workspace as startup directory; home if no workspace | DELEGATED | ASSUMED | +| 2,000 scrollback lines, 1 MiB queued input, bounded screen dimensions | AGENT_PARAMETER | ASSUMED | +| Preserve build/debug/Git subprocess APIs and present their logs as output, not shell output | IMPLEMENTATION | CONFIRMED | +| Do not silently fall back to pipe input on unsupported ConPTY systems | DELEGATED | ASSUMED | + +## Shared base and category + +One feature category: real integrated terminal, branch `feature/native-terminal`. +The shared base is the public screen/session/key/snapshot contract in terminal.h, +the existing status/error conventions and reproducibly built libvterm target. +Validate this base before implementing its native consumers. + +## Implementation units + +1. Shared screen model: VT parsing, Unicode cells, colors, cursor, alternate + screen, bounded history, snapshots, key/paste encoding and regression tests. +2. Session transports: macOS PTY controlling terminal, Windows ConPTY, async + input/output, resize, lifecycle/termination and real shell tests. +3. Native terminal surfaces: direct text/key input, copy/paste, selection, resize, + scrollback, cursor and ANSI rendering; remove separate shell entry/send UI. +4. Cross-platform integration, lifetime/error tests, packaging licenses and + independent review; one PR targeting dev. No automatic merge is requested. + +## Required audit + +Prove isatty/prompt, direct input, Tab/history, Ctrl+C without killing the shell, +screen cursor/ANSI semantics, resizing, shell exit/stop/restart and teardown. +Keep actual terminal output free of `[stderr]` prefixes. Unsupported Windows +versions must report a clear error. Keep runtime UTF-8/CJK and clipboard input +separate from app/editor shortcuts and preserve ordinary build/Git/debug output. diff --git a/include/axyne/terminal.h b/include/axyne/terminal.h new file mode 100644 index 0000000..ce8b506 --- /dev/null +++ b/include/axyne/terminal.h @@ -0,0 +1,90 @@ +#ifndef AXYNE_TERMINAL_H +#define AXYNE_TERMINAL_H + +#include +#include +#include "axyne/status.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct AxyneTerminal AxyneTerminal; +typedef void (*AxyneTerminalChangedFn)(AxyneTerminal *, void *); +typedef void (*AxyneTerminalExitFn)(AxyneTerminal *, int, void *); + +typedef struct AxyneTerminalSpec { + const char *executable; + const char *const *arguments; + size_t argument_count; + const char *working_directory; + AxyneTerminalChangedFn on_changed; + AxyneTerminalExitFn on_exit; + void *user_data; +} AxyneTerminalSpec; + +typedef enum AxyneTerminalKey { + AXYNE_TERMINAL_ENTER = 1, AXYNE_TERMINAL_TAB, + AXYNE_TERMINAL_BACKSPACE, AXYNE_TERMINAL_ESCAPE, + AXYNE_TERMINAL_UP, AXYNE_TERMINAL_DOWN, + AXYNE_TERMINAL_LEFT, AXYNE_TERMINAL_RIGHT, + AXYNE_TERMINAL_INSERT, AXYNE_TERMINAL_DELETE, + AXYNE_TERMINAL_HOME, AXYNE_TERMINAL_END, + AXYNE_TERMINAL_PAGEUP, AXYNE_TERMINAL_PAGEDOWN, + AXYNE_TERMINAL_F1 = 257 +} AxyneTerminalKey; +enum { AXYNE_TERMINAL_SHIFT = 1, AXYNE_TERMINAL_ALT = 2, + AXYNE_TERMINAL_CTRL = 4 }; +enum { AXYNE_TERMINAL_BOLD = 1, AXYNE_TERMINAL_ITALIC = 2, + AXYNE_TERMINAL_UNDERLINE = 4, AXYNE_TERMINAL_REVERSE = 8 }; + +typedef struct AxyneTerminalCell { + uint32_t chars[6]; + uint32_t foreground, background; /* RGB, default palette already resolved */ + unsigned attributes; + int width; /* 0: wide-character continuation; 1 or 2: leading cell */ +} AxyneTerminalCell; + +typedef struct AxyneTerminalSnapshot { + int rows, columns; + int cursor_row, cursor_column, cursor_visible; + int alternate_screen; + int scroll_offset; + size_t history_lines; + AxyneTerminalCell *cells; +} AxyneTerminalSnapshot; + +/* The model supports deterministic VT tests without starting a child. + * Feed/snapshot/key/resize are internally synchronized. Callbacks notify only; + * native adapters marshal repaint/exit to their UI thread. Session output + * changes and exit callbacks run serially on the session reader thread, with + * no model locks held. Exit is delivered once, after output is drained; no + * further session notifications follow it. Callbacks may begin before start + * returns, so create/initialize user_data before calling start. They may query + * snapshots or enqueue input but must not start or release a session. Manual + * model operations (feed without a child, resize, scroll, clear) do not deliver + * notifications: callers schedule their own repaint. Inputs are copied by + * start. user_data stays valid until release finishes. Release terminates the + * session and joins workers; no callbacks can arrive after it returns. */ +AxyneStatus axyne_terminal_create(int rows, int columns, size_t history_limit, + AxyneTerminal **out, AxyneError *error); +AxyneStatus axyne_terminal_start(AxyneTerminal *, const AxyneTerminalSpec *, AxyneError *); +AxyneStatus axyne_terminal_write(AxyneTerminal *, const char *, size_t, AxyneError *); +AxyneStatus axyne_terminal_key(AxyneTerminal *, AxyneTerminalKey, unsigned, AxyneError *); +AxyneStatus axyne_terminal_unichar(AxyneTerminal *, uint32_t, unsigned, AxyneError *); +AxyneStatus axyne_terminal_paste(AxyneTerminal *, const char *, size_t, AxyneError *); +AxyneStatus axyne_terminal_resize(AxyneTerminal *, int rows, int columns, AxyneError *); +void axyne_terminal_feed(AxyneTerminal *, const char *, size_t); +void axyne_terminal_scroll(AxyneTerminal *, int lines); +void axyne_terminal_clear(AxyneTerminal *); +void axyne_terminal_set_colors(AxyneTerminal *, uint32_t foreground, uint32_t background); +int axyne_terminal_running(AxyneTerminal *); +AxyneStatus axyne_terminal_snapshot(AxyneTerminal *, AxyneTerminalSnapshot *, AxyneError *); +void axyne_terminal_snapshot_free(AxyneTerminalSnapshot *); +AxyneStatus axyne_terminal_terminate(AxyneTerminal *, AxyneError *); +void axyne_terminal_release(AxyneTerminal *); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/features/terminal/CMakeLists.txt b/src/features/terminal/CMakeLists.txt new file mode 100644 index 0000000..8f46b33 --- /dev/null +++ b/src/features/terminal/CMakeLists.txt @@ -0,0 +1,29 @@ +# Upstream 0.3.3 release includes generated encoding tables, unlike Git tags. +# Pin its complete archive so Windows needs no Perl/source-generation tools. +FetchContent_Declare(axyne_vterm_source + URL https://www.leonerd.org.uk/code/libvterm/libvterm-0.3.3.tar.gz + URL_HASH SHA256=09156f43dd2128bd347cbeebe50d9a571d32c64e0cf18d211197946aff7226e0) +FetchContent_GetProperties(axyne_vterm_source) +if(NOT axyne_vterm_source_POPULATED) + cmake_policy(PUSH) + if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(axyne_vterm_source) + cmake_policy(POP) +endif() +file(GLOB AXYNE_VTERM_SOURCES CONFIGURE_DEPENDS "${axyne_vterm_source_SOURCE_DIR}/src/*.c") +add_library(axyne_vterm STATIC ${AXYNE_VTERM_SOURCES}) +target_include_directories(axyne_vterm SYSTEM PUBLIC "${axyne_vterm_source_SOURCE_DIR}/include") +target_include_directories(axyne_vterm PRIVATE "${axyne_vterm_source_SOURCE_DIR}/src") +if(MSVC) + target_compile_options(axyne_vterm PRIVATE /utf-8) +endif() +if(APPLE) + install(FILES "${axyne_vterm_source_SOURCE_DIR}/LICENSE" + DESTINATION "${AXYNE_MACOS_BUNDLE_NAME}/Contents/Resources" + RENAME libvterm-LICENSE.txt CONFIGURATIONS Release) +elseif(WIN32) + install(FILES "${axyne_vterm_source_SOURCE_DIR}/LICENSE" + DESTINATION . RENAME libvterm-LICENSE.txt CONFIGURATIONS Release) +endif() From ec2f5146c9472cfaf28d2b210f598d18b3bafc25 Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 20:00:57 +0900 Subject: [PATCH 02/14] feat: add VT terminal model and native shell transports --- docs/NATIVE_TERMINAL_SPEC.md | 6 + src/features/terminal/CMakeLists.txt | 15 ++ src/features/terminal/terminal.cpp | 190 ++++++++++++++++++++ src/features/terminal/terminal_internal.hpp | 35 ++++ src/features/terminal/terminal_posix.cpp | 183 +++++++++++++++++++ src/features/terminal/terminal_windows.cpp | 149 +++++++++++++++ tests/CMakeLists.txt | 18 ++ tests/test_terminal_model.cpp | 37 ++++ tests/test_terminal_session.cpp | 59 ++++++ 9 files changed, 692 insertions(+) create mode 100644 src/features/terminal/terminal.cpp create mode 100644 src/features/terminal/terminal_internal.hpp create mode 100644 src/features/terminal/terminal_posix.cpp create mode 100644 src/features/terminal/terminal_windows.cpp create mode 100644 tests/test_terminal_model.cpp create mode 100644 tests/test_terminal_session.cpp diff --git a/docs/NATIVE_TERMINAL_SPEC.md b/docs/NATIVE_TERMINAL_SPEC.md index 6786db4..1f9973a 100644 --- a/docs/NATIVE_TERMINAL_SPEC.md +++ b/docs/NATIVE_TERMINAL_SPEC.md @@ -34,6 +34,12 @@ Validate this base before implementing its native consumers. ## Required audit +Private terminal implementation uses C++17 standard mutexes, worker threads and +RAII behind the frozen C ABI; existing C17 modules and native C/Objective-C +adapters are unchanged. This is an IMPLEMENTATION choice. Screen bounds are +512 rows and 1024 columns (AGENT_PARAMETER / ASSUMED). Manual model operations +never notify; reader notifications run after releasing model and queue locks. + Prove isatty/prompt, direct input, Tab/history, Ctrl+C without killing the shell, screen cursor/ANSI semantics, resizing, shell exit/stop/restart and teardown. Keep actual terminal output free of `[stderr]` prefixes. Unsupported Windows diff --git a/src/features/terminal/CMakeLists.txt b/src/features/terminal/CMakeLists.txt index 8f46b33..ba7b59a 100644 --- a/src/features/terminal/CMakeLists.txt +++ b/src/features/terminal/CMakeLists.txt @@ -1,5 +1,6 @@ # Upstream 0.3.3 release includes generated encoding tables, unlike Git tags. # Pin its complete archive so Windows needs no Perl/source-generation tools. +enable_language(CXX) FetchContent_Declare(axyne_vterm_source URL https://www.leonerd.org.uk/code/libvterm/libvterm-0.3.3.tar.gz URL_HASH SHA256=09156f43dd2128bd347cbeebe50d9a571d32c64e0cf18d211197946aff7226e0) @@ -12,6 +13,20 @@ if(NOT axyne_vterm_source_POPULATED) FetchContent_Populate(axyne_vterm_source) cmake_policy(POP) endif() +add_library(axyne_terminal STATIC "${CMAKE_CURRENT_LIST_DIR}/terminal.cpp") +target_include_directories(axyne_terminal PUBLIC "${PROJECT_SOURCE_DIR}/include") +target_link_libraries(axyne_terminal PRIVATE axyne_vterm) +set_target_properties(axyne_terminal PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +if(WIN32) + target_sources(axyne_terminal PRIVATE "${CMAKE_CURRENT_LIST_DIR}/terminal_windows.cpp") +else() + find_package(Threads REQUIRED) + target_sources(axyne_terminal PRIVATE "${CMAKE_CURRENT_LIST_DIR}/terminal_posix.cpp") + target_link_libraries(axyne_terminal PRIVATE Threads::Threads) +endif() +if(TARGET axyne_ui) + target_link_libraries(axyne_ui PRIVATE axyne_terminal) +endif() file(GLOB AXYNE_VTERM_SOURCES CONFIGURE_DEPENDS "${axyne_vterm_source_SOURCE_DIR}/src/*.c") add_library(axyne_vterm STATIC ${AXYNE_VTERM_SOURCES}) target_include_directories(axyne_vterm SYSTEM PUBLIC "${axyne_vterm_source_SOURCE_DIR}/include") diff --git a/src/features/terminal/terminal.cpp b/src/features/terminal/terminal.cpp new file mode 100644 index 0000000..ecc2927 --- /dev/null +++ b/src/features/terminal/terminal.cpp @@ -0,0 +1,190 @@ +#include "terminal_internal.hpp" +#include +#include +#include +#include +#include + +static constexpr size_t input_limit = 1024 * 1024; +static bool dimensions(int rows, int cols) { + return rows >= 1 && rows <= 512 && cols >= 1 && cols <= 1024; +} +AxyneStatus terminal_result(AxyneError *e, AxyneStatus s, const char *message) { + if (e) { e->code = s; std::snprintf(e->message, sizeof e->message, "%s", message); } + return s; +} +AxyneStatus terminal_enqueue(AxyneTerminal *t, const char *data, size_t len, AxyneError *e) { + std::lock_guard lock(t->queue_mutex); + if (!t->running || t->stopping) + return terminal_result(e, AXYNE_STATUS_NOT_FOUND, "Terminal session is not running"); + if (len > input_limit - t->queued_bytes) + return terminal_result(e, AXYNE_STATUS_BUSY, "Terminal input queue is full (1 MiB)"); + try { if (len) t->writes.emplace_back(data, len); } + catch (const std::bad_alloc &) { return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); } + t->queued_bytes += len; + t->queue_ready.notify_one(); + return terminal_result(e, AXYNE_STATUS_OK); +} +static void output(const char *s, size_t n, void *context) { + auto *t = static_cast(context); + if (t->encoding) t->encoded.append(s, n); + else if (t->running) terminal_enqueue(t, s, n, nullptr); +} +static int property(VTermProp prop, VTermValue *v, void *context) { + auto *t = static_cast(context); + if (prop == VTERM_PROP_CURSORVISIBLE) t->cursor_visible = v->boolean; + if (prop == VTERM_PROP_ALTSCREEN) { t->alternate = v->boolean; t->scroll = 0; } + return 1; +} +static int pushline(int cols, const VTermScreenCell *cells, void *context) { + auto *t = static_cast(context); + if (!t->history_limit || t->alternate) return 1; + try { t->history.emplace_back(cells, cells + cols); } + catch (const std::bad_alloc &) { return 0; } + if (t->scroll) ++t->scroll; + if (t->history.size() > t->history_limit) t->history.pop_front(); + t->scroll = std::min(t->scroll, static_cast(t->history.size())); + return 1; +} +static int popline(int cols, VTermScreenCell *cells, void *context) { + auto *t = static_cast(context); + if (t->history.empty() || t->alternate) return 0; + const auto &line = t->history.back(); + VTermColor fg, bg; + vterm_state_get_default_colors(vterm_obtain_state(t->vt), &fg, &bg); + for (int c = 0; c < cols; ++c) { + if (c < static_cast(line.size())) cells[c] = line[c]; + else { cells[c] = {}; cells[c].width = 1; cells[c].fg = fg; cells[c].bg = bg; } + } + t->history.pop_back(); + t->scroll = std::min(t->scroll, static_cast(t->history.size())); + return 1; +} +static int clear_history(void *context) { + auto *t = static_cast(context); + t->history.clear(); t->scroll = 0; return 1; +} +static const VTermScreenCallbacks callbacks = { + nullptr, nullptr, nullptr, property, nullptr, nullptr, pushline, popline, clear_history +}; +extern "C" AxyneStatus axyne_terminal_create(int rows, int cols, size_t limit, + AxyneTerminal **out, AxyneError *e) { + if (!out || !dimensions(rows, cols) || limit > 2000) + return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT, "Terminal dimensions exceed 512 rows / 1024 columns or history exceeds 2000 lines"); + *out = nullptr; + auto *t = new (std::nothrow) AxyneTerminal; + if (!t) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + t->vt = vterm_new(rows, cols); + if (!t->vt) { delete t; return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); } + t->history_limit = limit; + vterm_set_utf8(t->vt, 1); + t->screen = vterm_obtain_screen(t->vt); + vterm_screen_enable_altscreen(t->screen, 1); + vterm_screen_set_callbacks(t->screen, &callbacks, t); + vterm_output_set_callback(t->vt, output, t); + vterm_screen_reset(t->screen, 1); + *out = t; + return terminal_result(e, AXYNE_STATUS_OK); +} +extern "C" void axyne_terminal_feed(AxyneTerminal *t, const char *s, size_t n) { + if (!t || (!s && n)) return; + std::lock_guard lock(t->model_mutex); + vterm_input_write(t->vt, s, n); vterm_screen_flush_damage(t->screen); +} +extern "C" AxyneStatus axyne_terminal_write(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) { + if (!t || (!s && n)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return terminal_enqueue(t, s, n, e); +} +template static AxyneStatus encode(AxyneTerminal *t, F f, AxyneError *e) { + if (!t) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + std::lock_guard lock(t->model_mutex); + t->encoding = true; t->encoded.clear(); + try { f(); } + catch (const std::bad_alloc &) { t->encoding = false; return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); } + t->encoding = false; t->scroll = 0; + return terminal_enqueue(t, t->encoded.data(), t->encoded.size(), e); +} +extern "C" AxyneStatus axyne_terminal_key(AxyneTerminal *t, AxyneTerminalKey k, unsigned mods, AxyneError *e) { + if ((k < 1 || k > AXYNE_TERMINAL_PAGEDOWN) && (k < 257 || k > 280)) + return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return encode(t, [&]{ vterm_keyboard_key(t->vt, static_cast(k), static_cast(mods & 7)); }, e); +} +extern "C" AxyneStatus axyne_terminal_unichar(AxyneTerminal *t, uint32_t c, unsigned mods, AxyneError *e) { + if (c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return encode(t, [&]{ vterm_keyboard_unichar(t->vt, c, static_cast(mods & 7)); }, e); +} +extern "C" AxyneStatus axyne_terminal_paste(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) { + if ((!s && n) || n > input_limit - 12) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return encode(t, [&]{ vterm_keyboard_start_paste(t->vt); t->encoded.append(s ? s : "", n); vterm_keyboard_end_paste(t->vt); }, e); +} +extern "C" AxyneStatus axyne_terminal_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) { + if (!t || !dimensions(rows, cols)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + std::lock_guard lock(t->model_mutex); + vterm_set_size(t->vt, rows, cols); vterm_screen_flush_damage(t->screen); + return t->running ? terminal_transport_resize(t, rows, cols, e) : terminal_result(e, AXYNE_STATUS_OK); +} +extern "C" void axyne_terminal_scroll(AxyneTerminal *t, int lines) { + if (!t) return; + std::lock_guard lock(t->model_mutex); + t->scroll = t->alternate ? 0 : static_cast(std::clamp(static_cast(t->scroll) + lines, 0LL, static_cast(t->history.size()))); +} +extern "C" void axyne_terminal_clear(AxyneTerminal *t) { + if (!t) return; + std::lock_guard lock(t->model_mutex); + t->history.clear(); t->scroll = 0; vterm_screen_reset(t->screen, 1); +} +extern "C" void axyne_terminal_set_colors(AxyneTerminal *t, uint32_t fg, uint32_t bg) { + if (!t) return; + std::lock_guard lock(t->model_mutex); + VTermColor f, b; vterm_color_rgb(&f, fg >> 16, fg >> 8, fg); vterm_color_rgb(&b, bg >> 16, bg >> 8, bg); + vterm_screen_set_default_colors(t->screen, &f, &b); +} +static uint32_t rgb(AxyneTerminal *t, VTermColor color) { + vterm_screen_convert_color_to_rgb(t->screen, &color); + return (color.rgb.red << 16) | (color.rgb.green << 8) | color.rgb.blue; +} +extern "C" AxyneStatus axyne_terminal_snapshot(AxyneTerminal *t, AxyneTerminalSnapshot *s, AxyneError *e) { + if (!t || !s) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + std::lock_guard lock(t->model_mutex); + *s = {}; vterm_get_size(t->vt, &s->rows, &s->columns); + s->cells = static_cast(std::calloc(static_cast(s->rows) * s->columns, sizeof *s->cells)); + if (!s->cells) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + VTermPos pos; vterm_state_get_cursorpos(vterm_obtain_state(t->vt), &pos); + s->cursor_row = pos.row; s->cursor_column = pos.col; s->cursor_visible = t->cursor_visible && !t->scroll; + s->alternate_screen = t->alternate; s->scroll_offset = t->scroll; s->history_lines = t->history.size(); + VTermColor fg, bg; vterm_state_get_default_colors(vterm_obtain_state(t->vt), &fg, &bg); + for (int r = 0; r < s->rows; ++r) for (int c = 0; c < s->columns; ++c) { + VTermScreenCell cell{}; cell.width = 1; cell.fg = fg; cell.bg = bg; + int line = static_cast(t->history.size()) - t->scroll + r; + if (line < static_cast(t->history.size())) { + const auto &h = t->history[line]; if (c < static_cast(h.size())) cell = h[c]; + } else vterm_screen_get_cell(t->screen, {r - t->scroll, c}, &cell); + auto &dest = s->cells[r * s->columns + c]; + std::memcpy(dest.chars, cell.chars, sizeof dest.chars); + dest.width = cell.chars[0] == UINT32_MAX ? 0 : cell.width; + if (!dest.width) std::memset(dest.chars, 0, sizeof dest.chars); + dest.foreground = rgb(t, cell.fg); dest.background = rgb(t, cell.bg); + dest.attributes = (cell.attrs.bold ? AXYNE_TERMINAL_BOLD : 0) | (cell.attrs.italic ? AXYNE_TERMINAL_ITALIC : 0) | + (cell.attrs.underline ? AXYNE_TERMINAL_UNDERLINE : 0) | (cell.attrs.reverse ? AXYNE_TERMINAL_REVERSE : 0); + } + return terminal_result(e, AXYNE_STATUS_OK); +} +extern "C" void axyne_terminal_snapshot_free(AxyneTerminalSnapshot *s) { if (s) { std::free(s->cells); *s = {}; } } +extern "C" int axyne_terminal_running(AxyneTerminal *t) { return t && t->running; } +extern "C" AxyneStatus axyne_terminal_start(AxyneTerminal *t, const AxyneTerminalSpec *spec, AxyneError *e) { + if (!t || !spec || !spec->executable || (spec->argument_count && !spec->arguments)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + if (t->running) return terminal_result(e, AXYNE_STATUS_BUSY); + terminal_transport_destroy(t); + t->changed = spec->on_changed; t->exited = spec->on_exit; t->context = spec->user_data; + t->stopping = false; + { std::lock_guard lock(t->queue_mutex); t->writes.clear(); t->queued_bytes = 0; } + return terminal_transport_start(t, spec, e); +} +extern "C" AxyneStatus axyne_terminal_terminate(AxyneTerminal *t, AxyneError *e) { + if (!t) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + terminal_transport_stop(t); return terminal_result(e, AXYNE_STATUS_OK); +} +extern "C" void axyne_terminal_release(AxyneTerminal *t) { + if (!t) return; + terminal_transport_destroy(t); vterm_free(t->vt); delete t; +} diff --git a/src/features/terminal/terminal_internal.hpp b/src/features/terminal/terminal_internal.hpp new file mode 100644 index 0000000..a9fd448 --- /dev/null +++ b/src/features/terminal/terminal_internal.hpp @@ -0,0 +1,35 @@ +#pragma once +#include "axyne/terminal.h" +#include +#include +#include +#include +#include +#include +#include +#include + +struct AxyneTerminal { + std::mutex model_mutex, queue_mutex; + std::condition_variable queue_ready; + VTerm *vt = nullptr; + VTermScreen *screen = nullptr; + std::deque> history; + size_t history_limit = 0, queued_bytes = 0; + int scroll = 0, cursor_visible = 1, alternate = 0; + std::deque writes; + std::string encoded; + bool encoding = false; + std::atomic running{false}, stopping{false}; + std::thread reader, writer; + AxyneTerminalChangedFn changed = nullptr; + AxyneTerminalExitFn exited = nullptr; + void *context = nullptr; + void *transport = nullptr; +}; +AxyneStatus terminal_result(AxyneError *, AxyneStatus, const char * = ""); +AxyneStatus terminal_enqueue(AxyneTerminal *, const char *, size_t, AxyneError *); +AxyneStatus terminal_transport_start(AxyneTerminal *, const AxyneTerminalSpec *, AxyneError *); +AxyneStatus terminal_transport_resize(AxyneTerminal *, int, int, AxyneError *); +void terminal_transport_stop(AxyneTerminal *); +void terminal_transport_destroy(AxyneTerminal *); diff --git a/src/features/terminal/terminal_posix.cpp b/src/features/terminal/terminal_posix.cpp new file mode 100644 index 0000000..f669c50 --- /dev/null +++ b/src/features/terminal/terminal_posix.cpp @@ -0,0 +1,183 @@ +#include "terminal_internal.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; +struct PosixTerminal { + std::mutex child_mutex; + int master = -1; + pid_t child = -1; // Kept unreaped until session teardown; cannot be reused. + bool child_exited = false; +}; +static void nonblocking(int fd) { fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); } +static void cloexec(int fd) { fcntl(fd, F_SETFD, FD_CLOEXEC); } +static AxyneStatus failure(AxyneError *e, const char *what) { + std::string message = std::string(what) + ": " + std::strerror(errno); + return terminal_result(e, AXYNE_STATUS_IO_ERROR, message.c_str()); +} +static void writer(AxyneTerminal *t, PosixTerminal *p) { + // Ignore SIGPIPE on this worker only; do not change the application's policy. + sigset_t set; sigemptyset(&set); sigaddset(&set, SIGPIPE); pthread_sigmask(SIG_BLOCK, &set, nullptr); + while (!t->stopping) { + std::string data; + { + std::unique_lock lock(t->queue_mutex); + t->queue_ready.wait(lock, [&]{ return t->stopping || !t->writes.empty(); }); + if (t->stopping) break; + data = std::move(t->writes.front()); t->writes.pop_front(); + } + size_t at = 0; + while (at < data.size() && !t->stopping) { + pollfd fd{p->master, POLLOUT, 0}; + int ready = poll(&fd, 1, 50); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0 || (fd.revents & (POLLHUP | POLLERR | POLLNVAL))) break; + if (!ready) continue; + ssize_t n = write(p->master, data.data() + at, data.size() - at); + if (n > 0) at += static_cast(n); + else if (n < 0 && errno != EAGAIN && errno != EINTR) break; + } + { std::lock_guard lock(t->queue_mutex); t->queued_bytes -= data.size(); } + } +} +static void reader(AxyneTerminal *t, PosixTerminal *p) { + using Clock = std::chrono::steady_clock; + auto end = Clock::time_point::max(); + bool eof = false; + int code = -1; + while (!eof) { + // WNOWAIT deliberately retains PID/session ownership until stop and join. + siginfo_t info{}; + { std::lock_guard lock(p->child_mutex); + if (!p->child_exited && waitid(P_PID, p->child, &info, WEXITED | WNOHANG | WNOWAIT) == 0 && info.si_pid) { + p->child_exited = true; + code = info.si_code == CLD_EXITED ? info.si_status : 128 + info.si_status; + end = Clock::now() + std::chrono::milliseconds(500); + } + } + if (t->stopping && end == Clock::time_point::max()) end = Clock::now() + std::chrono::milliseconds(500); + pollfd fd{p->master, POLLIN, 0}; + int ready = poll(&fd, 1, 50); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0) break; + if (ready && (fd.revents & (POLLIN | POLLHUP | POLLERR))) { + char buffer[16384]; + // Bounded per turn even when a child produces output continuously. + for (int batch = 0; batch < 16; ++batch) { + ssize_t n = read(p->master, buffer, sizeof buffer); + if (n > 0) { + axyne_terminal_feed(t, buffer, static_cast(n)); + if (t->changed) t->changed(t, t->context); + } else { + if (n == 0 || (errno != EINTR && errno != EAGAIN)) eof = true; + break; + } + } + } + if (Clock::now() >= end) break; + } + t->running = false; t->stopping = true; t->queue_ready.notify_all(); + if (t->exited) t->exited(t, code, t->context); +} +AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec *s, AxyneError *e) { + auto *p = new (std::nothrow) PosixTerminal; + if (!p) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + p->master = posix_openpt(O_RDWR | O_NOCTTY | O_CLOEXEC); + if (p->master < 0 || grantpt(p->master) || unlockpt(p->master)) { + auto result = failure(e, "Cannot allocate PTY"); if (p->master >= 0) close(p->master); delete p; return result; + } + // All strings, argv, and environment are constructed before fork. + std::string slave_name = ptsname(p->master); + std::vector env; + for (char **entry = environ; *entry; ++entry) { + if (std::strncmp(*entry, "TERM=", 5) && std::strncmp(*entry, "COLORTERM=", 10)) env.emplace_back(*entry); + } + env.emplace_back("TERM=xterm-256color"); env.emplace_back("COLORTERM=truecolor"); + std::vector envp; for (auto &entry : env) envp.push_back(entry.data()); envp.push_back(nullptr); + std::vector args; args.emplace_back(s->executable); + for (size_t i = 0; i < s->argument_count; ++i) { + if (!s->arguments[i]) { close(p->master); delete p; return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); } + args.emplace_back(s->arguments[i]); + } + std::vector argv; for (auto &arg : args) argv.push_back(arg.data()); argv.push_back(nullptr); + std::string cwd = s->working_directory ? s->working_directory : ""; + int handshake[2]; + if (pipe(handshake)) { auto result = failure(e, "Cannot create startup handshake"); close(p->master); delete p; return result; } + cloexec(handshake[0]); cloexec(handshake[1]); + int rows, cols; { std::lock_guard lock(t->model_mutex); vterm_get_size(t->vt, &rows, &cols); } + winsize size{}; size.ws_row = static_cast(rows); size.ws_col = static_cast(cols); + p->child = fork(); + if (!p->child) { + close(handshake[0]); + int saved = 0; + if (setsid() < 0) saved = errno; + int slave = saved ? -1 : open(slave_name.c_str(), O_RDWR); + if (slave < 0 && !saved) saved = errno; + if (!saved && ioctl(slave, TIOCSCTTY, 0) < 0) saved = errno; + if (!saved && ioctl(slave, TIOCSWINSZ, &size) < 0) saved = errno; + for (int fd = 0; fd < 3 && !saved; ++fd) if (dup2(slave, fd) < 0) saved = errno; + if (slave > 2) close(slave); + close(p->master); + if (!saved && !cwd.empty() && chdir(cwd.c_str())) saved = errno; + if (!saved) { + sigset_t empty; sigemptyset(&empty); sigprocmask(SIG_SETMASK, &empty, nullptr); + for (int signal : {SIGINT, SIGQUIT, SIGTERM, SIGHUP, SIGPIPE, SIGTSTP, SIGTTIN, SIGTTOU}) ::signal(signal, SIG_DFL); + execve(argv[0], argv.data(), envp.data()); saved = errno; + } + (void)write(handshake[1], &saved, sizeof saved); _exit(127); + } + close(handshake[1]); + if (p->child < 0) { auto result = failure(e, "Cannot fork terminal"); close(handshake[0]); close(p->master); delete p; return result; } + int saved = 0; ssize_t count; + do { count = read(handshake[0], &saved, sizeof saved); } while (count < 0 && errno == EINTR); + close(handshake[0]); + if (count != 0) { + if (count < 0) saved = errno; + kill(p->child, SIGKILL); while (waitpid(p->child, nullptr, 0) < 0 && errno == EINTR) {} + close(p->master); delete p; errno = saved; return failure(e, "Cannot start terminal shell"); + } + nonblocking(p->master); t->transport = p; t->running = true; + try { t->writer = std::thread(writer, t, p); t->reader = std::thread(reader, t, p); } + catch (...) { terminal_transport_destroy(t); return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Cannot create terminal workers"); } + return terminal_result(e, AXYNE_STATUS_OK); +} +AxyneStatus terminal_transport_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) { + auto *p = static_cast(t->transport); + winsize size{}; size.ws_row = static_cast(rows); size.ws_col = static_cast(cols); + if (p && ioctl(p->master, TIOCSWINSZ, &size) < 0) return failure(e, "Cannot resize terminal"); + return terminal_result(e, AXYNE_STATUS_OK); +} +void terminal_transport_stop(AxyneTerminal *t) { + auto *p = static_cast(t->transport); + t->stopping = true; t->queue_ready.notify_all(); + if (!p) return; + std::lock_guard lock(p->child_mutex); + // The shell's session/process-group leader remains owned and unreaped. + // A terminal hangup reaches its foreground group via the tty driver; + // do not signal a numeric foreground PGID which might have been reused. + if (!p->child_exited) kill(p->child, SIGHUP); + kill(-p->child, SIGKILL); + kill(p->child, SIGKILL); +} +void terminal_transport_destroy(AxyneTerminal *t) { + terminal_transport_stop(t); + if (t->reader.joinable()) t->reader.join(); + if (t->writer.joinable()) t->writer.join(); + auto *p = static_cast(t->transport); + if (p) { + // Closing the controlling master hangs up any remaining foreground job. + close(p->master); + while (waitpid(p->child, nullptr, 0) < 0 && errno == EINTR) {} + delete p; t->transport = nullptr; + } + t->running = false; +} diff --git a/src/features/terminal/terminal_windows.cpp b/src/features/terminal/terminal_windows.cpp new file mode 100644 index 0000000..c02a0b3 --- /dev/null +++ b/src/features/terminal/terminal_windows.cpp @@ -0,0 +1,149 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include "terminal_internal.hpp" +#include +#include + +using CreatePC = HRESULT (WINAPI *)(COORD, HANDLE, HANDLE, DWORD, HANDLE *); +using ResizePC = HRESULT (WINAPI *)(HANDLE, COORD); +using ClosePC = void (WINAPI *)(HANDLE); +struct WindowsTerminal { + HANDLE input = nullptr, output = nullptr, process = nullptr, job = nullptr, pc = nullptr; + ResizePC resize = nullptr; ClosePC close = nullptr; + std::mutex pc_mutex; + std::thread closer; + std::atomic closed{false}; + DWORD exit_code = 0; +}; +static AxyneStatus failure(AxyneError *e, const char *message) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, message); } +static std::wstring wide(const char *s) { + if (!s) return {}; + int n = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, -1, nullptr, 0); + if (!n) return {}; + std::wstring result(n, 0); MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, -1, result.data(), n); result.resize(n-1); return result; +} +static std::wstring quote(const std::wstring &s) { + std::wstring result = L"\""; size_t slashes = 0; + for (wchar_t c : s) { + if (c == L'\\') { ++slashes; continue; } + result.append(c == L'"' ? slashes * 2 + 1 : slashes, L'\\'); slashes = 0; result += c; + } + result.append(slashes * 2, L'\\'); result += L'"'; return result; +} +static void writer(AxyneTerminal *t, WindowsTerminal *p) { + while (!t->stopping) { + std::string data; + { std::unique_lock lock(t->queue_mutex); + t->queue_ready.wait(lock, [&]{ return t->stopping || !t->writes.empty(); }); + if (t->stopping) break; + data = std::move(t->writes.front()); t->writes.pop_front(); } + size_t at = 0; + while (at < data.size() && !t->stopping) { + DWORD n = 0; + if (!WriteFile(p->input, data.data()+at, static_cast(data.size()-at), &n, nullptr) || !n) break; + at += n; + } + { std::lock_guard lock(t->queue_mutex); t->queued_bytes -= data.size(); } + } +} +static void reader(AxyneTerminal *t, WindowsTerminal *p) { + for (;;) { + DWORD available = 0; + if (!PeekNamedPipe(p->output, nullptr, 0, nullptr, &available, nullptr)) break; + if (available) { + char buffer[16384]; DWORD n = 0; + if (!ReadFile(p->output, buffer, sizeof buffer, &n, nullptr) || !n) break; + axyne_terminal_feed(t, buffer, n); + if (t->changed) t->changed(t, t->context); + } else { + if (p->closed) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + // Exit is serialized behind the final frame and ClosePseudoConsole. + while (!p->closed) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + t->running = false; + if (t->exited) t->exited(t, static_cast(p->exit_code), t->context); +} +static void closer(AxyneTerminal *t, WindowsTerminal *p) { + while (!t->stopping && WaitForSingleObject(p->process, 20) == WAIT_TIMEOUT) {} + t->stopping = true; t->queue_ready.notify_all(); + TerminateJobObject(p->job, 1); + // Cancel synchronous writer I/O before joining, keeping the reader alive + // while ClosePseudoConsole emits its final frame (Microsoft's contract). + if (t->writer.joinable()) CancelSynchronousIo(t->writer.native_handle()); + GetExitCodeProcess(p->process, &p->exit_code); + { std::lock_guard lock(p->pc_mutex); if (p->pc) { p->close(p->pc); p->pc = nullptr; } } + p->closed = true; +} +AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec *s, AxyneError *e) { + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + auto create = reinterpret_cast(GetProcAddress(kernel, "CreatePseudoConsole")); + auto resize = reinterpret_cast(GetProcAddress(kernel, "ResizePseudoConsole")); + auto closepc = reinterpret_cast(GetProcAddress(kernel, "ClosePseudoConsole")); + if (!create || !resize || !closepc) return terminal_result(e, AXYNE_STATUS_UNSUPPORTED, "Real terminals require Windows 10 1809 or later (ConPTY)"); + std::wstring executable = wide(s->executable), cwd = wide(s->working_directory), command = quote(executable); + for (size_t i = 0; i < s->argument_count; ++i) { + if (!s->arguments[i]) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + command += L" " + quote(wide(s->arguments[i])); + } + auto *p = new WindowsTerminal; p->resize = resize; p->close = closepc; t->transport = p; + HANDLE inread = nullptr, outwrite = nullptr; + if (!CreatePipe(&inread, &p->input, nullptr, 0) || !CreatePipe(&p->output, &outwrite, nullptr, 0)) { + if (inread) CloseHandle(inread); if (outwrite) CloseHandle(outwrite); terminal_transport_destroy(t); return failure(e, "Cannot create ConPTY channels"); + } + int rows, cols; { std::lock_guard lock(t->model_mutex); vterm_get_size(t->vt, &rows, &cols); } + HRESULT hr = create({static_cast(cols), static_cast(rows)}, inread, outwrite, 0, &p->pc); + CloseHandle(inread); CloseHandle(outwrite); + if (FAILED(hr)) { terminal_transport_destroy(t); return failure(e, "Cannot create pseudoconsole"); } + // Start the drain thread before any startup error can close the console. + try { t->reader = std::thread(reader, t, p); } + catch (...) { CloseHandle(p->output); p->output = nullptr; terminal_transport_destroy(t); return failure(e, "Cannot create terminal reader"); } + SIZE_T bytes = 0; InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); + std::vector storage(bytes); + STARTUPINFOEXW si{}; si.StartupInfo.cb = sizeof si; + si.lpAttributeList = reinterpret_cast(storage.data()); + bool initialized = InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &bytes) != 0; + bool ready = initialized && UpdateProcThreadAttribute(si.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, p->pc, sizeof p->pc, nullptr, nullptr); + p->job = CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ready = ready && p->job && SetInformationJobObject(p->job, JobObjectExtendedLimitInformation, &limits, sizeof limits); + PROCESS_INFORMATION pi{}; + bool spawned = ready && CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, nullptr, cwd.empty() ? nullptr : cwd.c_str(), &si.StartupInfo, &pi); + if (initialized) DeleteProcThreadAttributeList(si.lpAttributeList); + if (!spawned) { terminal_transport_destroy(t); return failure(e, "Cannot start terminal shell"); } + p->process = pi.hProcess; + if (!AssignProcessToJobObject(p->job, pi.hProcess)) { + TerminateProcess(pi.hProcess, 1); CloseHandle(pi.hThread); terminal_transport_destroy(t); return failure(e, "Cannot own terminal process tree"); + } + t->running = true; + try { t->writer = std::thread(writer, t, p); p->closer = std::thread(closer, t, p); } + catch (...) { TerminateProcess(pi.hProcess, 1); CloseHandle(pi.hThread); terminal_transport_destroy(t); return failure(e, "Cannot create terminal workers"); } + ResumeThread(pi.hThread); CloseHandle(pi.hThread); return terminal_result(e, AXYNE_STATUS_OK); +} +AxyneStatus terminal_transport_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) { + auto *p = static_cast(t->transport); + std::lock_guard lock(p->pc_mutex); + return p->pc && FAILED(p->resize(p->pc, {static_cast(cols), static_cast(rows)})) ? failure(e, "Cannot resize pseudoconsole") : terminal_result(e, AXYNE_STATUS_OK); +} +void terminal_transport_stop(AxyneTerminal *t) { + t->stopping = true; t->queue_ready.notify_all(); + auto *p = static_cast(t->transport); + if (p && p->job) TerminateJobObject(p->job, 1); + if (t->writer.joinable()) CancelSynchronousIo(t->writer.native_handle()); +} +void terminal_transport_destroy(AxyneTerminal *t) { + terminal_transport_stop(t); + auto *p = static_cast(t->transport); + if (!p) return; + if (p->closer.joinable()) p->closer.join(); + else { + if (p->pc) { p->close(p->pc); p->pc = nullptr; } + p->closed = true; + } + if (t->reader.joinable()) t->reader.join(); + if (t->writer.joinable()) { CancelSynchronousIo(t->writer.native_handle()); t->writer.join(); } + for (HANDLE handle : {p->input, p->output, p->process, p->job}) if (handle) CloseHandle(handle); + delete p; t->transport = nullptr; t->running = false; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4e41db2..b409f3a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,21 @@ +add_executable(axyne_terminal_model_tests test_terminal_model.cpp) +target_link_libraries(axyne_terminal_model_tests PRIVATE axyne_terminal axyne_vterm) +set_target_properties(axyne_terminal_model_tests PROPERTIES CXX_STANDARD 17) +add_test(NAME axyne-terminal-model COMMAND axyne_terminal_model_tests) +set_tests_properties(axyne-terminal-model PROPERTIES TIMEOUT 30) +add_executable(axyne_terminal_session_tests test_terminal_session.cpp) +target_link_libraries(axyne_terminal_session_tests PRIVATE axyne_terminal) +set_target_properties(axyne_terminal_session_tests PROPERTIES CXX_STANDARD 17) +add_test(NAME axyne-terminal-session COMMAND axyne_terminal_session_tests) +set_tests_properties(axyne-terminal-session PROPERTIES TIMEOUT 40) +if(MSVC) + target_compile_options(axyne_terminal_model_tests PRIVATE /UNDEBUG) + target_compile_options(axyne_terminal_session_tests PRIVATE /UNDEBUG) +else() + target_compile_options(axyne_terminal_model_tests PRIVATE -UNDEBUG) + target_compile_options(axyne_terminal_session_tests PRIVATE -UNDEBUG) +endif() + add_executable(axyne_tests test_main.c test_documents_search.c diff --git a/tests/test_terminal_model.cpp b/tests/test_terminal_model.cpp new file mode 100644 index 0000000..735798a --- /dev/null +++ b/tests/test_terminal_model.cpp @@ -0,0 +1,37 @@ +#include "../src/features/terminal/terminal_internal.hpp" +#include +#include +#include +static void feed(AxyneTerminal *t, const char *s) { axyne_terminal_feed(t, s, std::strlen(s)); } +static AxyneTerminalSnapshot snapshot(AxyneTerminal *t) { + AxyneTerminalSnapshot s{}; assert(axyne_terminal_snapshot(t, &s, nullptr) == AXYNE_STATUS_OK); return s; +} +int main() { + AxyneTerminal *t = nullptr; + assert(axyne_terminal_create(4, 20, 8, &t, nullptr) == AXYNE_STATUS_OK); + feed(t, "abc\rX\033[2;4HY\033[38;2;12;34;56m\033[48;5;196m\033[1;4mZ"); + auto s = snapshot(t); + assert(s.cells[0].chars[0] == 'X' && s.cells[1].chars[0] == 'b'); + assert(s.cells[23].chars[0] == 'Y' && s.cells[24].chars[0] == 'Z'); + assert(s.cells[24].foreground == 0x0c2238 && s.cells[24].background == 0xff0000); + assert((s.cells[24].attributes & 5) == 5); axyne_terminal_snapshot_free(&s); + feed(t, "\033[0m\033[2J\033[H"); + axyne_terminal_feed(t, "\xe4", 1); axyne_terminal_feed(t, "\xb8\xad", 2); + s = snapshot(t); assert(s.cells[0].chars[0] == 0x4e2d && s.cells[0].width == 2 && s.cells[1].width == 0); + axyne_terminal_snapshot_free(&s); + feed(t, "\033[?1049hALT"); s = snapshot(t); assert(s.alternate_screen); axyne_terminal_snapshot_free(&s); + feed(t, "\033[?1049l"); s = snapshot(t); assert(!s.alternate_screen && s.cells[0].chars[0] == 0x4e2d); axyne_terminal_snapshot_free(&s); + feed(t, "\033[?1h"); axyne_terminal_key(t, AXYNE_TERMINAL_UP, 0, nullptr); assert(t->encoded == "\033OA"); + feed(t, "\033[?1l"); axyne_terminal_key(t, AXYNE_TERMINAL_UP, 0, nullptr); assert(t->encoded == "\033[A"); + feed(t, "\033[?2004h"); axyne_terminal_paste(t, "hello", 5, nullptr); assert(t->encoded == "\033[200~hello\033[201~"); + axyne_terminal_unichar(t, 'c', AXYNE_TERMINAL_CTRL, nullptr); assert(t->encoded == "\003"); + axyne_terminal_clear(t); feed(t, "one\r\ntwo\r\nthree\r\nfour\r\nfive\r\n"); + s = snapshot(t); assert(s.history_lines == 2); axyne_terminal_snapshot_free(&s); + axyne_terminal_scroll(t, 2); s = snapshot(t); assert(s.cells[0].chars[0] == 'o' && !s.cursor_visible); axyne_terminal_snapshot_free(&s); + assert(axyne_terminal_resize(t, 6, 30, nullptr) == AXYNE_STATUS_OK); + s = snapshot(t); assert(s.rows == 6 && s.columns == 30); axyne_terminal_snapshot_free(&s); + assert(axyne_terminal_resize(t, 513, 20, nullptr) == AXYNE_STATUS_INVALID_ARGUMENT); + axyne_terminal_set_colors(t, 0xabcdef, 0x123456); axyne_terminal_clear(t); + s = snapshot(t); assert(s.cells[0].foreground == 0xabcdef && s.cells[0].background == 0x123456); axyne_terminal_snapshot_free(&s); + axyne_terminal_release(t); std::cout << "VT model tests passed\n"; +} diff --git a/tests/test_terminal_session.cpp b/tests/test_terminal_session.cpp new file mode 100644 index 0000000..7ac9401 --- /dev/null +++ b/tests/test_terminal_session.cpp @@ -0,0 +1,59 @@ +#include "axyne/terminal.h" +#include +#include +#include +#include +#include +#include +using Clock = std::chrono::steady_clock; +static std::string text(AxyneTerminal *t) { + AxyneTerminalSnapshot s{}; assert(axyne_terminal_snapshot(t, &s, nullptr) == AXYNE_STATUS_OK); + std::string result; + for (int r = 0; r < s.rows; ++r) { + for (int c = 0; c < s.columns; ++c) { uint32_t ch = s.cells[r*s.columns+c].chars[0]; result += ch && ch < 128 ? char(ch) : ' '; } + result += '\n'; + } + axyne_terminal_snapshot_free(&s); return result; +} +static void await_text(AxyneTerminal *t, const char *expected) { + auto deadline = Clock::now() + std::chrono::seconds(5); + while (text(t).find(expected) == std::string::npos && Clock::now() < deadline) std::this_thread::sleep_for(std::chrono::milliseconds(20)); + if (text(t).find(expected) == std::string::npos) { std::cerr << text(t); std::abort(); } +} +static void send(AxyneTerminal *t, const char *s) { assert(axyne_terminal_write(t, s, std::strlen(s), nullptr) == AXYNE_STATUS_OK); } +int main() { + AxyneTerminal *t; assert(axyne_terminal_create(24, 100, 2000, &t, nullptr) == AXYNE_STATUS_OK); +#ifdef _WIN32 + const char *args[] = {"/Q"}; AxyneTerminalSpec spec{"C:\\Windows\\System32\\cmd.exe", args, 1, nullptr, nullptr, nullptr, nullptr}; +#else + const char *args[] = {"-f", "-i"}; AxyneTerminalSpec spec{"/bin/zsh", args, 2, "/tmp", nullptr, nullptr, nullptr}; +#endif + AxyneError e{}; assert(axyne_terminal_start(t, &spec, &e) == AXYNE_STATUS_OK); +#ifdef _WIN32 + send(t, "echo AXYNE_READY\r"); await_text(t, "AXYNE_READY"); +#else + send(t, "unsetopt zle; PS1='AXYNE_PROMPT> '; setopt zle\r"); await_text(t, "AXYNE_PROMPT>"); + send(t, "[[ -t 0 && -t 1 && -t 2 ]] && print TTY_OK; print CWD:$PWD\r"); await_text(t, "TTY_OK"); await_text(t, "CWD:/"); + send(t, "print HISTORY_OK\r"); await_text(t, "HISTORY_OK"); + assert(axyne_terminal_key(t, AXYNE_TERMINAL_UP, 0, nullptr) == AXYNE_STATUS_OK); + assert(axyne_terminal_key(t, AXYNE_TERMINAL_ENTER, 0, nullptr) == AXYNE_STATUS_OK); + send(t, "sleep 20\r"); std::this_thread::sleep_for(std::chrono::milliseconds(150)); + assert(axyne_terminal_unichar(t, 'c', AXYNE_TERMINAL_CTRL, nullptr) == AXYNE_STATUS_OK); + send(t, "print INTERRUPT_OK\r"); await_text(t, "INTERRUPT_OK"); assert(axyne_terminal_running(t)); +#endif + assert(axyne_terminal_resize(t, 30, 120, nullptr) == AXYNE_STATUS_OK); + send(t, "echo FINAL_DRAIN; exit\r"); + auto deadline = Clock::now() + std::chrono::seconds(5); + while (axyne_terminal_running(t) && Clock::now() < deadline) std::this_thread::sleep_for(std::chrono::milliseconds(20)); + assert(!axyne_terminal_running(t)); await_text(t, "FINAL_DRAIN"); axyne_terminal_release(t); + for (int i = 0; i < 8; ++i) { + assert(axyne_terminal_create(24, 80, 20, &t, nullptr) == AXYNE_STATUS_OK); + assert(axyne_terminal_start(t, &spec, &e) == AXYNE_STATUS_OK); + auto start = Clock::now(); axyne_terminal_release(t); + assert(Clock::now() - start < std::chrono::seconds(3)); + } + assert(axyne_terminal_create(24, 80, 20, &t, nullptr) == AXYNE_STATUS_OK); + spec.executable = "/axyne/missing/shell"; + assert(axyne_terminal_start(t, &spec, &e) != AXYNE_STATUS_OK); assert(!axyne_terminal_running(t)); axyne_terminal_release(t); + std::cout << "Native session tests passed\n"; +} From a99bca4c49d0cf1a4ef982cc3ab51e7391acaa0b Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 20:05:20 +0900 Subject: [PATCH 03/14] fix: harden terminal startup cleanup and exit verification --- src/features/terminal/terminal.cpp | 65 +++++++++++++++------ src/features/terminal/terminal_internal.hpp | 2 +- src/features/terminal/terminal_posix.cpp | 39 ++++++++++--- tests/test_terminal_session.cpp | 23 ++++++-- 4 files changed, 97 insertions(+), 32 deletions(-) diff --git a/src/features/terminal/terminal.cpp b/src/features/terminal/terminal.cpp index ecc2927..cc926fc 100644 --- a/src/features/terminal/terminal.cpp +++ b/src/features/terminal/terminal.cpp @@ -27,7 +27,10 @@ AxyneStatus terminal_enqueue(AxyneTerminal *t, const char *data, size_t len, Axy } static void output(const char *s, size_t n, void *context) { auto *t = static_cast(context); - if (t->encoding) t->encoded.append(s, n); + if (t->encoding) { + try { t->encoded.append(s, n); } + catch (...) { t->encoding_failed = true; } + } else if (t->running) terminal_enqueue(t, s, n, nullptr); } static int property(VTermProp prop, VTermValue *v, void *context) { @@ -68,7 +71,7 @@ static const VTermScreenCallbacks callbacks = { nullptr, nullptr, nullptr, property, nullptr, nullptr, pushline, popline, clear_history }; extern "C" AxyneStatus axyne_terminal_create(int rows, int cols, size_t limit, - AxyneTerminal **out, AxyneError *e) { + AxyneTerminal **out, AxyneError *e) try { if (!out || !dimensions(rows, cols) || limit > 2000) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT, "Terminal dimensions exceed 512 rows / 1024 columns or history exceeds 2000 lines"); *out = nullptr; @@ -85,65 +88,78 @@ extern "C" AxyneStatus axyne_terminal_create(int rows, int cols, size_t limit, vterm_screen_reset(t->screen, 1); *out = t; return terminal_result(e, AXYNE_STATUS_OK); +} catch (...) { + if (out) *out = nullptr; + return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY, "Cannot allocate terminal model"); } -extern "C" void axyne_terminal_feed(AxyneTerminal *t, const char *s, size_t n) { +extern "C" void axyne_terminal_feed(AxyneTerminal *t, const char *s, size_t n) try { if (!t || (!s && n)) return; std::lock_guard lock(t->model_mutex); vterm_input_write(t->vt, s, n); vterm_screen_flush_damage(t->screen); } -extern "C" AxyneStatus axyne_terminal_write(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) { +catch (...) {} +extern "C" AxyneStatus axyne_terminal_write(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) try { if (!t || (!s && n)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); return terminal_enqueue(t, s, n, e); } +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } template static AxyneStatus encode(AxyneTerminal *t, F f, AxyneError *e) { if (!t) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); std::lock_guard lock(t->model_mutex); - t->encoding = true; t->encoded.clear(); + t->encoding = true; t->encoding_failed = false; t->encoded.clear(); try { f(); } catch (const std::bad_alloc &) { t->encoding = false; return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); } t->encoding = false; t->scroll = 0; + if (t->encoding_failed) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); return terminal_enqueue(t, t->encoded.data(), t->encoded.size(), e); } -extern "C" AxyneStatus axyne_terminal_key(AxyneTerminal *t, AxyneTerminalKey k, unsigned mods, AxyneError *e) { +extern "C" AxyneStatus axyne_terminal_key(AxyneTerminal *t, AxyneTerminalKey k, unsigned mods, AxyneError *e) try { if ((k < 1 || k > AXYNE_TERMINAL_PAGEDOWN) && (k < 257 || k > 280)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); return encode(t, [&]{ vterm_keyboard_key(t->vt, static_cast(k), static_cast(mods & 7)); }, e); } -extern "C" AxyneStatus axyne_terminal_unichar(AxyneTerminal *t, uint32_t c, unsigned mods, AxyneError *e) { +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" AxyneStatus axyne_terminal_unichar(AxyneTerminal *t, uint32_t c, unsigned mods, AxyneError *e) try { if (c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); return encode(t, [&]{ vterm_keyboard_unichar(t->vt, c, static_cast(mods & 7)); }, e); } -extern "C" AxyneStatus axyne_terminal_paste(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) { +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" AxyneStatus axyne_terminal_paste(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) try { if ((!s && n) || n > input_limit - 12) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); return encode(t, [&]{ vterm_keyboard_start_paste(t->vt); t->encoded.append(s ? s : "", n); vterm_keyboard_end_paste(t->vt); }, e); } -extern "C" AxyneStatus axyne_terminal_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) { +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" AxyneStatus axyne_terminal_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) try { if (!t || !dimensions(rows, cols)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); std::lock_guard lock(t->model_mutex); vterm_set_size(t->vt, rows, cols); vterm_screen_flush_damage(t->screen); return t->running ? terminal_transport_resize(t, rows, cols, e) : terminal_result(e, AXYNE_STATUS_OK); } -extern "C" void axyne_terminal_scroll(AxyneTerminal *t, int lines) { +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" void axyne_terminal_scroll(AxyneTerminal *t, int lines) try { if (!t) return; std::lock_guard lock(t->model_mutex); t->scroll = t->alternate ? 0 : static_cast(std::clamp(static_cast(t->scroll) + lines, 0LL, static_cast(t->history.size()))); } -extern "C" void axyne_terminal_clear(AxyneTerminal *t) { +catch (...) {} +extern "C" void axyne_terminal_clear(AxyneTerminal *t) try { if (!t) return; std::lock_guard lock(t->model_mutex); t->history.clear(); t->scroll = 0; vterm_screen_reset(t->screen, 1); } -extern "C" void axyne_terminal_set_colors(AxyneTerminal *t, uint32_t fg, uint32_t bg) { +catch (...) {} +extern "C" void axyne_terminal_set_colors(AxyneTerminal *t, uint32_t fg, uint32_t bg) try { if (!t) return; std::lock_guard lock(t->model_mutex); VTermColor f, b; vterm_color_rgb(&f, fg >> 16, fg >> 8, fg); vterm_color_rgb(&b, bg >> 16, bg >> 8, bg); vterm_screen_set_default_colors(t->screen, &f, &b); } +catch (...) {} static uint32_t rgb(AxyneTerminal *t, VTermColor color) { vterm_screen_convert_color_to_rgb(t->screen, &color); return (color.rgb.red << 16) | (color.rgb.green << 8) | color.rgb.blue; } -extern "C" AxyneStatus axyne_terminal_snapshot(AxyneTerminal *t, AxyneTerminalSnapshot *s, AxyneError *e) { +extern "C" AxyneStatus axyne_terminal_snapshot(AxyneTerminal *t, AxyneTerminalSnapshot *s, AxyneError *e) try { if (!t || !s) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); std::lock_guard lock(t->model_mutex); *s = {}; vterm_get_size(t->vt, &s->rows, &s->columns); @@ -169,9 +185,12 @@ extern "C" AxyneStatus axyne_terminal_snapshot(AxyneTerminal *t, AxyneTerminalSn } return terminal_result(e, AXYNE_STATUS_OK); } -extern "C" void axyne_terminal_snapshot_free(AxyneTerminalSnapshot *s) { if (s) { std::free(s->cells); *s = {}; } } -extern "C" int axyne_terminal_running(AxyneTerminal *t) { return t && t->running; } -extern "C" AxyneStatus axyne_terminal_start(AxyneTerminal *t, const AxyneTerminalSpec *spec, AxyneError *e) { +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" void axyne_terminal_snapshot_free(AxyneTerminalSnapshot *s) try { if (s) { std::free(s->cells); *s = {}; } } +catch (...) {} +extern "C" int axyne_terminal_running(AxyneTerminal *t) try { return t && t->running; } +catch (...) { return 0; } +extern "C" AxyneStatus axyne_terminal_start(AxyneTerminal *t, const AxyneTerminalSpec *spec, AxyneError *e) try { if (!t || !spec || !spec->executable || (spec->argument_count && !spec->arguments)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); if (t->running) return terminal_result(e, AXYNE_STATUS_BUSY); terminal_transport_destroy(t); @@ -179,12 +198,20 @@ extern "C" AxyneStatus axyne_terminal_start(AxyneTerminal *t, const AxyneTermina t->stopping = false; { std::lock_guard lock(t->queue_mutex); t->writes.clear(); t->queued_bytes = 0; } return terminal_transport_start(t, spec, e); -} -extern "C" AxyneStatus axyne_terminal_terminate(AxyneTerminal *t, AxyneError *e) { +} catch (const std::bad_alloc &) { + if (t) terminal_transport_destroy(t); + return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY, "Cannot allocate terminal session"); +} catch (...) { + if (t) terminal_transport_destroy(t); + return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Cannot initialize terminal session"); +} +extern "C" AxyneStatus axyne_terminal_terminate(AxyneTerminal *t, AxyneError *e) try { if (!t) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); terminal_transport_stop(t); return terminal_result(e, AXYNE_STATUS_OK); } -extern "C" void axyne_terminal_release(AxyneTerminal *t) { +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" void axyne_terminal_release(AxyneTerminal *t) try { if (!t) return; terminal_transport_destroy(t); vterm_free(t->vt); delete t; } +catch (...) {} diff --git a/src/features/terminal/terminal_internal.hpp b/src/features/terminal/terminal_internal.hpp index a9fd448..75fb795 100644 --- a/src/features/terminal/terminal_internal.hpp +++ b/src/features/terminal/terminal_internal.hpp @@ -19,7 +19,7 @@ struct AxyneTerminal { int scroll = 0, cursor_visible = 1, alternate = 0; std::deque writes; std::string encoded; - bool encoding = false; + bool encoding = false, encoding_failed = false; std::atomic running{false}, stopping{false}; std::thread reader, writer; AxyneTerminalChangedFn changed = nullptr; diff --git a/src/features/terminal/terminal_posix.cpp b/src/features/terminal/terminal_posix.cpp index f669c50..41d72d9 100644 --- a/src/features/terminal/terminal_posix.cpp +++ b/src/features/terminal/terminal_posix.cpp @@ -86,14 +86,33 @@ static void reader(AxyneTerminal *t, PosixTerminal *p) { if (Clock::now() >= end) break; } t->running = false; t->stopping = true; t->queue_ready.notify_all(); + // EOF may race the first WNOHANG check. Observe the root after draining, + // retaining its zombie rather than reaping the session/group identity. + if (code == -1) { + siginfo_t info{}; + auto status_deadline = Clock::now() + std::chrono::milliseconds(500); + for (;;) { + { std::lock_guard lock(p->child_mutex); + int result = waitid(P_PID, p->child, &info, WEXITED | WNOHANG | WNOWAIT); + if (result < 0 || info.si_pid) { + p->child_exited = true; + if (info.si_pid) code = info.si_code == CLD_EXITED ? info.si_status : 128 + info.si_status; + break; + } + } + if (Clock::now() >= status_deadline) terminal_transport_stop(t); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } if (t->exited) t->exited(t, code, t->context); } AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec *s, AxyneError *e) { auto *p = new (std::nothrow) PosixTerminal; if (!p) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + t->transport = p; // Exception cleanup owns every opened descriptor. p->master = posix_openpt(O_RDWR | O_NOCTTY | O_CLOEXEC); if (p->master < 0 || grantpt(p->master) || unlockpt(p->master)) { - auto result = failure(e, "Cannot allocate PTY"); if (p->master >= 0) close(p->master); delete p; return result; + auto result = failure(e, "Cannot allocate PTY"); terminal_transport_destroy(t); return result; } // All strings, argv, and environment are constructed before fork. std::string slave_name = ptsname(p->master); @@ -105,13 +124,13 @@ AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec * std::vector envp; for (auto &entry : env) envp.push_back(entry.data()); envp.push_back(nullptr); std::vector args; args.emplace_back(s->executable); for (size_t i = 0; i < s->argument_count; ++i) { - if (!s->arguments[i]) { close(p->master); delete p; return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); } + if (!s->arguments[i]) { terminal_transport_destroy(t); return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); } args.emplace_back(s->arguments[i]); } std::vector argv; for (auto &arg : args) argv.push_back(arg.data()); argv.push_back(nullptr); std::string cwd = s->working_directory ? s->working_directory : ""; int handshake[2]; - if (pipe(handshake)) { auto result = failure(e, "Cannot create startup handshake"); close(p->master); delete p; return result; } + if (pipe(handshake)) { auto result = failure(e, "Cannot create startup handshake"); terminal_transport_destroy(t); return result; } cloexec(handshake[0]); cloexec(handshake[1]); int rows, cols; { std::lock_guard lock(t->model_mutex); vterm_get_size(t->vt, &rows, &cols); } winsize size{}; size.ws_row = static_cast(rows); size.ws_col = static_cast(cols); @@ -136,14 +155,14 @@ AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec * (void)write(handshake[1], &saved, sizeof saved); _exit(127); } close(handshake[1]); - if (p->child < 0) { auto result = failure(e, "Cannot fork terminal"); close(handshake[0]); close(p->master); delete p; return result; } + if (p->child < 0) { auto result = failure(e, "Cannot fork terminal"); close(handshake[0]); terminal_transport_destroy(t); return result; } int saved = 0; ssize_t count; do { count = read(handshake[0], &saved, sizeof saved); } while (count < 0 && errno == EINTR); close(handshake[0]); if (count != 0) { if (count < 0) saved = errno; kill(p->child, SIGKILL); while (waitpid(p->child, nullptr, 0) < 0 && errno == EINTR) {} - close(p->master); delete p; errno = saved; return failure(e, "Cannot start terminal shell"); + p->child = -1; terminal_transport_destroy(t); errno = saved; return failure(e, "Cannot start terminal shell"); } nonblocking(p->master); t->transport = p; t->running = true; try { t->writer = std::thread(writer, t, p); t->reader = std::thread(reader, t, p); } @@ -159,8 +178,12 @@ AxyneStatus terminal_transport_resize(AxyneTerminal *t, int rows, int cols, Axyn void terminal_transport_stop(AxyneTerminal *t) { auto *p = static_cast(t->transport); t->stopping = true; t->queue_ready.notify_all(); - if (!p) return; + if (!p || p->child <= 0) return; std::lock_guard lock(p->child_mutex); + siginfo_t info{}; + if (waitid(P_PID, p->child, &info, WEXITED | WNOHANG | WNOWAIT) < 0 && errno == ECHILD) { + p->child = -1; return; // Never signal an externally reaped/reused ID. + } // The shell's session/process-group leader remains owned and unreaped. // A terminal hangup reaches its foreground group via the tty driver; // do not signal a numeric foreground PGID which might have been reused. @@ -175,8 +198,8 @@ void terminal_transport_destroy(AxyneTerminal *t) { auto *p = static_cast(t->transport); if (p) { // Closing the controlling master hangs up any remaining foreground job. - close(p->master); - while (waitpid(p->child, nullptr, 0) < 0 && errno == EINTR) {} + if (p->master >= 0) close(p->master); + if (p->child > 0) while (waitpid(p->child, nullptr, 0) < 0 && errno == EINTR) {} delete p; t->transport = nullptr; } t->running = false; diff --git a/tests/test_terminal_session.cpp b/tests/test_terminal_session.cpp index 7ac9401..58ca508 100644 --- a/tests/test_terminal_session.cpp +++ b/tests/test_terminal_session.cpp @@ -5,6 +5,10 @@ #include #include #include +#include +struct Events { std::atomic changed{0}, exits{0}, code{-1}; }; +static void changed(AxyneTerminal *, void *p) { auto *e = static_cast(p); assert(e->exits == 0); ++e->changed; } +static void exited(AxyneTerminal *, int code, void *p) { auto *e = static_cast(p); e->code = code; ++e->exits; } using Clock = std::chrono::steady_clock; static std::string text(AxyneTerminal *t) { AxyneTerminalSnapshot s{}; assert(axyne_terminal_snapshot(t, &s, nullptr) == AXYNE_STATUS_OK); @@ -22,30 +26,41 @@ static void await_text(AxyneTerminal *t, const char *expected) { } static void send(AxyneTerminal *t, const char *s) { assert(axyne_terminal_write(t, s, std::strlen(s), nullptr) == AXYNE_STATUS_OK); } int main() { + Events events; AxyneTerminal *t; assert(axyne_terminal_create(24, 100, 2000, &t, nullptr) == AXYNE_STATUS_OK); #ifdef _WIN32 const char *args[] = {"/Q"}; AxyneTerminalSpec spec{"C:\\Windows\\System32\\cmd.exe", args, 1, nullptr, nullptr, nullptr, nullptr}; #else const char *args[] = {"-f", "-i"}; AxyneTerminalSpec spec{"/bin/zsh", args, 2, "/tmp", nullptr, nullptr, nullptr}; #endif + spec.on_changed = changed; spec.on_exit = exited; spec.user_data = &events; AxyneError e{}; assert(axyne_terminal_start(t, &spec, &e) == AXYNE_STATUS_OK); #ifdef _WIN32 send(t, "echo AXYNE_READY\r"); await_text(t, "AXYNE_READY"); #else send(t, "unsetopt zle; PS1='AXYNE_PROMPT> '; setopt zle\r"); await_text(t, "AXYNE_PROMPT>"); - send(t, "[[ -t 0 && -t 1 && -t 2 ]] && print TTY_OK; print CWD:$PWD\r"); await_text(t, "TTY_OK"); await_text(t, "CWD:/"); - send(t, "print HISTORY_OK\r"); await_text(t, "HISTORY_OK"); + send(t, "[[ -t 0 && -t 1 && -t 2 ]] && printf '%s%s\\n' TTY_ OK; print CWD:$PWD\r"); await_text(t, "TTY_OK"); await_text(t, "CWD:/"); + send(t, "printf '%s%s\\n' HISTORY_ OK\r"); await_text(t, "HISTORY_OK"); assert(axyne_terminal_key(t, AXYNE_TERMINAL_UP, 0, nullptr) == AXYNE_STATUS_OK); assert(axyne_terminal_key(t, AXYNE_TERMINAL_ENTER, 0, nullptr) == AXYNE_STATUS_OK); + auto history_deadline = Clock::now() + std::chrono::seconds(5); + while (Clock::now() < history_deadline) { auto value = text(t); auto first = value.find("HISTORY_OK"); if (first != std::string::npos && value.find("HISTORY_OK", first+1) != std::string::npos) break; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } + { auto value = text(t); auto first = value.find("HISTORY_OK"); assert(first != std::string::npos && value.find("HISTORY_OK", first+1) != std::string::npos); } send(t, "sleep 20\r"); std::this_thread::sleep_for(std::chrono::milliseconds(150)); assert(axyne_terminal_unichar(t, 'c', AXYNE_TERMINAL_CTRL, nullptr) == AXYNE_STATUS_OK); - send(t, "print INTERRUPT_OK\r"); await_text(t, "INTERRUPT_OK"); assert(axyne_terminal_running(t)); + send(t, "printf '%s%s\\n' INTERRUPT_ OK\r"); await_text(t, "INTERRUPT_OK"); assert(axyne_terminal_running(t)); #endif assert(axyne_terminal_resize(t, 30, 120, nullptr) == AXYNE_STATUS_OK); - send(t, "echo FINAL_DRAIN; exit\r"); +#ifdef _WIN32 + send(t, "set AXYNE_A=FINAL_\r\necho %AXYNE_A%DRAIN\r\nexit 7\r\n"); +#else + send(t, "printf '%s%s\\n' FINAL_ DRAIN; exit 7\r"); +#endif auto deadline = Clock::now() + std::chrono::seconds(5); while (axyne_terminal_running(t) && Clock::now() < deadline) std::this_thread::sleep_for(std::chrono::milliseconds(20)); assert(!axyne_terminal_running(t)); await_text(t, "FINAL_DRAIN"); axyne_terminal_release(t); + assert(events.exits == 1 && events.code == 7 && events.changed > 0); + spec.on_changed = nullptr; spec.on_exit = nullptr; spec.user_data = nullptr; for (int i = 0; i < 8; ++i) { assert(axyne_terminal_create(24, 80, 20, &t, nullptr) == AXYNE_STATUS_OK); assert(axyne_terminal_start(t, &spec, &e) == AXYNE_STATUS_OK); From 716d9d2f1f80ffd25cb311519eadb7d344abf961 Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 20:08:36 +0900 Subject: [PATCH 04/14] fix: drain ConPTY through EOF without resize lock cycles --- src/features/terminal/terminal.cpp | 4 ++-- src/features/terminal/terminal_windows.cpp | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/features/terminal/terminal.cpp b/src/features/terminal/terminal.cpp index cc926fc..4c72d1d 100644 --- a/src/features/terminal/terminal.cpp +++ b/src/features/terminal/terminal.cpp @@ -131,8 +131,8 @@ extern "C" AxyneStatus axyne_terminal_paste(AxyneTerminal *t, const char *s, siz catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } extern "C" AxyneStatus axyne_terminal_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) try { if (!t || !dimensions(rows, cols)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); - std::lock_guard lock(t->model_mutex); - vterm_set_size(t->vt, rows, cols); vterm_screen_flush_damage(t->screen); + { std::lock_guard lock(t->model_mutex); + vterm_set_size(t->vt, rows, cols); vterm_screen_flush_damage(t->screen); } return t->running ? terminal_transport_resize(t, rows, cols, e) : terminal_result(e, AXYNE_STATUS_OK); } catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } diff --git a/src/features/terminal/terminal_windows.cpp b/src/features/terminal/terminal_windows.cpp index c02a0b3..b8469f8 100644 --- a/src/features/terminal/terminal_windows.cpp +++ b/src/features/terminal/terminal_windows.cpp @@ -56,7 +56,8 @@ static void reader(AxyneTerminal *t, WindowsTerminal *p) { axyne_terminal_feed(t, buffer, n); if (t->changed) t->changed(t, t->context); } else { - if (p->closed) break; + // ClosePseudoConsole can return before its final output on newer + // Windows versions. Only a broken pipe is stream EOF. std::this_thread::sleep_for(std::chrono::milliseconds(10)); } } @@ -72,8 +73,11 @@ static void closer(AxyneTerminal *t, WindowsTerminal *p) { // Cancel synchronous writer I/O before joining, keeping the reader alive // while ClosePseudoConsole emits its final frame (Microsoft's contract). if (t->writer.joinable()) CancelSynchronousIo(t->writer.native_handle()); - GetExitCodeProcess(p->process, &p->exit_code); - { std::lock_guard lock(p->pc_mutex); if (p->pc) { p->close(p->pc); p->pc = nullptr; } } + WaitForSingleObject(p->process, INFINITE); + if (!GetExitCodeProcess(p->process, &p->exit_code)) p->exit_code = static_cast(-1); + HANDLE pc; + { std::lock_guard lock(p->pc_mutex); pc = p->pc; p->pc = nullptr; } + if (pc) p->close(pc); // No mutex held while the reader drains the last frame. p->closed = true; } AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec *s, AxyneError *e) { From e04047ac10910bd357ac37460137e9afd3af7d52 Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 20:15:18 +0900 Subject: [PATCH 05/14] fix: preserve shell exit status after terminal output EOF --- src/features/terminal/terminal_posix.cpp | 4 +--- tests/test_terminal_session.cpp | 11 ++++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/features/terminal/terminal_posix.cpp b/src/features/terminal/terminal_posix.cpp index 41d72d9..4edace1 100644 --- a/src/features/terminal/terminal_posix.cpp +++ b/src/features/terminal/terminal_posix.cpp @@ -85,12 +85,10 @@ static void reader(AxyneTerminal *t, PosixTerminal *p) { } if (Clock::now() >= end) break; } - t->running = false; t->stopping = true; t->queue_ready.notify_all(); // EOF may race the first WNOHANG check. Observe the root after draining, // retaining its zombie rather than reaping the session/group identity. if (code == -1) { siginfo_t info{}; - auto status_deadline = Clock::now() + std::chrono::milliseconds(500); for (;;) { { std::lock_guard lock(p->child_mutex); int result = waitid(P_PID, p->child, &info, WEXITED | WNOHANG | WNOWAIT); @@ -100,10 +98,10 @@ static void reader(AxyneTerminal *t, PosixTerminal *p) { break; } } - if (Clock::now() >= status_deadline) terminal_transport_stop(t); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } } + t->running = false; t->stopping = true; t->queue_ready.notify_all(); if (t->exited) t->exited(t, code, t->context); } AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec *s, AxyneError *e) { diff --git a/tests/test_terminal_session.cpp b/tests/test_terminal_session.cpp index 58ca508..70e7489 100644 --- a/tests/test_terminal_session.cpp +++ b/tests/test_terminal_session.cpp @@ -36,7 +36,7 @@ int main() { spec.on_changed = changed; spec.on_exit = exited; spec.user_data = &events; AxyneError e{}; assert(axyne_terminal_start(t, &spec, &e) == AXYNE_STATUS_OK); #ifdef _WIN32 - send(t, "echo AXYNE_READY\r"); await_text(t, "AXYNE_READY"); + send(t, "set AXYNE_PREFIX=AXYNE_\r\necho %AXYNE_PREFIX%READY\r\n"); await_text(t, "AXYNE_READY"); #else send(t, "unsetopt zle; PS1='AXYNE_PROMPT> '; setopt zle\r"); await_text(t, "AXYNE_PROMPT>"); send(t, "[[ -t 0 && -t 1 && -t 2 ]] && printf '%s%s\\n' TTY_ OK; print CWD:$PWD\r"); await_text(t, "TTY_OK"); await_text(t, "CWD:/"); @@ -61,6 +61,15 @@ int main() { assert(!axyne_terminal_running(t)); await_text(t, "FINAL_DRAIN"); axyne_terminal_release(t); assert(events.exits == 1 && events.code == 7 && events.changed > 0); spec.on_changed = nullptr; spec.on_exit = nullptr; spec.user_data = nullptr; +#ifndef _WIN32 + assert(axyne_terminal_create(24, 80, 20, &t, nullptr) == AXYNE_STATUS_OK); + const char *eofArgs[] = {"-f", "-c", "exec /dev/null 2>&1; sleep 1; exit 7"}; + Events eofEvents; + AxyneTerminalSpec eofSpec{"/bin/zsh",eofArgs,3,"/tmp",changed,exited,&eofEvents}; + assert(axyne_terminal_start(t,&eofSpec,&e)==AXYNE_STATUS_OK); + std::this_thread::sleep_for(std::chrono::milliseconds(1300)); + axyne_terminal_release(t); assert(eofEvents.exits==1&&eofEvents.code==7); +#endif for (int i = 0; i < 8; ++i) { assert(axyne_terminal_create(24, 80, 20, &t, nullptr) == AXYNE_STATUS_OK); assert(axyne_terminal_start(t, &spec, &e) == AXYNE_STATUS_OK); From e34df0e18286e4068eef5d2b28acc63a316123c9 Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 20:17:52 +0900 Subject: [PATCH 06/14] fix: establish ConPTY prompt before testing direct keyboard input --- src/features/terminal/terminal_windows.cpp | 6 +++--- tests/test_terminal_session.cpp | 23 +++++++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/features/terminal/terminal_windows.cpp b/src/features/terminal/terminal_windows.cpp index b8469f8..0dbc70f 100644 --- a/src/features/terminal/terminal_windows.cpp +++ b/src/features/terminal/terminal_windows.cpp @@ -98,11 +98,10 @@ AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec * } int rows, cols; { std::lock_guard lock(t->model_mutex); vterm_get_size(t->vt, &rows, &cols); } HRESULT hr = create({static_cast(cols), static_cast(rows)}, inread, outwrite, 0, &p->pc); - CloseHandle(inread); CloseHandle(outwrite); - if (FAILED(hr)) { terminal_transport_destroy(t); return failure(e, "Cannot create pseudoconsole"); } + if (FAILED(hr)) { CloseHandle(inread); CloseHandle(outwrite); terminal_transport_destroy(t); return failure(e, "Cannot create pseudoconsole"); } // Start the drain thread before any startup error can close the console. try { t->reader = std::thread(reader, t, p); } - catch (...) { CloseHandle(p->output); p->output = nullptr; terminal_transport_destroy(t); return failure(e, "Cannot create terminal reader"); } + catch (...) { CloseHandle(inread); CloseHandle(outwrite); CloseHandle(p->output); p->output = nullptr; terminal_transport_destroy(t); return failure(e, "Cannot create terminal reader"); } SIZE_T bytes = 0; InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); std::vector storage(bytes); STARTUPINFOEXW si{}; si.StartupInfo.cb = sizeof si; @@ -116,6 +115,7 @@ AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec * bool spawned = ready && CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, nullptr, cwd.empty() ? nullptr : cwd.c_str(), &si.StartupInfo, &pi); if (initialized) DeleteProcThreadAttributeList(si.lpAttributeList); + CloseHandle(inread); CloseHandle(outwrite); if (!spawned) { terminal_transport_destroy(t); return failure(e, "Cannot start terminal shell"); } p->process = pi.hProcess; if (!AssignProcessToJobObject(p->job, pi.hProcess)) { diff --git a/tests/test_terminal_session.cpp b/tests/test_terminal_session.cpp index 70e7489..565a9fe 100644 --- a/tests/test_terminal_session.cpp +++ b/tests/test_terminal_session.cpp @@ -22,9 +22,22 @@ static std::string text(AxyneTerminal *t) { static void await_text(AxyneTerminal *t, const char *expected) { auto deadline = Clock::now() + std::chrono::seconds(5); while (text(t).find(expected) == std::string::npos && Clock::now() < deadline) std::this_thread::sleep_for(std::chrono::milliseconds(20)); - if (text(t).find(expected) == std::string::npos) { std::cerr << text(t); std::abort(); } + if (text(t).find(expected) == std::string::npos) { + std::cerr << "Terminal wait failed: marker=" << expected << " running=" << axyne_terminal_running(t) << "\n" << text(t) << std::flush; + axyne_terminal_release(t); std::exit(EXIT_FAILURE); + } } static void send(AxyneTerminal *t, const char *s) { assert(axyne_terminal_write(t, s, std::strlen(s), nullptr) == AXYNE_STATUS_OK); } +#ifdef _WIN32 +static void command(AxyneTerminal *t, const char *s) { + AxyneError error{}; + for (;*s;++s) { + auto status=axyne_terminal_unichar(t,static_cast(*s),0,&error); + if(status!=AXYNE_STATUS_OK) {std::cerr<<"ConPTY typing failed: "<"); + command(t, "set AXYNE_PREFIX=AXYNE_"); + command(t, "echo %AXYNE_PREFIX%READY"); await_text(t, "AXYNE_READY"); #else send(t, "unsetopt zle; PS1='AXYNE_PROMPT> '; setopt zle\r"); await_text(t, "AXYNE_PROMPT>"); send(t, "[[ -t 0 && -t 1 && -t 2 ]] && printf '%s%s\\n' TTY_ OK; print CWD:$PWD\r"); await_text(t, "TTY_OK"); await_text(t, "CWD:/"); @@ -52,7 +69,7 @@ int main() { #endif assert(axyne_terminal_resize(t, 30, 120, nullptr) == AXYNE_STATUS_OK); #ifdef _WIN32 - send(t, "set AXYNE_A=FINAL_\r\necho %AXYNE_A%DRAIN\r\nexit 7\r\n"); + command(t, "set AXYNE_A=FINAL_"); command(t, "echo %AXYNE_A%DRAIN"); command(t,"exit 7"); #else send(t, "printf '%s%s\\n' FINAL_ DRAIN; exit 7\r"); #endif From 741e046dd78dde7b554675f3a0f1d9dfb7d4d82e Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 20:26:13 +0900 Subject: [PATCH 07/14] fix: prepare ConPTY allocations before opening session resources --- src/features/terminal/terminal_windows.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/terminal/terminal_windows.cpp b/src/features/terminal/terminal_windows.cpp index 0dbc70f..3b9224f 100644 --- a/src/features/terminal/terminal_windows.cpp +++ b/src/features/terminal/terminal_windows.cpp @@ -91,6 +91,9 @@ AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec * if (!s->arguments[i]) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); command += L" " + quote(wide(s->arguments[i])); } + // Finish every allocating preparation before handles or workers exist. + SIZE_T bytes = 0; InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); + std::vector storage(bytes); auto *p = new WindowsTerminal; p->resize = resize; p->close = closepc; t->transport = p; HANDLE inread = nullptr, outwrite = nullptr; if (!CreatePipe(&inread, &p->input, nullptr, 0) || !CreatePipe(&p->output, &outwrite, nullptr, 0)) { @@ -102,8 +105,6 @@ AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec * // Start the drain thread before any startup error can close the console. try { t->reader = std::thread(reader, t, p); } catch (...) { CloseHandle(inread); CloseHandle(outwrite); CloseHandle(p->output); p->output = nullptr; terminal_transport_destroy(t); return failure(e, "Cannot create terminal reader"); } - SIZE_T bytes = 0; InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); - std::vector storage(bytes); STARTUPINFOEXW si{}; si.StartupInfo.cb = sizeof si; si.lpAttributeList = reinterpret_cast(storage.data()); bool initialized = InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &bytes) != 0; From b5c0b9fd9fdac359fc67318f6b6a174bee2ba801 Mon Sep 17 00:00:00 2001 From: soyuo Date: Tue, 13 Oct 2026 20:26:13 +0900 Subject: [PATCH 08/14] feat: integrate native terminal grids with direct shell input --- src/features/ui/CMakeLists.txt | 5 +- .../ui/platform/macos/terminal_view_macos.h | 13 ++ .../ui/platform/macos/terminal_view_macos.m | 182 ++++++++++++++++++ src/features/ui/platform/macos/ui_macos.m | 168 ++++++---------- .../platform/windows/terminal_view_windows.c | 117 +++++++++++ .../platform/windows/terminal_view_windows.h | 11 ++ src/features/ui/platform/windows/ui_windows.c | 182 ++++++------------ tests/CMakeLists.txt | 15 ++ tests/test_editor_runtime_macos.m | 20 +- tests/test_terminal_view_macos.m | 41 ++++ tests/test_terminal_view_windows.c | 24 +++ tests/test_workspace_layout_macos.m | 22 ++- 12 files changed, 556 insertions(+), 244 deletions(-) create mode 100644 src/features/ui/platform/macos/terminal_view_macos.h create mode 100644 src/features/ui/platform/macos/terminal_view_macos.m create mode 100644 src/features/ui/platform/windows/terminal_view_windows.c create mode 100644 src/features/ui/platform/windows/terminal_view_windows.h create mode 100644 tests/test_terminal_view_macos.m create mode 100644 tests/test_terminal_view_windows.c diff --git a/src/features/ui/CMakeLists.txt b/src/features/ui/CMakeLists.txt index e20cfd6..c2cf601 100644 --- a/src/features/ui/CMakeLists.txt +++ b/src/features/ui/CMakeLists.txt @@ -1,12 +1,14 @@ if(WIN32) add_library(axyne_ui STATIC "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/terminal_view_windows.c" "${CMAKE_CURRENT_LIST_DIR}/syntax.c" "${CMAKE_CURRENT_LIST_DIR}/platform/windows/preferences_window_windows.c") - target_link_libraries(axyne_ui PRIVATE user32 gdi32 comdlg32 shell32 ole32) + target_link_libraries(axyne_ui PRIVATE user32 gdi32 comdlg32 shell32 ole32 imm32) elseif(APPLE) add_library(axyne_ui STATIC "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/terminal_view_macos.m" "${CMAKE_CURRENT_LIST_DIR}/syntax.c" "${CMAKE_CURRENT_LIST_DIR}/platform/macos/preferences_window_macos.m" "${CMAKE_CURRENT_LIST_DIR}/platform/macos/popup_menu_macos.m" @@ -19,6 +21,7 @@ target_include_directories(axyne_ui PRIVATE "${axyne_scintilla_SOURCE_DIR}/include" ) target_link_libraries(axyne PRIVATE axyne_ui) +target_link_libraries(axyne_ui PRIVATE axyne_terminal) if(MSVC) target_compile_options(axyne_ui PRIVATE /W4 /utf-8) diff --git a/src/features/ui/platform/macos/terminal_view_macos.h b/src/features/ui/platform/macos/terminal_view_macos.h new file mode 100644 index 0000000..50c90f0 --- /dev/null +++ b/src/features/ui/platform/macos/terminal_view_macos.h @@ -0,0 +1,13 @@ +#import +#include "axyne/terminal.h" +@interface AxyneTerminalView : NSView +- (AxyneStatus)start:(const AxyneTerminalSpec *)spec error:(AxyneError *)error; +- (void)stop; +- (void)shutdown; +- (BOOL)running; +- (AxyneTerminal *)terminal; +- (void)setFont:(NSFont *)font foreground:(uint32_t)fg background:(uint32_t)bg; +- (void)copy:(id)sender; +- (void)paste:(id)sender; +- (void)selectAll:(id)sender; +@end diff --git a/src/features/ui/platform/macos/terminal_view_macos.m b/src/features/ui/platform/macos/terminal_view_macos.m new file mode 100644 index 0000000..f37a953 --- /dev/null +++ b/src/features/ui/platform/macos/terminal_view_macos.m @@ -0,0 +1,182 @@ +#import "terminal_view_macos.h" +@class AxyneTerminalView; +@interface AxyneTerminalLife : NSObject { +@public AxyneTerminalView *view; // Main-thread-only weak target; blocks own the box. +} +@end +@implementation AxyneTerminalLife +@end +@interface AxyneTerminalView () { + AxyneTerminal *_terminal; + AxyneTerminalLife *_life; + NSFont *_font; + NSMutableAttributedString *_marked; + CGFloat _cellWidth, _cellHeight; + NSInteger _selectionStart, _selectionEnd; + uint32_t _foreground, _background; +} +@end +static NSColor *color(uint32_t rgb) { + return [NSColor colorWithSRGBRed:((rgb >> 16) & 255)/255.0 green:((rgb >> 8)&255)/255.0 blue:(rgb&255)/255.0 alpha:1]; +} +static NSString *cellString(const AxyneTerminalCell *cell) { + NSMutableString *text = [NSMutableString string]; + for (int n = 0; n < 6 && cell->chars[n]; ++n) { + uint32_t c = cell->chars[n]; + if (c <= 0xffff) { unichar u = c; [text appendString:[NSString stringWithCharacters:&u length:1]]; } + else if (c <= 0x10ffff) { unichar pair[] = {0xd800 + ((c-0x10000)>>10), 0xdc00 + ((c-0x10000)&1023)}; [text appendString:[NSString stringWithCharacters:pair length:2]]; } + } + return [text length] ? text : @" "; +} +static void changed(AxyneTerminal *terminal, void *context) { + (void)terminal; + AxyneTerminalLife *life = (AxyneTerminalLife *)context; + dispatch_async(dispatch_get_main_queue(), ^{ + [life->view setNeedsDisplay:YES]; + NSView *parent = [life->view superview]; + if ([parent respondsToSelector:@selector(refreshActionControls)]) + [parent performSelector:@selector(refreshActionControls)]; + }); +} +static void exited(AxyneTerminal *terminal, int code, void *context) { + (void)code; changed(terminal, context); +} +@implementation AxyneTerminalView +- (id)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + _life = [AxyneTerminalLife new]; _life->view = self; + _marked = [[NSMutableAttributedString alloc] init]; + _selectionStart = _selectionEnd = -1; + axyne_terminal_create(24, 80, 2000, &_terminal, NULL); + [self setFont:[NSFont monospacedSystemFontOfSize:12 weight:NSFontWeightRegular] foreground:0xd4d4d4 background:0x181a1f]; + } + return self; +} +- (BOOL)isFlipped { return YES; } +- (BOOL)acceptsFirstResponder { return YES; } +- (AxyneTerminal *)terminal { return _terminal; } +- (BOOL)running { return axyne_terminal_running(_terminal); } +- (AxyneStatus)start:(const AxyneTerminalSpec *)spec error:(AxyneError *)error { + AxyneTerminalSpec owned = *spec; + owned.on_changed = changed; owned.on_exit = exited; owned.user_data = _life; + AxyneStatus result = axyne_terminal_start(_terminal, &owned, error); + [self setNeedsDisplay:YES]; return result; +} +- (void)stop { axyne_terminal_terminate(_terminal, NULL); } +- (void)shutdown { + _life->view = nil; + if (_terminal) { axyne_terminal_release(_terminal); _terminal = NULL; } +} +- (void)dealloc { [self shutdown]; [_life release]; [_font release]; [_marked release]; [super dealloc]; } +- (void)setFont:(NSFont *)font foreground:(uint32_t)fg background:(uint32_t)bg { + [font retain]; [_font release]; _font = font; _foreground = fg; _background = bg; + _cellWidth = MAX(1, ceil([@"M" sizeWithAttributes:@{NSFontAttributeName:font}].width)); + _cellHeight = MAX(1, ceil([font ascender]-[font descender]+[font leading]+2)); + axyne_terminal_set_colors(_terminal, fg, bg); [self resizeGrid]; [self setNeedsDisplay:YES]; +} +- (void)resizeGrid { + int rows = MAX(1, MIN(512, (int)((NSHeight([self bounds])-8)/_cellHeight))); + int cols = MAX(1, MIN(1024, (int)((NSWidth([self bounds])-8)/_cellWidth))); + AxyneTerminalSnapshot s = {0}; + if (axyne_terminal_snapshot(_terminal, &s, NULL) == AXYNE_STATUS_OK) { + BOOL different = s.rows != rows || s.columns != cols; axyne_terminal_snapshot_free(&s); + if (different) axyne_terminal_resize(_terminal, rows, cols, NULL); + } +} +- (void)setFrameSize:(NSSize)size { [super setFrameSize:size]; [self resizeGrid]; } +- (void)drawRect:(NSRect)dirty { + (void)dirty; [color(_background) setFill]; NSRectFill([self bounds]); + AxyneTerminalSnapshot s = {0}; if (axyne_terminal_snapshot(_terminal, &s, NULL) != AXYNE_STATUS_OK) return; + NSInteger first = MIN(_selectionStart, _selectionEnd), last = MAX(_selectionStart, _selectionEnd); + for (int r=0; rwidth) continue; + uint32_t fg=cell->foreground, bg=cell->background; + if (cell->attributes & AXYNE_TERMINAL_REVERSE) { uint32_t temp=fg; fg=bg; bg=temp; } + NSRect rect=NSMakeRect(4+c*_cellWidth, 4+r*_cellHeight, _cellWidth*MAX(1,cell->width), _cellHeight); + BOOL selected = first >= 0 && index >= first && index <= last; + [(selected ? [NSColor selectedTextBackgroundColor] : color(bg)) setFill]; NSRectFill(rect); + if (!cell->width) continue; + NSFontTraitMask traits = (cell->attributes & AXYNE_TERMINAL_BOLD ? NSBoldFontMask : 0) | (cell->attributes & AXYNE_TERMINAL_ITALIC ? NSItalicFontMask : 0); + NSFont *font = traits ? [[NSFontManager sharedFontManager] convertFont:_font toHaveTrait:traits] : _font; + [cellString(cell) drawAtPoint:rect.origin withAttributes:@{NSFontAttributeName:font, NSForegroundColorAttributeName:selected ? [NSColor selectedTextColor] : color(fg), NSUnderlineStyleAttributeName:@(cell->attributes & AXYNE_TERMINAL_UNDERLINE ? NSUnderlineStyleSingle : 0)}]; + } + if (s.cursor_visible && [[self window] firstResponder] == self) { + [color(_foreground) setFill]; NSRectFill(NSMakeRect(4+s.cursor_column*_cellWidth, 4+(s.cursor_row+1)*_cellHeight-2, _cellWidth, 2)); + } + if ([_marked length]) [_marked drawAtPoint:NSMakePoint(4+s.cursor_column*_cellWidth,4+s.cursor_row*_cellHeight)]; + axyne_terminal_snapshot_free(&s); +} +- (NSInteger)cellAtPoint:(NSPoint)point { + AxyneTerminalSnapshot s={0}; if (axyne_terminal_snapshot(_terminal,&s,NULL)!=AXYNE_STATUS_OK) return -1; + int c=MAX(0,MIN(s.columns-1,(int)((point.x-4)/_cellWidth))), r=MAX(0,MIN(s.rows-1,(int)((point.y-4)/_cellHeight))); + NSInteger index=r*s.columns+c; axyne_terminal_snapshot_free(&s); return index; +} +- (void)mouseDown:(NSEvent *)event { + [[self window] makeFirstResponder:self]; _selectionStart=_selectionEnd=[self cellAtPoint:[self convertPoint:[event locationInWindow] fromView:nil]]; [self setNeedsDisplay:YES]; +} +- (void)mouseDragged:(NSEvent *)event { _selectionEnd=[self cellAtPoint:[self convertPoint:[event locationInWindow] fromView:nil]]; [self setNeedsDisplay:YES]; } +- (void)scrollWheel:(NSEvent *)event { axyne_terminal_scroll(_terminal, (int)round([event scrollingDeltaY])); _selectionStart=_selectionEnd=-1; [self setNeedsDisplay:YES]; } +- (void)copy:(id)sender { + (void)sender; if (_selectionStart<0) return; + AxyneTerminalSnapshot s={0}; if (axyne_terminal_snapshot(_terminal,&s,NULL)!=AXYNE_STATUS_OK) return; + NSInteger first=MIN(_selectionStart,_selectionEnd), last=MIN(MAX(_selectionStart,_selectionEnd),s.rows*s.columns-1); + NSMutableString *value=[NSMutableString string]; + for (NSInteger i=first;i<=last;++i) { if (i>first && i%s.columns==0) [value appendString:@"\n"]; if (s.cells[i].width) [value appendString:cellString(&s.cells[i])]; } + [[NSPasteboard generalPasteboard] clearContents]; [[NSPasteboard generalPasteboard] setString:value forType:NSPasteboardTypeString]; axyne_terminal_snapshot_free(&s); +} +- (void)paste:(id)sender { + (void)sender; NSString *value=[[NSPasteboard generalPasteboard] stringForType:NSPasteboardTypeString]; + value=[[value stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"] stringByReplacingOccurrencesOfString:@"\n" withString:@"\r"]; + NSData *data=[value dataUsingEncoding:NSUTF8StringEncoding]; axyne_terminal_paste(_terminal,[data bytes],[data length],NULL); [self setNeedsDisplay:YES]; +} +- (void)selectAll:(id)sender { + (void)sender; AxyneTerminalSnapshot s={0}; + if(axyne_terminal_snapshot(_terminal,&s,NULL)==AXYNE_STATUS_OK) { + _selectionStart=0; _selectionEnd=s.rows*s.columns-1; + axyne_terminal_snapshot_free(&s); [self setNeedsDisplay:YES]; + } +} +- (BOOL)performKeyEquivalent:(NSEvent *)event { + if ([[self window] firstResponder]!=self) return NO; + NSEventModifierFlags flags=[event modifierFlags]; NSString *key=[event charactersIgnoringModifiers]; + if (flags & NSEventModifierFlagCommand) { + if ([key isEqualToString:@"c"]) { [self copy:nil]; return YES; } + if ([key isEqualToString:@"v"]) { [self paste:nil]; return YES; } + if ([key isEqualToString:@"a"]) { [self selectAll:nil]; return YES; } + return NO; + } + [self keyDown:event]; return YES; +} +- (void)keyDown:(NSEvent *)event { + unsigned mods=([event modifierFlags]&NSEventModifierFlagShift ? AXYNE_TERMINAL_SHIFT : 0) | ([event modifierFlags]&NSEventModifierFlagOption ? AXYNE_TERMINAL_ALT : 0) | ([event modifierFlags]&NSEventModifierFlagControl ? AXYNE_TERMINAL_CTRL : 0); + AxyneTerminalKey key=0; + switch ([event keyCode]) { + case 36: case 76: key=AXYNE_TERMINAL_ENTER; break; case 48:key=AXYNE_TERMINAL_TAB;break; case 51:key=AXYNE_TERMINAL_BACKSPACE;break; + case 53:key=AXYNE_TERMINAL_ESCAPE;break;case 123:key=AXYNE_TERMINAL_LEFT;break;case 124:key=AXYNE_TERMINAL_RIGHT;break;case 125:key=AXYNE_TERMINAL_DOWN;break;case 126:key=AXYNE_TERMINAL_UP;break; + case 115:key=AXYNE_TERMINAL_HOME;break;case 119:key=AXYNE_TERMINAL_END;break;case 116:key=AXYNE_TERMINAL_PAGEUP;break;case 121:key=AXYNE_TERMINAL_PAGEDOWN;break;case 117:key=AXYNE_TERMINAL_DELETE;break; + } + NSString *chars=[event charactersIgnoringModifiers]; + if (!key && [chars length] && [chars characterAtIndex:0]>=NSF1FunctionKey && [chars characterAtIndex:0]<=NSF24FunctionKey) key=(AxyneTerminalKey)(AXYNE_TERMINAL_F1+[chars characterAtIndex:0]-NSF1FunctionKey); + if (key) axyne_terminal_key(_terminal,key,mods,NULL); + else if ((mods&AXYNE_TERMINAL_CTRL) && [chars length]) axyne_terminal_unichar(_terminal,[chars characterAtIndex:0],mods,NULL); + else [self interpretKeyEvents:@[event]]; + _selectionStart=_selectionEnd=-1; [self setNeedsDisplay:YES]; +} +- (void)insertText:(id)value replacementRange:(NSRange)range { + (void)range; NSString *text=[value isKindOfClass:[NSAttributedString class]] ? [value string] : value; + for (NSUInteger i=0;i<[text length];++i) { uint32_t c=[text characterAtIndex:i]; if (c>=0xd800 && c<=0xdbff && i+1<[text length]) { uint32_t low=[text characterAtIndex:i+1]; if (low>=0xdc00 && low<=0xdfff) { ++i;c=0x10000+((c-0xd800)<<10)+low-0xdc00; } } axyne_terminal_unichar(_terminal,c,0,NULL); } + [self unmarkText]; [self setNeedsDisplay:YES]; +} +- (void)setMarkedText:(id)value selectedRange:(NSRange)selected replacementRange:(NSRange)range { (void)selected;(void)range; [_marked setAttributedString:[value isKindOfClass:[NSAttributedString class]] ? value : [[[NSAttributedString alloc] initWithString:value attributes:@{NSFontAttributeName:_font,NSForegroundColorAttributeName:color(_foreground),NSUnderlineStyleAttributeName:@1}] autorelease]]; [self setNeedsDisplay:YES]; } +- (void)unmarkText { [_marked deleteCharactersInRange:NSMakeRange(0,[_marked length])]; } +- (BOOL)hasMarkedText { return [_marked length]>0; } +- (NSRange)markedRange { return [_marked length] ? NSMakeRange(0,[_marked length]) : NSMakeRange(NSNotFound,0); } +- (NSRange)selectedRange { return NSMakeRange(NSNotFound,0); } +- (NSArray *)validAttributesForMarkedText { return @[]; } +- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(NSRangePointer)actual { (void)range;if(actual)*actual=NSMakeRange(NSNotFound,0);return nil; } +- (NSUInteger)characterIndexForPoint:(NSPoint)point { (void)point;return NSNotFound; } +- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actual { (void)range;if(actual)*actual=NSMakeRange(0,0); AxyneTerminalSnapshot s={0};axyne_terminal_snapshot(_terminal,&s,NULL);NSRect rect=NSMakeRect(4+s.cursor_column*_cellWidth,4+s.cursor_row*_cellHeight,_cellWidth,_cellHeight);axyne_terminal_snapshot_free(&s);return [[self window] convertRectToScreen:[self convertRect:rect toView:nil]]; } +- (void)doCommandBySelector:(SEL)selector { (void)selector; } +@end diff --git a/src/features/ui/platform/macos/ui_macos.m b/src/features/ui/platform/macos/ui_macos.m index 637ea98..b55ad17 100644 --- a/src/features/ui/platform/macos/ui_macos.m +++ b/src/features/ui/platform/macos/ui_macos.m @@ -1,4 +1,5 @@ #import +#import "terminal_view_macos.h" #include #include #include @@ -1437,7 +1438,7 @@ @interface AxyneWorkspaceView : NSView