diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cd1f347a127b..583438ceacce3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -924,36 +924,6 @@ jobs: echo "No macOS updater manifests found to merge." >&2 exit 1 fi - - # - name: Merge Windows updater manifests - # run: | - # shopt -s nullglob - # found_windows_manifest=false - # for x64_manifest in release-assets/*-win-x64.yml; do - # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then - # continue - # fi - - # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" - # output_manifest="${x64_manifest/-win-x64.yml/.yml}" - # if [[ ! -f "$arm64_manifest" ]]; then - # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 - # exit 1 - # fi - - # found_windows_manifest=true - # node scripts/merge-update-manifests.ts --platform win \ - # "$arm64_manifest" \ - # "$x64_manifest" \ - # "$output_manifest" - # rm -f "$arm64_manifest" "$x64_manifest" - # done - - # if [[ "$found_windows_manifest" != true ]]; then - # echo "No Windows updater manifests found to merge." >&2 - # exit 1 - # fi - - name: Publish release if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 552180c8b14fe..cd14d4b951c24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,11 @@ First off, thank you for considering contributing to this fork! It's people like you that make the open-source community such an amazing place to learn, inspire, and create. +## Developer Setup + +See the [maintainer scripts guide](docs/internals/scripts.md#first-checkout) for the initial checkout, +development commands, tests, and platform-specific desktop packaging prerequisites. + This fork is maintained in [aaditagrawal/t3code](https://github.com/aaditagrawal/t3code) and focuses on expanding provider support, preserving usage and limit monitoring, and improving the core orchestration and persistence layers. ## Protected Fork Features @@ -40,8 +45,8 @@ We welcome pull requests! To help us review your PR effectively, please: ## Development Setup 1. Clone the repository: `git clone https://github.com/aaditagrawal/t3code.git` -2. Install dependencies: `bun install` -3. Run the development server: `bun run dev` +2. Install dependencies: `pnpm install --frozen-lockfile` +3. Run the development server: `pnpm dev` ## Code Style diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 510d849fff6cb..858620764898b 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -214,13 +214,10 @@ describe("WSL runtime cache", () => { expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); expect(script).toContain(' node_pty_payload_present "$runtime_root"'); - expect(script).not.toContain("node_modules/effect/package.json"); expect(script).toContain("if runtime_is_ready; then"); expect(script).toContain("trap 'exit 1' HUP INT TERM"); expect(script).toContain('exec 9> "$runtime_lock"'); expect(script).toContain("flock -x 9"); - expect(script).not.toContain("runtime_lock_pid"); - expect(script).not.toContain("sleep 0.1"); expect(script).not.toContain('rm -rf "$runtime_lock"'); expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); diff --git a/apps/marketing/public/apple-touch-icon.webp b/apps/marketing/public/apple-touch-icon.webp deleted file mode 100644 index fc9905619026e..0000000000000 Binary files a/apps/marketing/public/apple-touch-icon.webp and /dev/null differ diff --git a/apps/marketing/public/favicon-16x16.webp b/apps/marketing/public/favicon-16x16.webp deleted file mode 100644 index b09dc155aef60..0000000000000 Binary files a/apps/marketing/public/favicon-16x16.webp and /dev/null differ diff --git a/apps/marketing/public/favicon-32x32.webp b/apps/marketing/public/favicon-32x32.webp deleted file mode 100644 index 72c9243de42ad..0000000000000 Binary files a/apps/marketing/public/favicon-32x32.webp and /dev/null differ diff --git a/apps/marketing/public/icon.png b/apps/marketing/public/icon.png deleted file mode 100644 index 073ad811c2500..0000000000000 Binary files a/apps/marketing/public/icon.png and /dev/null differ diff --git a/apps/marketing/public/screenshot.webp b/apps/marketing/public/screenshot.webp deleted file mode 100644 index b9b0703dca038..0000000000000 Binary files a/apps/marketing/public/screenshot.webp and /dev/null differ diff --git a/apps/marketing/tweets.md b/apps/marketing/tweets.md deleted file mode 100644 index 92a087d8018bf..0000000000000 --- a/apps/marketing/tweets.md +++ /dev/null @@ -1,19 +0,0 @@ -https://x.com/Shay_Benshabtay/status/2054668503857156326 -https://x.com/teja2495/status/2052420254991581623 -https://x.com/developedbyed/status/2030627970532921605 -https://x.com/tannerlinsley/status/2031102771529920966 -https://x.com/aronprins/status/2045102518196183109 -https://x.com/BennettBuhner/status/2054667115697754387 -https://x.com/ex0t1clol/status/2054666870008021197 -https://x.com/Josikinz/status/2030367951694745870 -https://x.com/jetpackjoe_/status/2054666792933404959 -https://x.com/mil000/status/2030120041451246071 -https://x.com/_winter_wonders/status/2052350198764970434 -https://x.com/kostyniuk00/status/2052041388179468521 -https://x.com/gnukeith/status/2054670073579630730 -https://x.com/peculiarnewbie/status/2054671685027233827 -https://x.com/leodev/status/2054679746353537042 -https://x.com/pocarles/status/2054673964274758046 -https://x.com/DavidKPiano/status/2054682983504719930 -https://x.com/iamkaffe/status/2054675539311411280 -https://x.com/uwunetes/status/2054683356022120640 diff --git a/apps/mobile/modules/t3-terminal/README.md b/apps/mobile/modules/t3-terminal/README.md index 32670b893c703..51586a5402a89 100644 --- a/apps/mobile/modules/t3-terminal/README.md +++ b/apps/mobile/modules/t3-terminal/README.md @@ -8,9 +8,9 @@ The JavaScript contract is intentionally small: - resize from the native surface is emitted as `{ cols: number, rows: number }` - remote PTY output is delivered by the existing `WsRpcClient.terminal` RPC stream -The iOS implementation uses the vendored `GhosttyKit.xcframework` built from the Ghostty custom-I/O -fork, with T3's iOS 16 compatibility patch applied. `T3TerminalView` owns a `libghostty` surface and -uses that callback I/O model: +The iOS implementation uses the vendored `GhosttyKit.xcframework` built from VVTerm's Ghostty +custom-I/O and live-padding branch. `T3TerminalView` owns a `libghostty` surface and uses that +callback I/O model: 1. initialize libghostty once for the process 2. create one Ghostty app and surface per native view @@ -26,14 +26,17 @@ Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`. ## Rebuilding GhosttyKit -The checked-in `GhosttyKit.xcframework` is built from the Ghostty custom-I/O fork (https://github.com/Yash-Singh1/ghostty/tree/custom-io). -Set the directory to the cloned repository checked out on the `custom-io` branch to `GHOSTTY_SOURCE_DIR`. +The checked-in `GhosttyKit.xcframework` is built from Yash Singh's Ghostty fork at revision +`cf8edc23f3a6a87a96e41a90013e89e987d34980`. Set `GHOSTTY_SOURCE_DIR` to a clone of +https://github.com/Yash-Singh1/ghostty checked out at that revision (the +`t3code/custom-io-ordered-feed` branch when vendored, based on VVTerm's +`vvterm/custom-io-padding` branch). ```bash apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh ``` -The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the +The script builds Ghostty with Zig 0.16.0, strips the iOS archives, and replaces only the `ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal` fails, run `xcodebuild -downloadComponent MetalToolchain`. diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index b06f18eadce52..0f8a18d50cbd0 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -2,13 +2,13 @@ ## Ghostty / libghostty -The iOS terminal renderer vendors `GhosttyKit.xcframework`, a libghostty build produced from T3's -iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork. +The iOS terminal renderer vendors `GhosttyKit.xcframework`, a libghostty build produced from +VVTerm's custom-I/O and live-padding Ghostty branch. - Upstream project: https://github.com/ghostty-org/ghostty -- Custom-I/O base fork: https://github.com/wiedymi/ghostty/tree/custom-io -- Vendored source fork: https://github.com/Yash-Singh1/ghostty/tree/custom-io -- Vendored revision: `d36c3b8dffd0d756dd5e5f4933962f774a0e6753` +- Vendored source branch: https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed +- Vendored revision: `cf8edc23f3a6a87a96e41a90013e89e987d34980` +- Based on: https://github.com/wiedymi/ghostty/tree/vvterm/custom-io-padding - Reference integration: https://github.com/vivy-company/vvterm - License: MIT diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h index 232e094ceef07..05ee8f1826949 100644 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h @@ -1,10 +1,14 @@ -// Ghostty embedding API. The documentation for the embedding API is -// only within the Zig source files that define the implementations. This -// isn't meant to be a general purpose embedding API (yet) so there hasn't -// been documentation or example work beyond that. +// Ghostty's internal embedder API, a.k.a. "libghostty-internal". // -// The only consumer of this API is the macOS app, but the API is built to -// be more general purpose. +// The only consumer of this API is the macOS app, and while it is fairly +// comprehensive, it is tailored to the needs of the macOS app and not designed +// for external use, hence why most functions are undocumented and some are +// macOS-specific (e.g. ones dealing with the Metal graphics API). +// +// External embedders should instead use `libghostty-vt` or other related +// packages, which are extensively documented and designed from the ground up +// to be used in other software. Header files for which can be found in +// `include/ghostty/`. #ifndef GHOSTTY_H #define GHOSTTY_H @@ -68,7 +72,7 @@ typedef enum { GHOSTTY_PLATFORM_IOS, } ghostty_platform_e; -// Callback for custom I/O write handler. +// Callback for custom surface I/O writes. typedef void (*ghostty_surface_write_fn)(void* userdata, const uint8_t* data, size_t len); @@ -76,19 +80,56 @@ typedef void (*ghostty_surface_write_fn)(void* userdata, typedef enum { GHOSTTY_CLIPBOARD_STANDARD, GHOSTTY_CLIPBOARD_SELECTION, + GHOSTTY_CLIPBOARD_PRIMARY, } ghostty_clipboard_e; +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. typedef struct { const char *mime; const char *data; + size_t len; } ghostty_clipboard_content_s; +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_LIST, } ghostty_clipboard_request_e; +// apprt.ClipboardReadResult +typedef enum { + GHOSTTY_CLIPBOARD_READ_STARTED, + GHOSTTY_CLIPBOARD_READ_UNAVAILABLE, + GHOSTTY_CLIPBOARD_READ_UNSUPPORTED, +} ghostty_clipboard_read_result_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -369,7 +410,6 @@ typedef enum { } ghostty_input_trigger_tag_e; typedef union { - ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; // catch_all has no payload @@ -652,6 +692,12 @@ typedef enum { GHOSTTY_INSPECTOR_HIDE, } ghostty_action_inspector_e; +// apprt.action.ExportTerminalIO.C +typedef struct { + const char* contents; + size_t len; +} ghostty_action_export_terminal_io_s; + // apprt.action.QuitTimer typedef enum { GHOSTTY_QUIT_TIMER_START, @@ -679,6 +725,7 @@ typedef struct { typedef enum { GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB, + GHOSTTY_PROMPT_TITLE_WINDOW, } ghostty_action_prompt_title_e; // apprt.action.Pwd.C @@ -686,6 +733,14 @@ typedef struct { const char* pwd; } ghostty_action_pwd_s; +// apprt.action.OpenConfig +typedef enum { + // Open the config in the OS default editor. + GHOSTTY_ACTION_OPEN_CONFIG_OS_OPEN, + // Open the config in a new window using $EDITOR or $VISUAL + GHOSTTY_ACTION_OPEN_CONFIG_NEW_WINDOW, +} ghostty_action_open_config_e; + // terminal.MouseShape typedef enum { GHOSTTY_MOUSE_SHAPE_DEFAULT, @@ -819,6 +874,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_HTML, + GHOSTTY_ACTION_OPEN_URL_KIND_OSC8, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C @@ -921,9 +977,11 @@ typedef enum { GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_EXPORT_TERMINAL_IO, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_SET_TAB_TITLE, + GHOSTTY_ACTION_SET_WINDOW_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -941,6 +999,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, @@ -955,6 +1014,7 @@ typedef enum { GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, + GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW, } ghostty_action_tag_e; typedef union { @@ -970,6 +1030,7 @@ typedef union { ghostty_action_cell_size_s cell_size; ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; + ghostty_action_export_terminal_io_s export_terminal_io; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; ghostty_action_set_title_s set_tab_title; @@ -996,6 +1057,7 @@ typedef union { ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; + ghostty_action_open_config_e open_config; } ghostty_action_u; typedef struct { @@ -1004,12 +1066,16 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, - ghostty_clipboard_e, - void*); +typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( + void*, + ghostty_clipboard_e, + void*, + const char* const*, + size_t, + bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const char*, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1061,6 +1127,8 @@ typedef union { // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_NEW_TAB, + GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, } ghostty_ipc_action_tag_e; //------------------------------------------------------------------- @@ -1084,6 +1152,7 @@ GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintpt GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char*, uintptr_t); +GHOSTTY_API bool ghostty_config_key_is_binding(ghostty_config_t, ghostty_input_key_s); GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); @@ -1095,7 +1164,6 @@ GHOSTTY_API void ghostty_app_tick(ghostty_app_t); GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); -GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); @@ -1116,10 +1184,12 @@ GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); -GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t); +GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, + const uint8_t*, + size_t); GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t, - ghostty_surface_write_fn, - void*); + ghostty_surface_write_fn, + void*); GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); @@ -1161,10 +1231,12 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_complete_s*, + void*); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h deleted file mode 100644 index 4f8fef88ecc7f..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @file vt.h - * - * libghostty-vt - Virtual terminal emulator library - * - * This library provides functionality for parsing and handling terminal - * escape sequences as well as maintaining terminal state such as styles, - * cursor position, screen, scrollback, and more. - * - * WARNING: This is an incomplete, work-in-progress API. It is not yet - * stable and is definitely going to change. - */ - -/** - * @mainpage libghostty-vt - Virtual Terminal Emulator Library - * - * libghostty-vt is a C library which implements a modern terminal emulator, - * extracted from the [Ghostty](https://ghostty.org) terminal emulator. - * - * libghostty-vt contains the logic for handling the core parts of a terminal - * emulator: parsing terminal escape sequences, maintaining terminal state, - * encoding input events, etc. It can handle scrollback, line wrapping, - * reflow on resize, and more. - * - * @warning This library is currently in development and the API is not yet stable. - * Breaking changes are expected in future versions. Use with caution in production code. - * - * @section groups_sec API Reference - * - * The API is organized into the following groups: - * - @ref key "Key Encoding" - Encode key events into terminal sequences - * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences - * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences - * - @ref paste "Paste Utilities" - Validate paste data safety - * - @ref allocator "Memory Management" - Memory management and custom allocators - * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions - * - * @section examples_sec Examples - * - * Complete working examples: - * - @ref c-vt/src/main.c - OSC parser example - * - @ref c-vt-key-encode/src/main.c - Key encoding example - * - @ref c-vt-paste/src/main.c - Paste safety check example - * - @ref c-vt-sgr/src/main.c - SGR parser example - * - */ - -/** @example c-vt/src/main.c - * This example demonstrates how to use the OSC parser to parse an OSC sequence, - * extract command information, and retrieve command-specific data like window titles. - */ - -/** @example c-vt-key-encode/src/main.c - * This example demonstrates how to use the key encoder to convert key events - * into terminal escape sequences using the Kitty keyboard protocol. - */ - -/** @example c-vt-paste/src/main.c - * This example demonstrates how to use the paste utilities to check if - * paste data is safe before sending it to the terminal. - */ - -/** @example c-vt-sgr/src/main.c - * This example demonstrates how to use the SGR parser to parse terminal - * styling sequences and extract text attributes like colors and underline styles. - */ - -#ifndef GHOSTTY_VT_H -#define GHOSTTY_VT_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h deleted file mode 100644 index 4cebe91bb10eb..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @file allocator.h - * - * Memory management interface for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_ALLOCATOR_H -#define GHOSTTY_VT_ALLOCATOR_H - -#include -#include -#include - -/** @defgroup allocator Memory Management - * - * libghostty-vt does require memory allocation for various operations, - * but is resilient to allocation failures and will gracefully handle - * out-of-memory situations by returning error codes. - * - * The exact memory management semantics are documented in the relevant - * functions and data structures. - * - * libghostty-vt uses explicit memory allocation via an allocator - * interface provided by GhosttyAllocator. The interface is based on the - * [Zig](https://ziglang.org) allocator interface, since this has been - * shown to be a flexible and powerful interface in practice and enables - * a wide variety of allocation strategies. - * - * **For the common case, you can pass NULL as the allocator for any - * function that accepts one,** and libghostty will use a default allocator. - * The default allocator will be libc malloc/free if libc is linked. - * Otherwise, a custom allocator is used (currently Zig's SMP allocator) - * that doesn't require any external dependencies. - * - * ## Basic Usage - * - * For simple use cases, you can ignore this interface entirely by passing NULL - * as the allocator parameter to functions that accept one. This will use the - * default allocator (typically libc malloc/free, if libc is linked, but - * we provide our own default allocator if libc isn't linked). - * - * To use a custom allocator: - * 1. Implement the GhosttyAllocatorVtable function pointers - * 2. Create a GhosttyAllocator struct with your vtable and context - * 3. Pass the allocator to functions that accept one - * - * @{ - */ - -/** - * Function table for custom memory allocator operations. - * - * This vtable defines the interface for a custom memory allocator. All - * function pointers must be valid and non-NULL. - * - * @ingroup allocator - * - * If you're not going to use a custom allocator, you can ignore all of - * this. All functions that take an allocator pointer allow NULL to use a - * default allocator. - * - * The interface is based on the Zig allocator interface. I'll say up front - * that it is easy to look at this interface and think "wow, this is really - * overcomplicated". The reason for this complexity is well thought out by - * the Zig folks, and it enables a diverse set of allocation strategies - * as shown by the Zig ecosystem. As a consolation, please note that many - * of the arguments are only needed for advanced use cases and can be - * safely ignored in simple implementations. For example, if you look at - * the Zig implementation of the libc allocator in `lib/std/heap.zig` - * (search for CAllocator), you'll see it is very simple. - * - * We chose to align with the Zig allocator interface because: - * - * 1. It is a proven interface that serves a wide variety of use cases - * in the real world via the Zig ecosystem. It's shown to work. - * - * 2. Our core implementation itself is Zig, and this lets us very - * cheaply and easily convert between C and Zig allocators. - * - * NOTE(mitchellh): In the future, we can have default implementations of - * resize/remap and allow those to be null. - */ -typedef struct { - /** - * Return a pointer to `len` bytes with specified `alignment`, or return - * `NULL` indicating the allocation failed. - * - * @param ctx The allocator context - * @param len Number of bytes to allocate - * @param alignment Required alignment for the allocation. Guaranteed to - * be a power of two between 1 and 16 inclusive. - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to allocated memory, or NULL if allocation failed - */ - void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory in place. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to resize - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return true if resize was successful in-place, false if relocation would be required - */ - bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory, allowing relocation. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * A non-`NULL` return value indicates the resize was successful. The - * allocation may have same address, or may have been relocated. In either - * case, the allocation now has size of `new_len`. A `NULL` return value - * indicates that the resize would be equivalent to allocating new memory, - * copying the bytes from the old memory, and then freeing the old memory. - * In such case, it is more efficient for the caller to perform the copy. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to remap - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed - */ - void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Free and invalidate a region of memory. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to free - * @param memory_len Size of the memory block - * @param alignment Alignment (must match original allocation) - * @param ret_addr First return address of the allocation call stack (0 if not provided) - */ - void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); -} GhosttyAllocatorVtable; - -/** - * Custom memory allocator. - * - * For functions that take an allocator pointer, a NULL pointer indicates - * that the default allocator should be used. The default allocator will - * be libc malloc/free if we're linking to libc. If libc isn't linked, - * a custom allocator is used (currently Zig's SMP allocator). - * - * @ingroup allocator - * - * Usage example: - * @code - * GhosttyAllocator allocator = { - * .vtable = &my_allocator_vtable, - * .ctx = my_allocator_state - * }; - * @endcode - */ -typedef struct GhosttyAllocator { - /** - * Opaque context pointer passed to all vtable functions. - * This allows the allocator implementation to maintain state - * or reference external resources needed for memory management. - */ - void *ctx; - - /** - * Pointer to the allocator's vtable containing function pointers - * for memory operations (alloc, resize, remap, free). - */ - const GhosttyAllocatorVtable *vtable; -} GhosttyAllocator; - -/** @} */ - -#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h deleted file mode 100644 index 0d57b8db4abe2..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @file color.h - * - * Color types and utilities. - */ - -#ifndef GHOSTTY_VT_COLOR_H -#define GHOSTTY_VT_COLOR_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * RGB color value. - * - * @ingroup sgr - */ -typedef struct { - uint8_t r; /**< Red component (0-255) */ - uint8_t g; /**< Green component (0-255) */ - uint8_t b; /**< Blue component (0-255) */ -} GhosttyColorRgb; - -/** - * Palette color index (0-255). - * - * @ingroup sgr - */ -typedef uint8_t GhosttyColorPaletteIndex; - -/** @addtogroup sgr - * @{ - */ - -/** Black color (0) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLACK 0 -/** Red color (1) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_RED 1 -/** Green color (2) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_GREEN 2 -/** Yellow color (3) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_YELLOW 3 -/** Blue color (4) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLUE 4 -/** Magenta color (5) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_MAGENTA 5 -/** Cyan color (6) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_CYAN 6 -/** White color (7) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_WHITE 7 -/** Bright black color (8) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 -/** Bright red color (9) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 -/** Bright green color (10) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 -/** Bright yellow color (11) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 -/** Bright blue color (12) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 -/** Bright magenta color (13) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 -/** Bright cyan color (14) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 -/** Bright white color (15) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 - -/** @} */ - -/** - * Get the RGB color components. - * - * This function extracts the individual red, green, and blue components - * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments - * where accessing struct fields directly is difficult. - * - * @param color The RGB color value - * @param r Pointer to store the red component (0-255) - * @param g Pointer to store the green component (0-255) - * @param b Pointer to store the blue component (0-255) - * - * @ingroup sgr - */ -void ghostty_color_rgb_get(GhosttyColorRgb color, - uint8_t* r, - uint8_t* g, - uint8_t* b); - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_COLOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h deleted file mode 100644 index 772b5d43bcf32..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file key.h - * - * Key encoding module - encode key events into terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_H -#define GHOSTTY_VT_KEY_H - -/** @defgroup key Key Encoding - * - * Utilities for encoding key events into terminal escape sequences, - * supporting both legacy encoding as well as Kitty Keyboard Protocol. - * - * ## Basic Usage - * - * 1. Create an encoder instance with ghostty_key_encoder_new() - * 2. Configure encoder options with ghostty_key_encoder_setopt(). - * 3. For each key event: - * - Create a key event with ghostty_key_event_new() - * - Set event properties (action, key, modifiers, etc.) - * - Encode with ghostty_key_encoder_encode() - * - Free the event with ghostty_key_event_free() - * - Note: You can also reuse the same key event multiple times by - * changing its properties. - * 4. Free the encoder with ghostty_key_encoder_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create encoder - * GhosttyKeyEncoder encoder; - * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); - * assert(result == GHOSTTY_SUCCESS); - * - * // Enable Kitty keyboard protocol with all features - * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, - * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); - * - * // Create and configure key event for Ctrl+C press - * GhosttyKeyEvent event; - * result = ghostty_key_event_new(NULL, &event); - * assert(result == GHOSTTY_SUCCESS); - * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); - * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); - * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); - * - * // Encode the key event - * char buf[128]; - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence (e.g., write to terminal) - * fwrite(buf, 1, written, stdout); - * - * // Cleanup - * ghostty_key_event_free(event); - * ghostty_key_encoder_free(encoder); - * return 0; - * } - * @endcode - * - * For a complete working example, see example/c-vt-key-encode in the - * repository. - * - * @{ - */ - -#include -#include - -/** @} */ - -#endif /* GHOSTTY_VT_KEY_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h deleted file mode 100644 index 766a2942796ff..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h +++ /dev/null @@ -1,221 +0,0 @@ -/** - * @file encoder.h - * - * Key event encoding to terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_ENCODER_H -#define GHOSTTY_VT_KEY_ENCODER_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key encoder instance. - * - * This handle represents a key encoder that converts key events into terminal - * escape sequences. - * - * @ingroup key - */ -typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; - -/** - * Kitty keyboard protocol flags. - * - * Bitflags representing the various modes of the Kitty keyboard protocol. - * These can be combined using bitwise OR operations. Valid values all - * start with `GHOSTTY_KITTY_KEY_`. - * - * @ingroup key - */ -typedef uint8_t GhosttyKittyKeyFlags; - -/** Kitty keyboard protocol disabled (all flags off) */ -#define GHOSTTY_KITTY_KEY_DISABLED 0 - -/** Disambiguate escape codes */ -#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) - -/** Report key press and release events */ -#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) - -/** Report alternate key codes */ -#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) - -/** Report all key events including those normally handled by the terminal */ -#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) - -/** Report associated text with key events */ -#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) - -/** All Kitty keyboard protocol flags enabled */ -#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) - -/** - * macOS option key behavior. - * - * Determines whether the "option" key on macOS is treated as "alt" or not. - * See the Ghostty `macos-option-as-alt` configuration option for more details. - * - * @ingroup key - */ -typedef enum { - /** Option key is not treated as alt */ - GHOSTTY_OPTION_AS_ALT_FALSE = 0, - /** Option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_TRUE = 1, - /** Only left option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_LEFT = 2, - /** Only right option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_RIGHT = 3, -} GhosttyOptionAsAlt; - -/** - * Key encoder option identifiers. - * - * These values are used with ghostty_key_encoder_setopt() to configure - * the behavior of the key encoder. - * - * @ingroup key - */ -typedef enum { - /** Terminal DEC mode 1: cursor key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, - - /** Terminal DEC mode 66: keypad key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, - - /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, - - /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, - - /** xterm modifyOtherKeys mode 2 (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, - - /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ - GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, - - /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ - GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, -} GhosttyKeyEncoderOption; - -/** - * Create a new key encoder instance. - * - * Creates a new key encoder with default options. The encoder can be configured - * using ghostty_key_encoder_setopt() and must be freed using - * ghostty_key_encoder_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param encoder Pointer to store the created encoder handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); - -/** - * Free a key encoder instance. - * - * Releases all resources associated with the key encoder. After this call, - * the encoder handle becomes invalid and must not be used. - * - * @param encoder The encoder handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); - -/** - * Set an option on the key encoder. - * - * Configures the behavior of the key encoder. Options control various aspects - * of encoding such as terminal modes (cursor key application mode, keypad mode), - * protocol selection (Kitty keyboard protocol flags), and platform-specific - * behaviors (macOS option-as-alt). - * - * A null pointer value does nothing. It does not reset the value to the - * default. The setopt call will do nothing. - * - * @param encoder The encoder handle, must not be NULL - * @param option The option to set - * @param value Pointer to the value to set (type depends on the option) - * - * @ingroup key - */ -void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); - -/** - * Encode a key event into a terminal escape sequence. - * - * Converts a key event into the appropriate terminal escape sequence based on - * the encoder's current options. The sequence is written to the provided buffer. - * - * Not all key events produce output. For example, unmodified modifier keys - * typically don't generate escape sequences. Check the out_len parameter to - * determine if any data was written. - * - * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY - * and out_len will contain the required buffer size. The caller can then - * allocate a larger buffer and call the function again. - * - * @param encoder The encoder handle, must not be NULL - * @param event The key event to encode, must not be NULL - * @param out_buf Buffer to write the encoded sequence to - * @param out_buf_size Size of the output buffer in bytes - * @param out_len Pointer to store the number of bytes written (may be NULL) - * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code - * - * ## Example: Calculate required buffer size - * - * @code{.c} - * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) - * size_t required = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); - * assert(result == GHOSTTY_OUT_OF_MEMORY); - * - * // Allocate buffer of required size - * char *buf = malloc(required); - * - * // Encode with properly sized buffer - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence... - * - * free(buf); - * @endcode - * - * ## Example: Direct encoding with static buffer - * - * @code{.c} - * // Most escape sequences are short, so a static buffer often suffices - * char buf[128]; - * size_t written = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * - * if (result == GHOSTTY_SUCCESS) { - * // Write the encoded sequence to the terminal - * write(pty_fd, buf, written); - * } else if (result == GHOSTTY_OUT_OF_MEMORY) { - * // Buffer too small, written contains required size - * char *dynamic_buf = malloc(written); - * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); - * assert(result == GHOSTTY_SUCCESS); - * write(pty_fd, dynamic_buf, written); - * free(dynamic_buf); - * } - * @endcode - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); - -#endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h deleted file mode 100644 index dbd2e9f841a5b..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h +++ /dev/null @@ -1,474 +0,0 @@ -/** - * @file event.h - * - * Key event representation and manipulation. - */ - -#ifndef GHOSTTY_VT_KEY_EVENT_H -#define GHOSTTY_VT_KEY_EVENT_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key event. - * - * This handle represents a keyboard input event containing information about - * the physical key pressed, modifiers, and generated text. - * - * @ingroup key - */ -typedef struct GhosttyKeyEvent *GhosttyKeyEvent; - -/** - * Keyboard input event types. - * - * @ingroup key - */ -typedef enum { - /** Key was released */ - GHOSTTY_KEY_ACTION_RELEASE = 0, - /** Key was pressed */ - GHOSTTY_KEY_ACTION_PRESS = 1, - /** Key is being repeated (held down) */ - GHOSTTY_KEY_ACTION_REPEAT = 2, -} GhosttyKeyAction; - -/** - * Keyboard modifier keys bitmask. - * - * A bitmask representing all keyboard modifiers. This tracks which modifier keys - * are pressed and, where supported by the platform, which side (left or right) - * of each modifier is active. - * - * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. - * - * Modifier side bits are only meaningful when the corresponding modifier bit is set. - * Not all platforms support distinguishing between left and right modifier - * keys and Ghostty is built to expect that some platforms may not provide this - * information. - * - * @ingroup key - */ -typedef uint16_t GhosttyMods; - -/** Shift key is pressed */ -#define GHOSTTY_MODS_SHIFT (1 << 0) -/** Control key is pressed */ -#define GHOSTTY_MODS_CTRL (1 << 1) -/** Alt/Option key is pressed */ -#define GHOSTTY_MODS_ALT (1 << 2) -/** Super/Command/Windows key is pressed */ -#define GHOSTTY_MODS_SUPER (1 << 3) -/** Caps Lock is active */ -#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) -/** Num Lock is active */ -#define GHOSTTY_MODS_NUM_LOCK (1 << 5) - -/** - * Right shift is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SHIFT is set. - */ -#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) -/** - * Right ctrl is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_CTRL is set. - */ -#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) -/** - * Right alt is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_ALT is set. - */ -#define GHOSTTY_MODS_ALT_SIDE (1 << 8) -/** - * Right super is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SUPER is set. - */ -#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) - -/** - * Physical key codes. - * - * The set of key codes that Ghostty is aware of. These represent physical keys - * on the keyboard and are layout-independent. For example, the "a" key on a US - * keyboard is the same as the "ф" key on a Russian keyboard, but both will - * report the same key_a value. - * - * Layout-dependent strings are provided separately as UTF-8 text and are produced - * by the platform. These values are based on the W3C UI Events KeyboardEvent code - * standard. See: https://www.w3.org/TR/uievents-code - * - * @ingroup key - */ -typedef enum { - GHOSTTY_KEY_UNIDENTIFIED = 0, - - // Writing System Keys (W3C § 3.1.1) - GHOSTTY_KEY_BACKQUOTE, - GHOSTTY_KEY_BACKSLASH, - GHOSTTY_KEY_BRACKET_LEFT, - GHOSTTY_KEY_BRACKET_RIGHT, - GHOSTTY_KEY_COMMA, - GHOSTTY_KEY_DIGIT_0, - GHOSTTY_KEY_DIGIT_1, - GHOSTTY_KEY_DIGIT_2, - GHOSTTY_KEY_DIGIT_3, - GHOSTTY_KEY_DIGIT_4, - GHOSTTY_KEY_DIGIT_5, - GHOSTTY_KEY_DIGIT_6, - GHOSTTY_KEY_DIGIT_7, - GHOSTTY_KEY_DIGIT_8, - GHOSTTY_KEY_DIGIT_9, - GHOSTTY_KEY_EQUAL, - GHOSTTY_KEY_INTL_BACKSLASH, - GHOSTTY_KEY_INTL_RO, - GHOSTTY_KEY_INTL_YEN, - GHOSTTY_KEY_A, - GHOSTTY_KEY_B, - GHOSTTY_KEY_C, - GHOSTTY_KEY_D, - GHOSTTY_KEY_E, - GHOSTTY_KEY_F, - GHOSTTY_KEY_G, - GHOSTTY_KEY_H, - GHOSTTY_KEY_I, - GHOSTTY_KEY_J, - GHOSTTY_KEY_K, - GHOSTTY_KEY_L, - GHOSTTY_KEY_M, - GHOSTTY_KEY_N, - GHOSTTY_KEY_O, - GHOSTTY_KEY_P, - GHOSTTY_KEY_Q, - GHOSTTY_KEY_R, - GHOSTTY_KEY_S, - GHOSTTY_KEY_T, - GHOSTTY_KEY_U, - GHOSTTY_KEY_V, - GHOSTTY_KEY_W, - GHOSTTY_KEY_X, - GHOSTTY_KEY_Y, - GHOSTTY_KEY_Z, - GHOSTTY_KEY_MINUS, - GHOSTTY_KEY_PERIOD, - GHOSTTY_KEY_QUOTE, - GHOSTTY_KEY_SEMICOLON, - GHOSTTY_KEY_SLASH, - - // Functional Keys (W3C § 3.1.2) - GHOSTTY_KEY_ALT_LEFT, - GHOSTTY_KEY_ALT_RIGHT, - GHOSTTY_KEY_BACKSPACE, - GHOSTTY_KEY_CAPS_LOCK, - GHOSTTY_KEY_CONTEXT_MENU, - GHOSTTY_KEY_CONTROL_LEFT, - GHOSTTY_KEY_CONTROL_RIGHT, - GHOSTTY_KEY_ENTER, - GHOSTTY_KEY_META_LEFT, - GHOSTTY_KEY_META_RIGHT, - GHOSTTY_KEY_SHIFT_LEFT, - GHOSTTY_KEY_SHIFT_RIGHT, - GHOSTTY_KEY_SPACE, - GHOSTTY_KEY_TAB, - GHOSTTY_KEY_CONVERT, - GHOSTTY_KEY_KANA_MODE, - GHOSTTY_KEY_NON_CONVERT, - - // Control Pad Section (W3C § 3.2) - GHOSTTY_KEY_DELETE, - GHOSTTY_KEY_END, - GHOSTTY_KEY_HELP, - GHOSTTY_KEY_HOME, - GHOSTTY_KEY_INSERT, - GHOSTTY_KEY_PAGE_DOWN, - GHOSTTY_KEY_PAGE_UP, - - // Arrow Pad Section (W3C § 3.3) - GHOSTTY_KEY_ARROW_DOWN, - GHOSTTY_KEY_ARROW_LEFT, - GHOSTTY_KEY_ARROW_RIGHT, - GHOSTTY_KEY_ARROW_UP, - - // Numpad Section (W3C § 3.4) - GHOSTTY_KEY_NUM_LOCK, - GHOSTTY_KEY_NUMPAD_0, - GHOSTTY_KEY_NUMPAD_1, - GHOSTTY_KEY_NUMPAD_2, - GHOSTTY_KEY_NUMPAD_3, - GHOSTTY_KEY_NUMPAD_4, - GHOSTTY_KEY_NUMPAD_5, - GHOSTTY_KEY_NUMPAD_6, - GHOSTTY_KEY_NUMPAD_7, - GHOSTTY_KEY_NUMPAD_8, - GHOSTTY_KEY_NUMPAD_9, - GHOSTTY_KEY_NUMPAD_ADD, - GHOSTTY_KEY_NUMPAD_BACKSPACE, - GHOSTTY_KEY_NUMPAD_CLEAR, - GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, - GHOSTTY_KEY_NUMPAD_COMMA, - GHOSTTY_KEY_NUMPAD_DECIMAL, - GHOSTTY_KEY_NUMPAD_DIVIDE, - GHOSTTY_KEY_NUMPAD_ENTER, - GHOSTTY_KEY_NUMPAD_EQUAL, - GHOSTTY_KEY_NUMPAD_MEMORY_ADD, - GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, - GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, - GHOSTTY_KEY_NUMPAD_MEMORY_STORE, - GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, - GHOSTTY_KEY_NUMPAD_MULTIPLY, - GHOSTTY_KEY_NUMPAD_PAREN_LEFT, - GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, - GHOSTTY_KEY_NUMPAD_SUBTRACT, - GHOSTTY_KEY_NUMPAD_SEPARATOR, - GHOSTTY_KEY_NUMPAD_UP, - GHOSTTY_KEY_NUMPAD_DOWN, - GHOSTTY_KEY_NUMPAD_RIGHT, - GHOSTTY_KEY_NUMPAD_LEFT, - GHOSTTY_KEY_NUMPAD_BEGIN, - GHOSTTY_KEY_NUMPAD_HOME, - GHOSTTY_KEY_NUMPAD_END, - GHOSTTY_KEY_NUMPAD_INSERT, - GHOSTTY_KEY_NUMPAD_DELETE, - GHOSTTY_KEY_NUMPAD_PAGE_UP, - GHOSTTY_KEY_NUMPAD_PAGE_DOWN, - - // Function Section (W3C § 3.5) - GHOSTTY_KEY_ESCAPE, - GHOSTTY_KEY_F1, - GHOSTTY_KEY_F2, - GHOSTTY_KEY_F3, - GHOSTTY_KEY_F4, - GHOSTTY_KEY_F5, - GHOSTTY_KEY_F6, - GHOSTTY_KEY_F7, - GHOSTTY_KEY_F8, - GHOSTTY_KEY_F9, - GHOSTTY_KEY_F10, - GHOSTTY_KEY_F11, - GHOSTTY_KEY_F12, - GHOSTTY_KEY_F13, - GHOSTTY_KEY_F14, - GHOSTTY_KEY_F15, - GHOSTTY_KEY_F16, - GHOSTTY_KEY_F17, - GHOSTTY_KEY_F18, - GHOSTTY_KEY_F19, - GHOSTTY_KEY_F20, - GHOSTTY_KEY_F21, - GHOSTTY_KEY_F22, - GHOSTTY_KEY_F23, - GHOSTTY_KEY_F24, - GHOSTTY_KEY_F25, - GHOSTTY_KEY_FN, - GHOSTTY_KEY_FN_LOCK, - GHOSTTY_KEY_PRINT_SCREEN, - GHOSTTY_KEY_SCROLL_LOCK, - GHOSTTY_KEY_PAUSE, - - // Media Keys (W3C § 3.6) - GHOSTTY_KEY_BROWSER_BACK, - GHOSTTY_KEY_BROWSER_FAVORITES, - GHOSTTY_KEY_BROWSER_FORWARD, - GHOSTTY_KEY_BROWSER_HOME, - GHOSTTY_KEY_BROWSER_REFRESH, - GHOSTTY_KEY_BROWSER_SEARCH, - GHOSTTY_KEY_BROWSER_STOP, - GHOSTTY_KEY_EJECT, - GHOSTTY_KEY_LAUNCH_APP_1, - GHOSTTY_KEY_LAUNCH_APP_2, - GHOSTTY_KEY_LAUNCH_MAIL, - GHOSTTY_KEY_MEDIA_PLAY_PAUSE, - GHOSTTY_KEY_MEDIA_SELECT, - GHOSTTY_KEY_MEDIA_STOP, - GHOSTTY_KEY_MEDIA_TRACK_NEXT, - GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, - GHOSTTY_KEY_POWER, - GHOSTTY_KEY_SLEEP, - GHOSTTY_KEY_AUDIO_VOLUME_DOWN, - GHOSTTY_KEY_AUDIO_VOLUME_MUTE, - GHOSTTY_KEY_AUDIO_VOLUME_UP, - GHOSTTY_KEY_WAKE_UP, - - // Legacy, Non-standard, and Special Keys (W3C § 3.7) - GHOSTTY_KEY_COPY, - GHOSTTY_KEY_CUT, - GHOSTTY_KEY_PASTE, -} GhosttyKey; - -/** - * Create a new key event instance. - * - * Creates a new key event with default values. The event must be freed using - * ghostty_key_event_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param event Pointer to store the created key event handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); - -/** - * Free a key event instance. - * - * Releases all resources associated with the key event. After this call, - * the event handle becomes invalid and must not be used. - * - * @param event The key event handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_event_free(GhosttyKeyEvent event); - -/** - * Set the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @param action The action to set - * - * @ingroup key - */ -void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); - -/** - * Get the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @return The key action - * - * @ingroup key - */ -GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); - -/** - * Set the physical key code. - * - * @param event The key event handle, must not be NULL - * @param key The physical key code to set - * - * @ingroup key - */ -void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); - -/** - * Get the physical key code. - * - * @param event The key event handle, must not be NULL - * @return The physical key code - * - * @ingroup key - */ -GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); - -/** - * Set the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @param mods The modifier keys bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); - -/** - * Get the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @return The modifier keys bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); - -/** - * Set the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @param consumed_mods The consumed modifiers bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); - -/** - * Get the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @return The consumed modifiers bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); - -/** - * Set whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @param composing Whether the key event is part of a composition sequence - * - * @ingroup key - */ -void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); - -/** - * Get whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @return Whether the key event is part of a composition sequence - * - * @ingroup key - */ -bool ghostty_key_event_get_composing(GhosttyKeyEvent event); - -/** - * Set the UTF-8 text generated by the key event. - * - * The key event does NOT take ownership of the text pointer. The caller - * must ensure the string remains valid for the lifetime needed by the event. - * - * @param event The key event handle, must not be NULL - * @param utf8 The UTF-8 text to set (or NULL for empty) - * @param len Length of the UTF-8 text in bytes - * - * @ingroup key - */ -void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); - -/** - * Get the UTF-8 text generated by the key event. - * - * The returned pointer is valid until the event is freed or the UTF-8 text is modified. - * - * @param event The key event handle, must not be NULL - * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) - * @return The UTF-8 text (or NULL for empty) - * - * @ingroup key - */ -const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); - -/** - * Set the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @param codepoint The unshifted Unicode codepoint to set - * - * @ingroup key - */ -void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); - -/** - * Get the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @return The unshifted Unicode codepoint - * - * @ingroup key - */ -uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); - -#endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h deleted file mode 100644 index f53077ab32679..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h +++ /dev/null @@ -1,233 +0,0 @@ -/** - * @file osc.h - * - * OSC (Operating System Command) sequence parser and command handling. - */ - -#ifndef GHOSTTY_VT_OSC_H -#define GHOSTTY_VT_OSC_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParser *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommand *GhosttyOscCommand; - -/** @defgroup osc OSC Parser - * - * OSC (Operating System Command) sequence parser and command handling. - * - * The parser operates in a streaming fashion, processing input byte-by-byte - * to handle OSC sequences that may arrive in fragments across multiple reads. - * This interface makes it easy to integrate into most environments and avoids - * over-allocating buffers. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_osc_new() - * 2. Feed bytes to the parser using ghostty_osc_next() - * 3. Finalize parsing with ghostty_osc_end() to get the command - * 4. Query command type and extract data using ghostty_osc_command_type() - * and ghostty_osc_command_data() - * 5. Free the parser with ghostty_osc_free() when done - * - * @{ - */ - -/** - * OSC command types. - * - * @ingroup osc - */ -typedef enum { - GHOSTTY_OSC_COMMAND_INVALID = 0, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, - GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, - GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, - GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, - GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, - GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, - GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, - GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, - GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, - GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, - GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, - GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, - GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, - GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, - GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, - GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, - GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, - GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, - GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, - GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, - GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, -} GhosttyOscCommandType; - -/** - * OSC command data types. - * - * These values specify what type of data to extract from an OSC command - * using `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef enum { - /** Invalid data type. Never results in any data extraction. */ - GHOSTTY_OSC_DATA_INVALID = 0, - - /** - * Window title string data. - * - * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE - * - * Output type: const char ** (pointer to null-terminated string) - * - * Lifetime: Valid until the next call to any ghostty_osc_* function with - * the same parser instance. Memory is owned by the parser. - */ - GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, -} GhosttyOscCommandData; - -/** - * Create a new OSC parser instance. - * - * Creates a new OSC (Operating System Command) parser using the provided - * allocator. The parser must be freed using ghostty_vt_osc_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup osc - */ -GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); - -/** - * Free an OSC parser instance. - * - * Releases all resources associated with the OSC parser. After this call, - * the parser handle becomes invalid and must not be used. - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup osc - */ -void ghostty_osc_free(GhosttyOscParser parser); - -/** - * Reset an OSC parser instance to its initial state. - * - * Resets the parser state, clearing any partially parsed OSC sequences - * and returning the parser to its initial state. This is useful for - * reusing a parser instance or recovering from parse errors. - * - * @param parser The parser handle to reset, must not be null. - * - * @ingroup osc - */ -void ghostty_osc_reset(GhosttyOscParser parser); - -/** - * Parse the next byte in an OSC sequence. - * - * Processes a single byte as part of an OSC sequence. The parser maintains - * internal state to track the progress through the sequence. Call this - * function for each byte in the sequence data. - * - * When finished pumping the parser with bytes, call ghostty_osc_end - * to get the final result. - * - * @param parser The parser handle, must not be null. - * @param byte The next byte to parse - * - * @ingroup osc - */ -void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); - -/** - * Finalize OSC parsing and retrieve the parsed command. - * - * Call this function after feeding all bytes of an OSC sequence to the parser - * using ghostty_osc_next() with the exception of the terminating character - * (ESC or ST). This function finalizes the parsing process and returns the - * parsed OSC command. - * - * The return value is never NULL. Invalid commands will return a command - * with type GHOSTTY_OSC_COMMAND_INVALID. - * - * The terminator parameter specifies the byte that terminated the OSC sequence - * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is - * preserved in the parsed command so that responses can use the same terminator - * format for better compatibility with the calling program. For commands that - * do not require a response, this parameter is ignored and the resulting - * command will not retain the terminator information. - * - * The returned command handle is valid until the next call to any - * `ghostty_osc_*` function with the same parser instance with the exception - * of command introspection functions such as `ghostty_osc_command_type`. - * - * @param parser The parser handle, must not be null. - * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) - * @return Handle to the parsed OSC command - * - * @ingroup osc - */ -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); - -/** - * Get the type of an OSC command. - * - * Returns the type identifier for the given OSC command. This can be used - * to determine what kind of command was parsed and what data might be - * available from it. - * - * @param command The OSC command handle to query (may be NULL) - * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL - * - * @ingroup osc - */ -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); - -/** - * Extract data from an OSC command. - * - * Extracts typed data from the given OSC command based on the specified - * data type. The output pointer must be of the appropriate type for the - * requested data kind. Valid command types, output types, and memory - * safety information are documented in the `GhosttyOscCommandData` enum. - * - * @param command The OSC command handle to query (may be NULL) - * @param data The type of data to extract - * @param out Pointer to store the extracted data (type depends on data parameter) - * @return true if data extraction was successful, false otherwise - * - * @ingroup osc - */ -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); - -/** @} */ - -#endif /* GHOSTTY_VT_OSC_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h deleted file mode 100644 index d90f303d43ebf..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file paste.h - * - * Paste utilities - validate and encode paste data for terminal input. - */ - -#ifndef GHOSTTY_VT_PASTE_H -#define GHOSTTY_VT_PASTE_H - -/** @defgroup paste Paste Utilities - * - * Utilities for validating paste data safety. - * - * ## Basic Usage - * - * Use ghostty_paste_is_safe() to check if paste data contains potentially - * dangerous sequences before sending it to the terminal. - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * const char* safe_data = "hello world"; - * const char* unsafe_data = "rm -rf /\n"; - * - * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { - * printf("Safe to paste\n"); - * } - * - * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { - * printf("Unsafe! Contains newline\n"); - * } - * - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Check if paste data is safe to paste into the terminal. - * - * Data is considered unsafe if it contains: - * - Newlines (`\n`) which can inject commands - * - The bracketed paste end sequence (`\x1b[201~`) which can be used - * to exit bracketed paste mode and inject commands - * - * This check is conservative and considers data unsafe regardless of - * current terminal state. - * - * @param data The paste data to check (must not be NULL) - * @param len The length of the data in bytes - * @return true if the data is safe to paste, false otherwise - */ -bool ghostty_paste_is_safe(const char* data, size_t len); - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h deleted file mode 100644 index 65938ee766f50..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @file result.h - * - * Result codes for libghostty-vt operations. - */ - -#ifndef GHOSTTY_VT_RESULT_H -#define GHOSTTY_VT_RESULT_H - -/** - * Result codes for libghostty-vt operations. - */ -typedef enum { - /** Operation completed successfully */ - GHOSTTY_SUCCESS = 0, - /** Operation failed due to failed allocation */ - GHOSTTY_OUT_OF_MEMORY = -1, - /** Operation failed due to invalid value */ - GHOSTTY_INVALID_VALUE = -2, -} GhosttyResult; - -#endif /* GHOSTTY_VT_RESULT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h deleted file mode 100644 index 0c1afc309bd5c..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h +++ /dev/null @@ -1,394 +0,0 @@ -/** - * @file sgr.h - * - * SGR (Select Graphic Rendition) attribute parsing and handling. - */ - -#ifndef GHOSTTY_VT_SGR_H -#define GHOSTTY_VT_SGR_H - -/** @defgroup sgr SGR Parser - * - * SGR (Select Graphic Rendition) attribute parser. - * - * SGR sequences are the syntax used to set styling attributes such as - * bold, italic, underline, and colors for text in terminal emulators. - * For example, you may be familiar with sequences like `ESC[1;31m`. The - * `1;31` is the SGR attribute list. - * - * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) - * and returns individual text attributes like bold, italic, colors, etc. - * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, - * and handles various color formats including 8-color, 16-color, 256-color, - * X11 named colors, and RGB in multiple formats. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_sgr_new() - * 2. Set SGR parameters with ghostty_sgr_set_params() - * 3. Iterate through attributes using ghostty_sgr_next() - * 4. Free the parser with ghostty_sgr_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create parser - * GhosttySgrParser parser; - * GhosttyResult result = ghostty_sgr_new(NULL, &parser); - * assert(result == GHOSTTY_SUCCESS); - * - * // Parse "bold, red foreground" sequence: ESC[1;31m - * uint16_t params[] = {1, 31}; - * result = ghostty_sgr_set_params(parser, params, NULL, 2); - * assert(result == GHOSTTY_SUCCESS); - * - * // Iterate through attributes - * GhosttySgrAttribute attr; - * while (ghostty_sgr_next(parser, &attr)) { - * switch (attr.tag) { - * case GHOSTTY_SGR_ATTR_BOLD: - * printf("Bold enabled\n"); - * break; - * case GHOSTTY_SGR_ATTR_FG_8: - * printf("Foreground color: %d\n", attr.value.fg_8); - * break; - * default: - * break; - * } - * } - * - * // Cleanup - * ghostty_sgr_free(parser); - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Opaque handle to an SGR parser instance. - * - * This handle represents an SGR (Select Graphic Rendition) parser that can - * be used to parse SGR sequences and extract individual text attributes. - * - * @ingroup sgr - */ -typedef struct GhosttySgrParser* GhosttySgrParser; - -/** - * SGR attribute tags. - * - * These values identify the type of an SGR attribute in a tagged union. - * Use the tag to determine which field in the attribute value union to access. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_ATTR_UNSET = 0, - GHOSTTY_SGR_ATTR_UNKNOWN = 1, - GHOSTTY_SGR_ATTR_BOLD = 2, - GHOSTTY_SGR_ATTR_RESET_BOLD = 3, - GHOSTTY_SGR_ATTR_ITALIC = 4, - GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, - GHOSTTY_SGR_ATTR_FAINT = 6, - GHOSTTY_SGR_ATTR_UNDERLINE = 7, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, - GHOSTTY_SGR_ATTR_OVERLINE = 12, - GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, - GHOSTTY_SGR_ATTR_BLINK = 14, - GHOSTTY_SGR_ATTR_RESET_BLINK = 15, - GHOSTTY_SGR_ATTR_INVERSE = 16, - GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, - GHOSTTY_SGR_ATTR_INVISIBLE = 18, - GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, - GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, - GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, - GHOSTTY_SGR_ATTR_BG_8 = 24, - GHOSTTY_SGR_ATTR_FG_8 = 25, - GHOSTTY_SGR_ATTR_RESET_FG = 26, - GHOSTTY_SGR_ATTR_RESET_BG = 27, - GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, - GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, - GHOSTTY_SGR_ATTR_BG_256 = 30, - GHOSTTY_SGR_ATTR_FG_256 = 31, -} GhosttySgrAttributeTag; - -/** - * Underline style types. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_UNDERLINE_NONE = 0, - GHOSTTY_SGR_UNDERLINE_SINGLE = 1, - GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, - GHOSTTY_SGR_UNDERLINE_CURLY = 3, - GHOSTTY_SGR_UNDERLINE_DOTTED = 4, - GHOSTTY_SGR_UNDERLINE_DASHED = 5, -} GhosttySgrUnderline; - -/** - * Unknown SGR attribute data. - * - * Contains the full parameter list and the partial list where parsing - * encountered an unknown or invalid sequence. - * - * @ingroup sgr - */ -typedef struct { - const uint16_t* full_ptr; - size_t full_len; - const uint16_t* partial_ptr; - size_t partial_len; -} GhosttySgrUnknown; - -/** - * SGR attribute value union. - * - * This union contains all possible attribute values. Use the tag field - * to determine which union member is active. Attributes without associated - * data (like bold, italic) don't use the union value. - * - * @ingroup sgr - */ -typedef union { - GhosttySgrUnknown unknown; - GhosttySgrUnderline underline; - GhosttyColorRgb underline_color; - GhosttyColorPaletteIndex underline_color_256; - GhosttyColorRgb direct_color_fg; - GhosttyColorRgb direct_color_bg; - GhosttyColorPaletteIndex bg_8; - GhosttyColorPaletteIndex fg_8; - GhosttyColorPaletteIndex bright_bg_8; - GhosttyColorPaletteIndex bright_fg_8; - GhosttyColorPaletteIndex bg_256; - GhosttyColorPaletteIndex fg_256; - uint64_t _padding[8]; -} GhosttySgrAttributeValue; - -/** - * SGR attribute (tagged union). - * - * A complete SGR attribute with both its type tag and associated value. - * Always check the tag field to determine which value union member is valid. - * - * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be - * identified by tag alone; the value union is not used for these and - * the memory in the value field is undefined. - * - * @ingroup sgr - */ -typedef struct { - GhosttySgrAttributeTag tag; - GhosttySgrAttributeValue value; -} GhosttySgrAttribute; - -/** - * Create a new SGR parser instance. - * - * Creates a new SGR (Select Graphic Rendition) parser using the provided - * allocator. The parser must be freed using ghostty_sgr_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or - * NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, - GhosttySgrParser* parser); - -/** - * Free an SGR parser instance. - * - * Releases all resources associated with the SGR parser. After this call, - * the parser handle becomes invalid and must not be used. This includes - * any attributes previously returned by ghostty_sgr_next(). - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup sgr - */ -void ghostty_sgr_free(GhosttySgrParser parser); - -/** - * Reset an SGR parser instance to the beginning of the parameter list. - * - * Resets the parser's iteration state without clearing the parameters. - * After calling this, ghostty_sgr_next() will start from the beginning - * of the parameter list again. - * - * @param parser The parser handle to reset, must not be NULL - * - * @ingroup sgr - */ -void ghostty_sgr_reset(GhosttySgrParser parser); - -/** - * Set SGR parameters for parsing. - * - * Sets the SGR parameter list to parse. Parameters are the numeric values - * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). - * - * The separators array optionally specifies the separator type for each - * parameter position. Each byte should be either ';' for semicolon or ':' - * for colon. This is needed for certain color formats that use colon - * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator - * values are treated as semicolons. The separators array must have the same - * length as the params array, if it is not NULL. - * - * If separators is NULL, all parameters are assumed to be semicolon-separated. - * - * This function makes an internal copy of the parameter and separator data, - * so the caller can safely free or modify the input arrays after this call. - * - * After calling this function, the parser is automatically reset and ready - * to iterate from the beginning. - * - * @param parser The parser handle, must not be NULL - * @param params Array of SGR parameter values - * @param separators Optional array of separator characters (';' or ':'), or - * NULL - * @param len Number of parameters (and separators if provided) - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, - const uint16_t* params, - const char* separators, - size_t len); - -/** - * Get the next SGR attribute. - * - * Parses and returns the next attribute from the parameter list. - * Call this function repeatedly until it returns false to process - * all attributes in the sequence. - * - * @param parser The parser handle, must not be NULL - * @param attr Pointer to store the next attribute - * @return true if an attribute was returned, false if no more attributes - * - * @ingroup sgr - */ -bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); - -/** - * Get the full parameter list from an unknown SGR attribute. - * - * This function retrieves the full parameter list that was provided to the - * parser when an unknown attribute was encountered. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the full parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the partial parameter list from an unknown SGR attribute. - * - * This function retrieves the partial parameter list where parsing stopped - * when an unknown attribute was encountered. Primarily useful in WebAssembly - * environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the partial parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the tag from an SGR attribute. - * - * This function extracts the tag that identifies which type of attribute - * this is. Primarily useful in WebAssembly environments where accessing - * struct fields directly is difficult. - * - * @param attr The SGR attribute - * @return The attribute tag - * - * @ingroup sgr - */ -GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); - -/** - * Get the value from an SGR attribute. - * - * This function returns a pointer to the value union from an SGR attribute. Use - * the tag to determine which field of the union is valid. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param attr Pointer to the SGR attribute - * @return Pointer to the attribute value union - * - * @ingroup sgr - */ -GhosttySgrAttributeValue* ghostty_sgr_attribute_value( - GhosttySgrAttribute* attr); - -#ifdef __wasm__ -/** - * Allocate memory for an SGR attribute (WebAssembly only). - * - * This is a convenience function for WebAssembly environments to allocate - * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. - * - * @return Pointer to the allocated attribute structure - * - * @ingroup wasm - */ -GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); - -/** - * Free memory for an SGR attribute (WebAssembly only). - * - * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. - * - * @param attr Pointer to the attribute structure to free - * - * @ingroup wasm - */ -void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); -#endif - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_SGR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h deleted file mode 100644 index 37a8263265d00..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @file wasm.h - * - * WebAssembly utility functions for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_WASM_H -#define GHOSTTY_VT_WASM_H - -#ifdef __wasm__ - -#include -#include - -/** @defgroup wasm WebAssembly Utilities - * - * Convenience functions for allocating various types in WebAssembly builds. - * **These are only available the libghostty-vt wasm module.** - * - * Ghostty relies on pointers to various types for ABI compatibility, and - * creating those pointers in Wasm can be tedious. These functions provide - * a purely additive set of utilities that simplify memory management in - * Wasm environments without changing the core C library API. - * - * @note These functions always use the default allocator. If you need - * custom allocation strategies, you should allocate types manually using - * your custom allocator. This is a very rare use case in the WebAssembly - * world so these are optimized for simplicity. - * - * ## Example Usage - * - * Here's a simple example of using the Wasm utilities with the key encoder: - * - * @code - * const { exports } = wasmInstance; - * const view = new DataView(wasmMemory.buffer); - * - * // Create key encoder - * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); - * exports.ghostty_key_encoder_new(null, encoderPtr); - * const encoder = view.getUint32(encoder, true); - * - * // Configure encoder with Kitty protocol flags - * const flagsPtr = exports.ghostty_wasm_alloc_u8(); - * view.setUint8(flagsPtr, 0x1F); - * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); - * - * // Allocate output buffer and size pointer - * const bufferSize = 32; - * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); - * const writtenPtr = exports.ghostty_wasm_alloc_usize(); - * - * // Encode the key event - * exports.ghostty_key_encoder_encode( - * encoder, eventPtr, bufPtr, bufferSize, writtenPtr - * ); - * - * // Read encoded output - * const bytesWritten = view.getUint32(writtenPtr, true); - * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); - * @endcode - * - * @remark The code above is pretty ugly! This is the lowest level interface - * to the libghostty-vt Wasm module. In practice, this should be wrapped - * in a higher-level API that abstracts away all this. - * - * @{ - */ - -/** - * Allocate an opaque pointer. This can be used for any opaque pointer - * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. - * - * @return Pointer to allocated opaque pointer, or NULL if allocation failed - * @ingroup wasm - */ -void** ghostty_wasm_alloc_opaque(void); - -/** - * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_opaque(void **ptr); - -/** - * Allocate an array of uint8_t values. - * - * @param len Number of uint8_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u8_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); - -/** - * Allocate an array of uint16_t values. - * - * @param len Number of uint16_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint16_t* ghostty_wasm_alloc_u16_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u16_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); - -/** - * Allocate a single uint8_t value. - * - * @return Pointer to allocated uint8_t, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8(void); - -/** - * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_u8(uint8_t *ptr); - -/** - * Allocate a single size_t value. - * - * @return Pointer to allocated size_t, or NULL if allocation failed - * @ingroup wasm - */ -size_t* ghostty_wasm_alloc_usize(void); - -/** - * Free a size_t allocated by ghostty_wasm_alloc_usize(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_usize(size_t *ptr); - -/** @} */ - -#endif /* __wasm__ */ - -#endif /* GHOSTTY_VT_WASM_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a index 788a46839d136..b8f96b69dd72b 100644 Binary files a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a differ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h index 232e094ceef07..05ee8f1826949 100644 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h @@ -1,10 +1,14 @@ -// Ghostty embedding API. The documentation for the embedding API is -// only within the Zig source files that define the implementations. This -// isn't meant to be a general purpose embedding API (yet) so there hasn't -// been documentation or example work beyond that. +// Ghostty's internal embedder API, a.k.a. "libghostty-internal". // -// The only consumer of this API is the macOS app, but the API is built to -// be more general purpose. +// The only consumer of this API is the macOS app, and while it is fairly +// comprehensive, it is tailored to the needs of the macOS app and not designed +// for external use, hence why most functions are undocumented and some are +// macOS-specific (e.g. ones dealing with the Metal graphics API). +// +// External embedders should instead use `libghostty-vt` or other related +// packages, which are extensively documented and designed from the ground up +// to be used in other software. Header files for which can be found in +// `include/ghostty/`. #ifndef GHOSTTY_H #define GHOSTTY_H @@ -68,7 +72,7 @@ typedef enum { GHOSTTY_PLATFORM_IOS, } ghostty_platform_e; -// Callback for custom I/O write handler. +// Callback for custom surface I/O writes. typedef void (*ghostty_surface_write_fn)(void* userdata, const uint8_t* data, size_t len); @@ -76,19 +80,56 @@ typedef void (*ghostty_surface_write_fn)(void* userdata, typedef enum { GHOSTTY_CLIPBOARD_STANDARD, GHOSTTY_CLIPBOARD_SELECTION, + GHOSTTY_CLIPBOARD_PRIMARY, } ghostty_clipboard_e; +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. typedef struct { const char *mime; const char *data; + size_t len; } ghostty_clipboard_content_s; +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_LIST, } ghostty_clipboard_request_e; +// apprt.ClipboardReadResult +typedef enum { + GHOSTTY_CLIPBOARD_READ_STARTED, + GHOSTTY_CLIPBOARD_READ_UNAVAILABLE, + GHOSTTY_CLIPBOARD_READ_UNSUPPORTED, +} ghostty_clipboard_read_result_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -369,7 +410,6 @@ typedef enum { } ghostty_input_trigger_tag_e; typedef union { - ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; // catch_all has no payload @@ -652,6 +692,12 @@ typedef enum { GHOSTTY_INSPECTOR_HIDE, } ghostty_action_inspector_e; +// apprt.action.ExportTerminalIO.C +typedef struct { + const char* contents; + size_t len; +} ghostty_action_export_terminal_io_s; + // apprt.action.QuitTimer typedef enum { GHOSTTY_QUIT_TIMER_START, @@ -679,6 +725,7 @@ typedef struct { typedef enum { GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB, + GHOSTTY_PROMPT_TITLE_WINDOW, } ghostty_action_prompt_title_e; // apprt.action.Pwd.C @@ -686,6 +733,14 @@ typedef struct { const char* pwd; } ghostty_action_pwd_s; +// apprt.action.OpenConfig +typedef enum { + // Open the config in the OS default editor. + GHOSTTY_ACTION_OPEN_CONFIG_OS_OPEN, + // Open the config in a new window using $EDITOR or $VISUAL + GHOSTTY_ACTION_OPEN_CONFIG_NEW_WINDOW, +} ghostty_action_open_config_e; + // terminal.MouseShape typedef enum { GHOSTTY_MOUSE_SHAPE_DEFAULT, @@ -819,6 +874,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_HTML, + GHOSTTY_ACTION_OPEN_URL_KIND_OSC8, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C @@ -921,9 +977,11 @@ typedef enum { GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_EXPORT_TERMINAL_IO, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_SET_TAB_TITLE, + GHOSTTY_ACTION_SET_WINDOW_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -941,6 +999,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, @@ -955,6 +1014,7 @@ typedef enum { GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, + GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW, } ghostty_action_tag_e; typedef union { @@ -970,6 +1030,7 @@ typedef union { ghostty_action_cell_size_s cell_size; ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; + ghostty_action_export_terminal_io_s export_terminal_io; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; ghostty_action_set_title_s set_tab_title; @@ -996,6 +1057,7 @@ typedef union { ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; + ghostty_action_open_config_e open_config; } ghostty_action_u; typedef struct { @@ -1004,12 +1066,16 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, - ghostty_clipboard_e, - void*); +typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( + void*, + ghostty_clipboard_e, + void*, + const char* const*, + size_t, + bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const char*, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1061,6 +1127,8 @@ typedef union { // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_NEW_TAB, + GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, } ghostty_ipc_action_tag_e; //------------------------------------------------------------------- @@ -1084,6 +1152,7 @@ GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintpt GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char*, uintptr_t); +GHOSTTY_API bool ghostty_config_key_is_binding(ghostty_config_t, ghostty_input_key_s); GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); @@ -1095,7 +1164,6 @@ GHOSTTY_API void ghostty_app_tick(ghostty_app_t); GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); -GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); @@ -1116,10 +1184,12 @@ GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); -GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t); +GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, + const uint8_t*, + size_t); GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t, - ghostty_surface_write_fn, - void*); + ghostty_surface_write_fn, + void*); GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); @@ -1161,10 +1231,12 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_complete_s*, + void*); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h deleted file mode 100644 index 4f8fef88ecc7f..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @file vt.h - * - * libghostty-vt - Virtual terminal emulator library - * - * This library provides functionality for parsing and handling terminal - * escape sequences as well as maintaining terminal state such as styles, - * cursor position, screen, scrollback, and more. - * - * WARNING: This is an incomplete, work-in-progress API. It is not yet - * stable and is definitely going to change. - */ - -/** - * @mainpage libghostty-vt - Virtual Terminal Emulator Library - * - * libghostty-vt is a C library which implements a modern terminal emulator, - * extracted from the [Ghostty](https://ghostty.org) terminal emulator. - * - * libghostty-vt contains the logic for handling the core parts of a terminal - * emulator: parsing terminal escape sequences, maintaining terminal state, - * encoding input events, etc. It can handle scrollback, line wrapping, - * reflow on resize, and more. - * - * @warning This library is currently in development and the API is not yet stable. - * Breaking changes are expected in future versions. Use with caution in production code. - * - * @section groups_sec API Reference - * - * The API is organized into the following groups: - * - @ref key "Key Encoding" - Encode key events into terminal sequences - * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences - * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences - * - @ref paste "Paste Utilities" - Validate paste data safety - * - @ref allocator "Memory Management" - Memory management and custom allocators - * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions - * - * @section examples_sec Examples - * - * Complete working examples: - * - @ref c-vt/src/main.c - OSC parser example - * - @ref c-vt-key-encode/src/main.c - Key encoding example - * - @ref c-vt-paste/src/main.c - Paste safety check example - * - @ref c-vt-sgr/src/main.c - SGR parser example - * - */ - -/** @example c-vt/src/main.c - * This example demonstrates how to use the OSC parser to parse an OSC sequence, - * extract command information, and retrieve command-specific data like window titles. - */ - -/** @example c-vt-key-encode/src/main.c - * This example demonstrates how to use the key encoder to convert key events - * into terminal escape sequences using the Kitty keyboard protocol. - */ - -/** @example c-vt-paste/src/main.c - * This example demonstrates how to use the paste utilities to check if - * paste data is safe before sending it to the terminal. - */ - -/** @example c-vt-sgr/src/main.c - * This example demonstrates how to use the SGR parser to parse terminal - * styling sequences and extract text attributes like colors and underline styles. - */ - -#ifndef GHOSTTY_VT_H -#define GHOSTTY_VT_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h deleted file mode 100644 index 4cebe91bb10eb..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @file allocator.h - * - * Memory management interface for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_ALLOCATOR_H -#define GHOSTTY_VT_ALLOCATOR_H - -#include -#include -#include - -/** @defgroup allocator Memory Management - * - * libghostty-vt does require memory allocation for various operations, - * but is resilient to allocation failures and will gracefully handle - * out-of-memory situations by returning error codes. - * - * The exact memory management semantics are documented in the relevant - * functions and data structures. - * - * libghostty-vt uses explicit memory allocation via an allocator - * interface provided by GhosttyAllocator. The interface is based on the - * [Zig](https://ziglang.org) allocator interface, since this has been - * shown to be a flexible and powerful interface in practice and enables - * a wide variety of allocation strategies. - * - * **For the common case, you can pass NULL as the allocator for any - * function that accepts one,** and libghostty will use a default allocator. - * The default allocator will be libc malloc/free if libc is linked. - * Otherwise, a custom allocator is used (currently Zig's SMP allocator) - * that doesn't require any external dependencies. - * - * ## Basic Usage - * - * For simple use cases, you can ignore this interface entirely by passing NULL - * as the allocator parameter to functions that accept one. This will use the - * default allocator (typically libc malloc/free, if libc is linked, but - * we provide our own default allocator if libc isn't linked). - * - * To use a custom allocator: - * 1. Implement the GhosttyAllocatorVtable function pointers - * 2. Create a GhosttyAllocator struct with your vtable and context - * 3. Pass the allocator to functions that accept one - * - * @{ - */ - -/** - * Function table for custom memory allocator operations. - * - * This vtable defines the interface for a custom memory allocator. All - * function pointers must be valid and non-NULL. - * - * @ingroup allocator - * - * If you're not going to use a custom allocator, you can ignore all of - * this. All functions that take an allocator pointer allow NULL to use a - * default allocator. - * - * The interface is based on the Zig allocator interface. I'll say up front - * that it is easy to look at this interface and think "wow, this is really - * overcomplicated". The reason for this complexity is well thought out by - * the Zig folks, and it enables a diverse set of allocation strategies - * as shown by the Zig ecosystem. As a consolation, please note that many - * of the arguments are only needed for advanced use cases and can be - * safely ignored in simple implementations. For example, if you look at - * the Zig implementation of the libc allocator in `lib/std/heap.zig` - * (search for CAllocator), you'll see it is very simple. - * - * We chose to align with the Zig allocator interface because: - * - * 1. It is a proven interface that serves a wide variety of use cases - * in the real world via the Zig ecosystem. It's shown to work. - * - * 2. Our core implementation itself is Zig, and this lets us very - * cheaply and easily convert between C and Zig allocators. - * - * NOTE(mitchellh): In the future, we can have default implementations of - * resize/remap and allow those to be null. - */ -typedef struct { - /** - * Return a pointer to `len` bytes with specified `alignment`, or return - * `NULL` indicating the allocation failed. - * - * @param ctx The allocator context - * @param len Number of bytes to allocate - * @param alignment Required alignment for the allocation. Guaranteed to - * be a power of two between 1 and 16 inclusive. - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to allocated memory, or NULL if allocation failed - */ - void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory in place. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to resize - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return true if resize was successful in-place, false if relocation would be required - */ - bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory, allowing relocation. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * A non-`NULL` return value indicates the resize was successful. The - * allocation may have same address, or may have been relocated. In either - * case, the allocation now has size of `new_len`. A `NULL` return value - * indicates that the resize would be equivalent to allocating new memory, - * copying the bytes from the old memory, and then freeing the old memory. - * In such case, it is more efficient for the caller to perform the copy. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to remap - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed - */ - void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Free and invalidate a region of memory. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to free - * @param memory_len Size of the memory block - * @param alignment Alignment (must match original allocation) - * @param ret_addr First return address of the allocation call stack (0 if not provided) - */ - void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); -} GhosttyAllocatorVtable; - -/** - * Custom memory allocator. - * - * For functions that take an allocator pointer, a NULL pointer indicates - * that the default allocator should be used. The default allocator will - * be libc malloc/free if we're linking to libc. If libc isn't linked, - * a custom allocator is used (currently Zig's SMP allocator). - * - * @ingroup allocator - * - * Usage example: - * @code - * GhosttyAllocator allocator = { - * .vtable = &my_allocator_vtable, - * .ctx = my_allocator_state - * }; - * @endcode - */ -typedef struct GhosttyAllocator { - /** - * Opaque context pointer passed to all vtable functions. - * This allows the allocator implementation to maintain state - * or reference external resources needed for memory management. - */ - void *ctx; - - /** - * Pointer to the allocator's vtable containing function pointers - * for memory operations (alloc, resize, remap, free). - */ - const GhosttyAllocatorVtable *vtable; -} GhosttyAllocator; - -/** @} */ - -#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h deleted file mode 100644 index 0d57b8db4abe2..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @file color.h - * - * Color types and utilities. - */ - -#ifndef GHOSTTY_VT_COLOR_H -#define GHOSTTY_VT_COLOR_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * RGB color value. - * - * @ingroup sgr - */ -typedef struct { - uint8_t r; /**< Red component (0-255) */ - uint8_t g; /**< Green component (0-255) */ - uint8_t b; /**< Blue component (0-255) */ -} GhosttyColorRgb; - -/** - * Palette color index (0-255). - * - * @ingroup sgr - */ -typedef uint8_t GhosttyColorPaletteIndex; - -/** @addtogroup sgr - * @{ - */ - -/** Black color (0) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLACK 0 -/** Red color (1) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_RED 1 -/** Green color (2) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_GREEN 2 -/** Yellow color (3) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_YELLOW 3 -/** Blue color (4) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLUE 4 -/** Magenta color (5) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_MAGENTA 5 -/** Cyan color (6) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_CYAN 6 -/** White color (7) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_WHITE 7 -/** Bright black color (8) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 -/** Bright red color (9) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 -/** Bright green color (10) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 -/** Bright yellow color (11) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 -/** Bright blue color (12) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 -/** Bright magenta color (13) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 -/** Bright cyan color (14) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 -/** Bright white color (15) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 - -/** @} */ - -/** - * Get the RGB color components. - * - * This function extracts the individual red, green, and blue components - * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments - * where accessing struct fields directly is difficult. - * - * @param color The RGB color value - * @param r Pointer to store the red component (0-255) - * @param g Pointer to store the green component (0-255) - * @param b Pointer to store the blue component (0-255) - * - * @ingroup sgr - */ -void ghostty_color_rgb_get(GhosttyColorRgb color, - uint8_t* r, - uint8_t* g, - uint8_t* b); - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_COLOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h deleted file mode 100644 index 772b5d43bcf32..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file key.h - * - * Key encoding module - encode key events into terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_H -#define GHOSTTY_VT_KEY_H - -/** @defgroup key Key Encoding - * - * Utilities for encoding key events into terminal escape sequences, - * supporting both legacy encoding as well as Kitty Keyboard Protocol. - * - * ## Basic Usage - * - * 1. Create an encoder instance with ghostty_key_encoder_new() - * 2. Configure encoder options with ghostty_key_encoder_setopt(). - * 3. For each key event: - * - Create a key event with ghostty_key_event_new() - * - Set event properties (action, key, modifiers, etc.) - * - Encode with ghostty_key_encoder_encode() - * - Free the event with ghostty_key_event_free() - * - Note: You can also reuse the same key event multiple times by - * changing its properties. - * 4. Free the encoder with ghostty_key_encoder_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create encoder - * GhosttyKeyEncoder encoder; - * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); - * assert(result == GHOSTTY_SUCCESS); - * - * // Enable Kitty keyboard protocol with all features - * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, - * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); - * - * // Create and configure key event for Ctrl+C press - * GhosttyKeyEvent event; - * result = ghostty_key_event_new(NULL, &event); - * assert(result == GHOSTTY_SUCCESS); - * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); - * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); - * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); - * - * // Encode the key event - * char buf[128]; - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence (e.g., write to terminal) - * fwrite(buf, 1, written, stdout); - * - * // Cleanup - * ghostty_key_event_free(event); - * ghostty_key_encoder_free(encoder); - * return 0; - * } - * @endcode - * - * For a complete working example, see example/c-vt-key-encode in the - * repository. - * - * @{ - */ - -#include -#include - -/** @} */ - -#endif /* GHOSTTY_VT_KEY_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h deleted file mode 100644 index 766a2942796ff..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h +++ /dev/null @@ -1,221 +0,0 @@ -/** - * @file encoder.h - * - * Key event encoding to terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_ENCODER_H -#define GHOSTTY_VT_KEY_ENCODER_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key encoder instance. - * - * This handle represents a key encoder that converts key events into terminal - * escape sequences. - * - * @ingroup key - */ -typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; - -/** - * Kitty keyboard protocol flags. - * - * Bitflags representing the various modes of the Kitty keyboard protocol. - * These can be combined using bitwise OR operations. Valid values all - * start with `GHOSTTY_KITTY_KEY_`. - * - * @ingroup key - */ -typedef uint8_t GhosttyKittyKeyFlags; - -/** Kitty keyboard protocol disabled (all flags off) */ -#define GHOSTTY_KITTY_KEY_DISABLED 0 - -/** Disambiguate escape codes */ -#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) - -/** Report key press and release events */ -#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) - -/** Report alternate key codes */ -#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) - -/** Report all key events including those normally handled by the terminal */ -#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) - -/** Report associated text with key events */ -#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) - -/** All Kitty keyboard protocol flags enabled */ -#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) - -/** - * macOS option key behavior. - * - * Determines whether the "option" key on macOS is treated as "alt" or not. - * See the Ghostty `macos-option-as-alt` configuration option for more details. - * - * @ingroup key - */ -typedef enum { - /** Option key is not treated as alt */ - GHOSTTY_OPTION_AS_ALT_FALSE = 0, - /** Option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_TRUE = 1, - /** Only left option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_LEFT = 2, - /** Only right option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_RIGHT = 3, -} GhosttyOptionAsAlt; - -/** - * Key encoder option identifiers. - * - * These values are used with ghostty_key_encoder_setopt() to configure - * the behavior of the key encoder. - * - * @ingroup key - */ -typedef enum { - /** Terminal DEC mode 1: cursor key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, - - /** Terminal DEC mode 66: keypad key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, - - /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, - - /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, - - /** xterm modifyOtherKeys mode 2 (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, - - /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ - GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, - - /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ - GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, -} GhosttyKeyEncoderOption; - -/** - * Create a new key encoder instance. - * - * Creates a new key encoder with default options. The encoder can be configured - * using ghostty_key_encoder_setopt() and must be freed using - * ghostty_key_encoder_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param encoder Pointer to store the created encoder handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); - -/** - * Free a key encoder instance. - * - * Releases all resources associated with the key encoder. After this call, - * the encoder handle becomes invalid and must not be used. - * - * @param encoder The encoder handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); - -/** - * Set an option on the key encoder. - * - * Configures the behavior of the key encoder. Options control various aspects - * of encoding such as terminal modes (cursor key application mode, keypad mode), - * protocol selection (Kitty keyboard protocol flags), and platform-specific - * behaviors (macOS option-as-alt). - * - * A null pointer value does nothing. It does not reset the value to the - * default. The setopt call will do nothing. - * - * @param encoder The encoder handle, must not be NULL - * @param option The option to set - * @param value Pointer to the value to set (type depends on the option) - * - * @ingroup key - */ -void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); - -/** - * Encode a key event into a terminal escape sequence. - * - * Converts a key event into the appropriate terminal escape sequence based on - * the encoder's current options. The sequence is written to the provided buffer. - * - * Not all key events produce output. For example, unmodified modifier keys - * typically don't generate escape sequences. Check the out_len parameter to - * determine if any data was written. - * - * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY - * and out_len will contain the required buffer size. The caller can then - * allocate a larger buffer and call the function again. - * - * @param encoder The encoder handle, must not be NULL - * @param event The key event to encode, must not be NULL - * @param out_buf Buffer to write the encoded sequence to - * @param out_buf_size Size of the output buffer in bytes - * @param out_len Pointer to store the number of bytes written (may be NULL) - * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code - * - * ## Example: Calculate required buffer size - * - * @code{.c} - * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) - * size_t required = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); - * assert(result == GHOSTTY_OUT_OF_MEMORY); - * - * // Allocate buffer of required size - * char *buf = malloc(required); - * - * // Encode with properly sized buffer - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence... - * - * free(buf); - * @endcode - * - * ## Example: Direct encoding with static buffer - * - * @code{.c} - * // Most escape sequences are short, so a static buffer often suffices - * char buf[128]; - * size_t written = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * - * if (result == GHOSTTY_SUCCESS) { - * // Write the encoded sequence to the terminal - * write(pty_fd, buf, written); - * } else if (result == GHOSTTY_OUT_OF_MEMORY) { - * // Buffer too small, written contains required size - * char *dynamic_buf = malloc(written); - * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); - * assert(result == GHOSTTY_SUCCESS); - * write(pty_fd, dynamic_buf, written); - * free(dynamic_buf); - * } - * @endcode - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); - -#endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h deleted file mode 100644 index dbd2e9f841a5b..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h +++ /dev/null @@ -1,474 +0,0 @@ -/** - * @file event.h - * - * Key event representation and manipulation. - */ - -#ifndef GHOSTTY_VT_KEY_EVENT_H -#define GHOSTTY_VT_KEY_EVENT_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key event. - * - * This handle represents a keyboard input event containing information about - * the physical key pressed, modifiers, and generated text. - * - * @ingroup key - */ -typedef struct GhosttyKeyEvent *GhosttyKeyEvent; - -/** - * Keyboard input event types. - * - * @ingroup key - */ -typedef enum { - /** Key was released */ - GHOSTTY_KEY_ACTION_RELEASE = 0, - /** Key was pressed */ - GHOSTTY_KEY_ACTION_PRESS = 1, - /** Key is being repeated (held down) */ - GHOSTTY_KEY_ACTION_REPEAT = 2, -} GhosttyKeyAction; - -/** - * Keyboard modifier keys bitmask. - * - * A bitmask representing all keyboard modifiers. This tracks which modifier keys - * are pressed and, where supported by the platform, which side (left or right) - * of each modifier is active. - * - * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. - * - * Modifier side bits are only meaningful when the corresponding modifier bit is set. - * Not all platforms support distinguishing between left and right modifier - * keys and Ghostty is built to expect that some platforms may not provide this - * information. - * - * @ingroup key - */ -typedef uint16_t GhosttyMods; - -/** Shift key is pressed */ -#define GHOSTTY_MODS_SHIFT (1 << 0) -/** Control key is pressed */ -#define GHOSTTY_MODS_CTRL (1 << 1) -/** Alt/Option key is pressed */ -#define GHOSTTY_MODS_ALT (1 << 2) -/** Super/Command/Windows key is pressed */ -#define GHOSTTY_MODS_SUPER (1 << 3) -/** Caps Lock is active */ -#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) -/** Num Lock is active */ -#define GHOSTTY_MODS_NUM_LOCK (1 << 5) - -/** - * Right shift is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SHIFT is set. - */ -#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) -/** - * Right ctrl is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_CTRL is set. - */ -#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) -/** - * Right alt is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_ALT is set. - */ -#define GHOSTTY_MODS_ALT_SIDE (1 << 8) -/** - * Right super is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SUPER is set. - */ -#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) - -/** - * Physical key codes. - * - * The set of key codes that Ghostty is aware of. These represent physical keys - * on the keyboard and are layout-independent. For example, the "a" key on a US - * keyboard is the same as the "ф" key on a Russian keyboard, but both will - * report the same key_a value. - * - * Layout-dependent strings are provided separately as UTF-8 text and are produced - * by the platform. These values are based on the W3C UI Events KeyboardEvent code - * standard. See: https://www.w3.org/TR/uievents-code - * - * @ingroup key - */ -typedef enum { - GHOSTTY_KEY_UNIDENTIFIED = 0, - - // Writing System Keys (W3C § 3.1.1) - GHOSTTY_KEY_BACKQUOTE, - GHOSTTY_KEY_BACKSLASH, - GHOSTTY_KEY_BRACKET_LEFT, - GHOSTTY_KEY_BRACKET_RIGHT, - GHOSTTY_KEY_COMMA, - GHOSTTY_KEY_DIGIT_0, - GHOSTTY_KEY_DIGIT_1, - GHOSTTY_KEY_DIGIT_2, - GHOSTTY_KEY_DIGIT_3, - GHOSTTY_KEY_DIGIT_4, - GHOSTTY_KEY_DIGIT_5, - GHOSTTY_KEY_DIGIT_6, - GHOSTTY_KEY_DIGIT_7, - GHOSTTY_KEY_DIGIT_8, - GHOSTTY_KEY_DIGIT_9, - GHOSTTY_KEY_EQUAL, - GHOSTTY_KEY_INTL_BACKSLASH, - GHOSTTY_KEY_INTL_RO, - GHOSTTY_KEY_INTL_YEN, - GHOSTTY_KEY_A, - GHOSTTY_KEY_B, - GHOSTTY_KEY_C, - GHOSTTY_KEY_D, - GHOSTTY_KEY_E, - GHOSTTY_KEY_F, - GHOSTTY_KEY_G, - GHOSTTY_KEY_H, - GHOSTTY_KEY_I, - GHOSTTY_KEY_J, - GHOSTTY_KEY_K, - GHOSTTY_KEY_L, - GHOSTTY_KEY_M, - GHOSTTY_KEY_N, - GHOSTTY_KEY_O, - GHOSTTY_KEY_P, - GHOSTTY_KEY_Q, - GHOSTTY_KEY_R, - GHOSTTY_KEY_S, - GHOSTTY_KEY_T, - GHOSTTY_KEY_U, - GHOSTTY_KEY_V, - GHOSTTY_KEY_W, - GHOSTTY_KEY_X, - GHOSTTY_KEY_Y, - GHOSTTY_KEY_Z, - GHOSTTY_KEY_MINUS, - GHOSTTY_KEY_PERIOD, - GHOSTTY_KEY_QUOTE, - GHOSTTY_KEY_SEMICOLON, - GHOSTTY_KEY_SLASH, - - // Functional Keys (W3C § 3.1.2) - GHOSTTY_KEY_ALT_LEFT, - GHOSTTY_KEY_ALT_RIGHT, - GHOSTTY_KEY_BACKSPACE, - GHOSTTY_KEY_CAPS_LOCK, - GHOSTTY_KEY_CONTEXT_MENU, - GHOSTTY_KEY_CONTROL_LEFT, - GHOSTTY_KEY_CONTROL_RIGHT, - GHOSTTY_KEY_ENTER, - GHOSTTY_KEY_META_LEFT, - GHOSTTY_KEY_META_RIGHT, - GHOSTTY_KEY_SHIFT_LEFT, - GHOSTTY_KEY_SHIFT_RIGHT, - GHOSTTY_KEY_SPACE, - GHOSTTY_KEY_TAB, - GHOSTTY_KEY_CONVERT, - GHOSTTY_KEY_KANA_MODE, - GHOSTTY_KEY_NON_CONVERT, - - // Control Pad Section (W3C § 3.2) - GHOSTTY_KEY_DELETE, - GHOSTTY_KEY_END, - GHOSTTY_KEY_HELP, - GHOSTTY_KEY_HOME, - GHOSTTY_KEY_INSERT, - GHOSTTY_KEY_PAGE_DOWN, - GHOSTTY_KEY_PAGE_UP, - - // Arrow Pad Section (W3C § 3.3) - GHOSTTY_KEY_ARROW_DOWN, - GHOSTTY_KEY_ARROW_LEFT, - GHOSTTY_KEY_ARROW_RIGHT, - GHOSTTY_KEY_ARROW_UP, - - // Numpad Section (W3C § 3.4) - GHOSTTY_KEY_NUM_LOCK, - GHOSTTY_KEY_NUMPAD_0, - GHOSTTY_KEY_NUMPAD_1, - GHOSTTY_KEY_NUMPAD_2, - GHOSTTY_KEY_NUMPAD_3, - GHOSTTY_KEY_NUMPAD_4, - GHOSTTY_KEY_NUMPAD_5, - GHOSTTY_KEY_NUMPAD_6, - GHOSTTY_KEY_NUMPAD_7, - GHOSTTY_KEY_NUMPAD_8, - GHOSTTY_KEY_NUMPAD_9, - GHOSTTY_KEY_NUMPAD_ADD, - GHOSTTY_KEY_NUMPAD_BACKSPACE, - GHOSTTY_KEY_NUMPAD_CLEAR, - GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, - GHOSTTY_KEY_NUMPAD_COMMA, - GHOSTTY_KEY_NUMPAD_DECIMAL, - GHOSTTY_KEY_NUMPAD_DIVIDE, - GHOSTTY_KEY_NUMPAD_ENTER, - GHOSTTY_KEY_NUMPAD_EQUAL, - GHOSTTY_KEY_NUMPAD_MEMORY_ADD, - GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, - GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, - GHOSTTY_KEY_NUMPAD_MEMORY_STORE, - GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, - GHOSTTY_KEY_NUMPAD_MULTIPLY, - GHOSTTY_KEY_NUMPAD_PAREN_LEFT, - GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, - GHOSTTY_KEY_NUMPAD_SUBTRACT, - GHOSTTY_KEY_NUMPAD_SEPARATOR, - GHOSTTY_KEY_NUMPAD_UP, - GHOSTTY_KEY_NUMPAD_DOWN, - GHOSTTY_KEY_NUMPAD_RIGHT, - GHOSTTY_KEY_NUMPAD_LEFT, - GHOSTTY_KEY_NUMPAD_BEGIN, - GHOSTTY_KEY_NUMPAD_HOME, - GHOSTTY_KEY_NUMPAD_END, - GHOSTTY_KEY_NUMPAD_INSERT, - GHOSTTY_KEY_NUMPAD_DELETE, - GHOSTTY_KEY_NUMPAD_PAGE_UP, - GHOSTTY_KEY_NUMPAD_PAGE_DOWN, - - // Function Section (W3C § 3.5) - GHOSTTY_KEY_ESCAPE, - GHOSTTY_KEY_F1, - GHOSTTY_KEY_F2, - GHOSTTY_KEY_F3, - GHOSTTY_KEY_F4, - GHOSTTY_KEY_F5, - GHOSTTY_KEY_F6, - GHOSTTY_KEY_F7, - GHOSTTY_KEY_F8, - GHOSTTY_KEY_F9, - GHOSTTY_KEY_F10, - GHOSTTY_KEY_F11, - GHOSTTY_KEY_F12, - GHOSTTY_KEY_F13, - GHOSTTY_KEY_F14, - GHOSTTY_KEY_F15, - GHOSTTY_KEY_F16, - GHOSTTY_KEY_F17, - GHOSTTY_KEY_F18, - GHOSTTY_KEY_F19, - GHOSTTY_KEY_F20, - GHOSTTY_KEY_F21, - GHOSTTY_KEY_F22, - GHOSTTY_KEY_F23, - GHOSTTY_KEY_F24, - GHOSTTY_KEY_F25, - GHOSTTY_KEY_FN, - GHOSTTY_KEY_FN_LOCK, - GHOSTTY_KEY_PRINT_SCREEN, - GHOSTTY_KEY_SCROLL_LOCK, - GHOSTTY_KEY_PAUSE, - - // Media Keys (W3C § 3.6) - GHOSTTY_KEY_BROWSER_BACK, - GHOSTTY_KEY_BROWSER_FAVORITES, - GHOSTTY_KEY_BROWSER_FORWARD, - GHOSTTY_KEY_BROWSER_HOME, - GHOSTTY_KEY_BROWSER_REFRESH, - GHOSTTY_KEY_BROWSER_SEARCH, - GHOSTTY_KEY_BROWSER_STOP, - GHOSTTY_KEY_EJECT, - GHOSTTY_KEY_LAUNCH_APP_1, - GHOSTTY_KEY_LAUNCH_APP_2, - GHOSTTY_KEY_LAUNCH_MAIL, - GHOSTTY_KEY_MEDIA_PLAY_PAUSE, - GHOSTTY_KEY_MEDIA_SELECT, - GHOSTTY_KEY_MEDIA_STOP, - GHOSTTY_KEY_MEDIA_TRACK_NEXT, - GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, - GHOSTTY_KEY_POWER, - GHOSTTY_KEY_SLEEP, - GHOSTTY_KEY_AUDIO_VOLUME_DOWN, - GHOSTTY_KEY_AUDIO_VOLUME_MUTE, - GHOSTTY_KEY_AUDIO_VOLUME_UP, - GHOSTTY_KEY_WAKE_UP, - - // Legacy, Non-standard, and Special Keys (W3C § 3.7) - GHOSTTY_KEY_COPY, - GHOSTTY_KEY_CUT, - GHOSTTY_KEY_PASTE, -} GhosttyKey; - -/** - * Create a new key event instance. - * - * Creates a new key event with default values. The event must be freed using - * ghostty_key_event_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param event Pointer to store the created key event handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); - -/** - * Free a key event instance. - * - * Releases all resources associated with the key event. After this call, - * the event handle becomes invalid and must not be used. - * - * @param event The key event handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_event_free(GhosttyKeyEvent event); - -/** - * Set the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @param action The action to set - * - * @ingroup key - */ -void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); - -/** - * Get the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @return The key action - * - * @ingroup key - */ -GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); - -/** - * Set the physical key code. - * - * @param event The key event handle, must not be NULL - * @param key The physical key code to set - * - * @ingroup key - */ -void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); - -/** - * Get the physical key code. - * - * @param event The key event handle, must not be NULL - * @return The physical key code - * - * @ingroup key - */ -GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); - -/** - * Set the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @param mods The modifier keys bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); - -/** - * Get the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @return The modifier keys bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); - -/** - * Set the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @param consumed_mods The consumed modifiers bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); - -/** - * Get the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @return The consumed modifiers bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); - -/** - * Set whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @param composing Whether the key event is part of a composition sequence - * - * @ingroup key - */ -void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); - -/** - * Get whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @return Whether the key event is part of a composition sequence - * - * @ingroup key - */ -bool ghostty_key_event_get_composing(GhosttyKeyEvent event); - -/** - * Set the UTF-8 text generated by the key event. - * - * The key event does NOT take ownership of the text pointer. The caller - * must ensure the string remains valid for the lifetime needed by the event. - * - * @param event The key event handle, must not be NULL - * @param utf8 The UTF-8 text to set (or NULL for empty) - * @param len Length of the UTF-8 text in bytes - * - * @ingroup key - */ -void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); - -/** - * Get the UTF-8 text generated by the key event. - * - * The returned pointer is valid until the event is freed or the UTF-8 text is modified. - * - * @param event The key event handle, must not be NULL - * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) - * @return The UTF-8 text (or NULL for empty) - * - * @ingroup key - */ -const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); - -/** - * Set the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @param codepoint The unshifted Unicode codepoint to set - * - * @ingroup key - */ -void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); - -/** - * Get the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @return The unshifted Unicode codepoint - * - * @ingroup key - */ -uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); - -#endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h deleted file mode 100644 index f53077ab32679..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h +++ /dev/null @@ -1,233 +0,0 @@ -/** - * @file osc.h - * - * OSC (Operating System Command) sequence parser and command handling. - */ - -#ifndef GHOSTTY_VT_OSC_H -#define GHOSTTY_VT_OSC_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParser *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommand *GhosttyOscCommand; - -/** @defgroup osc OSC Parser - * - * OSC (Operating System Command) sequence parser and command handling. - * - * The parser operates in a streaming fashion, processing input byte-by-byte - * to handle OSC sequences that may arrive in fragments across multiple reads. - * This interface makes it easy to integrate into most environments and avoids - * over-allocating buffers. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_osc_new() - * 2. Feed bytes to the parser using ghostty_osc_next() - * 3. Finalize parsing with ghostty_osc_end() to get the command - * 4. Query command type and extract data using ghostty_osc_command_type() - * and ghostty_osc_command_data() - * 5. Free the parser with ghostty_osc_free() when done - * - * @{ - */ - -/** - * OSC command types. - * - * @ingroup osc - */ -typedef enum { - GHOSTTY_OSC_COMMAND_INVALID = 0, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, - GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, - GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, - GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, - GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, - GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, - GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, - GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, - GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, - GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, - GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, - GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, - GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, - GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, - GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, - GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, - GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, - GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, - GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, - GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, - GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, -} GhosttyOscCommandType; - -/** - * OSC command data types. - * - * These values specify what type of data to extract from an OSC command - * using `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef enum { - /** Invalid data type. Never results in any data extraction. */ - GHOSTTY_OSC_DATA_INVALID = 0, - - /** - * Window title string data. - * - * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE - * - * Output type: const char ** (pointer to null-terminated string) - * - * Lifetime: Valid until the next call to any ghostty_osc_* function with - * the same parser instance. Memory is owned by the parser. - */ - GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, -} GhosttyOscCommandData; - -/** - * Create a new OSC parser instance. - * - * Creates a new OSC (Operating System Command) parser using the provided - * allocator. The parser must be freed using ghostty_vt_osc_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup osc - */ -GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); - -/** - * Free an OSC parser instance. - * - * Releases all resources associated with the OSC parser. After this call, - * the parser handle becomes invalid and must not be used. - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup osc - */ -void ghostty_osc_free(GhosttyOscParser parser); - -/** - * Reset an OSC parser instance to its initial state. - * - * Resets the parser state, clearing any partially parsed OSC sequences - * and returning the parser to its initial state. This is useful for - * reusing a parser instance or recovering from parse errors. - * - * @param parser The parser handle to reset, must not be null. - * - * @ingroup osc - */ -void ghostty_osc_reset(GhosttyOscParser parser); - -/** - * Parse the next byte in an OSC sequence. - * - * Processes a single byte as part of an OSC sequence. The parser maintains - * internal state to track the progress through the sequence. Call this - * function for each byte in the sequence data. - * - * When finished pumping the parser with bytes, call ghostty_osc_end - * to get the final result. - * - * @param parser The parser handle, must not be null. - * @param byte The next byte to parse - * - * @ingroup osc - */ -void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); - -/** - * Finalize OSC parsing and retrieve the parsed command. - * - * Call this function after feeding all bytes of an OSC sequence to the parser - * using ghostty_osc_next() with the exception of the terminating character - * (ESC or ST). This function finalizes the parsing process and returns the - * parsed OSC command. - * - * The return value is never NULL. Invalid commands will return a command - * with type GHOSTTY_OSC_COMMAND_INVALID. - * - * The terminator parameter specifies the byte that terminated the OSC sequence - * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is - * preserved in the parsed command so that responses can use the same terminator - * format for better compatibility with the calling program. For commands that - * do not require a response, this parameter is ignored and the resulting - * command will not retain the terminator information. - * - * The returned command handle is valid until the next call to any - * `ghostty_osc_*` function with the same parser instance with the exception - * of command introspection functions such as `ghostty_osc_command_type`. - * - * @param parser The parser handle, must not be null. - * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) - * @return Handle to the parsed OSC command - * - * @ingroup osc - */ -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); - -/** - * Get the type of an OSC command. - * - * Returns the type identifier for the given OSC command. This can be used - * to determine what kind of command was parsed and what data might be - * available from it. - * - * @param command The OSC command handle to query (may be NULL) - * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL - * - * @ingroup osc - */ -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); - -/** - * Extract data from an OSC command. - * - * Extracts typed data from the given OSC command based on the specified - * data type. The output pointer must be of the appropriate type for the - * requested data kind. Valid command types, output types, and memory - * safety information are documented in the `GhosttyOscCommandData` enum. - * - * @param command The OSC command handle to query (may be NULL) - * @param data The type of data to extract - * @param out Pointer to store the extracted data (type depends on data parameter) - * @return true if data extraction was successful, false otherwise - * - * @ingroup osc - */ -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); - -/** @} */ - -#endif /* GHOSTTY_VT_OSC_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h deleted file mode 100644 index d90f303d43ebf..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file paste.h - * - * Paste utilities - validate and encode paste data for terminal input. - */ - -#ifndef GHOSTTY_VT_PASTE_H -#define GHOSTTY_VT_PASTE_H - -/** @defgroup paste Paste Utilities - * - * Utilities for validating paste data safety. - * - * ## Basic Usage - * - * Use ghostty_paste_is_safe() to check if paste data contains potentially - * dangerous sequences before sending it to the terminal. - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * const char* safe_data = "hello world"; - * const char* unsafe_data = "rm -rf /\n"; - * - * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { - * printf("Safe to paste\n"); - * } - * - * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { - * printf("Unsafe! Contains newline\n"); - * } - * - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Check if paste data is safe to paste into the terminal. - * - * Data is considered unsafe if it contains: - * - Newlines (`\n`) which can inject commands - * - The bracketed paste end sequence (`\x1b[201~`) which can be used - * to exit bracketed paste mode and inject commands - * - * This check is conservative and considers data unsafe regardless of - * current terminal state. - * - * @param data The paste data to check (must not be NULL) - * @param len The length of the data in bytes - * @return true if the data is safe to paste, false otherwise - */ -bool ghostty_paste_is_safe(const char* data, size_t len); - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h deleted file mode 100644 index 65938ee766f50..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @file result.h - * - * Result codes for libghostty-vt operations. - */ - -#ifndef GHOSTTY_VT_RESULT_H -#define GHOSTTY_VT_RESULT_H - -/** - * Result codes for libghostty-vt operations. - */ -typedef enum { - /** Operation completed successfully */ - GHOSTTY_SUCCESS = 0, - /** Operation failed due to failed allocation */ - GHOSTTY_OUT_OF_MEMORY = -1, - /** Operation failed due to invalid value */ - GHOSTTY_INVALID_VALUE = -2, -} GhosttyResult; - -#endif /* GHOSTTY_VT_RESULT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h deleted file mode 100644 index 0c1afc309bd5c..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h +++ /dev/null @@ -1,394 +0,0 @@ -/** - * @file sgr.h - * - * SGR (Select Graphic Rendition) attribute parsing and handling. - */ - -#ifndef GHOSTTY_VT_SGR_H -#define GHOSTTY_VT_SGR_H - -/** @defgroup sgr SGR Parser - * - * SGR (Select Graphic Rendition) attribute parser. - * - * SGR sequences are the syntax used to set styling attributes such as - * bold, italic, underline, and colors for text in terminal emulators. - * For example, you may be familiar with sequences like `ESC[1;31m`. The - * `1;31` is the SGR attribute list. - * - * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) - * and returns individual text attributes like bold, italic, colors, etc. - * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, - * and handles various color formats including 8-color, 16-color, 256-color, - * X11 named colors, and RGB in multiple formats. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_sgr_new() - * 2. Set SGR parameters with ghostty_sgr_set_params() - * 3. Iterate through attributes using ghostty_sgr_next() - * 4. Free the parser with ghostty_sgr_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create parser - * GhosttySgrParser parser; - * GhosttyResult result = ghostty_sgr_new(NULL, &parser); - * assert(result == GHOSTTY_SUCCESS); - * - * // Parse "bold, red foreground" sequence: ESC[1;31m - * uint16_t params[] = {1, 31}; - * result = ghostty_sgr_set_params(parser, params, NULL, 2); - * assert(result == GHOSTTY_SUCCESS); - * - * // Iterate through attributes - * GhosttySgrAttribute attr; - * while (ghostty_sgr_next(parser, &attr)) { - * switch (attr.tag) { - * case GHOSTTY_SGR_ATTR_BOLD: - * printf("Bold enabled\n"); - * break; - * case GHOSTTY_SGR_ATTR_FG_8: - * printf("Foreground color: %d\n", attr.value.fg_8); - * break; - * default: - * break; - * } - * } - * - * // Cleanup - * ghostty_sgr_free(parser); - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Opaque handle to an SGR parser instance. - * - * This handle represents an SGR (Select Graphic Rendition) parser that can - * be used to parse SGR sequences and extract individual text attributes. - * - * @ingroup sgr - */ -typedef struct GhosttySgrParser* GhosttySgrParser; - -/** - * SGR attribute tags. - * - * These values identify the type of an SGR attribute in a tagged union. - * Use the tag to determine which field in the attribute value union to access. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_ATTR_UNSET = 0, - GHOSTTY_SGR_ATTR_UNKNOWN = 1, - GHOSTTY_SGR_ATTR_BOLD = 2, - GHOSTTY_SGR_ATTR_RESET_BOLD = 3, - GHOSTTY_SGR_ATTR_ITALIC = 4, - GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, - GHOSTTY_SGR_ATTR_FAINT = 6, - GHOSTTY_SGR_ATTR_UNDERLINE = 7, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, - GHOSTTY_SGR_ATTR_OVERLINE = 12, - GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, - GHOSTTY_SGR_ATTR_BLINK = 14, - GHOSTTY_SGR_ATTR_RESET_BLINK = 15, - GHOSTTY_SGR_ATTR_INVERSE = 16, - GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, - GHOSTTY_SGR_ATTR_INVISIBLE = 18, - GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, - GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, - GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, - GHOSTTY_SGR_ATTR_BG_8 = 24, - GHOSTTY_SGR_ATTR_FG_8 = 25, - GHOSTTY_SGR_ATTR_RESET_FG = 26, - GHOSTTY_SGR_ATTR_RESET_BG = 27, - GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, - GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, - GHOSTTY_SGR_ATTR_BG_256 = 30, - GHOSTTY_SGR_ATTR_FG_256 = 31, -} GhosttySgrAttributeTag; - -/** - * Underline style types. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_UNDERLINE_NONE = 0, - GHOSTTY_SGR_UNDERLINE_SINGLE = 1, - GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, - GHOSTTY_SGR_UNDERLINE_CURLY = 3, - GHOSTTY_SGR_UNDERLINE_DOTTED = 4, - GHOSTTY_SGR_UNDERLINE_DASHED = 5, -} GhosttySgrUnderline; - -/** - * Unknown SGR attribute data. - * - * Contains the full parameter list and the partial list where parsing - * encountered an unknown or invalid sequence. - * - * @ingroup sgr - */ -typedef struct { - const uint16_t* full_ptr; - size_t full_len; - const uint16_t* partial_ptr; - size_t partial_len; -} GhosttySgrUnknown; - -/** - * SGR attribute value union. - * - * This union contains all possible attribute values. Use the tag field - * to determine which union member is active. Attributes without associated - * data (like bold, italic) don't use the union value. - * - * @ingroup sgr - */ -typedef union { - GhosttySgrUnknown unknown; - GhosttySgrUnderline underline; - GhosttyColorRgb underline_color; - GhosttyColorPaletteIndex underline_color_256; - GhosttyColorRgb direct_color_fg; - GhosttyColorRgb direct_color_bg; - GhosttyColorPaletteIndex bg_8; - GhosttyColorPaletteIndex fg_8; - GhosttyColorPaletteIndex bright_bg_8; - GhosttyColorPaletteIndex bright_fg_8; - GhosttyColorPaletteIndex bg_256; - GhosttyColorPaletteIndex fg_256; - uint64_t _padding[8]; -} GhosttySgrAttributeValue; - -/** - * SGR attribute (tagged union). - * - * A complete SGR attribute with both its type tag and associated value. - * Always check the tag field to determine which value union member is valid. - * - * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be - * identified by tag alone; the value union is not used for these and - * the memory in the value field is undefined. - * - * @ingroup sgr - */ -typedef struct { - GhosttySgrAttributeTag tag; - GhosttySgrAttributeValue value; -} GhosttySgrAttribute; - -/** - * Create a new SGR parser instance. - * - * Creates a new SGR (Select Graphic Rendition) parser using the provided - * allocator. The parser must be freed using ghostty_sgr_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or - * NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, - GhosttySgrParser* parser); - -/** - * Free an SGR parser instance. - * - * Releases all resources associated with the SGR parser. After this call, - * the parser handle becomes invalid and must not be used. This includes - * any attributes previously returned by ghostty_sgr_next(). - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup sgr - */ -void ghostty_sgr_free(GhosttySgrParser parser); - -/** - * Reset an SGR parser instance to the beginning of the parameter list. - * - * Resets the parser's iteration state without clearing the parameters. - * After calling this, ghostty_sgr_next() will start from the beginning - * of the parameter list again. - * - * @param parser The parser handle to reset, must not be NULL - * - * @ingroup sgr - */ -void ghostty_sgr_reset(GhosttySgrParser parser); - -/** - * Set SGR parameters for parsing. - * - * Sets the SGR parameter list to parse. Parameters are the numeric values - * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). - * - * The separators array optionally specifies the separator type for each - * parameter position. Each byte should be either ';' for semicolon or ':' - * for colon. This is needed for certain color formats that use colon - * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator - * values are treated as semicolons. The separators array must have the same - * length as the params array, if it is not NULL. - * - * If separators is NULL, all parameters are assumed to be semicolon-separated. - * - * This function makes an internal copy of the parameter and separator data, - * so the caller can safely free or modify the input arrays after this call. - * - * After calling this function, the parser is automatically reset and ready - * to iterate from the beginning. - * - * @param parser The parser handle, must not be NULL - * @param params Array of SGR parameter values - * @param separators Optional array of separator characters (';' or ':'), or - * NULL - * @param len Number of parameters (and separators if provided) - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, - const uint16_t* params, - const char* separators, - size_t len); - -/** - * Get the next SGR attribute. - * - * Parses and returns the next attribute from the parameter list. - * Call this function repeatedly until it returns false to process - * all attributes in the sequence. - * - * @param parser The parser handle, must not be NULL - * @param attr Pointer to store the next attribute - * @return true if an attribute was returned, false if no more attributes - * - * @ingroup sgr - */ -bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); - -/** - * Get the full parameter list from an unknown SGR attribute. - * - * This function retrieves the full parameter list that was provided to the - * parser when an unknown attribute was encountered. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the full parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the partial parameter list from an unknown SGR attribute. - * - * This function retrieves the partial parameter list where parsing stopped - * when an unknown attribute was encountered. Primarily useful in WebAssembly - * environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the partial parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the tag from an SGR attribute. - * - * This function extracts the tag that identifies which type of attribute - * this is. Primarily useful in WebAssembly environments where accessing - * struct fields directly is difficult. - * - * @param attr The SGR attribute - * @return The attribute tag - * - * @ingroup sgr - */ -GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); - -/** - * Get the value from an SGR attribute. - * - * This function returns a pointer to the value union from an SGR attribute. Use - * the tag to determine which field of the union is valid. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param attr Pointer to the SGR attribute - * @return Pointer to the attribute value union - * - * @ingroup sgr - */ -GhosttySgrAttributeValue* ghostty_sgr_attribute_value( - GhosttySgrAttribute* attr); - -#ifdef __wasm__ -/** - * Allocate memory for an SGR attribute (WebAssembly only). - * - * This is a convenience function for WebAssembly environments to allocate - * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. - * - * @return Pointer to the allocated attribute structure - * - * @ingroup wasm - */ -GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); - -/** - * Free memory for an SGR attribute (WebAssembly only). - * - * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. - * - * @param attr Pointer to the attribute structure to free - * - * @ingroup wasm - */ -void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); -#endif - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_SGR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h deleted file mode 100644 index 37a8263265d00..0000000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @file wasm.h - * - * WebAssembly utility functions for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_WASM_H -#define GHOSTTY_VT_WASM_H - -#ifdef __wasm__ - -#include -#include - -/** @defgroup wasm WebAssembly Utilities - * - * Convenience functions for allocating various types in WebAssembly builds. - * **These are only available the libghostty-vt wasm module.** - * - * Ghostty relies on pointers to various types for ABI compatibility, and - * creating those pointers in Wasm can be tedious. These functions provide - * a purely additive set of utilities that simplify memory management in - * Wasm environments without changing the core C library API. - * - * @note These functions always use the default allocator. If you need - * custom allocation strategies, you should allocate types manually using - * your custom allocator. This is a very rare use case in the WebAssembly - * world so these are optimized for simplicity. - * - * ## Example Usage - * - * Here's a simple example of using the Wasm utilities with the key encoder: - * - * @code - * const { exports } = wasmInstance; - * const view = new DataView(wasmMemory.buffer); - * - * // Create key encoder - * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); - * exports.ghostty_key_encoder_new(null, encoderPtr); - * const encoder = view.getUint32(encoder, true); - * - * // Configure encoder with Kitty protocol flags - * const flagsPtr = exports.ghostty_wasm_alloc_u8(); - * view.setUint8(flagsPtr, 0x1F); - * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); - * - * // Allocate output buffer and size pointer - * const bufferSize = 32; - * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); - * const writtenPtr = exports.ghostty_wasm_alloc_usize(); - * - * // Encode the key event - * exports.ghostty_key_encoder_encode( - * encoder, eventPtr, bufPtr, bufferSize, writtenPtr - * ); - * - * // Read encoded output - * const bytesWritten = view.getUint32(writtenPtr, true); - * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); - * @endcode - * - * @remark The code above is pretty ugly! This is the lowest level interface - * to the libghostty-vt Wasm module. In practice, this should be wrapped - * in a higher-level API that abstracts away all this. - * - * @{ - */ - -/** - * Allocate an opaque pointer. This can be used for any opaque pointer - * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. - * - * @return Pointer to allocated opaque pointer, or NULL if allocation failed - * @ingroup wasm - */ -void** ghostty_wasm_alloc_opaque(void); - -/** - * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_opaque(void **ptr); - -/** - * Allocate an array of uint8_t values. - * - * @param len Number of uint8_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u8_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); - -/** - * Allocate an array of uint16_t values. - * - * @param len Number of uint16_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint16_t* ghostty_wasm_alloc_u16_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u16_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); - -/** - * Allocate a single uint8_t value. - * - * @return Pointer to allocated uint8_t, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8(void); - -/** - * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_u8(uint8_t *ptr); - -/** - * Allocate a single size_t value. - * - * @return Pointer to allocated size_t, or NULL if allocation failed - * @ingroup wasm - */ -size_t* ghostty_wasm_alloc_usize(void); - -/** - * Free a size_t allocated by ghostty_wasm_alloc_usize(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_usize(size_t *ptr); - -/** @} */ - -#endif /* __wasm__ */ - -#endif /* GHOSTTY_VT_WASM_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a index d35d909c34f96..d700ddcf130e2 100644 Binary files a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a differ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION b/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION index 15420168ec99d..92e5c8af3fc81 100644 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION @@ -1 +1 @@ -d36c3b8dffd0d756dd5e5f4933962f774a0e6753 +cf8edc23f3a6a87a96e41a90013e89e987d34980 diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index cee4d0427c603..f04db4467fdfe 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -459,7 +459,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { supports_selection_clipboard: false, wakeup_cb: { _ in }, action_cb: { _, _, _ in false }, - read_clipboard_cb: { _, _, _ in false }, + read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED }, confirm_read_clipboard_cb: { _, _, _, _ in }, write_clipboard_cb: { _, _, _, _, _ in }, close_surface_cb: { _, _ in } diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh index d2f1e19bc4082..4f6f95010c537 100755 --- a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh +++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh @@ -7,7 +7,7 @@ MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty" GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/ghostty}" -GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.16.0}" GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" log() { @@ -86,8 +86,8 @@ log "building GhosttyKit.xcframework" ) xcframework="${GHOSTTY_SOURCE_DIR}/macos/GhosttyKit.xcframework" -ios_archive="${xcframework}/ios-arm64/libghostty-fat.a" -sim_archive="${xcframework}/ios-arm64-simulator/libghostty-fat.a" +ios_archive="${xcframework}/ios-arm64/libghostty-internal.a" +sim_archive="${xcframework}/ios-arm64-simulator/libghostty-internal.a" [[ -f "${ios_archive}" ]] || die "missing built iOS archive: ${ios_archive}" [[ -f "${sim_archive}" ]] || die "missing built iOS simulator archive: ${sim_archive}" @@ -102,5 +102,8 @@ rsync -a --delete "${xcframework}/ios-arm64/Headers/" \ "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/Headers/" rsync -a --delete "${xcframework}/ios-arm64-simulator/Headers/" \ "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/Headers/" +sed -i '' -e 's/[[:space:]]*$//' \ + "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h" \ + "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h" log "done" diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 293833b20e225..d20cc534e601a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -52,7 +52,6 @@ "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", "@noble/curves": "catalog:", - "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@react-native-ai/apple": "0.12.0", "@react-native-menu/menu": "^2.0.0", diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts deleted file mode 100644 index 0a819df28d264..0000000000000 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; - -describe("resolveTerminalRouteBootstrap", () => { - it("redirects bare terminal routes to another already-running terminal for the thread", () => { - expect( - resolveTerminalRouteBootstrap({ - hasThread: true, - hasWorkspaceRoot: true, - hasOpened: false, - requestedTerminalId: null, - currentTerminalId: "default", - runningTerminalId: "term-2", - currentTerminalStatus: "closed", - hasCurrentTerminalHydration: false, - }), - ).toEqual({ - kind: "redirect", - terminalId: "term-2", - }); - }); - - it("hydrates the current running terminal when client state is not hydrated yet", () => { - expect( - resolveTerminalRouteBootstrap({ - hasThread: true, - hasWorkspaceRoot: true, - hasOpened: false, - requestedTerminalId: null, - currentTerminalId: "default", - runningTerminalId: "default", - currentTerminalStatus: "running", - hasCurrentTerminalHydration: false, - }), - ).toEqual({ - kind: "open", - }); - }); - - it("opens explicit terminal routes when the session still needs hydration", () => { - expect( - resolveTerminalRouteBootstrap({ - hasThread: true, - hasWorkspaceRoot: true, - hasOpened: false, - requestedTerminalId: "term-2", - currentTerminalId: "term-2", - runningTerminalId: "term-2", - currentTerminalStatus: "running", - hasCurrentTerminalHydration: false, - }), - ).toEqual({ - kind: "open", - }); - }); - - it("stays idle after the route already bootstrapped once", () => { - expect( - resolveTerminalRouteBootstrap({ - hasThread: true, - hasWorkspaceRoot: true, - hasOpened: true, - requestedTerminalId: null, - currentTerminalId: "default", - runningTerminalId: "default", - currentTerminalStatus: "running", - hasCurrentTerminalHydration: true, - }), - ).toEqual({ - kind: "idle", - }); - }); - - it("stays idle when the current running terminal is already hydrated in client state", () => { - expect( - resolveTerminalRouteBootstrap({ - hasThread: true, - hasWorkspaceRoot: true, - hasOpened: false, - requestedTerminalId: null, - currentTerminalId: "default", - runningTerminalId: "default", - currentTerminalStatus: "running", - hasCurrentTerminalHydration: true, - }), - ).toEqual({ - kind: "idle", - }); - }); - - it("stays idle for explicit running terminal routes that already have hydrated output", () => { - expect( - resolveTerminalRouteBootstrap({ - hasThread: true, - hasWorkspaceRoot: true, - hasOpened: false, - requestedTerminalId: "term-2", - currentTerminalId: "term-2", - runningTerminalId: "term-2", - currentTerminalStatus: "running", - hasCurrentTerminalHydration: true, - }), - ).toEqual({ - kind: "idle", - }); - }); -}); diff --git a/apps/mobile/src/features/terminal/terminalRouteBootstrap.ts b/apps/mobile/src/features/terminal/terminalRouteBootstrap.ts deleted file mode 100644 index 20514224d6fba..0000000000000 --- a/apps/mobile/src/features/terminal/terminalRouteBootstrap.ts +++ /dev/null @@ -1,35 +0,0 @@ -export function resolveTerminalRouteBootstrap(input: { - readonly hasThread: boolean; - readonly hasWorkspaceRoot: boolean; - readonly hasOpened: boolean; - readonly requestedTerminalId: string | null; - readonly currentTerminalId: string; - readonly runningTerminalId: string | null; - readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed"; - /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */ - readonly hasCurrentTerminalHydration: boolean; -}): - | { readonly kind: "idle" } - | { readonly kind: "redirect"; readonly terminalId: string } - | { readonly kind: "open" } { - if (!input.hasThread || !input.hasWorkspaceRoot || input.hasOpened) { - return { kind: "idle" }; - } - - if ( - input.requestedTerminalId === null && - input.runningTerminalId !== null && - input.runningTerminalId !== input.currentTerminalId - ) { - return { kind: "redirect", terminalId: input.runningTerminalId }; - } - - if ( - (input.currentTerminalStatus === "running" || input.currentTerminalStatus === "starting") && - input.hasCurrentTerminalHydration - ) { - return { kind: "idle" }; - } - - return { kind: "open" }; -} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 67c64ccf1479d..e325245e802d3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -125,6 +125,7 @@ import { cn } from "../../lib/cn"; import { deriveCenteredContentHorizontalPadding, deriveThreadFeedInitialContentInset, + deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; import { @@ -148,6 +149,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedLiveFollow, type ThreadFeedLiveFollowEvent, + type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, @@ -191,12 +193,9 @@ function formatMessageTime(input: string): string { return MESSAGE_TIME_FORMATTER.format(timestamp); } -// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's -// classNames. The fold row's min-h-11 (44px) stays taller than its single -// text-sm line at every supported base font size (26px at the 22pt maximum), -// so its height is a constant; a drifted value costs one correction on -// measure, not a persistent offset. -const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4) +// Fixed heights mirror renderFeedEntry's classNames and are only used while +// text fits at the current font settings. Larger accessibility text is measured. +const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); // Let neighboring rows move out of the new rows' space before showing their text. const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( @@ -1452,11 +1451,13 @@ function renderFeedEntry( props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; + readonly workRowSizing: ReturnType; + readonly workGroupScrollPositions: Map; readonly terminalAssistantMessageIds: ReadonlySet; readonly unsettledTurnId: TurnId | null; readonly onCopyWorkRow: (rowId: string, value: string) => void; - readonly onToggleWorkGroup: (groupId: string) => void; - readonly onToggleWorkRow: (rowId: string) => void; + readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void; + readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; @@ -1482,8 +1483,14 @@ function renderFeedEntry( onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" + style={{ + minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), + }} > - + {entry.label} props.onToggleWorkGroup(entry.groupId)} + onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); } @@ -1669,9 +1678,15 @@ function renderFeedEntry( return ( | null>(null); - const { width: windowWidth } = useWindowDimensions(); + const { width: windowWidth, fontScale } = useWindowDimensions(); + const { appearance } = useAppearancePreferences(); + const workRowSizing = useMemo( + () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }), + [appearance.baseFontSize, fontScale], + ); + const previousTextSize = useRef(workRowSizing.textSizeKey); + useLayoutEffect(() => { + if (previousTextSize.current === workRowSizing.textSizeKey) { + return; + } + previousTextSize.current = workRowSizing.textSizeKey; + // Text-size changes invalidate the outer list's fixed-height cache too. + // This never runs for scrolling, streamed output, or disclosure toggles. + props.listRef.current?.clearCaches({ mode: "sizes" }); + }, [workRowSizing.textSizeKey, props.listRef]); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); @@ -2227,6 +2257,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { () => ({ copiedRowId, expandedWorkRows, + workRowSizing, iconSubtleColor, markdownStyles, reviewCommentColors, @@ -2236,6 +2267,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [ copiedRowId, expandedWorkRows, + workRowSizing, iconSubtleColor, markdownStyles, reviewCommentColors, @@ -2338,6 +2370,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // ThreadId, and keying resets (or the list mount) on the bare id would // carry stale scroll/follow state across an environment switch. const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); + // Virtualized groups can unmount without losing the reader's place. This cache + // belongs to this thread view only and never causes per-scroll React updates. + const workGroupScrollPositions = useMemo( + () => new Map(), + [feedThreadKey], + ); useEffect(() => { reportHeaderMaterialVisibility(false); @@ -2541,8 +2579,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onToggleWorkGroup = useCallback( - (groupId: string) => { - suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`); + (groupId: string, anchorKey: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey); setInteractionState((current) => ({ ...current, expandedWorkGroups: { @@ -2555,8 +2593,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const onToggleWorkRow = useCallback( - (rowId: string) => { - suspendEndScrollMaintenanceForDisclosure(rowId); + (rowId: string, anchorKey: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey); setInteractionState((current) => ({ ...current, expandedWorkRows: { @@ -2610,6 +2648,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // average once one of their type has been measured. const getFixedItemSize = useCallback( (entry: ThreadFeedEntry) => { + if (workRowSizing.fixedRowHeight === undefined) { + return undefined; + } switch (entry.type) { case "turn-fold": return TURN_FOLD_HEIGHT; @@ -2625,7 +2666,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return undefined; } }, - [expandedWorkRows], + [expandedWorkRows, workRowSizing.fixedRowHeight], ); // Disclosures can mount existing offscreen rows as well as new work rows. @@ -2641,6 +2682,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { environmentId: props.environmentId, copiedRowId, expandedWorkRows, + workRowSizing, + workGroupScrollPositions, terminalAssistantMessageIds, unsettledTurnId, onCopyWorkRow, @@ -2668,6 +2711,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { copiedRowId, disclosureToggleSettling, expandedWorkRows, + workRowSizing, + workGroupScrollPositions, terminalAssistantMessageIds, unsettledTurnId, iconSubtleColor, diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index b776008058768..13e81130823af 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -3,8 +3,85 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveThreadFeedLiveFollow, resolveThreadFeedSubmissionAnchor, + resolveThreadWorkGroupInitialScroll, + shouldFollowThreadWorkGroupAppend, } from "./thread-feed-live-follow"; +describe("tool-group scroll restoration", () => { + const position = { + rowId: "read-output", + offsetWithinRow: 80, + scrollOffset: 600, + contentHeight: 1_000, + }; + + it("restores the visible row and its detail offset rather than stale absolute pixels", () => { + const before = [{ id: "first" }, { id: "read-output" }, { id: "last" }]; + const after = [{ id: "older" }, ...before]; + expect(resolveThreadWorkGroupInitialScroll(before, position)).toEqual({ + index: 1, + viewOffset: -80, + }); + expect(resolveThreadWorkGroupInitialScroll(after, position)).toEqual({ + index: 2, + viewOffset: -80, + }); + }); + + it("starts normally when the saved row no longer belongs to the group", () => { + expect(resolveThreadWorkGroupInitialScroll([{ id: "other" }], position)).toBeUndefined(); + expect(resolveThreadWorkGroupInitialScroll([{ id: "read-output" }], undefined)).toBeUndefined(); + }); +}); + +describe("tool-group append following", () => { + const previousRows = Array.from({ length: 10 }, (_, index) => ({ id: `call-${index}` })); + const appendedRows = [...previousRows, { id: "new-call" }]; + const atEnd = { + previousRows, + rows: appendedRows, + previousContentHeight: 289, + contentHeight: 318, + viewportHeight: 256, + scrollOffset: 33, + detailsChanged: false, + userScrolling: false, + }; + + it("follows a new call when the reader was at the end", () => { + expect(shouldFollowThreadWorkGroupAppend(atEnd)).toBe(true); + }); + + it("follows the first overflowing append as a short group reaches its height cap", () => { + expect( + shouldFollowThreadWorkGroupAppend({ + ...atEnd, + previousRows: previousRows.slice(0, 8), + rows: previousRows.slice(0, 9), + previousContentHeight: 231, + contentHeight: 260, + viewportHeight: 231, + scrollOffset: 0, + }), + ).toBe(true); + }); + + it.each([ + { name: "reading earlier calls", changes: { scrollOffset: 20 } }, + { name: "dragging before leaving the edge", changes: { userScrolling: true } }, + { name: "opening detail during an append", changes: { detailsChanged: true } }, + { name: "streaming a result", changes: { rows: previousRows, contentHeight: 600 } }, + { name: "updating a lifecycle label", changes: { rows: previousRows, contentHeight: 289 } }, + { name: "prepending old calls", changes: { rows: [{ id: "older" }, ...previousRows] } }, + { + name: "replacing a call while appending", + changes: { rows: [{ id: "replacement" }, ...appendedRows.slice(1)] }, + }, + ])("does not steal the reader's position when $name", ({ changes }) => { + expect(shouldFollowThreadWorkGroupAppend({ ...atEnd, ...changes })).toBe(false); + }); +}); + describe("resolveThreadFeedSubmissionAnchor", () => { it("anchors the first user message in a thread", () => { expect( diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 83d5cc22faed2..431dda7550d14 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -12,6 +12,41 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; }; +export interface ThreadWorkGroupScrollPosition { + readonly rowId: string; + readonly offsetWithinRow: number; + readonly scrollOffset: number; + readonly contentHeight: number; +} + +export function resolveThreadWorkGroupInitialScroll( + rows: ReadonlyArray<{ readonly id: string }>, + position: ThreadWorkGroupScrollPosition | undefined, +) { + const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; + return index >= 0 && position ? { index, viewOffset: -position.offsetWithinRow } : undefined; +} + +export function shouldFollowThreadWorkGroupAppend(input: { + readonly previousRows: ReadonlyArray<{ readonly id: string }>; + readonly rows: ReadonlyArray<{ readonly id: string }>; + readonly previousContentHeight: number; + readonly contentHeight: number; + readonly viewportHeight: number; + readonly scrollOffset: number; + readonly detailsChanged: boolean; + readonly userScrolling: boolean; +}) { + return ( + !input.detailsChanged && + !input.userScrolling && + input.contentHeight > input.previousContentHeight && + input.rows.length > input.previousRows.length && + input.previousRows.every((row, index) => row.id === input.rows[index]?.id) && + input.previousContentHeight - input.viewportHeight - input.scrollOffset <= 1 + ); +} + export function resolveThreadFeedSubmissionAnchor(input: { readonly currentAnchorMessageId: AnchorId | null; readonly submittedMessageId: AnchorId; diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 085c5580a9f90..a9472450b9b74 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,8 +1,21 @@ import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { MaskedView } from "@expo/ui/community/masked-view"; +import type { LegendListRef } from "@legendapp/list/react-native"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { useIsFocused } from "@react-navigation/native"; -import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react"; +import { + memo, + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type ReactNode, +} from "react"; import { AccessibilityInfo, AppState, @@ -15,12 +28,21 @@ import { import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; +import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; +import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { + resolveThreadWorkGroupInitialScroll, + shouldFollowThreadWorkGroupAppend, + type ThreadWorkGroupScrollPosition, +} from "./thread-feed-live-follow"; +import { + resolveWorkEntryToolPresentation, type ToolGroupSummaryKind, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; +import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; import Animated, { cancelAnimation, @@ -45,6 +67,33 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); +type WorkContentIcon = AppSymbolName | "browser" | "t3-code"; + +function WorkLogIcon(props: { + readonly icon: WorkContentIcon; + readonly color: ColorValue; + readonly highlighted?: boolean; +}) { + if (props.icon === "t3-code") { + return ( + + ); + } + return ( + + ); +} export function ThreadDisclosureChevron(props: { readonly expanded: boolean; @@ -86,7 +135,7 @@ export function ThreadDisclosureChevron(props: { function ShimmerWorkContent(props: { readonly highlighted: boolean; - readonly icon: AppSymbolName; + readonly icon: WorkContentIcon; readonly iconSubtleColor: ColorValue; readonly label: string; readonly onTextLayout?: ComponentProps["onTextLayout"]; @@ -96,14 +145,10 @@ function ShimmerWorkContent(props: { {props.showIcon ? ( - ) : null} @@ -122,7 +167,7 @@ function ShimmerWorkContent(props: { } export function ShimmeringWorkContent(props: { - readonly icon: AppSymbolName; + readonly icon: WorkContentIcon; readonly iconSubtleColor: ColorValue; readonly label: string; readonly showIcon: boolean; @@ -293,182 +338,461 @@ function isFreshRow(createdAt: string): boolean { return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; } -// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log -// rows are single-line (numberOfLines={1}) inside a min-height that stays -// taller than text-sm at every supported base font size, so row height is -// deterministic. Values mirror the classNames below. A mismatch only costs a -// one-time correction on measure. -const WORK_ROW_HEIGHT = 32; // min-h-8 +// The minimum matches min-h-8 below. Exact sizing is disabled when native +// accessibility scaling can make the single-line text taller than that minimum. +const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; const WORK_ROW_GAP = 1; // gap-px -const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 +const WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px rem +const WORK_GROUP_MAX_HEIGHT = 256; +const WORK_GROUP_EDGE_FADE_HEIGHT = 12; + +export const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; -export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8 +function workLogRowsHeight( + activities: ReadonlyArray, + rowHeight = WORK_ROW_HEIGHT, +): number { + return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP; +} export function collapsedWorkLogHeight(activities: ReadonlyArray): number { - const rows = activities; - if (rows.length === 0) { + if (activities.length === 0) { return 0; } - return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP; + const height = workLogRowsHeight(activities); + return ( + WORK_LOG_BOTTOM_MARGIN + + (activities[0]?.groupedToolDetail ? Math.min(height, WORK_GROUP_MAX_HEIGHT) : height) + ); } -export function ThreadWorkLog(props: { +interface ThreadWorkLogProps { readonly activities: ReadonlyArray; + readonly anchorKey: string; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; - readonly iconSubtleColor: import("react-native").ColorValue; + readonly rowSizing: ReturnType; + readonly scrollPositions: Map; + readonly iconSubtleColor: ColorValue; readonly onCopyRow: (rowId: string, value: string) => void; - readonly onToggleRow: (rowId: string) => void; + readonly onToggleRow: (rowId: string, anchorKey: string) => void; readonly renderImage: MarkdownImageRenderer; -}) { - const rows = props.activities.map((activity) => ({ - ...activity, - detail: compactActivityDetail(activity.detail), - })); +} + +export function ThreadWorkLog(props: ThreadWorkLogProps) { + const renderRow = useCallback( + (row: ThreadFeedActivity) => ( + + ), + [ + props.anchorKey, + props.copiedRowId, + props.expandedRows, + props.iconSubtleColor, + props.onCopyRow, + props.onToggleRow, + props.renderImage, + ], + ); - if (rows.length === 0) { + if (props.activities.length === 0) { return null; } return ( - - {rows.map((row) => { - const expanded = props.expandedRows[row.id] ?? false; - const canExpand = row.canExpand; - const fullDetail = expanded ? row.getFullDetail() : null; - const viewedImagePath = workEntryViewedImagePath(row.workEntry); - const previewText = row.detail ?? row.summary; - const displayText = expanded && row.workEntry.command?.trim() ? "Command" : previewText; - const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; - const failed = row.status === "failure"; - const showIcon = !row.groupedToolDetail || iconIsDestructive || failed; - - return ( - - { - if (canExpand) { - void Haptics.selectionAsync(); - props.onToggleRow(row.id); - } - }} - onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} - className="rounded-md px-0.5 py-0 active:bg-subtle" - > - - {row.live ? ( - - ) : ( - <> - - {showIcon ? ( - - ) : null} - - - {displayText} - - - )} - - - {props.copiedRowId === row.id ? ( - - Copied - - ) : null} - - {canExpand ? ( - - ) : null} - - - - - - {fullDetail ? ( - - {viewedImagePath ? ( - - {props.renderImage({ href: viewedImagePath, alt: null, title: null })} - - ) : null} - - - {fullDetail} - - - - ) : null} - - ); - })} - + {props.activities[0]?.groupedToolDetail ? ( + + ) : ( + {props.activities.map(renderRow)} + )} ); } +function ThreadWorkGroupList(props: { + readonly activities: ReadonlyArray; + readonly expandedRows: Readonly>; + readonly groupId: string; + readonly rowSizing: ReturnType; + readonly scrollPositions: Map; + readonly renderRow: (row: ThreadFeedActivity) => ReactNode; +}) { + const estimatedRowsHeight = workLogRowsHeight( + props.activities, + props.rowSizing.estimatedRowHeight, + ); + const [initialPosition] = useState(() => { + const position = props.scrollPositions.get(props.groupId); + return props.activities.some((row) => row.id === position?.rowId) ? position : undefined; + }); + const [initialScrollIndex] = useState(() => + resolveThreadWorkGroupInitialScroll(props.activities, initialPosition), + ); + const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); + const listRef = useRef(null); + const loadedRef = useRef(false); + const userScrollingRef = useRef(false); + const pendingAppendHeightRef = useRef(null); + const previousContent = useRef({ + rows: props.activities, + height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0), + expandedRows: props.expandedRows, + }); + const [measuredContent, setMeasuredContent] = useState(() => ({ + height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0), + rowCount: props.activities.length, + })); + const contentHeight = Math.max( + 1, + measuredContent.height + + Math.max(0, props.activities.length - measuredContent.rowCount) * + (props.rowSizing.estimatedRowHeight + WORK_ROW_GAP), + ); + const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); + const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); + const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); + const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; + const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height; + + // Opaque covers remove each edge fade at the scroll boundary. Scroll offset + // stays on the UI thread; only content-size changes update React state. + const topCoverStyle = useAnimatedStyle(() => ({ + opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), + })); + const bottomCoverStyle = useAnimatedStyle(() => ({ + opacity: + 1 - + Math.min( + 1, + Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, + ), + })); + const rememberPosition = useCallback(() => { + if (!loadedRef.current) return; + const state = listRef.current?.getState(); + const position = state && resolveWorkGroupScrollAnchor(state); + if (!state || !position) return; + props.scrollPositions.set(props.groupId, { + ...position, + contentHeight: state.contentLength, + }); + }, [props.groupId, props.scrollPositions]); + const finishPendingAppend = useCallback(() => { + const targetHeight = pendingAppendHeightRef.current; + const state = listRef.current?.getState(); + if ( + targetHeight !== null && + state && + !userScrollingRef.current && + Math.abs(state.scrollLength - targetHeight) <= 1 + ) { + pendingAppendHeightRef.current = null; + void listRef.current?.scrollToEnd({ animated: false }); + } + }, []); + const onContentSizeChange = useCallback( + (_width: number, nextHeight: number) => { + const previous = previousContent.current; + const detailsChanged = previous.expandedRows !== props.expandedRows; + const followAppend = + loadedRef.current && + shouldFollowThreadWorkGroupAppend({ + previousRows: previous.rows, + rows: props.activities, + previousContentHeight: previous.height, + contentHeight: nextHeight, + viewportHeight: Math.min(previous.height, WORK_GROUP_MAX_HEIGHT), + scrollOffset: scrollOffset.value, + detailsChanged, + userScrolling: userScrollingRef.current, + }); + previousContent.current = { + rows: props.activities, + height: nextHeight, + expandedRows: props.expandedRows, + }; + setMeasuredContent((current) => + current.height === nextHeight && current.rowCount === props.activities.length + ? current + : { height: nextHeight, rowCount: props.activities.length }, + ); + // Follow new calls only, never a detail toggle or a growing tool result. + if (followAppend) { + pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT); + } else if (detailsChanged || userScrollingRef.current || previous.rows !== props.activities) { + pendingAppendHeightRef.current = null; + } else if (pendingAppendHeightRef.current !== null) { + pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT); + } + // A short group can grow its viewport on this append. Wait for that + // layout before calculating the end offset, rather than jumping twice. + finishPendingAppend(); + rememberPosition(); + }, + [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition], + ); + const getFixedItemSize = useCallback( + (row: ThreadFeedActivity, index: number) => + props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined + ? undefined + : props.rowSizing.fixedRowHeight + (index < props.activities.length - 1 ? WORK_ROW_GAP : 0), + [props.activities.length, props.expandedRows, props.rowSizing.fixedRowHeight], + ); + const renderItem = useCallback( + ({ item, index }: { item: ThreadFeedActivity; index: number }) => ( + + {props.renderRow(item)} + + ), + [props.activities.length, props.renderRow], + ); + + return ( + + + + + + + + + + + + + + + + } + > + { + loadedRef.current = true; + setRestoringPosition(false); + rememberPosition(); + }} + onScroll={rememberPosition} + onScrollBeginDrag={() => { + userScrollingRef.current = true; + pendingAppendHeightRef.current = null; + }} + onScrollEndDrag={() => { + userScrollingRef.current = false; + }} + onMomentumScrollBegin={() => { + userScrollingRef.current = true; + pendingAppendHeightRef.current = null; + }} + onMomentumScrollEnd={() => { + userScrollingRef.current = false; + rememberPosition(); + }} + maintainVisibleContentPosition + nestedScrollEnabled + directionalLockEnabled + showsVerticalScrollIndicator + scrollsToTop={false} + bounces={false} + keyboardShouldPersistTaps="handled" + style={StyleSheet.absoluteFill} + /> + + ); +} + +function workLogRowKey(row: ThreadFeedActivity): string { + return row.id; +} + +const ThreadWorkLogRow = memo(function ThreadWorkLogRow( + props: Omit< + ThreadWorkLogProps, + "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions" + > & { + readonly row: ThreadFeedActivity; + readonly copied: boolean; + readonly expanded: boolean; + }, +) { + const { row, expanded } = props; + const canExpand = row.canExpand; + const fullDetail = expanded ? row.getFullDetail() : null; + const viewedImagePath = workEntryViewedImagePath(row.workEntry); + const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); + const previewText = + toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; + const displayText = + !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; + const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; + const failed = row.status === "failure"; + const icon = toolPresentation?.icon ?? (failed ? "xmark" : workRowSymbolName(row.icon)); + + return ( + + { + if (canExpand) { + void Haptics.selectionAsync(); + props.onToggleRow(row.id, props.anchorKey); + } + }} + onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} + className="rounded-md px-0.5 py-0 active:bg-subtle" + > + + {row.live ? ( + + ) : ( + <> + + + + + {displayText} + + + )} + + + {props.copied ? ( + + Copied + + ) : null} + {failed && toolPresentation ? ( + + + + ) : null} + + {canExpand ? ( + + ) : null} + + + + + + {fullDetail ? ( + + {viewedImagePath ? ( + + {props.renderImage({ href: viewedImagePath, alt: null, title: null })} + + ) : null} + + + {fullDetail} + + + + ) : null} + + ); +}); + export function ThreadWorkGroupToggle(props: { + readonly rowSizing: ReturnType; readonly expanded: boolean; readonly hiddenCount: number; readonly iconSubtleColor: import("react-native").ColorValue; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; + readonly summaryToolIcon?: "browser" | "t3-code"; readonly hasFailure: boolean; readonly shimmer: boolean; readonly onToggle: () => void; @@ -476,7 +800,7 @@ export function ThreadWorkGroupToggle(props: { const accessibilityLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; - const icon = toolGroupSummarySymbolName(props.summaryKind); + const icon = props.summaryToolIcon ?? toolGroupSummarySymbolName(props.summaryKind); return ( @@ -491,9 +815,11 @@ export function ThreadWorkGroupToggle(props: { props.onToggle(); }} className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle" + style={{ minHeight: props.rowSizing.estimatedRowHeight }} > {props.shimmer ? ( - + - + {props.summary} @@ -533,6 +858,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { return { ios: "square.and.pencil", android: "edit" }; case "command": return { ios: "terminal", android: "terminal" }; + case "browser": case "search": return { ios: "globe", android: "public" }; case "code-search": diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index b1722a137c81e..8342fd1aeebc2 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -8,11 +8,50 @@ import { deriveLayout, deriveStableFormSheetDetent, deriveThreadFeedInitialContentInset, + deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, SPLIT_LAYOUT_MIN_HEIGHT, SPLIT_LAYOUT_MIN_WIDTH, } from "./layout"; +describe("thread work-log text sizing", () => { + it.each([11, 16, 22])( + "keeps exact compact rows at base size %i without OS enlargement", + (baseFontSize) => { + expect(deriveThreadWorkLogSizing({ baseFontSize, fontScale: 1 })).toMatchObject({ + estimatedRowHeight: 28, + fixedRowHeight: 28, + }); + }, + ); + + it.each([ + { baseFontSize: 16, fontScale: 1.25, estimatedRowHeight: 28 }, + { baseFontSize: 16, fontScale: 2, estimatedRowHeight: 38 }, + { baseFontSize: 22, fontScale: 2, estimatedRowHeight: 52 }, + ])( + "measures accessibility text instead of locking it to the estimate: %j", + ({ estimatedRowHeight, ...settings }) => { + expect(deriveThreadWorkLogSizing(settings)).toMatchObject({ + estimatedRowHeight, + fixedRowHeight: undefined, + }); + }, + ); + + it("invalidates native text measurements even when the minimum row height is unchanged", () => { + const original = deriveThreadWorkLogSizing({ baseFontSize: 16, fontScale: 1 }); + for (const settings of [ + { baseFontSize: 16, fontScale: 1.1 }, + { baseFontSize: 17, fontScale: 1 }, + ]) { + const resized = deriveThreadWorkLogSizing(settings); + expect(resized.estimatedRowHeight).toBe(original.estimatedRowHeight); + expect(resized.textSizeKey).not.toBe(original.textSizeKey); + } + }); +}); + describe("deriveThreadFeedInitialContentInset", () => { it("seeds Android scroll math with the composer overlay estimate", () => { expect( diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 33438a324c121..4199dc8dc8eda 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -1,3 +1,6 @@ +import { scaledTypographyLineHeight } from "./appearancePreferences"; +import { MOBILE_TYPOGRAPHY } from "./typography"; + function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } @@ -18,6 +21,29 @@ const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; export const CHAT_CONTENT_MAX_WIDTH = 960; +// min-h-8 uses the 14px rem configured in metro.config.js. +export const THREAD_WORK_ROW_MIN_HEIGHT = 28; + +export function deriveThreadWorkLogSizing(input: { + readonly baseFontSize: number; + readonly fontScale: number; +}) { + const lineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.footnote, input.baseFontSize); + return { + // Different text metrics can share the same minimum row height. + textSizeKey: `${input.baseFontSize}:${input.fontScale}`, + estimatedRowHeight: Math.max( + THREAD_WORK_ROW_MIN_HEIGHT, + Math.ceil(lineHeight * input.fontScale), + ), + // Native text can exceed its authored line height with accessibility scaling. + // Leave those rows measured instead of promising LegendList an exact size. + fixedRowHeight: + input.fontScale <= 1 && lineHeight <= THREAD_WORK_ROW_MIN_HEIGHT + ? THREAD_WORK_ROW_MIN_HEIGHT + : undefined, + }; +} export const AUXILIARY_PANE_MIN_WIDTH = 260; export const AUXILIARY_PANE_MAX_WIDTH = 480; diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index aafc49e36024f..c507672fb245d 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -102,7 +102,7 @@ describe("mobile model options", () => { ]); }); - it("normalizes a legacy fallback selection against current capabilities", () => { + it("does not materialize catalog defaults for missing stored options", () => { const config = { providers: [ { @@ -140,11 +140,17 @@ describe("mobile model options", () => { const [option] = buildModelOptions(config, { instanceId: ProviderInstanceId.make("codex"), model: "gpt-test", - options: [{ id: "fastMode", value: true }], }); expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); - expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); + expect(option?.selection.options).toBeUndefined(); + + const [explicitOption] = buildModelOptions(config, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-test", + options: [{ id: "serviceTier", value: "priority" }], + }); + expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]); }); it("rejects stored selections whose provider is not usable", () => { diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 26ffd6855582e..13e62181a43b1 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -4,7 +4,7 @@ import type { ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; import { - buildProviderOptionSelectionsFromDescriptors, + buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -45,11 +45,12 @@ function normalizeSelectionOptions( if (!capabilities) { return selection; } - const options = buildProviderOptionSelectionsFromDescriptors( + const options = buildExplicitProviderOptionSelectionsFromDescriptors( getProviderOptionDescriptors({ caps: capabilities, selections: selection.options, }), + selection.options, ); return options ? { ...selection, options } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 826de5c97dc65..32d12b074851c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -825,6 +825,408 @@ describe("buildThreadFeed", () => { expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); }); + it.each([ + { + source: "raw MCP browser identity", + label: "Call MCP tool", + title: "Call MCP tool", + item: { server: "t3-code", tool: "preview_navigate" }, + status: "inProgress", + displayName: "Navigating the preview browser", + icon: "browser", + }, + { + source: "raw MCP orchestration identity", + label: "Call MCP tool", + title: "Call MCP tool", + item: { server: "t3-code", tool: "task_status" }, + status: "inProgress", + displayName: "Getting delegated task status", + icon: "t3-code", + }, + { + source: "provider-qualified title", + label: "Call MCP tool", + title: "mcp__t3-code__preview_snapshot", + item: undefined, + status: "inProgress", + displayName: "Taking a snapshot of the preview page", + icon: "browser", + }, + { + source: "provider-qualified label", + label: "mcp__t3-code__task_status", + title: undefined, + item: undefined, + status: "inProgress", + displayName: "Getting delegated task status", + icon: "t3-code", + }, + { + source: "browser identity without lifecycle status", + label: "Call MCP tool", + title: "Call MCP tool", + item: { server: "t3-code", tool: "preview_click" }, + status: undefined, + displayName: "Click in the preview browser", + liveDisplayName: "Clicking in the preview browser", + icon: "browser", + }, + { + source: "orchestration identity without lifecycle status", + label: "Call MCP tool", + title: "Call MCP tool", + item: { server: "t3-code", tool: "task_status" }, + status: undefined, + displayName: "Get delegated task status", + liveDisplayName: "Getting delegated task status", + icon: "t3-code", + }, + ])( + "uses friendly row and running labels from $source", + ({ label, title, item, status, displayName, liveDisplayName, icon }) => { + const turnId = TurnId.make("turn-friendly-mcp"); + const rawCommand = "node mcp-call.js"; + const rawDetail = '{"provider":"raw MCP output"}'; + const thread = makeThread({ + id: ThreadId.make("thread-friendly-mcp"), + projectId: ProjectId.make("project-1"), + title: "Friendly MCP labels", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("friendly-mcp"), + kind: "tool.updated", + tone: "tool", + summary: label, + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + title, + itemType: "mcp_tool_call", + detail: rawDetail, + status, + data: { item, command: rawCommand }, + }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + const group = feed[0]; + expect(group).toMatchObject({ + type: "activity-group", + activities: [{ summary: displayName, detail: rawCommand }], + }); + if (!group || group.type !== "activity-group") return; + const activity = group.activities[0]!; + expect(activity.getFullDetail()).toContain(rawCommand); + expect(activity.getFullDetail()).toContain(rawDetail); + expect(activity.getCopyText()).toContain(rawCommand); + expect(activity.getCopyText()).toContain(rawDetail); + expect(activity.getCopyText()).not.toContain(displayName); + if (item) expect(activity.getFullDetail()).toContain(JSON.stringify(item, null, 2)); + expect( + deriveThreadFeedPresentation( + feed, + thread.latestTurn, + new Set(), + new Set(), + thread.latestTurn!.startedAt, + ), + ).toMatchObject([ + { + type: "work-toggle", + summary: liveDisplayName ?? displayName, + summaryToolIcon: icon, + live: true, + }, + ]); + }, + ); + + it("retains Claude MCP metadata behind friendly row and running labels", () => { + const turnId = TurnId.make("turn-claude-mcp"); + const toolData = { + toolName: "mcp__t3-code__preview_click", + input: { locator: { role: "button", name: "Continue" } }, + result: { content: "Clicked Continue" }, + }; + const detail = "Click Continue"; + const thread = makeThread({ + id: ThreadId.make("thread-claude-mcp"), + projectId: ProjectId.make("project-1"), + title: "Claude MCP labels", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("claude-mcp-completed"), + kind: "tool.completed", + tone: "tool", + summary: "MCP tool call completed", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + title: "MCP tool call", + itemType: "mcp_tool_call", + status: "completed", + detail, + data: toolData, + }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + const group = feed[0]; + expect(group).toMatchObject({ + type: "activity-group", + activities: [ + { + summary: "Clicked in the preview browser", + detail, + workEntry: { label: "MCP tool call completed", toolTitle: "MCP tool call" }, + }, + ], + }); + if (!group || group.type !== "activity-group") return; + const activity = group.activities[0]!; + const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`; + expect(activity.workEntry.toolData).toBe(toolData); + expect(activity.getFullDetail()).toBe(fullDetail); + expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`); + expect( + deriveThreadFeedPresentation( + feed, + thread.latestTurn, + new Set(), + new Set(), + thread.latestTurn!.startedAt, + ), + ).toMatchObject([ + { + type: "work-toggle", + summary: "Clicked in the preview browser", + summaryToolIcon: "browser", + live: true, + }, + ]); + }); + + it.each([ + { + status: "completed", + displayName: "Clicked in the preview browser", + detail: "Clicked Continue", + hasFailure: false, + }, + { + status: "failed", + displayName: "Failed to click in the preview browser", + detail: "Timed out waiting for Continue", + hasFailure: true, + }, + ])( + "keeps a browser group expanded as its action changes from active to $status", + ({ status, displayName, detail, hasFailure }) => { + const turnId = TurnId.make("turn-preview-lifecycle"); + const toolCallId = "preview-click"; + const groupId = `work-group:tool:${turnId}:${toolCallId}`; + const toolData = { + server: "t3-code", + tool: "preview_click", + arguments: { locator: { role: "button", name: "Continue" } }, + }; + const thread = makeThread({ + id: ThreadId.make("thread-preview-lifecycle"), + projectId: ProjectId.make("project-1"), + title: "Browser tool lifecycle", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("preview-click-started"), + kind: "tool.updated", + tone: "tool", + summary: "MCP tool call", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + title: "MCP tool call", + itemType: "mcp_tool_call", + status: "inProgress", + toolCallId, + data: { item: toolData }, + }, + }), + ], + }); + const present = (currentThread: OrchestrationThread) => + deriveThreadFeedPresentation( + buildThreadFeed(currentThread), + currentThread.latestTurn, + new Set([turnId]), + new Set([groupId]), + currentThread.latestTurn?.state === "running" ? currentThread.latestTurn.startedAt : null, + ); + + expect(present(thread)).toMatchObject([ + { + type: "work-toggle", + groupId, + hiddenCount: 1, + expanded: true, + summary: "Clicking in the preview browser", + summaryToolIcon: "browser", + live: true, + shimmer: true, + }, + { + type: "activity-group", + id: `work-details:${groupId}`, + activities: [ + { + id: "preview-click-started", + summary: "Clicking in the preview browser", + lifecycleStatus: "inProgress", + live: true, + }, + ], + }, + ]); + + const terminalThread = { + ...thread, + activities: [ + ...thread.activities, + makeActivity({ + id: EventId.make("preview-click-completed"), + kind: "tool.completed", + tone: "tool", + summary: "MCP tool call completed", + createdAt: "2026-04-01T00:00:03.000Z", + turnId, + payload: { itemType: "mcp_tool_call", toolCallId, status, detail }, + }), + ], + }; + const terminalRows = present(terminalThread); + expect(terminalRows).toMatchObject([ + { + type: "work-toggle", + groupId, + hiddenCount: 1, + expanded: true, + summary: displayName, + summaryToolIcon: "browser", + hasFailure, + live: true, + shimmer: false, + }, + { + type: "activity-group", + id: `work-details:${groupId}`, + activities: [ + { + id: "preview-click-started", + summary: displayName, + lifecycleStatus: status, + live: false, + }, + ], + }, + ]); + const terminalGroup = terminalRows[1]; + if (terminalGroup?.type !== "activity-group") return; + const activity = terminalGroup.activities[0]!; + const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`; + expect(activity.workEntry.toolData).toBe(toolData); + expect(activity.getFullDetail()).toBe(fullDetail); + expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`); + + const settledRows = present({ + ...terminalThread, + latestTurn: { + ...thread.latestTurn!, + state: "completed", + completedAt: "2026-04-01T00:00:04.000Z", + }, + }); + expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({ + groupId, + hiddenCount: 1, + expanded: true, + summary: "Used browser 1 time", + summaryKind: "browser", + hasFailure, + live: false, + }); + expect(settledRows.find((entry) => entry.type === "activity-group")).toMatchObject({ + id: `work-details:${groupId}`, + activities: [{ id: "preview-click-started", summary: displayName, live: false }], + }); + }, + ); + + it.each([ + [0, "Used browser 3 times", "browser"], + [2, "Ran 2 commands and used browser 3 times", "mixed"], + ] as const)( + "separates browser counts from %s completed commands", + (commandCount, summary, summaryKind) => { + const thread = makeThread({ + id: ThreadId.make("thread-browser-counts"), + projectId: ProjectId.make("project-1"), + title: "Browser group counts", + activities: Array.from({ length: commandCount + 3 }, (_, index) => + makeActivity({ + id: EventId.make(`browser-count-${index}`), + createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(), + kind: "tool.completed", + tone: "tool", + summary: index < commandCount ? "Ran command" : "MCP tool call", + payload: { + toolCallId: `browser-count-${index}`, + status: "completed", + ...(index < commandCount + ? { + itemType: "command_execution", + data: { item: { command: "/bin/bash -lc 'vp test run'" } }, + } + : { + itemType: "mcp_tool_call", + data: { item: { server: "t3-code", tool: "preview_click" } }, + }), + }, + }), + ), + }); + expect( + deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set(), new Set()), + ).toMatchObject([{ type: "work-toggle", summary, summaryKind, live: false }]); + }, + ); + it("defers large tool output expansion until a work row is opened or copied", () => { let serializedToolOutputs = 0; const activities = Array.from({ length: 5_000 }, (_, index) => @@ -866,6 +1268,22 @@ describe("buildThreadFeed", () => { } expect(group.activities).toHaveLength(5_000); + const expanded = deriveThreadFeedPresentation( + feed, + null, + new Set(), + new Set(["work-group:large-tool-0"]), + ); + expect(expanded).toHaveLength(2); + expect(expanded[1]).toMatchObject({ + type: "activity-group", + id: "work-details:work-group:large-tool-0", + }); + if (expanded[1]?.type === "activity-group") { + expect(expanded[1].activities).toHaveLength(5_000); + expect(expanded[1].activities[0]?.getFullDetail).toBe(group.activities[0]?.getFullDetail); + } + expect(serializedToolOutputs).toBe(0); expect(group.activities[0]?.getFullDetail()).toContain('"output"'); expect(serializedToolOutputs).toBe(1); expect(group.activities[0]?.getCopyText()).toContain('"output"'); @@ -1137,17 +1555,26 @@ describe("buildThreadFeed", () => { type: "activity-group", activities: [{ status: "success" }, { status: "failure" }], }); - expect( - deriveThreadFeedPresentation( - feed, - thread.latestTurn, - new Set(), - new Set(["work-group:tool-succeeded"]), - ).map((entry) => entry.id), - ).toEqual(["work-toggle:work-group:tool-succeeded", "tool-succeeded", "tool-failed"]); + const expanded = deriveThreadFeedPresentation( + feed, + thread.latestTurn, + new Set(), + new Set(["work-group:tool-succeeded"]), + ); + expect(expanded.map((entry) => entry.id)).toEqual([ + "work-toggle:work-group:tool-succeeded", + "work-details:work-group:tool-succeeded", + ]); + expect(expanded[1]).toMatchObject({ + type: "activity-group", + activities: [ + { id: "tool-succeeded", status: "success", groupedToolDetail: true }, + { id: "tool-failed", status: "failure", groupedToolDetail: true }, + ], + }); }); - it("models work-log overflow as list rows", () => { + it("keeps expanded work in one group with stable row identities", () => { const activity = ( id: string, createdAt: string, @@ -1206,111 +1633,192 @@ describe("buildThreadFeed", () => { ); expect(expanded.map((entry) => entry.id)).toEqual([ "work-toggle:work-group:activity-1", - "activity-1", - "activity-2", - "activity-3", + "work-details:work-group:activity-1", ]); expect(expanded[0]).toMatchObject({ type: "work-toggle", expanded: true, }); + expect(expanded[1]).toMatchObject({ + type: "activity-group", + activities: [ + { id: "activity-1", groupedToolDetail: true, live: false }, + { id: "activity-2", groupedToolDetail: true, live: false }, + { id: "activity-3", groupedToolDetail: true, live: false }, + ], + }); }); - it("keeps live state on the active uninterrupted tool run", () => { - const turnId = TurnId.make("turn-live-tools"); - const activity = ( - id: string, - status: ThreadFeedActivity["status"], - lifecycleStatus: ThreadFeedActivity["lifecycleStatus"], - tone: "tool" | "error" = "tool", - command?: string, - ): ThreadFeedActivity => ({ - id, - createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, - turnId, - summary: `Tool ${id}`, - detail: null, - canExpand: false, - getFullDetail: () => null, - getCopyText: () => id, - icon: "command", - toolLike: true, - status, - lifecycleStatus, - workEntry: { + it.each( + [ + "sudo -u root pnpm test", + "/bin/zsh -lc 'sudo -u root pnpm test'", + "/bin/bash -lc 'sudo -u root pnpm test'", + ].flatMap((command) => + ( + [ + { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, + { lifecycleStatus: "completed", summary: "Ran pnpm", shimmer: false }, + { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, + { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, + { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, + ] as const + ).map((state) => ({ command, ...state })), + ), + )( + "keeps the command summary in sync with $lifecycleStatus: $command", + ({ command, lifecycleStatus, summary, shimmer }) => { + const turnId = TurnId.make("turn-live-tools"); + const activity = ( + id: string, + status: ThreadFeedActivity["status"], + lifecycleStatus: ThreadFeedActivity["lifecycleStatus"], + tone: "tool" | "error" = "tool", + command?: string, + ): ThreadFeedActivity => ({ id, createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, turnId, - label: `Tool ${id}`, - tone, - toolLifecycleStatus: lifecycleStatus, - ...(command ? { command, itemType: "command_execution" as const } : {}), - }, - }); - const feed: ThreadFeedEntry[] = [ - { - type: "activity-group", - id: "activity-1", - createdAt: "2026-04-01T00:00:01.000Z", + summary: `Tool ${id}`, + detail: lifecycleStatus === "stopped" ? "Exit code 130" : null, + canExpand: false, + getFullDetail: () => null, + getCopyText: () => id, + icon: "command", + toolLike: true, + status, + lifecycleStatus, + workEntry: { + id, + createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, + turnId, + label: `Tool ${id}`, + tone, + toolLifecycleStatus: lifecycleStatus, + ...(lifecycleStatus === "stopped" ? { detail: "Exit code 130" } : {}), + ...(command ? { command, itemType: "command_execution" as const } : {}), + }, + }); + const feed: ThreadFeedEntry[] = [ + { + type: "activity-group", + id: "activity-1", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + activities: [ + activity("activity-1", "success", "completed"), + activity("activity-2", "failure", "failed", "error"), + activity( + "activity-3", + lifecycleStatus === "inProgress" + ? "neutral" + : lifecycleStatus === "completed" + ? "success" + : "failure", + lifecycleStatus, + "tool", + command, + ), + ...(lifecycleStatus === "inProgress" + ? [activity("activity-4", "success", "completed", "tool", "printf done")] + : []), + ], + }, + ]; + const latestTurn = { turnId, - activities: [ - activity("activity-1", "success", "completed"), - activity("activity-2", "failure", "failed", "error"), - activity("activity-3", "success", "completed", "tool", "sudo -u root pnpm test"), - ], + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + + const rows = deriveThreadFeedPresentation( + feed, + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ + ["work-toggle:work-group:activity-1", "work-toggle"], + ["activity-2", "activity-group"], + ["work-live:work-group:activity-3", "work-toggle"], + ]); + expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ + false, + false, + true, + ]); + expect(rows[2]).toMatchObject({ + summary, + summaryKind: "command", + live: true, + shimmer, + }); + expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + + const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); + expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); + + const completedRows = deriveThreadFeedPresentation( + feed, + { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, + new Set([turnId]), + new Set(), + latestTurn.startedAt, + ); + expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); + }, + ); + + it.each([ + ["inProgress", true], + ["completed", false], + ["failed", false], + ["declined", false], + ["stopped", false], + ] as const)("respects the %s lifecycle of trailing task progress", (status, shimmer) => { + const turnId = TurnId.make("turn-task-progress"); + const thread = makeThread({ + id: ThreadId.make("thread-task-progress"), + projectId: ProjectId.make("project-1"), + title: "Task lifecycle", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, }, - ]; - const latestTurn = { - turnId, - state: "running" as const, - requestedAt: "2026-04-01T00:00:00.000Z", - startedAt: "2026-04-01T00:00:00.000Z", - completedAt: null, - assistantMessageId: null, - }; + activities: [ + makeActivity({ + id: EventId.make("task-progress"), + kind: "task.progress", + summary: "Task progress", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { taskId: "task-1", status }, + }), + ], + }); const rows = deriveThreadFeedPresentation( - feed, - latestTurn, + buildThreadFeed(thread), + thread.latestTurn, new Set(), new Set(), - latestTurn.startedAt, - ); - expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ - ["work-toggle:work-group:activity-1", "work-toggle"], - ["activity-2", "activity-group"], - ["work-live:work-group:activity-3", "work-toggle"], - ]); - expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ - false, - false, - true, - ]); - expect(rows[2]).toMatchObject({ - summary: "Running pnpm", - summaryKind: "command", - live: true, - shimmer: true, - }); - expect(rows[0]).toMatchObject({ live: false, shimmer: false }); - - const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); - expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ - { live: false, shimmer: false }, - { live: false, shimmer: false }, - ]); - - const completedRows = deriveThreadFeedPresentation( - feed, - { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, - new Set([turnId]), - new Set(), - latestTurn.startedAt, + thread.latestTurn!.startedAt, ); - expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ - { live: false, shimmer: false }, - { live: false, shimmer: false }, - ]); + expect(rows.some((entry) => entry.type === "work-toggle" && entry.shimmer)).toBe(shimmer); }); it("does not revive cached in-progress tools after work stops", () => { @@ -1417,6 +1925,37 @@ describe("buildThreadFeed", () => { summary: "Ran 2 commands", live: false, }); + + const groupId = `work-group:tool:${turnId}:call-a`; + const startedAt = "2026-04-01T00:00:00.000Z"; + const runningRows = deriveThreadFeedPresentation( + buildThreadFeed({ ...thread, activities: thread.activities.slice(0, 2) }), + { turnId, state: "running", startedAt, completedAt: null }, + new Set(), + new Set([groupId]), + startedAt, + ); + expect(runningRows.find((entry) => entry.type === "activity-group")).toMatchObject({ + id: `work-details:${groupId}`, + activities: [ + { id: "call-a-1", lifecycleStatus: "inProgress", groupedToolDetail: true, live: false }, + { id: "call-b-2", lifecycleStatus: "inProgress", groupedToolDetail: true, live: true }, + ], + }); + + const completedRows = deriveThreadFeedPresentation( + feed, + null, + new Set([turnId]), + new Set([groupId]), + ); + expect(completedRows.find((entry) => entry.type === "activity-group")).toMatchObject({ + id: `work-details:${groupId}`, + activities: [ + { id: "call-a-1", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, + { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, + ], + }); }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9515b824f63ec..a7fb002726f77 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -19,6 +19,7 @@ import { isWorktreeSetupActivity, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, + resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupSummaryKind, type ToolGroupSummaryKind, @@ -146,6 +147,7 @@ export type ThreadFeedEntry = readonly expanded: boolean; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; + readonly summaryToolIcon?: "browser" | "t3-code"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; @@ -478,8 +480,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo } if (itemType === "mcp_tool_call") { const data = asRecord(payload?.data); - if (data?.item !== undefined) { - entry.toolData = data.item; + const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; + if (toolData !== undefined) { + entry.toolData = toolData; } } if (itemType) { @@ -827,6 +830,8 @@ function capitalizePhrase(value: string): string { } function workEntryHeading(workEntry: WorkLogEntry): string { + const presentation = resolveWorkEntryToolPresentation(workEntry); + if (presentation) return presentation.displayName; if (!workEntry.toolTitle) { return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } @@ -1502,19 +1507,26 @@ function appendToolGroupRows( : activities[0]!.id; const groupId = `work-group:${identity}`; const expanded = expandedWorkGroupIds.has(groupId); - const latestInProgressActivity = activities.findLast( + const latestActiveActivity = activities.findLast( (activity) => - isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId, + isWorking && + activity.turnId === unsettledTurnId && + (activity.lifecycleStatus === "inProgress" || + (activeTail && + activity.lifecycleStatus === undefined && + (activity.workEntry.sourceActivityKind === "task.progress" || activity.toolLike))), ); - const live = activeTail || latestInProgressActivity !== undefined; - const latestActivity = activeTail - ? activities.at(-1)! - : (latestInProgressActivity ?? activities.at(-1)!); + const active = latestActiveActivity !== undefined; + const live = activeTail || active; + const latestActivity = latestActiveActivity ?? activities.at(-1)!; const summary = live - ? liveToolActivitySummary(latestActivity) + ? liveToolActivitySummary(latestActivity, active) : activities.length === 1 && !activities[0]!.toolLike ? activities[0]!.workEntry.label : summarizeToolGroup(activities.map((activity) => activity.workEntry)); + const summaryToolIcon = live + ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon + : undefined; result.push({ type: "work-toggle", id: `${live ? "work-live" : "work-toggle"}:${groupId}`, @@ -1527,40 +1539,52 @@ function appendToolGroupRows( summaryKind: toolGroupSummaryKind( (live ? [latestActivity] : activities).map((activity) => activity.workEntry), ), + ...(summaryToolIcon ? { summaryToolIcon } : {}), hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", live, - // Match the live label until the turn or contiguous tool run settles. - shimmer: live, + shimmer: active, }); if (!expanded) { return; } - for (const activity of activities) { - result.push({ - type: "activity-group", - id: activity.id, - createdAt: activity.createdAt, - turnId: activity.turnId, - activities: [ - { - ...activity, - groupedToolDetail: true, - live: - isWorking && - activity.id === latestActivity.id && - activity.lifecycleStatus === "inProgress" && - activity.turnId === unsettledTurnId, - }, - ], - }); - } + result.push({ + type: "activity-group", + id: `work-details:${groupId}`, + createdAt: activities[0]!.createdAt, + turnId: activities[0]!.turnId, + activities: activities.map((activity) => ({ + ...activity, + groupedToolDetail: true, + live: + isWorking && + activity.id === latestActivity.id && + activity.lifecycleStatus === "inProgress" && + activity.turnId === unsettledTurnId, + })), + }); } -function liveToolActivitySummary(activity: ThreadFeedActivity): string { +function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string { + const presentation = resolveWorkEntryToolPresentation( + activity.workEntry, + active ? "inProgress" : "completed", + ); + if (presentation) return presentation.displayName; const command = activity.workEntry.command?.trim(); if (command) { const program = commandProgramName(command); - return program ? `Running ${program}` : "Running command"; + const status = activity.lifecycleStatus ?? (active ? "inProgress" : "completed"); + const verb = + status === "inProgress" + ? "Running" + : status === "failed" + ? "Failed" + : status === "declined" + ? "Declined" + : status === "stopped" + ? "Stopped" + : "Ran"; + return `${verb} ${program ?? "command"}`; } return activity.detail ?? activity.summary; } @@ -1774,15 +1798,20 @@ export function buildThreadFeed( const detail = workEntryPreview(entry); const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); const getCopyText = memoizeValue(() => { + const copyLabel = capitalizePhrase( + normalizeCompactToolLabel(entry.toolTitle || entry.label), + ); const fullDetail = getFullDetail(); if (entry.command) { const normalizedCommand = - entry.rawCommand && summary.trim() !== entry.command.trim() ? entry.command : null; - return [summary, normalizedCommand, fullDetail ?? entry.command] + entry.rawCommand && copyLabel.trim() !== entry.command.trim() + ? entry.command + : null; + return [copyLabel, normalizedCommand, fullDetail ?? entry.command] .filter((value): value is string => Boolean(value)) .join("\n"); } - return [summary, detail, fullDetail] + return [copyLabel, detail, fullDetail] .filter((value, index, values): value is string => { return Boolean(value) && values.indexOf(value) === index; }) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f70590cb45ab6..db030312a78a0 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -26,7 +26,7 @@ import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; -import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; +import { TextGeneration } from "../src/textGeneration/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -336,7 +336,7 @@ export const makeOrchestrationIntegrationHarness = ( const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), - } as unknown as TextGenerationShape); + } as unknown as TextGeneration["Service"]); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), diff --git a/apps/server/package.json b/apps/server/package.json index eef25eec7554c..d3d7955e3ba9a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,7 +26,6 @@ "@cursor/sdk": "^1.0.13", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", - "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@factory/droid-sdk": "^0.2.0", "@ff-labs/fff-node": "0.9.4", diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 39b3b24311292..6ae40c773a009 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -30,7 +30,6 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; -import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; import { clearPersistedServerRuntimeState, readPersistedServerRuntimeState, @@ -481,7 +480,6 @@ const projectAddCommand = Command.make("add", { projectId, title, workspaceRoot, - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), createdAt: DateTime.formatIso(yield* DateTime.now), }); return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 6df2e7f795272..2196ae450a541 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -45,7 +45,7 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; -import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -310,7 +310,7 @@ describe("ProviderCommandReactor", () => { pr: null, }), ); - const generateBranchName = vi.fn((_) => + const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ operation: "generateBranchName", @@ -318,7 +318,7 @@ describe("ProviderCommandReactor", () => { }), ), ); - const generateThreadTitle = vi.fn((_) => + const generateThreadTitle = vi.fn((_) => Effect.fail( new TextGenerationError({ operation: "generateThreadTitle", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0dbc8e32cfae0..6cdb9fae821cf 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -658,21 +658,28 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + const refreshWorkspaceSnapshot = effectiveCwd + ? providerRegistry + .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) + .pipe(Effect.forkDetach) + : Effect.void; const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; }) => - providerService.startSession(threadId, { - threadId, - ...(preferredProvider ? { provider: preferredProvider } : {}), - providerInstanceId: desiredInstanceId, - ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.title ? { title: thread.title } : {}), - modelSelection: desiredModelSelection, - ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - runtimeMode: desiredRuntimeMode, - }); + providerService + .startSession(threadId, { + threadId, + ...(preferredProvider ? { provider: preferredProvider } : {}), + providerInstanceId: desiredInstanceId, + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), + modelSelection: desiredModelSelection, + ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + runtimeMode: desiredRuntimeMode, + }) + .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { @@ -730,6 +737,7 @@ const make = Effect.gen(function* () { !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { + yield* refreshWorkspaceSnapshot; return existingSessionThreadId; } diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts index afee13343ad10..4624ff0abffe6 100644 --- a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -1,4 +1,11 @@ -import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; +import { + CommandId, + EventId, + ProjectId, + ProviderInstanceId, + type ModelSelection, + type OrchestrationEvent, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -31,7 +38,53 @@ const seedProjectCreated = (sequence: number): OrchestrationEvent => ({ }, }); -it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { +it.layer(NodeServices.layer)("decider project defaults", (it) => { + it.effect("only treats metadata updates as explicit model defaults", () => + Effect.gen(function* () { + const selection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const created = yield* decideOrchestrationCommand({ + command: { + type: "project.create", + commandId: CommandId.make("cmd-project-model-create"), + projectId, + title: "Model default", + workspaceRoot: "/tmp/model-default", + defaultModelSelection: selection, + createdAt: now, + }, + readModel: createEmptyReadModel(now), + }); + const createdEvent = Array.isArray(created) ? created[0] : created; + expect(createdEvent.type).toBe("project.created"); + expect( + (createdEvent.payload as { defaultModelSelection?: unknown }).defaultModelSelection, + ).toBeNull(); + + const withProject = yield* projectEvent(createEmptyReadModel(now), { + ...createdEvent, + sequence: 1, + }); + const updated = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-model-update"), + projectId, + defaultModelSelection: selection, + }, + readModel: withProject, + }); + const updatedEvent = Array.isArray(updated) ? updated[0] : updated; + expect(updatedEvent.type).toBe("project.meta-updated"); + expect( + (updatedEvent.payload as { defaultModelSelection?: unknown }).defaultModelSelection, + ).toEqual(selection); + }), + ); + it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () => Effect.gen(function* () { const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 49b38111f0557..cbef567e60940 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -219,7 +219,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, - defaultModelSelection: command.defaultModelSelection ?? null, + // Project creation has no user model choice. Older clients sent an + // automatic seed here, but only a metadata update records an + // explicit project default. + defaultModelSelection: null, faviconPath: null, scripts: [], createdAt: command.createdAt, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 43532df456e49..cd6c9a61a69a8 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -80,6 +80,9 @@ import Migration0047 from "./Migrations/047_ProjectionThreadLinkedPullRequest.ts // Upstream ProjectionThreadsUnsettledAt was runtime ID 43. Append it after // the fork's already-deployed migration lineage. import Migration0048 from "./Migrations/048_ProjectionThreadsUnsettledAt.ts"; +// Upstream ClearAutomaticProjectModelDefaults was runtime ID 44. The fork has +// already deployed through ID 48, so append it without reusing an applied ID. +import Migration0049 from "./Migrations/049_ClearAutomaticProjectModelDefaults.ts"; /** * Migration loader with all migrations defined inline. @@ -140,6 +143,7 @@ export const migrationEntries = [ [46, "AuthSessionClientConnection", Migration0046], [47, "ProjectionThreadLinkedPullRequest", Migration0047], [48, "ProjectionThreadsUnsettledAt", Migration0048], + [49, "ClearAutomaticProjectModelDefaults", Migration0049], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 1e64519ff4f81..dc823852e26f4 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -380,3 +380,93 @@ layer("016_CanonicalizeModelSelections", (it) => { }), ); }); + +layer("049_ClearAutomaticProjectModelDefaults", (it) => { + it.effect("clears create-time seeds and preserves explicit project defaults", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 48 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + default_thread_env_mode, + favicon_path, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES + ('project-auto', 'Auto', '/tmp/auto', '{"instanceId":"codex","model":"gpt-5.6-sol"}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z', NULL), + ('project-title-only', 'Title only', '/tmp/title-only', '{"instanceId":"codex","model":"gpt-5.6-sol"}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z', NULL), + ('project-explicit', 'Explicit', '/tmp/explicit', '{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z', NULL) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES + ('event-auto-create', 'project', 'project-auto', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-auto-create', NULL, 'command-auto-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'), + ('event-title-create', 'project', 'project-title-only', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-title-create', NULL, 'command-title-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'), + ('event-title-update', 'project', 'project-title-only', 1, 'project.meta-updated', '2026-08-02T00:00:00.000Z', 'command-title-update', NULL, 'command-title-update', 'client', '{"title":"Renamed"}', '{}'), + ('event-explicit-create', 'project', 'project-explicit', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-explicit-create', NULL, 'command-explicit-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'), + ('event-explicit-update', 'project', 'project-explicit', 1, 'project.meta-updated', '2026-08-02T00:00:00.000Z', 'command-explicit-update', NULL, 'command-explicit-update', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}}', '{}') + `; + + yield* runMigrations({ toMigrationInclusive: 49 }); + + const projects = yield* sql<{ + readonly projectId: string; + readonly selection: string | null; + }>` + SELECT + project_id AS "projectId", + default_model_selection_json AS "selection" + FROM projection_projects + ORDER BY project_id + `; + assert.deepStrictEqual(projects, [ + { projectId: "project-auto", selection: null }, + { + projectId: "project-explicit", + selection: + '{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}', + }, + { projectId: "project-title-only", selection: null }, + ]); + + const createdEvents = yield* sql<{ + readonly streamId: string; + readonly model: string | null; + }>` + SELECT + stream_id AS "streamId", + json_extract(payload_json, '$.defaultModelSelection.model') AS "model" + FROM orchestration_events + WHERE event_type = 'project.created' + ORDER BY stream_id + `; + assert.deepStrictEqual(createdEvents, [ + { streamId: "project-auto", model: null }, + { streamId: "project-explicit", model: "gpt-5.6-sol" }, + { streamId: "project-title-only", model: null }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/049_ClearAutomaticProjectModelDefaults.ts b/apps/server/src/persistence/Migrations/049_ClearAutomaticProjectModelDefaults.ts new file mode 100644 index 0000000000000..469402907fa55 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ClearAutomaticProjectModelDefaults.ts @@ -0,0 +1,51 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Project creation never exposed a model choice. A later metadata event + // containing this field is the evidence that the user set or reset one. + yield* sql` + WITH automatically_seeded_projects AS ( + SELECT created.stream_id AS project_id + FROM orchestration_events AS created + WHERE created.aggregate_kind = 'project' + AND created.event_type = 'project.created' + AND json_type(created.payload_json, '$.defaultModelSelection') IS NOT NULL + AND json_type(created.payload_json, '$.defaultModelSelection') <> 'null' + AND NOT EXISTS ( + SELECT 1 + FROM orchestration_events AS configured + WHERE configured.aggregate_kind = 'project' + AND configured.stream_id = created.stream_id + AND configured.event_type = 'project.meta-updated' + AND json_type(configured.payload_json, '$.defaultModelSelection') IS NOT NULL + ) + ) + UPDATE projection_projects + SET default_model_selection_json = NULL + WHERE project_id IN (SELECT project_id FROM automatically_seeded_projects) + `; + + yield* sql` + UPDATE orchestration_events AS created + SET payload_json = json_set( + created.payload_json, + '$.defaultModelSelection', + json('null') + ) + WHERE created.aggregate_kind = 'project' + AND created.event_type = 'project.created' + AND json_type(created.payload_json, '$.defaultModelSelection') IS NOT NULL + AND json_type(created.payload_json, '$.defaultModelSelection') <> 'null' + AND NOT EXISTS ( + SELECT 1 + FROM orchestration_events AS configured + WHERE configured.aggregate_kind = 'project' + AND configured.stream_id = created.stream_id + AND configured.event_type = 'project.meta-updated' + AND json_type(configured.payload_json, '$.defaultModelSelection') IS NOT NULL + ) + `; +}); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 6bb14321a7227..e9a75780a544d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -36,7 +36,12 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexProviderStatus, + makePendingCodexProvider, + probeCodexSkillsForCwd, +} from "../Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; @@ -195,6 +200,34 @@ export const CodexDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (cwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeCodexSkillsForCwd({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.timeout("20 seconds"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe Codex skills for '${cwd}'`, + cause, + }), + ), + ); return { instanceId, @@ -204,6 +237,7 @@ export const CodexDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/DroidDriver.ts b/apps/server/src/provider/Drivers/DroidDriver.ts index 0d1f50a407941..d5cbd52017a81 100644 --- a/apps/server/src/provider/Drivers/DroidDriver.ts +++ b/apps/server/src/provider/Drivers/DroidDriver.ts @@ -16,7 +16,9 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerConfig } from "../../config.ts"; -import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; + +type TextGenerationService = TextGeneration["Service"]; import { ProviderDriverError } from "../Errors.ts"; import { makeDroidAdapter } from "../Layers/DroidAdapter.ts"; import { checkDroidProviderStatus, makePendingDroidProvider } from "../Layers/DroidProvider.ts"; @@ -69,7 +71,7 @@ const withInstanceIdentity = continuation: { groupKey: input.continuationGroupKey }, }); -function makeUnsupportedTextGeneration(): TextGenerationShape { +function makeUnsupportedTextGeneration(): TextGenerationService { const fail = (operation: TextGenerationError["operation"]) => Effect.fail( new TextGenerationError({ diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 4ba0b858c6251..8ff01946644ce 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, makePendingOpenCodeProvider, + openCodeSkillsToServerProviderSkills, } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -148,6 +149,34 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime, openCodeRuntime), ); + const loadSkillsForCwd = (cwd: string) => + effectiveConfig.serverUrl.trim().length > 0 + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: effectiveConfig.binaryPath, + directory: cwd, + serverUrl: effectiveConfig.serverUrl, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + environment: processEnv, + }); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + }); + return yield* openCodeRuntime.loadOpenCodeSkills(client); + }), + ) + : openCodeRuntime.loadSkillsFromCli({ + binaryPath: effectiveConfig.binaryPath, + cwd, + environment: processEnv, + }); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( @@ -189,6 +218,27 @@ export const OpenCodeDriver: ProviderDriver accentColor, enabled, snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills: openCodeSkillsToServerProviderSkills(skills), + })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode skills for '${cwd}'`, + cause, + }), + ), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/StandardAcpCliDriver.ts b/apps/server/src/provider/Drivers/StandardAcpCliDriver.ts index f4f23dac72443..9d439351f91ab 100644 --- a/apps/server/src/provider/Drivers/StandardAcpCliDriver.ts +++ b/apps/server/src/provider/Drivers/StandardAcpCliDriver.ts @@ -18,7 +18,9 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; + +type TextGenerationService = TextGeneration["Service"]; import { ProviderDriverError } from "../Errors.ts"; import { parseStandardAcpCliArguments } from "../acp/StandardAcpCliSupport.ts"; import type { StandardAcpAdapterLiveOptions } from "../Layers/StandardAcpAdapter.ts"; @@ -83,7 +85,7 @@ export interface StandardAcpCliDriverConfig; } -function makeUnsupportedTextGeneration(displayName: string): TextGenerationShape { +function makeUnsupportedTextGeneration(displayName: string): TextGenerationService { const fail = (operation: TextGenerationError["operation"]) => Effect.fail( new TextGenerationError({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 776a8e00e1cc8..4d7efbe2106b6 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -414,6 +414,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { env: environment, extendEnv: true }, + ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(skillsResponse, input.cwd); +}); + const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); for (const model of codexSettings.customModels) { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index d297360e6d349..7f327cae8fb36 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -407,6 +407,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntimeError({ @@ -415,6 +416,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => Effect.succeed([]), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index ed84fab9979ab..d81d66574db56 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -149,6 +149,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); }, + loadOpenCodeSkills: () => Effect.succeed([]), + loadSkillsFromCli: () => Effect.succeed([]), }; beforeEach(() => { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index f4ce905642b6f..ed935fb314022 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -260,9 +260,11 @@ function trimOptional(value: string | null | undefined): string | undefined { return trimmed && trimmed.length > 0 ? trimmed : undefined; } -function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray { +export function openCodeSkillsToServerProviderSkills( + input: OpenCodeInventory["skills"] | undefined, +): ReadonlyArray { const skills: ServerProviderSkill[] = []; - for (const skill of input.skills ?? []) { + for (const skill of input ?? []) { const name = trimOptional(skill.name); const path = trimOptional(skill.location); if (!name || !path) { @@ -475,7 +477,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value.inventory); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); const connectedCount = inventoryExit.value.inventory.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 280601275a7e1..2336184adaaa9 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -96,11 +96,6 @@ const fakeCursorAdapter: CursorAdapter.CursorAdapterShape = { streamEvents: Stream.empty, }; -// ProviderAdapterRegistryLive is now a facade over ProviderInstanceRegistry — -// it walks `listInstances` once at boot and surfaces the default-instance -// adapter keyed by its driver kind. To test the facade we supply four fake -// instances whose `instanceId === defaultInstanceIdForDriver(driverKind)` so -// they pass the default-instance filter. const makeFakeInstance = ( driverKindString: "codex" | "claudeAgent" | "cursor" | "opencode", adapter: ProviderInstance["adapter"], @@ -184,13 +179,5 @@ it.layer(layer)("ProviderAdapterRegistryLive", (it) => { defaultInstanceIdForDriver(OPENCODE_DRIVER), defaultInstanceIdForDriver(CURSOR_DRIVER), ]); - - const providers = yield* registry.listProviders(); - assert.deepStrictEqual(providers, [ - CODEX_DRIVER, - CLAUDE_AGENT_DRIVER, - OPENCODE_DRIVER, - CURSOR_DRIVER, - ]); })); }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b492399b10b1b..919a345aa9881 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,11 +15,7 @@ * * @module ProviderAdapterRegistryLive */ -import { - defaultInstanceIdForDriver, - ProviderInstanceId, - type ProviderDriverKind, -} from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -71,31 +67,10 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun Effect.map((instances) => instances.map((instance) => instance.instanceId)), ); - const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => - registry.listInstances.pipe( - Effect.map((instances) => { - const kinds = new Set(); - for (const instance of instances) { - const defaultId = defaultInstanceIdForDriver(instance.driverKind); - if (instance.instanceId === defaultId) { - // Only the default-instance rows show up through the legacy - // shim — custom instances like `codex_personal` have no - // `ProviderDriverKind` equivalent. - kinds.add(instance.driverKind); - } - } - return Array.from(kinds); - }), - ); - return { getByInstance, getInstanceInfo, listInstances, - listProviders, - // Proxy directly — the facade has no state of its own; the instance - // registry already coalesces adds/removes/rebuilds into one emission. - streamChanges: registry.streamChanges, subscribeChanges: registry.subscribeChanges, } satisfies ProviderAdapterRegistryShape; }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 79d0cb9fa549d..2042def34fc35 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -41,6 +42,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr import { haveProvidersChanged, mergeProviderSnapshot, + upsertProviderWorkspaceSnapshot, ProviderRegistryLive, } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; @@ -572,6 +574,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); + it("stores workspace skills and commands without changing machine metadata", () => { + const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } satisfies ServerProvider; + const scopedSnapshot = { + ...provider, + checkedAt: "2026-03-25T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], + } satisfies ServerProvider; + + const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot); + + assert.deepStrictEqual(result.slashCommands, provider.slashCommands); + assert.deepStrictEqual(result.skills, provider.skills); + assert.deepStrictEqual(result.workspaceSnapshots, [ + { + cwd: "/project", + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommands, + skills: scopedSnapshot.skills, + }, + ]); + }); + it("preserves previously discovered provider models when a refresh returns none", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -992,6 +1029,164 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const machineProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const scopedProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const pendingScopedProvider = { + ...scopedProvider, + status: "error", + installed: false, + slashCommands: [], + } as const satisfies ServerProvider; + const snapshotCalls = yield* Ref.make(0); + const returnPendingSnapshot = yield* Ref.make(true); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const makeInstance = ( + provider: ServerProvider, + snapshotForCwd: NonNullable, + ): ProviderInstance => ({ + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + snapshotForCwd, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const firstInstance = makeInstance(machineProvider, () => + Effect.gen(function* () { + yield* Ref.update(snapshotCalls, (count) => count + 1); + if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + return scopedProvider; + }), + ); + const rebuiltProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + status: "warning", + installed: false, + auth: { status: "unknown" }, + } satisfies ServerProvider; + const rebuiltInstance = makeInstance(rebuiltProvider, () => + Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), + ); + const registryChanges = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([firstInstance]); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === requestedId), + ), + ), + listInstances: Ref.get(instancesRef), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(registryChanges), + subscribeChanges: PubSub.subscribe(registryChanges), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-workspace-snapshot-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); + yield* Ref.set(returnPendingSnapshot, false); + const workspaceUpdate = yield* registry.streamChanges.pipe( + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const firstRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const duplicateRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(firstRefresh); + yield* Fiber.join(duplicateRefresh); + const published = yield* Fiber.join(workspaceUpdate); + assert.strictEqual(published._tag, "Some"); + const providers = yield* registry.getProviders; + assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots?.[0]?.skills, + scopedProvider.skills, + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + + yield* Ref.set(instancesRef, [rebuiltInstance]); + yield* PubSub.publish(registryChanges, undefined); + let rebuilt = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; + attempt += 1 + ) { + yield* Effect.yieldNow; + rebuilt = yield* registry.getProviders; + } + assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); + assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("refreshes OpenCode catalogs and preserves other providers", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2433f951c9e78..915539cbdb3c9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,6 +78,28 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; + +export function upsertProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string, + scopedSnapshot: ServerProvider, +): ServerProvider { + const workspaceSnapshot = { + cwd, + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommands, + skills: scopedSnapshot.skills, + } satisfies NonNullable[number]; + return { + ...provider, + workspaceSnapshots: [ + ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), + workspaceSnapshot, + ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), + }; +} + const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver !== ProviderDriverKind.make("opencode")) { return true; @@ -140,6 +162,11 @@ export const mergeProviderSnapshot = ( : { ...nextProvider, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(nextProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + : previousProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : {}), ...(shouldRetainMissingOpenCodeMetadata(nextProvider) ? { slashCommands: @@ -283,6 +310,9 @@ export const ProviderRegistryLive = Layer.effect( ), ); const providersRef = yield* Ref.make>(cachedProviders); + const workspaceRefreshesRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); @@ -315,7 +345,8 @@ export const ProviderRegistryLive = Layer.effect( cacheDir: config.providerStatusCacheDir, instanceId: key, }).pipe(Effect.provideService(Path.Path, path)); - yield* writeProviderStatusCache({ filePath, provider }).pipe( + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; + yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.tapError(Effect.logError), @@ -557,6 +588,28 @@ export const ProviderRegistryLive = Layer.effect( newlyAdded.push([instanceId, instance] as const); } + const rebuiltInstanceIds = new Set( + newlyAdded + .map(([instanceId]) => instanceId) + .filter((instanceId) => previousSubs.has(instanceId)), + ); + if (rebuiltInstanceIds.size > 0) { + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = previousProviders.map((provider) => { + if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; + const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; + return machineSnapshot; + }); + return [[previousProviders, providers] as const, providers]; + }, + ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + } + // Fork long-lived subscriptions to each new/rebuilt instance's // change stream before reading its current snapshot. If the // driver's own initial probe finishes during this sync, either @@ -698,12 +751,76 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const providers = yield* Ref.get(providersRef); + const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); + if ( + !provider || + !provider.enabled || + provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + ) { + return providers; + } + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (!instance?.snapshotForCwd) return providers; + const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + if (current?.has(input.cwd)) return [false, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Set(current).add(input.cwd)); + return [true, next] as const; + }); + if (!claimed) return yield* Ref.get(providersRef); + return yield* instance.snapshotForCwd(input.cwd).pipe( + Effect.flatMap((scopedSnapshot) => + scopedSnapshot.status === "error" + ? Ref.get(providersRef) + : instanceRegistry.getInstance(input.instanceId).pipe( + Effect.flatMap((currentInstance) => { + if (currentInstance !== instance) return Ref.get(providersRef); + return Ref.modify(providersRef, (currentProviders) => { + const nextProviders = currentProviders.map((candidate) => + candidate.instanceId === input.instanceId && + !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) + : candidate, + ); + return [[currentProviders, nextProviders] as const, nextProviders]; + }).pipe( + Effect.tap(([previousProviders, nextProviders]) => + haveProvidersChanged(previousProviders, nextProviders) + ? PubSub.publish(changesPubSub, nextProviders) + : Effect.void, + ), + Effect.map(([, nextProviders]) => nextProviders), + ); + }), + ), + ), + Effect.ensuring( + Ref.update(workspaceRefreshesRef, (refreshes) => { + const next = new Map(refreshes); + const current = new Set(next.get(instance)); + current.delete(input.cwd); + if (current.size) next.set(instance, current); + else next.delete(instance); + return next; + }), + ), + ); + }); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshWorkspaceSnapshot: (input) => + refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 40a5c6905e2f5..e47ffab5c1f53 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -517,8 +517,6 @@ it.effect( }) : Effect.fail(unsupported()), listInstances: () => Effect.succeed([instanceId]), - listProviders: () => Effect.succeed([driverKind] as const), - streamChanges: Stream.empty, subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), @@ -597,8 +595,6 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance }) : Effect.fail(unsupported()), listInstances: () => Effect.succeed([instanceId]), - listProviders: () => Effect.succeed([CODEX_DRIVER] as const), - streamChanges: Stream.empty, subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), diff --git a/apps/server/src/provider/OpenCodeServerOwner.test.ts b/apps/server/src/provider/OpenCodeServerOwner.test.ts index 053c0b1bf63fb..5e66f0a563562 100644 --- a/apps/server/src/provider/OpenCodeServerOwner.test.ts +++ b/apps/server/src/provider/OpenCodeServerOwner.test.ts @@ -56,7 +56,9 @@ const makeRuntime = Effect.gen(function* () { runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; return { runtime, starts, closes, failNextStart, started, closed }; }); @@ -137,7 +139,9 @@ it.effect("invalidates an exited process so the next borrower starts a new one", runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; yield* Effect.scoped( @@ -183,7 +187,9 @@ it.effect("replaces a dead cached process before its exit watcher runs", () => runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; yield* Effect.scoped( @@ -236,7 +242,9 @@ it.effect("cleans up an interrupted startup and allows a retry", () => runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; yield* Effect.scoped( diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a4e..bdb3020ed42fd 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProvider, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -69,6 +70,7 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; + readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; } diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts index 5b755c42eedc3..20ee2d472a101 100644 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -1,21 +1,9 @@ /** * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. * - * Maps a `ProviderInstanceId` (the new per-instance routing key) or a - * `ProviderDriverKind` (legacy single-instance-per-driver key) to the concrete - * adapter service (Codex, Claude, etc). It does not own session lifecycle - * or routing rules; `ProviderService` uses this registry together with - * `ProviderSessionDirectory`. - * - * During the driver/instance migration this tag exposes both flavours: - * - * - `getByInstance` / `listInstances` — new per-instance routing. Callers - * that already know an `instanceId` (threads, sessions, events) - * should prefer these. - * (`defaultInstanceIdForDriver(kind) === kind`), matching the pre-Slice-D - * behaviour. New code should not grow additional callers of the kind-keyed - * methods; they exist so the settings UI, WS refresh RPC, and a handful - * of legacy persisted rows can still be routed during the rollout. + * Maps a `ProviderInstanceId` to its provider adapter. `ProviderService` uses + * this registry with `ProviderSessionDirectory`. The registry does not own + * session lifecycle or routing rules. * * @module ProviderAdapterRegistry */ @@ -24,7 +12,6 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as PubSub from "effect/PubSub"; import type * as Scope from "effect/Scope"; -import type * as Stream from "effect/Stream"; import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; @@ -64,25 +51,6 @@ export interface ProviderAdapterRegistryShape { */ readonly listInstances: () => Effect.Effect>; - /** - * Legacy: list provider kinds whose default instance is currently - * registered. - * - * @deprecated Prefer `listInstances`. Retained for migration-era call - * sites that iterate providers to build UI/metrics. - */ - readonly listProviders: () => Effect.Effect>; - - /** - * Change notification stream mirroring `ProviderInstanceRegistry.streamChanges`. - * Emits one `void` tick whenever the set of live instances changes - * (instance added, removed, or rebuilt after a settings edit). Consumers - * that fan out `adapter.streamEvents` per instance — e.g. `ProviderService`'s - * runtime event bus — re-pull `listInstances` on each tick and fork new - * subscriptions for instances they haven't seen yet. - */ - readonly streamChanges: Stream.Stream; - /** * Acquire a change subscription synchronously in the caller's current fiber. * Consumers that must avoid missing a publish between initial reconciliation diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b303381f..a1ea0c083ac26 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -48,6 +48,11 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + readonly refreshWorkspaceSnapshot: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) => Effect.Effect>; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 140e9fea42377..fe8ff02b403d5 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -254,11 +254,19 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadOpenCodeSkills: ( + client: OpencodeClient, + ) => Effect.Effect, OpenCodeRuntimeError>; readonly loadInventoryFromCli: (input: { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect; + readonly loadSkillsFromCli: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect, OpenCodeRuntimeError>; } function parseServerUrlFromOutput(output: string): string | null { @@ -856,8 +864,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.orElseSucceed((): ReadonlyArray => []), ); - const loadSkills = (client: OpencodeClient) => - runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( + const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => + runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( Effect.map((result) => (result.data ?? []).map((skill) => ({ name: skill.name, @@ -865,8 +873,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { location: skill.location, })), ), - Effect.orElseSucceed((): ReadonlyArray => []), ); + const loadSkills = (client: OpencodeClient) => + loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { @@ -972,13 +981,35 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }; }); + const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + cwd: input.cwd, + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.succeed(parseSkillsCliOutput(result.stdout)) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: `OpenCode skills command exited with code ${result.code}.`, + }), + ), + ), + ); + return { startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadOpenCodeSkills, loadInventoryFromCli, + loadSkillsFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 641c9b52e56c7..c45f1f7406cbb 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -189,6 +189,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts index c696a51c37ee3..ae1a120813a36 100644 --- a/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts @@ -2,15 +2,6 @@ * Test helpers for constructing a `ProviderAdapterRegistryShape` mock from a * kind-keyed adapter map. * - * Tests historically assembled a `registry` object with only `getByProvider` - * + `listProviders` populated. Slice D grew the shape with `getByInstance` - * and `listInstances`; this helper fills both in from a single kind-keyed - * input so individual fixtures can stay concise. - * - * Non-default instance ids (e.g. `codex_personal`) are not addressable via - * the shim returned here — the legacy test fixtures only ever had - * single-instance-per-driver data anyway. - * * @module provider/testUtils/providerAdapterRegistryMock */ import { @@ -20,9 +11,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as PubSub from "effect/PubSub"; -import * as Record from "effect/Record"; -import * as Result from "effect/Result"; -import * as Stream from "effect/Stream"; import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; @@ -34,9 +22,8 @@ export type KindAdapterMap = Partial< /** * Build a `ProviderAdapterRegistryShape` from a kind-keyed adapter map. - * Every adapter present in the map is addressable via both the legacy - * `getByProvider(kind)` path and the new `getByInstance(id)` path (where - * `id = defaultInstanceIdForDriver(kind)`). + * Every adapter present in the map is addressable through its default + * provider instance id. */ export const makeAdapterRegistryMock = (adapters: KindAdapterMap): ProviderAdapterRegistryShape => { const byInstanceId = new Map>(); @@ -80,18 +67,6 @@ export const makeAdapterRegistryMock = (adapters: KindAdapterMap): ProviderAdapt }); }, listInstances: () => Effect.succeed(Array.from(byInstanceId.keys())), - listProviders: () => - Effect.succeed( - Record.keys( - Record.filterMap(adapters, (adapter, kind) => - adapter !== undefined ? Result.succeed(kind) : Result.failVoid, - ), - ), - ), - // Static test fixtures don't reload; an empty stream is enough to - // satisfy the shape. Tests exercising hot-reload build their own - // stream via the real `ProviderInstanceRegistry`. - streamChanges: Stream.empty, subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b0590099..d9a04de6f607a 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 6747bfccc329a..40b0bd67e8368 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -16,8 +16,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; -it("uses the canonical Codex default for auto-bootstrapped model selection", () => { - assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { +it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { + assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -158,7 +158,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -197,7 +197,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => Effect.gen(function* () { - const dispatchCalls = yield* Ref.make>([]); + const dispatchCalls = yield* Ref.make< + ReadonlyArray<{ + readonly type: string; + readonly defaultModelSelection?: unknown; + readonly modelSelection?: unknown; + }> + >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", @@ -224,7 +230,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => - Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, @@ -236,7 +242,16 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); - assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]); + const commands = yield* Ref.get(dispatchCalls); + assert.deepStrictEqual( + commands.map((command) => command.type), + ["project.create", "thread.create"], + ); + assert.equal("defaultModelSelection" in commands[0]!, false); + assert.deepStrictEqual( + commands[1]?.modelSelection, + ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), + ); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 6bea823b19c32..3f1c212af91da 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -166,7 +166,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( Effect.asVoid, ); -export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ +export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -199,26 +199,25 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { serverConfig.cwd, ); let nextProjectId: ProjectId; - let nextProjectDefaultModelSelection: ModelSelection; + let nextThreadModelSelection: ModelSelection; if (Option.isNone(existingProject)) { const createdAt = DateTime.formatIso(yield* DateTime.now); nextProjectId = ProjectId.make(yield* randomUUID); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); + nextThreadModelSelection = getAutoBootstrapThreadModelSelection(); yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.make(yield* randomUUID), projectId: nextProjectId, title: bootstrapProjectTitle, workspaceRoot: serverConfig.cwd, - defaultModelSelection: nextProjectDefaultModelSelection, createdAt, }); } else { nextProjectId = existingProject.value.id; - nextProjectDefaultModelSelection = - existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); + nextThreadModelSelection = + existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); } const existingThreadId = @@ -232,7 +231,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { threadId: createdThreadId, projectId: nextProjectId, title: "New thread", - modelSelection: nextProjectDefaultModelSelection, + modelSelection: nextThreadModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, diff --git a/apps/server/src/textGeneration/AmpTextGeneration.ts b/apps/server/src/textGeneration/AmpTextGeneration.ts index 2ed1296bf57d0..80800f78da343 100644 --- a/apps/server/src/textGeneration/AmpTextGeneration.ts +++ b/apps/server/src/textGeneration/AmpTextGeneration.ts @@ -2,7 +2,7 @@ * AmpTextGeneration — Graceful "not supported" text-generation shape for Amp. * * Amp's CLI does not currently expose a structured-output mode that maps - * cleanly onto our `TextGenerationShape` contract (commit messages, PR + * cleanly onto our text-generation service contract (commit messages, PR * titles, branch names, thread titles). Rather than block the driver from * registering, this factory returns an implementation that fails every * operation with a clear `TextGenerationError`. The user can still pick a @@ -16,7 +16,9 @@ import * as Effect from "effect/Effect"; import type { GenericProviderSettings } from "@t3tools/contracts"; import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import { type TextGeneration } from "./TextGeneration.ts"; + +type TextGenerationService = TextGeneration["Service"]; const NOT_SUPPORTED_DETAIL = "Amp does not expose a structured text-generation API. Use a different provider instance for commit/PR/branch/title generation."; @@ -33,16 +35,16 @@ export const makeAmpTextGeneration = Effect.fn("makeAmpTextGeneration")(function _ampSettings: GenericProviderSettings, _environment: NodeJS.ProcessEnv = process.env, ) { - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = () => + const generateCommitMessage: TextGenerationService["generateCommitMessage"] = () => fail("generateCommitMessage"); - const generatePrContent: TextGenerationShape["generatePrContent"] = () => + const generatePrContent: TextGenerationService["generatePrContent"] = () => fail("generatePrContent"); - const generateBranchName: TextGenerationShape["generateBranchName"] = () => + const generateBranchName: TextGenerationService["generateBranchName"] = () => fail("generateBranchName"); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = () => + const generateThreadTitle: TextGenerationService["generateThreadTitle"] = () => fail("generateThreadTitle"); return { @@ -50,5 +52,5 @@ export const makeAmpTextGeneration = Effect.fn("makeAmpTextGeneration")(function generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGenerationService; }); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 2146bc03ac7a7..d502584e893f5 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -1,5 +1,5 @@ /** - * CopilotTextGeneration — `TextGenerationShape` factory for the GitHub + * CopilotTextGeneration — text-generation service factory for the GitHub * Copilot provider. * * The Copilot SDK does not expose a straightforward "one-shot prompt with @@ -10,7 +10,7 @@ * approvals, slash-command discovery, etc. * * Until/unless the SDK ships a dedicated structured-prompt entrypoint, - * this factory exposes a `TextGenerationShape` that fails gracefully on + * this factory exposes a text-generation service that fails gracefully on * every operation with a stable, user-actionable error message. Callers * (`SessionTextGeneration` etc.) already fall back to other providers * when one fails, so this keeps Copilot a valid `ProviderInstance` member @@ -22,7 +22,9 @@ import * as Effect from "effect/Effect"; import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import { type TextGeneration } from "./TextGeneration.ts"; + +type TextGenerationService = TextGeneration["Service"]; import type { CopilotSettings } from "../provider/Drivers/CopilotSettings.ts"; const UNSUPPORTED_DETAIL = @@ -48,13 +50,13 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( }), ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = () => + const generateCommitMessage: TextGenerationService["generateCommitMessage"] = () => fail("generateCommitMessage"); - const generatePrContent: TextGenerationShape["generatePrContent"] = () => + const generatePrContent: TextGenerationService["generatePrContent"] = () => fail("generatePrContent"); - const generateBranchName: TextGenerationShape["generateBranchName"] = () => + const generateBranchName: TextGenerationService["generateBranchName"] = () => fail("generateBranchName"); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = () => + const generateThreadTitle: TextGenerationService["generateThreadTitle"] = () => fail("generateThreadTitle"); return { @@ -62,5 +64,5 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGenerationService; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 0fa68390645de..3ef53c5734a51 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -15,7 +15,9 @@ import { type CursorSdkClient, type CursorSdkRunResult, } from "../provider/cursor/CursorSdkClient.ts"; -import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; +import { type TextGeneration, type ThreadTitleGenerationResult } from "./TextGeneration.ts"; + +type TextGenerationService = TextGeneration["Service"]; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -167,7 +169,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu ), ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + const generateCommitMessage: TextGenerationService["generateCommitMessage"] = Effect.fn( "CursorTextGeneration.generateCommitMessage", )(function* (input) { const { prompt, outputSchema } = buildCommitMessagePrompt({ @@ -195,7 +197,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu }; }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + const generatePrContent: TextGenerationService["generatePrContent"] = Effect.fn( "CursorTextGeneration.generatePrContent", )(function* (input) { const { prompt, outputSchema } = buildPrContentPrompt({ @@ -222,7 +224,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu }; }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + const generateBranchName: TextGenerationService["generateBranchName"] = Effect.fn( "CursorTextGeneration.generateBranchName", )(function* (input) { const { prompt, outputSchema } = buildBranchNamePrompt({ @@ -243,7 +245,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu }; }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + const generateThreadTitle: TextGenerationService["generateThreadTitle"] = Effect.fn( "CursorTextGeneration.generateThreadTitle", )(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ @@ -270,5 +272,5 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGenerationService; }); diff --git a/apps/server/src/textGeneration/GeminiCliTextGeneration.ts b/apps/server/src/textGeneration/GeminiCliTextGeneration.ts index 592571f62486b..a7944d6f486d3 100644 --- a/apps/server/src/textGeneration/GeminiCliTextGeneration.ts +++ b/apps/server/src/textGeneration/GeminiCliTextGeneration.ts @@ -1,5 +1,5 @@ /** - * GeminiCliTextGeneration — `TextGenerationShape` factory for the Gemini CLI + * GeminiCliTextGeneration — text-generation service factory for the Gemini CLI * provider. * * Earlier revisions invoked `gemini -p --approval-mode yolo` inside @@ -23,7 +23,9 @@ import * as Effect from "effect/Effect"; import { type GenericProviderSettings, TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import { type TextGeneration } from "./TextGeneration.ts"; + +type TextGenerationService = TextGeneration["Service"]; const UNSUPPORTED_DETAIL = "Gemini CLI is not supported for headless text generation (the only non-interactive Gemini mode auto-approves tool calls in the workspace). Pick a different provider for commit / PR / branch / thread title generation."; @@ -48,13 +50,13 @@ export const makeGeminiCliTextGeneration = Effect.fn("makeGeminiCliTextGeneratio }), ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = () => + const generateCommitMessage: TextGenerationService["generateCommitMessage"] = () => fail("generateCommitMessage"); - const generatePrContent: TextGenerationShape["generatePrContent"] = () => + const generatePrContent: TextGenerationService["generatePrContent"] = () => fail("generatePrContent"); - const generateBranchName: TextGenerationShape["generateBranchName"] = () => + const generateBranchName: TextGenerationService["generateBranchName"] = () => fail("generateBranchName"); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = () => + const generateThreadTitle: TextGenerationService["generateThreadTitle"] = () => fail("generateThreadTitle"); return { @@ -62,5 +64,5 @@ export const makeGeminiCliTextGeneration = Effect.fn("makeGeminiCliTextGeneratio generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGenerationService; }); diff --git a/apps/server/src/textGeneration/KiloTextGeneration.ts b/apps/server/src/textGeneration/KiloTextGeneration.ts index b66bf773c53d8..7c1d37561fac0 100644 --- a/apps/server/src/textGeneration/KiloTextGeneration.ts +++ b/apps/server/src/textGeneration/KiloTextGeneration.ts @@ -23,7 +23,9 @@ import { KiloServerManager } from "../kiloServerManager.ts"; import { parseKiloModel, readJsonData } from "../kilo/utils.ts"; import { createClient } from "../kilo/serverLifecycle.ts"; import type { KiloProviderOptions, SharedServerState } from "../kilo/types.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import { type TextGeneration } from "./TextGeneration.ts"; + +type TextGenerationService = TextGeneration["Service"]; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -197,7 +199,7 @@ export const makeKiloTextGeneration = Effect.fn("makeKiloTextGeneration")(functi ); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + const generateCommitMessage: TextGenerationService["generateCommitMessage"] = Effect.fn( "KiloTextGeneration.generateCommitMessage", )(function* (input) { const { prompt, outputSchema } = buildCommitMessagePrompt({ @@ -223,7 +225,7 @@ export const makeKiloTextGeneration = Effect.fn("makeKiloTextGeneration")(functi }; }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + const generatePrContent: TextGenerationService["generatePrContent"] = Effect.fn( "KiloTextGeneration.generatePrContent", )(function* (input) { const { prompt, outputSchema } = buildPrContentPrompt({ @@ -247,7 +249,7 @@ export const makeKiloTextGeneration = Effect.fn("makeKiloTextGeneration")(functi }; }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + const generateBranchName: TextGenerationService["generateBranchName"] = Effect.fn( "KiloTextGeneration.generateBranchName", )(function* (input) { const { prompt, outputSchema } = buildBranchNamePrompt({ @@ -268,7 +270,7 @@ export const makeKiloTextGeneration = Effect.fn("makeKiloTextGeneration")(functi }; }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + const generateThreadTitle: TextGenerationService["generateThreadTitle"] = Effect.fn( "KiloTextGeneration.generateThreadTitle", )(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ @@ -294,5 +296,5 @@ export const makeKiloTextGeneration = Effect.fn("makeKiloTextGeneration")(functi generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGenerationService; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 39c1031727dc9..688bf4bca3527 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -136,6 +136,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ @@ -144,6 +145,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => Effect.succeed([]), }; const DEFAULT_TEST_MODEL_SELECTION = { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1e..cc9b2e3926f36 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -73,15 +73,6 @@ export interface ThreadTitleGenerationResult { title: string; } -export interface TextGenerationService { - generateCommitMessage( - input: CommitMessageGenerationInput, - ): Promise; - generatePrContent(input: PrContentGenerationInput): Promise; - generateBranchName(input: BranchNameGenerationInput): Promise; - generateThreadTitle(input: ThreadTitleGenerationInput): Promise; -} - /** * TextGeneration - Service tag for commit and change request text generation. */ @@ -116,9 +107,6 @@ export class TextGeneration extends Context.Service< } >()("t3/textGeneration/TextGeneration") {} -/** @deprecated Use `TextGeneration["Service"]`. */ -export type TextGenerationShape = TextGeneration["Service"]; - type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6d0edf95d074..360fdc0da8a5c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1679,9 +1679,14 @@ const makeWsRpcLayer = ( [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - (input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() + (input.cwd !== undefined && input.instanceId !== undefined + ? providerRegistry.refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + }) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a267a6e07572a..29353e480f50e 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -16,7 +16,6 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { - type RelayClientDeviceRecord, type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, type RelayManagedEndpointProviderKind, @@ -26,10 +25,6 @@ import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; -import { - readPrimaryEnvironmentDescriptor, - readPrimaryEnvironmentTarget, -} from "../environments/primary"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { resolveCloudPublicConfig } from "./publicConfig"; import { @@ -215,20 +210,6 @@ export function collectCloudLinkTargets(input: { return [...byId.values()]; } -export function readPrimaryCloudLinkTarget(): CloudLinkTarget | null { - const descriptor = readPrimaryEnvironmentDescriptor(); - const target = readPrimaryEnvironmentTarget(); - if (!descriptor || !target) { - return null; - } - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: target.target.httpBaseUrl, - wsBaseUrl: target.target.wsBaseUrl, - }; -} - export function listManagedCloudEnvironments(input: { readonly clerkToken: string; }): Effect.Effect< @@ -260,32 +241,6 @@ export function listManagedCloudEnvironments(input: { }); } -export function listCloudDevices(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - if (!relayUrl()) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list cloud devices.", - cause, - }), - ), - ); - }); -} - export function readPrimaryCloudLinkState(input: { readonly target: CloudLinkTarget; }): Effect.Effect { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 64ad1ce7d7827..e23febd1e07aa 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,7 +6,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; @@ -41,6 +41,7 @@ import { shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, + toolGroupConsumesUpwardNavigation, } from "./ChatView.logic"; describe("isVideoPreviewRequestCurrent", () => { @@ -51,6 +52,134 @@ describe("isVideoPreviewRequestCurrent", () => { }); }); +describe("toolGroupConsumesUpwardNavigation", () => { + class ScrollElement extends EventTarget { + scrollTop = 0; + scrollHeight = 100; + clientHeight = 100; + overflowY = "visible"; + + constructor( + readonly parentElement: ScrollElement | null = null, + readonly isToolGroup = false, + ) { + super(); + } + + closest(selector: string): ScrollElement | null { + if (selector !== "[data-tool-group-scroll]") return null; + return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); + } + } + + beforeEach(() => { + vi.stubGlobal("Element", ScrollElement); + vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ + overflowY: element.overflowY, + })); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("releases upward navigation when an overflowing group is at the top", () => { + const group = Object.assign(new ScrollElement(null, true), { + overflowY: "auto", + scrollHeight: 300, + }); + + expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); + }); + + it.each([ + { overflowY: "auto", scrollTop: 1 }, + { overflowY: "auto", scrollTop: 0.25 }, + { overflowY: "scroll", scrollTop: 80 }, + ])("consumes upward navigation within a scrolled group: %j", (scroll) => { + const group = Object.assign(new ScrollElement(null, true), { + scrollHeight: 300, + ...scroll, + }); + + expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); + }); + + it.each([100, 300])( + "consumes scrolling in a nested result with a group content height of %i", + (scrollHeight) => { + const group = Object.assign(new ScrollElement(null, true), { + overflowY: "auto", + scrollHeight, + }); + const result = Object.assign(new ScrollElement(group), { + overflowY: "auto", + scrollHeight: 300, + scrollTop: 0.25, + }); + + expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); + }, + ); + + it("releases upward navigation when the group and nested result are both at the top", () => { + const group = Object.assign(new ScrollElement(null, true), { + overflowY: "auto", + scrollHeight: 300, + }); + const result = Object.assign(new ScrollElement(group), { + overflowY: "scroll", + scrollHeight: 300, + }); + + expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); + }); + + it("ignores targets outside a tool group and non-element targets", () => { + const outside = Object.assign(new ScrollElement(), { + overflowY: "auto", + scrollHeight: 300, + scrollTop: 40, + }); + + expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); + expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); + expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); + }); + + it("does not consume scrolling from an ancestor beyond the tool group", () => { + const timeline = Object.assign(new ScrollElement(), { + overflowY: "auto", + scrollHeight: 300, + scrollTop: 40, + }); + const group = new ScrollElement(timeline, true); + + expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); + }); + + it.each(["hidden", "clip", "visible"])( + "ignores a non-scrollable child with overflow-y %s", + (overflowY) => { + const group = new ScrollElement(null, true); + const result = Object.assign(new ScrollElement(group), { + overflowY, + scrollHeight: 300, + scrollTop: 40, + }); + + expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); + }, + ); + + it("does not consume programmatic scrolling on an overflow-hidden group", () => { + const group = Object.assign(new ScrollElement(null, true), { + overflowY: "hidden", + scrollHeight: 300, + scrollTop: 40, + }); + + expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); + }); +}); + const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index de349e3dbee72..20620b65e6241 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -99,6 +99,22 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: { }); } +export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { + const elementTarget = target instanceof Element ? target : null; + const group = elementTarget?.closest("[data-tool-group-scroll]"); + if (!group) return false; + + // A nested result or the group itself can consume an upward scroll. + for (let element = elementTarget; element; element = element.parentElement) { + if (element.scrollTop > 0) { + const overflowY = getComputedStyle(element).overflowY; + if (overflowY === "auto" || overflowY === "scroll") return true; + } + if (element === group) break; + } + return false; +} + export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; hasTimelineEntries: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 68b2c675ecd9c..d5e1cbc40c5c2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -279,6 +279,7 @@ import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { @@ -381,6 +382,7 @@ import { shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, codexArtifactTemplatePromptToAppend, + toolGroupConsumesUpwardNavigation, waitForStartedServerThread, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; @@ -4248,7 +4250,11 @@ function ChatViewContent(props: ChatViewProps) { // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { - if (event.deltaY < 0 && contentScrollsUp()) { + if ( + event.deltaY < 0 && + contentScrollsUp() && + !toolGroupConsumesUpwardNavigation(event.target) + ) { handleManualNavigation(); } }; @@ -4310,7 +4316,7 @@ function ChatViewContent(props: ChatViewProps) { case "PageUp": case "Home": case "ArrowUp": - if (contentScrollsUp()) { + if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { handleManualNavigation(); } break; @@ -7357,7 +7363,11 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={ + activeProviderStatus + ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) + : EMPTY_PROVIDER_SKILLS + } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} contentInsetEndAdjustment={composerOverlayHeight} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c86866462c743..391137787def9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -157,11 +157,7 @@ import { } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; -import { - deriveProviderInstanceEntries, - resolveDefaultProviderModelSelection, - type ProviderInstanceEntry, -} from "../providerInstances"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; @@ -1875,10 +1871,6 @@ function OpenCommandPaletteDialog(props: { } const projectId = newProjectId(); - const targetEnvironmentProviders = - environments.find((environment) => environment.environmentId === input.environmentId) - ?.serverConfig?.providers ?? - (input.environmentId === primaryEnvironmentId ? providers : []); const createResult = await createProject({ environmentId: input.environmentId, input: { @@ -1886,10 +1878,7 @@ function OpenCommandPaletteDialog(props: { title: inferProjectTitleFromPath(cwd), workspaceRoot: cwd, createWorkspaceRootIfMissing: true, - defaultModelSelection: resolveDefaultProviderModelSelection( - targetEnvironmentProviders, - null, - ), + defaultModelSelection: null, }, }); if (createResult._tag === "Failure") { diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index a9b7cf542e028..66a49ebf80847 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -46,20 +46,6 @@ export function DiffPanelShell(props: { ); } -export function DiffPanelHeaderSkeleton() { - return ( - <> -
- -
-
- - -
- - ); -} - function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) { return (
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d448a720ebfc6..1c75476b96d6d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -17,7 +17,15 @@ import type { } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; -import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { + type MouseEvent, + useCallback, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from "react"; import { flushSync } from "react-dom"; import { CheckIcon, @@ -91,7 +99,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { openPullRequestLink } from "~/lib/openPullRequestLink"; +import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { gitCwd: string | null; @@ -995,6 +1003,7 @@ export default function GitActionsControl({ () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); + const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -1442,7 +1451,7 @@ export default function GitActionsControl({ const toastCta = actionResult.toast.cta; let toastActionProps: { children: string; - onClick: () => void; + onClick: (event: MouseEvent) => void; } | null = null; if (toastCta.kind === "run_action") { toastActionProps = { @@ -1457,11 +1466,9 @@ export default function GitActionsControl({ } else if (toastCta.kind === "open_pr") { toastActionProps = { children: toastCta.label, - onClick: () => { - const api = readLocalApi(); - if (!api) return; + onClick: (event) => { closeResultToast(); - void api.shell.openExternal(toastCta.url); + openPrLink(event, toastCta.url); }, }; } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 1e05658894e6a..ea853d4e553d0 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -202,10 +202,6 @@ export function providerUpdateNotificationKey( return parts.length > 0 ? parts.join("|") : null; } -export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): string { - return providerUpdateNotificationKey([provider])!; -} - export function formatProviderList(providers: ReadonlyArray>) { const names = providers.map( (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, diff --git a/apps/web/src/components/T3Wordmark.tsx b/apps/web/src/components/T3Wordmark.tsx new file mode 100644 index 0000000000000..d0882a5dd61e3 --- /dev/null +++ b/apps/web/src/components/T3Wordmark.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from "react"; + +export function T3Wordmark(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index accc6e0872ef4..4998c40b0c558 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -22,17 +22,6 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('data-changed-files-state="expanded"'); expect(markup).toContain('aria-expanded="true"'); - expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain('class="group flex min-w-0 flex-1 items-center rounded-xl'); - expect(markup).not.toMatch(/class="group flex min-w-0 flex-1 items-center[^"]*overflow-hidden/); - expect(markup).toContain('class="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden"'); - expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); - expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); - expect(markup).toContain("@[24rem]/changed-files:inline"); - expect(markup).not.toContain("sm:inline"); - expect(markup).toContain('class="flex shrink-0 items-center gap-1.5 pr-1"'); - expect(markup).toContain("!size-[22px]"); - expect(markup).toContain("size-3"); expect(markup).toContain('aria-label="Collapse all folders"'); expect(markup).toContain('aria-label="Open diff"'); expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 301e4d823432c..e290692213444 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -330,9 +330,13 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { serverEnvironment } from "../../state/server"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const COMPOSER_FLOATING_LAYER_SELECTOR = [ @@ -1085,6 +1089,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const selectedProviderSkills = selectedProviderStatus + ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) + : []; + const selectedProviderSlashCommands = selectedProviderStatus + ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) + : []; + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const workspaceRefreshKeyRef = useRef(null); + const hadWorkspaceSnapshotRef = useRef(false); + useEffect(() => { + const hasWorkspaceSnapshot = Boolean( + gitCwd && + selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), + ); + if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { + workspaceRefreshKeyRef.current = null; + } + hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; + }, [gitCwd, selectedProviderStatus]); + useEffect(() => { + if (!gitCwd || !selectedProviderEntry) return; + const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; + const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === gitCwd, + ); + if (workspaceRefreshKeyRef.current === key) return; + if (hasWorkspaceSnapshot) { + workspaceRefreshKeyRef.current = key; + return; + } + workspaceRefreshKeyRef.current = key; + void refreshProviders({ + environmentId, + input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, + }).then( + (result) => { + const hasWorkspaceSnapshot = + result._tag === "Success" && + result.value.providers + .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) + ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); + if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { + workspaceRefreshKeyRef.current = null; + } + }, + () => { + if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null; + }, + ); + }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -1312,11 +1368,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : []), ] satisfies ReadonlyArray>; const slashMenuSkills = getProviderSkillsForSlashMenu( - selectedProviderStatus?.skills ?? [], + selectedProviderSkills, settings.showSkillsInSlashMenu, ); const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( - selectedProviderStatus?.slashCommands ?? [], + selectedProviderSlashCommands, slashMenuSkills, ).map((command) => ({ id: `provider-slash-command:${selectedProvider}:${command.name}`, @@ -1345,25 +1401,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; }, [ composerTrigger, planModeUiEnabled, selectedProvider, + selectedProviderSkills, + selectedProviderSlashCommands, selectedProviderStatus, settings.showSkillsInSlashMenu, workspaceEntries.entries, @@ -4070,7 +4126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx index 6435820c2e7b6..7a59d90546317 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -5,24 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ComposerCommandMenu } from "./ComposerCommandMenu"; describe("ComposerCommandMenu", () => { - it("renders slash-command results as an attached composer drawer", () => { - const markup = renderToStaticMarkup( - {}} - onSelect={() => {}} - />, - ); - - expect(markup).toContain('data-composer-command-drawer="true"'); - expect(markup).not.toContain("dropdown-glass"); - }); - - it("renders commands without a category heading or invented icons", () => { + it("renders slash commands with their descriptions", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("/model"); expect(markup).toContain("Switch response model for this thread"); - expect(markup).not.toContain("Built-in"); - expect(markup).not.toContain(" { + it("shows the app source for an app skill", () => { const markup = renderToStaticMarkup( { expect(markup).toContain('data-slot="badge"'); expect(markup).toContain(">App Skill"); expect(markup).toContain("Open and control the in-app browser"); - expect(markup).toContain("max-w-[48ch]"); - expect(markup).toContain("text-secondary-label text-xs"); - expect(markup).toContain("ms-auto"); - expect(markup).toContain("text-current"); - expect(markup.indexOf("Open and control the in-app browser")).toBeLessThan( - markup.indexOf(">App Skill"), - ); expect(markup).toContain(" { + it("shows the repo source for a slash skill", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("lucide-folder"); expect(markup).toContain(">Repo"); expect(markup).toContain("Find the right skill or workflow"); - expect(markup).not.toContain("font-medium text-secondary-label"); }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 1061f3d783254..068347e9c593d 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -45,27 +45,6 @@ function renderPendingActions(isRunning: boolean) { ); } -function renderStandaloneStop() { - return renderToStaticMarkup( - createElement(ComposerPrimaryActions, { - compact: true, - pendingAction: null, - isRunning: true, - showPlanFollowUpPrompt: false, - promptHasText: false, - isSendBusy: false, - sendDisabledReason: null, - isConnecting: false, - isEnvironmentUnavailable: false, - isPreparingWorktree: false, - hasSendableContent: false, - onPreviousPendingQuestion: () => {}, - onInterrupt: () => {}, - onImplementPlanInNewThread: () => {}, - }), - ); -} - function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { @@ -220,12 +199,6 @@ describe("ComposerPrimaryActions", () => { expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); }); - it("matches the small pending action size without changing the standalone size", () => { - expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); - expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); - expect(renderStandaloneStop()).not.toContain("sm:size-7"); - }); - it.each(["artwork", "nightly-artwork"] as const)( "renders stage artwork inside the send button in %s mode", (mode) => { @@ -235,18 +208,15 @@ describe("ComposerPrimaryActions", () => { const markup = renderSendButton(); expect(markup).toContain("stage-nightly"); - expect(markup).toContain("bg-transparent text-white"); - expect(markup).not.toContain("bg-message-action text-message-action-foreground"); }, ); - it("keeps the normal send-button fill when artwork identification is inactive", () => { + it("hides stage artwork when artwork identification is inactive", () => { stageArtworkState.variant = "nightly"; const markup = renderSendButton(); expect(markup).not.toContain("stage-nightly"); - expect(markup).toContain("bg-message-action text-message-action-foreground"); }); it("only renders stop while running when Enter-to-send is available", () => { @@ -262,7 +232,6 @@ describe("ComposerPrimaryActions", () => { expect(markup).toContain('aria-label="Stop generation"'); expect(markup).toContain('aria-label="Send message"'); expect(markup).toContain('type="submit"'); - expect(markup).toContain("size-9 sm:size-8"); }); it("keeps stop as the only action while running with an empty composer", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d702d6ddda742..9baaa14395df6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4,11 +4,180 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, + liveWorkEntryLabel, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + resolveWorkGroupScrollIndex, + shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + workEntryDisplayLabel, } from "./MessagesTimeline.logic"; +describe("expanded tool group scrolling", () => { + const entries = [{ id: "first" }, { id: "second" }]; + + it("follows appended calls only at the hard end", () => { + const appended = [...entries, { id: "third" }]; + expect(shouldFollowWorkGroupAppend(entries, appended, 0)).toBe(true); + expect(shouldFollowWorkGroupAppend(entries, appended, 0.5)).toBe(true); + expect(shouldFollowWorkGroupAppend(entries, appended, 1)).toBe(true); + expect(shouldFollowWorkGroupAppend(entries, appended, 1.01)).toBe(false); + expect(shouldFollowWorkGroupAppend(entries, appended, 10)).toBe(false); + expect(shouldFollowWorkGroupAppend(entries, appended, Infinity)).toBe(false); + }); + + it("does not follow output updates, prepends, or replacements", () => { + expect( + shouldFollowWorkGroupAppend( + entries, + entries.map((entry) => ({ ...entry })), + 0, + ), + ).toBe(false); + expect(shouldFollowWorkGroupAppend(entries, [{ id: "older" }, ...entries], 0)).toBe(false); + expect( + shouldFollowWorkGroupAppend( + entries, + [{ id: "replacement" }, entries[1]!, { id: "third" }], + 0, + ), + ).toBe(false); + expect(shouldFollowWorkGroupAppend([], entries, 0)).toBe(false); + }); + + it("restores the visible tool and its offset inside expanded output", () => { + const anchor = { entryId: "second", offset: 120 }; + expect(resolveWorkGroupScrollIndex(entries, anchor)).toEqual({ index: 1, viewOffset: -120 }); + expect(resolveWorkGroupScrollIndex([{ id: "older" }, ...entries], anchor)).toEqual({ + index: 2, + viewOffset: -120, + }); + }); + + it("starts normally when the saved tool no longer exists", () => { + expect(resolveWorkGroupScrollIndex(entries, undefined)).toBeUndefined(); + expect( + resolveWorkGroupScrollIndex(entries, { entryId: "removed", offset: 120 }), + ).toBeUndefined(); + }); +}); + +describe("work entry labels", () => { + const entry = { + id: "tool-1", + createdAt: "2026-09-01T12:00:00Z", + label: "Tool call", + tone: "tool" as const, + }; + + it.each([ + ["inProgress", "Clicking in the preview browser"], + ["completed", "Clicked in the preview browser"], + ["failed", "Failed to click in the preview browser"], + ["declined", "Declined to click in the preview browser"], + ["stopped", "Stopped clicking in the preview browser"], + ] as const)("uses the same friendly %s label in both views", (toolLifecycleStatus, label) => { + const browserEntry = { + ...entry, + toolTitle: "T3-code.preview_click", + detail: '{"ok":true}', + toolLifecycleStatus, + }; + expect(liveWorkEntryLabel(browserEntry, undefined, toolLifecycleStatus === "inProgress")).toBe( + label, + ); + expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label); + }); + + it("uses the active summary state for legacy tools without a lifecycle status", () => { + const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" }; + expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( + "Clicking in the preview browser", + ); + expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe( + "Clicked in the preview browser", + ); + }); + + it("does not describe a finished call as still running while the turn continues", () => { + const browserEntry = { + ...entry, + toolTitle: "T3-code.preview_click", + toolLifecycleStatus: "completed" as const, + }; + expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( + "Clicked in the preview browser", + ); + }); + + it("keeps custom titles and output for unrecognized tools", () => { + const unknownEntry = { ...entry, toolTitle: "mcp__github__search_issues" }; + expect(liveWorkEntryLabel(unknownEntry, undefined, true)).toBe("Mcp__github__search_issues"); + expect(workEntryDisplayLabel({ ...unknownEntry, detail: "Found 3 issues" }, undefined)).toBe( + "Found 3 issues", + ); + }); + + it("keeps command summaries compact without replacing the full command in expanded rows", () => { + const commandEntry = { ...entry, command: "vp test run", detail: "All tests passed" }; + expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp"); + expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp"); + expect(workEntryDisplayLabel(commandEntry, undefined)).toBe("vp test run"); + }); + + it("summarizes the program inside a shell wrapper while preserving the expanded command", () => { + const command = "/bin/zsh -lc 'vp test run apps/web/src/session-logic.test.ts'"; + const commandEntry = { ...entry, command }; + expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp"); + expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp"); + expect(workEntryDisplayLabel(commandEntry, undefined)).toBe(command); + }); + + it.each([ + ["inProgress", "Running vp"], + ["completed", "Ran vp"], + ["failed", "Failed vp"], + ["declined", "Declined vp"], + ["stopped", "Stopped vp"], + ] as const)( + "uses the command's %s outcome even while the turn continues", + (toolLifecycleStatus, label) => { + const commandEntry = { + ...entry, + command: "/bin/bash -lc 'vp test run'", + toolLifecycleStatus, + }; + expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(label); + expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(label); + }, + ); + + it("gives a completed browser group its own count and summary icon category", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "browser-entry", + kind: "work", + createdAt: entry.createdAt, + entry: { + ...entry, + itemType: "mcp_tool_call", + toolLifecycleStatus: "completed", + toolData: { server: "t3-code", tool: "preview_click" }, + }, + }, + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + expect(rows).toMatchObject([ + { kind: "work-toggle", summary: "Used browser 1 time", summaryKind: "browser" }, + ]); + }); +}); + describe("shouldPreserveAssistantLineBreaks", () => { it("preserves Claude insight formatting without changing regular markdown", () => { expect( @@ -1197,8 +1366,60 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it.each([ + [undefined, true], + ["inProgress", true], + ["completed", false], + ["failed", null], + ["declined", false], + ["stopped", false], + ] as const)( + "respects the %s lifecycle of trailing task progress", + (toolLifecycleStatus, active) => { + const turnId = TurnId.make("turn-task-progress"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "task-progress-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "task-progress", + createdAt: "2026-01-01T00:00:05Z", + turnId, + label: "Task progress", + tone: "thinking", + sourceActivityKind: "task.progress", + ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), + }, + }, + ], + latestTurn: { + turnId, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const workLiveRow = rows.find((row) => row.kind === "work-live"); + if (active === null) { + expect(workLiveRow).toBeUndefined(); + expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + } else { + expect(workLiveRow).toMatchObject({ active }); + } + }, + ); + it("reuses one activity row for initial thinking and the latest tool", () => { - const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => + const deriveRows = ( + toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null, + ) => deriveMessagesTimelineRows({ timelineEntries: toolLifecycleStatus === null @@ -1217,6 +1438,7 @@ describe("deriveMessagesTimelineRows", () => { requestKind: "command", tone: "tool" as const, toolLifecycleStatus, + ...(toolLifecycleStatus === "inProgress" ? { detail: "exit code 1" } : {}), }, }, ], @@ -1235,6 +1457,7 @@ describe("deriveMessagesTimelineRows", () => { const initialRows = deriveRows(null); const runningRows = deriveRows("inProgress"); const completedRows = deriveRows("completed"); + const failedRows = deriveRows("failed"); const declinedRows = deriveRows("declined"); const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); @@ -1243,11 +1466,14 @@ describe("deriveMessagesTimelineRows", () => { expect(initialActivityRow).toMatchObject({ kind: "thinking" }); expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(failedRows.some((row) => row.kind === "work-live")).toBe(false); + expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(failedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); @@ -1464,15 +1690,61 @@ describe("deriveMessagesTimelineRows", () => { }); expect(expandedRows.map((row) => row.id)).toEqual([ "work-toggle:work-entry-1", - "work-1", - "work-2", - "work-3", + "work-group:work-entry-1:details", ]); + expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({ + isExpandedToolGroup: true, + groupedEntries: timelineEntries.map(({ entry }) => entry), + }); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, }); }); + it.each([true, false])( + "keeps a large expanded tool run inside one timeline item, live=%s", + (isWorking) => { + const turnId = TurnId.make("turn-many-tools"); + const createdAt = "2026-09-01T12:00:00Z"; + const timelineEntries = Array.from({ length: 1_000 }, (_, index) => ({ + id: `tool-entry-${index}`, + kind: "work" as const, + createdAt, + entry: { + id: `tool-${index}`, + toolCallId: `call-${index}`, + createdAt, + turnId, + label: "t3-code.preview_snapshot", + tone: "tool" as const, + toolLifecycleStatus: + isWorking && index === 999 ? ("inProgress" as const) : ("completed" as const), + }, + })); + const groupId = `work-group:tool:${turnId}:call-0`; + const input = { + timelineEntries, + isWorking, + expandedTurnIds: new Set([turnId]), + runningTurnId: isWorking ? turnId : null, + activeTurnStartedAt: isWorking ? createdAt : null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + const expandedRows = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set([groupId]), + }); + const groupRows = expandedRows.filter((row) => row.kind === "work"); + expect(groupRows).toHaveLength(1); + expect(groupRows[0]?.groupedEntries.map(({ id }) => id)).toEqual( + timelineEntries.map(({ entry }) => entry.id), + ); + expect(groupRows[0]?.id).toBe(`${groupId}:details`); + expect(deriveMessagesTimelineRows(input).some((row) => row.kind === "work")).toBe(false); + }, + ); + it.each([ ["recovered", ["failed", "completed"], false], ["ending in failure", ["completed", "failed"], true], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9f8794801854d..7cea3803827da 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,10 @@ import * as Equal from "effect/Equal"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; +import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import { + normalizeCompactToolLabel, omitSupersededLifecycleMarkers, + resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupSummaryKind, type ToolGroupSummaryKind, @@ -23,6 +26,7 @@ import { } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; +import { formatWorkspaceRelativePath } from "../../filePathDisplay"; export const TIMELINE_MINIMAP_ITEM_SPACING = 8; export const TIMELINE_MINIMAP_MIN_ITEMS = 2; @@ -30,6 +34,50 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; export const TIMELINE_CONTENT_MAX_WIDTH = 768; export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) { + const toolPresentation = resolveWorkEntryToolPresentation(entry); + if (toolPresentation) return toolPresentation.displayName; + if (entry.command) return entry.command; + if (entry.detail) return entry.detail; + const [firstPath] = entry.changedFiles ?? []; + if (firstPath) { + const path = formatWorkspaceRelativePath(firstPath, workspaceRoot); + return entry.changedFiles!.length === 1 + ? path + : `${path} +${entry.changedFiles!.length - 1} more`; + } + const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label); + return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; +} + +export function liveWorkEntryLabel( + entry: WorkLogEntry, + workspaceRoot: string | undefined, + active: boolean, +) { + const toolPresentation = resolveWorkEntryToolPresentation( + entry, + active ? "inProgress" : "completed", + ); + if (toolPresentation) return toolPresentation.displayName; + const command = entry.command?.trim(); + if (command) { + const status = entry.toolLifecycleStatus ?? (active ? "inProgress" : "completed"); + const verb = + status === "inProgress" + ? "Running" + : status === "failed" + ? "Failed" + : status === "declined" + ? "Declined" + : status === "stopped" + ? "Stopped" + : "Ran"; + return `${verb} ${commandProgramName(command) ?? "command"}`; + } + return workEntryDisplayLabel(entry, workspaceRoot); +} + export function workEntryIsVisibleInGroup( entry: WorkLogEntry, expandedToolGroupEntry = false, @@ -42,6 +90,35 @@ export function workEntryIsVisibleInGroup( ); } +export interface WorkGroupScrollAnchor { + readonly entryId: string; + readonly offset: number; +} + +/** Restore a visible tool, including a position partway through its expanded output. */ +export function resolveWorkGroupScrollIndex( + entries: ReadonlyArray<{ readonly id: string }>, + anchor: WorkGroupScrollAnchor | undefined, +): { index: number; viewOffset: number } | undefined { + if (!anchor) return undefined; + const index = entries.findIndex((entry) => entry.id === anchor.entryId); + return index < 0 ? undefined : { index, viewOffset: -anchor.offset }; +} + +/** Only newly appended calls may follow the end, never status or output updates. */ +export function shouldFollowWorkGroupAppend( + previous: ReadonlyArray<{ readonly id: string }>, + entries: ReadonlyArray<{ readonly id: string }>, + distanceFromEnd: number, +): boolean { + return ( + previous.length > 0 && + entries.length > previous.length && + distanceFromEnd <= 1 && + previous.every((entry, index) => entry.id === entries[index]?.id) + ); +} + export interface TimelineEndState { readonly isAtEnd?: boolean; readonly contentLength?: number; @@ -197,8 +274,7 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; - isExpandedToolGroupEntry: boolean; - isLastExpandedToolGroupEntry: boolean; + isExpandedToolGroup: boolean; } | { kind: "work-live"; @@ -292,6 +368,20 @@ function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; } +function expandedWorkGroupRow( + groupId: string, + createdAt: string, + groupedEntries: WorkLogEntry[], +): Extract { + return { + kind: "work", + id: `${groupId}:details`, + createdAt, + groupedEntries, + isExpandedToolGroup: true, + }; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -384,8 +474,8 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { return ( entry.toolLifecycleStatus === "inProgress" || - entry.sourceActivityKind === "task.progress" || - (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry)) + (entry.toolLifecycleStatus === undefined && + (entry.sourceActivityKind === "task.progress" || workLogEntryIsToolLike(entry))) ); } @@ -601,13 +691,18 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); + const latestToolFailed = + latestRunningToolEntry === undefined && + latestVisibleToolEntry !== undefined && + latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" && + workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry); const latestToolKeepsActivityLive = latestRunningToolEntry !== undefined || (latestVisibleToolEntry !== undefined && workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestVisibleToolEntry + activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { @@ -625,7 +720,7 @@ export function deriveMessagesTimelineRows(input: { })() : null; const activeWorkEntryIds = new Set( - activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), + activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [], ); const appendWorkingRow = () => { nextRows.push({ @@ -640,16 +735,13 @@ export function deriveMessagesTimelineRows(input: { nextRows.push(activeWorkRow); hasActivityRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; - for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { - nextRows.push({ - kind: "work", - id: workEntry.id, - createdAt: workEntry.createdAt, - groupedEntries: [workEntry], - isExpandedToolGroupEntry: true, - isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, - }); - } + nextRows.push( + expandedWorkGroupRow( + activeWorkRow.groupId, + activeWorkRow.createdAt, + activeWorkRow.groupedEntries, + ), + ); }; for (let index = 0; index < input.timelineEntries.length; index += 1) { @@ -693,8 +785,7 @@ export function deriveMessagesTimelineRows(input: { id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: [timelineEntry.entry], - isExpandedToolGroupEntry: false, - isLastExpandedToolGroupEntry: false, + isExpandedToolGroup: false, }); continue; } @@ -740,16 +831,9 @@ export function deriveMessagesTimelineRows(input: { }); hasActivityRow = true; if (expanded) { - for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { - nextRows.push({ - kind: "work", - id: workEntry.id, - createdAt: workEntry.createdAt, - groupedEntries: [workEntry], - isExpandedToolGroupEntry: true, - isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, - }); - } + nextRows.push( + expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), + ); } } else { const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); @@ -774,16 +858,9 @@ export function deriveMessagesTimelineRows(input: { workEntryDisplayIndicatesToolFailure(latestToolEntry), }); if (expanded) { - for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { - nextRows.push({ - kind: "work", - id: workEntry.id, - createdAt: workEntry.createdAt, - groupedEntries: [workEntry], - isExpandedToolGroupEntry: true, - isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, - }); - } + nextRows.push( + expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), + ); } } } @@ -840,7 +917,7 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasActivityRow) { + if (input.isWorking && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, @@ -891,8 +968,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "work": { const bw = b as typeof a; return ( - a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && - a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && + a.isExpandedToolGroup === bw.isExpandedToolGroup && Equal.equals(a.groupedEntries, bw.groupedEntries) ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a54aaaab34327..04a2f8ac56281 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -16,12 +16,7 @@ vi.mock("@legendapp/list/react", async () => { ListFooterComponent?: ReactNode; anchoredEndSpace?: { anchorIndex: number; - anchorMaxSize?: number; - anchorOffset?: number; - onReady?: (info: { anchorIndex: number }) => void; }; - contentInsetEndAdjustment?: number; - className?: string; maintainScrollAtEnd?: | boolean | { @@ -32,27 +27,12 @@ vi.mock("@legendapp/list/react", async () => { layout?: boolean; }; }; - maintainVisibleContentPosition?: - | boolean - | { - data?: boolean; - size?: boolean; - shouldRestorePosition?: (item: { id: string }) => boolean; - }; ref?: Ref; }) => { - if (props.anchoredEndSpace) { - props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); - } return (
{ ? props.maintainScrollAtEnd.on?.layout : undefined } - data-maintain-visible-content-position={ - typeof props.maintainVisibleContentPosition === "object" - ? "object" - : props.maintainVisibleContentPosition - } - data-maintain-visible-content-position-data={ - typeof props.maintainVisibleContentPosition === "object" - ? props.maintainVisibleContentPosition.data - : undefined - } - data-maintain-visible-content-position-size={ - typeof props.maintainVisibleContentPosition === "object" - ? props.maintainVisibleContentPosition.size - : undefined - } - data-maintain-visible-content-position-restore={ - typeof props.maintainVisibleContentPosition === "object" - ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition) - : undefined - } > {props.ListHeaderComponent} {props.data.map((item) => ( @@ -293,7 +253,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("codex-thread-1"); }); - it("renders the worked-for row at assistant response text size", () => { + it("renders elapsed time for a completed turn", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); const markup = renderToStaticMarkup( @@ -328,23 +288,6 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain("Worked for 8.0s"); - expect(markup).toContain("px-1 text-sm leading-relaxed text-muted-foreground"); - }); - - it("uses the larger leading inset only when the top fade is enabled", () => { - const timelineEntries = [buildUserTimelineEntry("Hello")]; - - const compactMarkup = renderToStaticMarkup( - , - ); - const fadedMarkup = renderToStaticMarkup( - , - ); - - expect(compactMarkup).toContain('class="h-3 sm:h-4"'); - expect(compactMarkup).not.toContain("topbar-scroll-fade"); - expect(fadedMarkup).toContain('class="h-[var(--workspace-titlebar-scroll-fade-height)]"'); - expect(fadedMarkup).toContain("topbar-scroll-fade"); }); it("keeps assistant changed-files headers sticky below the thread header", () => { @@ -491,74 +434,6 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); - it("anchors the first user message using its measured height", () => { - const onAnchorReady = vi.fn(); - const firstEntry = { - ...buildUserTimelineEntry("First prompt."), - message: { - ...buildUserTimelineEntry("First prompt.").message, - attachments: [ - { - type: "image" as const, - id: "attachment-1", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 1, - previewUrl: "data:image/png;base64,iVBORw0KGgo=", - }, - ], - }, - }; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-anchor-index="0"'); - expect(markup).toContain('data-anchor-offset="24"'); - expect(markup).toContain('data-anchor-on-ready="true"'); - expect(markup).not.toContain("data-anchor-max-size="); - expect(markup).toContain('data-content-inset-end="144"'); - expect(markup).toContain("[overflow-anchor:none]"); - expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); - expect(markup).toContain('data-maintain-visible-content-position="object"'); - expect(markup).toContain('data-maintain-visible-content-position-data="true"'); - expect(markup).toContain('data-maintain-visible-content-position-size="true"'); - expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); - expect(onAnchorReady).toHaveBeenCalledOnce(); - expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0); - }); - - it("does not reserve end space for a follow-up user message", () => { - const onAnchorReady = vi.fn(); - const firstEntry = buildUserTimelineEntry("First prompt."); - const secondEntry = { - ...buildUserTimelineEntry("Newest prompt."), - id: "entry-2", - message: { - ...buildUserTimelineEntry("Newest prompt.").message, - id: MessageId.make("message-2"), - }, - }; - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain("data-anchor-index="); - expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); - expect(onAnchorReady).not.toHaveBeenCalled(); - }); - it("renders generic attachments as download links instead of image previews", () => { const entry = { ...buildUserTimelineEntry("Read the report."), @@ -1381,7 +1256,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-timeline-row-id="live-activity-row"'); }); - it("keeps the completed command in the shared activity row", () => { + it("keeps the completed command in the shared activity row with a past-tense label", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("Running pnpm"); + expect(markup).toContain("Ran pnpm"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("live-activity-focus"); - expect(markup).not.toContain("Ran pnpm"); + expect(markup).not.toContain("Running pnpm"); expect(markup).not.toContain("Thinking"); expect(markup).not.toContain('data-timeline-row-kind="thinking"'); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b6a0dc6b4dda0..a4a0bb7a3a594 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -8,11 +8,12 @@ import { } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; -import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import { + resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; +import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; import { emptyAgentPanelModel, @@ -63,6 +64,7 @@ import { } from "../../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; +import { T3Wordmark } from "../T3Wordmark"; import { BotIcon, BrainIcon, @@ -88,6 +90,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; @@ -108,7 +111,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, - normalizeCompactToolLabel, + liveWorkEntryLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -117,13 +120,17 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, + resolveWorkGroupScrollIndex, + shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, toolGroupAction, + workEntryDisplayLabel, workEntryIsVisibleInGroup, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, type TimelineLatestTurn, + type WorkGroupScrollAnchor, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -184,6 +191,8 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; + onToggleWorkEntry: (anchorKey: string) => void; + workGroupViewState: WorkGroupViewState; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; } @@ -197,6 +206,16 @@ interface TimelineRowActivityState { const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); + +interface WorkGroupViewState { + scrollPositions: Map; + expandedEntries: Set; +} + +const WorkGroupViewCtx = createContext<{ + state: WorkGroupViewState; + onToggleEntry: () => void; +} | null>(null); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER = (
@@ -344,6 +363,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }, []); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); + // Scroll/disclosure state outlives virtualized rows, but never the current thread. + const workGroupViewState = useMemo( + () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), + [routeThreadKey], + ); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); const disclosureAnchorKeyRef = useRef(null); @@ -617,6 +641,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, + workGroupViewState, agentPanelModel, onOpenAgents, }), @@ -638,6 +664,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + suspendEndScrollMaintenanceForDisclosure, + workGroupViewState, agentPanelModel, onOpenAgents, ], @@ -1044,8 +1072,7 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { - const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; - const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; + const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; const isExpandedToolGroupHeader = (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded); @@ -1054,10 +1081,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time className={cn( // Commentary (non-terminal assistant) rows carry no metadata row, so // they sit closer to the work that follows them. - isExpandedToolGroupEntry - ? isLastExpandedToolGroupEntry - ? "pb-1" - : "pb-0" + isExpandedToolGroup + ? "pb-1" : isExpandedToolGroupHeader ? "pb-0" : row.kind === "turn-fold" || row.kind === "working" @@ -1080,8 +1105,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time > {row.kind === "work" ? ( ) : null} {row.kind === "work-live" ? : null} @@ -1581,43 +1607,189 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { // re-render only the affected row, not the entire list. // --------------------------------------------------------------------------- -/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ +/** Renders standalone activity or one bounded, virtualized expanded tool group. */ const WorkGroupSection = memo(function WorkGroupSection({ + anchorKey, groupedEntries, - isExpandedToolGroupEntry, + isExpandedToolGroup, }: { + anchorKey: string; groupedEntries: Extract["groupedEntries"]; - isExpandedToolGroupEntry: boolean; + isExpandedToolGroup: boolean; }) { - const { workspaceRoot } = use(TimelineRowCtx); + const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( - () => - groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)), - [groupedEntries, isExpandedToolGroupEntry], + () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), + [groupedEntries, isExpandedToolGroup], ); - const GroupContainer = isExpandedToolGroupEntry ? "div" : "section"; if (nonEmptyEntries.length === 0) return null; + if (isExpandedToolGroup) { + return ( + + ); + } return ( - +
{nonEmptyEntries.map((workEntry) => ( ))}
- +
); }); +function ExpandedWorkGroupEntries({ + anchorKey, + entries, + workspaceRoot, +}: { + anchorKey: string; + entries: TimelineWorkEntry[]; + workspaceRoot: string | undefined; +}) { + const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx); + const [initialScrollIndex] = useState(() => + resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), + ); + const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); + const listRef = useRef(null); + const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 }); + const [appendState, setAppendState] = useState({ entries, follow: false }); + // Capture the pre-change edge once per incoming array, before new layout + // metrics arrive. Edge/viewport changes never turn a status update into a follow. + if (appendState.entries !== entries) { + setAppendState({ + entries, + follow: + fades.viewportHeight > 0 && + shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0), + }); + } + + const groupView = useMemo( + () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }), + [anchorKey, onToggleWorkEntry, viewState], + ); + const updateScrollFades = useCallback(() => { + const element = listRef.current?.getScrollableNode(); + if (!element) return; + const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop; + const viewportHeight = element.clientHeight; + const top = element.scrollTop > 1; + const bottom = distanceFromEnd > 1; + setFades((previous) => + previous.top === top && + previous.bottom === bottom && + previous.viewportHeight === viewportHeight + ? previous + : { top, bottom, viewportHeight }, + ); + }, []); + + const handleScroll = useCallback(() => { + const state = listRef.current?.getState(); + const position = state && resolveWorkGroupScrollAnchor(state); + if (position) { + viewState.scrollPositions.set(anchorKey, { + entryId: position.rowId, + offset: position.offsetWithinRow, + }); + } + updateScrollFades(); + }, [anchorKey, updateScrollFades, viewState]); + + const handleLoad = useCallback(() => { + const list = listRef.current; + const element = list?.getScrollableNode(); + if (initialScrollIndex && list && element) { + // Bootstrap can report the restored target before the DOM has applied it. + // Reconcile once at load, before releasing the measured anchor row. + const offset = Math.max( + 0, + Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight), + ); + if (Math.abs(element.scrollTop - offset) > 1) { + void list.scrollToOffset({ offset, animated: false }); + } + } + setRestoringPosition(false); + }, [initialScrollIndex]); + + useLayoutEffect(() => { + const element = listRef.current?.getScrollableNode(); + if (!element) return; + updateScrollFades(); + const observer = new ResizeObserver(updateScrollFades); + observer.observe(element); + if (element.firstElementChild) observer.observe(element.firstElementChild); + return () => observer.disconnect(); + }, [updateScrollFades]); + + const renderEntry = useCallback( + ({ item }: { item: TimelineWorkEntry }) => ( + + ), + [workspaceRoot], + ); + + return ( + + + + ); +} + +const workEntryKey = (entry: TimelineWorkEntry) => entry.id; + function ActivityShimmerOverlay({ children }: { children: ReactNode }) { return ( - ) : null} {label} + {failed && isSpecialToolIcon ? : null} ); } @@ -1737,6 +1911,8 @@ function toolGroupSummaryIconName( return "square-pen"; case "command": return "terminal"; + case "browser": + return "browser"; case "search": return "globe"; case "code-search": @@ -1770,7 +1946,7 @@ function WorkGroupToggleTimelineRow({ onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} > - @@ -2304,6 +2480,7 @@ function formatWorkingTimerNow(startIso: string): string { type WorkEntryIconName = | "bot" | "brain" + | "browser" | "check" | "circle-alert" | "eye" @@ -2313,16 +2490,21 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "t3-code" | "wrench" | "x" | "zap"; -function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) { +function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { case "bot": return ; case "brain": return ; + case "browser": + return ; + case "t3-code": + return ; case "check": return ; case "circle-alert": @@ -2378,21 +2560,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { }; } -function workEntryPreview( - workEntry: Pick, - workspaceRoot: string | undefined, -) { - if (workEntry.command) return workEntry.command; - if (workEntry.detail) return workEntry.detail; - if ((workEntry.changedFiles?.length ?? 0) === 0) return null; - const [firstPath] = workEntry.changedFiles ?? []; - if (!firstPath) return null; - const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); - return workEntry.changedFiles!.length === 1 - ? displayPath - : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; -} - function workEntryRawCommand( workEntry: Pick, ): string | null { @@ -2403,26 +2570,6 @@ function workEntryRawCommand( return rawCommand === workEntry.command.trim() ? null : rawCommand; } -function liveWorkEntryLabel( - workEntry: TimelineWorkEntry, - workspaceRoot: string | undefined, - active: boolean, -): string { - const command = workEntry.command?.trim(); - if (command) { - const program = commandProgramName(command); - const verb = active - ? "Running" - : workEntry.toolLifecycleStatus === "declined" - ? "Declined" - : "Ran"; - if (program) return `${verb} ${program}`; - return `${verb} command`; - } - - return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); -} - function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, @@ -2461,6 +2608,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { ) { return "message-circle"; } + const toolPresentation = resolveWorkEntryToolPresentation(workEntry); + if (toolPresentation) return toolPresentation.icon; const action = toolGroupAction(workEntry); if (action !== "other") return toolGroupSummaryIconName(action); @@ -2481,21 +2630,6 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { return workToneIcon(workEntry.tone).iconName; } -function capitalizePhrase(value: string): string { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return value; - } - return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; -} - -function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { - if (!workEntry.toolTitle) { - return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); - } - return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); -} - const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); /** @@ -2576,7 +2710,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time className="flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50" > - + {lead} {workflowName ? · {workflowName} : null} @@ -2618,15 +2752,39 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); - const [expanded, setExpanded] = useState(false); + const groupView = use(WorkGroupViewCtx); + const [expanded, setExpanded] = useState( + () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, + ); + const toggleExpanded = () => { + const next = !expanded; + if (groupView) { + groupView.onToggleEntry(); + if (next) groupView.state.expandedEntries.add(workEntry.id); + else groupView.state.expandedEntries.delete(workEntry.id); + } + setExpanded(next); + }; const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); + const toolPresentation = resolveWorkEntryToolPresentation(workEntry); const entryIconName = - showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry); - const previewText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); - const displayText = expanded && workEntry.command?.trim() ? "Command" : previewText; - const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); + showWarningIndicator || (showFailedIndicator && !toolPresentation) + ? "circle-alert" + : workEntryIconName(workEntry); + const previewText = workEntryDisplayLabel(workEntry, workspaceRoot); + const displayText = + !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; + const canExpand = + (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || + Boolean( + workEntryRawCommand(workEntry) || + workEntry.command?.trim() || + workEntry.detail?.trim() || + workEntry.changedFiles?.length, + ); + const expandedBody = expanded ? buildToolCallExpandedBody(workEntry, workspaceRoot) : null; const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef @@ -2635,7 +2793,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot, }) : null; - const canExpand = expandedBody !== null; const showDestructiveRowStyle = showFailedIndicator && (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry)); @@ -2658,7 +2815,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : workLogEntryIsToolLike(workEntry) ? "text-secondary-label" : "text-foreground/80"; - const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; const accessibleDisplayText = showFailedIndicator ? `${previewText}, tool call failed` : previewText; @@ -2668,11 +2824,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { tabIndex: 0 as const, "aria-label": accessibleDisplayText, "aria-expanded": expanded, - onClick: () => setExpanded((v) => !v), + onClick: toggleExpanded, onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - setExpanded((v) => !v); + toggleExpanded(); } }, } @@ -2690,12 +2846,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { >
- @@ -2706,6 +2861,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { {displayText}

+ {showFailedIndicator && toolPresentation ? ( + + ) : null} { ); }); - it("returns descriptor defaults when no selections are provided", () => { + it("uses descriptor defaults for display without dispatching them as overrides", () => { const state = getComposerProviderState({ provider: PROVIDER, model: MODEL, @@ -86,7 +86,7 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: PROVIDER, promptEffort: "high", - modelOptionsForDispatch: selections(["effort", "high"]), + modelOptionsForDispatch: undefined, }); }); @@ -165,9 +165,7 @@ describe("getComposerProviderState", () => { }); expect(state.promptEffort).toBe("high"); - expect(state.modelOptionsForDispatch).toEqual( - selections(["effort", "high"], ["contextWindow", "200k"], ["agent", "plan"]), - ); + expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "plan"])); }); it("drops the plan agent from dispatch when legacy plan mode is disabled", () => { @@ -219,7 +217,7 @@ describe("getComposerProviderState", () => { planModeEnabled: false, }); - expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "research"])); + expect(state.modelOptionsForDispatch).toBeUndefined(); }); it("returns undefined dispatch options when the model declares no descriptors", () => { diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 3e72b6ea6af2d..5da27505391df 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -6,7 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { - buildProviderOptionSelectionsFromDescriptors, + buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, @@ -94,7 +94,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com return { provider, promptEffort, - modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors), + modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( + descriptors, + modelOptions, + ), ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame", diff --git a/apps/web/src/components/chat/providerIconUtils.test.ts b/apps/web/src/components/chat/providerIconUtils.test.ts index 1426f82cd4f76..25e4dc1ca4f62 100644 --- a/apps/web/src/components/chat/providerIconUtils.test.ts +++ b/apps/web/src/components/chat/providerIconUtils.test.ts @@ -2,7 +2,8 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { OhMyPiIcon, OpenAI } from "../Icons"; -import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { PROVIDER_OPTIONS } from "../../session-logic"; +import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; describe("Oh My Pi provider presentation", () => { it("uses the Oh My Pi mark instead of the Codex fallback", () => { @@ -13,7 +14,7 @@ describe("Oh My Pi provider presentation", () => { }); it("advertises Oh My Pi as an available provider", () => { - expect(AVAILABLE_PROVIDER_OPTIONS).toContainEqual({ + expect(PROVIDER_OPTIONS).toContainEqual({ value: "ohMyPi", label: "Oh My Pi", available: true, diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index ed5bbfbd1c601..c5511ef6445fc 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -11,7 +11,6 @@ import { OpenCodeIcon, PiAgentIcon, } from "../Icons"; -import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, @@ -25,17 +24,6 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("ohMyPi")]: OhMyPiIcon, }; -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderDriverKind; - label: string; - available: true; - pickerSidebarBadge?: "new" | "soon"; -} { - return option.available; -} - -export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); - export type ModelEsque = { slug: string; name: string; diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 513c4f5498fb4..656ffbea81989 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -125,11 +125,6 @@ export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateAction return getDesktopUpdateActionError(result) !== null; } -export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean { - if (!state || state.status !== "error") return false; - return state.errorContext === "download" || state.errorContext === "install"; -} - export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { if (!state || !state.enabled) return false; return ( diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index b39cfd9ff1b53..203be64eebec1 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -50,9 +50,9 @@ describe("isLineInFileDiff", () => { describe("isFileDiffCollapsed", () => { const NO_TOGGLES: ReadonlySet = new Set(); - it("folds every file before the reader has touched anything", () => { - expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(true); - expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(true); + it("opens every file before the reader has touched anything", () => { + expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(false); + expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(false); }); it("opens every file once the toolbar has asked for it", () => { @@ -66,12 +66,12 @@ describe("isFileDiffCollapsed", () => { expect(isFileDiffCollapsed("b.ts", "folded", NO_TOGGLES)).toBe(true); }); - it("keeps a file the reader opened open as the next slice arrives", () => { - // The file keys grow with every slice, so the answer for one already open must not depend on - // how many of them there are by then. + it("keeps a file the reader folded closed as the next slice arrives", () => { + // The file keys grow with every slice, so the answer for one already folded must not depend + // on how many of them there are by then. const toggled = new Set(["b.ts"]); - expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(false); - expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(true); + expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(true); + expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(false); }); it("still answers to a toggle after either toolbar press", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index b3c19c4fe9c23..a286a4552cb6d 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -30,15 +30,14 @@ export type DiffFoldOverride = "expanded" | "folded" | null; * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from * what the toolbar last said rather than as the set of folded files: a file that has not loaded * yet cannot be in a set, and would otherwise land expanded moments after the reader folded - * everything. Folded is the starting point whatever the change's size, because laying out every - * file of it costs the reader the seconds before the tab is usable and buries the file they came - * for among the ones they did not. + * everything. Files start expanded so opening the Code tab immediately shows the change; the + * reader can still fold individual files or the whole diff from the toolbar. */ export function isFileDiffCollapsed( fileKey: string, foldOverride: DiffFoldOverride, toggledFileKeys: ReadonlySet, ): boolean { - const foldedByDefault = foldOverride !== "expanded"; + const foldedByDefault = foldOverride === "folded"; return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 16246d272e27f..683454938e7e4 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -11,6 +11,7 @@ import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-ro import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { useEnvironments } from "../../state/environments"; +import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarArtworkVariant, @@ -86,7 +87,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { )} to="/" > - + - - - ); -} - function SidebarUtilityItem({ icon, label, diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 845e0994fb46d..4a5b7022adc72 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -221,8 +221,6 @@ export function isCollapsedCursorAdjacentToInlineToken( return false; } -export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; - export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fe1dab1997630..6ef8c65188582 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4117,11 +4117,3 @@ export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): vo } clearBackgroundDraftSubmissionByRef(threadRef); } - -export function finalizePromotedDraftThreadsByRef( - serverThreadRefs: Iterable, -): void { - for (const threadRef of serverThreadRefs) { - finalizePromotedDraftThreadByRef(threadRef); - } -} diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index c00bdef6df37e..65f1042552e80 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -419,14 +419,6 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } -export function isPreviewToggleShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "preview.toggle", options); -} - export function isPreviewRefreshShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -435,14 +427,6 @@ export function isPreviewRefreshShortcut( return matchesCommandShortcut(event, keybindings, "preview.refresh", options); } -export function isPreviewFocusUrlShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "preview.focusUrl", options); -} - export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 3ba24cb2c2b43..4e2ee139cd855 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -25,28 +25,6 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { readonly updatedAt: string; }; -/** Map a provider driver kind to a user-facing display name. */ -export function formatProviderDisplayName(provider: string | null | undefined): string { - if (!provider) return "This agent"; - switch (provider) { - case "claudeAgent": - case "claude": - return "Claude"; - case "codex": - return "Codex"; - case "cursor": - return "Cursor"; - case "opencode": - return "OpenCode"; - default: { - // Title-case unknown driver kinds so they read reasonably. - const trimmed = provider.replace(/Agent$/i, "").trim(); - if (trimmed.length === 0) return provider; - return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); - } - } -} - export function deriveLatestContextWindowSnapshot( activities: ReadonlyArray, ): ContextWindowSnapshot | null { diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index d96021aeb5e0f..f4d81dfa0e538 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; import { type CxOptions, cx } from "class-variance-authority"; import * as Encoding from "effect/Encoding"; import { twMerge } from "tailwind-merge"; @@ -16,10 +16,6 @@ export function isWindowsPlatform(platform: string): boolean { return /^win(dows)?/i.test(platform); } -export function isLinuxPlatform(platform: string): boolean { - return /linux/i.test(platform); -} - export function normalizeSearchText(value: string): string { return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ").trim(); } @@ -46,8 +42,6 @@ export function randomUUID(): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -export const newCommandId = (): CommandId => CommandId.make(randomUUID()); - export const newProjectId = (): ProjectId => ProjectId.make(randomUUID()); export const newThreadId = (): ThreadId => ThreadId.make(randomUUID()); diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 3c6dd268640e2..ec44fcc222925 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -38,6 +38,10 @@ class FakeElement { return this.childNodes.map((child) => child.textContent).join(""); } + get children(): ReadonlyArray { + return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement); + } + append(...children: Array): this { this.childNodes.push(...children); return this; @@ -50,6 +54,34 @@ class FakeElement { hasAttribute(name: string): boolean { return Object.hasOwn(this.attributes, name); } + + closest(): FakeElement | null { + return null; + } + + /** Supports only the selectors markdown-clipboard actually asks for. */ + querySelector(selector: string): FakeElement | null { + const childOnly = selector.startsWith(":scope > "); + const target = childOnly ? selector.slice(":scope > ".length) : selector; + const matches = (element: FakeElement): boolean => { + if (target === 'input[type="checkbox"]') { + return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox"; + } + return element.tagName === target.toUpperCase(); + }; + const search = (parent: FakeElement): FakeElement | null => { + for (const child of parent.childNodes) { + if (!(child instanceof FakeElement)) continue; + if (matches(child)) return child; + if (!childOnly) { + const nested = search(child); + if (nested) return nested; + } + } + return null; + }; + return search(this); + } } function asNode(element: FakeElement): Node { @@ -61,6 +93,21 @@ function shikiCodeLine(text: string): FakeElement { return new FakeElement("SPAN", ["line"]).append(token); } +/** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */ +function renderedCodeBlock(lines: ReadonlyArray): FakeElement { + const code = new FakeElement("CODE"); + lines.forEach((line, index) => { + if (index > 0) code.append(new FakeText("\n")); + code.append(shikiCodeLine(line)); + }); + return new FakeElement("DIV", ["chat-markdown-codeblock"]).append( + new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append( + new FakeText("sh"), + ), + new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)), + ); +} + describe("serializeRenderedMarkdownFragment", () => { beforeEach(() => { vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); @@ -125,6 +172,82 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + it("keeps fences when a bare list item sits alongside the code block", () => { + // serializeListItem emits "- " for an item with no text, so the item is + // content the plain-code path would drop. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI")), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```"); + }); + + it("keeps fences when a checkbox-only task item sits alongside the code block", () => { + // The checkbox is a skipped tag, so the item renders no text of its own, but + // it still carries the task state. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append( + new FakeElement("LI").append(new FakeElement("INPUT", [], { type: "checkbox" })), + ), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "- [ ]\n\n```\npnpm test\n```", + ); + }); + + it("still drops fences for a code block that is the whole list item", () => { + // The item only wraps the block, so a selection that never left the pre + // would drop the marker too. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI").append(renderedCodeBlock(["pnpm test"]))), + new FakeText("\n"), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("pnpm test"); + }); + + it("keeps fences when a file chip sits alongside the code block", () => { + // The chip renders as a button, a skipped tag, but its data-markdown-copy + // still contributes markdown, so the block is not the only visible content. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["pnpm test"]), + new FakeText("\n"), + new FakeElement("BUTTON", [], { "data-markdown-copy": "`src/foo.ts`" }), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "```\npnpm test\n```\n\n`src/foo.ts`", + ); + }); + + it("omits fences when a selection past the last line drags in the whole code block", () => { + // Dragging over the final newline ends the range after the pre, so the + // fragment carries the block plus the empty head of the next paragraph. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]), + new FakeText("\n"), + new FakeElement("P").append(new FakeText("")), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN", + ); + }); + + it("still fences a code block copied alongside prose", () => { + const container = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Run this:")), + renderedCodeBlock(["gh workflow run Deploy --ref main"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "Run this:\n\n```\ngh workflow run Deploy --ref main\n```", + ); + }); + it("uses a rendered card's explicit Markdown copy representation", () => { const card = new FakeElement("DIV", [], { "data-markdown-copy": "Hello World (Document template)\n\n", diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a188ca..4a96c8b31d135 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -256,6 +256,73 @@ function serializeNode(node: Node): string { } } +/** + * Tracks whether a fragment carries exactly one code block and nothing else a + * reader would see. + */ +interface SoleCodeBlockScan { + pre: Element | null; + other: boolean; +} + +function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { + for (const child of node.childNodes) { + if (scan.other) return; + if (child.nodeType === Node.TEXT_NODE) { + if ((child.textContent ?? "").trim().length > 0) scan.other = true; + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const element = child as Element; + // Mirrors serializeNode's order: an element carrying markdown of its own + // still contributes it even when its tag is otherwise skipped, as a file + // chip rendered as a button does. + if (element.hasAttribute("data-markdown-details")) { + scan.other = true; + continue; + } + const markdownCopy = element.getAttribute("data-markdown-copy"); + if (markdownCopy !== null) { + if (markdownCopy.trim().length > 0) scan.other = true; + continue; + } + if (isSkippedElement(element)) continue; + if (element.tagName === "PRE") { + if (scan.pre) scan.other = true; + else scan.pre = element; + continue; + } + if (element.tagName === "IMG" || element.tagName === "HR") { + scan.other = true; + continue; + } + if (element.tagName === "LI") { + // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item, + // so an item that does not hold the block carries content of its own even + // when it renders no text. An item that wraps the block is just the + // structure around it, and a pre-only selection would drop the marker too. + const preBeforeItem = scan.pre; + scanForSoleCodeBlock(element, scan); + if (!scan.other && scan.pre === preBeforeItem) scan.other = true; + continue; + } + scanForSoleCodeBlock(element, scan); + } +} + +/** + * A drag that ends on a block's final newline pulls the closing `pre` into the + * range, so the fragment holds the whole block even though the user only + * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose + * only visible content is one code block copies as plain code, matching a + * selection that never left the `pre`. + */ +function soleCodeBlock(container: Node): Element | null { + const scan: SoleCodeBlockScan = { pre: null, other: false }; + scanForSoleCodeBlock(container, scan); + return scan.other ? null : scan.pre; +} + /** Collapses serializer spacing artifacts without touching fenced code content. */ function tidyMarkdown(markdown: string): string { return markdown @@ -268,6 +335,8 @@ function tidyMarkdown(markdown: string): string { } export function serializeRenderedMarkdownFragment(container: Node): string { + const codeBlock = soleCodeBlock(container); + if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, ""); return tidyMarkdown(serializeChildren(container)); } diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index d0a701766dd16..a5623be4d0fec 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -85,22 +85,3 @@ export function useThreadDiscoveredPorts(input: { [input.threadId, ports], ); } - -export function useTerminalDiscoveredPorts(input: { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; - readonly terminalId: string | null; -}): ReadonlyArray { - const ports = useDiscoveredPorts(input.environmentId); - return useMemo( - () => - input.threadId && input.terminalId - ? ports.filter( - (port) => - port.terminal?.threadId === input.threadId && - port.terminal.terminalId === input.terminalId, - ) - : EMPTY_PORTS, - [input.terminalId, input.threadId, ports], - ); -} diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index ef60d554dd783..3858cf7e04618 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -299,17 +299,6 @@ export function getProviderInstanceEntry( return deriveProviderInstanceEntries(providers).find((entry) => entry.instanceId === instanceId); } -/** - * Model list for a specific instance. Returns `[]` when the instance isn't - * present so callers don't have to thread optionality through render code. - */ -export function getProviderInstanceModels( - providers: ReadonlyArray, - instanceId: ProviderInstanceId, -): ReadonlyArray { - return getProviderInstanceEntry(providers, instanceId)?.models ?? []; -} - /** * Default model slug for a specific instance: its declared built-in default, * then its first built-in model, then any model it reports, then the driver-level default. Custom diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 2f640d582307a..b1bed7020e62f 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -38,14 +38,6 @@ export function getProviderSnapshot( return providers.find((candidate) => candidate.instanceId === defaultInstanceId); } -export function getProviderDisplayName( - providers: ReadonlyArray, - provider: ProviderDriverKind, -): string { - const snapshot = getProviderSnapshot(providers, provider); - return snapshot?.displayName?.trim() || formatProviderDriverKindLabel(provider); -} - export function getProviderInteractionModeToggle( providers: ReadonlyArray, provider: ProviderDriverKind, @@ -53,16 +45,6 @@ export function getProviderInteractionModeToggle( return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; } -export function isProviderEnabled( - providers: ReadonlyArray, - provider: ProviderDriverKind, -): boolean { - if (providers.length === 0) { - return true; - } - return getProviderSnapshot(providers, provider)?.enabled ?? false; -} - // Resolve an instance selection to the correlated live driver. If the // instance is absent, fall back to a live enabled provider instead of // inferring a driver from the missing instance id. diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts deleted file mode 100644 index 619e38ccbfc2e..0000000000000 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -// Regression coverage compares the onboarding header with the shared titlebar contract. -import * as NodeFS from "node:fs"; - -import { describe, expect, it } from "vite-plus/test"; - -describe("hosted static onboarding header", () => { - it("uses the shared workspace topbar geometry", () => { - const routeSource = NodeFS.readFileSync(new URL("./_chat.index.tsx", import.meta.url), "utf8"); - const onboardingStart = routeSource.indexOf("function HostedStaticOnboardingState()"); - const onboardingEnd = routeSource.indexOf('', onboardingStart); - - expect(onboardingStart).toBeGreaterThanOrEqual(0); - expect(onboardingEnd).toBeGreaterThan(onboardingStart); - - const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - - expect(onboardingHeader).toContain(" location.pathname }); + const report = useMemo(() => errorReport(error, pathname), [error, pathname]); return (
@@ -292,22 +296,32 @@ function RootRouteErrorView({ error, reset }: ErrorComponentProps) { +
-
- - Show error details - Hide error details - -
-            {details}
+        
+

Error report

+
+            {report}
           
-
+
); } +/** Copies the full error report and swaps to a check mark for a moment as confirmation. */ +function CopyErrorButton({ report }: { report: string }) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-report" }); + + return ( + + ); +} + function errorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; @@ -336,6 +350,29 @@ function errorDetails(error: unknown): string { } } +const MAX_ERROR_CAUSE_DEPTH = 5; + +/** + * Full error text for bug reports: app build, page path, time, then the stack + * and any cause chain. Takes the pathname only so tokens in the query never + * land on the clipboard. + */ +function errorReport(error: unknown, pathname: string): string { + const lines = [ + `${APP_DISPLAY_NAME} ${APP_VERSION}`, + `Path: ${pathname}`, + `Time: ${new Date().toISOString()}`, + "", + errorDetails(error), + ]; + let cause = error instanceof Error ? error.cause : undefined; + for (let depth = 0; cause !== undefined && depth < MAX_ERROR_CAUSE_DEPTH; depth += 1) { + lines.push("", "Caused by:", errorDetails(cause)); + cause = cause instanceof Error ? cause.cause : undefined; + } + return lines.join("\n"); +} + function AuthenticatedTracingBootstrap() { useEffect(() => { void configureClientTracing(); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 08cc426caadcb..713457658e931 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -7,6 +7,7 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation"; import { deriveActiveWorkStartedAt, @@ -1250,6 +1251,36 @@ describe("deriveWorkLogEntries", () => { expect(entry?.toolData).toEqual(item); }); + it.each([ + ["inProgress", "Clicking in the preview browser"], + ["completed", "Clicked in the preview browser"], + ["failed", "Failed to click in the preview browser"], + ] as const)( + "preserves Claude MCP identity behind generic titles while %s", + (status, displayName) => { + const data = { + toolName: "mcp__t3_code__preview_click", + input: { selector: "#submit" }, + ...(status === "inProgress" + ? {} + : { result: { type: "tool_result", is_error: status === "failed", content: "Result" } }), + }; + const [entry] = deriveWorkLogEntries([ + makeActivity({ + kind: status === "inProgress" ? "tool.updated" : "tool.completed", + summary: "MCP tool call", + payload: { itemType: "mcp_tool_call", title: "MCP tool call", status, data }, + }), + ]); + + expect(entry).toMatchObject({ toolTitle: "MCP tool call", toolData: data }); + expect(resolveWorkEntryToolPresentation(entry!)).toEqual({ + displayName, + icon: "browser", + }); + }, + ); + it("keeps MCP payloads while collapsing lifecycle updates", () => { const item = { type: "mcpToolCall", @@ -1283,6 +1314,9 @@ describe("deriveWorkLogEntries", () => { const [entry] = deriveWorkLogEntries(activities); expect(entry?.toolData).toEqual(item); expect(entry?.toolCallId).toBe("call-1"); + expect(resolveWorkEntryToolPresentation(entry!)?.displayName).toBe( + "Took a snapshot of the preview page", + ); }); it("collapses interleaved lifecycle updates by tool call id", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 1d32e2e295d2d..8d31b3f894162 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -976,8 +976,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo } if (itemType === "mcp_tool_call") { const data = asRecord(payload?.data); - if (data?.item !== undefined) { - entry.toolData = data.item; + const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; + if (toolData !== undefined) { + entry.toolData = toolData; } } if (itemType) { diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 7e4b2b203ec43..f67eb943e3fbc 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1521,8 +1521,6 @@ export class ThemeLibraryStorageError extends Schema.TaggedErrorClass, themes: ReadonlyArray, diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index b996ccbed8868..7b2b13eef6364 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -73,6 +73,79 @@ authenticated. - `vp run dist:desktop:win`: Builds a Windows NSIS installer into `./release`. `:arm64` and `:x64` variants exist. +### Linux AppImage prerequisites + +Linux AppImage packaging compiles the Rust resource monitor. Install a Rust toolchain, the standard +C/C++ build tools, and ImageMagick before running `vp run dist:desktop:linux`. + +Ubuntu and Debian: + +```bash +sudo apt-get update +sudo apt-get install cargo rustc build-essential imagemagick +``` + +Fedora: + +```bash +sudo dnf install rust cargo gcc gcc-c++ make ImageMagick +``` + +Arch Linux: + +```bash +sudo pacman -S rust base-devel imagemagick +``` + +The artifact script checks these capabilities before starting the web and desktop builds. If +anything is unavailable, it reports every failed check together and prints the Ubuntu/Debian +installation command. The checks verify that the required executables are available and that the +requested Rust standard library target is installed. + +### macOS DMG prerequisites + +Install the Xcode Command Line Tools and Rust before building a DMG: + +```bash +xcode-select --install +``` + +Install Rust from [rustup.rs](https://rustup.rs). The artifact script checks Cargo, Clang, Make, +`sips`, and `iconutil`. Universal builds additionally require `lipo`. It also verifies that Rust +has every requested target; add missing targets with: + +```bash +rustup target add aarch64-apple-darwin x86_64-apple-darwin +``` + +Unsigned local builds need no Apple credentials. Builds using `--signed` additionally require the +certificate, provisioning profile, team ID, and notarization configuration described below. + +### Windows installer prerequisites + +Install Rust from [rustup.rs](https://rustup.rs), Python 3, and Visual Studio Build Tools. In the +Visual Studio Installer, select **Desktop development with C++** and include: + +- MSVC x64/x86 build tools +- A Windows 10 or Windows 11 SDK +- MSVC Spectre-mitigated libraries + +ARM64 installers require the corresponding MSVC ARM64 build tools and Spectre-mitigated libraries +instead of the x64/x86 components. + +Add the Rust target matching the installer architecture: + +```powershell +rustup target add x86_64-pc-windows-msvc +# For an arm64 installer: +rustup target add aarch64-pc-windows-msvc +``` + +Windows supplies `tar.exe`; it is checked when `--wsl-prebuild` makes the artifact include the WSL +runtime. NSIS is downloaded by electron-builder and does not need a separate installation. +Unsigned local builds need no Azure credentials. Builds using `--signed` additionally require the +Azure Trusted Signing configuration described below. + ### Desktop `.dmg` packaging notes - Default build is unsigned/not notarized for local sharing. diff --git a/docs/user/composer.md b/docs/user/composer.md index 6d1b8a47863f1..326eb59ce0b03 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -52,6 +52,16 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list when starting a thread or changing an existing thread's model. +## Model defaults + +T3 Code remembers the last provider, model, and model options you selected and reuses that +selection for new threads. A model configured in a project's settings overrides the remembered +selection for that project; resetting the project setting returns it to the remembered selection. + +Model options shown as provider defaults remain display values until you choose them in T3 Code. +T3 Code only sends options you selected explicitly, so an unset reasoning level or service tier can +still come from the provider's own configuration. + ## Quote an assistant response On web and desktop, select text in an assistant response, then choose **Cite in composer** from the diff --git a/docs/user/tool-activity.md b/docs/user/tool-activity.md new file mode 100644 index 0000000000000..67a3049016e31 --- /dev/null +++ b/docs/user/tool-activity.md @@ -0,0 +1,18 @@ +# Tool activity + +Open a tool-group summary in the conversation to see its individual calls. Each row has an icon; +select a row to inspect its details. Select the group summary again to collapse it. + +Long groups scroll inside a bounded area without expanding the whole conversation. Faded edges +indicate more calls above or below. Short groups use only the space they need. +Collapsing and reopening a group preserves your reading position and any open call details. + +Recognized T3 tools use descriptive labels in both the running summary and individual rows. +Labels follow the call's state, such as "Clicking" while running and "Clicked" after success. +Failed, declined, and stopped calls say what happened without implying success. +Preview browser actions use a globe icon. Other T3 tools keep the T3 mark. +Group summaries count browser actions separately, such as "Used browser 18 times" or +"Ran 4 commands and used browser 15 times". Browser-only groups also use a globe icon. + +Command summaries show the program inside a shell wrapper, such as "Running vp" for +`/bin/zsh -lc 'vp test run'`. Expanded rows keep the full command. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index af61d66228cfd..f9cc9777213ac 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -198,6 +198,10 @@ "./work-log/command-label": { "types": "./src/work-log/commandLabel.ts", "default": "./src/work-log/commandLabel.ts" + }, + "./work-log/scroll-anchor": { + "types": "./src/work-log/scrollAnchor.ts", + "default": "./src/work-log/scrollAnchor.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 29dcf4ffc60bf..62d0319db1bc6 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,3 +1,4 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -5,9 +6,33 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, } from "./providerSkills.ts"; +const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + workspaceSnapshots: [ + { + cwd: "/workspace/project-a", + checkedAt: "2026-01-01T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }], + }, + ], +} satisfies ServerProvider; + describe("formatProviderSkillDisplayName", () => { it("prefers the provider display name", () => { expect( @@ -211,3 +236,19 @@ describe("resolveProviderSkillSourceKind", () => { ).toBe("other"); }); }); + +describe("workspace provider snapshots", () => { + it("uses the cwd snapshot after a provider session has populated it", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }, + ]); + expect(resolveProviderSlashCommandsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project" }, + ]); + }); + + it("keeps the machine snapshot before this cwd has a provider snapshot", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); + expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); + }); +}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index f90ffe142e4a3..b80cd58038090 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -1,4 +1,8 @@ -import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; +import type { + ServerProvider, + ServerProviderSkill, + ServerProviderSlashCommand, +} from "@t3tools/contracts"; export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; @@ -98,3 +102,25 @@ export function resolveProviderSkillSourceKind( return "other"; } } + +function resolveProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string | null | undefined, +) { + if (!cwd) return undefined; + return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); +} + +export function resolveProviderSkillsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["skills"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills; +} + +export function resolveProviderSlashCommandsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["slashCommands"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 8cd570bfba22a..681dd4344b022 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -849,7 +849,8 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverRefreshProviders, concurrency: { mode: "singleFlight", - key: ({ environmentId }) => environmentId, + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.instanceId ?? null, input.cwd ?? null]), }, }), hermesGatewayCreateEnrollment: createEnvironmentRpcCommand(runtime, { diff --git a/packages/client-runtime/src/work-log/commandLabel.test.ts b/packages/client-runtime/src/work-log/commandLabel.test.ts new file mode 100644 index 0000000000000..1cee96e2f2b02 --- /dev/null +++ b/packages/client-runtime/src/work-log/commandLabel.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { commandProgramName } from "./commandLabel.ts"; + +describe("commandProgramName", () => { + it.each([ + ["/bin/zsh -lc 'vp test run apps/web/src/session-logic.test.ts'", "vp"], + ["/bin/zsh -lc 'git diff --check'", "git"], + ['/bin/zsh -lc "npx -y react-doctor@latest apps/web"', "npx"], + ["/bin/zsh -lc 'rg -n \"registerHooks|worker\" apps/web/src'", "rg"], + ["/bin/bash --noprofile --norc -l -c 'sed -n 1,270p file.ts'", "sed"], + ["/bin/bash -o pipefail -lc 'vp test run'", "vp"], + ["/bin/bash --rcfile /tmp/config -c 'git status'", "git"], + ["sh -ec 'node scripts/check.js'", "node"], + ["fish --command 'rg --files'", "rg"], + ["zsh -lc 'CI=1 env -u DEBUG sudo -u root vp test run'", "vp"], + ["env CI=1 /bin/zsh -lc '\"/Applications/My Tools/bin/check\" --verbose'", "check"], + ["bash -lc \"zsh -c 'git status'\"", "git"], + ['"C:\\Program Files\\Git\\bin\\bash.exe" -lc "git status"', "git"], + ])("unwraps shell scripts without executing them: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["vp test run", "vp"], + ["sudo -u root pnpm test", "pnpm"], + ["env --split-string='CI=1 node scripts/check.js'", "node"], + ['"C:\\Program Files\\nodejs\\node.exe" script.js', "node.exe"], + ["/bin/zsh", "zsh"], + ["/bin/bash -l", "bash"], + ["zsh script.sh -c 'git status'", "zsh"], + ["bash -- -c 'git status'", "bash"], + ["bash --rcfile config.sh", "bash"], + ["my-shell -c 'git status'", "my-shell"], + ])("preserves ordinary programs and actual shell launches: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + "", + "zsh -lc", + "zsh -lc ''", + "zsh -lc 'git status", + "zsh -lc 'env'", + 'zsh -lc "git \\\"', + ])("falls back for missing or malformed scripts: %s", (command) => { + expect(commandProgramName(command)).toBeNull(); + }); + + it("bounds nested shell unwrapping", () => { + let command = "git status"; + for (let depth = 0; depth < 9; depth += 1) { + command = `sh -c '${command.replaceAll("'", "'\\''")}'`; + } + expect(commandProgramName(command)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts index b0858725a3f78..d3cd945bf3f51 100644 --- a/packages/client-runtime/src/work-log/commandLabel.ts +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -1,5 +1,21 @@ type CommandWrapper = "env" | "sudo"; +const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]); +const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]); + +function shellCommandArgumentIndex(tokens: ReadonlyArray, start: number): number | null { + for (let index = start; index < tokens.length; index += 1) { + const option = tokens[index]!; + if (option === "--" || !option.startsWith("-")) return null; + if (SHELL_OPTIONS_WITH_VALUE.has(option)) { + index += 1; + continue; + } + if (option === "--command" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(option)) return index + 1; + } + return null; +} + const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), @@ -162,7 +178,14 @@ export function commandProgramName(command: string, depth = 0): string | null { } return null; } - return token.split(/[\\/]/).at(-1) || null; + if (tokenProgram && SHELL_PROGRAMS.has(tokenProgram.replace(/\.exe$/i, "").toLowerCase())) { + const scriptIndex = shellCommandArgumentIndex(tokens, index + 1); + if (scriptIndex !== null) { + const script = tokens[scriptIndex]; + return script ? commandProgramName(script, depth + 1) : null; + } + } + return tokenProgram || null; } return null; diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 8d179c417b727..be590287a3046 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -6,10 +6,218 @@ import { commandDetailRepeatsCommand, extractCommandOutputText, resolveViewedImageAsset, + resolveWorkEntryToolPresentation, + summarizeToolGroup, toolGroupAction, + toolGroupSummaryKind, + type WorkLogPresentationEntry, workEntryViewedImagePath, } from "./presentation.js"; +describe("resolveWorkEntryToolPresentation", () => { + it.each([ + "mcp__t3-code__preview_click", + "mcp__t3_code__preview_click", + "mcp__t3code__preview_click", + "T3-code.preview_click", + "t3-code · preview_click completed", + "t3_code/preview_click", + "preview_click", + ])("recognizes browser tool names across providers: %s", (label) => { + expect(resolveWorkEntryToolPresentation({ label })).toEqual({ + displayName: "Click in the preview browser", + icon: "browser", + }); + }); + + it("uses structured MCP identity when the provider supplies a custom title", () => { + expect( + resolveWorkEntryToolPresentation({ + label: "Tool call complete", + toolTitle: "Inspect the current page", + toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } }, + }), + ).toEqual({ displayName: "Take a snapshot of the preview page", icon: "browser" }); + }); + + it.each([ + ["inProgress", "Clicking in the preview browser"], + ["completed", "Clicked in the preview browser"], + ["failed", "Failed to click in the preview browser"], + ["declined", "Declined to click in the preview browser"], + ["stopped", "Stopped clicking in the preview browser"], + ["unknown", "Click in the preview browser"], + ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => { + expect( + resolveWorkEntryToolPresentation({ + label: "T3-code.preview_click", + toolLifecycleStatus, + }), + ).toEqual({ displayName, icon: "browser" }); + }); + + it("uses the summary's state only when the provider omitted a lifecycle status", () => { + const entry = { label: "T3-code.preview_click" }; + expect(resolveWorkEntryToolPresentation(entry, "inProgress")?.displayName).toBe( + "Clicking in the preview browser", + ); + expect(resolveWorkEntryToolPresentation(entry, "completed")?.displayName).toBe( + "Clicked in the preview browser", + ); + expect( + resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "completed" }, "inProgress") + ?.displayName, + ).toBe("Clicked in the preview browser"); + expect( + resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "failed" }, "completed") + ?.displayName, + ).toBe("Failed to click in the preview browser"); + }); + + it.each([ + ["preview_type", "Typing in the preview browser", "Typed in the preview browser"], + [ + "preview_set_appearance", + "Setting preview browser appearance", + "Set preview browser appearance", + ], + [ + "preview_snapshot", + "Taking a snapshot of the preview page", + "Took a snapshot of the preview page", + ], + [ + "preview_recording_stop", + "Stopping recording the preview browser", + "Stopped recording the preview browser", + ], + ["t3_thread_read", "Reading a T3 thread", "Read a T3 thread"], + ["t3_thread_send", "Sending to a T3 thread", "Sent to a T3 thread"], + [ + "t3_worktree_handoff", + "Handing off thread to a git worktree", + "Handed off thread to a git worktree", + ], + ])("preserves verb forms and the rest of %s's label", (tool, running, completed) => { + const entry = { label: `t3-code.${tool}` }; + expect( + resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "inProgress" }) + ?.displayName, + ).toBe(running); + expect( + resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "completed" })?.displayName, + ).toBe(completed); + }); + + it("keeps T3 branding for non-browser tools and falls back to the original tool label", () => { + expect( + resolveWorkEntryToolPresentation({ + label: "mcp__t3_code__task_status", + toolTitle: "Check the child task", + }), + ).toEqual({ displayName: "Get delegated task status", icon: "t3-code" }); + }); + + it("does not brand unknown tools or another server's matching tool name", () => { + for (const label of [ + "mcp__github__preview_click", + "t3-code.unknown_tool", + "t3-code.toString", + "Search files", + ]) { + expect(resolveWorkEntryToolPresentation({ label })).toBeNull(); + } + expect( + resolveWorkEntryToolPresentation({ + label: "preview_click", + toolData: { server: "another-server", tool: "preview_click" }, + }), + ).toBeNull(); + }); +}); + +describe("browser group summaries", () => { + const browserEntry: WorkLogPresentationEntry = { + label: "MCP tool call", + toolData: { server: "t3-code", tool: "preview_click" }, + itemType: "mcp_tool_call", + toolLifecycleStatus: "completed", + tone: "tool", + }; + const commandEntry: WorkLogPresentationEntry = { + label: "Ran command", + command: "/bin/bash -lc 'vp test run'", + itemType: "command_execution", + toolLifecycleStatus: "completed", + tone: "tool", + }; + + it.each([1, 18])("counts %s browser calls separately from generic tools", (count) => { + const entries = Array.from({ length: count }, (_, index) => ({ + ...browserEntry, + toolCallId: `browser-${index}`, + })); + expect(summarizeToolGroup(entries)).toBe( + `Used browser ${count} ${count === 1 ? "time" : "times"}`, + ); + expect(toolGroupSummaryKind(entries)).toBe("browser"); + }); + + it("combines command and browser counts in a single sentence", () => { + const entries = [ + ...Array.from({ length: 4 }, () => commandEntry), + ...Array.from({ length: 15 }, () => browserEntry), + ]; + expect(summarizeToolGroup(entries)).toBe("Ran 4 commands and used browser 15 times"); + expect(toolGroupSummaryKind(entries)).toBe("mixed"); + }); + + it("preserves first-seen action ordering alongside non-browser tools", () => { + expect( + summarizeToolGroup([ + browserEntry, + commandEntry, + { + ...browserEntry, + toolData: { server: "t3-code", tool: "task_status" }, + }, + ]), + ).toBe("Used browser 1 time, ran 1 command, and used 1 tool"); + }); + + it("recognizes Claude browser identity without treating script metadata as a shell command", () => { + expect( + summarizeToolGroup([ + { + ...browserEntry, + command: "node inspect-page.js", + toolData: { toolName: "mcp__t3_code__preview_evaluate" }, + }, + ]), + ).toBe("Used browser 1 time"); + }); + + it("keeps foreign tools and web searches out of the browser count", () => { + expect( + summarizeToolGroup([ + browserEntry, + { + ...browserEntry, + label: "preview_click", + toolData: { server: "another-server", tool: "preview_click" }, + }, + { label: "Search", tone: "tool", itemType: "web_search" }, + ]), + ).toBe("Used browser 1 time, used 1 tool, and searched the web 1 time"); + }); + + it("keeps browser screenshots in the browser count while preserving their image path", () => { + const entry = { ...browserEntry, viewedImagePath: "/workspace/page.png" }; + expect(summarizeToolGroup([entry])).toBe("Used browser 1 time"); + expect(workEntryViewedImagePath(entry)).toBe("/workspace/page.png"); + }); +}); + describe("command work-log details", () => { it("extracts Claude result blocks and projected output", () => { expect( diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 61c49d9ce0eff..4b9a20cf0406b 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -17,6 +17,7 @@ export function isWorktreeSetupActivity(kind: string): boolean { export interface WorkLogPresentationEntry { readonly label: string; readonly toolTitle?: string; + readonly toolData?: unknown; readonly tone: "thinking" | "tool" | "info" | "error"; readonly command?: string; readonly detail?: string; @@ -35,6 +36,7 @@ export type ToolGroupAction = | "read" | "edit" | "command" + | "browser" | "code-search" | "search" | "other" @@ -51,6 +53,103 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +const T3_MCP_TOOL_LABELS: Record< + string, + readonly [action: string, running: string, completed: string, detail: string] +> = { + orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], + delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], + task_status: ["Get", "Getting", "Got", "delegated task status"], + task_cancel: ["Cancel", "Canceling", "Canceled", "delegated task"], + schedule_task: ["Schedule", "Scheduling", "Scheduled", "a recurring task"], + list_scheduled_tasks: ["List", "Listing", "Listed", "scheduled tasks"], + update_scheduled_task: ["Update", "Updating", "Updated", "a scheduled task"], + delete_scheduled_task: ["Delete", "Deleting", "Deleted", "a scheduled task"], + create_threads: ["Create", "Creating", "Created", "T3 threads"], + t3_thread_start: ["Start", "Starting", "Started", "a T3 thread"], + t3_thread_list: ["List", "Listing", "Listed", "T3 threads"], + t3_thread_read: ["Read", "Reading", "Read", "a T3 thread"], + t3_thread_send: ["Send", "Sending", "Sent", "to a T3 thread"], + t3_thread_wait: ["Wait", "Waiting", "Waited", "for a T3 thread"], + t3_thread_interrupt: ["Interrupt", "Interrupting", "Interrupted", "a T3 thread"], + t3_worktree_handoff: ["Hand off", "Handing off", "Handed off", "thread to a git worktree"], + t3_worktree_status: ["Get", "Getting", "Got", "thread worktree status"], + preview_status: ["Get", "Getting", "Got", "preview browser status"], + preview_open: ["Open", "Opening", "Opened", "a page in the preview browser"], + preview_navigate: ["Navigate", "Navigating", "Navigated", "the preview browser"], + preview_snapshot: [ + "Take a snapshot of", + "Taking a snapshot of", + "Took a snapshot of", + "the preview page", + ], + preview_click: ["Click", "Clicking", "Clicked", "in the preview browser"], + preview_press: ["Press", "Pressing", "Pressed", "a key in the preview browser"], + preview_type: ["Type", "Typing", "Typed", "in the preview browser"], + preview_scroll: ["Scroll", "Scrolling", "Scrolled", "the preview browser"], + preview_resize: ["Resize", "Resizing", "Resized", "the preview browser"], + preview_evaluate: ["Evaluate", "Evaluating", "Evaluated", "script in the preview browser"], + preview_wait_for: ["Wait", "Waiting", "Waited", "for the preview page"], + preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"], + preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"], + preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], +}; + +function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) { + if (!value) return null; + const name = normalizeCompactToolLabel(value).replace( + /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i, + "", + ); + if (!Object.hasOwn(T3_MCP_TOOL_LABELS, name)) return null; + + const [action, running, completed, detail] = T3_MCP_TOOL_LABELS[name]!; + const verb = + status === "inProgress" + ? running + : status === "completed" + ? completed + : status === "failed" + ? `Failed to ${action.toLowerCase()}` + : status === "declined" + ? `Declined to ${action.toLowerCase()}` + : status === "stopped" + ? `Stopped ${running.toLowerCase()}` + : action; + + return { + displayName: `${verb} ${detail}`, + icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const), + }; +} + +/** Resolves tool identity before choosing labels or icons in either client. */ +export function resolveWorkEntryToolPresentation( + entry: Pick, + fallbackStatus?: "inProgress" | "completed", +) { + const status = entry.toolLifecycleStatus ?? fallbackStatus; + const data = entry.toolData; + if (data !== null && typeof data === "object") { + if ( + "server" in data && + typeof data.server === "string" && + "tool" in data && + typeof data.tool === "string" + ) { + return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status); + } + if ("toolName" in data && typeof data.toolName === "string") { + return resolveT3McpToolPresentation(data.toolName, status); + } + } + + return ( + resolveT3McpToolPresentation(entry.toolTitle, status) ?? + resolveT3McpToolPresentation(entry.label, status) + ); +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -207,6 +306,7 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): } export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { + if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || @@ -312,6 +412,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { return `Changed ${count} ${count === 1 ? "file" : "files"}`; case "command": return `Ran ${count} ${count === 1 ? "command" : "commands"}`; + case "browser": + return `Used browser ${count} ${count === 1 ? "time" : "times"}`; case "search": return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; case "code-search": diff --git a/packages/client-runtime/src/work-log/scrollAnchor.test.ts b/packages/client-runtime/src/work-log/scrollAnchor.test.ts new file mode 100644 index 0000000000000..f5ea9a51632ba --- /dev/null +++ b/packages/client-runtime/src/work-log/scrollAnchor.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveWorkGroupScrollAnchor } from "./scrollAnchor.js"; + +describe("tool-group scroll anchors", () => { + const data = Array.from({ length: 18 }, (_, index) => ({ id: `call-${index}` })); + const state = { data, positionAtIndex: (index: number) => index * 33 }; + + it("returns to the top after a fast flick even when the cached start is five rows behind", () => { + const snapshot = { ...state, scroll: 0, start: 5 }; + expect(resolveWorkGroupScrollAnchor(snapshot)).toEqual({ + rowId: "call-0", + offsetWithinRow: 0, + scrollOffset: 0, + }); + }); + + it.each([ + { scroll: 302, start: 0, rowId: "call-9", offsetWithinRow: 5 }, + { scroll: 40, start: 12, rowId: "call-1", offsetWithinRow: 7 }, + { scroll: 165, start: 4, rowId: "call-5", offsetWithinRow: 0 }, + ])("uses the measured position at $scroll rather than cached row $start", (example) => { + expect(resolveWorkGroupScrollAnchor({ ...state, ...example })).toEqual({ + rowId: example.rowId, + offsetWithinRow: example.offsetWithinRow, + scrollOffset: example.scroll, + }); + }); + + it("preserves an offset within expanded output", () => { + const positions = [0, 33, 600]; + expect( + resolveWorkGroupScrollAnchor({ + data: [{ id: "first" }, { id: "output" }, { id: "last" }], + scroll: 153, + positionAtIndex: (index) => positions[index], + }), + ).toEqual({ rowId: "output", offsetWithinRow: 120, scrollOffset: 153 }); + }); + + it("does not preserve overscroll before the first row", () => { + expect(resolveWorkGroupScrollAnchor({ ...state, scroll: -20 })).toEqual({ + rowId: "call-0", + offsetWithinRow: 0, + scrollOffset: 0, + }); + }); + + it("skips incomplete layout snapshots instead of saving an invalid anchor", () => { + expect(resolveWorkGroupScrollAnchor({ ...state, data: [], scroll: 0 })).toBeUndefined(); + expect(resolveWorkGroupScrollAnchor({ ...state, scroll: Number.NaN })).toBeUndefined(); + expect( + resolveWorkGroupScrollAnchor({ ...state, scroll: 165, positionAtIndex: () => undefined }), + ).toBeUndefined(); + expect( + resolveWorkGroupScrollAnchor({ + ...state, + scroll: 165, + positionAtIndex: (index) => (index === 0 ? 0 : Number.NaN), + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/client-runtime/src/work-log/scrollAnchor.ts b/packages/client-runtime/src/work-log/scrollAnchor.ts new file mode 100644 index 0000000000000..3b403e080201a --- /dev/null +++ b/packages/client-runtime/src/work-log/scrollAnchor.ts @@ -0,0 +1,33 @@ +/** Save the row at the current offset; a virtualizer's cached visible range can lag a fling. */ +export function resolveWorkGroupScrollAnchor(state: { + readonly data: ReadonlyArray<{ readonly id: string }>; + readonly scroll: number; + readonly positionAtIndex: (index: number) => number | undefined; +}) { + if (state.data.length === 0 || !Number.isFinite(state.scroll)) return undefined; + const scrollOffset = Math.max(0, state.scroll); + let low = 0; + let high = state.data.length - 1; + let index = 0; + let rowTop = state.positionAtIndex(0); + if (rowTop === undefined || !Number.isFinite(rowTop)) return undefined; + + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const top = state.positionAtIndex(middle); + if (top === undefined || !Number.isFinite(top)) return undefined; + if (top <= scrollOffset) { + index = middle; + rowTop = top; + low = middle + 1; + } else { + high = middle - 1; + } + } + + return { + rowId: state.data[index]!.id, + offsetWithinRow: Math.max(0, scrollOffset - rowTop), + scrollOffset, + }; +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4ec0d01b133da..d6344ff6aa456 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -733,6 +733,8 @@ export const ProjectCreateCommand = Schema.Struct({ title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), + // Retained for older clients that sent an automatic create-time seed. The + // server ignores it; explicit project defaults use project.meta.update. defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index df6ef1f74c254..47f8d6cf499c7 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { @@ -390,6 +391,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv * refreshes. */ instanceId: Schema.optional(ProviderInstanceId), + cwd: Schema.optional(TrimmedNonEmptyString), }), success: ServerProviderUpdatedPayload, error: EnvironmentAuthorizationError, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 333e9affc9fa3..0a5ac1f5269c2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -110,6 +110,14 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderWorkspaceSnapshot = Schema.Struct({ + cwd: TrimmedNonEmptyString, + checkedAt: IsoDateTime, + slashCommands: Schema.Array(ServerProviderSlashCommand), + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; + /** * Availability of a configured provider instance from the runtime's POV. * @@ -206,6 +214,7 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index a65a60fa4cc76..da779442c9c7b 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -3,6 +3,7 @@ import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, + buildExplicitProviderOptionSelectionsFromDescriptors, buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, @@ -111,6 +112,22 @@ describe("descriptor helpers", () => { ]); }); + it("builds dispatch options only from explicit selections", () => { + const descriptors = getProviderOptionDescriptors({ + caps: codexCaps, + selections: [{ id: "fastMode", value: true }], + }); + + expect(buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, undefined)).toBe( + undefined, + ); + expect( + buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, [ + { id: "fastMode", value: true }, + ]), + ).toEqual([{ id: "fastMode", value: true }]); + }); + it("stores option selection arrays in model selections", () => { expect( createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 1e47576949ed7..cad7c8e8db86b 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -204,6 +204,20 @@ export function buildProviderOptionSelectionsFromDescriptors( return nextSelections.length > 0 ? nextSelections : undefined; } +export function buildExplicitProviderOptionSelectionsFromDescriptors( + descriptors: ReadonlyArray | null | undefined, + selections: ReadonlyArray | null | undefined, +): Array | undefined { + if (!selections || selections.length === 0) { + return undefined; + } + const explicitIds = new Set(selections.map((selection) => selection.id)); + const normalized = buildProviderOptionSelectionsFromDescriptors(descriptors)?.filter( + (selection) => explicitIds.has(selection.id), + ); + return normalized && normalized.length > 0 ? normalized : undefined; +} + export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f86855cc7ae1..1200cbdc11325 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,9 +235,6 @@ importers: '@noble/curves': specifier: 'catalog:' version: 1.9.1 - '@noble/hashes': - specifier: 'catalog:' - version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -485,9 +482,6 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/platform-node-shared': - specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index bb680678cd221..a84634b428a2f 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -32,9 +32,14 @@ import { UnsupportedDesktopBuildArchitectureError, isMacPasskeySigningConfigurationError, LinuxIconResizeError, + LinuxDesktopBuildPrerequisitesMissingError, + MacDesktopBuildPrerequisitesMissingError, MacPasskeySigningConfigurationResolutionError, MissingMacPasskeyProvisioningProfileError, packWindowsServerAsar, + preflightLinuxDesktopBuild, + preflightMacDesktopBuild, + preflightWindowsDesktopBuild, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, @@ -63,6 +68,7 @@ import { copyDirectoryPreservingSymlinks, validateWindowsPackagedPayload, WindowsPrimaryNativeProbeError, + WindowsDesktopBuildPrerequisitesMissingError, WindowsPackagedPayloadValidationError, WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT, WINDOWS_SERVER_ASAR_IGNORE_GLOBS, @@ -102,7 +108,8 @@ const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(funct ); }); -function mockProcess(exitCode: number) { +function mockProcess(exitCode: number, stdout = "") { + const encodedStdout = new TextEncoder().encode(stdout); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), @@ -110,7 +117,7 @@ function mockProcess(exitCode: number) { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.empty, + stdout: stdout ? Stream.make(encodedStdout) : Stream.empty, stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -814,6 +821,175 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); + it.effect("reports every missing Linux desktop build prerequisite with an install command", () => + Effect.scoped( + Effect.gen(function* () { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = + []; + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push(childProcess); + const fails = childProcess.command === "cargo" || childProcess.command === "rustc"; + return Effect.succeed(mockProcess(fails ? 1 : 0)); + }), + ); + + const error = yield* preflightLinuxDesktopBuild("arm64").pipe( + Effect.provide(spawner), + Effect.flip, + ); + + assert.instanceOf(error, LinuxDesktopBuildPrerequisitesMissingError); + assert.deepStrictEqual(error.missing, ["cargo", "rust-target"]); + assert.include(error.message, "Rust compiler and Cargo (cargo, rustc)"); + assert.include(error.message, "Requested Rust standard library"); + assert.include(error.message, "sudo apt-get install cargo rustc"); + assert.include(error.message, "rustup target add aarch64-unknown-linux-gnu"); + assert.isTrue( + commands.some( + (command) => + command.command === "rustc" && command.args.includes("aarch64-unknown-linux-gnu"), + ), + ); + }), + ), + ); + + it.effect("reports missing macOS tools and Rust targets before building", () => + Effect.scoped( + Effect.gen(function* () { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = + []; + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push(childProcess); + const fails = + childProcess.command === "rustc" || + (childProcess.command === "xcrun" && childProcess.args.includes("iconutil")); + return Effect.succeed(mockProcess(fails ? 1 : 0)); + }), + ); + const error = yield* preflightMacDesktopBuild("universal").pipe( + Effect.provide(spawner), + Effect.flip, + ); + + assert.instanceOf(error, MacDesktopBuildPrerequisitesMissingError); + assert.deepStrictEqual(error.missing, ["rust", "iconutil"]); + assert.deepStrictEqual(error.rustTargets, ["aarch64-apple-darwin", "x86_64-apple-darwin"]); + assert.include(error.message, "xcode-select --install"); + assert.include(error.message, "rustup target add aarch64-apple-darwin x86_64-apple-darwin"); + assert.isTrue( + commands.some( + (command) => + command.command === "xcrun" && + command.args[0] === "--find" && + command.args[1] === "lipo", + ), + ); + }), + ), + ); + + it.effect("reports missing Windows toolchain capabilities before building", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-windows-preflight-" }); + const pythonPath = path.join(tempDir, "python.exe"); + yield* fs.writeFileString(pythonPath, "python"); + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { readonly command: string }; + const fails = + childProcess.command === "rustc" || + childProcess.command === "powershell.exe" || + childProcess.command === pythonPath; + return Effect.succeed(mockProcess(fails ? 1 : 0)); + }), + ); + const error = yield* preflightWindowsDesktopBuild({ + arch: "x64", + bundlesWslRuntime: true, + }).pipe( + Effect.provide( + Layer.merge( + spawner, + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { npm_config_python: pythonPath } }), + ), + ), + ), + Effect.flip, + ); + + assert.instanceOf(error, WindowsDesktopBuildPrerequisitesMissingError); + assert.deepStrictEqual(error.missing, ["rust", "python", "msvc"]); + assert.equal(error.rustTarget, "x86_64-pc-windows-msvc"); + assert.include(error.message, "Visual Studio Build Tools components"); + }), + ), + ); + + it.effect("rejects a PATH-discovered Python executable that is not Python 3", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-python2-preflight-" }); + const pythonPath = path.join(tempDir, "python"); + yield* fs.writeFileString(pythonPath, "python2"); + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + if (childProcess.command === "python") { + return Effect.succeed(mockProcess(0, `${pythonPath}\n`)); + } + if (childProcess.command === pythonPath) { + return Effect.succeed(mockProcess(1)); + } + return Effect.succeed(mockProcess(0)); + }), + ); + const error = yield* preflightWindowsDesktopBuild({ + arch: "x64", + bundlesWslRuntime: false, + }).pipe( + Effect.provide( + Layer.merge( + spawner, + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: "true" }, + }), + ), + ), + ), + Effect.flip, + ); + + assert.instanceOf(error, WindowsDesktopBuildPrerequisitesMissingError); + assert.deepStrictEqual(error.missing, ["python"]); + }), + ), + ); + it.effect("validates every ASAR-unpacked native in the packaged Windows payload", () => Effect.scoped( Effect.gen(function* () { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 65e40fa989546..dffe4b9d2800f 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -287,6 +287,125 @@ export class BuildCommandFailedError extends Schema.TaggedErrorClass()( + "LinuxDesktopBuildPrerequisitesMissingError", + { + missing: Schema.Array(Schema.String), + rustTarget: Schema.String, + }, +) { + override get message(): string { + const missingRequirements = LINUX_DESKTOP_BUILD_PREREQUISITES.filter((requirement) => + this.missing.includes(requirement.id), + ); + const details = missingRequirements + .map((requirement) => + requirement.packages.length > 0 + ? ` - ${requirement.description} (${requirement.packages.join(", ")})` + : ` - ${requirement.description}`, + ) + .join("\n"); + const packages = [ + ...new Set(missingRequirements.flatMap((requirement) => requirement.packages)), + ]; + return [ + "Linux desktop build prerequisites are missing:", + details, + "", + ...(packages.length > 0 + ? ["On Ubuntu/Debian, install them with:", ` sudo apt-get install ${packages.join(" ")}`] + : []), + ...(this.missing.includes("rust-target") + ? ["Add the requested Rust target with:", ` rustup target add ${this.rustTarget}`] + : []), + "", + "For other distributions, see docs/internals/scripts.md#linux-appimage-prerequisites.", + "Then rerun `vp run dist:desktop:linux`.", + ].join("\n"); + } +} + +const MAC_DESKTOP_BUILD_PREREQUISITES = [ + { id: "rust", description: "Rust/Cargo and the requested Rust target" }, + { id: "clang", description: "Xcode Command Line Tools (clang)" }, + { id: "make", description: "Xcode Command Line Tools (make)" }, + { id: "sips", description: "macOS image tool (sips)" }, + { id: "iconutil", description: "macOS icon tool (iconutil)" }, + { id: "lipo", description: "Xcode universal-binary tool (lipo)" }, +] as const; + +export class MacDesktopBuildPrerequisitesMissingError extends Schema.TaggedErrorClass()( + "MacDesktopBuildPrerequisitesMissingError", + { + missing: Schema.Array(Schema.String), + rustTargets: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const details = MAC_DESKTOP_BUILD_PREREQUISITES.filter((requirement) => + this.missing.includes(requirement.id), + ) + .map((requirement) => ` - ${requirement.description}`) + .join("\n"); + return [ + "macOS desktop build prerequisites are missing:", + details, + "", + "Install Apple's build tools with:", + " xcode-select --install", + "Install Rust from https://rustup.rs, then add the requested target(s):", + ` rustup target add ${this.rustTargets.join(" ")}`, + "", + "Then rerun the desktop artifact command.", + ].join("\n"); + } +} + +const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [ + { id: "rust", description: "Rust/Cargo and the requested MSVC Rust target" }, + { id: "python", description: "Python 3 for node-gyp" }, + { + id: "msvc", + description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries", + }, + { id: "tar", description: "tar for the bundled WSL runtime" }, +] as const; + +export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedErrorClass()( + "WindowsDesktopBuildPrerequisitesMissingError", + { + missing: Schema.Array(Schema.String), + rustTarget: Schema.String, + }, +) { + override get message(): string { + const details = WINDOWS_DESKTOP_BUILD_PREREQUISITES.filter((requirement) => + this.missing.includes(requirement.id), + ) + .map((requirement) => ` - ${requirement.description}`) + .join("\n"); + return [ + "Windows desktop build prerequisites are missing:", + details, + "", + "Install Rust from https://rustup.rs and add the requested target:", + ` rustup target add ${this.rustTarget}`, + "Install Python 3 and the Visual Studio Build Tools components listed in", + "docs/internals/scripts.md#windows-installer-prerequisites.", + "", + "Then rerun the desktop artifact command.", + ].join("\n"); + } +} + export class ResourceMonitorBuildOutputMissingError extends Schema.TaggedErrorClass()( "ResourceMonitorBuildOutputMissingError", { @@ -721,8 +840,18 @@ const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* ( ), localAppData: Config.string("LOCALAPPDATA").pipe(Config.option), }); + const isPython3 = (candidate: string) => + spawnAndCollectOutput( + ChildProcess.make(candidate, [ + "-c", + "import sys; raise SystemExit(0 if sys.version_info.major == 3 else 1)", + ]), + ).pipe( + Effect.map((result) => result.exitCode === 0), + Effect.orElseSucceed(() => false), + ); const configured = Option.getOrUndefined(env.configuredPython); - if (configured && (yield* fs.exists(configured))) { + if (configured && (yield* fs.exists(configured)) && (yield* isPython3(configured))) { return configured; } @@ -731,7 +860,7 @@ const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* ( if (localAppData) { for (const version of ["Python313", "Python312", "Python311", "Python310"]) { const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); - if (yield* fs.exists(candidate)) { + if ((yield* fs.exists(candidate)) && (yield* isPython3(candidate))) { return candidate; } } @@ -753,7 +882,7 @@ const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* ( } const executable = probe.stdout.trim(); - if (!executable || !(yield* fs.exists(executable))) { + if (!executable || !(yield* fs.exists(executable)) || !(yield* isPython3(executable))) { return undefined; } @@ -1484,6 +1613,164 @@ const runCommand = Effect.fn("runCommand")(function* ( } }); +const desktopBuildProbeSucceeds = Effect.fn("desktopBuildProbeSucceeds")(function* ( + command: ChildProcess.Command, + label: string, +) { + return yield* runCommand(command, { label, verbose: false }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); +}); + +const rustTargetIsInstalled = Effect.fn("rustTargetIsInstalled")(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + const result = yield* spawnAndCollectOutput( + ChildProcess.make("rustc", ["--print", "target-libdir", "--target", target]), + ).pipe(Effect.orElseSucceed(() => null)); + if (result === null || result.exitCode !== 0) return false; + + const targetLibDir = result.stdout.trim(); + if (targetLibDir === "") return false; + const entries = yield* fs.readDirectory(targetLibDir).pipe(Effect.orElseSucceed(() => [])); + return entries.some((entry) => entry.startsWith("libstd-") && entry.endsWith(".rlib")); +}); + +export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild")(function* ( + arch: typeof BuildArch.Type = "x64", +) { + const reuseResourceMonitor = yield* Config.boolean("T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR").pipe( + Config.withDefault(false), + ); + const rustTarget = resolveResourceMonitorRustTargets("linux", arch)[0]!; + + const checks = yield* Effect.all( + { + cargo: reuseResourceMonitor + ? Effect.succeed(true) + : desktopBuildProbeSucceeds(ChildProcess.make("cargo", ["--version"]), "cargo"), + "rust-target": reuseResourceMonitor + ? Effect.succeed(true) + : rustTargetIsInstalled(rustTarget), + cc: desktopBuildProbeSucceeds(ChildProcess.make("cc", ["--version"]), "cc"), + make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"), + imagemagick: Effect.all([ + desktopBuildProbeSucceeds(ChildProcess.make("magick", ["-version"]), "magick"), + desktopBuildProbeSucceeds(ChildProcess.make("convert", ["-version"]), "convert"), + ]).pipe(Effect.map(([magick, convert]) => magick || convert)), + }, + { concurrency: "unbounded" }, + ); + const missing = LINUX_DESKTOP_BUILD_PREREQUISITES.filter( + (requirement) => !checks[requirement.id], + ).map((requirement) => requirement.id); + + if (missing.length > 0) { + return yield* new LinuxDesktopBuildPrerequisitesMissingError({ missing, rustTarget }); + } +}); + +export const preflightMacDesktopBuild = Effect.fn("preflightMacDesktopBuild")(function* ( + arch: typeof BuildArch.Type, +) { + const rustTargets = resolveResourceMonitorRustTargets("mac", arch); + const reuseResourceMonitor = yield* Config.boolean("T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR").pipe( + Config.withDefault(false), + ); + const checks = yield* Effect.all( + { + rust: reuseResourceMonitor + ? Effect.succeed(true) + : Effect.all([ + desktopBuildProbeSucceeds(ChildProcess.make("cargo", ["--version"]), "cargo"), + Effect.forEach(rustTargets, rustTargetIsInstalled).pipe( + Effect.map((results) => results.every(Boolean)), + ), + ]).pipe(Effect.map(([cargo, targets]) => cargo && targets)), + clang: desktopBuildProbeSucceeds(ChildProcess.make("clang", ["--version"]), "clang"), + make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"), + sips: desktopBuildProbeSucceeds(ChildProcess.make("sips", ["--help"]), "sips"), + iconutil: desktopBuildProbeSucceeds( + ChildProcess.make("xcrun", ["--find", "iconutil"]), + "iconutil", + ), + lipo: + arch === "universal" + ? desktopBuildProbeSucceeds(ChildProcess.make("xcrun", ["--find", "lipo"]), "lipo") + : Effect.succeed(true), + }, + { concurrency: "unbounded" }, + ); + const missing = MAC_DESKTOP_BUILD_PREREQUISITES.filter( + (requirement) => !checks[requirement.id], + ).map((requirement) => requirement.id); + if (missing.length > 0) { + return yield* new MacDesktopBuildPrerequisitesMissingError({ missing, rustTargets }); + } +}); + +function windowsVswherePrerequisiteScript(arch: typeof BuildArch.Type): string { + const components = + arch === "arm64" + ? [ + "Microsoft.VisualStudio.Component.VC.Tools.ARM64", + "Microsoft.VisualStudio.Component.VC.Tools.ARM64.Spectre", + ] + : [ + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", + ]; + return [ + "$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\\Installer\\vswhere.exe'", + "if (!(Test-Path $vswhere)) { exit 1 }", + `$install = & $vswhere -latest -products * -requires ${components.join(" ")} -property installationPath`, + "if (!$install) { exit 1 }", + "$kitsRoot = Get-ItemPropertyValue 'HKLM:\\SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots' -Name KitsRoot10 -ErrorAction SilentlyContinue", + "if (!$kitsRoot -or !(Test-Path (Join-Path $kitsRoot 'Lib'))) { exit 1 }", + ].join("; "); +} + +export const preflightWindowsDesktopBuild = Effect.fn("preflightWindowsDesktopBuild")( + function* (input: { readonly arch: typeof BuildArch.Type; readonly bundlesWslRuntime: boolean }) { + const rustTarget = resolveResourceMonitorRustTargets("win", input.arch)[0]!; + const reuseResourceMonitor = yield* Config.boolean( + "T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR", + ).pipe(Config.withDefault(false)); + const python = yield* resolvePythonForNodeGyp(); + const checks = yield* Effect.all( + { + rust: reuseResourceMonitor + ? Effect.succeed(true) + : Effect.all([ + desktopBuildProbeSucceeds(ChildProcess.make("cargo", ["--version"]), "cargo"), + rustTargetIsInstalled(rustTarget), + ]).pipe(Effect.map(([cargo, target]) => cargo && target)), + python: Effect.succeed(python !== undefined), + msvc: desktopBuildProbeSucceeds( + ChildProcess.make("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + windowsVswherePrerequisiteScript(input.arch), + ]), + "Visual Studio Build Tools", + ), + tar: input.bundlesWslRuntime + ? desktopBuildProbeSucceeds(ChildProcess.make("tar.exe", ["--version"]), "tar") + : Effect.succeed(true), + }, + { concurrency: "unbounded" }, + ); + const missing = WINDOWS_DESKTOP_BUILD_PREREQUISITES.filter( + (requirement) => !checks[requirement.id], + ).map((requirement) => requirement.id); + if (missing.length > 0) { + return yield* new WindowsDesktopBuildPrerequisitesMissingError({ missing, rustTarget }); + } + }, +); + /** * Every `node_modules` directory that would be visible from `startDir`. * @@ -1730,22 +2017,22 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // stdin, a port, a lock) would otherwise hang release CI until the job // times out with nothing useful in the log. Effect.timeout(BUNDLE_SELF_CHECK_TIMEOUT), - Effect.catchTag("TimeoutError", () => - Effect.fail( - new BundleNotSelfContainedError({ - exitCode: -1, - output: `The packaged bundle did not print its version within ${Duration.toSeconds(BUNDLE_SELF_CHECK_TIMEOUT)}s; it is hanging rather than failing to resolve.`, - }), - ), - ), - Effect.catchTag("BuildCommandFailedError", (error) => - Effect.fail( - new BundleNotSelfContainedError({ - exitCode: error.exitCode, - output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(), - }), - ), - ), + Effect.catchTags({ + TimeoutError: () => + Effect.fail( + new BundleNotSelfContainedError({ + exitCode: -1, + output: `The packaged bundle did not print its version within ${Duration.toSeconds(BUNDLE_SELF_CHECK_TIMEOUT)}s; it is hanging rather than failing to resolve.`, + }), + ), + BuildCommandFailedError: (error) => + Effect.fail( + new BundleNotSelfContainedError({ + exitCode: error.exitCode, + output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(), + }), + ), + }), ); }, ); @@ -2919,6 +3206,21 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; + if (hostPlatform === "linux" && options.platform === "linux") { + yield* preflightLinuxDesktopBuild(options.arch); + } + if (hostPlatform === "darwin" && options.platform === "mac") { + yield* preflightMacDesktopBuild(options.arch); + } + if (hostPlatform === "win32" && options.platform === "win") { + yield* preflightWindowsDesktopBuild({ + arch: options.arch, + bundlesWslRuntime: bundlesWslRuntime({ + arch: options.arch, + prebuildPath: options.wslPrebuild, + }), + }); + } const workspaceConfig = yield* readWorkspaceConfig(); const workspaceCatalog = workspaceConfig.catalog ?? {}; const workspaceOverrides = workspaceConfig.overrides ?? {}; diff --git a/vite.config.ts b/vite.config.ts index d0029698a3b7f..332538bd88f81 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,7 +39,6 @@ export default defineConfig({ "**/routeTree.gen.ts", "apps/mobile/android/**", "apps/mobile/ios/**", - "apps/web/public/mockServiceWorker.js", "apps/web/src/lib/vendor/qrcodegen.ts", "packages/shared/src/qrCode.ts", "apps/mobile/uniwind-types.d.ts",