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..18e3dbf 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**: `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. @@ -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..90c50ea 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:** 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. - **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..33ad0f6 100644 --- a/client/chrome.go +++ b/client/chrome.go @@ -12,21 +12,174 @@ 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" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "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 +// (matches horizontal padding in chromeComposerPanel). +func composeInputWidth(viewportW int) int { + return composeInnerWidth(chromeFullWidth(viewportW)) +} + +func composeInnerWidth(fullW int) int { + w := fullW - 2 + if w < 20 { + return 20 + } + return w +} + +// 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 { + s = s.Foreground(fg) + } + 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.Faint(true) + for _, state := range []*textarea.StyleState{&s.Focused, &s.Blurred} { + state.Base = lipgloss.NewStyle() + state.Text = line + state.CursorLine = line + state.Prompt = faint + state.Placeholder = faint + state.LineNumber = faint + state.CursorLineNumber = faint + state.EndOfBuffer = line + } + 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, 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. +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..c159d40 100644 --- a/client/chrome_test.go +++ b/client/chrome_test.go @@ -4,7 +4,11 @@ import ( "strings" "testing" + "charm.land/bubbles/v2/textarea" + 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) { @@ -141,3 +145,150 @@ 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 enabled") + } +} + +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, 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)-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/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..a70b176 100644 --- a/client/config/interactive_ui.go +++ b/client/config/interactive_ui.go @@ -5,15 +5,14 @@ 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 ( 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) @@ -33,6 +32,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 +87,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 +142,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 +246,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 +312,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 +377,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 +443,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 +474,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 +540,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 +551,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 +607,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 +676,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 @@ -719,7 +725,8 @@ func (m ProfileSelectionModel) View() string { if m.cursor == len(m.profiles) { b.WriteString(focusedStyle.Render("> " + newProfileLine)) } else { - b.WriteString(" " + newProfileLine) + b.WriteString(" ") + b.WriteString(newProfileLine) } b.WriteString("\n") } @@ -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..b784443 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" ) @@ -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 @@ -612,7 +614,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 +658,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() @@ -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) { @@ -1174,7 +1182,8 @@ 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: + m.updateModifierKeys(v.Key()) switch { case key.Matches(v, m.keys.Help): // Close any open menus first @@ -1241,6 +1250,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 { @@ -1318,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 @@ -1426,55 +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): - if m.showHelp { - m.helpViewport.ScrollUp(1) - } else if m.textarea.Focused() { - m.viewport.ScrollUp(1) - } else { - m.userListViewport.ScrollUp(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 - } - } - 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) - } - 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 - } - } - return m, nil case key.Matches(v, m.keys.Copy): // Custom Copy if m.textarea.Focused() { text := m.textarea.Value() @@ -1791,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 @@ -2430,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) @@ -2453,8 +2440,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 +2450,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 +2475,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.textarea.SetWidth(chatWidth) - m.userListViewport.Width = userListWidth - m.userListViewport.Height = m.height - m.textarea.Height() - 6 + m.viewport.SetWidth(chatWidth) + m.viewport.SetHeight(m.height - m.textarea.Height() - 6) + m.textarea.SetWidth(composeInputWidth(chatWidth)) + 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 +2504,83 @@ 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.Mod&tea.ModShift != 0 { + 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 + 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 + } 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 +2594,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,8 +2604,8 @@ func (m *model) View() string { // Chat and user list layout chatBoxStyle := m.styles.Box - chatPanel := chatBoxStyle.Width(m.viewport.Width).Render(m.viewport.View()) - userPanel := m.userListViewport.View() + chatPanel := chatBoxStyle.Width(m.viewport.Width()).Render(m.viewport.View()) + userPanel := lipgloss.NewStyle().MarginRight(1).Render(m.userListViewport.View()) row := lipgloss.JoinHorizontal(lipgloss.Top, userPanel, chatPanel) // Typing indicator @@ -2616,7 +2645,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 := chromeTypingLine(chromeFullWidth(m.viewport.Width()), typingLine) // DM mode indicator var dmIndicator string @@ -2629,7 +2658,8 @@ func (m *model) View() string { 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, m.textarea.Height(), inputContent, m.textarea.Value() == "") // Compose layout ui := lipgloss.JoinVertical(lipgloss.Left, @@ -2663,11 +2693,11 @@ 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) + return newMainTeaView(m.styles, ui, m.shiftHeld) } // Show file picker interface as full-screen if shown @@ -2692,11 +2722,11 @@ 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) + return newMainTeaView(m.styles, ui, m.shiftHeld) } // Show help as full-screen modal if shown @@ -2720,8 +2750,8 @@ 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" - footerStyle := lipgloss.NewStyle(). + helpFooter := "Use ↑/↓, PgUp/PgDn, or mouse wheel to scroll • Press Ctrl+H to close help" + footerStyle := m.styles.HelpOverlay. Width(helpWidth). Align(lipgloss.Center). Foreground(lipgloss.Color("#888888")). @@ -2731,10 +2761,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 +2782,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 +2792,7 @@ func (m *model) View() string { ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dbMenu) } - return m.styles.Background.Render(ui) + return newMainTeaView(m.styles, ui, m.shiftHeld) } func main() { @@ -3153,16 +3171,17 @@ 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(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 +3266,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..c1c62b7 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,55 @@ 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, normalizeURLHyphens(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 != "" { + href := normalizeURLHyphens(state.currentURL) + out.WriteString(styles.Hyperlink.Hyperlink(href).Render(segment.String())) + } else if link { out.WriteString(styles.Hyperlink.Render(segment.String())) } else { out.WriteString(segment.String()) @@ -243,16 +280,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 +307,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 +323,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..0708a04 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,23 @@ 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) + } + 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) } } } @@ -301,6 +321,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 +417,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 +461,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 +477,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..e5e7a40 --- /dev/null +++ b/client/scroll_input.go @@ -0,0 +1,165 @@ +package main + +import ( + "strings" + + "charm.land/bubbles/v2/key" + "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 +} + +// 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 { + 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..2a0a9c9 --- /dev/null +++ b/client/scroll_input_test.go @@ -0,0 +1,204 @@ +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 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() { + 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..a7ab25f 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 @@ -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/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..d6377e9 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,21 @@ 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/lucasb-eyer/go-colorful v1.4.0 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 +25,18 @@ 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/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..8e91a56 100644 --- a/server/config_ui.go +++ b/server/config_ui.go @@ -7,16 +7,15 @@ 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 ( 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) @@ -26,6 +25,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 +95,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 +111,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 +119,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 +195,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 +259,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 +321,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 +365,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