claudeup 6.3.2 → 6.4.0
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 +4 -4
- package/src/__tests__/cli-live.test.ts +9 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gitignore-prerun.test.ts +6 -13
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/manifest.test.ts +134 -0
- package/src/__tests__/model-visuals.test.tsx +789 -0
- package/src/__tests__/models-adapter.test.ts +317 -0
- package/src/__tests__/models-cli.test.ts +173 -0
- package/src/__tests__/models-core.test.ts +640 -0
- package/src/__tests__/models-manager.test.ts +497 -0
- package/src/__tests__/models-screen-state.test.ts +259 -0
- package/src/__tests__/profile-materializer.test.ts +46 -0
- package/src/__tests__/resolver.test.ts +36 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/symlink-manager.test.ts +65 -1
- package/src/__tests__/tabbar-layout.test.ts +40 -2
- package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
- package/src/cli/doctor.ts +90 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/models.ts +214 -0
- package/src/cli/router.ts +12 -0
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/models-presets.ts +281 -0
- package/src/data/predefined-profiles.ts +9 -0
- package/src/data/settings-catalog.ts +11 -4
- package/src/main.tsx +51 -82
- package/src/services/hook-registration.ts +218 -0
- package/src/services/manifest.ts +84 -0
- package/src/services/models-core.ts +628 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +11 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/styles-manager.ts +23 -45
- package/src/services/symlink-manager.ts +57 -11
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +21 -0
- package/src/types/index.ts +14 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/modelsAdapter.ts +170 -0
- package/src/ui/components/TabBar.tsx +9 -4
- package/src/ui/components/layout/FooterHints.tsx +20 -3
- package/src/ui/components/layout/ScreenLayout.tsx +87 -7
- package/src/ui/components/primitives/MetaText.tsx +27 -1
- package/src/ui/renderers/modelRenderers.tsx +1004 -0
- package/src/ui/renderers/modelVisuals.tsx +853 -0
- package/src/ui/renderers/skillRenderers.tsx +13 -3
- package/src/ui/renderers/styleRenderers.tsx +7 -3
- package/src/ui/screens/ModelsScreen.tsx +478 -0
- package/src/ui/screens/StylesScreen.tsx +8 -13
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +94 -0
- package/src/ui/state/types.ts +65 -2
- package/src/ui/theme-mode.ts +116 -0
- package/src/ui/theme.ts +26 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILT_IN_PRESETS,
|
|
3
|
+
DEFAULT_PRESET,
|
|
4
|
+
presetLabel,
|
|
5
|
+
} from "../../data/models-presets.js";
|
|
6
|
+
import {
|
|
7
|
+
GRADES,
|
|
8
|
+
type ModelsConfig,
|
|
9
|
+
type ModelsState,
|
|
10
|
+
type ModelsStatus,
|
|
11
|
+
} from "../../services/models-core.js";
|
|
12
|
+
|
|
13
|
+
// ─── Item types ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The one header row: what is routed right now.
|
|
17
|
+
*
|
|
18
|
+
* Not selectable — the cursor skips it, the same way the Styles screen skips a
|
|
19
|
+
* category label. It carries only what the ROW draws; the drift lines and
|
|
20
|
+
* warnings behind the counts live on the `ModelsStatus` the detail renderer is
|
|
21
|
+
* handed, so they are not stored twice and cannot disagree.
|
|
22
|
+
*/
|
|
23
|
+
export interface ModelsStatusItem {
|
|
24
|
+
id: string;
|
|
25
|
+
kind: "status";
|
|
26
|
+
label: string;
|
|
27
|
+
state: ModelsState;
|
|
28
|
+
/** The preset the project's config names, or null when there is none. */
|
|
29
|
+
preset: string | null;
|
|
30
|
+
driftCount: number;
|
|
31
|
+
warningCount: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ModelsPresetItem {
|
|
35
|
+
id: string;
|
|
36
|
+
kind: "preset";
|
|
37
|
+
label: string;
|
|
38
|
+
config: ModelsConfig;
|
|
39
|
+
/** The routing this project's `models.json` names. */
|
|
40
|
+
active: boolean;
|
|
41
|
+
/** What `claudeup models use` picks when nothing else is chosen. */
|
|
42
|
+
isDefault: boolean;
|
|
43
|
+
/** Not one of the built-ins — this project's own, hand-edited config. */
|
|
44
|
+
custom: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ModelsBrowserItem = ModelsStatusItem | ModelsPresetItem;
|
|
48
|
+
|
|
49
|
+
// ─── Adapter ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface BuildModelsItemsArgs {
|
|
52
|
+
/** What `readModelsStatus` reported, or null before the first read lands. */
|
|
53
|
+
status: ModelsStatus | null;
|
|
54
|
+
/** The validated config, or null when there is none (or it failed validation). */
|
|
55
|
+
config: ModelsConfig | null;
|
|
56
|
+
query: string;
|
|
57
|
+
/**
|
|
58
|
+
* Presets to offer. Defaults to the shipped set, which is a compile-time
|
|
59
|
+
* constant — so building the list still costs no I/O whatsoever.
|
|
60
|
+
*/
|
|
61
|
+
presets?: ModelsConfig[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Does `config` answer to the filter?
|
|
66
|
+
*
|
|
67
|
+
* Matched over the preset name AND the models it names, because "sonnet" is the
|
|
68
|
+
* thing a user actually looks for — the preset names encode which model leads,
|
|
69
|
+
* not which models appear.
|
|
70
|
+
*/
|
|
71
|
+
function matches(config: ModelsConfig, lowerQuery: string): boolean {
|
|
72
|
+
if (!lowerQuery) return true;
|
|
73
|
+
// Both names are searchable: the row SHOWS "Opus with Fable help", and a user who knows
|
|
74
|
+
// the command types "fable-advisor". Matching only one of them makes the other look like
|
|
75
|
+
// a missing preset.
|
|
76
|
+
const haystack = [
|
|
77
|
+
config.preset,
|
|
78
|
+
presetLabel(config.preset),
|
|
79
|
+
config.main.model,
|
|
80
|
+
...GRADES.map((grade) => config.grades[grade].model),
|
|
81
|
+
]
|
|
82
|
+
.join(" ")
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
return haystack.includes(lowerQuery);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the flat list for the Models screen.
|
|
89
|
+
*
|
|
90
|
+
* One status header, then one row per preset in the order they ship. Nothing is
|
|
91
|
+
* grouped: there are four of them and a grade table each, so sections would cost
|
|
92
|
+
* rows and buy no navigation.
|
|
93
|
+
*
|
|
94
|
+
* The header is emitted only when at least one preset row survives the filter. A
|
|
95
|
+
* header with nothing under it reads as a result, and would leave the empty
|
|
96
|
+
* state — the one thing that says how to clear the filter — unreachable.
|
|
97
|
+
*/
|
|
98
|
+
export function buildModelsItems({
|
|
99
|
+
status,
|
|
100
|
+
config,
|
|
101
|
+
query,
|
|
102
|
+
presets = BUILT_IN_PRESETS,
|
|
103
|
+
}: BuildModelsItemsArgs): ModelsBrowserItem[] {
|
|
104
|
+
if (!status) return [];
|
|
105
|
+
|
|
106
|
+
const lowerQuery = query.trim().toLowerCase();
|
|
107
|
+
const activeName = config?.preset ?? null;
|
|
108
|
+
const builtInNames = new Set(presets.map((preset) => preset.preset));
|
|
109
|
+
|
|
110
|
+
const rows: ModelsPresetItem[] = presets
|
|
111
|
+
.filter((preset) => matches(preset, lowerQuery))
|
|
112
|
+
.map((preset) => ({
|
|
113
|
+
id: `preset:${preset.preset}`,
|
|
114
|
+
kind: "preset" as const,
|
|
115
|
+
label: presetLabel(preset.preset),
|
|
116
|
+
config: preset,
|
|
117
|
+
active: preset.preset === activeName,
|
|
118
|
+
isDefault: preset.preset === DEFAULT_PRESET,
|
|
119
|
+
custom: false,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
// A project that hand-edited its models.json names a preset no built-in
|
|
123
|
+
// carries. It is the routing actually in force, so it gets a row of its own —
|
|
124
|
+
// otherwise the list shows four presets with none of them marked, which reads
|
|
125
|
+
// as "routing is off" when it is emphatically on.
|
|
126
|
+
if (
|
|
127
|
+
config &&
|
|
128
|
+
activeName &&
|
|
129
|
+
!builtInNames.has(activeName) &&
|
|
130
|
+
matches(config, lowerQuery)
|
|
131
|
+
) {
|
|
132
|
+
rows.push({
|
|
133
|
+
id: `preset:${activeName}`,
|
|
134
|
+
kind: "preset",
|
|
135
|
+
label: presetLabel(activeName),
|
|
136
|
+
config,
|
|
137
|
+
active: true,
|
|
138
|
+
isDefault: false,
|
|
139
|
+
custom: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (rows.length === 0) return [];
|
|
144
|
+
|
|
145
|
+
const header: ModelsStatusItem = {
|
|
146
|
+
id: "status",
|
|
147
|
+
kind: "status",
|
|
148
|
+
label: status.preset
|
|
149
|
+
? `Model tiers: ${status.state} · ${presetLabel(status.preset)}`
|
|
150
|
+
: `Model tiers: ${status.state}`,
|
|
151
|
+
state: status.state,
|
|
152
|
+
preset: status.preset,
|
|
153
|
+
driftCount: status.drift.length,
|
|
154
|
+
warningCount: status.warnings.length,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return [header, ...rows];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Index of the first row the user can actually land on, or 0 if none.
|
|
162
|
+
*
|
|
163
|
+
* The status row is a header, not a choice: every key this screen binds acts on
|
|
164
|
+
* a preset, so parking the cursor there would make `a` and Enter do nothing on
|
|
165
|
+
* the row the screen opens with.
|
|
166
|
+
*/
|
|
167
|
+
export function firstSelectableIndex(items: ModelsBrowserItem[]): number {
|
|
168
|
+
const index = items.findIndex((item) => item.kind !== "status");
|
|
169
|
+
return index >= 0 ? index : 0;
|
|
170
|
+
}
|
|
@@ -19,6 +19,11 @@ export const TABS: Tab[] = [
|
|
|
19
19
|
{ key: "7", label: "Git State", screen: "gitignore" },
|
|
20
20
|
{ key: "8", label: "Alias", screen: "alias" },
|
|
21
21
|
{ key: "9", label: "Styles", screen: "styles" },
|
|
22
|
+
// Last, and keyed "0" because the digits ran out. The number row reads
|
|
23
|
+
// 1…9 then 0, so a tenth tab keyed 0 sits under the finger that follows 9 —
|
|
24
|
+
// which is the only reason the order here and the order on the keyboard
|
|
25
|
+
// agree.
|
|
26
|
+
{ key: "0", label: "Models", screen: "models" },
|
|
22
27
|
];
|
|
23
28
|
|
|
24
29
|
/** Columns one tab occupies: the text plus its one-space padding each side. */
|
|
@@ -35,11 +40,11 @@ export function barWidth(texts: string[]): number {
|
|
|
35
40
|
/**
|
|
36
41
|
* Choose what each tab shows for the space available.
|
|
37
42
|
*
|
|
38
|
-
* The full bar is ~
|
|
39
|
-
*
|
|
40
|
-
* ("1:", "4:", "7:Git ") that
|
|
43
|
+
* The full bar is ~110 columns at ten tabs, so on a standard 100-column window
|
|
44
|
+
* it overflows and OpenTUI clips each cell — producing a row of truncated stubs
|
|
45
|
+
* ("1:", "4:", "7:Git ") that names nothing. Dropping the inactive labels keeps
|
|
41
46
|
* the bar honest: the number is what you press, and the one tab whose name
|
|
42
|
-
* matters is the one you are on.
|
|
47
|
+
* matters is the one you are on. Compacted, ten tabs need ~46 columns.
|
|
43
48
|
*/
|
|
44
49
|
export function layoutTabs(
|
|
45
50
|
tabs: Tab[],
|
|
@@ -3,11 +3,28 @@ import { theme } from "../../theme.js";
|
|
|
3
3
|
|
|
4
4
|
/** One footer hint: a key (or key group) and the action it performs. */
|
|
5
5
|
export interface FooterHint {
|
|
6
|
-
/** Key(s) for this action, e.g. ["↑", "↓"] or ["U"]. Joined
|
|
6
|
+
/** Key(s) for this action, e.g. ["↑", "↓"] or ["U"]. Joined by `joinKeys`. */
|
|
7
7
|
keys: string[];
|
|
8
8
|
label: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Render a key group as one chip's text.
|
|
13
|
+
*
|
|
14
|
+
* Single-character keys butt together, because `↑↓` is how an arrow pair is written
|
|
15
|
+
* everywhere and a separator there would be noise. Anything longer needs a gap, or the
|
|
16
|
+
* names run into each other and read as one key that does not exist — MEASURED on screen:
|
|
17
|
+
* `["Enter", "a"]` rendered as `Entera`, and the layout's own scroll hint
|
|
18
|
+
* `["^U/^D", "PgUp/Dn"]` rendered as `^U/^DPgUp/Dn` on every screen in the app.
|
|
19
|
+
*
|
|
20
|
+
* A space rather than a slash, because several key names already contain one.
|
|
21
|
+
*/
|
|
22
|
+
export function joinKeys(keys: string[]): string {
|
|
23
|
+
return keys.every((k) => [...k].length === 1)
|
|
24
|
+
? keys.join("")
|
|
25
|
+
: keys.join(" ");
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
// The shared chip palette, not a local one. This file used to define its own
|
|
12
29
|
// (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
|
|
13
30
|
// stayed a low-contrast grey-on-grey strip after the palette was made vivid —
|
|
@@ -31,7 +48,7 @@ export function FooterHints({
|
|
|
31
48
|
const nodes: React.ReactNode[] = [];
|
|
32
49
|
hints.forEach((hint, i) => {
|
|
33
50
|
// A hint is identified by its key chord plus label, not its position.
|
|
34
|
-
const id = `${hint.keys
|
|
51
|
+
const id = `${joinKeys(hint.keys)}:${hint.label}`;
|
|
35
52
|
if (i > 0) {
|
|
36
53
|
nodes.push(
|
|
37
54
|
<span key={`gap-${id}`} fg={LABEL_FG}>
|
|
@@ -41,7 +58,7 @@ export function FooterHints({
|
|
|
41
58
|
}
|
|
42
59
|
nodes.push(
|
|
43
60
|
<span key={`k-${id}`} bg={KEY_BG} fg={KEY_FG}>
|
|
44
|
-
{` ${hint.keys
|
|
61
|
+
{` ${joinKeys(hint.keys)} `}
|
|
45
62
|
</span>,
|
|
46
63
|
);
|
|
47
64
|
nodes.push(
|
|
@@ -9,6 +9,70 @@ import { theme } from "../../theme.js";
|
|
|
9
9
|
import { TabBar } from "../TabBar.js";
|
|
10
10
|
import { type FooterHint, FooterHints } from "./FooterHints.js";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Panel content: a node, or a function handed the panel's usable columns.
|
|
14
|
+
*
|
|
15
|
+
* The function form exists because the geometry below — the app frame's side
|
|
16
|
+
* padding, the 49/50 split, the panel padding and the scrollbar column — is
|
|
17
|
+
* ScreenLayout's, and a screen that re-derives it from `terminalWidth` is
|
|
18
|
+
* guessing at constants it cannot see. Both screens that wrap body text did
|
|
19
|
+
* exactly that, with the same hand-copied formula, and both were wrong.
|
|
20
|
+
*
|
|
21
|
+
* `React.ReactNode` does not include functions, so the union is unambiguous.
|
|
22
|
+
*/
|
|
23
|
+
export type PanelContent =
|
|
24
|
+
| React.ReactNode
|
|
25
|
+
| ((width: number) => React.ReactNode);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The app frame (App.tsx) pads the screen one column each side before
|
|
29
|
+
* ScreenLayout ever renders, so every percentage below resolves against
|
|
30
|
+
* `terminalWidth - 2`.
|
|
31
|
+
*/
|
|
32
|
+
const APP_SIDE_PADDING = 2;
|
|
33
|
+
const LIST_FRACTION = 0.49;
|
|
34
|
+
const DETAIL_FRACTION = 0.5;
|
|
35
|
+
/** The list panel's own `paddingRight`. */
|
|
36
|
+
const LIST_CHROME = 1;
|
|
37
|
+
/** The detail panel's `paddingLeft`, plus the scrollbox's scrollbar column. */
|
|
38
|
+
const DETAIL_CHROME = 2;
|
|
39
|
+
|
|
40
|
+
/** Columns a panel's content can actually use, at this terminal width. */
|
|
41
|
+
function panelWidth(
|
|
42
|
+
terminalWidth: number,
|
|
43
|
+
fraction: number,
|
|
44
|
+
chrome: number,
|
|
45
|
+
): number {
|
|
46
|
+
const inner = Math.max(1, terminalWidth - APP_SIDE_PADDING);
|
|
47
|
+
return Math.max(8, Math.floor(inner * fraction) - chrome);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Usable columns in the list panel.
|
|
52
|
+
*
|
|
53
|
+
* MEASURED against real renders at 70…200 columns, not derived from the JSX:
|
|
54
|
+
* Yoga rounds a fractional percentage to whole cells, so the arithmetic only
|
|
55
|
+
* matches the pixels if you check. This is exact at 8 of the 12 widths sampled
|
|
56
|
+
* and one column short at the other four, which is the safe direction — a
|
|
57
|
+
* budget one column under folds nothing, a budget one column over paints into
|
|
58
|
+
* the separator.
|
|
59
|
+
*/
|
|
60
|
+
export function listPanelWidth(terminalWidth: number): number {
|
|
61
|
+
return panelWidth(terminalWidth, LIST_FRACTION, LIST_CHROME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Usable columns in the detail panel, scrollbar excluded.
|
|
66
|
+
*
|
|
67
|
+
* Exact at 11 of the 12 widths sampled, one column short at the twelfth. The
|
|
68
|
+
* formula each screen used to carry instead was `floor(width * 0.5) - 4`: it
|
|
69
|
+
* forgot the app frame's padding, counted the scrollbar twice, and then floored
|
|
70
|
+
* at 24 — so below about 56 columns it asked for MORE room than the panel has.
|
|
71
|
+
*/
|
|
72
|
+
export function detailPanelWidth(terminalWidth: number): number {
|
|
73
|
+
return panelWidth(terminalWidth, DETAIL_FRACTION, DETAIL_CHROME);
|
|
74
|
+
}
|
|
75
|
+
|
|
12
76
|
interface ScreenLayoutProps {
|
|
13
77
|
/** Screen title (e.g., "claudeup Plugins") */
|
|
14
78
|
title: string;
|
|
@@ -40,10 +104,10 @@ interface ScreenLayoutProps {
|
|
|
40
104
|
* (the standard look). A string or ReactNode is still accepted for
|
|
41
105
|
* free-form footers. */
|
|
42
106
|
footerHints: FooterHint[] | string | React.ReactNode;
|
|
43
|
-
/** Left panel content */
|
|
44
|
-
listPanel:
|
|
45
|
-
/** Right panel content
|
|
46
|
-
detailPanel:
|
|
107
|
+
/** Left panel content, or a function given the list panel's usable columns. */
|
|
108
|
+
listPanel: PanelContent;
|
|
109
|
+
/** Right panel content, or a function given the detail panel's usable columns. */
|
|
110
|
+
detailPanel: PanelContent;
|
|
47
111
|
/**
|
|
48
112
|
* Identity of the detail content, e.g. the selected row's id. When it
|
|
49
113
|
* changes, the detail pane scrolls back to the top — a reading position
|
|
@@ -77,6 +141,15 @@ export function ScreenLayout({
|
|
|
77
141
|
const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
|
|
78
142
|
const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
|
|
79
143
|
|
|
144
|
+
const listContent =
|
|
145
|
+
typeof listPanel === "function"
|
|
146
|
+
? listPanel(listPanelWidth(dimensions.terminalWidth))
|
|
147
|
+
: listPanel;
|
|
148
|
+
const detailContent =
|
|
149
|
+
typeof detailPanel === "function"
|
|
150
|
+
? detailPanel(detailPanelWidth(dimensions.terminalWidth))
|
|
151
|
+
: detailPanel;
|
|
152
|
+
|
|
80
153
|
const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
81
154
|
|
|
82
155
|
// The detail pane has no cursor, so it needs its own scroll keys: arrows
|
|
@@ -177,14 +250,21 @@ export function ScreenLayout({
|
|
|
177
250
|
|
|
178
251
|
{/* Main content area */}
|
|
179
252
|
<box flexDirection="row" height={panelHeight}>
|
|
180
|
-
{/* List panel
|
|
253
|
+
{/* List panel. `overflow="hidden"` is the safety net, not the plan:
|
|
254
|
+
the box has a fixed height, so content taller than it does NOT
|
|
255
|
+
scroll and does NOT clip on its own — Yoga shrinks the children
|
|
256
|
+
and they composite on top of each other. MEASURED at 80×24:
|
|
257
|
+
"fable-advisor" and "fable-lead" rendered as "fable-leadical".
|
|
258
|
+
A screen that can overflow should budget its own rows; this
|
|
259
|
+
stops the failure being unreadable when one does not. */}
|
|
181
260
|
<box
|
|
182
261
|
flexDirection="column"
|
|
183
262
|
width="49%"
|
|
184
263
|
height={panelHeight}
|
|
185
264
|
paddingRight={1}
|
|
265
|
+
overflow="hidden"
|
|
186
266
|
>
|
|
187
|
-
{
|
|
267
|
+
{listContent}
|
|
188
268
|
</box>
|
|
189
269
|
|
|
190
270
|
{/* Vertical separator */}
|
|
@@ -208,7 +288,7 @@ export function ScreenLayout({
|
|
|
208
288
|
},
|
|
209
289
|
}}
|
|
210
290
|
>
|
|
211
|
-
<box flexDirection="column">{
|
|
291
|
+
<box flexDirection="column">{detailContent}</box>
|
|
212
292
|
</scrollbox>
|
|
213
293
|
</box>
|
|
214
294
|
</box>
|
|
@@ -4,11 +4,37 @@ import { theme } from "../../theme.js";
|
|
|
4
4
|
interface MetaTextProps {
|
|
5
5
|
text: string;
|
|
6
6
|
tone?: "muted" | "warning" | "success" | "danger";
|
|
7
|
+
/**
|
|
8
|
+
* True when this sits inside a SELECTED row. Not cosmetic.
|
|
9
|
+
*
|
|
10
|
+
* Every `theme.meta` tone is chosen against the terminal's own background,
|
|
11
|
+
* and none of them survives the selection fill: measured on a screenshot,
|
|
12
|
+
* `muted` is 1.11:1 on it, `warning` 1.07:1, `danger` 1.11:1. So a selected
|
|
13
|
+
* row's `(default)`, `(new)`, `(needed)` and version strings were drawn in a
|
|
14
|
+
* colour indistinguishable from the purple behind them.
|
|
15
|
+
*
|
|
16
|
+
* On a selected row the tone's HUE is surrendered — the selection owns the
|
|
17
|
+
* colour channel there — and only the emphasis survives: `fg` for a tone
|
|
18
|
+
* that was saying something, `dim` for one that was only muttering. The
|
|
19
|
+
* label is words, so its meaning does not depend on the hue.
|
|
20
|
+
*/
|
|
21
|
+
selected?: boolean;
|
|
7
22
|
}
|
|
8
23
|
|
|
9
24
|
/**
|
|
10
25
|
* Subdued text for versions, stars, status indicators.
|
|
11
26
|
*/
|
|
12
|
-
export function MetaText({
|
|
27
|
+
export function MetaText({
|
|
28
|
+
text,
|
|
29
|
+
tone = "muted",
|
|
30
|
+
selected = false,
|
|
31
|
+
}: MetaTextProps) {
|
|
32
|
+
if (selected) {
|
|
33
|
+
return (
|
|
34
|
+
<span fg={tone === "muted" ? theme.selection.dim : theme.selection.fg}>
|
|
35
|
+
{text}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
13
39
|
return <span fg={theme.meta[tone]}>{text}</span>;
|
|
14
40
|
}
|