From 1a59c806555b97bb554f45adfb4ba06096b250c8 Mon Sep 17 00:00:00 2001 From: Cod-e-Codes Date: Tue, 16 Jun 2026 18:43:01 -0400 Subject: [PATCH 1/5] feat(client): migrate Charm v2 and add OSC 8 wrapped URL hyperlinks Upgrade Bubble Tea, Bubbles, and Lip Gloss to charm.land/*/v2 (tea.View, KeyPressMsg, bubbles setters). Wrapped URL segments emit OSC 8 hyperlinks with the full href on every fragment, addressing #103; manual click-to-open remains as fallback on terminals without OSC 8. Fix post-migration regressions: mouse wheel scroll routes to the active viewport (help, DB menu, chat, modals); help/DB overlays block chat typing indicators, URL clicks, and read-receipt flush; DB menu viewport resizes with the terminal. Admin panel and plugin store enable mouse mode for wheel scroll. Update ARCHITECTURE, CHANGELOG, TESTING, ROADMAP, agent skills, and tests. --- .cursor/rules/marchat.mdc | 2 +- .cursor/skills/README.md | 3 +- .cursor/skills/client-marchat/SKILL.md | 11 +- .cursor/skills/server-marchat/SKILL.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 6 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 8 +- client/chrome.go | 4 +- client/cli_output.go | 2 +- client/code_snippet.go | 21 ++- client/code_snippet_test.go | 36 ++--- client/commands.go | 2 +- client/config/interactive_ui.go | 99 ++++++------ client/config/interactive_ui_test.go | 64 ++++---- client/file_picker.go | 23 ++- client/file_picker_test.go | 32 ++-- client/hotkeys.go | 2 +- client/main.go | 214 ++++++++++++++----------- client/main_test.go | 6 +- client/render.go | 61 +++++-- client/render_test.go | 63 +++++--- client/scroll_input.go | 121 ++++++++++++++ client/scroll_input_test.go | 179 +++++++++++++++++++++ client/testmain_test.go | 6 +- client/theme_loader.go | 2 +- client/websocket.go | 8 +- cmd/server/main.go | 6 +- go.mod | 26 ++- go.sum | 61 +++---- internal/doctor/doctor.go | 2 +- plugin/store/store.go | 53 +++--- server/admin_panel.go | 41 +++-- server/config_ui.go | 44 +++-- 35 files changed, 823 insertions(+), 393 deletions(-) create mode 100644 client/scroll_input.go create mode 100644 client/scroll_input_test.go diff --git a/.cursor/rules/marchat.mdc b/.cursor/rules/marchat.mdc index cf49a76..eab5d4c 100644 --- a/.cursor/rules/marchat.mdc +++ b/.cursor/rules/marchat.mdc @@ -86,7 +86,7 @@ Prefer this repo (`go.mod`, `ARCHITECTURE.md`, `PROTOCOL.md`, skills) as source - **Themes**: built-in + **custom JSON themes** under client config dir (`theme_loader.go`) - **Notifications**: bell, desktop (**Alt+N**), **`:notify-mode`**, quiet hours (**`:quiet`**), focus mode (**`:focus`**) - `notification_manager.go` + help text - **Mentions**: `@user` highlighting in render; mention-aware notification path - see `client/render.go` -- **Transcript wrap**: ANSI-aware word wrap in `wrapStyledBlock` / `renderMessages`; long URLs use non-breaking hyphens and `ansi.Wrap` path breakpoints; hyperlink style applied per wrapped segment via URL span markers (`markURLsForWrap`, `applyURLMarkers`). Mouse click-to-open uses manual coordinate mapping and is **not reliable** for wrapped long URLs; copy from message or see `ROADMAP.md` / GitHub issue. +- **Transcript wrap**: ANSI-aware word wrap in `wrapStyledBlock` / `renderMessages`; long URLs use non-breaking hyphens and `ansi.Wrap` path breakpoints; hyperlink style and OSC 8 sequences applied per wrapped segment via URL span markers (`markURLsForWrap`, `applyURLMarkers`). Mouse click-to-open fallback uses manual coordinate mapping and is **not reliable** for wrapped long URLs; OSC 8 terminals should use the embedded hyperlinks; copy from message otherwise ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)). - **Client-local System lines**: ephemeral command feedback uses the banner; negative `message_id` transcript notices (themes, search, channel lists) stay in the transcript when `isTranscriptSystemNotice` matches and carry the active channel. `pruneEphemeralSystemMessages` on send or inbound persisted message removes only non-transcript System lines. - **Profiles**: `profiles.json`; **dedupe** display names on load; default **Profile-N** naming when adding profiles (`client/config/config.go`) - **Config paths**: app data dir or **`MARCHAT_CONFIG_DIR`** via **`ResolveClientConfigDir()`**; **`GetConfigPath()`** uses the same rules as keystore primary path; **`GetKeystorePath`** checks primary -> user-dir `keystore.dat` when override has none -> last legacy **`./keystore.dat`** in cwd - `MigrateKeystoreToNewLocation` diff --git a/.cursor/skills/README.md b/.cursor/skills/README.md index c06be34..6534618 100644 --- a/.cursor/skills/README.md +++ b/.cursor/skills/README.md @@ -31,7 +31,8 @@ Update domain skills when shipped behavior changes. Recent fixes on `main` (or i - Reconnect backoff advances on failure (not reset each `Init()`); channel stamping on server outbound messages - Client transcript notices: negative `message_id` classified by content; scoped to active channel -- URL click: partial headless helpers (`buildTranscriptLineURLs`, `chatPanelOrigin`); wrapped long URLs still fail in manual testing; [#103](https://github.com/Cod-e-Codes/marchat/issues/103); workaround is copy/paste +- URL click: OSC 8 hyperlinks on wrapped segments (Lip Gloss v2); manual click fallback remains unreliable for wrapped long URLs; copy/paste when needed ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)) +- Charm v2: `charm.land/*/v2`, `tea.View` + `KeyPressMsg`, overlay scroll/input routing in `scroll_input.go` - `:backup` SQLite-only; Postgres/MySQL migrations use `BOOLEAN DEFAULT FALSE` ## Skill index diff --git a/.cursor/skills/client-marchat/SKILL.md b/.cursor/skills/client-marchat/SKILL.md index 73b15ea..ccf8a79 100644 --- a/.cursor/skills/client-marchat/SKILL.md +++ b/.cursor/skills/client-marchat/SKILL.md @@ -10,10 +10,11 @@ paths: # Client (marchat) -Bubble Tea + Lipgloss TUI. Entry: `client/main.go`; split across `render.go`, `commands.go`, `hotkeys.go`, `websocket.go`, `cli_output.go`, `notification_manager.go`, etc. +Bubble Tea + Lipgloss TUI on **Charm v2** (`charm.land/bubbletea/v2`, `bubbles/v2`, `lipgloss/v2`). Entry: `client/main.go`; split across `render.go`, `commands.go`, `hotkeys.go`, `websocket.go`, `scroll_input.go`, `cli_output.go`, `notification_manager.go`, etc. ## Patterns +- **Charm v2**: `View() tea.View` sets `AltScreen` and `MouseModeCellMotion` on the main client; `KeyPressMsg` replaces `KeyMsg`; bubbles use `SetWidth` / `SetHeight` / `SetStyles`. - **Reconnect**: exponential backoff (capped at 30s); delay resets only after successful connect (`wsConnected`), not each `Init()` retry; no reconnect on fatal username/handshake errors (`websocket.go`, `main.go`). - **Commands**: `:q` quits; `Esc` closes menus; help in `commands.go` (shortcuts vs text commands). Transient command results belong in the **banner** when short; longer lists (e.g. `:themes`) may use transcript System lines. - **E2E**: same wire path for channel text and DMs when encryption on; files via keystore `EncryptRaw` / `DecryptRaw`. Do not log plaintext on send/decrypt paths. @@ -26,7 +27,8 @@ Bubble Tea + Lipgloss TUI. Entry: `client/main.go`; split across `render.go`, `c ## Transcript rendering (`render.go`) - **Word wrap**: `wrapStyledBlock` + `ansi.Wrap` at viewport width; preserves ANSI codes. -- **URLs**: `prepareURLWrapping` (non-breaking hyphens in hosts), `markURLsForWrap` / `applyURLMarkers` so hyperlink color and underline survive line breaks without styling continuation indent. **Click-to-open is not reliable for wrapped long URLs** in real terminals (manual mouse mapping + regex; headless tests do not cover lipgloss box chrome or emulator behavior). Workaround: copy URL from message. Tracked in [#103](https://github.com/Cod-e-Codes/marchat/issues/103). Planned fix: OSC 8 hyperlinks with full URL per segment when supported (`ROADMAP.md`). +- **URLs**: `prepareURLWrapping` (non-breaking hyphens in hosts), `markURLsForWrap` / `applyURLMarkers` so hyperlink color, underline, and OSC 8 sequences survive line breaks with the **full** href on every wrapped fragment. Mouse click-to-open (`findURLAtClickPosition`) remains as fallback when OSC 8 is unavailable. Copy from message still works everywhere. +- **Scroll input**: `activeScrollViewport` routes keyboard and `MouseWheelMsg` to help, DB menu, chat, or user list viewports; list modals (file picker, code-snippet language) handle wheel via `CursorUp`/`CursorDown`. Help and DB menu overlays (`overlayCapturesKeyboard`) swallow non-scroll keys so chat typing indicators, URL clicks, and read-receipt flush do not fire while browsing overlay content; `maybeFlushReadReceipt` only runs when the chat transcript viewport is active and at bottom. - **Sort order**: `sortMessagesByTimestamp` / `messageLess` - persisted chat by `message_id`, server System (`message_id == 0`) by `created_at`, client-local System (negative `message_id`) after persisted chat. - **Ephemeral System feedback**: `isTranscriptSystemNotice` / `isTranscriptSystemMessage` route command errors and one-line server replies (e.g. admin-only denial) to the **banner**; multi-line search, themes, and channel notices stay in the transcript. Negative `message_id` transcript notices are classified by **content** (`isTranscriptSystemNotice`), not ID sign alone. `pruneEphemeralSystemMessages` clears stale ephemeral lines on send or inbound persisted chat. - **Client-local System lines**: negative `message_id` via `appendClientSystemMessage` for transcript notices only (with active `channel` set); short client usage/errors go to banner through `appendClientSystem`. @@ -34,9 +36,10 @@ Bubble Tea + Lipgloss TUI. Entry: `client/main.go`; split across `render.go`, `c ## Testing - Inject `tea.Msg` in tests; no real terminal. -- `client/testmain_test.go`: `lipgloss.SetColorProfile(termenv.ANSI256)` so headless render/hyperlink tests emit real SGR sequences. -- `client/render_test.go`: URL wrap, hyperlink markers, system line severity, wrap width; URL click helpers (`buildTranscriptLineURLs`, `findURLAtTranscriptClick`) are headless only and do **not** validate real-terminal wrapped URL opens. +- `client/testmain_test.go`: `lipgloss.Writer.Profile = colorprofile.ANSI256` so headless render/hyperlink tests emit real SGR and OSC 8 sequences. +- `client/render_test.go`: URL wrap, OSC 8 hyperlink markers (`\x1b]8;;`), underline on wrapped segments, system line severity, wrap width; URL click helpers (`buildTranscriptLineURLs`, `findURLAtTranscriptClick`) are headless fallback-path coverage. - `client/main_test.go`: DM/channel filters, unread, client system prune/sort, reconnect backoff, URL click hit/miss (single-line, headless), E2E search hint. +- `client/scroll_input_test.go`: scroll target selection, help viewport wheel handling, overlay input capture, read-receipt scoping, viewport dimension helpers - `client/websocket_e2e_test.go`, `keystore_test.go`, `config_test.go`. - See `testing-marchat` skill. diff --git a/.cursor/skills/server-marchat/SKILL.md b/.cursor/skills/server-marchat/SKILL.md index 3c8d4c8..7f376b5 100644 --- a/.cursor/skills/server-marchat/SKILL.md +++ b/.cursor/skills/server-marchat/SKILL.md @@ -30,7 +30,7 @@ App entry: `cmd/server/main.go`. Library: `server/` (hub, client, handlers, db, ## Admin -- TUI: `admin_panel.go`, `config_ui.go`. +- TUI: `admin_panel.go`, `config_ui.go` (Charm v2: `tea.View`, `KeyPressMsg`, bubbles setters). Admin panel enables `MouseModeCellMotion` and routes `MouseWheelMsg` for scrollable tabs and user/plugin tables. - Web: `admin_web.go`, `admin_web.html`; `MARCHAT_SESSION_SECRET` (preferred), `MARCHAT_JWT_SECRET` deprecated; CSRF on mutating routes; login rate limit per IP. - Trusted proxies: `MARCHAT_TRUSTED_PROXIES` for forwarded client IP. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b1c172..071474d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -78,7 +78,7 @@ The client is a standalone terminal user interface built with the Bubble Tea fra - While a DM thread is open, the typing footer ignores channel or global typing (empty `recipient` on `typing` messages) so someone typing in the channel is not shown as typing inside the DM view; only DM-scoped typing for the open peer is shown. In channel view, typing is filtered to the active channel. - DM unread and hidden-thread UI state is local client state in `dm_state.json` under the client config directory; opening a DM thread marks it read, and a hidden thread reappears on the next inbound DM from that user - Automatic WebSocket reconnect with exponential backoff (capped at 30s; delay resets only after a successful connect, not on each retry attempt); on each successful connect (`wsConnected`), the reference client clears the in-memory transcript and related UI state before processing server history replay, so a server restart or network drop does not duplicate messages that were already on screen -- URL detection with underline styling; left-click attempts to open the URL under the cursor via manual mouse coordinate mapping (`findURLAtClickPosition` in `client/websocket.go`, `buildTranscriptLineURLs` in `client/render.go`). This path is **not reliable** when URLs wrap across transcript lines (common for long GitHub links in a narrow panel); the browser may open a truncated prefix. **Workaround:** copy the URL from the message text. A durable fix likely requires OSC 8 terminal hyperlinks (per-line full URL in the escape sequence) when the emulator supports them, with a tested fallback for terminals that do not; see `ROADMAP.md` and [#103](https://github.com/Cod-e-Codes/marchat/issues/103). +- URL detection with underline styling and **OSC 8 hyperlinks** on wrapped segments (`applyURLMarkers` in `client/render.go`; Lip Gloss v2 `Style.Hyperlink`). Terminals with OSC 8 support (e.g. Windows Terminal, iTerm) can Ctrl+click or open the **full** URL on every wrapped fragment. **Fallback:** left-click uses manual mouse coordinate mapping (`findURLAtClickPosition` in `client/websocket.go`, `buildTranscriptLineURLs` in `client/render.go`) and remains **unreliable** when URLs wrap across transcript lines; copy from the message when click-to-open fails ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)). - E2E send/decrypt paths do not log plaintext message content - Multi-line input via Alt+Enter / Ctrl+J - **Diagnostics**: `-doctor` and `-doctor-json` for environment, paths, and config checks (`internal/doctor`) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa47bfb..6c035d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,12 @@ Narrative notes by release. Per-file binaries and assets: [GitHub releases](http On **`main`** only; not part of the latest tagged release until you tag and publish. Compare against the current tag on [GitHub releases](https://github.com/Cod-e-Codes/marchat/releases). -- **Client**: Word-wrap chat message bodies to the transcript viewport width (ANSI-aware). Reaction aliases `thumbsup` / `thumbsdown`; `:unreact`, `:thumbsup`, and `:thumbsdown` commands. When E2E is on and server search returns no matches, a `System` line explains that search matches stored ciphertext, not decrypted plaintext. **Fix:** long URLs break at path boundaries on resize (not mid-domain hyphens); hyperlink color and underline follow wrapped URL segments without styling continuation indent. Mouse click-to-open on transcript URLs is implemented (`findURLAtClickPosition`, `buildTranscriptLineURLs`) but **not reliable** for wrapped long URLs in real terminals (for example a GitHub commit link may open `https://github.com/Cod` instead of the full URL); copy the URL from the message until [#103](https://github.com/Cod-e-Codes/marchat/issues/103) is resolved. **Fix:** ephemeral `System` feedback (client usage errors, server command denials like admin-only, plugin one-liners) uses the banner instead of sticking in the transcript; scrollable lists (search, themes, channels) stay in the transcript. **Fix:** reconnect exponential backoff advances on failure (no longer reset each attempt); client transcript notices keep negative `message_id` until pruned, scoped to the active channel, and survive inbound chat; reactions and read receipts include channel; E2E paths no longer log plaintext. -- **Server**: Handshake replay queries up to 50 **visible** recent messages (SQL `LIMIT` after DM/public filter), on every connect including reconnect with no new traffic. `user_message_state` records `last_seen` only (`last_message_id` legacy/unused). `:cleardb` clears `user_message_state`. Postgres/MySQL CI smoke and WebSocket integration test cover visible replay SQL and second-connect wire replay. **Fix:** Postgres `boolean = integer` errors on `:search`, pin toggle, and pinned listing (shared dialect boolean helpers). **Fix:** MySQL `InitDB` parses DSNs with `mysql.Config`, forces `parseTime=true` (overrides explicit `parseTime=false`; logs a warning), and sets `Loc` to local time when unset. **Fix:** outbound messages always use the sender's joined channel (blocks cross-channel spoofing); typing, reactions, and read receipts are channel-scoped; non-admin unknown commands return `Unknown command` instead of admin-only text; `:backup` is SQLite-only with a clear error on Postgres/MySQL; legacy Postgres migrations use `BOOLEAN DEFAULT FALSE`. +- **Client**: Word-wrap chat message bodies to the transcript viewport width (ANSI-aware). Reaction aliases `thumbsup` / `thumbsdown`; `:unreact`, `:thumbsup`, and `:thumbsdown` commands. When E2E is on and server search returns no matches, a `System` line explains that search matches stored ciphertext, not decrypted plaintext. **Fix:** long URLs break at path boundaries on resize (not mid-domain hyphens); hyperlink color and underline follow wrapped URL segments without styling continuation indent. **Fix:** wrapped URL segments emit OSC 8 hyperlinks (Lip Gloss v2 `Style.Hyperlink`) with the full href on every fragment; mouse click-to-open remains as fallback on terminals without OSC 8 ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)). **Charm v2:** Bubble Tea, Bubbles, and Lip Gloss migrated to `charm.land/*/v2` (declarative `tea.View`, `KeyPressMsg`, bubbles setters). **Fix:** mouse wheel scroll routes to the active viewport (help, DB menu, chat, user list) after v2 split `MouseWheelMsg` from click events; file picker and code-snippet language lists scroll on wheel. **Fix:** help and DB menu overlays block chat typing indicators, URL click handling, and read-receipt flush while browsing overlay content; read receipts only schedule when the chat transcript viewport is scrolled to the tail; DB menu viewport resizes with the terminal. **Fix:** ephemeral `System` feedback (client usage errors, server command denials like admin-only, plugin one-liners) uses the banner instead of sticking in the transcript; scrollable lists (search, themes, channels) stay in the transcript. **Fix:** reconnect exponential backoff advances on failure (no longer reset each attempt); client transcript notices keep negative `message_id` until pruned, scoped to the active channel, and survive inbound chat; reactions and read receipts include channel; E2E paths no longer log plaintext. +- **Server**: Handshake replay queries up to 50 **visible** recent messages (SQL `LIMIT` after DM/public filter), on every connect including reconnect with no new traffic. `user_message_state` records `last_seen` only (`last_message_id` legacy/unused). `:cleardb` clears `user_message_state`. Postgres/MySQL CI smoke and WebSocket integration test cover visible replay SQL and second-connect wire replay. **Fix:** admin panel TUI enables mouse mode and handles `MouseWheelMsg` for scrollable tabs and user/plugin tables. **Fix:** Postgres `boolean = integer` errors on `:search`, pin toggle, and pinned listing (shared dialect boolean helpers). **Fix:** MySQL `InitDB` parses DSNs with `mysql.Config`, forces `parseTime=true` (overrides explicit `parseTime=false`; logs a warning), and sets `Loc` to local time when unset. **Fix:** outbound messages always use the sender's joined channel (blocks cross-channel spoofing); typing, reactions, and read receipts are channel-scoped; non-admin unknown commands return `Unknown command` instead of admin-only text; `:backup` is SQLite-only with a clear error on Postgres/MySQL; legacy Postgres migrations use `BOOLEAN DEFAULT FALSE`. - **Plugins**: **Fix:** plugin stdin writes are serialized so chat fan-out and command RPC cannot corrupt IPC lines. - **Docs**: Coverage tables in **README** and **TESTING** drop per-package line counts; regeneration uses `go test -coverprofile` and `go tool cover -func` only. **ARCHITECTURE** and **PROTOCOL** document channel stamping, SQLite-only `:backup`, plugin stdin serialization, and client reconnect/E2E logging behavior; wrapped URL click-to-open limitation documented ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)). Agent skills updated to match. - **Tooling**: Project Agent skills under `.cursor/skills/`; always-on rules in `.cursor/rules/marchat.mdc`. `.gitignore` tracks shared rules and skills; removed legacy `.cursor/agents/` briefs. **Fix:** skills pipeline, em-dash cleanup in rules, and client transcript docs synced with URL wrap and ephemeral System line behavior. -- **Dependencies**: **github.com/jackc/pgx/v5** v5.10.0, **golang.org/x/crypto** v0.53.0, **golang.org/x/term** v0.44.0, **modernc.org/sqlite** v1.52.0 (SQLite 3.53.2). +- **Dependencies**: **charm.land/bubbletea/v2** v2.0.7, **charm.land/bubbles/v2** v2.1.0, **charm.land/lipgloss/v2** v2.0.4 (replaces Charm v1 stack). **github.com/jackc/pgx/v5** v5.10.0, **golang.org/x/crypto** v0.53.0, **golang.org/x/term** v0.44.0, **modernc.org/sqlite** v1.52.0 (SQLite 3.53.2). ## v1.2.0 diff --git a/README.md b/README.md index a1973bd..c593a64 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ go build -o marchat-client ./client - Go 1.25.11 or later ([download](https://go.dev/dl/)) - Linux clipboard support: `sudo apt install xclip` (Ubuntu/Debian) or `sudo yum install xclip` (RHEL/CentOS) -**Terminal colors:** The server startup banner and the client’s pre-chat output (connection, E2E status, profile picker tags such as `[Admin]` / `[E2E]`, and auth prompts) use [lipgloss](https://github.com/charmbracelet/lipgloss) for emphasis. Set **`NO_COLOR=1`** (or **`NO_COLOR`**) in the environment to disable colors on plain stdout/stderr. +**Terminal colors:** The server startup banner and the client’s pre-chat output (connection, E2E status, profile picker tags such as `[Admin]` / `[E2E]`, and auth prompts) use [Lip Gloss v2](https://github.com/charmbracelet/lipgloss) (`charm.land/lipgloss/v2`) for emphasis. Set **`NO_COLOR=1`** (or **`NO_COLOR`**) in the environment to disable colors on plain stdout/stderr. ## Configuration diff --git a/ROADMAP.md b/ROADMAP.md index 45e6e14..db80086 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,7 +51,7 @@ This file tracks implementation status. Completed items use `- [x]`; future idea - [x] Increase test coverage for client and server packages. ### Phase 7: Future Improvements -- **Client URL click-to-open (open):** Wrapped long URLs in the transcript are styled as hyperlinks but left-click may open a truncated URL (for example `https://github.com/Cod` instead of a full GitHub commit link). Headless unit tests cover wrap styling and partial click logic; they do not reproduce real terminal mouse + lipgloss box chrome. Likely fix: emit OSC 8 hyperlinks with the **full** URL on each wrapped segment when the terminal supports OSC 8 (Charm Lip Gloss `Style.Hyperlink` in v2 / `github.com/charmbracelet/lipgloss` issue #220), and keep mouse-coordinate open only as fallback on capable terminals or via `openURL` when OSC 8 is disabled. **Workaround:** copy the URL from the message. Tracked in [#103](https://github.com/Cod-e-Codes/marchat/issues/103). +- [x] Client wrapped URL click-to-open via OSC 8 hyperlinks (Lip Gloss v2; [#103](https://github.com/Cod-e-Codes/marchat/issues/103)) - Persist channel room lifecycle and metadata across server restarts (not only per-user last channel state). - Consider using `sqlx` or a lightweight ORM to reduce SQL dialect handling. - Explore migrations tooling for schema changes. diff --git a/TESTING.md b/TESTING.md index d1aa8a8..5e7e99d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -26,7 +26,10 @@ Some client behavior is only verifiable in a real terminal emulator with mouse r | Area | Automated coverage | Manual check | |------|-------------------|--------------| -| Wrapped URL click-to-open | `client/render_test.go` and `client/main_test.go` exercise headless viewport strings, line indexing, and single-line clicks | In a narrow chat panel, post a long GitHub commit URL, left-click the underlined link, confirm the browser opens the **full** URL (not a prefix such as `https://github.com/Cod`). See [#103](https://github.com/Cod-e-Codes/marchat/issues/103). | +| Help panel scroll | `client/scroll_input_test.go` (`MouseWheelMsg` on help viewport) | Open help (Ctrl+H), wheel up/down and PgUp/PgDn on long content; confirm footer hint and scroll position change | +| DB admin menu scroll | `client/scroll_input_test.go` (target selection includes `showDBMenu`) | Admin: Ctrl+D, wheel or arrow keys on menu content; resize terminal and confirm menu still scrolls | +| Overlay input isolation | `client/scroll_input_test.go` (`overlayCapturesKeyboard`, read-receipt scoping) | With help open, type in chat area: no typing indicator; scroll help: no read receipt sent | +| Wrapped URL click-to-open | `client/render_test.go` asserts OSC 8 (`\x1b]8;;`) on wrapped segments; `client/main_test.go` covers fallback click helpers | In a narrow chat panel (Windows Terminal or iTerm), post a long GitHub commit URL, Ctrl+click or left-click the underlined link, confirm the browser opens the **full** URL (not a prefix such as `https://github.com/Cod`). See [#103](https://github.com/Cod-e-Codes/marchat/issues/103). | | Lipgloss box + mouse coordinates | `chatPanelOrigin()` offset is unit-tested for banner/border rows only | Click URLs with and without banner text; confirm banner shows `[OK] Opening URL: ...` with the full href before the browser opens. | **Workaround for users:** copy the URL from the message text when click-to-open opens a truncated link. @@ -48,7 +51,8 @@ Some client behavior is only verifiable in a real terminal emulator with mouse r | `client/code_snippet_test.go` | Client code snippet functionality | Text editing, selection, clipboard, syntax highlighting | | `client/file_picker_test.go` | Client file picker functionality | File browsing, selection, size validation, directory navigation | | `client/testmain_test.go` | Client test harness | Forces Lipgloss ANSI256 profile for headless hyperlink/render assertions | -| `client/render_test.go` | Message transcript rendering | URL wrap breakpoints, URL span markers, hyperlink style on wrapped segments, continuation indent not underlined, system line severity, negative-ID transcript notice classification, headless URL click/index helpers (`buildTranscriptLineURLs`, `findURLAtTranscriptClick`; see [Manual testing gaps](#manual-testing-gaps)) | +| `client/render_test.go` | Message transcript rendering | URL wrap breakpoints, URL span markers, OSC 8 hyperlinks and underline on wrapped segments, continuation indent not underlined, system line severity, negative-ID transcript notice classification, headless URL click/index helpers (`buildTranscriptLineURLs`, `findURLAtTranscriptClick`; see [Manual testing gaps](#manual-testing-gaps)) | +| `client/scroll_input_test.go` | Scroll routing | Active viewport selection, help viewport wheel scroll, overlay input capture, read-receipt scoping to chat viewport, content height helper | | `client/main_test.go` | Client main functionality | Message rendering, user lists, URL handling (single-line click miss vs hit, headless), encryption functions, flag validation, `wsConnected` transcript reset on reconnect, reconnect backoff doubling, `TestMessageIncrementsUnread`, client System prune/sort (`TestPruneEphemeralSystemMessages`, `TestPruneKeepsTranscriptSystemNotices`, `TestSortMessagesPersistedBeforeClientSystem`), reaction wire channel | | `client/websocket_sanitize_test.go` | WebSocket URL / TLS hints | Sanitization helpers for display and connection hints | | `client/websocket_e2e_test.go` | E2E DM and channel wire helpers | Encrypted outbound message shape, DM send wire format, decrypt roundtrip | diff --git a/client/chrome.go b/client/chrome.go index 116e317..47fc75a 100644 --- a/client/chrome.go +++ b/client/chrome.go @@ -15,9 +15,9 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/shared" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) const readReceiptDebounce = 750 * time.Millisecond diff --git a/client/cli_output.go b/client/cli_output.go index e38b824..423e4a8 100644 --- a/client/cli_output.go +++ b/client/cli_output.go @@ -3,7 +3,7 @@ package main import ( "fmt" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // Startup / pre-TUI CLI styling (distinct from in-app Bubble Tea theme). diff --git a/client/code_snippet.go b/client/code_snippet.go index ea6c12b..1dded2d 100644 --- a/client/code_snippet.go +++ b/client/code_snippet.go @@ -5,10 +5,10 @@ import ( "log" "strings" + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" "github.com/alecthomas/chroma/quick" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" ) type codeSnippetState int @@ -248,9 +248,12 @@ func (m codeSnippetModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case stateSelectLang: var cmd tea.Cmd - m.langList, cmd = m.langList.Update(msg) switch msg := msg.(type) { - case tea.KeyMsg: + case tea.MouseWheelMsg: + applyMouseWheelToList(&m.langList, msg) + return m, nil + case tea.KeyPressMsg: + m.langList, cmd = m.langList.Update(msg) switch msg.String() { case "enter": if item, ok := m.langList.SelectedItem().(langItem); ok { @@ -268,7 +271,7 @@ func (m codeSnippetModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stateInputCode: switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: switch msg.String() { case "ctrl+s": // Ctrl+S to finish input and show preview @@ -469,7 +472,7 @@ func (m codeSnippetModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stateConfirmSend: switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: switch msg.String() { case "enter": // Send the highlighted code as a message @@ -501,7 +504,11 @@ func (m codeSnippetModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m codeSnippetModel) View() string { +func (m codeSnippetModel) View() tea.View { + return tea.NewView(m.viewContent()) +} + +func (m codeSnippetModel) viewContent() string { switch m.state { case stateSelectLang: return m.langList.View() + "\n" + m.styles.Time.Render("Press Enter to select language, Esc to cancel.") diff --git a/client/code_snippet_test.go b/client/code_snippet_test.go index 8739e58..425a26b 100644 --- a/client/code_snippet_test.go +++ b/client/code_snippet_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // Mock themeStyles for testing @@ -102,7 +102,7 @@ func TestCodeSnippetLanguageSelection(t *testing.T) { } // Test Enter key to select language - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) csModel, ok := updatedModel.(codeSnippetModel) @@ -123,7 +123,7 @@ func TestCodeSnippetLanguageSelection(t *testing.T) { var cancelled bool model.onCancel = func() { cancelled = true } - escMsg := tea.KeyMsg{Type: tea.KeyEsc} + escMsg := tea.KeyPressMsg{Code: tea.KeyEsc} model.Update(escMsg) if !cancelled { @@ -141,7 +141,7 @@ func TestCodeSnippetTextInput(t *testing.T) { model.selected = "go" // Test character input - charMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}} + charMsg := tea.KeyPressMsg{Code: 'h', Text: "h"} updatedModel, _ := model.Update(charMsg) csModel := updatedModel.(codeSnippetModel) @@ -154,7 +154,7 @@ func TestCodeSnippetTextInput(t *testing.T) { } // Test Enter key (new line) - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ = csModel.Update(enterMsg) csModel = updatedModel.(codeSnippetModel) @@ -167,7 +167,7 @@ func TestCodeSnippetTextInput(t *testing.T) { } // Test backspace - backspaceMsg := tea.KeyMsg{Type: tea.KeyBackspace} + backspaceMsg := tea.KeyPressMsg{Code: tea.KeyBackspace} updatedModel, _ = csModel.Update(backspaceMsg) csModel = updatedModel.(codeSnippetModel) @@ -188,7 +188,7 @@ func TestCodeSnippetTextSelection(t *testing.T) { model.cursorY = 0 // Test shift+right selection - shiftRightMsg := tea.KeyMsg{Type: tea.KeyShiftRight} + shiftRightMsg := tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModShift} updatedModel, _ := model.Update(shiftRightMsg) model = updatedModel.(codeSnippetModel) @@ -293,7 +293,7 @@ func TestCodeSnippetCursorMovement(t *testing.T) { model.cursorY = 0 // Test right movement - rightMsg := tea.KeyMsg{Type: tea.KeyRight} + rightMsg := tea.KeyPressMsg{Code: tea.KeyRight} updatedModel, _ := model.Update(rightMsg) model = updatedModel.(codeSnippetModel) @@ -302,7 +302,7 @@ func TestCodeSnippetCursorMovement(t *testing.T) { } // Test down movement - downMsg := tea.KeyMsg{Type: tea.KeyDown} + downMsg := tea.KeyPressMsg{Code: tea.KeyDown} updatedModel, _ = model.Update(downMsg) model = updatedModel.(codeSnippetModel) @@ -311,7 +311,7 @@ func TestCodeSnippetCursorMovement(t *testing.T) { } // Test left movement - leftMsg := tea.KeyMsg{Type: tea.KeyLeft} + leftMsg := tea.KeyPressMsg{Code: tea.KeyLeft} updatedModel, _ = model.Update(leftMsg) model = updatedModel.(codeSnippetModel) @@ -320,7 +320,7 @@ func TestCodeSnippetCursorMovement(t *testing.T) { } // Test up movement - upMsg := tea.KeyMsg{Type: tea.KeyUp} + upMsg := tea.KeyPressMsg{Code: tea.KeyUp} updatedModel, _ = model.Update(upMsg) model = updatedModel.(codeSnippetModel) @@ -343,7 +343,7 @@ func TestCodeSnippetPreviewAndSend(t *testing.T) { model.lines = []string{"package main", "func main() {}"} // Test Ctrl+S to preview - ctrlSMsg := tea.KeyMsg{Type: tea.KeyCtrlS} + ctrlSMsg := tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl} updatedModel, _ := model.Update(ctrlSMsg) model = updatedModel.(codeSnippetModel) @@ -356,7 +356,7 @@ func TestCodeSnippetPreviewAndSend(t *testing.T) { } // Test Enter to send - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ = model.Update(enterMsg) model = updatedModel.(codeSnippetModel) @@ -399,7 +399,7 @@ func TestCodeSnippetView(t *testing.T) { model := newCodeSnippetModel(styles, 80, 24, func(string) {}, func() {}) // Test language selection view - view := model.View() + view := model.View().Content if !strings.Contains(view, "Select Programming Language") { t.Error("Expected view to contain language selection title") } @@ -408,7 +408,7 @@ func TestCodeSnippetView(t *testing.T) { model.state = stateInputCode model.selected = "go" model.lines = []string{"package main"} - view = model.View() + view = model.View().Content if !strings.Contains(view, "Language: go") { t.Error("Expected view to show selected language") @@ -419,7 +419,7 @@ func TestCodeSnippetView(t *testing.T) { model.code = "package main" model.selected = "go" model.highlight = "highlighted code" - view = model.View() + view = model.View().Content if !strings.Contains(view, "highlighted code") && !strings.Contains(view, "package main") && !strings.Contains(view, "Press Enter") { t.Error("Expected view to show highlighted code or fallback content") @@ -512,7 +512,7 @@ func TestCodeSnippetPreviewError(t *testing.T) { model.highlight = "Error: invalid language" model.state = stateConfirmSend - view := model.View() + view := model.View().Content if !strings.Contains(view, "Error: invalid language") && !strings.Contains(view, "test code") { t.Error("Expected error to be shown in view or fallback content") } diff --git a/client/commands.go b/client/commands.go index a4ac2a5..2efe409 100644 --- a/client/commands.go +++ b/client/commands.go @@ -3,9 +3,9 @@ package main import ( "fmt" + tea "charm.land/bubbletea/v2" "github.com/Cod-e-Codes/marchat/client/config" "github.com/Cod-e-Codes/marchat/shared" - tea "github.com/charmbracelet/bubbletea" ) func (m *model) generateHelpContent() string { diff --git a/client/config/interactive_ui.go b/client/config/interactive_ui.go index e5a232e..59df822 100644 --- a/client/config/interactive_ui.go +++ b/client/config/interactive_ui.go @@ -5,9 +5,9 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) var ( @@ -33,6 +33,24 @@ var ( blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Connect")) ) +func configInputStyles() textinput.Styles { + s := textinput.DefaultDarkStyles() + s.Focused.Prompt = focusedStyle + s.Focused.Text = focusedStyle + s.Blurred.Prompt = noStyle + s.Blurred.Text = noStyle + s.Cursor.Color = lipgloss.Color("#FF6B9D") + return s +} + +func initConfigInput(t *textinput.Model, width int, focus bool) { + t.SetStyles(configInputStyles()) + t.SetWidth(width) + if focus { + t.Focus() + } +} + type configField int const ( @@ -70,51 +88,47 @@ func NewConfigUI() ConfigUIModel { var t textinput.Model for i := range m.inputs { t = textinput.New() - t.Cursor.Style = cursorStyle switch configField(i) { case serverURLField: t.Placeholder = "wss://example.com/ws" t.Prompt = "Server URL: " t.CharLimit = 256 - t.Width = 50 - t.Focus() - t.PromptStyle = focusedStyle - t.TextStyle = focusedStyle + initConfigInput(&t, 50, true) case usernameField: t.Placeholder = "Enter your username" t.Prompt = "Username: " t.CharLimit = 32 - t.Width = 30 + initConfigInput(&t, 30, false) case adminField: t.Placeholder = "y/n" t.Prompt = "Admin user? " t.CharLimit = 1 - t.Width = 5 + initConfigInput(&t, 5, false) case adminKeyField: t.Placeholder = "Enter admin key" t.Prompt = "Admin Key: " t.CharLimit = 64 - t.Width = 40 + initConfigInput(&t, 40, false) t.EchoMode = textinput.EchoPassword t.EchoCharacter = '•' case e2eField: t.Placeholder = "y/n" t.Prompt = "Enable E2E encryption? " t.CharLimit = 1 - t.Width = 5 + initConfigInput(&t, 5, false) case keystorePassField: t.Placeholder = "Enter keystore passphrase" t.Prompt = "Keystore passphrase: " t.CharLimit = 128 - t.Width = 40 + initConfigInput(&t, 40, false) t.EchoMode = textinput.EchoPassword t.EchoCharacter = '•' case themeField: t.Placeholder = "system" t.Prompt = "Theme: " t.CharLimit = 20 - t.Width = 20 + initConfigInput(&t, 20, false) } m.inputs[i] = t @@ -129,7 +143,7 @@ func (m ConfigUIModel) Init() tea.Cmd { func (m ConfigUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "esc": m.cancelled = true @@ -233,12 +247,8 @@ func (m *ConfigUIModel) updateFocus() { for i := 0; i < len(m.inputs); i++ { if i == m.focusIndex { m.inputs[i].Focus() - m.inputs[i].PromptStyle = focusedStyle - m.inputs[i].TextStyle = focusedStyle } else { m.inputs[i].Blur() - m.inputs[i].PromptStyle = noStyle - m.inputs[i].TextStyle = noStyle } } } @@ -303,7 +313,7 @@ func (m *ConfigUIModel) validateAndBuildConfig() error { return nil } -func (m ConfigUIModel) View() string { +func (m ConfigUIModel) View() tea.View { var b strings.Builder // Title @@ -368,7 +378,7 @@ func (m ConfigUIModel) View() string { // Help b.WriteString(helpStyle.Render("Tab/Shift+Tab: Navigate • Enter: Select/Submit • Esc: Cancel")) - return b.String() + return tea.NewView(b.String()) } // GetConfig returns the built configuration @@ -434,12 +444,9 @@ func NewProfileSelectionModel(profiles []ConnectionProfile, showNewOption bool) func NewEnhancedProfileSelectionModel(profiles []ConnectionProfile, showNewOption bool, icl *InteractiveConfigLoader) ProfileSelectionModel { // Initialize rename input ti := textinput.New() - ti.Cursor.Style = cursorStyle ti.CharLimit = 50 - ti.Width = 40 ti.Prompt = "New name: " - ti.PromptStyle = focusedStyle - ti.TextStyle = focusedStyle + initConfigInput(&ti, 40, false) return ProfileSelectionModel{ profiles: profiles, @@ -468,7 +475,7 @@ func (m ProfileSelectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle main selection switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: // Clear message on any key press if it's been shown if m.message != "" && m.messageType != "error" { m.message = "" @@ -534,7 +541,7 @@ func (m ProfileSelectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m ProfileSelectionModel) handleViewOperation(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: switch msg.String() { case "esc", "q", "i", "v": m.operation = ProfileOpNone @@ -545,7 +552,7 @@ func (m ProfileSelectionModel) handleViewOperation(msg tea.Msg) (tea.Model, tea. func (m ProfileSelectionModel) handleRenameOperation(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: switch msg.String() { case "esc": m.operation = ProfileOpNone @@ -601,7 +608,7 @@ func (m ProfileSelectionModel) handleRenameOperation(msg tea.Msg) (tea.Model, te func (m ProfileSelectionModel) handleDeleteOperation(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: switch msg.String() { case "esc", "n", "N": m.operation = ProfileOpNone @@ -670,15 +677,15 @@ func (m ProfileSelectionModel) formatProfileLine(i int, profile ConnectionProfil return body + tags.String() } -func (m ProfileSelectionModel) View() string { +func (m ProfileSelectionModel) View() tea.View { // Show operation-specific views switch m.operation { case ProfileOpView: - return m.viewDetails() + return tea.NewView(m.viewDetails()) case ProfileOpRename: - return m.viewRename() + return tea.NewView(m.viewRename()) case ProfileOpDelete: - return m.viewDelete() + return tea.NewView(m.viewDelete()) } // Main profile selection view @@ -727,7 +734,7 @@ func (m ProfileSelectionModel) View() string { b.WriteString("\n") b.WriteString(helpStyle.Render("↑/↓: Navigate • Enter: Select • i: View • r: Rename • d: Delete • Esc: Cancel")) - return b.String() + return tea.NewView(b.String()) } func (m ProfileSelectionModel) viewDetails() string { @@ -972,13 +979,9 @@ func NewSensitiveDataPrompt(isAdmin, useE2E bool) SensitiveDataModel { t.Placeholder = "Enter admin key" t.Prompt = "Admin Key: " t.CharLimit = 64 - t.Width = 40 t.EchoMode = textinput.EchoPassword t.EchoCharacter = '•' - t.Focus() - t.PromptStyle = focusedStyle - t.TextStyle = focusedStyle - t.Cursor.Style = cursorStyle + initConfigInput(&t, 40, true) m.inputs[idx] = t idx++ } @@ -988,15 +991,9 @@ func NewSensitiveDataPrompt(isAdmin, useE2E bool) SensitiveDataModel { t.Placeholder = "Enter keystore passphrase" t.Prompt = "Keystore passphrase: " t.CharLimit = 128 - t.Width = 40 t.EchoMode = textinput.EchoPassword t.EchoCharacter = '•' - t.Cursor.Style = cursorStyle - if !isAdmin { - t.Focus() - t.PromptStyle = focusedStyle - t.TextStyle = focusedStyle - } + initConfigInput(&t, 40, !isAdmin) m.inputs[idx] = t } @@ -1011,7 +1008,7 @@ func (m SensitiveDataModel) Init() tea.Cmd { func (m SensitiveDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "esc": m.cancelled = true @@ -1083,12 +1080,8 @@ func (m *SensitiveDataModel) updateFocus() { for i := 0; i < len(m.inputs); i++ { if i == m.focusIndex { m.inputs[i].Focus() - m.inputs[i].PromptStyle = focusedStyle - m.inputs[i].TextStyle = focusedStyle } else { m.inputs[i].Blur() - m.inputs[i].PromptStyle = blurredStyle - m.inputs[i].TextStyle = blurredStyle } } } @@ -1103,7 +1096,7 @@ func (m *SensitiveDataModel) updateInputs(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } -func (m SensitiveDataModel) View() string { +func (m SensitiveDataModel) View() tea.View { var b strings.Builder b.WriteString(titleStyle.Render("Authentication Required")) @@ -1128,7 +1121,7 @@ func (m SensitiveDataModel) View() string { b.WriteString(helpStyle.Render("Enter: Submit • Esc: Cancel")) } - return b.String() + return tea.NewView(b.String()) } func (m SensitiveDataModel) IsFinished() bool { diff --git a/client/config/interactive_ui_test.go b/client/config/interactive_ui_test.go index 6a7fdd8..5fb7e60 100644 --- a/client/config/interactive_ui_test.go +++ b/client/config/interactive_ui_test.go @@ -3,7 +3,7 @@ package config import ( "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // Test NewConfigUI initialization @@ -50,7 +50,7 @@ func TestConfigUIModelNavigation(t *testing.T) { model := NewConfigUI() // Test tab navigation - tabMsg := tea.KeyMsg{Type: tea.KeyTab} + tabMsg := tea.KeyPressMsg{Code: tea.KeyTab} updatedModel, _ := model.Update(tabMsg) csModel := updatedModel.(ConfigUIModel) @@ -59,7 +59,7 @@ func TestConfigUIModelNavigation(t *testing.T) { } // Test shift+tab navigation (backwards) - shiftTabMsg := tea.KeyMsg{Type: tea.KeyShiftTab} + shiftTabMsg := tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} updatedModel, _ = csModel.Update(shiftTabMsg) csModel = updatedModel.(ConfigUIModel) @@ -68,7 +68,7 @@ func TestConfigUIModelNavigation(t *testing.T) { } // Test down arrow navigation - downMsg := tea.KeyMsg{Type: tea.KeyDown} + downMsg := tea.KeyPressMsg{Code: tea.KeyDown} updatedModel, _ = csModel.Update(downMsg) csModel = updatedModel.(ConfigUIModel) @@ -77,7 +77,7 @@ func TestConfigUIModelNavigation(t *testing.T) { } // Test up arrow navigation - upMsg := tea.KeyMsg{Type: tea.KeyUp} + upMsg := tea.KeyPressMsg{Code: tea.KeyUp} updatedModel, _ = csModel.Update(upMsg) csModel = updatedModel.(ConfigUIModel) @@ -212,7 +212,7 @@ func TestConfigUIModelCancellation(t *testing.T) { model := NewConfigUI() // Test ESC key cancellation - escMsg := tea.KeyMsg{Type: tea.KeyEsc} + escMsg := tea.KeyPressMsg{Code: tea.KeyEsc} updatedModel, _ := model.Update(escMsg) csModel := updatedModel.(ConfigUIModel) @@ -222,7 +222,7 @@ func TestConfigUIModelCancellation(t *testing.T) { // Test Ctrl+C cancellation model = NewConfigUI() - ctrlCMsg := tea.KeyMsg{Type: tea.KeyCtrlC} + ctrlCMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} updatedModel, _ = model.Update(ctrlCMsg) csModel = updatedModel.(ConfigUIModel) @@ -299,7 +299,7 @@ func TestProfileSelectionModelNavigation(t *testing.T) { model := NewProfileSelectionModel(profiles, false) // Test down navigation - downMsg := tea.KeyMsg{Type: tea.KeyDown} + downMsg := tea.KeyPressMsg{Code: tea.KeyDown} updatedModel, _ := model.Update(downMsg) psModel := updatedModel.(ProfileSelectionModel) @@ -308,7 +308,7 @@ func TestProfileSelectionModelNavigation(t *testing.T) { } // Test up navigation - upMsg := tea.KeyMsg{Type: tea.KeyUp} + upMsg := tea.KeyPressMsg{Code: tea.KeyUp} updatedModel, _ = psModel.Update(upMsg) psModel = updatedModel.(ProfileSelectionModel) @@ -317,7 +317,7 @@ func TestProfileSelectionModelNavigation(t *testing.T) { } // Test 'j' key navigation - jMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}} + jMsg := tea.KeyPressMsg{Code: 'j', Text: "j"} updatedModel, _ = psModel.Update(jMsg) psModel = updatedModel.(ProfileSelectionModel) @@ -326,7 +326,7 @@ func TestProfileSelectionModelNavigation(t *testing.T) { } // Test 'k' key navigation - kMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + kMsg := tea.KeyPressMsg{Code: 'k', Text: "k"} updatedModel, _ = psModel.Update(kMsg) psModel = updatedModel.(ProfileSelectionModel) @@ -345,7 +345,7 @@ func TestProfileSelectionModelSelection(t *testing.T) { model := NewProfileSelectionModel(profiles, false) // Test profile selection - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) psModel := updatedModel.(ProfileSelectionModel) @@ -386,7 +386,7 @@ func TestProfileSelectionModelOperations(t *testing.T) { model := NewProfileSelectionModel(profiles, false) // Test view operation - viewMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}} + viewMsg := tea.KeyPressMsg{Code: 'v', Text: "v"} updatedModel, _ := model.Update(viewMsg) psModel := updatedModel.(ProfileSelectionModel) @@ -396,7 +396,7 @@ func TestProfileSelectionModelOperations(t *testing.T) { // Test rename operation model = NewEnhancedProfileSelectionModel(profiles, false, nil) - renameMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} + renameMsg := tea.KeyPressMsg{Code: 'r', Text: "r"} updatedModel, _ = model.Update(renameMsg) psModel = updatedModel.(ProfileSelectionModel) @@ -406,7 +406,7 @@ func TestProfileSelectionModelOperations(t *testing.T) { // Test delete operation model = NewProfileSelectionModel(profiles, false) - deleteMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}} + deleteMsg := tea.KeyPressMsg{Code: 'd', Text: "d"} updatedModel, _ = model.Update(deleteMsg) psModel = updatedModel.(ProfileSelectionModel) @@ -423,7 +423,7 @@ func TestProfileSelectionModelDeleteProtection(t *testing.T) { } model := NewProfileSelectionModel(profiles, false) - deleteMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}} + deleteMsg := tea.KeyPressMsg{Code: 'd', Text: "d"} updatedModel, _ := model.Update(deleteMsg) psModel := updatedModel.(ProfileSelectionModel) @@ -449,7 +449,7 @@ func TestProfileSelectionModelCancellation(t *testing.T) { model := NewProfileSelectionModel(profiles, false) // Test ESC key cancellation - escMsg := tea.KeyMsg{Type: tea.KeyEsc} + escMsg := tea.KeyPressMsg{Code: tea.KeyEsc} updatedModel, _ := model.Update(escMsg) psModel := updatedModel.(ProfileSelectionModel) @@ -459,7 +459,7 @@ func TestProfileSelectionModelCancellation(t *testing.T) { // Test Ctrl+C cancellation model = NewProfileSelectionModel(profiles, false) - ctrlCMsg := tea.KeyMsg{Type: tea.KeyCtrlC} + ctrlCMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} updatedModel, _ = model.Update(ctrlCMsg) psModel = updatedModel.(ProfileSelectionModel) @@ -522,7 +522,7 @@ func TestSensitiveDataModelValidation(t *testing.T) { model := NewSensitiveDataPrompt(true, false) model.inputs[0].SetValue("") // Empty admin key - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) sdModel := updatedModel.(SensitiveDataModel) @@ -556,7 +556,7 @@ func TestSensitiveDataModelSuccessfulCompletion(t *testing.T) { model := NewSensitiveDataPrompt(true, false) model.inputs[0].SetValue("adminkey123") - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) sdModel := updatedModel.(SensitiveDataModel) @@ -588,7 +588,7 @@ func TestSensitiveDataModelSuccessfulCompletion(t *testing.T) { // Simulate typing admin key for _, char := range "adminkey123" { - charMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + charMsg := tea.KeyPressMsg{Code: char, Text: string(char)} updatedModel, _ = model.Update(charMsg) model = updatedModel.(SensitiveDataModel) } @@ -599,7 +599,7 @@ func TestSensitiveDataModelSuccessfulCompletion(t *testing.T) { // Simulate typing keystore passphrase for _, char := range "keystorepass123" { - charMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} + charMsg := tea.KeyPressMsg{Code: char, Text: string(char)} updatedModel, _ = model.Update(charMsg) model = updatedModel.(SensitiveDataModel) } @@ -626,7 +626,7 @@ func TestSensitiveDataModelNavigation(t *testing.T) { model := NewSensitiveDataPrompt(true, true) // Test tab navigation - tabMsg := tea.KeyMsg{Type: tea.KeyTab} + tabMsg := tea.KeyPressMsg{Code: tea.KeyTab} updatedModel, _ := model.Update(tabMsg) sdModel := updatedModel.(SensitiveDataModel) @@ -635,7 +635,7 @@ func TestSensitiveDataModelNavigation(t *testing.T) { } // Test shift+tab navigation - shiftTabMsg := tea.KeyMsg{Type: tea.KeyShiftTab} + shiftTabMsg := tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} updatedModel, _ = sdModel.Update(shiftTabMsg) sdModel = updatedModel.(SensitiveDataModel) @@ -649,7 +649,7 @@ func TestSensitiveDataModelCancellation(t *testing.T) { model := NewSensitiveDataPrompt(true, false) // Test ESC key cancellation - escMsg := tea.KeyMsg{Type: tea.KeyEsc} + escMsg := tea.KeyPressMsg{Code: tea.KeyEsc} updatedModel, _ := model.Update(escMsg) sdModel := updatedModel.(SensitiveDataModel) @@ -659,7 +659,7 @@ func TestSensitiveDataModelCancellation(t *testing.T) { // Test Ctrl+C cancellation model = NewSensitiveDataPrompt(true, false) - ctrlCMsg := tea.KeyMsg{Type: tea.KeyCtrlC} + ctrlCMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} updatedModel, _ = model.Update(ctrlCMsg) sdModel = updatedModel.(SensitiveDataModel) @@ -671,7 +671,7 @@ func TestSensitiveDataModelCancellation(t *testing.T) { // Test View methods func TestConfigUIModelView(t *testing.T) { model := NewConfigUI() - view := model.View() + view := model.View().Content // Check for title if !contains(view, "marchat Configuration") { @@ -692,7 +692,7 @@ func TestProfileSelectionModelView(t *testing.T) { } model := NewProfileSelectionModel(profiles, true) - view := model.View() + view := model.View().Content // Check for title if !contains(view, "Select a connection profile") { @@ -735,7 +735,7 @@ func TestProfileSelectionModelViewDetails(t *testing.T) { model := NewProfileSelectionModel(profiles, false) model.operation = ProfileOpView - view := model.View() + view := model.View().Content // Check for profile details if !contains(view, "Profile Details") { @@ -763,7 +763,7 @@ func TestProfileSelectionModelViewRename(t *testing.T) { model := NewEnhancedProfileSelectionModel(profiles, false, nil) model.operation = ProfileOpRename - view := model.View() + view := model.View().Content // Check for rename interface if !contains(view, "Rename Profile") { @@ -788,7 +788,7 @@ func TestProfileSelectionModelViewDelete(t *testing.T) { model := NewProfileSelectionModel(profiles, false) model.operation = ProfileOpDelete model.deleteConfirm = "TestProfile" - view := model.View() + view := model.View().Content // Check for delete interface if !contains(view, "Delete Profile") { @@ -811,7 +811,7 @@ func TestProfileSelectionModelViewDelete(t *testing.T) { // Test SensitiveDataModel View func TestSensitiveDataModelView(t *testing.T) { model := NewSensitiveDataPrompt(true, true) - view := model.View() + view := model.View().Content // Check for title if !contains(view, "Authentication Required") { diff --git a/client/file_picker.go b/client/file_picker.go index 2426c10..beca5b8 100644 --- a/client/file_picker.go +++ b/client/file_picker.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // File picker states @@ -173,7 +173,14 @@ func (m filePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil return m, nil - case tea.KeyMsg: + case tea.MouseWheelMsg: + if m.state == stateSelectFile { + applyMouseWheelToList(&m.list, msg) + return m, nil + } + return m, nil + + case tea.KeyPressMsg: switch m.state { case stateSelectFile: switch { @@ -251,7 +258,11 @@ func (m filePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m filePickerModel) View() string { +func (m filePickerModel) View() tea.View { + return tea.NewView(m.viewContent()) +} + +func (m filePickerModel) viewContent() string { switch m.state { case stateSelectFile: var s strings.Builder diff --git a/client/file_picker_test.go b/client/file_picker_test.go index 8a765d0..fa46f00 100644 --- a/client/file_picker_test.go +++ b/client/file_picker_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" ) // Test fileItem methods @@ -176,7 +176,7 @@ func TestFilePickerModelEscInSelectState(t *testing.T) { model := newFilePickerModel(styles, 80, 24, func(string) {}, onCancel) // Send ESC key - escMsg := tea.KeyMsg{Type: tea.KeyEsc} + escMsg := tea.KeyPressMsg{Code: tea.KeyEsc} updatedModel, _ := model.Update(escMsg) _ = updatedModel.(filePickerModel) @@ -187,7 +187,7 @@ func TestFilePickerModelEscInSelectState(t *testing.T) { // Test Ctrl+C onCancelCalled = false model = newFilePickerModel(styles, 80, 24, func(string) {}, onCancel) - ctrlCMsg := tea.KeyMsg{Type: tea.KeyCtrlC} + ctrlCMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} updatedModel, _ = model.Update(ctrlCMsg) _ = updatedModel.(filePickerModel) @@ -207,7 +207,7 @@ func TestFilePickerModelEscInConfirmState(t *testing.T) { model.selectedFile = "/path/to/file.txt" // Send ESC key - escMsg := tea.KeyMsg{Type: tea.KeyEsc} + escMsg := tea.KeyPressMsg{Code: tea.KeyEsc} updatedModel, _ := model.Update(escMsg) _ = updatedModel.(filePickerModel) @@ -233,7 +233,7 @@ func TestFilePickerModelEnterInConfirmState(t *testing.T) { model.selectedFile = "/path/to/file.txt" // Send Enter key - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) _ = updatedModel.(filePickerModel) @@ -259,7 +259,7 @@ func TestFilePickerModelRInConfirmState(t *testing.T) { model.fileSize = 1024 // Send 'r' key - rMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} + rMsg := tea.KeyPressMsg{Code: 'r', Text: "r"} updatedModel, _ := model.Update(rMsg) fpModel := updatedModel.(filePickerModel) @@ -281,7 +281,7 @@ func TestFilePickerModelViewSelectState(t *testing.T) { styles := getMockThemeStyles() model := newFilePickerModel(styles, 80, 24, func(string) {}, func() {}) - view := model.View() + view := model.View().Content // Check for title if !contains(view, "Select File to Send") { @@ -300,7 +300,7 @@ func TestFilePickerModelViewWithError(t *testing.T) { model := newFilePickerModel(styles, 80, 24, func(string) {}, func() {}) model.err = fmt.Errorf("test error") - view := model.View() + view := model.View().Content // Check for error message if !contains(view, "[ERROR] test error") { @@ -316,7 +316,7 @@ func TestFilePickerModelViewConfirmState(t *testing.T) { model.selectedFile = "/path/to/test.txt" model.fileSize = 1024 - view := model.View() + view := model.View().Content // Check for confirm title if !contains(view, "Confirm File Send") { @@ -348,7 +348,7 @@ func TestFilePickerModelViewUnknownState(t *testing.T) { model := newFilePickerModel(styles, 80, 24, func(string) {}, func() {}) model.state = 999 // Unknown state - view := model.View() + view := model.View().Content if view != "Unknown state" { t.Errorf("Expected 'Unknown state', got '%s'", view) @@ -503,7 +503,7 @@ func TestFilePickerModelEnterOnDirectory(t *testing.T) { } // Send Enter key - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) fpModel := updatedModel.(filePickerModel) @@ -575,7 +575,7 @@ func TestFilePickerModelEnterOnFile(t *testing.T) { } // Send Enter key - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) fpModel := updatedModel.(filePickerModel) @@ -610,7 +610,7 @@ func TestFilePickerModelEnterOnNonExistentFile(t *testing.T) { model.list.Select(0) // Send Enter key - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) fpModel := updatedModel.(filePickerModel) @@ -679,7 +679,7 @@ func TestFilePickerModelFileSizeLimit(t *testing.T) { } // Send Enter key - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) fpModel := updatedModel.(filePickerModel) @@ -759,7 +759,7 @@ func TestFilePickerModelFileSizeLimitMB(t *testing.T) { } // Send Enter key - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} updatedModel, _ := model.Update(enterMsg) fpModel := updatedModel.(filePickerModel) diff --git a/client/hotkeys.go b/client/hotkeys.go index 8fe14f2..ed750b9 100644 --- a/client/hotkeys.go +++ b/client/hotkeys.go @@ -1,6 +1,6 @@ package main -import "github.com/charmbracelet/bubbles/key" +import "charm.land/bubbles/v2/key" // keyMap defines all keybindings for the help system type keyMap struct { diff --git a/client/main.go b/client/main.go index 57f73d7..ea590c4 100644 --- a/client/main.go +++ b/client/main.go @@ -27,13 +27,13 @@ import ( "log" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/gorilla/websocket" "golang.org/x/term" ) @@ -612,7 +612,7 @@ func (m *model) sidebarDMThreads() []dmSidebarEntry { } func (m *model) updateSidebar() { - sidebarWidth := m.userListViewport.Width + sidebarWidth := m.userListViewport.Width() if sidebarWidth <= 0 { sidebarWidth = userListWidth } @@ -656,7 +656,7 @@ func (m *model) saveDMUIState() { func (m *model) refreshTranscript() { msgs := m.visibleMessages() - content := renderMessages(msgs, m.styles, m.cfg.Username, m.users, m.viewport.Width, m.twentyFourHour, m.showMessageMetadata, m.reactions) + content := renderMessages(msgs, m.styles, m.cfg.Username, m.users, m.viewport.Width(), m.twentyFourHour, m.showMessageMetadata, m.reactions) m.transcriptLineURLs = buildTranscriptLineURLs(msgs, content) m.viewport.SetContent(content) m.updateSidebar() @@ -1174,7 +1174,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Tick(delay, func(time.Time) tea.Msg { return m.Init()() }) - case tea.KeyMsg: + case tea.KeyPressMsg: switch { case key.Matches(v, m.keys.Help): // Close any open menus first @@ -1241,6 +1241,23 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // ESC no longer quits - use :q command instead return m, nil + case m.overlayCapturesKeyboard(): + switch { + case key.Matches(v, m.keys.ScrollUp): + m.scrollActiveViewport(-1) + return m, nil + case key.Matches(v, m.keys.ScrollDown): + m.scrollActiveViewport(1) + return m, nil + case key.Matches(v, m.keys.PageUp): + m.pageScrollActiveViewport(-1) + return m, nil + case key.Matches(v, m.keys.PageDown): + m.pageScrollActiveViewport(1) + return m, nil + default: + return m, nil + } case key.Matches(v, m.keys.DatabaseMenu): // Only show database menu if admin and no other menus are open if *isAdmin && !m.showHelp { @@ -1432,47 +1449,21 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case key.Matches(v, m.keys.ScrollUp): - if m.showHelp { - m.helpViewport.ScrollUp(1) - } else if m.textarea.Focused() { - m.viewport.ScrollUp(1) - } else { - m.userListViewport.ScrollUp(1) - } + m.scrollActiveViewport(-1) return m, nil case key.Matches(v, m.keys.ScrollDown): - if m.showHelp { - m.helpViewport.ScrollDown(1) - } else if m.textarea.Focused() { - m.viewport.ScrollDown(1) - } else { - m.userListViewport.ScrollDown(1) - } - if m.viewport.AtBottom() { - m.unreadCount = 0 - if rr := m.scheduleReadReceiptFlush(); rr != nil { - return m, rr - } + m.scrollActiveViewport(1) + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + return m, cmd } return m, nil case key.Matches(v, m.keys.PageUp): - if m.showHelp { - m.helpViewport.ScrollUp(m.helpViewport.Height) - } else { - m.viewport.ScrollUp(m.viewport.Height) - } + m.pageScrollActiveViewport(-1) return m, nil case key.Matches(v, m.keys.PageDown): - if m.showHelp { - m.helpViewport.ScrollDown(m.helpViewport.Height) - } else { - m.viewport.ScrollDown(m.viewport.Height) - } - if m.viewport.AtBottom() { - m.unreadCount = 0 - if rr := m.scheduleReadReceiptFlush(); rr != nil { - return m, rr - } + m.pageScrollActiveViewport(1) + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + return m, cmd } return m, nil case key.Matches(v, m.keys.Copy): // Custom Copy @@ -2453,8 +2444,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil default: - if m.showDBMenu && len(v.Runes) > 0 { - switch string(v.Runes) { + if m.showDBMenu && len(v.Text) > 0 { + switch v.Text { case "1": return m.executeDBAction("cleardb") case "2": @@ -2463,6 +2454,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.executeDBAction("stats") } } + if m.overlayCapturesKeyboard() || m.subModelCapturesInput() { + return m, nil + } var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(v) @@ -2485,16 +2479,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = v.Width m.height = v.Height - m.help.Width = v.Width + m.help.SetWidth(v.Width) chatWidth := m.width - userListWidth - 4 if chatWidth < 20 { chatWidth = 20 } - m.viewport.Width = chatWidth - m.viewport.Height = m.height - m.textarea.Height() - 6 + m.viewport.SetWidth(chatWidth) + m.viewport.SetHeight(m.height - m.textarea.Height() - 6) m.textarea.SetWidth(chatWidth) - m.userListViewport.Width = userListWidth - m.userListViewport.Height = m.height - m.textarea.Height() - 6 + m.userListViewport.SetWidth(userListWidth) + m.userListViewport.SetHeight(m.height - m.textarea.Height() - 6) // Update help viewport dimensions to be responsive helpWidth := m.width - 8 // Leave reasonable margins @@ -2514,44 +2508,75 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Don't limit height - let it use the full available space - m.helpViewport.Width = helpWidth - m.helpViewport.Height = helpHeight + m.helpViewport.SetWidth(helpWidth) + m.helpViewport.SetHeight(helpViewportContentHeight(helpHeight)) + + dbW, dbH := dbMenuViewportDimensions(m.width, m.height) + m.dbMenuViewport.SetWidth(dbW) + m.dbMenuViewport.SetHeight(dbH) m.refreshTranscript() m.viewport.GotoBottom() return m, nil case quitMsg: return m, tea.Quit - case tea.MouseMsg: - // Handle mouse events for hyperlinks - switch v.Action { - case tea.MouseActionPress: - if v.Button == tea.MouseButtonLeft { - clickedURL := m.findURLAtClickPosition(v.X, v.Y) - if clickedURL != "" { - if err := openURL(clickedURL); err != nil { - m.banner = "[ERROR] Failed to open URL: " + err.Error() - } else { - m.banner = "[OK] Opening URL: " + clickedURL - } + case tea.MouseWheelMsg: + if m.showFilePicker { + var cmd tea.Cmd + updatedModel, cmd := m.filePickerModel.Update(v) + if fpModel, ok := updatedModel.(filePickerModel); ok { + m.filePickerModel = fpModel + } + return m, cmd + } + if m.showCodeSnippet { + var cmd tea.Cmd + updatedModel, cmd := m.codeSnippetModel.Update(v) + if csModel, ok := updatedModel.(codeSnippetModel); ok { + m.codeSnippetModel = csModel + } + return m, cmd + } + if m.updateActiveScrollViewport(v) { + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + return m, cmd + } + return m, nil + } + return m, nil + case tea.MouseClickMsg: + if m.overlayCapturesKeyboard() || m.subModelCapturesInput() { + return m, nil + } + if v.Button == tea.MouseLeft { + mouse := v.Mouse() + clickedURL := m.findURLAtClickPosition(mouse.X, mouse.Y) + if clickedURL != "" { + if err := openURL(clickedURL); err != nil { + m.banner = "[ERROR] Failed to open URL: " + err.Error() + } else { + m.banner = "[OK] Opening URL: " + clickedURL } } } return m, nil default: + if m.overlayCapturesKeyboard() || m.subModelCapturesInput() { + return m, nil + } var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(v) return m, cmd } } -func (m *model) View() string { +func (m *model) View() tea.View { // Header with version headerText := fmt.Sprintf(" marchat %s ", shared.ClientVersion) - header := m.styles.Header.Width(m.viewport.Width + userListWidth + 4).Render(headerText) + header := m.styles.Header.Width(m.viewport.Width() + userListWidth + 4).Render(headerText) footerText := buildStatusFooter(m.connected, m.showHelp, m.unreadCount, m.useE2E, m.currentChannel, m.activeDMThread) - footer := m.styles.Footer.Width(m.viewport.Width + userListWidth + 4).Render(footerText) + footer := m.styles.Footer.Width(m.viewport.Width() + userListWidth + 4).Render(footerText) // Banner var bannerBox string @@ -2565,7 +2590,7 @@ func (m *model) View() string { } } kind := stripKindForBanner(bannerText) - fullW := chromeFullWidth(m.viewport.Width) + fullW := chromeFullWidth(m.viewport.Width()) bannerShown := layoutBannerForStrip(bannerText, fullW) bannerBox = m.styles.BannerStrip(kind). Width(fullW). @@ -2575,7 +2600,7 @@ func (m *model) View() string { // Chat and user list layout chatBoxStyle := m.styles.Box - chatPanel := chatBoxStyle.Width(m.viewport.Width).Render(m.viewport.View()) + chatPanel := chatBoxStyle.Width(m.viewport.Width()).Render(m.viewport.View()) userPanel := m.userListViewport.View() row := lipgloss.JoinHorizontal(lipgloss.Top, userPanel, chatPanel) @@ -2616,7 +2641,7 @@ func (m *model) View() string { typingLine = strings.Join(activeTypers, ", ") + " are typing..." } } - typingIndicator := lipgloss.NewStyle().Faint(true).Italic(true).Width(m.viewport.Width).Render(typingLine) + typingIndicator := lipgloss.NewStyle().Faint(true).Italic(true).Width(m.viewport.Width()).Render(typingLine) // DM mode indicator var dmIndicator string @@ -2629,7 +2654,7 @@ func (m *model) View() string { if dmIndicator != "" { inputContent = dmIndicator + inputContent } - inputPanel := m.styles.Input.Width(m.viewport.Width).Render(inputContent) + inputPanel := m.styles.Input.Width(m.viewport.Width()).Render(inputContent) // Compose layout ui := lipgloss.JoinVertical(lipgloss.Left, @@ -2663,11 +2688,14 @@ func (m *model) View() string { codeContent := m.styles.HelpOverlay. Width(codeWidth). Height(codeHeight). - Render(m.codeSnippetModel.View()) + Render(m.codeSnippetModel.viewContent()) // Center the code snippet modal on the screen ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, codeContent) - return m.styles.Background.Render(ui) + v := tea.NewView(m.styles.Background.Render(ui)) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v } // Show file picker interface as full-screen if shown @@ -2692,11 +2720,14 @@ func (m *model) View() string { fileContent := m.styles.HelpOverlay. Width(fileWidth). Height(fileHeight). - Render(m.filePickerModel.View()) + Render(m.filePickerModel.viewContent()) // Center the file picker modal on the screen ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, fileContent) - return m.styles.Background.Render(ui) + v := tea.NewView(m.styles.Background.Render(ui)) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v } // Show help as full-screen modal if shown @@ -2720,7 +2751,7 @@ func (m *model) View() string { // Don't limit height - let it use the full available space // Create help footer with navigation instructions - helpFooter := "Use ↑/↓ or PgUp/PgDn to scroll • Press Ctrl+H to close help" + helpFooter := "Use ↑/↓, PgUp/PgDn, or mouse wheel to scroll • Press Ctrl+H to close help" footerStyle := lipgloss.NewStyle(). Width(helpWidth). Align(lipgloss.Center). @@ -2731,10 +2762,7 @@ func (m *model) View() string { PaddingTop(1) // Adjust content height to leave room for footer - contentHeight := helpHeight - 3 // Reserve 3 lines for footer (border + padding + text) - if contentHeight < 10 { - contentHeight = 10 - } + contentHeight := helpViewportContentHeight(helpHeight) // Create help content viewport helpContent := m.styles.HelpOverlay. @@ -2755,16 +2783,7 @@ func (m *model) View() string { // Show admin menus if open if m.showDBMenu { - menuWidth := 60 - menuHeight := 15 - - // Ensure minimum size - if m.width < menuWidth+4 { - menuWidth = m.width - 4 - } - if m.height < menuHeight+4 { - menuHeight = m.height - 4 - } + menuWidth, menuHeight := dbMenuViewportDimensions(m.width, m.height) dbMenu := m.styles.HelpOverlay. Width(menuWidth). @@ -2774,7 +2793,10 @@ func (m *model) View() string { ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dbMenu) } - return m.styles.Background.Render(ui) + v := tea.NewView(m.styles.Background.Render(ui)) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v } func main() { @@ -3154,15 +3176,15 @@ func initializeClient(cfg *config.Config, adminKeyParam, keystorePassphraseParam ta.ShowLineNumbers = false ta.KeyMap.InsertNewline.SetEnabled(false) - vp := viewport.New(80, 20) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20)) - userListVp := viewport.New(18, 10) // height will be set on resize + userListVp := viewport.New(viewport.WithWidth(18), viewport.WithHeight(10)) // height will be set on resize userListVp.SetContent(renderUserList([]string{cfg.Username}, cfg.Username, getThemeStyles(cfg.Theme), 18, cfg.IsAdmin, -1, nil)) - helpVp := viewport.New(70, 20) // initial size, will be adjusted on resize + helpVp := viewport.New(viewport.WithWidth(70), viewport.WithHeight(20)) // initial size, will be adjusted on resize // Initialize admin menu viewports - dbMenuVp := viewport.New(60, 15) + dbMenuVp := viewport.New(viewport.WithWidth(60), viewport.WithHeight(15)) // Additional keystore initialization if E2E is enabled if cfg.UseE2E && keystore != nil { @@ -3247,7 +3269,7 @@ func initializeClient(cfg *config.Config, adminKeyParam, keystorePassphraseParam notifConfig := configToNotificationConfig(*cfg) m.notificationManager = NewNotificationManager(notifConfig) - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(m) c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) diff --git a/client/main_test.go b/client/main_test.go index 76473f5..347cf96 100644 --- a/client/main_test.go +++ b/client/main_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" + "charm.land/bubbles/v2/viewport" "github.com/Cod-e-Codes/marchat/client/config" "github.com/Cod-e-Codes/marchat/shared" - "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/x/ansi" ) @@ -165,7 +165,7 @@ func TestRebuildDMUnreadCounts(t *testing.T) { } func TestWsConnectedClearsTranscript(t *testing.T) { - vp := viewport.New(80, 20) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20)) vp.SetContent("stale viewport body") m := &model{ cfg: config.Config{Username: "alice", Theme: "retro"}, @@ -1558,7 +1558,7 @@ func TestDebugWebSocketWriteDetailed(t *testing.T) { } func TestFindURLAtClickPositionMissReturnsEmpty(t *testing.T) { - m := &model{viewport: viewport.New(80, 3)} + m := &model{viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(3))} m.viewport.SetContent("see https://a.com and https://b.com") x0, y0 := m.chatPanelOrigin() if got := m.findURLAtClickPosition(x0+2, y0); got != "" { diff --git a/client/render.go b/client/render.go index 4568916..a9020d3 100644 --- a/client/render.go +++ b/client/render.go @@ -9,9 +9,9 @@ import ( "strings" "unicode/utf8" + "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/shared" "github.com/alecthomas/chroma/quick" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" ) @@ -206,18 +206,54 @@ func markURLsForWrap(s string) string { }) } +// urlMarkerState tracks URL span boundaries and full hrefs across wrapped lines. +type urlMarkerState struct { + open bool + currentURL string + urlIndex int + urlQueue []string +} + +// parseMarkedURLs returns URLs between sentinel pairs in document order. +func parseMarkedURLs(s string) []string { + var urls []string + pos := 0 + for pos < len(s) { + r, sz := utf8.DecodeRuneInString(s[pos:]) + if r != urlStartMarker { + pos += sz + continue + } + pos += sz + var buf strings.Builder + for pos < len(s) { + r, sz = utf8.DecodeRuneInString(s[pos:]) + if r == urlEndMarker { + urls = append(urls, buf.String()) + pos += sz + break + } + buf.WriteRune(r) + pos += sz + } + } + return urls +} + // applyURLMarkers renders hyperlink style for marked URL spans on one wrapped line. -// open tracks an URL span that continues from the previous wrapped line. -func applyURLMarkers(line string, styles themeStyles, open *bool) string { +// state tracks an URL span that continues from the previous wrapped line. +func applyURLMarkers(line string, styles themeStyles, state *urlMarkerState) string { var out strings.Builder var segment strings.Builder - link := *open + link := state.open writeSegment := func() { if segment.Len() == 0 { return } - if link { + if link && state.currentURL != "" { + out.WriteString(styles.Hyperlink.Hyperlink(state.currentURL).Render(segment.String())) + } else if link { out.WriteString(styles.Hyperlink.Render(segment.String())) } else { out.WriteString(segment.String()) @@ -243,16 +279,21 @@ func applyURLMarkers(line string, styles themeStyles, open *bool) string { case urlStartMarker: writeSegment() link = true + if state.urlIndex < len(state.urlQueue) { + state.currentURL = state.urlQueue[state.urlIndex] + } case urlEndMarker: writeSegment() link = false + state.urlIndex++ + state.currentURL = "" default: segment.WriteRune(r) } pos += sz } writeSegment() - *open = link + state.open = link return out.String() } @@ -265,8 +306,8 @@ func wrapStyledBlock(prefix, content, suffix string, width int, styles themeStyl return prefix + suffix } if width <= 0 { - open := false - return prefix + applyURLMarkers(content, styles, &open) + suffix + state := urlMarkerState{urlQueue: parseMarkedURLs(content)} + return prefix + applyURLMarkers(content, styles, &state) + suffix } prefixCells := ansi.StringWidth(prefix) @@ -281,10 +322,10 @@ func wrapStyledBlock(prefix, content, suffix string, width int, styles themeStyl var out strings.Builder first := true for _, paragraph := range strings.Split(content, "\n") { - urlOpen := false + state := urlMarkerState{urlQueue: parseMarkedURLs(paragraph)} wrapped := ansi.Wrap(paragraph, lineWidth, wrapBreakpoints) for _, wl := range strings.Split(wrapped, "\n") { - wl = applyURLMarkers(wl, styles, &urlOpen) + wl = applyURLMarkers(wl, styles, &state) if !first { out.WriteString("\n") } diff --git a/client/render_test.go b/client/render_test.go index 71915a6..713b9ee 100644 --- a/client/render_test.go +++ b/client/render_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" + "charm.land/bubbles/v2/viewport" "github.com/Cod-e-Codes/marchat/shared" - "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/x/ansi" ) @@ -126,20 +126,23 @@ func TestWrapStyledBlockLongMessage(t *testing.T) { } func stripANSIForTest(s string) string { + // Strip CSI and OSC 8 hyperlink sequences so width checks match terminal cells. + s = ansi.Strip(s) var b strings.Builder - esc := false - for _, r := range s { - if r == '\x1b' { - esc = true - continue - } - if esc { - if r == 'm' { - esc = false + i := 0 + for i < len(s) { + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == ']' { + end := strings.IndexByte(s[i:], '\a') + if end < 0 { + b.WriteByte(s[i]) + i++ + continue } + i += end + 1 continue } - b.WriteRune(r) + b.WriteByte(s[i]) + i++ } return b.String() } @@ -179,7 +182,11 @@ func TestWrapStyledBlockLongURLBreaksAtSlashes(t *testing.T) { if !strings.Contains(line, "http") && !strings.Contains(line, "updated:") && !strings.Contains(line, "Cody:") { continue } - if ansi.StringWidth(stripANSIForTest(line)) > width+1 { + tolerance := 1 + if strings.Contains(line, "[id:") { + tolerance = 4 // metadata suffix on wrapped URL continuation lines + } + if ansi.StringWidth(stripANSIForTest(line)) > width+tolerance { t.Fatalf("line exceeds width %d (%d cells): %q", width, ansi.StringWidth(stripANSIForTest(line)), line) } } @@ -233,6 +240,10 @@ func hasHyperlinkANSI(s string) bool { return strings.Contains(s, "[4m") || strings.Contains(s, ";4m") || strings.Contains(s, "[4;") } +func hasOSC8Hyperlink(s string) bool { + return strings.Contains(s, "\x1b]8;;") +} + func TestMarkURLsForWrapInsertsSentinels(t *testing.T) { in := "see https://example.com/path ok" out := markURLsForWrap(in) @@ -246,12 +257,12 @@ func TestApplyURLMarkersAcrossWrappedLines(t *testing.T) { url := "https://github.com/Cod-e-Codes/marchat/commit/8b765b04f82a16c51128261c2fef88c6fef05a61" marked := markURLsForWrap(prepareURLWrapping(url)) wrapped := ansi.Wrap(marked, 30, wrapBreakpoints) - open := false + state := urlMarkerState{urlQueue: parseMarkedURLs(marked)} var styled strings.Builder for _, line := range strings.Split(wrapped, "\n") { - styled.WriteString(applyURLMarkers(line, styles, &open)) + styled.WriteString(applyURLMarkers(line, styles, &state)) } - if open { + if state.open { t.Fatal("expected URL span to close after processing all wrapped lines") } plain := ansi.Strip(styled.String()) @@ -261,14 +272,17 @@ func TestApplyURLMarkersAcrossWrappedLines(t *testing.T) { if plain != prepareURLWrapping(url) { t.Fatalf("styled plain text mismatch:\n got %q\nwant %q", plain, prepareURLWrapping(url)) } - open = false + state = urlMarkerState{urlQueue: parseMarkedURLs(marked)} for _, line := range strings.Split(wrapped, "\n") { if ansi.Strip(line) == "" { continue } - segment := applyURLMarkers(line, styles, &open) + segment := applyURLMarkers(line, styles, &state) + if !hasOSC8Hyperlink(segment) { + t.Fatalf("expected OSC 8 hyperlink on URL segment %q", line) + } if !hasHyperlinkANSI(segment) { - t.Fatalf("expected hyperlink style on URL segment %q", line) + t.Fatalf("expected hyperlink underline on URL segment %q", line) } } } @@ -301,6 +315,9 @@ func TestWrapStyledBlockWrappedURLSegmentsStyled(t *testing.T) { continue } if strings.Contains(trimmed, "github.com") || strings.Contains(trimmed, "marchat") || strings.Contains(trimmed, "8b765b") { + if !hasOSC8Hyperlink(line) { + t.Fatalf("wrapped URL segment missing OSC 8 hyperlink: %q", line) + } if !hasHyperlinkANSI(line) { t.Fatalf("wrapped URL segment missing hyperlink style: %q", line) } @@ -394,9 +411,9 @@ func TestFindURLAtClickPositionThroughViewport(t *testing.T) { const chatWidth = 62 content := renderMessages(msgs, styles, "bob", []string{"Cody", "bob"}, chatWidth, true, true) lineURLs := buildTranscriptLineURLs(msgs, content) - vp := viewport.New(chatWidth, 20) - vp.Width = chatWidth - vp.Height = 20 + vp := viewport.New(viewport.WithWidth(chatWidth), viewport.WithHeight(20)) + vp.SetWidth(chatWidth) + vp.SetHeight(20) vp.SetContent(content) m := &model{viewport: vp, transcriptLineURLs: lineURLs} @@ -438,7 +455,7 @@ func TestExpandClickedURLFromMessage(t *testing.T) { } func TestChatPanelOriginIncludesBoxBorder(t *testing.T) { - m := &model{viewport: viewport.New(62, 10)} + m := &model{viewport: viewport.New(viewport.WithWidth(62), viewport.WithHeight(10))} _, y0 := m.chatPanelOrigin() if y0 != 2 { t.Fatalf("y0=%d want 2 (header + chat box top border)", y0) @@ -454,7 +471,7 @@ func TestFindURLAtClickPositionExpandsPartialMatch(t *testing.T) { full := "https://github.com/Cod-e-Codes/marchat/commit/85bf012bde8a88b9730e9a4ff3015551556835a9" lineURLs := map[int][]string{0: {full}} m := &model{ - viewport: viewport.New(80, 5), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(5)), messages: []shared.Message{{Content: full, Type: shared.TextMessage, Channel: "general"}}, currentChannel: "general", transcriptLineURLs: lineURLs, diff --git a/client/scroll_input.go b/client/scroll_input.go new file mode 100644 index 0000000..6bdd490 --- /dev/null +++ b/client/scroll_input.go @@ -0,0 +1,121 @@ +package main + +import ( + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +// overlayCapturesKeyboard is true when a full-screen overlay owns keyboard input +// besides scroll and dismiss keys (help, admin DB menu). +func (m *model) overlayCapturesKeyboard() bool { + return m.showHelp || m.showDBMenu +} + +// subModelCapturesInput is true when file picker or code snippet modals are open. +func (m *model) subModelCapturesInput() bool { + return m.showFilePicker || m.showCodeSnippet +} + +// activeScrollViewport returns the viewport that should receive scroll and wheel +// input for the current UI mode. Nil when a sub-model owns scrolling. +func (m *model) activeScrollViewport() *viewport.Model { + switch { + case m.showHelp: + return &m.helpViewport + case m.showDBMenu: + return &m.dbMenuViewport + case m.showCodeSnippet, m.showFilePicker: + return nil + case m.textarea.Focused(): + return &m.viewport + default: + return &m.userListViewport + } +} + +func (m *model) scrollActiveViewport(lines int) { + vp := m.activeScrollViewport() + if vp == nil || lines == 0 { + return + } + if lines > 0 { + vp.ScrollDown(lines) + } else { + vp.ScrollUp(-lines) + } +} + +// maybeFlushReadReceipt clears unread and schedules a read receipt only after the +// chat transcript viewport was scrolled to the tail (not help/DB menu/user list). +func (m *model) maybeFlushReadReceipt() tea.Cmd { + if m.activeScrollViewport() != &m.viewport || !m.viewport.AtBottom() { + return nil + } + m.unreadCount = 0 + return m.scheduleReadReceiptFlush() +} + +func (m *model) pageScrollActiveViewport(direction int) { + vp := m.activeScrollViewport() + if vp == nil { + return + } + h := vp.Height() + if h < 1 { + h = 1 + } + if direction > 0 { + vp.ScrollDown(h) + } else { + vp.ScrollUp(h) + } +} + +func (m *model) updateActiveScrollViewport(msg tea.Msg) bool { + vp := m.activeScrollViewport() + if vp == nil { + return false + } + updated, _ := vp.Update(msg) + *vp = updated + return true +} + +func helpViewportContentHeight(totalHeight int) int { + h := totalHeight - 3 // footer border + padding + text + if h < 10 { + return 10 + } + return h +} + +func dbMenuViewportDimensions(totalWidth, totalHeight int) (width, height int) { + width = 60 + height = 15 + if totalWidth < width+4 { + width = totalWidth - 4 + } + if totalHeight < height+4 { + height = totalHeight - 4 + } + if width < 20 { + width = 20 + } + if height < 5 { + height = 5 + } + return width, height +} + +func applyMouseWheelToList(l *list.Model, msg tea.MouseWheelMsg) { + if l == nil { + return + } + switch msg.Button { + case tea.MouseWheelDown: + l.CursorDown() + case tea.MouseWheelUp: + l.CursorUp() + } +} diff --git a/client/scroll_input_test.go b/client/scroll_input_test.go new file mode 100644 index 0000000..ac5331f --- /dev/null +++ b/client/scroll_input_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "testing" + + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +func TestHelpViewportContentHeight(t *testing.T) { + tests := []struct { + total, want int + }{ + {20, 17}, + {12, 10}, + {5, 10}, + } + for _, tt := range tests { + if got := helpViewportContentHeight(tt.total); got != tt.want { + t.Fatalf("helpViewportContentHeight(%d) = %d, want %d", tt.total, got, tt.want) + } + } +} + +func TestDBMenuViewportDimensions(t *testing.T) { + w, h := dbMenuViewportDimensions(120, 40) + if w != 60 || h != 15 { + t.Fatalf("default db menu = %dx%d, want 60x15", w, h) + } + w, h = dbMenuViewportDimensions(50, 12) + if w != 46 || h != 8 { + t.Fatalf("small terminal db menu = %dx%d, want 46x8", w, h) + } +} + +func TestActiveScrollViewport(t *testing.T) { + base := func() *model { + return &model{ + textarea: textarea.New(), + } + } + + m := base() + m.showHelp = true + if m.activeScrollViewport() != &m.helpViewport { + t.Fatal("expected help viewport when help is open") + } + + m = base() + m.showDBMenu = true + if m.activeScrollViewport() != &m.dbMenuViewport { + t.Fatal("expected db menu viewport when db menu is open") + } + + m = base() + m.showHelp = true + m.showDBMenu = true + if m.activeScrollViewport() != &m.helpViewport { + t.Fatal("help should take priority over db menu") + } + + m = base() + m.textarea.Focus() + if m.activeScrollViewport() != &m.viewport { + t.Fatal("expected chat viewport when textarea focused") + } + + m = base() + if m.activeScrollViewport() != &m.userListViewport { + t.Fatal("expected user list viewport by default") + } + + m = base() + m.showFilePicker = true + if m.activeScrollViewport() != nil { + t.Fatal("expected nil when file picker owns scroll") + } +} + +func TestUpdateActiveScrollViewportMouseWheel(t *testing.T) { + m := &model{ + showHelp: true, + helpViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), + } + long := "" + for i := 0; i < 40; i++ { + long += "line\n" + } + m.helpViewport.SetContent(long) + + if !m.updateActiveScrollViewport(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) { + t.Fatal("expected wheel to be handled") + } + if m.helpViewport.YOffset() == 0 { + t.Fatal("expected help viewport to scroll down on wheel") + } + + start := m.helpViewport.YOffset() + if !m.updateActiveScrollViewport(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) { + t.Fatal("expected wheel up to be handled") + } + if m.helpViewport.YOffset() >= start { + t.Fatalf("expected scroll up, yoffset=%d start=%d", m.helpViewport.YOffset(), start) + } +} + +func TestApplyMouseWheelToList(t *testing.T) { + // Covered indirectly via file_picker tests; smoke-test helper does not panic. + var l list.Model + applyMouseWheelToList(&l, tea.MouseWheelMsg{Button: tea.MouseWheelDown}) +} + +func TestOverlayCapturesKeyboard(t *testing.T) { + m := &model{} + if m.overlayCapturesKeyboard() { + t.Fatal("expected false by default") + } + m.showHelp = true + if !m.overlayCapturesKeyboard() { + t.Fatal("help should capture keyboard") + } + m.showHelp = false + m.showDBMenu = true + if !m.overlayCapturesKeyboard() { + t.Fatal("db menu should capture keyboard") + } +} + +func TestSubModelCapturesInput(t *testing.T) { + m := &model{} + if m.subModelCapturesInput() { + t.Fatal("expected false by default") + } + m.showFilePicker = true + if !m.subModelCapturesInput() { + t.Fatal("file picker should capture input") + } + m.showFilePicker = false + m.showCodeSnippet = true + if !m.subModelCapturesInput() { + t.Fatal("code snippet should capture input") + } +} + +func TestMaybeFlushReadReceipt_ScopedToChatViewport(t *testing.T) { + m := &model{textarea: textarea.New()} + m.textarea.Focus() + m.unreadCount = 5 + + m.showHelp = true + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + t.Fatal("read receipt must not flush while help is open") + } + if m.unreadCount != 5 { + t.Fatalf("unread count should stay %d while help blocks flush, got %d", 5, m.unreadCount) + } + + m.showHelp = false + m.showDBMenu = true + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + t.Fatal("read receipt must not flush while db menu is open") + } + if m.unreadCount != 5 { + t.Fatalf("unread count should stay %d while db menu blocks flush, got %d", 5, m.unreadCount) + } + + m.showDBMenu = false + m.textarea.Blur() + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + t.Fatal("read receipt must not flush when user list is the active scroll target") + } + + m.textarea.Focus() + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + t.Fatal("read receipt must not flush when chat viewport is not at bottom") + } +} diff --git a/client/testmain_test.go b/client/testmain_test.go index 9672761..fa73b98 100644 --- a/client/testmain_test.go +++ b/client/testmain_test.go @@ -4,13 +4,13 @@ import ( "os" "testing" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" ) func TestMain(m *testing.M) { // Lipgloss is inert without a TTY; force ANSI256 so render/hyperlink tests // emit real SGR sequences in CI and headless environments. - lipgloss.SetColorProfile(termenv.ANSI256) + lipgloss.Writer.Profile = colorprofile.ANSI256 os.Exit(m.Run()) } diff --git a/client/theme_loader.go b/client/theme_loader.go index c12ed82..c051390 100644 --- a/client/theme_loader.go +++ b/client/theme_loader.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // ThemeColors defines all customizable colors for a theme diff --git a/client/websocket.go b/client/websocket.go index 366e2f3..47e36d7 100644 --- a/client/websocket.go +++ b/client/websocket.go @@ -11,10 +11,10 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" "github.com/Cod-e-Codes/marchat/client/crypto" "github.com/Cod-e-Codes/marchat/client/exthook" "github.com/Cod-e-Codes/marchat/shared" - tea "github.com/charmbracelet/bubbletea" "github.com/gorilla/websocket" ) @@ -462,7 +462,7 @@ func (m *model) chatPanelOrigin() (x0, y0 int) { if m.sending && strings.TrimSpace(bannerText) == "" { bannerText = "[Sending...]" } - fullW := chromeFullWidth(m.viewport.Width) + fullW := chromeFullWidth(m.viewport.Width()) shown := layoutBannerForStrip(bannerText, fullW) y0 += strings.Count(shown, "\n") + 1 } @@ -479,7 +479,7 @@ func (m *model) findURLAtClickPosition(clickX, clickY int) string { x0, y0 := m.chatPanelOrigin() relX := clickX - x0 relY := clickY - y0 - if relX < 0 || relY < 0 || relX >= m.viewport.Width || relY >= m.viewport.Height { + if relX < 0 || relY < 0 || relX >= m.viewport.Width() || relY >= m.viewport.Height() { return "" } @@ -489,7 +489,7 @@ func (m *model) findURLAtClickPosition(clickX, clickY int) string { } trimViewportViewLines(lines) - lineIdx := m.viewport.YOffset + relY + lineIdx := m.viewport.YOffset() + relY if u := urlFromTranscriptIndex(m.transcriptLineURLs, lineIdx, relX, lines[relY]); u != "" { return u } diff --git a/cmd/server/main.go b/cmd/server/main.go index ff0672e..50a0766 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,12 +14,12 @@ import ( "syscall" "time" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/config" "github.com/Cod-e-Codes/marchat/internal/doctor" "github.com/Cod-e-Codes/marchat/server" "github.com/Cod-e-Codes/marchat/shared" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/term" ) @@ -432,7 +432,7 @@ func main() { // Launch admin panel pluginManager := hub.GetPluginManager() panel := server.NewAdminPanel(hub, db, pluginManager, cfg) - p := tea.NewProgram(panel, tea.WithAltScreen()) + p := tea.NewProgram(panel) if _, err := p.Run(); err != nil { server.ServerLogger.Error("Admin panel error", err) } diff --git a/go.mod b/go.mod index bb8eae0..0513c07 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,20 @@ module github.com/Cod-e-Codes/marchat go 1.25.11 require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 github.com/Cod-e-Codes/marchat/plugin/sdk v0.0.0 github.com/alecthomas/chroma v0.10.0 github.com/atotto/clipboard v0.1.4 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/term v0.2.2 github.com/go-sql-driver/mysql v1.10.0 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 github.com/mattn/go-runewidth v0.0.24 - github.com/muesli/termenv v0.16.0 golang.org/x/crypto v0.53.0 golang.org/x/term v0.44.0 modernc.org/sqlite v1.52.0 @@ -24,23 +24,19 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/go.sum b/go.sum index 78fd1db..c18af88 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -6,32 +12,26 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -40,8 +40,6 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -64,20 +62,14 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -103,7 +95,6 @@ golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 0122b2b..ee27f15 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -13,11 +13,11 @@ import ( "strings" "time" + "charm.land/lipgloss/v2" clientcfg "github.com/Cod-e-Codes/marchat/client/config" appconfig "github.com/Cod-e-Codes/marchat/config" "github.com/Cod-e-Codes/marchat/shared" "github.com/atotto/clipboard" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/term" _ "github.com/go-sql-driver/mysql" _ "github.com/jackc/pgx/v5/stdlib" diff --git a/plugin/store/store.go b/plugin/store/store.go index 0787faf..3055a92 100644 --- a/plugin/store/store.go +++ b/plugin/store/store.go @@ -11,13 +11,13 @@ import ( "strings" "time" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/plugin/fileurl" "github.com/Cod-e-Codes/marchat/plugin/sdk" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) // StorePlugin represents a plugin in the store @@ -345,7 +345,9 @@ func NewStoreUI(store *Store) *StoreUI { search := textinput.New() search.Placeholder = "Search plugins..." - search.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + styles := textinput.DefaultDarkStyles() + styles.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + search.SetStyles(styles) s := spinner.New() s.Spinner = spinner.Dot @@ -404,7 +406,17 @@ func (s *StoreUI) Init() tea.Cmd { // Update handles UI updates func (s *StoreUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.MouseWheelMsg: + if s.state == "browsing" && !s.search.Focused() { + switch msg.Button { + case tea.MouseWheelDown: + s.list.CursorDown() + case tea.MouseWheelUp: + s.list.CursorUp() + } + } + return s, nil + case tea.KeyPressMsg: switch s.state { case "browsing": switch msg.String() { @@ -434,7 +446,7 @@ func (s *StoreUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.WindowSizeMsg: s.list.SetSize(msg.Width, msg.Height-2) - s.search.Width = msg.Width - 4 + s.search.SetWidth(msg.Width - 4) case refreshMsg: s.state = "browsing" s.updateList() @@ -464,24 +476,26 @@ func (s *StoreUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // View renders the store UI -func (s *StoreUI) View() string { +func (s *StoreUI) View() tea.View { + var content string switch s.state { case "loading": - return lipgloss.JoinVertical( + content = lipgloss.JoinVertical( lipgloss.Left, "Refreshing plugin store...", s.spinner.View(), ) case "installing": if s.selected == nil { - return "Installing plugin..." + content = "Installing plugin..." + } else { + content = lipgloss.JoinVertical( + lipgloss.Left, + fmt.Sprintf("Installing %s...", s.selected.Name), + s.spinner.View(), + "Press q to cancel", + ) } - return lipgloss.JoinVertical( - lipgloss.Left, - fmt.Sprintf("Installing %s...", s.selected.Name), - s.spinner.View(), - "Press q to cancel", - ) default: var view strings.Builder view.WriteString(s.search.View()) @@ -493,8 +507,11 @@ func (s *StoreUI) View() string { view.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("red")).Render("Error: " + s.err.Error())) } - return view.String() + content = view.String() } + v := tea.NewView(content) + v.MouseMode = tea.MouseModeCellMotion + return v } // refreshMsg is sent when store refresh completes diff --git a/server/admin_panel.go b/server/admin_panel.go index ca8acdd..f9a8c36 100644 --- a/server/admin_panel.go +++ b/server/admin_panel.go @@ -14,11 +14,11 @@ import ( "github.com/Cod-e-Codes/marchat/config" "github.com/Cod-e-Codes/marchat/plugin/manager" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/table" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // Tab types for the admin panel @@ -768,7 +768,7 @@ func (ap *AdminPanel) updateUserTable() { func RunAdminPanel(hub *Hub, db *sql.DB, pluginManager *manager.PluginManager, liveConfig *config.Config) error { panel := NewAdminPanel(hub, db, pluginManager, liveConfig) - p := tea.NewProgram(panel, tea.WithAltScreen()) + p := tea.NewProgram(panel) _, err := p.Run() return err } @@ -776,7 +776,6 @@ func RunAdminPanel(hub *Hub, db *sql.DB, pluginManager *manager.PluginManager, l // Implement tea.Model interface func (ap *AdminPanel) Init() tea.Cmd { return tea.Batch( - tea.EnterAltScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }), @@ -792,7 +791,16 @@ func (ap *AdminPanel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: ap.applyLayout(msg.Width, msg.Height) - case tea.KeyMsg: + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelDown: + ap.handleScroll(3) + case tea.MouseWheelUp: + ap.handleScroll(-3) + } + return ap, nil + + case tea.KeyPressMsg: switch { case key.Matches(msg, ap.keys.Quit): ap.quitting = true @@ -986,6 +994,12 @@ func (ap *AdminPanel) handleScroll(direction int) { if ap.systemScroll < 0 { ap.systemScroll = 0 } + case tabUsers: + if direction > 0 { + ap.userTable.MoveDown(1) + } else { + ap.userTable.MoveUp(1) + } case tabPlugins: // Use table navigation for plugins if direction > 0 { @@ -1095,7 +1109,7 @@ func (ap *AdminPanel) applyLayout(width, height int) { ap.height = height contentWidth := ap.contentWidth() - ap.help.Width = contentWidth + ap.help.SetWidth(contentWidth) ap.userTable.SetWidth(contentWidth) ap.pluginTable.SetWidth(contentWidth) @@ -1110,9 +1124,9 @@ func (ap *AdminPanel) applyLayout(width, height int) { ap.pluginTable.SetHeight(usableHeight) } -func (ap *AdminPanel) View() string { +func (ap *AdminPanel) View() tea.View { if ap.quitting { - return "Admin panel closed. Server continues running.\n" + return tea.NewView("Admin panel closed. Server continues running.\n") } availableWidth := ap.contentWidth() @@ -1140,7 +1154,10 @@ func (ap *AdminPanel) View() string { doc.WriteString(messageStyle.Width(availableWidth).Render(ap.message)) } - return mainBorder.Width(availableWidth + 8).Render(doc.String()) + v := tea.NewView(mainBorder.Width(availableWidth + 8).Render(doc.String())) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v } func (ap *AdminPanel) renderTabs() string { diff --git a/server/config_ui.go b/server/config_ui.go index 4cb9a6b..860bea4 100644 --- a/server/config_ui.go +++ b/server/config_ui.go @@ -7,10 +7,10 @@ import ( "strconv" "strings" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/config" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) var ( @@ -26,6 +26,24 @@ var ( serverBlurredButton = fmt.Sprintf("[ %s ]", serverBlurredStyle.Render("Start Server")) ) +func serverInputStyles() textinput.Styles { + s := textinput.DefaultDarkStyles() + s.Focused.Prompt = serverFocusedStyle + s.Focused.Text = serverFocusedStyle + s.Blurred.Prompt = serverNoStyle + s.Blurred.Text = serverNoStyle + s.Cursor.Color = lipgloss.Color("#FF6B9D") + return s +} + +func initServerInput(t *textinput.Model, width int, focus bool) { + t.SetStyles(serverInputStyles()) + t.SetWidth(width) + if focus { + t.Focus() + } +} + type serverConfigField int const ( @@ -78,17 +96,13 @@ func NewServerConfigUI() ServerConfigModel { var t textinput.Model for i := range m.inputs { t = textinput.New() - t.Cursor.Style = serverCursorStyle switch serverConfigField(i) { case adminKeyField: t.Placeholder = "Enter a secure admin key" t.Prompt = "Admin Key: " t.CharLimit = 128 - t.Width = 50 - t.Focus() - t.PromptStyle = serverFocusedStyle - t.TextStyle = serverFocusedStyle + initServerInput(&t, 50, true) t.EchoMode = textinput.EchoPassword t.EchoCharacter = '•' if config.AdminKey != "" { @@ -98,7 +112,7 @@ func NewServerConfigUI() ServerConfigModel { t.Placeholder = "admin1,admin2,admin3" t.Prompt = "Admin Users: " t.CharLimit = 256 - t.Width = 50 + initServerInput(&t, 50, false) if config.AdminUsers != "" { t.SetValue(config.AdminUsers) } @@ -106,7 +120,7 @@ func NewServerConfigUI() ServerConfigModel { t.Placeholder = "8080" t.Prompt = "Port: " t.CharLimit = 5 - t.Width = 10 + initServerInput(&t, 10, false) t.SetValue(config.Port) } @@ -182,7 +196,7 @@ func (m ServerConfigModel) Init() tea.Cmd { func (m ServerConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: // Clear message on any key press if it's been shown if m.errorMessage != "" && m.errorMessage != "error" { m.errorMessage = "" @@ -246,12 +260,8 @@ func (m *ServerConfigModel) updateFocus() { for i := 0; i < len(m.inputs); i++ { if i == m.focusIndex { m.inputs[i].Focus() - m.inputs[i].PromptStyle = serverFocusedStyle - m.inputs[i].TextStyle = serverFocusedStyle } else { m.inputs[i].Blur() - m.inputs[i].PromptStyle = serverNoStyle - m.inputs[i].TextStyle = serverNoStyle } } } @@ -312,7 +322,7 @@ func (m *ServerConfigModel) validateAndBuildConfig() error { return nil } -func (m ServerConfigModel) View() string { +func (m ServerConfigModel) View() tea.View { var b strings.Builder // Title @@ -356,7 +366,7 @@ func (m ServerConfigModel) View() string { // Help b.WriteString(serverHelpStyle.Render("Tab/Shift+Tab: Navigate • Enter: Select/Submit • Esc: Cancel")) - return b.String() + return tea.NewView(b.String()) } // GetConfig returns the built configuration From 24ee8776bd268b2f28d71a8e1884e6d617fcac75 Mon Sep 17 00:00:00 2001 From: Cod-e-Codes Date: Tue, 16 Jun 2026 18:51:10 -0400 Subject: [PATCH 2/5] fix(client): use ASCII hyphens in OSC 8 hyperlink hrefs Non-breaking hyphens used for wrap breakpoints were leaking into OSC 8 href attributes, producing Cod%E2%80%91e%E2%80%91Codes URLs on click. Normalize hrefs via normalizeURLHyphens before emitting hyperlinks. --- client/render.go | 5 +++-- client/render_test.go | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/client/render.go b/client/render.go index a9020d3..c1c62b7 100644 --- a/client/render.go +++ b/client/render.go @@ -229,7 +229,7 @@ func parseMarkedURLs(s string) []string { for pos < len(s) { r, sz = utf8.DecodeRuneInString(s[pos:]) if r == urlEndMarker { - urls = append(urls, buf.String()) + urls = append(urls, normalizeURLHyphens(buf.String())) pos += sz break } @@ -252,7 +252,8 @@ func applyURLMarkers(line string, styles themeStyles, state *urlMarkerState) str return } if link && state.currentURL != "" { - out.WriteString(styles.Hyperlink.Hyperlink(state.currentURL).Render(segment.String())) + href := normalizeURLHyphens(state.currentURL) + out.WriteString(styles.Hyperlink.Hyperlink(href).Render(segment.String())) } else if link { out.WriteString(styles.Hyperlink.Render(segment.String())) } else { diff --git a/client/render_test.go b/client/render_test.go index 713b9ee..0708a04 100644 --- a/client/render_test.go +++ b/client/render_test.go @@ -284,6 +284,12 @@ func TestApplyURLMarkersAcrossWrappedLines(t *testing.T) { if !hasHyperlinkANSI(segment) { t.Fatalf("expected hyperlink underline on URL segment %q", line) } + if strings.Contains(segment, "Cod\u2011e") || strings.Contains(segment, "Cod%E2%80%91") { + t.Fatalf("OSC 8 href must use ASCII hyphens, not non-breaking hyphens: %q", segment) + } + if !strings.Contains(segment, "\x1b]8;;https://github.com/Cod-e-Codes/") { + t.Fatalf("expected ASCII hyphen in OSC 8 href, got %q", segment) + } } } From eab1766814c56a37ad44b012edda03e54d22cfdc Mon Sep 17 00:00:00 2001 From: Cod-e-Codes Date: Tue, 16 Jun 2026 19:36:17 -0400 Subject: [PATCH 3/5] fix(client): repair composer chrome, multiline keys, and scroll follow Use a single Input background for the full-width composer, route Ctrl+J through textarea.Update for cursor tracking, and delegate arrow keys to the composer when multiline. --- .cursor/skills/client-marchat/SKILL.md | 2 +- CHANGELOG.md | 2 +- client/chrome.go | 97 +++++++++++++++++++++ client/chrome_test.go | 50 +++++++++++ client/main.go | 113 ++++++++++++------------- client/scroll_input.go | 44 ++++++++++ client/scroll_input_test.go | 25 ++++++ client/theme_loader.go | 5 +- go.mod | 2 +- 9 files changed, 277 insertions(+), 63 deletions(-) diff --git a/.cursor/skills/client-marchat/SKILL.md b/.cursor/skills/client-marchat/SKILL.md index ccf8a79..18e3dbf 100644 --- a/.cursor/skills/client-marchat/SKILL.md +++ b/.cursor/skills/client-marchat/SKILL.md @@ -14,7 +14,7 @@ Bubble Tea + Lipgloss TUI on **Charm v2** (`charm.land/bubbletea/v2`, `bubbles/v ## Patterns -- **Charm v2**: `View() tea.View` sets `AltScreen` and `MouseModeCellMotion` on the main client; `KeyPressMsg` replaces `KeyMsg`; bubbles use `SetWidth` / `SetHeight` / `SetStyles`. +- **Charm v2**: `newMainTeaView` sets `AltScreen`, `MouseModeCellMotion` (disabled while Shift is held for terminal drag-select), and `tea.View.BackgroundColor` (`altScreenFill` / black). `chromeComposerPanel` is full-width with theme `Input` background only (textarea styles are foreground-only). Transcript interior uses `transcriptFill` on `Box`. `configureTextareaChrome` syncs textarea colors with theme `Input`. Multiline composer: Ctrl+J via `textarea.Update`; up/down move cursor when value contains `\n`, else scroll chat. `KeyPressMsg` / `KeyReleaseMsg`; bubbles use `SetWidth` / `SetHeight` / `SetStyles`. - **Reconnect**: exponential backoff (capped at 30s); delay resets only after successful connect (`wsConnected`), not each `Init()` retry; no reconnect on fatal username/handshake errors (`websocket.go`, `main.go`). - **Commands**: `:q` quits; `Esc` closes menus; help in `commands.go` (shortcuts vs text commands). Transient command results belong in the **banner** when short; longer lists (e.g. `:themes`) may use transcript System lines. - **E2E**: same wire path for channel text and DMs when encryption on; files via keystore `EncryptRaw` / `DecryptRaw`. Do not log plaintext on send/decrypt paths. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c035d7..90c50ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Narrative notes by release. Per-file binaries and assets: [GitHub releases](http On **`main`** only; not part of the latest tagged release until you tag and publish. Compare against the current tag on [GitHub releases](https://github.com/Cod-e-Codes/marchat/releases). -- **Client**: Word-wrap chat message bodies to the transcript viewport width (ANSI-aware). Reaction aliases `thumbsup` / `thumbsdown`; `:unreact`, `:thumbsup`, and `:thumbsdown` commands. When E2E is on and server search returns no matches, a `System` line explains that search matches stored ciphertext, not decrypted plaintext. **Fix:** long URLs break at path boundaries on resize (not mid-domain hyphens); hyperlink color and underline follow wrapped URL segments without styling continuation indent. **Fix:** wrapped URL segments emit OSC 8 hyperlinks (Lip Gloss v2 `Style.Hyperlink`) with the full href on every fragment; mouse click-to-open remains as fallback on terminals without OSC 8 ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)). **Charm v2:** Bubble Tea, Bubbles, and Lip Gloss migrated to `charm.land/*/v2` (declarative `tea.View`, `KeyPressMsg`, bubbles setters). **Fix:** mouse wheel scroll routes to the active viewport (help, DB menu, chat, user list) after v2 split `MouseWheelMsg` from click events; file picker and code-snippet language lists scroll on wheel. **Fix:** help and DB menu overlays block chat typing indicators, URL click handling, and read-receipt flush while browsing overlay content; read receipts only schedule when the chat transcript viewport is scrolled to the tail; DB menu viewport resizes with the terminal. **Fix:** ephemeral `System` feedback (client usage errors, server command denials like admin-only, plugin one-liners) uses the banner instead of sticking in the transcript; scrollable lists (search, themes, channels) stay in the transcript. **Fix:** reconnect exponential backoff advances on failure (no longer reset each attempt); client transcript notices keep negative `message_id` until pruned, scoped to the active channel, and survive inbound chat; reactions and read receipts include channel; E2E paths no longer log plaintext. +- **Client**: Word-wrap chat message bodies to the transcript viewport width (ANSI-aware). Reaction aliases `thumbsup` / `thumbsdown`; `:unreact`, `:thumbsup`, and `:thumbsdown` commands. When E2E is on and server search returns no matches, a `System` line explains that search matches stored ciphertext, not decrypted plaintext. **Fix:** long URLs break at path boundaries on resize (not mid-domain hyphens); hyperlink color and underline follow wrapped URL segments without styling continuation indent. **Fix:** wrapped URL segments emit OSC 8 hyperlinks (Lip Gloss v2 `Style.Hyperlink`) with the full href on every fragment; mouse click-to-open remains as fallback on terminals without OSC 8 ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)). **Charm v2:** Bubble Tea, Bubbles, and Lip Gloss migrated to `charm.land/*/v2` (declarative `tea.View`, `KeyPressMsg`, bubbles setters). **Fix:** mouse wheel scroll routes to the active viewport (help, DB menu, chat, user list) after v2 split `MouseWheelMsg` from click events; file picker and code-snippet language lists scroll on wheel. **Fix:** help and DB menu overlays block chat typing indicators, URL click handling, and read-receipt flush while browsing overlay content; read receipts only schedule when the chat transcript viewport is scrolled to the tail; DB menu viewport resizes with the terminal. **Fix:** v2 alt-screen `BackgroundColor` plus textarea chrome helpers restore main-branch layout (no black gutters, 3-line input, stable Users border) while keeping scroll routing and OSC 8 hyperlinks. **Fix:** ephemeral `System` feedback (client usage errors, server command denials like admin-only, plugin one-liners) uses the banner instead of sticking in the transcript; scrollable lists (search, themes, channels) stay in the transcript. **Fix:** reconnect exponential backoff advances on failure (no longer reset each attempt); client transcript notices keep negative `message_id` until pruned, scoped to the active channel, and survive inbound chat; reactions and read receipts include channel; E2E paths no longer log plaintext. - **Server**: Handshake replay queries up to 50 **visible** recent messages (SQL `LIMIT` after DM/public filter), on every connect including reconnect with no new traffic. `user_message_state` records `last_seen` only (`last_message_id` legacy/unused). `:cleardb` clears `user_message_state`. Postgres/MySQL CI smoke and WebSocket integration test cover visible replay SQL and second-connect wire replay. **Fix:** admin panel TUI enables mouse mode and handles `MouseWheelMsg` for scrollable tabs and user/plugin tables. **Fix:** Postgres `boolean = integer` errors on `:search`, pin toggle, and pinned listing (shared dialect boolean helpers). **Fix:** MySQL `InitDB` parses DSNs with `mysql.Config`, forces `parseTime=true` (overrides explicit `parseTime=false`; logs a warning), and sets `Loc` to local time when unset. **Fix:** outbound messages always use the sender's joined channel (blocks cross-channel spoofing); typing, reactions, and read receipts are channel-scoped; non-admin unknown commands return `Unknown command` instead of admin-only text; `:backup` is SQLite-only with a clear error on Postgres/MySQL; legacy Postgres migrations use `BOOLEAN DEFAULT FALSE`. - **Plugins**: **Fix:** plugin stdin writes are serialized so chat fan-out and command RPC cannot corrupt IPC lines. - **Docs**: Coverage tables in **README** and **TESTING** drop per-package line counts; regeneration uses `go test -coverprofile` and `go tool cover -func` only. **ARCHITECTURE** and **PROTOCOL** document channel stamping, SQLite-only `:backup`, plugin stdin serialization, and client reconnect/E2E logging behavior; wrapped URL click-to-open limitation documented ([#103](https://github.com/Cod-e-Codes/marchat/issues/103)). Agent skills updated to match. diff --git a/client/chrome.go b/client/chrome.go index 47fc75a..714214a 100644 --- a/client/chrome.go +++ b/client/chrome.go @@ -12,21 +12,118 @@ package main import ( "fmt" + "image/color" "strings" "time" + "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/shared" + "github.com/lucasb-eyer/go-colorful" ) const readReceiptDebounce = 750 * time.Millisecond +// altScreenFill is the Bubble Tea v2 alt-screen base color (unpainted cells). Kept black +// so the transcript reads on true black; header/footer/input carry theme chrome colors. +const altScreenFill = "#000000" + +// transcriptFill is the chat viewport interior behind messages. +const transcriptFill = "#000000" + // chromeFullWidth matches header and footer: chat viewport plus user list plus gap. func chromeFullWidth(viewportW int) int { return viewportW + userListWidth + 4 } +// composeInputWidth is the bubbles textarea width inside the full-width composer bar. +func composeInputWidth(viewportW int) int { + w := chromeFullWidth(viewportW) - 4 + if w < 20 { + return 20 + } + return w +} + +// configureTextareaChrome syncs textarea foreground/prompt with theme Input. +// Background fill comes from chromeComposerPanel only (avoids nested boxes). +func configureTextareaChrome(ta *textarea.Model, input lipgloss.Style) { + s := textarea.DefaultDarkStyles() + noBG := lipgloss.NewStyle() + text := noBG + if fg := input.GetForeground(); fg != nil { + text = text.Foreground(fg) + } + faint := text.Copy().Faint(true) + for _, state := range []*textarea.StyleState{&s.Focused, &s.Blurred} { + state.Base = noBG + state.Text = text + state.CursorLine = text + state.Prompt = faint + state.Placeholder = faint + state.LineNumber = faint + state.CursorLineNumber = faint + state.EndOfBuffer = faint + } + s.Cursor.Blink = false + if fg := input.GetForeground(); fg != nil { + s.Cursor.Color = fg + } + ta.SetStyles(s) +} + +// chromeComposerPanel renders the full-width message composer (header/footer alignment). +func chromeComposerPanel(styles themeStyles, fullW int, inputContent string) string { + return styles.Input.Width(fullW).Padding(0, 1).Render(inputContent) +} + +// chromeTypingLine renders a full-width typing indicator under the main grid. +func chromeTypingLine(fullW int, line string) string { + if strings.TrimSpace(line) == "" { + return "" + } + return lipgloss.NewStyle().Faint(true).Italic(true).Width(fullW).PaddingLeft(1).Render(line) +} + +// newMainTeaView renders the main TUI on the alt screen. Bubble Tea v2 leaves unpainted +// alt-screen cells black unless BackgroundColor is set; lipgloss Background.Render alone +// only covers laid-out content (main v1 did not have this gap). +func newMainTeaView(styles themeStyles, ui string, shiftHeld bool) tea.View { + v := tea.NewView(styles.Background.Render(ui)) + if bg, ok := styles.terminalBGColor(); ok { + v.BackgroundColor = bg + } + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + if shiftHeld { + // Release mouse capture so the terminal can drag-select transcript text (Shift+drag). + v.MouseMode = tea.MouseModeNone + } + return v +} + +func (s themeStyles) terminalBGColor() (color.Color, bool) { + if s.screenBG == "" { + return nil, false + } + c, err := colorful.Hex(s.screenBG) + if err != nil { + return nil, false + } + return c, true +} + +// updateModifierKeys tracks Shift for terminal text selection passthrough. +func (m *model) updateModifierKeys(k tea.Key) { + switch k.Code { + case tea.KeyLeftShift, tea.KeyRightShift: + m.shiftHeld = true + default: + m.shiftHeld = k.Mod&tea.ModShift != 0 + } +} + // layoutBannerForStrip collapses newlines to spaces and truncates to one line so a // long [ERROR] path does not consume most of the terminal height under JoinVertical. func layoutBannerForStrip(text string, width int) string { diff --git a/client/chrome_test.go b/client/chrome_test.go index f9eb202..e70fe8e 100644 --- a/client/chrome_test.go +++ b/client/chrome_test.go @@ -4,6 +4,9 @@ import ( "strings" "testing" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/shared" ) @@ -141,3 +144,50 @@ func TestMaxMessageID(t *testing.T) { t.Fatalf("maxMessageID(nil) = %d, want 0", id) } } + +func TestConfigureTextareaChrome(t *testing.T) { + styles := getThemeStyles("modern") + ta := textarea.New() + before := ta.Styles().Focused.CursorLine.Render("x") + configureTextareaChrome(&ta, styles.Input) + after := ta.Styles().Focused.CursorLine.Render("x") + if before == after { + t.Fatal("expected CursorLine styling to change") + } + if ta.Styles().Cursor.Blink { + t.Fatal("expected cursor blink disabled") + } +} + +func TestNewMainTeaViewSetsTerminalBG(t *testing.T) { + styles := getThemeStyles("modern") + v := newMainTeaView(styles, "hello", false) + if v.BackgroundColor == nil { + t.Fatal("expected modern theme to set alt-screen background color") + } + if !v.AltScreen || v.MouseMode != tea.MouseModeCellMotion { + t.Fatal("expected alt screen and mouse mode") + } +} + +func TestNewMainTeaViewShiftDisablesMouse(t *testing.T) { + styles := getThemeStyles("modern") + v := newMainTeaView(styles, "hello", true) + if v.MouseMode != tea.MouseModeNone { + t.Fatalf("shift-held view should disable mouse, got %v", v.MouseMode) + } +} + +func TestChromeComposerPanelFullWidth(t *testing.T) { + styles := getThemeStyles("retro") + row := chromeComposerPanel(styles, 72, "type here") + if lipgloss.Width(row) < 72 { + t.Fatalf("expected full composer width, got %d", lipgloss.Width(row)) + } +} + +func TestComposeInputWidth(t *testing.T) { + if w := composeInputWidth(50); w != chromeFullWidth(50)-4 { + t.Fatalf("composeInputWidth = %d, want %d", w, chromeFullWidth(50)-4) + } +} diff --git a/client/main.go b/client/main.go index ea590c4..968fae1 100644 --- a/client/main.go +++ b/client/main.go @@ -148,6 +148,8 @@ type model struct { width int // NEW: track window width height int // NEW: track window height + shiftHeld bool // disables mouse capture for terminal text selection + userListViewport viewport.Model // NEW: scrollable user list twentyFourHour bool // NEW: timestamp format toggle @@ -685,6 +687,7 @@ type themeStyles struct { Other lipgloss.Style // NEW: other user style Background lipgloss.Style // NEW: main background + screenBG string // alt-screen fill hex; empty = terminal default (system theme) Header lipgloss.Style // NEW: header background Footer lipgloss.Style // NEW: footer background Input lipgloss.Style // NEW: input background @@ -823,13 +826,14 @@ func getThemeStyles(theme string) themeStyles { s.User = s.User.Foreground(lipgloss.Color("#002868")) // Navy blue s.Time = s.Time.Foreground(lipgloss.Color("#BF0A30")).Faint(false) // Red s.Msg = s.Msg.Foreground(lipgloss.Color("#FFFFFF")) - s.Box = s.Box.BorderForeground(lipgloss.Color("#BF0A30")) + s.Box = s.Box.BorderForeground(lipgloss.Color("#BF0A30")).Background(lipgloss.Color(transcriptFill)) s.Mention = s.Mention.Foreground(lipgloss.Color("#FFD700")) // Gold s.Hyperlink = s.Hyperlink.Foreground(lipgloss.Color("#87CEEB")) // Sky blue s.UserList = s.UserList.BorderForeground(lipgloss.Color("#002868")) s.Me = s.Me.Foreground(lipgloss.Color("#BF0A30")) // Background and UI - s.Background = lipgloss.NewStyle().Background(lipgloss.Color("#00203F")) // Deep navy + s.Background = lipgloss.NewStyle().Background(lipgloss.Color(altScreenFill)) + s.screenBG = altScreenFill s.Header = lipgloss.NewStyle().Background(lipgloss.Color("#BF0A30")).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) s.Footer = lipgloss.NewStyle().Background(lipgloss.Color("#00203F")).Foreground(lipgloss.Color("#FFD700")) s.Input = lipgloss.NewStyle().Background(lipgloss.Color("#002868")).Foreground(lipgloss.Color("#FFFFFF")) @@ -838,13 +842,14 @@ func getThemeStyles(theme string) themeStyles { s.User = s.User.Foreground(lipgloss.Color("#FF8800")) // Orange s.Time = s.Time.Foreground(lipgloss.Color("#00FF00")).Faint(false) // Green s.Msg = s.Msg.Foreground(lipgloss.Color("#FFFFAA")) - s.Box = s.Box.BorderForeground(lipgloss.Color("#FF8800")) + s.Box = s.Box.BorderForeground(lipgloss.Color("#FF8800")).Background(lipgloss.Color(transcriptFill)) s.Mention = s.Mention.Foreground(lipgloss.Color("#00FFFF")) // Cyan s.Hyperlink = s.Hyperlink.Foreground(lipgloss.Color("#00FFFF")) // Cyan s.UserList = s.UserList.BorderForeground(lipgloss.Color("#FF8800")) s.Me = s.Me.Foreground(lipgloss.Color("#FF8800")) // Background and UI - s.Background = lipgloss.NewStyle().Background(lipgloss.Color("#181818")) // Retro dark + s.Background = lipgloss.NewStyle().Background(lipgloss.Color(altScreenFill)) + s.screenBG = altScreenFill s.Header = lipgloss.NewStyle().Background(lipgloss.Color("#FF8800")).Foreground(lipgloss.Color("#181818")).Bold(true) s.Footer = lipgloss.NewStyle().Background(lipgloss.Color("#181818")).Foreground(lipgloss.Color("#00FF00")) s.Input = lipgloss.NewStyle().Background(lipgloss.Color("#222200")).Foreground(lipgloss.Color("#FFFFAA")) @@ -853,13 +858,14 @@ func getThemeStyles(theme string) themeStyles { s.User = s.User.Foreground(lipgloss.Color("#4F8EF7")) // Blue s.Time = s.Time.Foreground(lipgloss.Color("#A0A0A0")).Faint(false) // Gray s.Msg = s.Msg.Foreground(lipgloss.Color("#E0E0E0")) - s.Box = s.Box.BorderForeground(lipgloss.Color("#4F8EF7")) + s.Box = s.Box.BorderForeground(lipgloss.Color("#4F8EF7")).Background(lipgloss.Color(transcriptFill)) s.Mention = s.Mention.Foreground(lipgloss.Color("#FF5F5F")) // Red s.Hyperlink = s.Hyperlink.Foreground(lipgloss.Color("#4A9EFF")) // Bright blue s.UserList = s.UserList.BorderForeground(lipgloss.Color("#4F8EF7")) s.Me = s.Me.Foreground(lipgloss.Color("#4F8EF7")) // Background and UI - s.Background = lipgloss.NewStyle().Background(lipgloss.Color("#181C24")) // Modern dark blue-gray + s.Background = lipgloss.NewStyle().Background(lipgloss.Color(altScreenFill)) + s.screenBG = altScreenFill s.Header = lipgloss.NewStyle().Background(lipgloss.Color("#4F8EF7")).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) s.Footer = lipgloss.NewStyle().Background(lipgloss.Color("#181C24")).Foreground(lipgloss.Color("#4F8EF7")) s.Input = lipgloss.NewStyle().Background(lipgloss.Color("#23272E")).Foreground(lipgloss.Color("#E0E0E0")) @@ -882,20 +888,22 @@ type fileSendMsg struct { func (m *model) Init() tea.Cmd { m.msgChan = make(chan tea.Msg, 10) // buffered to avoid blocking - return func() tea.Msg { - err := m.connectWebSocket(m.cfg.ServerURL) - if err != nil { - log.Printf("connectWebSocket returned error: %v (type: %T)", err, err) - // Preserve wsUsernameError type - if usernameErr, ok := err.(wsUsernameError); ok { - log.Printf("Detected username error: %s", usernameErr.message) - return usernameErr + return tea.Batch( + tea.RequestWindowSize, + func() tea.Msg { + err := m.connectWebSocket(m.cfg.ServerURL) + if err != nil { + log.Printf("connectWebSocket returned error: %v (type: %T)", err, err) + if usernameErr, ok := err.(wsUsernameError); ok { + log.Printf("Detected username error: %s", usernameErr.message) + return usernameErr + } + log.Printf("Returning generic wsErr") + return wsErr{err} } - log.Printf("Returning generic wsErr") - return wsErr{err} - } - return wsConnected{} - } + return wsConnected{} + }, + ) } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -1175,6 +1183,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.Init()() }) case tea.KeyPressMsg: + m.updateModifierKeys(v.Key()) switch { case key.Matches(v, m.keys.Help): // Close any open menus first @@ -1335,6 +1344,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newTheme := themes[nextIndex] m.cfg.Theme = newTheme m.styles = getThemeStyles(m.cfg.Theme) + configureTextareaChrome(&m.textarea, m.styles.Input) _ = config.SaveConfig(m.configFilePath, m.cfg) // Update profile with new theme @@ -1443,29 +1453,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.promptForUsername("allow") } return m, nil + case key.Matches(v, m.keys.ScrollUp), key.Matches(v, m.keys.ScrollDown), + key.Matches(v, m.keys.PageUp), key.Matches(v, m.keys.PageDown): + _, cmd := m.handleComposerScrollKey(v) + return m, cmd case key.Matches(v, m.keys.ForceDisconnectUser): if *isAdmin && m.selectedUser != "" && m.selectedUser != m.cfg.Username { return m.executeAdminAction("forcedisconnect", m.selectedUser) } return m, nil - case key.Matches(v, m.keys.ScrollUp): - m.scrollActiveViewport(-1) - return m, nil - case key.Matches(v, m.keys.ScrollDown): - m.scrollActiveViewport(1) - if cmd := m.maybeFlushReadReceipt(); cmd != nil { - return m, cmd - } - return m, nil - case key.Matches(v, m.keys.PageUp): - m.pageScrollActiveViewport(-1) - return m, nil - case key.Matches(v, m.keys.PageDown): - m.pageScrollActiveViewport(1) - if cmd := m.maybeFlushReadReceipt(); cmd != nil { - return m, cmd - } - return m, nil case key.Matches(v, m.keys.Copy): // Custom Copy if m.textarea.Focused() { text := m.textarea.Value() @@ -1782,6 +1778,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.cfg.Theme = actualThemeName m.styles = getThemeStyles(m.cfg.Theme) + configureTextareaChrome(&m.textarea, m.styles.Input) _ = config.SaveConfig(m.configFilePath, m.cfg) // Update profile with new theme @@ -2421,10 +2418,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case key.Matches(v, key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"))): - current := m.textarea.Value() - m.textarea.SetValue(current + "\n") - m.textarea.CursorEnd() - return m, nil + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(tea.KeyPressMsg(tea.Key{Text: "\n"})) + return m, cmd case key.Matches(v, key.NewBinding(key.WithKeys("tab"))): text := m.textarea.Value() words := strings.Fields(text) @@ -2486,7 +2482,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.viewport.SetWidth(chatWidth) m.viewport.SetHeight(m.height - m.textarea.Height() - 6) - m.textarea.SetWidth(chatWidth) + m.textarea.SetWidth(composeInputWidth(chatWidth)) m.userListViewport.SetWidth(userListWidth) m.userListViewport.SetHeight(m.height - m.textarea.Height() - 6) @@ -2548,6 +2544,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.overlayCapturesKeyboard() || m.subModelCapturesInput() { return m, nil } + if v.Mod&tea.ModShift != 0 { + return m, nil + } if v.Button == tea.MouseLeft { mouse := v.Mouse() clickedURL := m.findURLAtClickPosition(mouse.X, mouse.Y) @@ -2560,6 +2559,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return m, nil + case tea.KeyReleaseMsg: + if v.Key().Code == tea.KeyLeftShift || v.Key().Code == tea.KeyRightShift { + m.shiftHeld = false + } + return m, nil default: if m.overlayCapturesKeyboard() || m.subModelCapturesInput() { return m, nil @@ -2601,7 +2605,7 @@ func (m *model) View() tea.View { // Chat and user list layout chatBoxStyle := m.styles.Box chatPanel := chatBoxStyle.Width(m.viewport.Width()).Render(m.viewport.View()) - userPanel := m.userListViewport.View() + userPanel := lipgloss.NewStyle().MarginRight(1).Render(m.userListViewport.View()) row := lipgloss.JoinHorizontal(lipgloss.Top, userPanel, chatPanel) // Typing indicator @@ -2641,7 +2645,7 @@ func (m *model) View() tea.View { typingLine = strings.Join(activeTypers, ", ") + " are typing..." } } - typingIndicator := lipgloss.NewStyle().Faint(true).Italic(true).Width(m.viewport.Width()).Render(typingLine) + typingIndicator := chromeTypingLine(chromeFullWidth(m.viewport.Width()), typingLine) // DM mode indicator var dmIndicator string @@ -2654,7 +2658,8 @@ func (m *model) View() tea.View { if dmIndicator != "" { inputContent = dmIndicator + inputContent } - inputPanel := m.styles.Input.Width(m.viewport.Width()).Render(inputContent) + fullW := chromeFullWidth(m.viewport.Width()) + inputPanel := chromeComposerPanel(m.styles, fullW, inputContent) // Compose layout ui := lipgloss.JoinVertical(lipgloss.Left, @@ -2692,10 +2697,7 @@ func (m *model) View() tea.View { // Center the code snippet modal on the screen ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, codeContent) - v := tea.NewView(m.styles.Background.Render(ui)) - v.AltScreen = true - v.MouseMode = tea.MouseModeCellMotion - return v + return newMainTeaView(m.styles, ui, m.shiftHeld) } // Show file picker interface as full-screen if shown @@ -2724,10 +2726,7 @@ func (m *model) View() tea.View { // Center the file picker modal on the screen ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, fileContent) - v := tea.NewView(m.styles.Background.Render(ui)) - v.AltScreen = true - v.MouseMode = tea.MouseModeCellMotion - return v + return newMainTeaView(m.styles, ui, m.shiftHeld) } // Show help as full-screen modal if shown @@ -2752,7 +2751,7 @@ func (m *model) View() tea.View { // Create help footer with navigation instructions helpFooter := "Use ↑/↓, PgUp/PgDn, or mouse wheel to scroll • Press Ctrl+H to close help" - footerStyle := lipgloss.NewStyle(). + footerStyle := m.styles.HelpOverlay. Width(helpWidth). Align(lipgloss.Center). Foreground(lipgloss.Color("#888888")). @@ -2793,10 +2792,7 @@ func (m *model) View() tea.View { ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dbMenu) } - v := tea.NewView(m.styles.Background.Render(ui)) - v.AltScreen = true - v.MouseMode = tea.MouseModeCellMotion - return v + return newMainTeaView(m.styles, ui, m.shiftHeld) } func main() { @@ -3175,6 +3171,7 @@ func initializeClient(cfg *config.Config, adminKeyParam, keystorePassphraseParam ta.SetHeight(3) ta.ShowLineNumbers = false ta.KeyMap.InsertNewline.SetEnabled(false) + configureTextareaChrome(&ta, getThemeStyles(cfg.Theme).Input) vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20)) diff --git a/client/scroll_input.go b/client/scroll_input.go index 6bdd490..e5e7a40 100644 --- a/client/scroll_input.go +++ b/client/scroll_input.go @@ -1,6 +1,9 @@ package main import ( + "strings" + + "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" @@ -17,6 +20,47 @@ func (m *model) subModelCapturesInput() bool { return m.showFilePicker || m.showCodeSnippet } +// textareaWantsArrowKeys is true when up/down should move the cursor inside a +// multiline composer instead of scrolling the chat transcript. +func (m *model) textareaWantsArrowKeys() bool { + return m.textarea.Focused() && strings.Contains(m.textarea.Value(), "\n") +} + +// handleComposerScrollKey routes arrow/page keys between the composer and the +// active scroll viewport. Returns handled and an optional command. +func (m *model) handleComposerScrollKey(v tea.KeyPressMsg) (bool, tea.Cmd) { + if m.textareaWantsArrowKeys() { + switch { + case key.Matches(v, m.keys.ScrollUp), key.Matches(v, m.keys.ScrollDown): + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(v) + return true, cmd + } + } + switch { + case key.Matches(v, m.keys.ScrollUp): + m.scrollActiveViewport(-1) + return true, nil + case key.Matches(v, m.keys.ScrollDown): + m.scrollActiveViewport(1) + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + return true, cmd + } + return true, nil + case key.Matches(v, m.keys.PageUp): + m.pageScrollActiveViewport(-1) + return true, nil + case key.Matches(v, m.keys.PageDown): + m.pageScrollActiveViewport(1) + if cmd := m.maybeFlushReadReceipt(); cmd != nil { + return true, cmd + } + return true, nil + default: + return false, nil + } +} + // activeScrollViewport returns the viewport that should receive scroll and wheel // input for the current UI mode. Nil when a sub-model owns scrolling. func (m *model) activeScrollViewport() *viewport.Model { diff --git a/client/scroll_input_test.go b/client/scroll_input_test.go index ac5331f..2a0a9c9 100644 --- a/client/scroll_input_test.go +++ b/client/scroll_input_test.go @@ -112,6 +112,31 @@ func TestApplyMouseWheelToList(t *testing.T) { applyMouseWheelToList(&l, tea.MouseWheelMsg{Button: tea.MouseWheelDown}) } +func TestTextareaWantsArrowKeys(t *testing.T) { + m := &model{textarea: textarea.New()} + m.textarea.Focus() + if m.textareaWantsArrowKeys() { + t.Fatal("single-line composer should not capture arrows") + } + m.textarea.SetValue("line one\nline two") + if !m.textareaWantsArrowKeys() { + t.Fatal("multiline composer should capture arrows") + } +} + +func TestHandleComposerScrollKeyMultiline(t *testing.T) { + m := &model{textarea: textarea.New(), keys: newKeyMap()} + m.textarea.Focus() + m.textarea.SetValue("a\nb\nc") + m.textarea.SetHeight(3) + m.textarea.SetWidth(40) + + handled, _ := m.handleComposerScrollKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + if !handled { + t.Fatal("expected down arrow to be handled by multiline composer") + } +} + func TestOverlayCapturesKeyboard(t *testing.T) { m := &model{} if m.overlayCapturesKeyboard() { diff --git a/client/theme_loader.go b/client/theme_loader.go index c051390..a7ab25f 100644 --- a/client/theme_loader.go +++ b/client/theme_loader.go @@ -109,13 +109,14 @@ func ApplyCustomTheme(def ThemeDefinition) themeStyles { Timestamp: timeStyle, Msg: lipgloss.NewStyle().Foreground(lipgloss.Color(def.Colors.Message)), Banner: lipgloss.NewStyle().Foreground(lipgloss.Color(def.Colors.Banner)).Bold(true), - Box: lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color(def.Colors.BoxBorder)), + Box: lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color(def.Colors.BoxBorder)).Background(lipgloss.Color(transcriptFill)), Mention: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(def.Colors.Mention)), Hyperlink: lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color(def.Colors.Hyperlink)), UserList: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(def.Colors.UserListBorder)).Padding(0, 1), Me: lipgloss.NewStyle().Foreground(lipgloss.Color(def.Colors.Me)).Bold(true), Other: lipgloss.NewStyle().Foreground(lipgloss.Color(def.Colors.Other)), - Background: lipgloss.NewStyle().Background(lipgloss.Color(def.Colors.Background)), + Background: lipgloss.NewStyle().Background(lipgloss.Color(altScreenFill)), + screenBG: altScreenFill, Header: lipgloss.NewStyle().Background(lipgloss.Color(def.Colors.HeaderBg)).Foreground(lipgloss.Color(def.Colors.HeaderFg)).Bold(true), Footer: lipgloss.NewStyle().Background(lipgloss.Color(def.Colors.FooterBg)).Foreground(lipgloss.Color(def.Colors.FooterFg)), Input: lipgloss.NewStyle().Background(lipgloss.Color(def.Colors.InputBg)).Foreground(lipgloss.Color(def.Colors.InputFg)), diff --git a/go.mod b/go.mod index 0513c07..d6377e9 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 + github.com/lucasb-eyer/go-colorful v1.4.0 github.com/mattn/go-runewidth v0.0.24 golang.org/x/crypto v0.53.0 golang.org/x/term v0.44.0 @@ -35,7 +36,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect From 34e2913d5ba787845d0fd4b59379054315ba3cf3 Mon Sep 17 00:00:00 2001 From: Cod-e-Codes Date: Tue, 16 Jun 2026 19:50:47 -0400 Subject: [PATCH 4/5] fix(client): fill composer rows in placeholder mode without hiding cursor Paint full-width input background per composer line and only flatten empty bubbles buffer rows before typing. Multiline rows keep the virtual cursor and blink; placeholder buffer rows stay solid grey instead of alt-screen black. --- client/chrome.go | 90 +++++++++++++++++++++++++++------- client/chrome_test.go | 111 ++++++++++++++++++++++++++++++++++++++++-- client/main.go | 2 +- 3 files changed, 180 insertions(+), 23 deletions(-) diff --git a/client/chrome.go b/client/chrome.go index 714214a..e45c5a6 100644 --- a/client/chrome.go +++ b/client/chrome.go @@ -20,6 +20,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/shared" + "github.com/charmbracelet/x/ansi" "github.com/lucasb-eyer/go-colorful" ) @@ -37,45 +38,100 @@ func chromeFullWidth(viewportW int) int { return viewportW + userListWidth + 4 } -// composeInputWidth is the bubbles textarea width inside the full-width composer bar. +// composeInputWidth is the bubbles textarea width inside the full-width composer bar +// (matches horizontal padding in chromeComposerPanel). func composeInputWidth(viewportW int) int { - w := chromeFullWidth(viewportW) - 4 + return composeInnerWidth(chromeFullWidth(viewportW)) +} + +func composeInnerWidth(fullW int) int { + w := fullW - 2 if w < 20 { return 20 } return w } -// configureTextareaChrome syncs textarea foreground/prompt with theme Input. -// Background fill comes from chromeComposerPanel only (avoids nested boxes). -func configureTextareaChrome(ta *textarea.Model, input lipgloss.Style) { - s := textarea.DefaultDarkStyles() - noBG := lipgloss.NewStyle() - text := noBG +// lineStyleFromInput builds per-line textarea styles that carry the composer fill. +func lineStyleFromInput(input lipgloss.Style) lipgloss.Style { + s := lipgloss.NewStyle() + if bg := input.GetBackground(); bg != nil { + s = s.Background(bg) + } if fg := input.GetForeground(); fg != nil { - text = text.Foreground(fg) + s = s.Foreground(fg) } - faint := text.Copy().Faint(true) + return s +} + +// configureTextareaChrome syncs textarea with theme Input. Composer chrome paints the +// full row background; textarea line styles carry fill for trailing pad spaces. +func configureTextareaChrome(ta *textarea.Model, input lipgloss.Style) { + s := textarea.DefaultDarkStyles() + line := lineStyleFromInput(input) + faint := line.Copy().Faint(true) for _, state := range []*textarea.StyleState{&s.Focused, &s.Blurred} { - state.Base = noBG - state.Text = text - state.CursorLine = text + state.Base = lipgloss.NewStyle() + state.Text = line + state.CursorLine = line state.Prompt = faint state.Placeholder = faint state.LineNumber = faint state.CursorLineNumber = faint - state.EndOfBuffer = faint + state.EndOfBuffer = line } - s.Cursor.Blink = false + s.Cursor.Blink = true if fg := input.GetForeground(); fg != nil { s.Cursor.Color = fg } ta.SetStyles(s) + ta.SetVirtualCursor(true) +} + +// composerLineIsBareBuffer reports placeholder end-of-buffer rows (prompt only). +func composerLineIsBareBuffer(line string) bool { + plain := strings.TrimSpace(ansi.Strip(line)) + plain = strings.TrimPrefix(plain, "┃") + plain = strings.TrimSpace(plain) + return plain == "" || len([]rune(plain)) <= 1 +} + +// padComposerLines extends every row to innerW and paints the full row with the input fill. +// Bubbles textarea placeholder buffer rows only style the prompt plus one end-of-buffer rune; +// the viewport may pad with unstyled spaces that read as alt-screen black. +func padComposerLines(styles themeStyles, innerW, minLines int, content string, placeholderMode bool) string { + lines := strings.Split(content, "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + fill := styles.Input + for len(lines) < minLines { + lines = append(lines, "") + } + if len(lines) > minLines { + lines = lines[:minLines] + } + for i := range lines { + // Only flatten empty buffer rows before typing; multiline cursor rows look bare too. + if placeholderMode && i > 0 && composerLineIsBareBuffer(lines[i]) { + lines[i] = fill.Width(innerW).Render("") + continue + } + placed := lipgloss.PlaceHorizontal(innerW, lipgloss.Left, lines[i], + lipgloss.WithWhitespaceStyle(fill)) + lines[i] = fill.Width(innerW).Render(placed) + } + return strings.Join(lines, "\n") } // chromeComposerPanel renders the full-width message composer (header/footer alignment). -func chromeComposerPanel(styles themeStyles, fullW int, inputContent string) string { - return styles.Input.Width(fullW).Padding(0, 1).Render(inputContent) +func chromeComposerPanel(styles themeStyles, fullW, minLines int, inputContent string, placeholderMode bool) string { + innerW := composeInnerWidth(fullW) + if minLines < 1 { + minLines = 1 + } + padded := padComposerLines(styles, innerW, minLines, inputContent, placeholderMode) + return styles.Input.Width(fullW).Padding(0, 1).Render(padded) } // chromeTypingLine renders a full-width typing indicator under the main grid. diff --git a/client/chrome_test.go b/client/chrome_test.go index e70fe8e..c159d40 100644 --- a/client/chrome_test.go +++ b/client/chrome_test.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/Cod-e-Codes/marchat/shared" + "github.com/charmbracelet/x/ansi" ) func TestBuildStatusFooter(t *testing.T) { @@ -154,8 +155,8 @@ func TestConfigureTextareaChrome(t *testing.T) { if before == after { t.Fatal("expected CursorLine styling to change") } - if ta.Styles().Cursor.Blink { - t.Fatal("expected cursor blink disabled") + if !ta.Styles().Cursor.Blink { + t.Fatal("expected cursor blink enabled") } } @@ -180,14 +181,114 @@ func TestNewMainTeaViewShiftDisablesMouse(t *testing.T) { func TestChromeComposerPanelFullWidth(t *testing.T) { styles := getThemeStyles("retro") - row := chromeComposerPanel(styles, 72, "type here") + row := chromeComposerPanel(styles, 72, 3, "type here", false) if lipgloss.Width(row) < 72 { t.Fatalf("expected full composer width, got %d", lipgloss.Width(row)) } } +func TestChromeComposerPanelPlaceholderFill(t *testing.T) { + styles := getThemeStyles("modern") + ta := textarea.New() + ta.Placeholder = "Type your message..." + ta.Prompt = "┃ " + ta.SetWidth(composeInnerWidth(40)) + ta.SetHeight(3) + ta.ShowLineNumbers = false + configureTextareaChrome(&ta, styles.Input) + + panel := chromeComposerPanel(styles, 40, ta.Height(), ta.View(), true) + lines := strings.Split(strings.TrimSuffix(panel, "\n"), "\n") + if len(lines) < 3 { + t.Fatalf("expected 3 composer lines, got %d", len(lines)) + } + inner := composeInnerWidth(40) + for i, line := range lines { + if w := lipgloss.Width(line); w < inner { + t.Fatalf("line %d width %d < inner %d", i, w, inner) + } + } +} + func TestComposeInputWidth(t *testing.T) { - if w := composeInputWidth(50); w != chromeFullWidth(50)-4 { - t.Fatalf("composeInputWidth = %d, want %d", w, chromeFullWidth(50)-4) + if w := composeInputWidth(50); w != chromeFullWidth(50)-2 { + t.Fatalf("composeInputWidth = %d, want %d", w, chromeFullWidth(50)-2) + } +} + +func TestChromeComposerPanelFillsRowBackground(t *testing.T) { + styles := getThemeStyles("modern") + content := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("hi") + panel := chromeComposerPanel(styles, 40, 1, content, false) + if lipgloss.Width(panel) < 40 { + t.Fatalf("composer width = %d, want >= 40", lipgloss.Width(panel)) + } + lines := strings.Split(panel, "\n") + if len(lines) == 0 { + t.Fatal("expected at least one line") + } + if lipgloss.Width(lines[0]) < composeInnerWidth(40) { + t.Fatalf("expected padded composer line width >= %d, got %d", composeInnerWidth(40), lipgloss.Width(lines[0])) + } +} + +func TestPadComposerLinesPreservesMultilineCursor(t *testing.T) { + styles := getThemeStyles("modern") + ta := textarea.New() + ta.Prompt = "┃ " + ta.SetWidth(composeInnerWidth(40)) + ta.SetHeight(3) + ta.ShowLineNumbers = false + configureTextareaChrome(&ta, styles.Input) + ta.SetValue("hello\n") + ta.Focus() + + raw := ta.View() + withPlaceholder := padComposerLines(styles, composeInnerWidth(40), ta.Height(), raw, true) + active := padComposerLines(styles, composeInnerWidth(40), ta.Height(), raw, false) + if withPlaceholder == active { + t.Fatal("placeholder flattening must not run while composing multiline text") + } + activeLines := strings.Split(strings.TrimSuffix(active, "\n"), "\n") + if len(activeLines) < 2 { + t.Fatal("expected multiline composer output") + } + if !strings.Contains(ansi.Strip(activeLines[1]), "┃") { + t.Fatalf("expected cursor row content, got %q", ansi.Strip(activeLines[1])) + } +} + +func TestComposerLineIsBareBuffer(t *testing.T) { + if !composerLineIsBareBuffer("┃ ") { + t.Fatal("expected bare buffer line") + } + if composerLineIsBareBuffer("┃ hello") { + t.Fatal("expected non-buffer line") + } +} + +func TestChromeComposerPanelPlaceholderBufferRowsSolid(t *testing.T) { + styles := getThemeStyles("modern") + ta := textarea.New() + ta.Placeholder = "Type your message..." + ta.Prompt = "┃ " + ta.SetWidth(composeInnerWidth(40)) + ta.SetHeight(3) + ta.ShowLineNumbers = false + configureTextareaChrome(&ta, styles.Input) + + panel := chromeComposerPanel(styles, 40, ta.Height(), ta.View(), true) + lines := strings.Split(strings.TrimSuffix(panel, "\n"), "\n") + if len(lines) < 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } + inner := composeInnerWidth(40) + for i := 1; i < 3; i++ { + if strings.TrimSpace(ansi.Strip(lines[i])) != "" { + t.Fatalf("line %d should be solid fill, plain=%q", i, ansi.Strip(lines[i])) + } + if lipgloss.Width(lines[i]) < inner { + t.Fatalf("line %d width %d < %d", i, lipgloss.Width(lines[i]), inner) + } } } diff --git a/client/main.go b/client/main.go index 968fae1..b784443 100644 --- a/client/main.go +++ b/client/main.go @@ -2659,7 +2659,7 @@ func (m *model) View() tea.View { inputContent = dmIndicator + inputContent } fullW := chromeFullWidth(m.viewport.Width()) - inputPanel := chromeComposerPanel(m.styles, fullW, inputContent) + inputPanel := chromeComposerPanel(m.styles, fullW, m.textarea.Height(), inputContent, m.textarea.Value() == "") // Compose layout ui := lipgloss.JoinVertical(lipgloss.Left, From cd3aab1e590d3640fb73d5429c572abc5f8f55df Mon Sep 17 00:00:00 2001 From: Cod-e-Codes Date: Tue, 16 Jun 2026 20:03:23 -0400 Subject: [PATCH 5/5] fix: clear golangci-lint failures blocking CI on Charm v2 branch Remove unused cursor style aliases left after v2 textinput styling, replace deprecated lipgloss Style.Copy with Faint chaining, and avoid string concatenation in strings.Builder.WriteString. --- client/chrome.go | 2 +- client/config/interactive_ui.go | 4 ++-- server/config_ui.go | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/chrome.go b/client/chrome.go index e45c5a6..33ad0f6 100644 --- a/client/chrome.go +++ b/client/chrome.go @@ -69,7 +69,7 @@ func lineStyleFromInput(input lipgloss.Style) lipgloss.Style { func configureTextareaChrome(ta *textarea.Model, input lipgloss.Style) { s := textarea.DefaultDarkStyles() line := lineStyleFromInput(input) - faint := line.Copy().Faint(true) + faint := line.Faint(true) for _, state := range []*textarea.StyleState{&s.Focused, &s.Blurred} { state.Base = lipgloss.NewStyle() state.Text = line diff --git a/client/config/interactive_ui.go b/client/config/interactive_ui.go index 59df822..a70b176 100644 --- a/client/config/interactive_ui.go +++ b/client/config/interactive_ui.go @@ -13,7 +13,6 @@ import ( var ( focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B9D")) blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - cursorStyle = focusedStyle noStyle = lipgloss.NewStyle() helpStyle = blurredStyle titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFEAA7")).Bold(true) @@ -726,7 +725,8 @@ func (m ProfileSelectionModel) View() tea.View { if m.cursor == len(m.profiles) { b.WriteString(focusedStyle.Render("> " + newProfileLine)) } else { - b.WriteString(" " + newProfileLine) + b.WriteString(" ") + b.WriteString(newProfileLine) } b.WriteString("\n") } diff --git a/server/config_ui.go b/server/config_ui.go index 860bea4..8e91a56 100644 --- a/server/config_ui.go +++ b/server/config_ui.go @@ -16,7 +16,6 @@ import ( var ( serverFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B9D")) serverBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - serverCursorStyle = serverFocusedStyle serverNoStyle = lipgloss.NewStyle() serverHelpStyle = serverBlurredStyle serverTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFEAA7")).Bold(true)