@silvery/examples 0.5.2 → 0.5.4

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/{examples/apps → apps}/aichat/index.tsx +4 -3
  3. package/{examples/apps → apps}/async-data.tsx +4 -4
  4. package/apps/components.tsx +658 -0
  5. package/{examples/apps → apps}/data-explorer.tsx +8 -8
  6. package/{examples/apps → apps}/dev-tools.tsx +35 -19
  7. package/{examples/apps → apps}/inline-bench.tsx +3 -1
  8. package/{examples/apps → apps}/kanban.tsx +20 -22
  9. package/{examples/apps → apps}/layout-ref.tsx +6 -6
  10. package/{examples/apps → apps}/panes/index.tsx +1 -1
  11. package/{examples/apps → apps}/paste-demo.tsx +2 -2
  12. package/{examples/apps → apps}/scroll.tsx +2 -2
  13. package/{examples/apps → apps}/search-filter.tsx +1 -1
  14. package/apps/selection.tsx +342 -0
  15. package/apps/spatial-focus-demo.tsx +368 -0
  16. package/{examples/apps → apps}/task-list.tsx +1 -1
  17. package/apps/terminal-caps-demo.tsx +334 -0
  18. package/apps/text-selection-demo.tsx +189 -0
  19. package/apps/textarea.tsx +155 -0
  20. package/{examples/apps → apps}/theme.tsx +1 -1
  21. package/apps/vterm-demo/index.tsx +216 -0
  22. package/dist/cli.d.mts +1 -0
  23. package/dist/cli.mjs +190 -0
  24. package/dist/cli.mjs.map +1 -0
  25. package/layout/dashboard.tsx +953 -0
  26. package/layout/live-resize.tsx +282 -0
  27. package/layout/overflow.tsx +51 -0
  28. package/layout/text-layout.tsx +283 -0
  29. package/package.json +27 -11
  30. package/bin/cli.ts +0 -294
  31. package/examples/apps/components.tsx +0 -463
  32. package/examples/apps/textarea.tsx +0 -91
  33. /package/{examples/_banner.tsx → _banner.tsx} +0 -0
  34. /package/{examples/apps → apps}/aichat/components.tsx +0 -0
  35. /package/{examples/apps → apps}/aichat/script.ts +0 -0
  36. /package/{examples/apps → apps}/aichat/state.ts +0 -0
  37. /package/{examples/apps → apps}/aichat/types.ts +0 -0
  38. /package/{examples/apps → apps}/app-todo.tsx +0 -0
  39. /package/{examples/apps → apps}/cli-wizard.tsx +0 -0
  40. /package/{examples/apps → apps}/clipboard.tsx +0 -0
  41. /package/{examples/apps → apps}/explorer.tsx +0 -0
  42. /package/{examples/apps → apps}/gallery.tsx +0 -0
  43. /package/{examples/apps → apps}/outline.tsx +0 -0
  44. /package/{examples/apps → apps}/terminal.tsx +0 -0
  45. /package/{examples/apps → apps}/transform.tsx +0 -0
  46. /package/{examples/apps → apps}/virtual-10k.tsx +0 -0
  47. /package/{examples/components → components}/counter.tsx +0 -0
  48. /package/{examples/components → components}/hello.tsx +0 -0
  49. /package/{examples/components → components}/progress-bar.tsx +0 -0
  50. /package/{examples/components → components}/select-list.tsx +0 -0
  51. /package/{examples/components → components}/spinner.tsx +0 -0
  52. /package/{examples/components → components}/text-input.tsx +0 -0
  53. /package/{examples/components → components}/virtual-list.tsx +0 -0
@@ -32,7 +32,6 @@ import {
32
32
  useInput,
33
33
  useApp,
34
34
  createTerm,
35
- H1,
36
35
  Strong,
37
36
  Kbd,
38
37
  Muted,
@@ -180,8 +179,25 @@ function LogRow({ entry, isSelected }: { entry: LogEntry; isSelected: boolean })
180
179
  const badge = LEVEL_BADGES[entry.level]
181
180
  const color = LEVEL_COLORS[entry.level]
182
181
 
182
+ // When selected, use $primary-fg for all text to ensure contrast against $primary bg.
183
+ // When not selected, use level-specific colors for visual distinction.
184
+ if (isSelected) {
185
+ return (
186
+ <Box paddingX={1} backgroundColor="$primary">
187
+ <Text color="$primary-fg">{formatTime(entry.timestamp)} </Text>
188
+ <Text color="$primary-fg" bold>
189
+ {badge}
190
+ </Text>
191
+ <Text color="$primary-fg"> [{entry.source.padEnd(9)}] </Text>
192
+ <Text color="$primary-fg" bold>
193
+ {entry.message}
194
+ </Text>
195
+ </Box>
196
+ )
197
+ }
198
+
183
199
  return (
184
- <Box paddingX={1} backgroundColor={isSelected ? "$primary" : undefined}>
200
+ <Box paddingX={1}>
185
201
  <Muted>{formatTime(entry.timestamp)} </Muted>
186
202
  <Strong color={color}>{badge}</Strong>
187
203
  <Muted> [{entry.source.padEnd(9)}] </Muted>
@@ -240,7 +256,6 @@ const rng = seededRandom(12345)
240
256
 
241
257
  export function DevTools() {
242
258
  const { exit } = useApp()
243
- const { width } = useBoxRect()
244
259
  const [entries, setEntries] = useState<LogEntry[]>(() => generateInitialLogs(INITIAL_COUNT))
245
260
  const [cursor, setCursor] = useState(INITIAL_COUNT - 1)
246
261
  const [autoScroll, setAutoScroll] = useState(true)
@@ -320,25 +335,34 @@ export function DevTools() {
320
335
  )
321
336
 
322
337
  return (
323
- <Box flexDirection="column" flexGrow={1}>
338
+ <Box flexDirection="column" flexGrow={1} padding={1}>
324
339
  {/* Header */}
325
- <Box paddingX={1} justifyContent="space-between">
340
+ <Box justifyContent="space-between" backgroundColor="$surfacebg">
326
341
  <Box gap={2}>
327
- <H1>Log Viewer</H1>
342
+ <Text bold color="$primary">
343
+ {"▸"} Log Viewer
344
+ </Text>
328
345
  <LevelCounts entries={entries} />
329
346
  </Box>
330
347
  <Box gap={1}>
331
- <Strong color="$primary">{cursor + 1}</Strong>
332
- <Muted>/ {entries.length}</Muted>
333
348
  {autoScroll && (
334
- <Text color="$success" bold>
335
- {" "}
336
- LIVE
349
+ <Text backgroundColor="$success" color="$success-fg" bold>
350
+ {" LIVE "}
337
351
  </Text>
338
352
  )}
353
+ <Strong color="$primary">{cursor + 1}</Strong>
354
+ <Muted>/ {entries.length}</Muted>
339
355
  </Box>
340
356
  </Box>
341
357
 
358
+ {/* Column headers */}
359
+ <Box paddingX={1}>
360
+ <Muted>{"Time "} </Muted>
361
+ <Muted>{"Lvl"} </Muted>
362
+ <Muted>{"[Source ]"} </Muted>
363
+ <Muted>Message</Muted>
364
+ </Box>
365
+
342
366
  <Box paddingX={1}>
343
367
  <Divider />
344
368
  </Box>
@@ -347,14 +371,6 @@ export function DevTools() {
347
371
  <Box flexGrow={1} flexDirection="column">
348
372
  <LogListArea entries={entries} cursor={cursor} />
349
373
  </Box>
350
-
351
- {/* Help bar */}
352
- <Box paddingX={1} justifyContent="space-between">
353
- <Muted>
354
- <Kbd>j/k</Kbd> navigate <Kbd>g/G</Kbd> start/end <Kbd>d/i/w/e</Kbd> add log <Kbd>c</Kbd> clear{" "}
355
- <Kbd>Esc/q</Kbd> quit
356
- </Muted>
357
- </Box>
358
374
  </Box>
359
375
  )
360
376
  }
@@ -133,4 +133,6 @@ async function main() {
133
133
  }
134
134
  }
135
135
 
136
- main().catch(console.error)
136
+ if (import.meta.main) {
137
+ main().catch(console.error)
138
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import React, { useState } from "react"
12
- import { render, Box, Text, Kbd, Muted, useInput, useApp, createTerm, type Key } from "silvery"
12
+ import { render, Box, Text, useInput, useApp, createTerm, type Key } from "silvery"
13
13
  import { ExampleBanner, type ExampleMeta } from "../_banner.js"
14
14
 
15
15
  export const meta: ExampleMeta = {
@@ -102,15 +102,19 @@ function Tag({ name }: { name: string }) {
102
102
 
103
103
  function CardComponent({ card, isSelected }: { card: Card; isSelected: boolean }) {
104
104
  return (
105
- <Box flexDirection="column" borderStyle="round" borderColor={isSelected ? "$primary" : "$border"} paddingX={1}>
105
+ <Box flexDirection="column" borderStyle="round" borderColor={isSelected ? "$primary" : "$border"}>
106
106
  {isSelected ? (
107
- <Text backgroundColor="$primary" color="black" bold>
108
- {card.title}
109
- </Text>
107
+ <Box backgroundColor="$primary" paddingX={1}>
108
+ <Text color="$primary-fg" bold wrap="truncate">
109
+ {card.title}
110
+ </Text>
111
+ </Box>
110
112
  ) : (
111
- <Text>{card.title}</Text>
113
+ <Box paddingX={1}>
114
+ <Text wrap="truncate">{card.title}</Text>
115
+ </Box>
112
116
  )}
113
- <Box gap={1}>
117
+ <Box gap={1} paddingX={1}>
114
118
  {card.tags.map((tag) => (
115
119
  <Tag key={tag} name={tag} />
116
120
  ))}
@@ -129,12 +133,18 @@ function ColumnComponent({
129
133
  selectedCardIndex: number
130
134
  }) {
131
135
  return (
132
- <Box flexDirection="column" flexGrow={1} borderStyle="single" borderColor={isSelected ? "$primary" : "$border"}>
136
+ <Box
137
+ flexDirection="column"
138
+ flexGrow={1}
139
+ flexBasis={0}
140
+ borderStyle="single"
141
+ borderColor={isSelected ? "$primary" : "$border"}
142
+ >
133
143
  <Box backgroundColor={isSelected ? "$primary" : undefined} paddingX={1}>
134
- <Text bold color={isSelected ? "black" : "$text"}>
144
+ <Text bold color={isSelected ? "$primary-fg" : "$text"}>
135
145
  {column.title}
136
146
  </Text>
137
- <Text color={isSelected ? "black" : "$muted"}> ({column.cards.length})</Text>
147
+ <Text color={isSelected ? "$primary-fg" : "$muted"}> ({column.cards.length})</Text>
138
148
  </Box>
139
149
 
140
150
  <Box
@@ -143,7 +153,6 @@ function ColumnComponent({
143
153
  overflow="scroll"
144
154
  scrollTo={isSelected ? selectedCardIndex : undefined}
145
155
  flexGrow={1}
146
- gap={1}
147
156
  >
148
157
  {column.cards.map((card, cardIndex) => (
149
158
  <CardComponent key={card.id} card={card} isSelected={isSelected && cardIndex === selectedCardIndex} />
@@ -159,15 +168,6 @@ function ColumnComponent({
159
168
  )
160
169
  }
161
170
 
162
- function HelpBar() {
163
- return (
164
- <Muted>
165
- {" "}
166
- <Kbd>h/l</Kbd> column <Kbd>j/k</Kbd> card <Kbd>{"</"}</Kbd> move <Kbd>Esc/q</Kbd> quit
167
- </Muted>
168
- )
169
- }
170
-
171
171
  export function KanbanBoard() {
172
172
  const { exit } = useApp()
173
173
  const [columns, setColumns] = useState<Column[]>(initialColumns)
@@ -241,8 +241,6 @@ export function KanbanBoard() {
241
241
  />
242
242
  ))}
243
243
  </Box>
244
-
245
- <HelpBar />
246
244
  </Box>
247
245
  )
248
246
  }
@@ -76,8 +76,8 @@ function ImperativeAccessDemo() {
76
76
  }
77
77
 
78
78
  return (
79
- <Box ref={boxRef} flexDirection="column" borderStyle="double" borderColor="magenta" padding={1}>
80
- <H1 color="magenta">Imperative Access (BoxHandle)</H1>
79
+ <Box ref={boxRef} flexDirection="column" borderStyle="double" borderColor="$accent" padding={1}>
80
+ <H1 color="$accent">Imperative Access (BoxHandle)</H1>
81
81
  <Muted>Press 'i' to inspect this box</Muted>
82
82
  <Box marginTop={1}>
83
83
  <Text>{info}</Text>
@@ -116,13 +116,13 @@ export function LayoutRefApp() {
116
116
  <Box flexDirection="column" padding={1}>
117
117
  {/* Row of resizable panes with onLayout callbacks */}
118
118
  <Box flexDirection="row" gap={1} height={8}>
119
- <ResizablePane title="Pane A" color="green" onLayoutChange={handleLayoutChange("a")} />
120
- <ResizablePane title="Pane B" color="blue" onLayoutChange={handleLayoutChange("b")} />
121
- <ResizablePane title="Pane C" color="cyan" onLayoutChange={handleLayoutChange("c")} />
119
+ <ResizablePane title="Pane A" color="$success" onLayoutChange={handleLayoutChange("a")} />
120
+ <ResizablePane title="Pane B" color="$info" onLayoutChange={handleLayoutChange("b")} />
121
+ <ResizablePane title="Pane C" color="$primary" onLayoutChange={handleLayoutChange("c")} />
122
122
  </Box>
123
123
 
124
124
  {/* Show layout info from onLayout callbacks */}
125
- <Box marginTop={1} borderStyle="single" borderColor="gray" padding={1}>
125
+ <Box marginTop={1} borderStyle="single" borderColor="$border" padding={1}>
126
126
  <Box flexDirection="column">
127
127
  <Text bold dim>
128
128
  onLayout Results:
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React, { useState, useEffect, useMemo } from "react"
11
11
  import { Box, Text, ListView } from "silvery"
12
- import { SearchProvider, SearchBar, useSearch } from "silvery"
12
+ import { SearchProvider, SearchBar, useSearch } from "@silvery/ag-react"
13
13
  import { run, useInput, type Key } from "silvery/runtime"
14
14
  import type { ExampleMeta } from "../../_banner.js"
15
15
  import { SCRIPT } from "../aichat/script.js"
@@ -68,7 +68,7 @@ function PasteEventCard({ event, isLatest }: { event: PasteEvent; isLatest: bool
68
68
  marginBottom={0}
69
69
  >
70
70
  <Box justifyContent="space-between">
71
- <H1 color={isLatest ? "$primary" : "white"}>Paste #{event.id}</H1>
71
+ <H1 color={isLatest ? "$primary" : undefined}>Paste #{event.id}</H1>
72
72
  <Small>{event.timestamp}</Small>
73
73
  </Box>
74
74
  <Box gap={2}>
@@ -80,7 +80,7 @@ function PasteEventCard({ event, isLatest }: { event: PasteEvent; isLatest: bool
80
80
  </Small>
81
81
  </Box>
82
82
  <Box marginTop={1}>
83
- <Text color="yellow">{displayText}</Text>
83
+ <Text color="$warning">{displayText}</Text>
84
84
  </Box>
85
85
  </Box>
86
86
  )
@@ -38,7 +38,7 @@ export function ScrollExample() {
38
38
  })
39
39
 
40
40
  return (
41
- <Box flexDirection="column" width={60} height={20}>
41
+ <Box flexDirection="column" padding={1} width={60} height={20}>
42
42
  <Box
43
43
  flexGrow={1}
44
44
  flexDirection="column"
@@ -50,7 +50,7 @@ export function ScrollExample() {
50
50
  >
51
51
  {items.map((item, index) => (
52
52
  <Box key={item.id} paddingX={1} backgroundColor={index === selectedIndex ? "$primary" : undefined}>
53
- <Text color={index === selectedIndex ? "black" : "white"} bold={index === selectedIndex}>
53
+ <Text color={index === selectedIndex ? "$primary-fg" : undefined} bold={index === selectedIndex}>
54
54
  {item.title}
55
55
  </Text>
56
56
  </Box>
@@ -162,7 +162,7 @@ function FilteredList({ query, isPending }: { query: string; isPending: boolean
162
162
  <Box key={item.id} marginBottom={1}>
163
163
  <Text bold>{item.name}</Text>
164
164
  <Text dim> [{item.category}]</Text>
165
- <Text color="gray"> {item.tags.join(", ")}</Text>
165
+ <Text color="$muted"> {item.tags.join(", ")}</Text>
166
166
  </Box>
167
167
  ))}
168
168
  {filtered.length === 0 && <Lead>No matches found</Lead>}
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Selection Model Demo
3
+ *
4
+ * Demonstrates the silvery selection model from docs/design/selection-model.md:
5
+ * - Node selection (click, j/k)
6
+ * - Multi-select (Cmd+click toggle, Shift+j/k extend)
7
+ * - Text editing (Enter → edit, Escape → node mode)
8
+ * - Mode ladder: text ──Esc──► node ──Esc──► board
9
+ * - Live status bar showing Selection state
10
+ *
11
+ * Run: bun examples/apps/selection.tsx
12
+ */
13
+
14
+ import React, { useState, useCallback } from "react"
15
+ import { Box, Text, type SilveryMouseEvent } from "silvery"
16
+ import { run, useInput, type Key } from "silvery/runtime"
17
+
18
+ // ============================================================================
19
+ // Selection Model (pure functions — the whole design doc in ~60 lines)
20
+ // ============================================================================
21
+
22
+ type ID = string
23
+
24
+ type TextPoint = { nodeId: ID; offset: number }
25
+
26
+ type Selection = {
27
+ nodes: readonly [ID, ...ID[]]
28
+ text?: readonly [TextPoint] | readonly [TextPoint, TextPoint]
29
+ }
30
+
31
+ const S = {
32
+ // Read
33
+ cursor: (sel: Selection): ID => sel.nodes[0],
34
+ anchor: (sel: Selection): ID => sel.nodes.at(-1)!,
35
+ ids: (sel: Selection): ReadonlySet<ID> => new Set(sel.nodes),
36
+ includes: (sel: Selection, id: ID): boolean => sel.nodes.includes(id),
37
+ isEditing: (sel: Selection): boolean => !!sel.text,
38
+ inputMode: (sel: Selection | undefined): "board" | "node" | "text" => (!sel ? "board" : sel.text ? "text" : "node"),
39
+
40
+ // Node mutations (clear text)
41
+ select: (id: ID): Selection => ({ nodes: [id] }),
42
+
43
+ toggle(sel: Selection, id: ID): Selection | undefined {
44
+ if (sel.nodes.includes(id)) {
45
+ const rest = sel.nodes.filter((n) => n !== id) as unknown as [ID, ...ID[]]
46
+ return rest.length > 0 ? { nodes: rest } : undefined
47
+ }
48
+ return { nodes: [id, ...sel.nodes] }
49
+ },
50
+
51
+ extend(sel: Selection, id: ID, allIds: readonly ID[]): Selection {
52
+ const anchorIdx = allIds.indexOf(S.anchor(sel))
53
+ const targetIdx = allIds.indexOf(id)
54
+ if (anchorIdx < 0 || targetIdx < 0) return sel
55
+ const lo = Math.min(anchorIdx, targetIdx)
56
+ const hi = Math.max(anchorIdx, targetIdx)
57
+ const range = allIds.slice(lo, hi + 1)
58
+ // cursor first, anchor last
59
+ const nodes = targetIdx <= anchorIdx ? (range as [ID, ...ID[]]) : ([...range].reverse() as [ID, ...ID[]])
60
+ return { nodes }
61
+ },
62
+
63
+ areaSelect(_sel: Selection | undefined, hitIds: readonly ID[], mode: "replace" | "xor"): Selection | undefined {
64
+ if (mode === "replace") {
65
+ return hitIds.length > 0 ? { nodes: hitIds as [ID, ...ID[]] } : undefined
66
+ }
67
+ // XOR: toggle each hit against current
68
+ let result = _sel
69
+ for (const id of hitIds) {
70
+ result = result ? S.toggle(result, id) : S.select(id)
71
+ }
72
+ return result
73
+ },
74
+
75
+ clear: (): undefined => undefined,
76
+
77
+ collapseToCursor(sel: Selection): Selection {
78
+ return { nodes: [sel.nodes[0]] }
79
+ },
80
+
81
+ // Text mutations (don't touch nodes)
82
+ edit(sel: Selection, offset: number): Selection {
83
+ return { ...sel, text: [{ nodeId: sel.nodes[0], offset }] }
84
+ },
85
+
86
+ stopEditing(sel: Selection): Selection {
87
+ const { text: _, ...rest } = sel
88
+ return rest as Selection
89
+ },
90
+ }
91
+
92
+ // ============================================================================
93
+ // Demo Data
94
+ // ============================================================================
95
+
96
+ const ITEMS: { id: ID; label: string }[] = [
97
+ { id: "inbox", label: "Inbox" },
98
+ { id: "today", label: "Today" },
99
+ { id: "next", label: "Next Actions" },
100
+ { id: "projects", label: "Projects" },
101
+ { id: "waiting", label: "Waiting For" },
102
+ { id: "someday", label: "Someday / Maybe" },
103
+ { id: "reference", label: "Reference" },
104
+ { id: "calendar", label: "Calendar" },
105
+ { id: "review", label: "Weekly Review" },
106
+ { id: "done", label: "Done" },
107
+ ]
108
+ const ALL_IDS = ITEMS.map((i) => i.id)
109
+
110
+ // ============================================================================
111
+ // Components
112
+ // ============================================================================
113
+
114
+ function ItemRow({
115
+ item,
116
+ sel,
117
+ onSelect,
118
+ }: {
119
+ item: { id: ID; label: string }
120
+ sel: Selection | undefined
121
+ onSelect: (id: ID, meta: boolean, shift: boolean) => void
122
+ }) {
123
+ const isCursor = sel ? S.cursor(sel) === item.id : false
124
+ const isAnchor = sel ? S.anchor(sel) === item.id : false
125
+ const isSelected = sel ? S.includes(sel, item.id) : false
126
+ const isEditing = isCursor && sel ? S.isEditing(sel) : false
127
+
128
+ const marker = isCursor ? "►" : isSelected ? "●" : " "
129
+ const anchorMark = isAnchor && !isCursor ? " ⚓" : ""
130
+
131
+ return (
132
+ <Box
133
+ onClick={(e: SilveryMouseEvent) => onSelect(item.id, e.metaKey, e.shiftKey)}
134
+ onDoubleClick={() => onSelect(item.id, false, false)}
135
+ >
136
+ <Text color={isSelected ? "$primary" : "$muted"}>{marker} </Text>
137
+ {isEditing ? (
138
+ <Text backgroundColor="$surface" color="$text" bold>
139
+ {" "}
140
+ {item.label}
141
+ <Text color="$primary">│</Text>{" "}
142
+ </Text>
143
+ ) : (
144
+ <Text
145
+ bold={isCursor}
146
+ color={isCursor ? "$primary" : isSelected ? "$primary" : "$text"}
147
+ dim={!isSelected && !isCursor}
148
+ >
149
+ {item.label}
150
+ </Text>
151
+ )}
152
+ <Text color="$muted" dim>
153
+ {anchorMark}
154
+ </Text>
155
+ </Box>
156
+ )
157
+ }
158
+
159
+ function StatusBar({ sel }: { sel: Selection | undefined }) {
160
+ const mode = S.inputMode(sel)
161
+ const modeColor = mode === "text" ? "$success" : mode === "node" ? "$primary" : "$muted"
162
+
163
+ return (
164
+ <Box flexDirection="column" borderStyle="single" borderColor="$border" paddingX={1}>
165
+ <Box gap={2}>
166
+ <Text color={modeColor} bold>
167
+ {mode.toUpperCase()}
168
+ </Text>
169
+ {sel && (
170
+ <>
171
+ <Text color="$muted">
172
+ cursor=<Text color="$primary">{S.cursor(sel)}</Text>
173
+ </Text>
174
+ <Text color="$muted">
175
+ anchor=<Text color="$text">{S.anchor(sel)}</Text>
176
+ </Text>
177
+ <Text color="$muted">
178
+ selected=<Text color="$text">{sel.nodes.length}</Text>
179
+ </Text>
180
+ {sel.text && (
181
+ <Text color="$muted">
182
+ text=<Text color="$success">offset {sel.text[0].offset}</Text>
183
+ </Text>
184
+ )}
185
+ </>
186
+ )}
187
+ </Box>
188
+ <Text color="$muted" dim>
189
+ j/k nav · Enter edit · Esc back · Shift+j/k extend · Cmd+click toggle · q quit
190
+ </Text>
191
+ </Box>
192
+ )
193
+ }
194
+
195
+ function SelectionDemo() {
196
+ const [sel, setSel] = useState<Selection | undefined>(undefined)
197
+ const [editTexts, setEditTexts] = useState<Record<ID, string>>(() =>
198
+ Object.fromEntries(ITEMS.map((i) => [i.id, i.label])),
199
+ )
200
+
201
+ const cursorIndex = sel ? ALL_IDS.indexOf(S.cursor(sel)) : -1
202
+
203
+ const handleSelect = useCallback((id: ID, meta: boolean, shift: boolean) => {
204
+ setSel((prev) => {
205
+ if (meta && prev) return S.toggle(prev, id)
206
+ if (shift && prev) return S.extend(prev, id, ALL_IDS)
207
+ return S.select(id)
208
+ })
209
+ }, [])
210
+
211
+ useInput((input: string, key: Key) => {
212
+ if (input === "q" || (key.escape && !sel)) return "exit"
213
+
214
+ const mode = S.inputMode(sel)
215
+
216
+ // Text mode: handle typing
217
+ if (mode === "text" && sel) {
218
+ if (key.escape) {
219
+ setSel(S.stopEditing(sel))
220
+ return
221
+ }
222
+ if (key.backspace && sel.text) {
223
+ const nodeId = S.cursor(sel)
224
+ const offset = sel.text[0].offset
225
+ if (offset > 0) {
226
+ setEditTexts((prev) => ({
227
+ ...prev,
228
+ [nodeId]: prev[nodeId]!.slice(0, offset - 1) + prev[nodeId]!.slice(offset),
229
+ }))
230
+ setSel(S.edit(sel, offset - 1))
231
+ }
232
+ return
233
+ }
234
+ if (key.leftArrow && sel.text) {
235
+ setSel(S.edit(sel, Math.max(0, sel.text[0].offset - 1)))
236
+ return
237
+ }
238
+ if (key.rightArrow && sel.text) {
239
+ const maxLen = editTexts[S.cursor(sel)]?.length ?? 0
240
+ setSel(S.edit(sel, Math.min(maxLen, sel.text[0].offset + 1)))
241
+ return
242
+ }
243
+ // Printable character
244
+ if (input && !key.ctrl && !key.meta && sel.text) {
245
+ const nodeId = S.cursor(sel)
246
+ const offset = sel.text[0].offset
247
+ setEditTexts((prev) => ({
248
+ ...prev,
249
+ [nodeId]: prev[nodeId]!.slice(0, offset) + input + prev[nodeId]!.slice(offset),
250
+ }))
251
+ setSel(S.edit(sel, offset + input.length))
252
+ return
253
+ }
254
+ return
255
+ }
256
+
257
+ // Node mode
258
+ if (mode === "node" && sel) {
259
+ if (key.escape) {
260
+ // Mode ladder: multi → single → board
261
+ if (sel.nodes.length > 1) {
262
+ setSel(S.collapseToCursor(sel))
263
+ } else {
264
+ setSel(S.clear())
265
+ }
266
+ return
267
+ }
268
+ if (key.return) {
269
+ const text = editTexts[S.cursor(sel)] ?? ""
270
+ setSel(S.edit(sel, text.length))
271
+ return
272
+ }
273
+ if (input === "j" || key.downArrow) {
274
+ const next = Math.min(ALL_IDS.length - 1, cursorIndex + 1)
275
+ if (key.shift) {
276
+ setSel(S.extend(sel, ALL_IDS[next]!, ALL_IDS))
277
+ } else {
278
+ setSel(S.select(ALL_IDS[next]!))
279
+ }
280
+ return
281
+ }
282
+ if (input === "k" || key.upArrow) {
283
+ const next = Math.max(0, cursorIndex - 1)
284
+ if (key.shift) {
285
+ setSel(S.extend(sel, ALL_IDS[next]!, ALL_IDS))
286
+ } else {
287
+ setSel(S.select(ALL_IDS[next]!))
288
+ }
289
+ return
290
+ }
291
+ return
292
+ }
293
+
294
+ // Board mode — any nav enters node mode
295
+ if (input === "j" || key.downArrow) setSel(S.select(ALL_IDS[0]!))
296
+ if (input === "k" || key.upArrow) setSel(S.select(ALL_IDS.at(-1)!))
297
+ })
298
+
299
+ // Sync edit texts back to items for display
300
+ const displayItems = ITEMS.map((item) => ({ ...item, label: editTexts[item.id] ?? item.label }))
301
+
302
+ return (
303
+ <Box flexDirection="column" padding={1} height="100%">
304
+ <Box marginBottom={1}>
305
+ <Text bold color="$primary">
306
+ Selection Model Demo
307
+ </Text>
308
+ <Text color="$muted"> — silvery reactive selection</Text>
309
+ </Box>
310
+
311
+ <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="$border" overflow="hidden">
312
+ {displayItems.map((item) => (
313
+ <ItemRow key={item.id} item={item} sel={sel} onSelect={handleSelect} />
314
+ ))}
315
+ </Box>
316
+
317
+ <StatusBar sel={sel} />
318
+
319
+ <Box marginTop={1} flexDirection="column">
320
+ <Text color="$muted" dim>
321
+ nodes=[{sel?.nodes.join(", ") ?? ""}]
322
+ </Text>
323
+ </Box>
324
+ </Box>
325
+ )
326
+ }
327
+
328
+ // ============================================================================
329
+ // Main
330
+ // ============================================================================
331
+
332
+ export const meta = {
333
+ name: "Selection",
334
+ description: "Reactive selection model — node/text modes, multi-select, mode ladder",
335
+ demo: true,
336
+ features: ["Selection model", "mode ladder", "multi-select", "text editing"],
337
+ }
338
+
339
+ if (import.meta.main) {
340
+ using handle = await run(<SelectionDemo />, { mode: "fullscreen" })
341
+ await handle.waitUntilExit()
342
+ }