claudeup 4.38.1 → 4.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.38.1",
3
+ "version": "4.39.1",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "4.38.1",
68
- "claudeup-darwin-x64": "4.38.1",
69
- "claudeup-linux-x64": "4.38.1"
67
+ "claudeup-darwin-arm64": "4.39.1",
68
+ "claudeup-darwin-x64": "4.39.1",
69
+ "claudeup-linux-x64": "4.39.1"
70
70
  }
71
71
  }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BoxRenderable, TextRenderable } from "@opentui/core";
3
+ import { createTestRenderer } from "@opentui/core/testing";
4
+
5
+ /**
6
+ * Pins the OpenTUI selection behaviour claudeup relies on after enabling the
7
+ * mouse outside tmux (src/main.tsx): a drag that stays inside one pane selects
8
+ * text from that pane only, because the renderer scopes the selection to the
9
+ * smallest ancestor covering the dragged-over renderables. Crossing the pane
10
+ * divider deliberately escalates the scope to the common ancestor — that is
11
+ * upstream's design, not a bug, and the second test documents it so a future
12
+ * re-pin that changes either half is caught here rather than by a user.
13
+ *
14
+ * The geometry mirrors ScreenLayout: two column boxes side by side under one
15
+ * row box, text rows inside each.
16
+ */
17
+ async function twoPaneRenderer() {
18
+ const { renderer, mockMouse, renderOnce } = await createTestRenderer({
19
+ width: 80,
20
+ height: 10,
21
+ useMouse: true,
22
+ });
23
+
24
+ const row = new BoxRenderable(renderer, {
25
+ id: "row",
26
+ width: 80,
27
+ height: 10,
28
+ flexDirection: "row",
29
+ });
30
+ renderer.root.add(row);
31
+
32
+ const left = new BoxRenderable(renderer, {
33
+ id: "left",
34
+ width: 40,
35
+ height: 10,
36
+ flexDirection: "column",
37
+ });
38
+ const separator = new BoxRenderable(renderer, {
39
+ id: "separator",
40
+ width: 1,
41
+ height: 10,
42
+ });
43
+ const right = new BoxRenderable(renderer, {
44
+ id: "right",
45
+ width: 39,
46
+ height: 10,
47
+ flexDirection: "column",
48
+ });
49
+ row.add(left);
50
+ row.add(separator);
51
+ row.add(right);
52
+
53
+ for (const line of ["alpha one", "alpha two", "alpha three"]) {
54
+ left.add(new TextRenderable(renderer, { content: line }));
55
+ }
56
+ for (const line of ["omega one", "omega two", "omega three"]) {
57
+ right.add(new TextRenderable(renderer, { content: line }));
58
+ }
59
+
60
+ await renderOnce();
61
+ return { renderer, mockMouse };
62
+ }
63
+
64
+ describe("mouse selection scope across panes", () => {
65
+ test("a drag inside the left pane selects left-pane text only", async () => {
66
+ const { renderer, mockMouse } = await twoPaneRenderer();
67
+
68
+ await mockMouse.drag(1, 0, 20, 2);
69
+
70
+ const selected = renderer.getSelection()?.getSelectedText() ?? "";
71
+ expect(selected).toContain("alpha");
72
+ expect(selected).not.toContain("omega");
73
+
74
+ renderer.destroy();
75
+ });
76
+
77
+ test("a drag inside the right pane selects right-pane text only", async () => {
78
+ const { renderer, mockMouse } = await twoPaneRenderer();
79
+
80
+ await mockMouse.drag(45, 0, 70, 2);
81
+
82
+ const selected = renderer.getSelection()?.getSelectedText() ?? "";
83
+ expect(selected).toContain("omega");
84
+ expect(selected).not.toContain("alpha");
85
+
86
+ renderer.destroy();
87
+ });
88
+
89
+ test("a drag across the divider escalates to both panes — upstream's design", async () => {
90
+ const { renderer, mockMouse } = await twoPaneRenderer();
91
+
92
+ await mockMouse.drag(1, 0, 70, 2);
93
+
94
+ const selected = renderer.getSelection()?.getSelectedText() ?? "";
95
+ expect(selected).toContain("alpha");
96
+ expect(selected).toContain("omega");
97
+
98
+ renderer.destroy();
99
+ });
100
+ });
package/src/main.tsx CHANGED
@@ -31,25 +31,37 @@ async function main(): Promise<void> {
31
31
  // absolute fill would box the UI into whatever theme we guessed. OpenTUI's
32
32
  // built-in default for unstyled cells is truecolor white, which is why every
33
33
  // element now names an adaptive colour explicitly — see src/ui/theme.ts.
34
+ // The mouse is ON, everywhere — it carries real features now: the wheel
35
+ // scrolls the pane under the cursor, and a drag selects text from ONE pane
36
+ // at a time (terminal-native selection is a screen-wide rectangle that
37
+ // grabs both columns), OSC 52-copied on release below.
38
+ //
39
+ // This deliberately includes tmux. Two earlier revisions got this wrong in
40
+ // opposite directions: one disabled the mouse everywhere on the theory that
41
+ // capture breaks tmux-level pane clicking, the next kept it off only inside
42
+ // tmux. Both dated from when claudeup had no mouse features, so disabling
43
+ // cost nothing. MEASURED on a heavily tmux'd machine: tmux forwards mouse
44
+ // to a pane application that asks for it (htop in the same session took
45
+ // clicks and wheel fine while claudeup sat inert), and tmux's own `mouse`
46
+ // option keeps governing pane management. Terminal-native selection stays
47
+ // reachable via Shift+drag in most terminals.
48
+ //
49
+ // enableMouseMovement (mode-1003 "report all motion") stays off — selection
50
+ // needs only button-held drag events, which button reporting delivers.
34
51
  const renderer = await createCliRenderer({
35
52
  backgroundColor: RGBA.defaultBackground(),
36
- // claudeup is keyboard-only — no component sets onMouseDown/Up/Move, and
37
- // every action has a key. OpenTUI nevertheless defaults BOTH of these to
38
- // true (`config.useMouse ?? true`, `config.enableMouseMovement ?? true`),
39
- // which turns on button reporting and mode-1003 "report all motion".
40
- //
41
- // Inside tmux that is actively harmful: once a pane's application asks for
42
- // mouse events, tmux hands them to that application instead of using them
43
- // to select a pane or scroll history. Clicking another pane then does
44
- // nothing, keyboard focus stays where it was, and what you type lands in
45
- // the pane you thought you had just left.
46
- //
47
- // Turning it off costs nothing here and gives clicking and scrolling back
48
- // to the terminal.
49
- useMouse: false,
53
+ useMouse: true,
50
54
  enableMouseMovement: false,
51
55
  });
52
56
 
57
+ // Mouse selection → system clipboard, on release. OSC 52 survives SSH;
58
+ // terminals that block it simply ignore the sequence. Never fires where the
59
+ // mouse is off, so the tmux path pays nothing.
60
+ renderer.on("selection", (selection: { getSelectedText(): string }) => {
61
+ const text = selection?.getSelectedText() ?? "";
62
+ if (text.length > 0) renderer.copyToClipboardOSC52(text);
63
+ });
64
+
53
65
  // Ask the terminal whether it is light or dark, once. Only the disabled-row
54
66
  // tint needs this — see src/ui/theme-mode.ts for why that one case cannot be
55
67
  // solved the way the rest of the palette is. Bounded wait: a terminal that
@@ -29,18 +29,35 @@ export function CategoryHeader({
29
29
  const countBadge = count !== undefined ? ` (${count})` : "";
30
30
  const statusText = status ? ` ${status}` : "";
31
31
 
32
- // Simple format without dynamic line calculation
32
+ // Flex row instead of one flat <text>: a flat text wraps when the pane is
33
+ // narrower than the row ("by MadAppGang" lost its final character and the wrap
34
+ // shifted every item below by one line). Here the dash filler is the only
35
+ // element allowed to shrink to nothing, so the badge survives at any width
36
+ // and the title clips last.
33
37
  return (
34
- <text fg={theme.colors.text}>
35
- <span fg={theme.colors.muted}>{expandIcon}</span>
36
- <span fg={theme.colors.text}>
37
- <strong> {title}</strong>
38
- </span>
39
- <span fg={theme.colors.muted}>{versionBadge}</span>
40
- <span fg={theme.colors.muted}>{countBadge}</span>
41
- <span fg={theme.colors.border}> ────</span>
42
- <span fg={statusColor}>{statusText}</span>
43
- </text>
38
+ <box flexDirection="row" width="100%" height={1} overflow="hidden">
39
+ <box flexShrink={1} minWidth={4} overflow="hidden" height={1}>
40
+ <text fg={theme.colors.text}>
41
+ <span fg={theme.colors.muted}>{expandIcon}</span>
42
+ <span fg={theme.colors.text}>
43
+ <strong> {title}</strong>
44
+ </span>
45
+ <span fg={theme.colors.muted}>{versionBadge}</span>
46
+ <span fg={theme.colors.muted}>{countBadge}</span>
47
+ </text>
48
+ </box>
49
+ {/* flexShrink 100: the filler must be the first thing sacrificed. With equal
50
+ shrink factors yoga takes width from the (larger-basis) title first, which
51
+ clipped a character off the title while all four dashes survived. */}
52
+ <box flexShrink={100} minWidth={0} overflow="hidden" height={1}>
53
+ <text fg={theme.colors.border}> ────</text>
54
+ </box>
55
+ {status ? (
56
+ <box flexShrink={0} height={1}>
57
+ <text fg={statusColor}>{statusText}</text>
58
+ </box>
59
+ ) : null}
60
+ </box>
44
61
  );
45
62
  }
46
63
 
@@ -70,6 +70,14 @@ export function ScrollableList<T>({
70
70
  <box
71
71
  key={getKey ? getKey(item, originalIndex) : `${originalIndex}`}
72
72
  width="100%"
73
+ // height=1 is load-bearing, not cosmetic. overflow="hidden" alone clips
74
+ // CONTENT but lets the box GROW: an over-wide <text> wraps to a second
75
+ // line, the row becomes two lines tall, and every item below shifts —
76
+ // which rendered as a phantom blank line under "Magus Marketing" and
77
+ // the last two plugins composited onto one row. The list's scroll math
78
+ // assumes one line per item; this makes that assumption true.
79
+ height={1}
80
+ flexShrink={0}
73
81
  overflow="hidden"
74
82
  >
75
83
  {renderItem(item, originalIndex, originalIndex === selectedIndex)}
@@ -1,5 +1,8 @@
1
- import React from "react";
1
+ import React, { useEffect, useRef } from "react";
2
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
3
  import { useDimensions } from "../../state/DimensionsContext.js";
4
+ import { useKeyboard } from "../../hooks/useKeyboard.js";
5
+ import { useApp } from "../../state/AppContext.js";
3
6
  import { TabBar } from "../TabBar.js";
4
7
  import type { Screen } from "../../state/types.js";
5
8
  import { FooterHints, type FooterHint } from "./FooterHints.js";
@@ -40,6 +43,12 @@ interface ScreenLayoutProps {
40
43
  listPanel: React.ReactNode;
41
44
  /** Right panel content (detail view) */
42
45
  detailPanel: React.ReactNode;
46
+ /**
47
+ * Identity of the detail content, e.g. the selected row's id. When it
48
+ * changes, the detail pane scrolls back to the top — a reading position
49
+ * belongs to one document, not to the panel.
50
+ */
51
+ detailKey?: string;
43
52
  }
44
53
 
45
54
  const HEADER_COLOR = theme.colors.accent;
@@ -54,8 +63,10 @@ export function ScreenLayout({
54
63
  footerHints,
55
64
  listPanel,
56
65
  detailPanel,
66
+ detailKey,
57
67
  }: ScreenLayoutProps) {
58
68
  const dimensions = useDimensions();
69
+ const { state } = useApp();
59
70
 
60
71
  const hasSearchBar = search && (search.isActive || search.query);
61
72
 
@@ -65,6 +76,34 @@ export function ScreenLayout({
65
76
  const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
66
77
  const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
67
78
 
79
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
80
+
81
+ // The detail pane has no cursor, so it needs its own scroll keys: arrows
82
+ // move the LIST selection, and the wheel only helps when the terminal
83
+ // delivers mouse reports (see src/main.tsx). PgUp/PgDn page with two rows
84
+ // of overlap so a page turn doesn't lose the reader's place; Ctrl+U/Ctrl+D
85
+ // half-page in the pager tradition — and exist because a MacBook keyboard
86
+ // hides PgDn behind Fn+↓, where nobody finds it.
87
+ const detailPageStep = Math.max(1, panelHeight - 2);
88
+ const detailHalfStep = Math.max(1, Math.floor(panelHeight / 2));
89
+ useKeyboard((event) => {
90
+ if (state.modal) return;
91
+ const ctrl = event.ctrl === true;
92
+ if (event.name === "pageup" || (ctrl && event.name === "u")) {
93
+ detailScrollRef.current?.scrollBy(
94
+ event.name === "pageup" ? -detailPageStep : -detailHalfStep,
95
+ );
96
+ } else if (event.name === "pagedown" || (ctrl && event.name === "d")) {
97
+ detailScrollRef.current?.scrollBy(
98
+ event.name === "pagedown" ? detailPageStep : detailHalfStep,
99
+ );
100
+ }
101
+ });
102
+
103
+ useEffect(() => {
104
+ if (detailKey !== undefined) detailScrollRef.current?.scrollTo(0);
105
+ }, [detailKey]);
106
+
68
107
  return (
69
108
  <box flexDirection="column" height={dimensions.contentHeight}>
70
109
  {/* Line above tabs */}
@@ -90,9 +129,14 @@ export function ScreenLayout({
90
129
  flexDirection="row"
91
130
  justifyContent="space-between"
92
131
  >
93
- <text fg={HEADER_COLOR}>
94
- <strong>{title}</strong>
95
- </text>
132
+ {/* The title never yields: an overlong right-hand status once
133
+ shrank it away and the two texts read as one sentence. Long
134
+ content belongs in the `notice` row, not beside the title. */}
135
+ <box flexShrink={0} marginRight={2}>
136
+ <text fg={HEADER_COLOR}>
137
+ <strong>{title}</strong>
138
+ </text>
139
+ </box>
96
140
  {subtitle && <text fg={theme.colors.muted}>{subtitle}</text>}
97
141
  {!subtitle && statusLine ? statusLine : null}
98
142
  </box>
@@ -147,18 +191,37 @@ export function ScreenLayout({
147
191
  <text fg={theme.colors.border}>{"│".repeat(panelHeight)}</text>
148
192
  </box>
149
193
 
150
- {/* Detail panel — scrollable */}
194
+ {/* Detail panel — scrollable. The thumb is themed so the reader can
195
+ see there is more below the fold; the track keeps the library
196
+ default so it stays chrome, not signal. */}
151
197
  <box width="50%" height={panelHeight} paddingLeft={1}>
152
- <scrollbox height={panelHeight} scrollY={true} scrollX={false}>
198
+ <scrollbox
199
+ ref={detailScrollRef}
200
+ height={panelHeight}
201
+ scrollY={true}
202
+ scrollX={false}
203
+ verticalScrollbarOptions={{
204
+ showArrows: false,
205
+ trackOptions: {
206
+ foregroundColor: theme.colors.muted,
207
+ },
208
+ }}
209
+ >
153
210
  <box flexDirection="column">{detailPanel}</box>
154
211
  </scrollbox>
155
212
  </box>
156
213
  </box>
157
214
 
158
- {/* Footer */}
215
+ {/* Footer — the layout appends its own scroll hint so every screen
216
+ advertises the detail-pane keys without each screen repeating it */}
159
217
  <box height={1} paddingLeft={1}>
160
218
  {Array.isArray(footerHints) ? (
161
- <FooterHints hints={footerHints as FooterHint[]} />
219
+ <FooterHints
220
+ hints={[
221
+ ...(footerHints as FooterHint[]),
222
+ { keys: ["^U/^D", "PgUp/Dn"], label: "scroll" },
223
+ ]}
224
+ />
162
225
  ) : typeof footerHints === "string" ? (
163
226
  <text fg={theme.colors.muted}>{footerHints}</text>
164
227
  ) : (
@@ -866,31 +866,35 @@ export function StylesScreen() {
866
866
 
867
867
  // ── Status line ───────────────────────────────────────────────────────────
868
868
 
869
- // Order matters. A transient message wins while it lasts, so pressing a key
870
- // always acknowledges the press otherwise the fill banner below would
871
- // shadow "already reading" and the second press would look ignored again,
872
- // which is the bug this whole path exists to fix. The banner is the fallback
873
- // because `statusMsg` is local and dies when the screen unmounts, leaving it
874
- // the only thing that still reports work in flight after a tab switch.
875
- const statusContent = statusMsg ? (
876
- <text fg={theme.colors.text}>
877
- <span
878
- fg={
879
- statusMsg.tone === "success"
880
- ? theme.colors.success
881
- : theme.colors.danger
882
- }
883
- >
884
- {statusMsg.text}
885
- </span>
869
+ // Transient feedback takes the full-width notice row under the header, not
870
+ // the header itself: a long message beside the title used to shrink the
871
+ // title away, and the two ran together as one garbled sentence ("claudeup
872
+ // Styleevidence-first ships with…"). Order matters inside the row. A
873
+ // message wins while it lasts, so pressing a key always acknowledges the
874
+ // press otherwise the fill banner below would shadow "already reading"
875
+ // and the second press would look ignored again, which is the bug this
876
+ // path exists to fix. The banner is the fallback because `statusMsg` is
877
+ // local and dies when the screen unmounts, leaving it the only thing that
878
+ // still reports work in flight after a tab switch.
879
+ const noticeContent = statusMsg ? (
880
+ <text
881
+ fg={
882
+ statusMsg.tone === "success"
883
+ ? theme.colors.success
884
+ : theme.colors.danger
885
+ }
886
+ >
887
+ {statusMsg.text}
886
888
  </text>
887
889
  ) : isFilling ? (
888
- <text fg={theme.colors.text}>
889
- <span fg={theme.colors.warning}>
890
- Reading the codebase to fill a template… this can take a few minutes
891
- </span>
890
+ <text fg={theme.colors.warning}>
891
+ Reading the codebase to fill a template… this can take a few minutes
892
892
  </text>
893
- ) : (
893
+ ) : undefined;
894
+
895
+ // The header keeps only the short ambient state — it shares its row with
896
+ // the title and must never crowd it.
897
+ const statusContent = (
894
898
  <text fg={theme.colors.text}>
895
899
  <span fg={theme.colors.muted}>Active: </span>
896
900
  <span
@@ -993,6 +997,7 @@ export function StylesScreen() {
993
997
  title="claudeup Styles"
994
998
  currentScreen="styles"
995
999
  statusLine={statusContent}
1000
+ notice={noticeContent}
996
1001
  search={
997
1002
  stylesState.searchQuery || isSearchActive
998
1003
  ? {
@@ -1082,6 +1087,7 @@ export function StylesScreen() {
1082
1087
  </box>
1083
1088
  }
1084
1089
  detailPanel={renderStyleDetail(selectedItem, detailWidth)}
1090
+ detailKey={selectedItem?.id ?? ""}
1085
1091
  />
1086
1092
  );
1087
1093
  }