critique 0.0.3 → 0.0.5

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/AGENTS.md CHANGED
@@ -10,6 +10,8 @@ ALWAYS!
10
10
 
11
11
  NEVER run bun run index.tsx. You cannot directly run the tui app. it will hang. instead ask me to do so.
12
12
 
13
+ NEVER use require. just import at the top of the file with esm
14
+
13
15
  use bun add to install packages instead of npm
14
16
 
15
17
  ## React
@@ -62,3 +64,12 @@ you can read more examples of opentui react code using gitchamber by listing and
62
64
  after any meaningful change update CHANGELOG.md with the version number and the list of changes made. in concise bullet points
63
65
 
64
66
  before updating the changelog bump the package.json version field first. NEVER do major bumps. NEVER publish yourself
67
+
68
+ NEVER update existing changelog bullet points for previous version unless you added those bullet points yourself recently and the change is of the same version as it is now.
69
+
70
+
71
+ ## zustand
72
+
73
+ - minimize number of props. do not use props if you can use zustand state instead. the app has global zustand state that lets you get a piece of state down from the component tree by using something like `useStore(x => x.something)` or `useLoaderData<typeof loader>()` or even useRouteLoaderData if you are deep in the react component tree
74
+
75
+ - do not consider local state truthful when interacting with server. when interacting with the server with rpc or api calls never use state from the render function as input for the api call. this state can easily become stale or not get updated in the closure context. instead prefer using zustand `useStore.getState().stateValue`. notice that useLoaderData or useParams should be fine in this case.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ # 0.0.5
2
+
3
+ - Fix patch application to use merge-base for correct ahead-only commits
4
+ - Use `git checkout HEAD` for reliable file restoration on deselect
5
+ - Improve error messages with full stderr output
6
+ - Add `execSyncWithError` wrapper for better error handling
7
+ - Support multi-select with array-based selected values in Dropdown
8
+
9
+ # 0.0.4
10
+
11
+ - Add `pick` command to selectively apply files from another branch using interactive UI
12
+ - Use Dropdown component with search and keyboard navigation
13
+ - Apply/restore patches on select/deselect with live preview
14
+ - Support conflict detection and 3-way merge
15
+ - Show error messages in UI
16
+
1
17
  # 0.0.3
2
18
 
3
19
  - Add `pick` command to selectively apply files from another branch
package/bun.lock CHANGED
@@ -14,6 +14,7 @@
14
14
  "react": "^19.2.0",
15
15
  "react-error-boundary": "^6.0.0",
16
16
  "shiki": "^3.13.0",
17
+ "zustand": "^5.0.8",
17
18
  },
18
19
  "devDependencies": {
19
20
  "@types/bun": "1.2.23",
@@ -320,6 +321,8 @@
320
321
 
321
322
  "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
322
323
 
324
+ "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
325
+
323
326
  "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
324
327
 
325
328
  "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
package/package.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "name": "critique",
3
3
  "module": "src/diff.tsx",
4
4
  "type": "module",
5
- "version": "0.0.3",
5
+ "version": "0.0.5",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
9
- "cli": "bun --watch src/cli.tsx",
9
+ "cli": "bun src/cli.tsx",
10
10
  "prepublishonly": "tsc",
11
11
  "example": "bun --watch src/example.tsx"
12
12
  },
@@ -24,6 +24,7 @@
24
24
  "diff": "^8.0.2",
25
25
  "react": "^19.2.0",
26
26
  "react-error-boundary": "^6.0.0",
27
- "shiki": "^3.13.0"
27
+ "shiki": "^3.13.0",
28
+ "zustand": "^5.0.8"
28
29
  }
29
30
  }
package/src/cli.tsx CHANGED
@@ -15,9 +15,24 @@ import fs from "fs";
15
15
  import { tmpdir } from "os";
16
16
  import { join } from "path";
17
17
  import * as p from "@clack/prompts";
18
+ import { create } from "zustand";
19
+ import Dropdown from "./dropdown.tsx";
18
20
 
19
21
  const execAsync = promisify(exec);
20
22
 
23
+ function execSyncWithError(
24
+ command: string,
25
+ options?: any,
26
+ ): { data?: any; error?: string } {
27
+ try {
28
+ const data = execSync(command, options);
29
+ return { data };
30
+ } catch (error: any) {
31
+ const stderr = error.stderr?.toString() || error.message || String(error);
32
+ return { error: stderr };
33
+ }
34
+ }
35
+
21
36
  const cli = cac("critique");
22
37
 
23
38
  class ScrollAcceleration {
@@ -223,10 +238,7 @@ cli
223
238
  });
224
239
 
225
240
  cli
226
- .command(
227
- "pick <branch>",
228
- "Pick files from another branch to apply to HEAD (experimental)",
229
- )
241
+ .command("pick <branch>", "Pick files from another branch to apply to HEAD")
230
242
  .action(async (branch: string) => {
231
243
  try {
232
244
  const { stdout: currentBranch } = await execAsync(
@@ -235,7 +247,7 @@ cli
235
247
  const current = currentBranch.trim();
236
248
 
237
249
  if (current === branch) {
238
- p.log.error("Cannot pick from the same branch");
250
+ console.error("Cannot pick from the same branch");
239
251
  process.exit(1);
240
252
  }
241
253
 
@@ -245,12 +257,12 @@ cli
245
257
  ).catch(() => ({ stdout: "" }));
246
258
 
247
259
  if (!branchExists.trim()) {
248
- p.log.error(`Branch "${branch}" does not exist`);
260
+ console.error(`Branch "${branch}" does not exist`);
249
261
  process.exit(1);
250
262
  }
251
263
 
252
264
  const { stdout: diffOutput } = await execAsync(
253
- `git diff --name-only HEAD ${branch}`,
265
+ `git diff --name-only HEAD...${branch}`,
254
266
  { encoding: "utf-8" },
255
267
  );
256
268
 
@@ -260,64 +272,158 @@ cli
260
272
  .filter((f) => f);
261
273
 
262
274
  if (files.length === 0) {
263
- p.log.info("No differences found between branches");
264
- process.exit(0);
265
- }
266
-
267
- const selectedFiles = await p.autocompleteMultiselect({
268
- message: `Select files to pick from "${branch}":`,
269
- options: files.map((file) => ({
270
- value: file,
271
- label: file,
272
- })),
273
- required: false,
274
- });
275
-
276
- if (p.isCancel(selectedFiles)) {
277
- p.cancel("Operation cancelled.");
275
+ console.log("No differences found between branches");
278
276
  process.exit(0);
279
277
  }
280
278
 
281
- if (!selectedFiles || selectedFiles.length === 0) {
282
- p.log.info("No files selected");
283
- process.exit(0);
279
+ interface PickState {
280
+ selectedFiles: Set<string>;
281
+ appliedFiles: Map<string, boolean>; // Track which files have patches applied
282
+ message: string;
283
+ messageType: "info" | "error" | "success" | "";
284
284
  }
285
285
 
286
- const { stdout: patchData } = await execAsync(
287
- `git diff HEAD ${branch} -- ${selectedFiles.join(" ")}`,
288
- { encoding: "utf-8" },
289
- );
290
-
291
- const patchFile = join(tmpdir(), `critique-pick-${Date.now()}.patch`);
292
- fs.writeFileSync(patchFile, patchData);
286
+ const usePickStore = create<PickState>(() => ({
287
+ selectedFiles: new Set(),
288
+ appliedFiles: new Map(),
289
+ message: "",
290
+ messageType: "",
291
+ }));
293
292
 
294
- try {
295
- execSync(`git apply --3way "${patchFile}"`, { stdio: "pipe" });
296
- } catch {
297
- execSync(`git apply "${patchFile}"`, { stdio: "inherit" });
293
+ interface PickAppProps {
294
+ files: string[];
295
+ branch: string;
298
296
  }
299
297
 
300
- fs.unlinkSync(patchFile);
301
-
302
- const { stdout: conflictFiles } = await execAsync(
303
- "git diff --name-only --diff-filter=U",
304
- { encoding: "utf-8" },
305
- );
306
-
307
- const conflicts = conflictFiles
308
- .trim()
309
- .split("\n")
310
- .filter((f) => f);
311
-
312
- if (conflicts.length > 0) {
313
- p.log.warn(`Applied with conflicts in ${conflicts.length} file(s):`);
314
- conflicts.forEach((file) => p.log.message(` - ${file}`));
315
- } else {
316
- p.log.success(`Applied changes from ${selectedFiles.length} file(s)`);
298
+ function PickApp({ files, branch }: PickAppProps) {
299
+ const selectedFiles = usePickStore((s) => s.selectedFiles);
300
+ const message = usePickStore((s) => s.message);
301
+ const messageType = usePickStore((s) => s.messageType);
302
+
303
+ const handleChange = async (value: string) => {
304
+ const isSelected = selectedFiles.has(value);
305
+
306
+ if (isSelected) {
307
+ const { error } = execSyncWithError(
308
+ `git checkout HEAD -- "${value}"`,
309
+ { stdio: "pipe" },
310
+ );
311
+ if (error) {
312
+ usePickStore.setState({
313
+ message: `Failed to restore ${value}: ${error}`,
314
+ messageType: "error",
315
+ });
316
+ return;
317
+ }
318
+
319
+ usePickStore.setState((state) => ({
320
+ selectedFiles: new Set(
321
+ Array.from(state.selectedFiles).filter((f) => f !== value),
322
+ ),
323
+ appliedFiles: new Map(
324
+ Array.from(state.appliedFiles).filter(([k]) => k !== value),
325
+ ),
326
+ }));
327
+ } else {
328
+ const { stdout: mergeBase } = await execAsync(
329
+ `git merge-base HEAD ${branch}`,
330
+ { encoding: "utf-8" },
331
+ );
332
+ const base = mergeBase.trim();
333
+
334
+ const { stdout: patchData } = await execAsync(
335
+ `git diff ${base} ${branch} -- ${value}`,
336
+ { encoding: "utf-8" },
337
+ );
338
+
339
+ const patchFile = join(
340
+ tmpdir(),
341
+ `critique-pick-${Date.now()}.patch`,
342
+ );
343
+ fs.writeFileSync(patchFile, patchData);
344
+
345
+ const result1 = execSyncWithError(
346
+ `git apply --3way "${patchFile}"`,
347
+ {
348
+ stdio: "pipe",
349
+ },
350
+ );
351
+
352
+ if (result1.error) {
353
+ const result2 = execSyncWithError(`git apply "${patchFile}"`, {
354
+ stdio: "pipe",
355
+ });
356
+
357
+ if (result2.error) {
358
+ usePickStore.setState({
359
+ message: `Failed to apply ${value}: ${result2.error}`,
360
+ messageType: "error",
361
+ });
362
+ fs.unlinkSync(patchFile);
363
+ return;
364
+ }
365
+ }
366
+
367
+ fs.unlinkSync(patchFile);
368
+
369
+ usePickStore.setState((state) => ({
370
+ selectedFiles: new Set([...state.selectedFiles, value]),
371
+ appliedFiles: new Map([...state.appliedFiles, [value, true]]),
372
+ message: "",
373
+ messageType: "",
374
+ }));
375
+ }
376
+ };
377
+
378
+ return (
379
+ <box style={{ padding: 1, flexDirection: "column" }}>
380
+ <Dropdown
381
+ tooltip={`Pick files from "${branch}"`}
382
+ onChange={handleChange}
383
+ selectedValues={Array.from(selectedFiles)}
384
+ placeholder="Search files..."
385
+ >
386
+ <Dropdown.Section>
387
+ {files.map((file) => (
388
+ <Dropdown.Item
389
+ key={file}
390
+ value={file}
391
+ title={file}
392
+ keywords={file.split("/")}
393
+ />
394
+ ))}
395
+ </Dropdown.Section>
396
+ </Dropdown>
397
+ {message && (
398
+ <box
399
+ style={{
400
+ paddingLeft: 2,
401
+ paddingRight: 2,
402
+ paddingTop: 1,
403
+ paddingBottom: 1,
404
+ marginTop: 1,
405
+ }}
406
+ >
407
+ <text
408
+ fg={
409
+ messageType === "error"
410
+ ? "#ff6b6b"
411
+ : messageType === "success"
412
+ ? "#51cf66"
413
+ : "#ffffff"
414
+ }
415
+ >
416
+ {message}
417
+ </text>
418
+ </box>
419
+ )}
420
+ </box>
421
+ );
317
422
  }
318
- process.exit(0);
423
+
424
+ await render(<PickApp files={files} branch={branch} />);
319
425
  } catch (error) {
320
- p.log.error(
426
+ console.error(
321
427
  `Error: ${error instanceof Error ? error.message : String(error)}`,
322
428
  );
323
429
  process.exit(1);
@@ -326,5 +432,5 @@ cli
326
432
 
327
433
  cli.help();
328
434
  cli.version("1.0.0");
329
-
435
+ // comment
330
436
  cli.parse();
@@ -0,0 +1,134 @@
1
+ // inspired by https://github.com/pacocoursey/use-descendants
2
+ //
3
+ import * as React from "react";
4
+
5
+ type DescendantMap<T> = { [id: string]: { index: number; props?: T } };
6
+
7
+ export interface DescendantContextType<T> {
8
+ getIndexForId: (id: string, props?: T) => number;
9
+ // IMPORTANT! map is not reactive, it cannot be used in render, only in useEffect or useLayoutEffect or other event handlers like useKeyboard
10
+ map: React.RefObject<DescendantMap<T>>;
11
+ reset: () => void;
12
+ }
13
+
14
+ const randomId = () => Math.random().toString(36).slice(2, 11);
15
+
16
+ export function createDescendants<T = any>() {
17
+ const DescendantContext = React.createContext<
18
+ DescendantContextType<T> | undefined
19
+ >(undefined);
20
+
21
+ function DescendantsProvider(props: {
22
+ value: DescendantContextType<T>;
23
+ children: React.ReactNode;
24
+ }) {
25
+ // On every re-render of children, reset the count
26
+ props.value.reset();
27
+
28
+ return (
29
+ <DescendantContext.Provider value={props.value}>
30
+ {props.children}
31
+ </DescendantContext.Provider>
32
+ );
33
+ }
34
+
35
+ const useDescendants = (): DescendantContextType<T> => {
36
+ const indexCounter = React.useRef<number>(0);
37
+ const map = React.useRef<DescendantMap<T>>({});
38
+
39
+ const reset = () => {
40
+ indexCounter.current = 0;
41
+ map.current = {};
42
+ };
43
+
44
+ const getIndexForId = (id: string, props?: T) => {
45
+ if (!map.current[id])
46
+ map.current[id] = {
47
+ index: indexCounter.current++,
48
+ };
49
+ map.current[id].props = props;
50
+ return map.current[id].index;
51
+ };
52
+
53
+ // React.useEffect(() => {
54
+ // return () => {
55
+ // reset()
56
+ // }
57
+ // }, [])
58
+
59
+ // Do NOT memoize context value, so that we bypass React.memo on any children
60
+ // We NEED them to re-render, in case stable children were re-ordered
61
+ // (this creates a new object every render, so children reading the context MUST re-render)
62
+ return { getIndexForId, map, reset };
63
+ };
64
+
65
+ /**
66
+ * Return index of the current item within its parent's list
67
+ */
68
+ function useDescendant(props?: T) {
69
+ const context = React.useContext(DescendantContext);
70
+ const [descendantId] = React.useState<string>(() => randomId());
71
+ const [index, setIndex] = React.useState<number>(-1);
72
+
73
+ React.useLayoutEffect(() => {
74
+ // Do this inside of useLayoutEffect, it's only
75
+ // called for the "real render" in React strict mode
76
+ setIndex(context?.getIndexForId(descendantId, props) ?? -1);
77
+ });
78
+
79
+ return { descendantId, index };
80
+ }
81
+
82
+ return { DescendantsProvider, useDescendants, useDescendant };
83
+ }
84
+
85
+ // EXAMPLE
86
+ const { DescendantsProvider, useDescendants, useDescendant } =
87
+ createDescendants<{
88
+ title?: string;
89
+ }>();
90
+
91
+ const FilteredIndexesContext = React.createContext<number[]>([]);
92
+
93
+ const MenuExample = () => {
94
+ const context = useDescendants();
95
+ const [search, setSearchRaw] = React.useState("");
96
+ const [filteredIndexes, setFilteredIndexes] = React.useState<number[]>([]);
97
+
98
+ // Filtering logic is now in this wrapper
99
+ const setSearch = (value: string) => {
100
+ setSearchRaw(value);
101
+ const items = Object.entries(context.map.current);
102
+ const filtered = items
103
+ .filter(([, item]) =>
104
+ item.props?.title?.toLowerCase().includes(value.toLowerCase()),
105
+ )
106
+ .map(([, item]) => item.index);
107
+ setFilteredIndexes(filtered);
108
+ };
109
+
110
+ return (
111
+ <DescendantsProvider value={context}>
112
+ <FilteredIndexesContext.Provider value={filteredIndexes}>
113
+ <box>
114
+ <input value={search} onInput={setSearch} placeholder="Search..." />
115
+ <Item title="First Item" />
116
+ <Item title="Second Item" />
117
+ <Item title="Third Item" />
118
+ </box>
119
+ </FilteredIndexesContext.Provider>
120
+ </DescendantsProvider>
121
+ );
122
+ };
123
+
124
+ const Item = ({ title }: { title: string }) => {
125
+ const { index } = useDescendant({ title });
126
+ const filteredIndexes = React.useContext(FilteredIndexesContext);
127
+
128
+ // If index is not in filteredIndexes, don't render
129
+ if (!filteredIndexes.includes(index)) {
130
+ return null;
131
+ }
132
+
133
+ return <text>{title}</text>;
134
+ };
@@ -0,0 +1,423 @@
1
+ import React, {
2
+ useState,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ createContext,
7
+ useContext,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { useKeyboard } from "@opentui/react";
11
+ import { TextAttributes } from "@opentui/core";
12
+ import { Theme } from "./theme";
13
+ import { createDescendants } from "./descendants";
14
+
15
+ const logger = console;
16
+ interface CommonProps {
17
+ key?: any;
18
+ }
19
+
20
+ // SearchBarInterface provides the common search bar props
21
+ interface SearchBarInterface {
22
+ isLoading?: boolean;
23
+ filtering?: boolean | { keepSectionOrder: boolean };
24
+ onSearchTextChange?: (text: string) => void;
25
+ throttle?: boolean;
26
+ }
27
+
28
+ export interface DropdownProps extends SearchBarInterface, CommonProps {
29
+ id?: string;
30
+ tooltip?: string;
31
+ placeholder?: string;
32
+ storeValue?: boolean | undefined;
33
+ selectedValues?: string[];
34
+ children?: ReactNode;
35
+ onChange?: (newValue: string) => void;
36
+ }
37
+
38
+ export interface DropdownItemProps extends CommonProps {
39
+ title: string;
40
+ value: string;
41
+ icon?: ReactNode;
42
+
43
+ keywords?: string[];
44
+ label?: string;
45
+ }
46
+
47
+ export interface DropdownSectionProps extends CommonProps {
48
+ title?: string;
49
+ children?: ReactNode;
50
+ }
51
+
52
+ // Create descendants for Dropdown items - minimal fields needed
53
+ interface DropdownItemDescendant {
54
+ value: string;
55
+ title: string;
56
+ hidden?: boolean;
57
+ }
58
+
59
+ const {
60
+ DescendantsProvider: DropdownDescendantsProvider,
61
+ useDescendants: useDropdownDescendants,
62
+ useDescendant: useDropdownItemDescendant,
63
+ } = createDescendants<DropdownItemDescendant>();
64
+
65
+ // Context for passing data to dropdown items
66
+ interface DropdownContextValue {
67
+ searchText: string;
68
+ filtering?: boolean | { keepSectionOrder: boolean };
69
+ currentSection?: string;
70
+ selectedIndex: number;
71
+ setSelectedIndex?: (index: number) => void;
72
+ selectedValues?: string[];
73
+ onChange?: (value: string) => void;
74
+ }
75
+
76
+ const DropdownContext = createContext<DropdownContextValue>({
77
+ searchText: "",
78
+ filtering: true,
79
+ selectedIndex: 0,
80
+ });
81
+
82
+ interface DropdownType {
83
+ (props: DropdownProps): any;
84
+ Item: (props: DropdownItemProps) => any;
85
+ Section: (props: DropdownSectionProps) => any;
86
+ }
87
+
88
+ const Dropdown: DropdownType = (props) => {
89
+ const {
90
+ tooltip,
91
+ onChange,
92
+ selectedValues,
93
+ children,
94
+ placeholder = "Search…",
95
+ storeValue,
96
+ isLoading,
97
+ filtering = true,
98
+ onSearchTextChange,
99
+ throttle,
100
+ } = props;
101
+
102
+ const [selected, setSelected] = useState(0);
103
+ const [searchText, setSearchText] = useState("");
104
+ const inputRef = useRef<any>(null);
105
+ const lastSearchTextRef = useRef("");
106
+ const throttleTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
107
+ const descendantsContext = useDropdownDescendants();
108
+
109
+ const inFocus = true;
110
+
111
+ // Create context value for children
112
+ const contextValue = useMemo<DropdownContextValue>(
113
+ () => ({
114
+ searchText,
115
+ filtering,
116
+ currentSection: undefined,
117
+ selectedIndex: selected,
118
+ setSelectedIndex: setSelected,
119
+ selectedValues,
120
+ onChange: (value: string) => selectItem(value),
121
+ }),
122
+ [searchText, filtering, selected, selectedValues],
123
+ );
124
+
125
+ // Reset selected index when search changes
126
+ useEffect(() => {
127
+ setSelected(0);
128
+ }, [searchText]);
129
+
130
+ // Handle search text change with throttling
131
+ const handleSearchTextChange = (text: string) => {
132
+ if (!inFocus) return;
133
+
134
+ setSearchText(text);
135
+
136
+ if (onSearchTextChange) {
137
+ if (throttle) {
138
+ if (throttleTimeoutRef.current) {
139
+ clearTimeout(throttleTimeoutRef.current);
140
+ }
141
+ throttleTimeoutRef.current = setTimeout(() => {
142
+ onSearchTextChange(text);
143
+ }, 300);
144
+ } else {
145
+ onSearchTextChange(text);
146
+ }
147
+ }
148
+ };
149
+
150
+ const move = (direction: -1 | 1) => {
151
+ const items = Object.values(descendantsContext.map.current)
152
+ .filter((item: any) => item.index !== -1)
153
+ .sort((a: any, b: any) => a.index - b.index);
154
+
155
+ if (items.length === 0) return;
156
+
157
+ let next = selected + direction;
158
+ if (next < 0) next = items.length - 1;
159
+ if (next >= items.length) next = 0;
160
+ setSelected(next);
161
+ };
162
+
163
+ const selectItem = (itemValue: string) => {
164
+ if (onChange) {
165
+ onChange(itemValue);
166
+ }
167
+ if (storeValue) {
168
+ logger.log("Storing value:", itemValue);
169
+ }
170
+ };
171
+
172
+ // Handle keyboard navigation
173
+ useKeyboard((evt) => {
174
+ if (evt.name === "up") {
175
+ move(-1);
176
+ }
177
+ if (evt.name === "down") {
178
+ move(1);
179
+ }
180
+ if (evt.name === "return") {
181
+ const items = Object.values(descendantsContext.map.current)
182
+ .filter((item: any) => item.index !== -1)
183
+ .sort((a: any, b: any) => a.index - b.index);
184
+
185
+ const currentItem = items[selected];
186
+ if (currentItem?.props) {
187
+ selectItem((currentItem.props as DropdownItemDescendant).value);
188
+ }
189
+ }
190
+ });
191
+
192
+ return (
193
+ <DropdownDescendantsProvider value={descendantsContext}>
194
+ <DropdownContext.Provider value={contextValue}>
195
+ <box>
196
+ <box style={{ paddingLeft: 2, paddingRight: 2 }}>
197
+ <box style={{ paddingLeft: 1, paddingRight: 1 }}>
198
+ <box
199
+ style={{
200
+ flexDirection: "row",
201
+ justifyContent: "space-between",
202
+ }}
203
+ >
204
+ <text attributes={TextAttributes.BOLD}>{tooltip}</text>
205
+ <text fg={Theme.textMuted}>esc</text>
206
+ </box>
207
+ <box style={{ paddingTop: 1, paddingBottom: 2 }}>
208
+ <input
209
+ ref={inputRef}
210
+ onInput={(value) => handleSearchTextChange(value)}
211
+ placeholder={placeholder}
212
+ focused={inFocus}
213
+ value={searchText}
214
+ focusedBackgroundColor={Theme.backgroundPanel}
215
+ cursorColor={Theme.primary}
216
+ focusedTextColor={Theme.textMuted}
217
+ />
218
+ </box>
219
+ </box>
220
+ <box style={{ paddingBottom: 1 }}>
221
+ {/* Render children - they will register as descendants and render themselves */}
222
+ {children}
223
+ </box>
224
+ </box>
225
+ <box
226
+ border={false}
227
+ style={{
228
+ paddingRight: 2,
229
+ paddingLeft: 3,
230
+ paddingBottom: 1,
231
+ paddingTop: 1,
232
+ flexDirection: "row",
233
+ }}
234
+ >
235
+ <text fg={Theme.text} attributes={TextAttributes.BOLD}>
236
+
237
+ </text>
238
+ <text fg={Theme.textMuted}> select</text>
239
+ <text fg={Theme.text} attributes={TextAttributes.BOLD}>
240
+ {" "}↑↓
241
+ </text>
242
+ <text fg={Theme.textMuted}> navigate</text>
243
+ </box>
244
+ </box>
245
+ </DropdownContext.Provider>
246
+ </DropdownDescendantsProvider>
247
+ );
248
+ };
249
+
250
+ function ItemOption(props: {
251
+ title: string;
252
+ icon?: ReactNode;
253
+ active?: boolean;
254
+ current?: boolean;
255
+ label?: string;
256
+ onMouseDown?: () => void;
257
+ onMouseMove?: () => void;
258
+ }) {
259
+ const [isHovered, setIsHovered] = useState(false);
260
+
261
+ return (
262
+ <box
263
+ style={{
264
+ flexDirection: "row",
265
+ backgroundColor: props.active
266
+ ? Theme.primary
267
+ : isHovered
268
+ ? Theme.backgroundPanel
269
+ : undefined,
270
+ paddingLeft: props.active ? 0 : 1,
271
+ paddingRight: 1,
272
+ justifyContent: "space-between",
273
+ }}
274
+ border={false}
275
+ onMouseMove={() => {
276
+ setIsHovered(true);
277
+ if (props.onMouseMove) props.onMouseMove();
278
+ }}
279
+ onMouseOut={() => setIsHovered(false)}
280
+ onMouseDown={props.onMouseDown}
281
+ >
282
+ <box style={{ flexDirection: "row" }}>
283
+ {props.active && (
284
+ <text fg={Theme.background} selectable={false}>
285
+ ›{""}
286
+ </text>
287
+ )}
288
+ {props.icon && (
289
+ <text
290
+ fg={props.active ? Theme.background : Theme.text}
291
+ selectable={false}
292
+ >
293
+ {String(props.icon)}{" "}
294
+ </text>
295
+ )}
296
+ <text
297
+ fg={
298
+ props.active
299
+ ? Theme.background
300
+ : props.current
301
+ ? Theme.primary
302
+ : Theme.text
303
+ }
304
+ attributes={props.active ? TextAttributes.BOLD : undefined}
305
+ selectable={false}
306
+ >
307
+ {props.title}
308
+ </text>
309
+ </box>
310
+ {props.label && (
311
+ <text
312
+ fg={props.active ? Theme.background : Theme.textMuted}
313
+ attributes={props.active ? TextAttributes.BOLD : undefined}
314
+ selectable={false}
315
+ >
316
+ {props.label}
317
+ </text>
318
+ )}
319
+ </box>
320
+ );
321
+ }
322
+
323
+ const DropdownItem: (props: DropdownItemProps) => any = (props) => {
324
+ const context = useContext(DropdownContext);
325
+ if (!context) return null;
326
+
327
+ const { searchText, filtering, currentSection, selectedIndex, selectedValues } =
328
+ context;
329
+
330
+ // Apply filtering logic
331
+ const shouldHide = (() => {
332
+ if (!filtering || !searchText.trim()) return false;
333
+ const needle = searchText.toLowerCase().trim();
334
+ const searchableText = [props.title, ...(props.keywords || [])]
335
+ .filter(Boolean)
336
+ .join(" ")
337
+ .toLowerCase();
338
+ return !searchableText.includes(needle);
339
+ })();
340
+
341
+ // Register as descendant
342
+ const { index } = useDropdownItemDescendant({
343
+ value: props.value,
344
+ title: props.title,
345
+ hidden: shouldHide,
346
+ });
347
+
348
+ // Don't render if hidden
349
+ if (shouldHide) return null;
350
+
351
+ // Determine if active (index will be -1 if hidden)
352
+ const isActive = index === selectedIndex && index !== -1;
353
+ const isCurrent = selectedValues ? selectedValues.includes(props.value) : false;
354
+
355
+ // Handle mouse events
356
+ const handleMouseMove = () => {
357
+ // Update selected index on hover
358
+ if (
359
+ context.setSelectedIndex &&
360
+ context.selectedIndex !== index &&
361
+ index !== -1
362
+ ) {
363
+ context.setSelectedIndex(index);
364
+ }
365
+ };
366
+
367
+ const handleMouseDown = () => {
368
+ // Trigger selection on click
369
+ if (context.onChange && props.value) {
370
+ context.onChange(props.value);
371
+ }
372
+ };
373
+
374
+ // Render the item directly
375
+ return (
376
+ <ItemOption
377
+ title={props.title}
378
+ icon={props.icon}
379
+ active={isActive}
380
+ current={isCurrent}
381
+ label={props.label}
382
+ onMouseMove={handleMouseMove}
383
+ onMouseDown={handleMouseDown}
384
+ />
385
+ );
386
+ };
387
+
388
+ const DropdownSection: (props: DropdownSectionProps) => any = (props) => {
389
+ const parentContext = useContext(DropdownContext);
390
+ if (!parentContext) return null;
391
+
392
+ // Create new context with section title
393
+ const sectionContextValue = useMemo(
394
+ () => ({
395
+ ...parentContext,
396
+ currentSection: props.title,
397
+ }),
398
+ [parentContext, props.title],
399
+ );
400
+
401
+ return (
402
+ <>
403
+ {/* Render section title if provided */}
404
+ {props.title && (
405
+ <box style={{ paddingTop: 1, paddingLeft: 1 }}>
406
+ <text fg={Theme.accent} attributes={TextAttributes.BOLD}>
407
+ {props.title}
408
+ </text>
409
+ </box>
410
+ )}
411
+ {/* Render children with section context */}
412
+ <DropdownContext.Provider value={sectionContextValue}>
413
+ {props.children}
414
+ </DropdownContext.Provider>
415
+ </>
416
+ );
417
+ };
418
+
419
+ Dropdown.Item = DropdownItem;
420
+ Dropdown.Section = DropdownSection;
421
+
422
+ export default Dropdown;
423
+ export { Dropdown };
package/src/theme.tsx ADDED
@@ -0,0 +1,29 @@
1
+ export const Theme = {
2
+ // Text colors
3
+ text: "#FFFFFF",
4
+ textMuted: "#999999",
5
+
6
+ // Background colors
7
+ background: "#000000",
8
+ backgroundPanel: "#1E1E1E", // Dark gray panel background
9
+
10
+ // Primary/accent colors
11
+ primary: "#0080FF", // Blue
12
+ accent: "#00FF80", // Light green (was using this for dates)
13
+
14
+ // Accessory colors (from List component)
15
+ info: "#0080FF", // Blue for text accessories
16
+ success: "#00FF80", // Green for date accessories
17
+ warning: "#FF8000", // Orange for tag accessories
18
+ error: "#FF0000", // Red for errors
19
+
20
+ // Additional UI colors
21
+ border: "#333333",
22
+ highlight: "#0080FF",
23
+ selected: "#0080FF",
24
+ yellow: "#FFFF00", // Yellow for icons
25
+ link: "#0080FF", // Blue for links
26
+
27
+ // Transparent
28
+ transparent: undefined, // Use undefined for no background color
29
+ } as const;