claudeup 4.37.0 → 4.38.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 +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +531 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +101 -7
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +113 -1
- package/src/ui/state/types.ts +60 -2
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMMUNITY_STYLES,
|
|
3
|
+
type CommunityStyle,
|
|
4
|
+
type CommunityStyleSource,
|
|
5
|
+
findCommunitySource,
|
|
6
|
+
} from "../../data/community-styles.js";
|
|
7
|
+
import type { CommunityUpstreamStatus } from "../../services/community-styles.js";
|
|
8
|
+
import type {
|
|
9
|
+
ImportedStyle,
|
|
10
|
+
StylePreset,
|
|
11
|
+
StyleSource,
|
|
12
|
+
} from "../../services/styles-manager.js";
|
|
13
|
+
|
|
14
|
+
// ─── Item types ───────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* List sections, in display order.
|
|
18
|
+
*
|
|
19
|
+
* Anthropic's official styles lead because they are the familiar starting
|
|
20
|
+
* point — someone who already knows Explanatory or Learning should find it
|
|
21
|
+
* first, then discover the presets that refine it. Team comes after the
|
|
22
|
+
* presets: it is the section a project fills in for itself.
|
|
23
|
+
*
|
|
24
|
+
* Community sorts LAST, and deliberately not second where "styles someone else
|
|
25
|
+
* wrote" would group it with Anthropic's. Two reasons decide it. It is the only
|
|
26
|
+
* section whose contents can change from the network, so keeping it at the
|
|
27
|
+
* bottom means the offline-authored sections never shift position underneath a
|
|
28
|
+
* user. And empty sections are omitted, so inserting it at position two would
|
|
29
|
+
* push Anthropic's familiar row down the screen for every existing user to buy
|
|
30
|
+
* discovery for a section most of them will not use.
|
|
31
|
+
*/
|
|
32
|
+
export type StyleCategoryKey =
|
|
33
|
+
| "anthropic"
|
|
34
|
+
| "verbosity"
|
|
35
|
+
| "modifier"
|
|
36
|
+
| "team"
|
|
37
|
+
| "personal"
|
|
38
|
+
| "community";
|
|
39
|
+
|
|
40
|
+
export interface StyleCategoryItem {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: "category";
|
|
43
|
+
label: string;
|
|
44
|
+
title: string;
|
|
45
|
+
categoryKey: StyleCategoryKey;
|
|
46
|
+
count: number;
|
|
47
|
+
tone: "purple" | "green" | "teal" | "yellow" | "gray" | "red";
|
|
48
|
+
badge?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StyleEntryItem {
|
|
52
|
+
id: string;
|
|
53
|
+
kind: "style";
|
|
54
|
+
label: string;
|
|
55
|
+
source: StyleSource;
|
|
56
|
+
/** In the pending selection (what apply would write). */
|
|
57
|
+
checked: boolean;
|
|
58
|
+
/** In the selection the generated style file records (what is live). */
|
|
59
|
+
applied: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Name of an already-selected style this one cancels out with, or null.
|
|
62
|
+
* Verbosity presets never report one — they behave as a radio group, so
|
|
63
|
+
* selecting a second replaces the first rather than colliding with it.
|
|
64
|
+
*/
|
|
65
|
+
conflictsWith: string | null;
|
|
66
|
+
/** Template presets ship an empty table and cannot be applied directly. */
|
|
67
|
+
disabled: boolean;
|
|
68
|
+
/** For a fetched community style: what the last upstream check found. */
|
|
69
|
+
upstream?: CommunityUpstreamStatus;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A registry entry whose text is NOT on this machine yet.
|
|
74
|
+
*
|
|
75
|
+
* Not an `ImportedStyle`, and that is the point. `ImportedStyle` means "text
|
|
76
|
+
* that exists here"; a synthetic one carrying an empty body would be tickable,
|
|
77
|
+
* composable, and would hash to a lie. Keeping the type honest costs one extra
|
|
78
|
+
* item kind and buys a list where every tickable row has real words behind it.
|
|
79
|
+
*/
|
|
80
|
+
export interface StyleOfferItem {
|
|
81
|
+
id: string;
|
|
82
|
+
kind: "offer";
|
|
83
|
+
label: string;
|
|
84
|
+
entry: CommunityStyle;
|
|
85
|
+
source: CommunityStyleSource;
|
|
86
|
+
/** True when the project's committed declaration asks for this one. */
|
|
87
|
+
declared: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type StyleBrowserItem =
|
|
91
|
+
| StyleCategoryItem
|
|
92
|
+
| StyleEntryItem
|
|
93
|
+
| StyleOfferItem;
|
|
94
|
+
|
|
95
|
+
// ─── Adapter ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface BuildStyleBrowserItemsArgs {
|
|
98
|
+
presets: StylePreset[];
|
|
99
|
+
imports: ImportedStyle[];
|
|
100
|
+
/** Pending selection, by `source.id`. */
|
|
101
|
+
selected: Set<string>;
|
|
102
|
+
/** Selection recorded in the generated style file (null if never applied). */
|
|
103
|
+
applied: { presets: string[]; imports: string[] } | null;
|
|
104
|
+
query: string;
|
|
105
|
+
/**
|
|
106
|
+
* Registry entries to offer. Defaults to the shipped registry, which is a
|
|
107
|
+
* compile-time constant, so building the list still costs no I/O whatsoever.
|
|
108
|
+
*/
|
|
109
|
+
registry?: CommunityStyle[];
|
|
110
|
+
/** Last upstream check per coordinate id. Read from the local store only. */
|
|
111
|
+
upstream?: Record<string, CommunityUpstreamStatus>;
|
|
112
|
+
/** `community:` ids the declaration asks for and this machine lacks. */
|
|
113
|
+
fetchable?: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function matches(source: StyleSource, lowerQuery: string): boolean {
|
|
117
|
+
if (!lowerQuery) return true;
|
|
118
|
+
const summary =
|
|
119
|
+
source.kind === "preset" ? source.summary : source.description;
|
|
120
|
+
// Filter on the displayed name too. A captured built-in shows as
|
|
121
|
+
// "Explanatory" and a titled preset as its full standard name; typing what
|
|
122
|
+
// you can see has to find it.
|
|
123
|
+
const shown = source.displayName;
|
|
124
|
+
return (
|
|
125
|
+
source.name.toLowerCase().includes(lowerQuery) ||
|
|
126
|
+
shown.toLowerCase().includes(lowerQuery) ||
|
|
127
|
+
summary.toLowerCase().includes(lowerQuery)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Same rule for an offer, over the fields an offer actually has. */
|
|
132
|
+
function offerMatches(entry: CommunityStyle, lowerQuery: string): boolean {
|
|
133
|
+
if (!lowerQuery) return true;
|
|
134
|
+
return (
|
|
135
|
+
entry.id.toLowerCase().includes(lowerQuery) ||
|
|
136
|
+
entry.displayName.toLowerCase().includes(lowerQuery) ||
|
|
137
|
+
entry.summary.toLowerCase().includes(lowerQuery)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Which already-selected preset cancels `preset` out.
|
|
143
|
+
*
|
|
144
|
+
* `conflicts` is declared on one side of a pair in the shipped presets but the
|
|
145
|
+
* relationship is symmetric, so both directions are checked — otherwise the
|
|
146
|
+
* warning appears or not depending on which of the two the user ticked first.
|
|
147
|
+
*/
|
|
148
|
+
function findConflict(
|
|
149
|
+
preset: StylePreset,
|
|
150
|
+
selectedPresets: StylePreset[],
|
|
151
|
+
): string | null {
|
|
152
|
+
for (const other of selectedPresets) {
|
|
153
|
+
if (other.name === preset.name) continue;
|
|
154
|
+
if (
|
|
155
|
+
preset.conflicts.includes(other.name) ||
|
|
156
|
+
other.conflicts.includes(preset.name)
|
|
157
|
+
) {
|
|
158
|
+
return other.name;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the flat list for the Styles screen.
|
|
166
|
+
*
|
|
167
|
+
* Section order is Anthropic official, verbosity (exactly one), modifiers (any
|
|
168
|
+
* number), the project's own styles, this machine's, and finally other people's.
|
|
169
|
+
* Category rows are non-selectable headers — the screen's key handler skips
|
|
170
|
+
* them, and an empty section is omitted entirely rather than shown with a zero
|
|
171
|
+
* count.
|
|
172
|
+
*/
|
|
173
|
+
export function buildStyleBrowserItems({
|
|
174
|
+
presets,
|
|
175
|
+
imports,
|
|
176
|
+
selected,
|
|
177
|
+
applied,
|
|
178
|
+
query,
|
|
179
|
+
registry = COMMUNITY_STYLES,
|
|
180
|
+
upstream = {},
|
|
181
|
+
fetchable = [],
|
|
182
|
+
}: BuildStyleBrowserItemsArgs): StyleBrowserItem[] {
|
|
183
|
+
const lowerQuery = query.trim().toLowerCase();
|
|
184
|
+
const items: StyleBrowserItem[] = [];
|
|
185
|
+
|
|
186
|
+
const appliedPresets = new Set(applied?.presets ?? []);
|
|
187
|
+
const appliedImports = new Set(applied?.imports ?? []);
|
|
188
|
+
const selectedPresets = presets.filter((preset) => selected.has(preset.id));
|
|
189
|
+
|
|
190
|
+
const verbosity = presets.filter((preset) => preset.axis === "verbosity");
|
|
191
|
+
const modifiers = presets.filter((preset) => preset.axis === "modifier");
|
|
192
|
+
|
|
193
|
+
const pushPresets = (
|
|
194
|
+
group: StylePreset[],
|
|
195
|
+
categoryKey: StyleCategoryKey,
|
|
196
|
+
title: string,
|
|
197
|
+
tone: StyleCategoryItem["tone"],
|
|
198
|
+
badge: string,
|
|
199
|
+
) => {
|
|
200
|
+
const filtered = group.filter((preset) => matches(preset, lowerQuery));
|
|
201
|
+
if (filtered.length === 0) return;
|
|
202
|
+
items.push({
|
|
203
|
+
id: `cat:${categoryKey}`,
|
|
204
|
+
kind: "category",
|
|
205
|
+
label: title,
|
|
206
|
+
title,
|
|
207
|
+
categoryKey,
|
|
208
|
+
count: filtered.length,
|
|
209
|
+
tone,
|
|
210
|
+
badge,
|
|
211
|
+
});
|
|
212
|
+
for (const preset of filtered) {
|
|
213
|
+
items.push({
|
|
214
|
+
id: preset.id,
|
|
215
|
+
kind: "style",
|
|
216
|
+
label: preset.displayName,
|
|
217
|
+
source: preset,
|
|
218
|
+
checked: selected.has(preset.id),
|
|
219
|
+
applied: appliedPresets.has(preset.name),
|
|
220
|
+
conflictsWith:
|
|
221
|
+
preset.axis === "verbosity"
|
|
222
|
+
? null
|
|
223
|
+
: findConflict(preset, selectedPresets),
|
|
224
|
+
disabled: preset.template,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const pushImports = (
|
|
230
|
+
origin: ImportedStyle["origin"],
|
|
231
|
+
categoryKey: StyleCategoryKey,
|
|
232
|
+
title: string,
|
|
233
|
+
tone: StyleCategoryItem["tone"],
|
|
234
|
+
badge: string,
|
|
235
|
+
) => {
|
|
236
|
+
const filtered = imports.filter(
|
|
237
|
+
(style) => style.origin === origin && matches(style, lowerQuery),
|
|
238
|
+
);
|
|
239
|
+
if (filtered.length === 0) return;
|
|
240
|
+
items.push({
|
|
241
|
+
id: `cat:${categoryKey}`,
|
|
242
|
+
kind: "category",
|
|
243
|
+
label: title,
|
|
244
|
+
title,
|
|
245
|
+
categoryKey,
|
|
246
|
+
count: filtered.length,
|
|
247
|
+
tone,
|
|
248
|
+
badge,
|
|
249
|
+
});
|
|
250
|
+
for (const style of filtered) {
|
|
251
|
+
items.push({
|
|
252
|
+
id: style.id,
|
|
253
|
+
kind: "style",
|
|
254
|
+
// The displayed name, not the on-disk slug — "Explanatory", not
|
|
255
|
+
// "builtin-explanatory". `id` still carries the real one.
|
|
256
|
+
label: style.displayName,
|
|
257
|
+
source: style,
|
|
258
|
+
checked: selected.has(style.id),
|
|
259
|
+
applied: appliedImports.has(style.id),
|
|
260
|
+
conflictsWith: null,
|
|
261
|
+
disabled: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* ONE stable order: registry order, by source then display name, whether a
|
|
268
|
+
* style is fetched or still an offer.
|
|
269
|
+
*
|
|
270
|
+
* Grouping fetched ones first was worse in use. Fetching moved the row out
|
|
271
|
+
* of the offers group and up into the fetched group, so everything below it
|
|
272
|
+
* shifted by one and the cursor — which tracks an index — ended up on a
|
|
273
|
+
* different style than the one just acted on. Position now says WHERE a
|
|
274
|
+
* style is in the registry and nothing else; whether it is on disk is the
|
|
275
|
+
* checkbox's job, which is the thing that can change without moving.
|
|
276
|
+
*
|
|
277
|
+
* Never ordered by popularity: the best-known names in this space ship no
|
|
278
|
+
* style files at all, so any such ranking would be one the registry cannot
|
|
279
|
+
* honour.
|
|
280
|
+
*/
|
|
281
|
+
const pushCommunity = () => {
|
|
282
|
+
const declared = new Set(fetchable);
|
|
283
|
+
const fetched = imports.filter((style) => style.origin === "community");
|
|
284
|
+
// A community style's `name` IS its registry coordinate id, which is what
|
|
285
|
+
// lets one row hold either state without moving.
|
|
286
|
+
const onDisk = new Map(fetched.map((style) => [style.name, style]));
|
|
287
|
+
|
|
288
|
+
// Walk the registry in its own order and emit whichever state each entry
|
|
289
|
+
// is in. A fetched entry keeps the slot its offer occupied.
|
|
290
|
+
const rows = registry
|
|
291
|
+
.filter((entry) => !entry.retired || onDisk.has(entry.id))
|
|
292
|
+
.filter(
|
|
293
|
+
(entry) =>
|
|
294
|
+
findCommunitySource(entry.sourceId)?.retired !== true ||
|
|
295
|
+
onDisk.has(entry.id),
|
|
296
|
+
)
|
|
297
|
+
.filter((entry) => {
|
|
298
|
+
const style = onDisk.get(entry.id);
|
|
299
|
+
// Filter on whichever representation is showing, so typing a query
|
|
300
|
+
// cannot make a row vanish just because it changed state.
|
|
301
|
+
return style
|
|
302
|
+
? matches(style, lowerQuery)
|
|
303
|
+
: offerMatches(entry, lowerQuery);
|
|
304
|
+
})
|
|
305
|
+
.sort(
|
|
306
|
+
(a, b) =>
|
|
307
|
+
a.sourceId.localeCompare(b.sourceId) ||
|
|
308
|
+
a.displayName.localeCompare(b.displayName),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Anything on disk the registry no longer lists — a retired entry pulled
|
|
312
|
+
// before it was dropped. It still belongs to the user, so it is shown,
|
|
313
|
+
// after the registry rows so it cannot shift them.
|
|
314
|
+
const listed = new Set(rows.map((entry) => entry.id));
|
|
315
|
+
const orphans = fetched
|
|
316
|
+
.filter((style) => !listed.has(style.name))
|
|
317
|
+
.filter((style) => matches(style, lowerQuery))
|
|
318
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
319
|
+
|
|
320
|
+
if (rows.length === 0 && orphans.length === 0) return;
|
|
321
|
+
|
|
322
|
+
items.push({
|
|
323
|
+
id: "cat:community",
|
|
324
|
+
kind: "category",
|
|
325
|
+
label: "Community",
|
|
326
|
+
title: "Community",
|
|
327
|
+
categoryKey: "community",
|
|
328
|
+
count: rows.length + orphans.length,
|
|
329
|
+
// Yellow, shared with Anthropic captures: both sections are text
|
|
330
|
+
// captured from somewhere else onto this disk, which is the right
|
|
331
|
+
// pairing. Red is the only unused tone and it means danger here — a
|
|
332
|
+
// normal, working section must not wear it.
|
|
333
|
+
tone: "yellow",
|
|
334
|
+
badge: "from GitHub",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const pushFetched = (style: ImportedStyle) => {
|
|
338
|
+
items.push({
|
|
339
|
+
id: style.id,
|
|
340
|
+
kind: "style",
|
|
341
|
+
label: style.displayName,
|
|
342
|
+
source: style,
|
|
343
|
+
checked: selected.has(style.id),
|
|
344
|
+
applied: appliedImports.has(style.id),
|
|
345
|
+
conflictsWith: null,
|
|
346
|
+
disabled: false,
|
|
347
|
+
upstream: upstream[style.name],
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
for (const entry of rows) {
|
|
352
|
+
const style = onDisk.get(entry.id);
|
|
353
|
+
if (style) {
|
|
354
|
+
pushFetched(style);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const source = findCommunitySource(entry.sourceId);
|
|
358
|
+
if (!source) continue;
|
|
359
|
+
items.push({
|
|
360
|
+
id: `offer:${entry.id}`,
|
|
361
|
+
kind: "offer",
|
|
362
|
+
label: entry.displayName,
|
|
363
|
+
entry,
|
|
364
|
+
source,
|
|
365
|
+
declared: declared.has(`community:${entry.id}`),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const style of orphans) pushFetched(style);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Badges are kept short deliberately: the row already carries a title and a
|
|
373
|
+
// count, and a long badge wraps onto a second line on a narrow pane, which
|
|
374
|
+
// costs a whole row and breaks the scan down the left column. The full
|
|
375
|
+
// explanation lives in the detail panel, where there is room for it.
|
|
376
|
+
pushImports(
|
|
377
|
+
"anthropic",
|
|
378
|
+
"anthropic",
|
|
379
|
+
"Anthropic official",
|
|
380
|
+
"yellow",
|
|
381
|
+
"captured",
|
|
382
|
+
);
|
|
383
|
+
pushPresets(verbosity, "verbosity", "Verbosity", "purple", "pick one");
|
|
384
|
+
pushPresets(modifiers, "modifier", "Modifiers", "teal", "combine freely");
|
|
385
|
+
pushImports("team", "team", "Team", "green", "in the repo");
|
|
386
|
+
pushImports("personal", "personal", "Personal", "gray", "this machine");
|
|
387
|
+
pushCommunity();
|
|
388
|
+
|
|
389
|
+
return items;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Index of the first row the user can actually land on, or 0 if none.
|
|
394
|
+
*
|
|
395
|
+
* `kind !== "category"` rather than `kind === "style"`: an offer is landable, it
|
|
396
|
+
* simply cannot be ticked. A cursor that skipped offers would make the whole
|
|
397
|
+
* section unreachable for a user who has fetched nothing — which is every user
|
|
398
|
+
* on their first launch.
|
|
399
|
+
*/
|
|
400
|
+
export function firstSelectableIndex(items: StyleBrowserItem[]): number {
|
|
401
|
+
const index = items.findIndex((item) => item.kind !== "category");
|
|
402
|
+
return index >= 0 ? index : 0;
|
|
403
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { useDimensions } from "../state/DimensionsContext.js";
|
|
2
3
|
import type { Screen } from "../state/types.js";
|
|
3
4
|
import { theme } from "../theme.js";
|
|
4
5
|
|
|
@@ -8,7 +9,7 @@ interface Tab {
|
|
|
8
9
|
screen: Screen;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
const TABS: Tab[] = [
|
|
12
|
+
export const TABS: Tab[] = [
|
|
12
13
|
{ key: "1", label: "Plugins", screen: "plugins" },
|
|
13
14
|
{ key: "2", label: "Skills", screen: "skills" },
|
|
14
15
|
{ key: "3", label: "MCP", screen: "mcp" },
|
|
@@ -17,18 +18,57 @@ const TABS: Tab[] = [
|
|
|
17
18
|
{ key: "6", label: "CLI", screen: "cli-tools" },
|
|
18
19
|
{ key: "7", label: "Git State", screen: "gitignore" },
|
|
19
20
|
{ key: "8", label: "Alias", screen: "alias" },
|
|
21
|
+
{ key: "9", label: "Styles", screen: "styles" },
|
|
20
22
|
];
|
|
21
23
|
|
|
24
|
+
/** Columns one tab occupies: the text plus its one-space padding each side. */
|
|
25
|
+
function cellWidth(text: string): number {
|
|
26
|
+
return text.length + 2;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Columns the whole bar occupies: every cell plus the separators between. */
|
|
30
|
+
export function barWidth(texts: string[]): number {
|
|
31
|
+
if (texts.length === 0) return 0;
|
|
32
|
+
return texts.reduce((n, t) => n + cellWidth(t), 0) + (texts.length - 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Choose what each tab shows for the space available.
|
|
37
|
+
*
|
|
38
|
+
* The full bar is ~99 columns at nine tabs, so on a 94-column pane it used to
|
|
39
|
+
* overflow and OpenTUI clipped each cell — producing a row of truncated stubs
|
|
40
|
+
* ("1:", "4:", "7:Git ") that named nothing. Dropping the inactive labels keeps
|
|
41
|
+
* the bar honest: the number is what you press, and the one tab whose name
|
|
42
|
+
* matters is the one you are on.
|
|
43
|
+
*/
|
|
44
|
+
export function layoutTabs(
|
|
45
|
+
tabs: Tab[],
|
|
46
|
+
currentScreen: Screen,
|
|
47
|
+
available: number,
|
|
48
|
+
): string[] {
|
|
49
|
+
const full = tabs.map((tab) => `${tab.key}:${tab.label}`);
|
|
50
|
+
if (barWidth(full) <= available) return full;
|
|
51
|
+
return tabs.map((tab) =>
|
|
52
|
+
tab.screen === currentScreen ? `${tab.key}:${tab.label}` : tab.key,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
interface TabBarProps {
|
|
23
57
|
currentScreen: Screen;
|
|
24
58
|
}
|
|
25
59
|
|
|
26
60
|
export function TabBar({ currentScreen }: TabBarProps) {
|
|
61
|
+
const dimensions = useDimensions();
|
|
62
|
+
// ScreenLayout pads the bar by 1 each side, inside a container padded by 1.
|
|
63
|
+
const available = Math.max(10, dimensions.terminalWidth - 4);
|
|
64
|
+
const texts = layoutTabs(TABS, currentScreen, available);
|
|
65
|
+
|
|
27
66
|
return (
|
|
28
67
|
<box flexDirection="row" gap={0}>
|
|
29
68
|
{TABS.map((tab, index) => {
|
|
30
69
|
const isSelected = tab.screen === currentScreen;
|
|
31
70
|
const isLast = index === TABS.length - 1;
|
|
71
|
+
const text = texts[index];
|
|
32
72
|
|
|
33
73
|
return (
|
|
34
74
|
<box key={tab.key} flexDirection="row">
|
|
@@ -36,18 +76,12 @@ export function TabBar({ currentScreen }: TabBarProps) {
|
|
|
36
76
|
{isSelected ? (
|
|
37
77
|
<box>
|
|
38
78
|
<text bg={theme.colors.accent} fg={theme.hints.fg}>
|
|
39
|
-
<strong>
|
|
40
|
-
{" "}
|
|
41
|
-
{tab.key}:{tab.label}{" "}
|
|
42
|
-
</strong>
|
|
79
|
+
<strong> {text} </strong>
|
|
43
80
|
</text>
|
|
44
81
|
</box>
|
|
45
82
|
) : (
|
|
46
83
|
<box>
|
|
47
|
-
<text fg={theme.colors.muted}>
|
|
48
|
-
{" "}
|
|
49
|
-
{tab.key}:{tab.label}{" "}
|
|
50
|
-
</text>
|
|
84
|
+
<text fg={theme.colors.muted}> {text} </text>
|
|
51
85
|
</box>
|
|
52
86
|
)}
|
|
53
87
|
{/* Separator */}
|
|
@@ -19,7 +19,10 @@ export function ActionHints({ hints }: ActionHintsProps) {
|
|
|
19
19
|
return (
|
|
20
20
|
<box flexDirection="column" marginTop={1}>
|
|
21
21
|
{hints.map((hint) => (
|
|
22
|
-
|
|
22
|
+
// A box with no flexDirection lays out as a column here, which put
|
|
23
|
+
// every key chip on its own line above its label. The chip and what
|
|
24
|
+
// it does are one unit — the footer hints already render that way.
|
|
25
|
+
<box key={`${hint.key}:${hint.label}`} flexDirection="row">
|
|
23
26
|
<text
|
|
24
27
|
bg={
|
|
25
28
|
hint.tone === "danger"
|
|
@@ -9,6 +9,13 @@ interface ListCategoryRowProps {
|
|
|
9
9
|
badge?: string;
|
|
10
10
|
tone?: keyof typeof theme.category;
|
|
11
11
|
selected: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the row can be opened and closed. Default true, which is what
|
|
14
|
+
* every collapsible list wants. Set false for a heading that is purely a
|
|
15
|
+
* label — a ▶ on a row that does not respond to Enter reads as a control
|
|
16
|
+
* that is broken rather than as a section title.
|
|
17
|
+
*/
|
|
18
|
+
expandable?: boolean;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
export function ListCategoryRow({
|
|
@@ -18,9 +25,11 @@ export function ListCategoryRow({
|
|
|
18
25
|
badge,
|
|
19
26
|
tone = "gray",
|
|
20
27
|
selected,
|
|
28
|
+
expandable = true,
|
|
21
29
|
}: ListCategoryRowProps) {
|
|
22
30
|
const colors = theme.category[tone];
|
|
23
|
-
const
|
|
31
|
+
const marker = expandable ? (expanded ? "▼ " : "▶ ") : "";
|
|
32
|
+
const label = `${marker}${title}${count !== undefined ? ` (${count})` : ""}`;
|
|
24
33
|
|
|
25
34
|
return (
|
|
26
35
|
<SelectableRow selected={selected}>
|
package/src/ui/registry.ts
CHANGED
|
@@ -8,6 +8,12 @@ export interface RowRenderProps<T> {
|
|
|
8
8
|
|
|
9
9
|
export interface DetailRenderProps<T> {
|
|
10
10
|
item: T;
|
|
11
|
+
/**
|
|
12
|
+
* Usable columns in the detail panel. Optional — renderers that only emit
|
|
13
|
+
* short lines can ignore it. Renderers that print wrapped body text need it,
|
|
14
|
+
* because OpenTUI does not reflow: an unwrapped line is clipped, not folded.
|
|
15
|
+
*/
|
|
16
|
+
width?: number;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export interface Hint {
|