herm-tui 1.0.0-dev.1 → 1.0.0-dev.3
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/db.worker.js +81 -0
- package/highlights-eq9cgrbb.scm +604 -0
- package/highlights-ghv9g403.scm +205 -0
- package/highlights-hk7bwhj4.scm +284 -0
- package/highlights-r812a2qc.scm +150 -0
- package/highlights-x6tmsnaa.scm +115 -0
- package/index.js +10374 -0
- package/injections-73j83es3.scm +27 -0
- package/package.json +14 -64
- package/parser.worker.js +8 -0
- package/tree-sitter-3jzf13jk.wasm +0 -0
- package/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/scripts/postinstall.ts +0 -29
- package/src/app/gateway.tsx +0 -83
- package/src/app/gatewayEvents.ts +0 -203
- package/src/app/launch.ts +0 -41
- package/src/app/skin.tsx +0 -31
- package/src/app/spawnHistory.ts +0 -75
- package/src/app/tabs.ts +0 -23
- package/src/app/turnReducer.ts +0 -390
- package/src/app/useAppKeys.ts +0 -268
- package/src/app/useAtRefPopover.ts +0 -99
- package/src/app/useInputHistory.ts +0 -66
- package/src/app/useSession.ts +0 -102
- package/src/app/useSlashCommands.ts +0 -70
- package/src/app/useSlashPopover.ts +0 -48
- package/src/app.tsx +0 -917
- package/src/commands/slash.ts +0 -151
- package/src/components/avatar/AnimatedAvatar.tsx +0 -66
- package/src/components/avatar/eikon.ts +0 -144
- package/src/components/avatar/states/error.ts +0 -1155
- package/src/components/avatar/states/idle.ts +0 -1155
- package/src/components/avatar/states/index.ts +0 -30
- package/src/components/avatar/states/listening.ts +0 -1155
- package/src/components/avatar/states/speaking.ts +0 -1155
- package/src/components/avatar/states/thinking.ts +0 -1155
- package/src/components/avatar/states/working.ts +0 -1155
- package/src/components/chat/AtRefPopover.tsx +0 -54
- package/src/components/chat/CodeBlock.tsx +0 -67
- package/src/components/chat/Composer.tsx +0 -347
- package/src/components/chat/DiffBlock.tsx +0 -116
- package/src/components/chat/ErrorBlock.tsx +0 -70
- package/src/components/chat/MediaChip.tsx +0 -114
- package/src/components/chat/MessageItem.tsx +0 -282
- package/src/components/chat/MessageList.tsx +0 -114
- package/src/components/chat/PromptCard.tsx +0 -359
- package/src/components/chat/SlashPopover.tsx +0 -158
- package/src/components/chat/ThoughtCloud.tsx +0 -185
- package/src/components/chat/TypingIndicator.tsx +0 -25
- package/src/components/chat/tool/Subagent.tsx +0 -75
- package/src/components/chat/tool/frame.tsx +0 -69
- package/src/components/chat/tool/index.tsx +0 -65
- package/src/components/chat/tool/preview.ts +0 -57
- package/src/components/sidebar/ContextGauge.tsx +0 -102
- package/src/components/sidebar/Sidebar.tsx +0 -143
- package/src/components/tabs/TabBar.tsx +0 -50
- package/src/components/ui/FileLink.tsx +0 -52
- package/src/config/index.ts +0 -156
- package/src/config/lane.ts +0 -161
- package/src/config/models.ts +0 -95
- package/src/config/rules.ts +0 -80
- package/src/config/schema.ts +0 -308
- package/src/dialogs/alert.tsx +0 -52
- package/src/dialogs/chafa.tsx +0 -72
- package/src/dialogs/confirm.tsx +0 -58
- package/src/dialogs/curator.tsx +0 -153
- package/src/dialogs/eikon-picker.tsx +0 -95
- package/src/dialogs/help.tsx +0 -80
- package/src/dialogs/history.tsx +0 -92
- package/src/dialogs/info.tsx +0 -115
- package/src/dialogs/keys.tsx +0 -170
- package/src/dialogs/logs.tsx +0 -42
- package/src/dialogs/message.tsx +0 -38
- package/src/dialogs/model-picker.tsx +0 -123
- package/src/dialogs/new-profile.tsx +0 -69
- package/src/dialogs/new-task.tsx +0 -103
- package/src/dialogs/profile.tsx +0 -55
- package/src/dialogs/rollback.tsx +0 -190
- package/src/dialogs/spawn-history.tsx +0 -80
- package/src/dialogs/text-prompt.tsx +0 -68
- package/src/dialogs/theme-picker.tsx +0 -50
- package/src/home/index.ts +0 -23
- package/src/home/store.ts +0 -267
- package/src/index.tsx +0 -113
- package/src/keys/catalog.ts +0 -115
- package/src/keys/chord.ts +0 -125
- package/src/keys/conflicts.ts +0 -48
- package/src/keys/context.tsx +0 -112
- package/src/keys/index.ts +0 -5
- package/src/keys/list.ts +0 -94
- package/src/keys/oc-compat.ts +0 -87
- package/src/tabs/Agents.tsx +0 -607
- package/src/tabs/Analytics.tsx +0 -154
- package/src/tabs/Chat.tsx +0 -50
- package/src/tabs/Config.tsx +0 -605
- package/src/tabs/Context.tsx +0 -599
- package/src/tabs/Cron.tsx +0 -294
- package/src/tabs/Env.tsx +0 -227
- package/src/tabs/Kanban.tsx +0 -367
- package/src/tabs/Memory.tsx +0 -294
- package/src/tabs/Sessions.tsx +0 -786
- package/src/tabs/Skills.tsx +0 -507
- package/src/tabs/Toolsets.tsx +0 -266
- package/src/theme/builtin.ts +0 -78
- package/src/theme/context.tsx +0 -106
- package/src/theme/index.ts +0 -4
- package/src/theme/resolve.ts +0 -134
- package/src/theme/syntax.ts +0 -31
- package/src/theme/themes/aura.json +0 -69
- package/src/theme/themes/ayu.json +0 -80
- package/src/theme/themes/carbonfox.json +0 -248
- package/src/theme/themes/catppuccin-frappe.json +0 -233
- package/src/theme/themes/catppuccin-macchiato.json +0 -233
- package/src/theme/themes/catppuccin.json +0 -112
- package/src/theme/themes/cobalt2.json +0 -228
- package/src/theme/themes/cursor.json +0 -249
- package/src/theme/themes/dracula.json +0 -219
- package/src/theme/themes/everforest.json +0 -241
- package/src/theme/themes/flexoki.json +0 -237
- package/src/theme/themes/github.json +0 -233
- package/src/theme/themes/gruvbox.json +0 -242
- package/src/theme/themes/kanagawa.json +0 -77
- package/src/theme/themes/lucent-orng.json +0 -237
- package/src/theme/themes/material.json +0 -235
- package/src/theme/themes/matrix.json +0 -77
- package/src/theme/themes/mercury.json +0 -252
- package/src/theme/themes/monokai.json +0 -221
- package/src/theme/themes/nightowl.json +0 -221
- package/src/theme/themes/nord.json +0 -223
- package/src/theme/themes/one-dark.json +0 -84
- package/src/theme/themes/opencode.json +0 -245
- package/src/theme/themes/orng.json +0 -249
- package/src/theme/themes/osaka-jade.json +0 -93
- package/src/theme/themes/palenight.json +0 -222
- package/src/theme/themes/rosepine.json +0 -234
- package/src/theme/themes/solarized.json +0 -223
- package/src/theme/themes/synthwave84.json +0 -226
- package/src/theme/themes/tokyonight.json +0 -243
- package/src/theme/themes/vercel.json +0 -245
- package/src/theme/themes/vesper.json +0 -218
- package/src/theme/themes/zenburn.json +0 -223
- package/src/theme/types.ts +0 -119
- package/src/types/message.ts +0 -97
- package/src/ui/ChafaImage.tsx +0 -64
- package/src/ui/Splash.tsx +0 -118
- package/src/ui/borders.ts +0 -28
- package/src/ui/command.tsx +0 -104
- package/src/ui/dialog-select.tsx +0 -164
- package/src/ui/dialog.tsx +0 -102
- package/src/ui/fmt.ts +0 -82
- package/src/ui/kv.tsx +0 -28
- package/src/ui/shell.tsx +0 -45
- package/src/ui/spinner.tsx +0 -59
- package/src/ui/splash-art.ts +0 -123
- package/src/ui/table.tsx +0 -117
- package/src/ui/ticker.tsx +0 -90
- package/src/ui/toast.tsx +0 -130
- package/src/utils/categorical.ts +0 -77
- package/src/utils/chafa.ts +0 -173
- package/src/utils/clipboard.ts +0 -67
- package/src/utils/context-segments.ts +0 -317
- package/src/utils/control.ts +0 -495
- package/src/utils/drop.ts +0 -25
- package/src/utils/editor.ts +0 -33
- package/src/utils/fuzzy.ts +0 -45
- package/src/utils/gateway-client.ts +0 -253
- package/src/utils/gateway-types.ts +0 -282
- package/src/utils/git.ts +0 -57
- package/src/utils/hermes-analytics.ts +0 -134
- package/src/utils/hermes-home.ts +0 -821
- package/src/utils/hermes-kanban.ts +0 -154
- package/src/utils/hermes-profiles.ts +0 -217
- package/src/utils/interpolate.ts +0 -31
- package/src/utils/math-unicode.ts +0 -818
- package/src/utils/memory-activity.ts +0 -140
- package/src/utils/open-file.ts +0 -13
- package/src/utils/paths.ts +0 -52
- package/src/utils/perf.ts +0 -235
- package/src/utils/preferences.ts +0 -150
- package/src/utils/sessions-db.ts +0 -396
- package/src/utils/subagent-tree.ts +0 -146
- package/src/utils/terminal-reset.ts +0 -129
- package/src/utils/tips.ts +0 -67
- package/src/utils/tokens.ts +0 -87
package/src/index.tsx
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
// NOTE: CPU usage at idle (~40-80% of one core) comes from OpenTUI's render loop,
|
|
3
|
-
// not React's scheduler. The TUI framework renders frames at targetFps (30) to the
|
|
4
|
-
// terminal, which is an inherent cost of continuous TUI rendering. The scheduler
|
|
5
|
-
// override (MessageChannel/setImmediate = undefined) was a red herring — verified
|
|
6
|
-
// via per-thread profiling that the main JS thread drives the render loop.
|
|
7
|
-
|
|
8
|
-
// OpenTUI's tree-sitter worker opens its wasm at a relative path that
|
|
9
|
-
// emscripten resolves against the worker's process.cwd(). In dev Bun's
|
|
10
|
-
// asset loader handles that; in the bundle we redirect to a shim sibling
|
|
11
|
-
// that chdirs into dist/ before loading the real worker. The shim is
|
|
12
|
-
// emitted alongside index.js at build time, so import.meta.dirname of
|
|
13
|
-
// THIS file is its directory.
|
|
14
|
-
import { dirname } from "path"
|
|
15
|
-
import { fileURLToPath } from "url"
|
|
16
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
17
|
-
// Only override when the shim actually exists next to the bundle. In
|
|
18
|
-
// dev runs the shim isn't emitted and we fall through to OpenTUI's
|
|
19
|
-
// default node_modules-relative resolution.
|
|
20
|
-
import { existsSync } from "fs"
|
|
21
|
-
import { join } from "path"
|
|
22
|
-
const shim = join(here, "parser.worker.shim.js")
|
|
23
|
-
if (existsSync(shim)) process.env.OTUI_TREE_SITTER_WORKER_PATH = shim
|
|
24
|
-
|
|
25
|
-
import { createCliRenderer } from "@opentui/core";
|
|
26
|
-
import { createRoot } from "@opentui/react";
|
|
27
|
-
import { App } from "./app";
|
|
28
|
-
import { parseLaunch, HELP, VERSION } from "./app/launch";
|
|
29
|
-
import * as perf from "./utils/perf";
|
|
30
|
-
import * as control from "./utils/control";
|
|
31
|
-
import * as preferences from "./utils/preferences";
|
|
32
|
-
import { resetTerminalModes, installExitResetHooks } from "./utils/terminal-reset";
|
|
33
|
-
import { warmup as warmTokens } from "./utils/tokens";
|
|
34
|
-
|
|
35
|
-
// Static ESM imports hoist above module-level code, so the only
|
|
36
|
-
// honest import-graph measurement is process-uptime at the point
|
|
37
|
-
// this line actually runs. Captured once; reported by perf.boot().
|
|
38
|
-
perf.boot("import-graph", Bun.nanoseconds() / 1e6)
|
|
39
|
-
|
|
40
|
-
const argv = Bun.argv.slice(2)
|
|
41
|
-
if (argv.includes("--help") || argv.includes("-h")) {
|
|
42
|
-
process.stdout.write(HELP)
|
|
43
|
-
process.exit(0)
|
|
44
|
-
}
|
|
45
|
-
if (argv.includes("--version") || argv.includes("-v")) {
|
|
46
|
-
process.stdout.write(VERSION + "\n")
|
|
47
|
-
process.exit(0)
|
|
48
|
-
}
|
|
49
|
-
const launch = parseLaunch(argv)
|
|
50
|
-
|
|
51
|
-
// Initialize and render
|
|
52
|
-
const main = async () => {
|
|
53
|
-
// Self-heal a tab that a prior crashed TUI left with mouse / focus
|
|
54
|
-
// / bracketed-paste / kitty-keyboard modes stuck on. @opentui/core
|
|
55
|
-
// only resets ?1049 (alt-screen), so without this the composer
|
|
56
|
-
// gets poisoned by raw escape sequences on startup.
|
|
57
|
-
resetTerminalModes()
|
|
58
|
-
// And on our own exit paths, so we don't poison the next process.
|
|
59
|
-
installExitResetHooks()
|
|
60
|
-
|
|
61
|
-
perf.mem("pre-renderer")
|
|
62
|
-
|
|
63
|
-
const prefs = preferences.load()
|
|
64
|
-
|
|
65
|
-
const end = perf.mark("renderer-init")
|
|
66
|
-
const renderer = await createCliRenderer({
|
|
67
|
-
exitOnCtrlC: false, // We handle Ctrl+C ourselves
|
|
68
|
-
useMouse: prefs.mouse ?? true,
|
|
69
|
-
targetFps: prefs.targetFps ?? 30,
|
|
70
|
-
gatherStats: false,
|
|
71
|
-
});
|
|
72
|
-
end()
|
|
73
|
-
|
|
74
|
-
// OpenTUI's setupTerminal emits CSI >4;1m (modifyOtherKeys=1), then
|
|
75
|
-
// upgrades to kitty (CSI >4;0m + CSI >{flags}u) only if the async
|
|
76
|
-
// CSI ?u probe gets a reply. Level 1 does NOT disambiguate Ctrl+digit
|
|
77
|
-
// — they arrive as legacy control bytes (Ctrl+2=NUL, Ctrl+3=ESC,
|
|
78
|
-
// Ctrl+4=FS…) so `key.ctrl` is never true and tab-jump is dead on
|
|
79
|
-
// terminals without kitty support. Level 2 encodes every modified key
|
|
80
|
-
// as CSI 27;m;c~ which parseKeypress handles. setupTerminal is awaited
|
|
81
|
-
// inside createCliRenderer, so >4;1m is already out; this lands after.
|
|
82
|
-
// If kitty detection later fires, its >4;0m overrides this — harmless.
|
|
83
|
-
// Re-asserted on focus since a suspended child may have reset modes.
|
|
84
|
-
const bump = () => renderer.capabilities?.kitty_keyboard
|
|
85
|
-
|| (process.stdout.isTTY && process.stdout.write("\x1b[>4;2m"))
|
|
86
|
-
bump()
|
|
87
|
-
renderer.on("focus", bump)
|
|
88
|
-
|
|
89
|
-
perf.mem("post-renderer")
|
|
90
|
-
|
|
91
|
-
const root = createRoot(renderer);
|
|
92
|
-
|
|
93
|
-
const endRender = perf.mark("first-render")
|
|
94
|
-
root.render(<App initialTheme={prefs.theme} launch={launch} />);
|
|
95
|
-
endRender()
|
|
96
|
-
perf.boot("first-render", Bun.nanoseconds() / 1e6)
|
|
97
|
-
|
|
98
|
-
// gpt-tokenizer is ~170ms to import and not needed for first frame;
|
|
99
|
-
// kick it off the hot path so the first count() call doesn't stall.
|
|
100
|
-
warmTokens()
|
|
101
|
-
|
|
102
|
-
perf.mem("post-first-render")
|
|
103
|
-
|
|
104
|
-
// Periodic memory monitor (every 15s when PERF=1)
|
|
105
|
-
perf.monitor(15_000)
|
|
106
|
-
|
|
107
|
-
// Control server for headless interaction (CONTROL=1)
|
|
108
|
-
control.start()
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
main().catch(console.error);
|
|
112
|
-
|
|
113
|
-
export {};
|
package/src/keys/catalog.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// Action catalog — the curated set of named, rebindable key actions.
|
|
2
|
-
//
|
|
3
|
-
// Each ActionId maps to a default chord string (see chord.ts for grammar),
|
|
4
|
-
// a human description, and a scope. Scope drives Help grouping and tells
|
|
5
|
-
// the migration which handler owns the match:
|
|
6
|
-
//
|
|
7
|
-
// global shell-level (useAppKeys) — fires regardless of focused tab
|
|
8
|
-
// list shared nav vocabulary consumed by useListKeys across tabs/dialogs
|
|
9
|
-
// dialog modal overlays
|
|
10
|
-
// composer textarea keyBindings (fed via toBindings)
|
|
11
|
-
// <tab> tab-local, matched only when that tab is focused
|
|
12
|
-
//
|
|
13
|
-
// `<leader>` is a two-stroke prefix (default Ctrl+X, rebindable via the
|
|
14
|
-
// `leader` entry). Existing Ctrl-chords are kept as secondary alternates
|
|
15
|
-
// so nothing breaks while the leader pattern settles; print() shows the
|
|
16
|
-
// first alternate, so Help advertises the leader form.
|
|
17
|
-
//
|
|
18
|
-
// Trailing markers cross-reference opencode's config/keybinds.ts:
|
|
19
|
-
// (blank) oc has the same action on substantively the same chord
|
|
20
|
-
// ø oc has an analogue but herm binds it differently
|
|
21
|
-
// ☨ no oc equivalent (herm-specific surface or concept)
|
|
22
|
-
|
|
23
|
-
export type Scope =
|
|
24
|
-
| "global" | "list" | "dialog" | "composer"
|
|
25
|
-
| "sessions" | "cron" | "env" | "agents" | "skills" | "config"
|
|
26
|
-
|
|
27
|
-
export type Def = { chord: string; desc: string; scope: Scope }
|
|
28
|
-
|
|
29
|
-
const def = (chord: string, desc: string, scope: Scope): Def => ({ chord, desc, scope })
|
|
30
|
-
|
|
31
|
-
export const DEFAULTS = {
|
|
32
|
-
// ── global ──────────────────────────────────────────────────────
|
|
33
|
-
"leader": def("ctrl+x", "Leader prefix", "global"),
|
|
34
|
-
"app.exit": def("ctrl+c", "Quit (or copy selection)", "global"),
|
|
35
|
-
"app.suspend": def("ctrl+z", "Suspend to shell", "global"),
|
|
36
|
-
"app.redraw": def("ctrl+l", "Clear & force-repaint terminal", "global"), // ☨
|
|
37
|
-
"app.sidebar": def("<leader>b", "Toggle sidebar", "global"),
|
|
38
|
-
"palette.open": def("ctrl+k", "Command palette", "global"), // ø command_list=ctrl+p
|
|
39
|
-
"help.open": def("f1", "Keyboard shortcuts", "global"), // ☨
|
|
40
|
-
"tab.next": def("ctrl+right", "Next tab", "global"), // ☨
|
|
41
|
-
"tab.prev": def("ctrl+left", "Previous tab", "global"), // ☨
|
|
42
|
-
"focus.cycle": def("tab", "Cycle focus (double-tap → composer)","global"), // ☨
|
|
43
|
-
"editor.open": def("<leader>e,ctrl+g", "Open $EDITOR on prompt", "global"),
|
|
44
|
-
"reply.copy": def("<leader>y,ctrl+y", "Copy last assistant reply", "global"),
|
|
45
|
-
"clipboard.attach": def("alt+v", "Attach clipboard image", "global"), // ø input_paste=ctrl+v
|
|
46
|
-
// "queue.pop": def("ctrl+u", "Pop last queued prompt", "global"), // ☨ k: need to think about this more. defer
|
|
47
|
-
"session.interrupt": def("escape", "Interrupt (double-tap while streaming)", "global"),
|
|
48
|
-
"session.new": def("<leader>n", "New session", "global"),
|
|
49
|
-
"session.undo": def("<leader>u", "Undo last turn", "global"),
|
|
50
|
-
"session.compress": def("<leader>c", "Compress context", "global"),
|
|
51
|
-
"session.timeline": def("<leader>g", "Session timeline", "global"),
|
|
52
|
-
"theme.pick": def("<leader>t", "Switch theme", "global"),
|
|
53
|
-
"model.pick": def("<leader>m", "Switch model", "global"),
|
|
54
|
-
// "tool.details": def("<leader>d", "Cycle tool-trail detail", "global"), // ø tool_details=none k: I need to see if it warrants a shortcut. defer
|
|
55
|
-
"status.open": def("<leader>s", "Show status", "global"),
|
|
56
|
-
|
|
57
|
-
// ── list (shared across tabs + list-shaped dialogs) ─────────────
|
|
58
|
-
// ☨ — oc has no generic list surface; nearest are per-dialog
|
|
59
|
-
// session_*/stash_* bindings and messages_* scroll.
|
|
60
|
-
"list.up": def("up", "Move selection up", "list"),
|
|
61
|
-
"list.down": def("down", "Move selection down", "list"),
|
|
62
|
-
"list.pageUp": def("pageup", "Page up", "list"),
|
|
63
|
-
"list.pageDown": def("pagedown", "Page down", "list"),
|
|
64
|
-
"list.home": def("home", "First item", "list"),
|
|
65
|
-
"list.end": def("end", "Last item", "list"),
|
|
66
|
-
"list.activate": def("return", "Activate / open", "list"),
|
|
67
|
-
"list.delete": def("d,delete", "Delete item", "list"),
|
|
68
|
-
"list.refresh": def("r", "Reload", "list"), // k: where is this used? → 7 tabs; removal tracked in herm-0pg.15 (gated on bqo)
|
|
69
|
-
"list.new": def("n", "Create", "list"), // k: keep
|
|
70
|
-
"list.search": def("/", "Filter", "list"),
|
|
71
|
-
"list.toggle": def("space", "Toggle item", "list"),
|
|
72
|
-
|
|
73
|
-
// ── dialog ──────────────────────────────────────────────────────
|
|
74
|
-
// ☨ — oc dialogs hardcode return/escape/y/n per-component.
|
|
75
|
-
"dialog.accept": def("return", "Accept", "dialog"),
|
|
76
|
-
"dialog.cancel": def("escape", "Cancel / close", "dialog"),
|
|
77
|
-
"dialog.confirm": def("y", "Yes", "dialog"),
|
|
78
|
-
"dialog.deny": def("n", "No", "dialog"),
|
|
79
|
-
"dialog.copy": def("c", "Copy body", "dialog"),
|
|
80
|
-
|
|
81
|
-
// ── composer (fed to <textarea keyBindings> via toBindings) ───── // k: I think you need to ELI5 what you mean here
|
|
82
|
-
"input.submit": def("return", "Send", "composer"),
|
|
83
|
-
"input.newline": def("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline", "composer"),
|
|
84
|
-
|
|
85
|
-
// ── tab-specific ────────────────────────────────────────────────
|
|
86
|
-
// ☨ — herm admin tabs (Cron/Env/Skills/Agents/Config) have no oc
|
|
87
|
-
// counterpart; sessions.rename diverges from oc's session-
|
|
88
|
-
// dialog ctrl+r.
|
|
89
|
-
"sessions.rename": def("ctrl+r", "Retitle session", "sessions"), // match oc session_rename
|
|
90
|
-
"sessions.prev": def("left", "Walk lineage back (continues from)", "sessions"),
|
|
91
|
-
"sessions.next": def("right", "Walk lineage forward (compressed to)", "sessions"),
|
|
92
|
-
"agents.kill": def("k", "Kill subagent", "agents"), // k: I like this
|
|
93
|
-
"agents.history": def("h", "Spawn history", "agents"), // k: keep
|
|
94
|
-
"config.save": def("ctrl+s", "Write config", "config"),
|
|
95
|
-
} satisfies Record<string, Def>
|
|
96
|
-
|
|
97
|
-
export type ActionId = keyof typeof DEFAULTS
|
|
98
|
-
|
|
99
|
-
/** Actions in a given scope, catalog order. */
|
|
100
|
-
export function inScope(s: Scope): ActionId[] {
|
|
101
|
-
return (Object.keys(DEFAULTS) as ActionId[]).filter(id => DEFAULTS[id].scope === s)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Two scopes overlap if both handlers can be live for the same keypress.
|
|
105
|
-
// global fires everywhere; list is active on every admin tab alongside that
|
|
106
|
-
// tab's own scope; dialog and composer are modal/focused surfaces that
|
|
107
|
-
// displace the rest; distinct tab scopes are mutually exclusive.
|
|
108
|
-
const TAB_SCOPES = new Set<Scope>(["sessions", "cron", "env", "agents", "skills", "config"])
|
|
109
|
-
export function scopesOverlap(a: Scope, b: Scope): boolean {
|
|
110
|
-
if (a === b) return true
|
|
111
|
-
if (a === "global" || b === "global") return true
|
|
112
|
-
if (a === "list") return TAB_SCOPES.has(b)
|
|
113
|
-
if (b === "list") return TAB_SCOPES.has(a)
|
|
114
|
-
return false
|
|
115
|
-
}
|
package/src/keys/chord.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
// Chord primitives — parse/match/print for keybinding strings.
|
|
2
|
-
//
|
|
3
|
-
// A chord string is comma-separated alternates, each alternate is
|
|
4
|
-
// `+`-separated modifiers followed by a key name:
|
|
5
|
-
// "ctrl+shift+k" "shift+return,ctrl+j" "<leader>e" "none"
|
|
6
|
-
//
|
|
7
|
-
// `<leader>` is a synthetic modifier; whether the leader is currently armed
|
|
8
|
-
// is provided by the caller at match time (the provider owns that state).
|
|
9
|
-
|
|
10
|
-
import type { ParsedKey } from "@opentui/core"
|
|
11
|
-
|
|
12
|
-
export type Chord = {
|
|
13
|
-
readonly name: string
|
|
14
|
-
readonly ctrl: boolean
|
|
15
|
-
readonly meta: boolean
|
|
16
|
-
readonly shift: boolean
|
|
17
|
-
readonly super: boolean
|
|
18
|
-
readonly leader: boolean
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const ALIAS: Record<string, string> = {
|
|
22
|
-
esc: "escape",
|
|
23
|
-
enter: "return",
|
|
24
|
-
del: "delete",
|
|
25
|
-
ins: "insert",
|
|
26
|
-
space: "space",
|
|
27
|
-
" ": "space",
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Parse a chord string into its alternate Chord list. "none" / "" → []. */
|
|
31
|
-
export function parse(spec: string): Chord[] {
|
|
32
|
-
if (!spec || spec === "none") return []
|
|
33
|
-
return spec.split(",").map(one)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function one(combo: string): Chord {
|
|
37
|
-
const c = { name: "", ctrl: false, meta: false, shift: false, super: false, leader: false }
|
|
38
|
-
for (const raw of combo.replace(/<leader>/g, "leader+").toLowerCase().split("+")) {
|
|
39
|
-
const p = raw.trim()
|
|
40
|
-
if (!p) continue
|
|
41
|
-
if (p === "ctrl") c.ctrl = true
|
|
42
|
-
else if (p === "alt" || p === "meta" || p === "option") c.meta = true
|
|
43
|
-
else if (p === "shift") c.shift = true
|
|
44
|
-
else if (p === "super" || p === "cmd") c.super = true
|
|
45
|
-
else if (p === "leader") c.leader = true
|
|
46
|
-
else c.name = ALIAS[p] ?? p
|
|
47
|
-
}
|
|
48
|
-
return c
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Normalize an OpenTUI ParsedKey to a Chord. */
|
|
52
|
-
export function from(key: ParsedKey, leader = false): Chord {
|
|
53
|
-
// kitty protocol emits name=" " for space; legacy emits "space".
|
|
54
|
-
const name = key.name === " " ? "space" : key.name
|
|
55
|
-
return {
|
|
56
|
-
name,
|
|
57
|
-
ctrl: key.ctrl,
|
|
58
|
-
meta: key.meta,
|
|
59
|
-
shift: key.shift,
|
|
60
|
-
super: key.super ?? false,
|
|
61
|
-
leader,
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function eq(a: Chord, b: Chord): boolean {
|
|
66
|
-
return a.name === b.name
|
|
67
|
-
&& a.ctrl === b.ctrl && a.meta === b.meta
|
|
68
|
-
&& a.shift === b.shift && a.super === b.super
|
|
69
|
-
&& a.leader === b.leader
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** True if the event matches any alternate in the chord list. */
|
|
73
|
-
export function match(list: ReadonlyArray<Chord>, key: ParsedKey, leader = false): boolean {
|
|
74
|
-
if (list.length === 0) return false
|
|
75
|
-
const k = from(key, leader)
|
|
76
|
-
return list.some(c => eq(c, k))
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Render the first alternate for display. `lead` substitutes `<leader>`. */
|
|
80
|
-
export function print(list: ReadonlyArray<Chord>, lead?: string): string {
|
|
81
|
-
const c = list[0]
|
|
82
|
-
if (!c) return ""
|
|
83
|
-
const mods: string[] = []
|
|
84
|
-
if (c.ctrl) mods.push("Ctrl")
|
|
85
|
-
if (c.meta) mods.push("Alt")
|
|
86
|
-
if (c.super) mods.push("Super")
|
|
87
|
-
if (c.shift) mods.push("Shift")
|
|
88
|
-
const name = LABEL[c.name] ?? cap(c.name)
|
|
89
|
-
const body = [...mods, name].join("+")
|
|
90
|
-
if (!c.leader) return body
|
|
91
|
-
return lead ? `${lead} ${body}` : `<leader> ${body}`
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const LABEL: Record<string, string> = {
|
|
95
|
-
return: "Enter",
|
|
96
|
-
escape: "Esc",
|
|
97
|
-
space: "Space",
|
|
98
|
-
delete: "Del",
|
|
99
|
-
backspace: "⌫",
|
|
100
|
-
up: "↑", down: "↓", left: "←", right: "→",
|
|
101
|
-
pageup: "PgUp", pagedown: "PgDn",
|
|
102
|
-
home: "Home", end: "End",
|
|
103
|
-
tab: "Tab",
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function cap(s: string): string {
|
|
107
|
-
return s.length === 1 ? s.toUpperCase() : s.charAt(0).toUpperCase() + s.slice(1)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Chord[] → KeyBinding[] for the textarea renderable's keyBindings prop. */
|
|
111
|
-
export function toBindings<A extends string>(list: ReadonlyArray<Chord>, action: A) {
|
|
112
|
-
return list.map(c => ({
|
|
113
|
-
name: c.name,
|
|
114
|
-
ctrl: c.ctrl || undefined,
|
|
115
|
-
meta: c.meta || undefined,
|
|
116
|
-
shift: c.shift || undefined,
|
|
117
|
-
super: c.super || undefined,
|
|
118
|
-
action,
|
|
119
|
-
}))
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Canonical string key for a Chord — for Map-bucketing by chord equality. */
|
|
123
|
-
export function key(c: Chord): string {
|
|
124
|
-
return `${c.leader ? "L" : ""}${c.ctrl ? "C" : ""}${c.meta ? "M" : ""}${c.shift ? "S" : ""}${c.super ? "W" : ""}-${c.name}`
|
|
125
|
-
}
|
package/src/keys/conflicts.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { DEFAULTS, scopesOverlap, type ActionId } from "./catalog"
|
|
2
|
-
import { key, type Chord } from "./chord"
|
|
3
|
-
|
|
4
|
-
export type Conflict = { chord: Chord; a: ActionId; b: ActionId }
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Find all action pairs that share a chord in overlapping scopes.
|
|
8
|
-
* O(N) bucket by chord-key, then pairwise scopesOverlap() within each
|
|
9
|
-
* bucket (buckets are tiny in practice).
|
|
10
|
-
*/
|
|
11
|
-
export function conflicts(table: ReadonlyMap<ActionId, ReadonlyArray<Chord>>): Conflict[] {
|
|
12
|
-
const buckets = new Map<string, Array<[ActionId, Chord]>>()
|
|
13
|
-
for (const [id, chords] of table)
|
|
14
|
-
for (const c of chords) {
|
|
15
|
-
const k = key(c)
|
|
16
|
-
const b = buckets.get(k)
|
|
17
|
-
if (b) b.push([id, c])
|
|
18
|
-
else buckets.set(k, [[id, c]])
|
|
19
|
-
}
|
|
20
|
-
const out: Conflict[] = []
|
|
21
|
-
for (const bucket of buckets.values()) {
|
|
22
|
-
if (bucket.length < 2) continue
|
|
23
|
-
for (let i = 0; i < bucket.length; i++)
|
|
24
|
-
for (let j = i + 1; j < bucket.length; j++) {
|
|
25
|
-
const [a, c] = bucket[i], [b] = bucket[j]
|
|
26
|
-
if (scopesOverlap(DEFAULTS[a].scope, DEFAULTS[b].scope))
|
|
27
|
-
out.push({ chord: c, a, b })
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return out
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Actions whose current chord collides with `id`'s in an overlapping scope. */
|
|
34
|
-
export function conflictsWith(
|
|
35
|
-
table: ReadonlyMap<ActionId, ReadonlyArray<Chord>>,
|
|
36
|
-
id: ActionId,
|
|
37
|
-
): ActionId[] {
|
|
38
|
-
const mine = new Set((table.get(id) ?? []).map(key))
|
|
39
|
-
if (mine.size === 0) return []
|
|
40
|
-
const scope = DEFAULTS[id].scope
|
|
41
|
-
const out: ActionId[] = []
|
|
42
|
-
for (const [other, chords] of table) {
|
|
43
|
-
if (other === id) continue
|
|
44
|
-
if (!scopesOverlap(scope, DEFAULTS[other].scope)) continue
|
|
45
|
-
if (chords.some(c => mine.has(key(c)))) out.push(other)
|
|
46
|
-
}
|
|
47
|
-
return out
|
|
48
|
-
}
|
package/src/keys/context.tsx
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
// KeysProvider — resolves the action catalog (DEFAULTS ← user overrides),
|
|
2
|
-
// owns leader-armed state, and exposes match()/print()/chord()/all.
|
|
3
|
-
//
|
|
4
|
-
// Leader flow: the provider's own useKeyboard sees the leader chord first
|
|
5
|
-
// (global listeners fire before renderable handlers). It arms, blurs the
|
|
6
|
-
// focused renderable so the follow-up bare letter isn't eaten by a textarea
|
|
7
|
-
// or tab handler, and starts a 2s window. The next keypress is evaluated
|
|
8
|
-
// with leader=true by callers' match(); on any keypress while armed the
|
|
9
|
-
// provider disarms in a microtask (after other useKeyboard subscribers on
|
|
10
|
-
// the same event have read `leader`) and restores focus.
|
|
11
|
-
|
|
12
|
-
import { createContext, useContext, useMemo, useRef, useCallback, useState, type ReactNode } from "react"
|
|
13
|
-
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
14
|
-
import type { ParsedKey, Renderable } from "@opentui/core"
|
|
15
|
-
import { usePref } from "../utils/preferences"
|
|
16
|
-
import { DEFAULTS, type ActionId, type Scope } from "./catalog"
|
|
17
|
-
import { parse, match as chordMatch, print as chordPrint, type Chord } from "./chord"
|
|
18
|
-
|
|
19
|
-
const LEADER_MS = 2000
|
|
20
|
-
|
|
21
|
-
export type Entry = { id: ActionId; desc: string; scope: Scope; chord: ReadonlyArray<Chord> }
|
|
22
|
-
|
|
23
|
-
export type Keys = {
|
|
24
|
-
/** True while the leader prefix is armed (between Ctrl+X and the next key). */
|
|
25
|
-
readonly leader: boolean
|
|
26
|
-
/** Does `key` match the action's resolved chord? Uses current leader state. */
|
|
27
|
-
match(id: ActionId, key: ParsedKey): boolean
|
|
28
|
-
/** Display string for an action's first chord, with <leader> substituted. */
|
|
29
|
-
print(id: ActionId): string
|
|
30
|
-
/** Resolved Chord[] for an action. */
|
|
31
|
-
chord(id: ActionId): ReadonlyArray<Chord>
|
|
32
|
-
/** All actions in a scope, resolved. */
|
|
33
|
-
all(scope: Scope): ReadonlyArray<Entry>
|
|
34
|
-
/** Full resolved id→Chord[] table (for conflict detection / rebind UI). */
|
|
35
|
-
readonly table: ReadonlyMap<ActionId, ReadonlyArray<Chord>>
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const Ctx = createContext<Keys | null>(null)
|
|
39
|
-
|
|
40
|
-
const NO_OVERRIDES: Readonly<Record<string, string>> = Object.freeze({})
|
|
41
|
-
|
|
42
|
-
export const KeysProvider = ({ children }: { children: ReactNode }) => {
|
|
43
|
-
const renderer = useRenderer()
|
|
44
|
-
const overrides = usePref("keys") ?? NO_OVERRIDES
|
|
45
|
-
|
|
46
|
-
// id → Chord[] computed once per overrides identity. Leader's own chord
|
|
47
|
-
// is looked up from the same table so it's rebindable.
|
|
48
|
-
const table = useMemo(() => {
|
|
49
|
-
const t = new Map<ActionId, Chord[]>()
|
|
50
|
-
for (const id of Object.keys(DEFAULTS) as ActionId[])
|
|
51
|
-
t.set(id, parse(overrides[id] ?? DEFAULTS[id].chord))
|
|
52
|
-
return t
|
|
53
|
-
}, [overrides])
|
|
54
|
-
|
|
55
|
-
const lead = table.get("leader")!
|
|
56
|
-
const leadLabel = chordPrint(lead)
|
|
57
|
-
|
|
58
|
-
// Leader arm state. `armed` is a ref so match() reads the value at
|
|
59
|
-
// key-time without the provider re-rendering every consumer on arm.
|
|
60
|
-
const armed = useRef(false)
|
|
61
|
-
const stolen = useRef<Renderable | null>(null)
|
|
62
|
-
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
63
|
-
const [, bump] = useState(0)
|
|
64
|
-
|
|
65
|
-
const disarm = useCallback(() => {
|
|
66
|
-
if (!armed.current) return
|
|
67
|
-
armed.current = false
|
|
68
|
-
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
|
69
|
-
const f = stolen.current
|
|
70
|
-
stolen.current = null
|
|
71
|
-
if (f && !f.isDestroyed && !renderer.currentFocusedRenderable) f.focus()
|
|
72
|
-
bump(n => n + 1)
|
|
73
|
-
}, [renderer])
|
|
74
|
-
|
|
75
|
-
const arm = useCallback(() => {
|
|
76
|
-
armed.current = true
|
|
77
|
-
stolen.current = renderer.currentFocusedRenderable ?? null
|
|
78
|
-
stolen.current?.blur()
|
|
79
|
-
if (timer.current) clearTimeout(timer.current)
|
|
80
|
-
timer.current = setTimeout(disarm, LEADER_MS)
|
|
81
|
-
bump(n => n + 1)
|
|
82
|
-
}, [renderer, disarm])
|
|
83
|
-
|
|
84
|
-
useKeyboard((key) => {
|
|
85
|
-
if (!armed.current && chordMatch(lead, key)) {
|
|
86
|
-
arm()
|
|
87
|
-
key.stopPropagation()
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
if (armed.current) queueMicrotask(disarm)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
const value = useMemo<Keys>(() => ({
|
|
94
|
-
get leader() { return armed.current },
|
|
95
|
-
match: (id, key) => chordMatch(table.get(id) ?? [], key, armed.current),
|
|
96
|
-
print: (id) => chordPrint(table.get(id) ?? [], leadLabel),
|
|
97
|
-
chord: (id) => table.get(id) ?? [],
|
|
98
|
-
all: (scope) =>
|
|
99
|
-
(Object.keys(DEFAULTS) as ActionId[])
|
|
100
|
-
.filter(id => DEFAULTS[id].scope === scope)
|
|
101
|
-
.map(id => ({ id, desc: DEFAULTS[id].desc, scope, chord: table.get(id) ?? [] })),
|
|
102
|
-
table,
|
|
103
|
-
}), [table, leadLabel])
|
|
104
|
-
|
|
105
|
-
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export const useKeys = (): Keys => {
|
|
109
|
-
const ctx = useContext(Ctx)
|
|
110
|
-
if (!ctx) throw new Error("useKeys() must be inside <KeysProvider>")
|
|
111
|
-
return ctx
|
|
112
|
-
}
|
package/src/keys/index.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export { KeysProvider, useKeys, type Keys } from "./context"
|
|
2
|
-
export { useListKeys, handleListKey, useFollow } from "./list"
|
|
3
|
-
export { DEFAULTS, type ActionId, type Scope } from "./catalog"
|
|
4
|
-
export { parse, toBindings, type Chord } from "./chord"
|
|
5
|
-
export { conflicts, conflictsWith } from "./conflicts"
|
package/src/keys/list.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// Shared list navigation — maps list.* catalog actions onto a selection
|
|
2
|
-
// index + per-tab action callbacks. Two entry points:
|
|
3
|
-
//
|
|
4
|
-
// handleListKey() plain dispatcher, returns true if consumed. Tabs with
|
|
5
|
-
// text-capture sub-modes (search/edit) call this from
|
|
6
|
-
// their own useKeyboard after the sub-mode branch.
|
|
7
|
-
// useListKeys() hook wrapper that owns the useKeyboard subscriber for
|
|
8
|
-
// tabs that don't need a bespoke one.
|
|
9
|
-
//
|
|
10
|
-
// Callers own the `active` guard (focused ∧ no dialog ∧ no sub-mode) so this
|
|
11
|
-
// layer doesn't depend on ui/dialog. Leader-armed correctness falls out of
|
|
12
|
-
// keys.match(): list.* chords have leader=false, so a bare letter while
|
|
13
|
-
// armed never matches here and falls through to useAppKeys.
|
|
14
|
-
|
|
15
|
-
import { useRef, type Dispatch, type SetStateAction } from "react"
|
|
16
|
-
import { useKeyboard } from "@opentui/react"
|
|
17
|
-
import type { ParsedKey, ScrollBoxRenderable } from "@opentui/core"
|
|
18
|
-
import { useKeys, type Keys } from "./context"
|
|
19
|
-
|
|
20
|
-
export type ListOpts = {
|
|
21
|
-
count: number
|
|
22
|
-
setSel: Dispatch<SetStateAction<number>>
|
|
23
|
-
/** PgUp/PgDn stride; typically viewport height − 1. */
|
|
24
|
-
page?: number
|
|
25
|
-
/** Called with the clamped target after every nav move (scroll-into-view). */
|
|
26
|
-
scrollTo?: (i: number) => void
|
|
27
|
-
onActivate?: () => void
|
|
28
|
-
onDelete?: () => void
|
|
29
|
-
onRefresh?: () => void
|
|
30
|
-
onNew?: () => void
|
|
31
|
-
onToggle?: () => void
|
|
32
|
-
onSearch?: () => void
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function handleListKey(keys: Keys, key: ParsedKey, o: ListOpts): boolean {
|
|
36
|
-
const move = (next: (p: number) => number) => {
|
|
37
|
-
o.setSel(p => {
|
|
38
|
-
const n = Math.max(0, Math.min(o.count - 1, next(p)))
|
|
39
|
-
o.scrollTo?.(n)
|
|
40
|
-
return n
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
const pg = o.page ?? 10
|
|
44
|
-
if (keys.match("list.up", key)) { move(p => p - 1); return true }
|
|
45
|
-
if (keys.match("list.down", key)) { move(p => p + 1); return true }
|
|
46
|
-
if (keys.match("list.pageUp", key)) { move(p => p - pg); return true }
|
|
47
|
-
if (keys.match("list.pageDown", key)) { move(p => p + pg); return true }
|
|
48
|
-
if (keys.match("list.home", key)) { move(() => 0); return true }
|
|
49
|
-
if (keys.match("list.end", key)) { move(() => o.count - 1); return true }
|
|
50
|
-
if (o.onActivate && keys.match("list.activate", key)) { o.onActivate(); return true }
|
|
51
|
-
if (o.onDelete && keys.match("list.delete", key)) { o.onDelete(); return true }
|
|
52
|
-
if (o.onRefresh && keys.match("list.refresh", key)) { o.onRefresh(); return true }
|
|
53
|
-
if (o.onNew && keys.match("list.new", key)) { o.onNew(); return true }
|
|
54
|
-
if (o.onToggle && keys.match("list.toggle", key)) { o.onToggle(); return true }
|
|
55
|
-
if (o.onSearch && keys.match("list.search", key)) { o.onSearch(); return true }
|
|
56
|
-
return false
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function useListKeys(o: ListOpts & {
|
|
60
|
-
active: boolean
|
|
61
|
-
/** Tab-scoped actions; runs if no list.* action matched. */
|
|
62
|
-
also?: (key: ParsedKey, keys: Keys) => void
|
|
63
|
-
}): Keys {
|
|
64
|
-
const keys = useKeys()
|
|
65
|
-
useKeyboard(key => {
|
|
66
|
-
if (!o.active) return
|
|
67
|
-
if (handleListKey(keys, key, o)) return
|
|
68
|
-
o.also?.(key, keys)
|
|
69
|
-
})
|
|
70
|
-
return keys
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Scroll-follow for list tabs: returns a scrollbox ref, a row-id
|
|
74
|
-
// generator, and the two ListOpts fields (scrollTo, page) derived from
|
|
75
|
-
// them. `scrollChildIntoView` resolves by element id, so each row's
|
|
76
|
-
// root box must set `id={follow.id(i)}` and the scrollbox
|
|
77
|
-
// `ref={follow.ref}`. Spread the rest into handleListKey/useListKeys:
|
|
78
|
-
//
|
|
79
|
-
// const follow = useFollow("tab")
|
|
80
|
-
// handleListKey(keys, key, { count, setSel, ...follow.opts, ... })
|
|
81
|
-
// <scrollbox ref={follow.ref}>
|
|
82
|
-
// <Row id={follow.id(i)} ... />
|
|
83
|
-
//
|
|
84
|
-
export function useFollow(prefix: string) {
|
|
85
|
-
const ref = useRef<ScrollBoxRenderable | null>(null)
|
|
86
|
-
const id = (i: number) => `${prefix}-row-${i}`
|
|
87
|
-
return {
|
|
88
|
-
ref, id,
|
|
89
|
-
opts: {
|
|
90
|
-
scrollTo: (n: number) => ref.current?.scrollChildIntoView(id(n)),
|
|
91
|
-
get page() { return Math.max(1, (ref.current?.viewport.height ?? 10) - 1) },
|
|
92
|
-
},
|
|
93
|
-
}
|
|
94
|
-
}
|