tmux-ide 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1024 -519
  3. package/bin/cli.ts +63 -6
  4. package/bunfig.toml +4 -0
  5. package/package.json +18 -7
  6. package/packages/contracts/package.json +22 -0
  7. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  8. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  9. package/packages/contracts/src/actions-contract.ts +310 -0
  10. package/packages/contracts/src/actions-errors.ts +41 -0
  11. package/packages/contracts/src/domain.ts +36 -0
  12. package/packages/contracts/src/ide-config.ts +170 -0
  13. package/packages/contracts/src/index.ts +24 -0
  14. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  15. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  16. package/packages/contracts/src/terminals.ts +116 -0
  17. package/packages/contracts/src/tmux.ts +60 -0
  18. package/packages/contracts/src/workspace.ts +67 -0
  19. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  20. package/packages/daemon/dist/agent-explain.js +19 -3
  21. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  22. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  23. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  24. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  25. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  26. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  27. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  28. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  29. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  30. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  31. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  32. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  33. package/packages/daemon/src/agent-explain.ts +298 -0
  34. package/packages/daemon/src/attach.ts +20 -0
  35. package/packages/daemon/src/bin.ts +4 -0
  36. package/packages/daemon/src/canonical.ts +7 -0
  37. package/packages/daemon/src/cli.ts +499 -0
  38. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  39. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  40. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  41. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  42. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  43. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  44. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  45. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  46. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  47. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  48. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  49. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  50. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  51. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  52. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  53. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  54. package/packages/daemon/src/command-center/discovery.ts +96 -0
  55. package/packages/daemon/src/command-center/index.ts +31 -0
  56. package/packages/daemon/src/command-center/schemas.ts +85 -0
  57. package/packages/daemon/src/command-center/server.ts +1260 -0
  58. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  59. package/packages/daemon/src/config.ts +549 -0
  60. package/packages/daemon/src/detect.ts +248 -0
  61. package/packages/daemon/src/doctor.ts +242 -0
  62. package/packages/daemon/src/embed.ts +5 -0
  63. package/packages/daemon/src/index.ts +12 -0
  64. package/packages/daemon/src/init.ts +211 -0
  65. package/packages/daemon/src/inspect.ts +178 -0
  66. package/packages/daemon/src/js-yaml.d.ts +10 -0
  67. package/packages/daemon/src/launch.ts +349 -0
  68. package/packages/daemon/src/lib/active-projects.ts +49 -0
  69. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  70. package/packages/daemon/src/lib/app-config.ts +427 -0
  71. package/packages/daemon/src/lib/app-settings.ts +53 -0
  72. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  73. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  74. package/packages/daemon/src/lib/auth/types.ts +2 -0
  75. package/packages/daemon/src/lib/auth-token.ts +5 -0
  76. package/packages/daemon/src/lib/authorship.ts +280 -0
  77. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  78. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  79. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  80. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  81. package/packages/daemon/src/lib/daemon.ts +79 -0
  82. package/packages/daemon/src/lib/dot-path.ts +17 -0
  83. package/packages/daemon/src/lib/errors.ts +67 -0
  84. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  85. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  86. package/packages/daemon/src/lib/log.ts +134 -0
  87. package/packages/daemon/src/lib/output.ts +76 -0
  88. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  89. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  90. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  91. package/packages/daemon/src/lib/project-probe.ts +92 -0
  92. package/packages/daemon/src/lib/project-registry.ts +296 -0
  93. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  94. package/packages/daemon/src/lib/session-options.ts +100 -0
  95. package/packages/daemon/src/lib/shell.ts +8 -0
  96. package/packages/daemon/src/lib/sizes.ts +36 -0
  97. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  98. package/packages/daemon/src/lib/slugify.ts +10 -0
  99. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  100. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  101. package/packages/daemon/src/lib/update-check.ts +298 -0
  102. package/packages/daemon/src/lib/update.ts +158 -0
  103. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  104. package/packages/daemon/src/lib/worktree.ts +289 -0
  105. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  106. package/packages/daemon/src/ls.ts +40 -0
  107. package/packages/daemon/src/restart.ts +24 -0
  108. package/packages/daemon/src/restore.ts +514 -0
  109. package/packages/daemon/src/schemas/domain.ts +2 -0
  110. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  111. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  112. package/packages/daemon/src/schemas/index.ts +59 -0
  113. package/packages/daemon/src/schemas/inspect.ts +67 -0
  114. package/packages/daemon/src/schemas/registry.ts +55 -0
  115. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  116. package/packages/daemon/src/send.ts +171 -0
  117. package/packages/daemon/src/server/README.md +15 -0
  118. package/packages/daemon/src/server/index.ts +74 -0
  119. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  120. package/packages/daemon/src/server/standalone.ts +18 -0
  121. package/packages/daemon/src/server/ws-route.ts +483 -0
  122. package/packages/daemon/src/status.ts +59 -0
  123. package/packages/daemon/src/stop.ts +28 -0
  124. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  125. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  126. package/packages/daemon/src/terminal/README.md +92 -0
  127. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  128. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  129. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  130. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  131. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  132. package/packages/daemon/src/tui/chrome/notify.ts +382 -0
  133. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  134. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  135. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  136. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  137. package/packages/daemon/src/tui/chrome/updater.ts +510 -0
  138. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  139. package/packages/daemon/src/tui/compiled.ts +121 -0
  140. package/packages/daemon/src/tui/detect/classify.ts +208 -0
  141. package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
  142. package/packages/daemon/src/tui/detect/manifest.ts +199 -0
  143. package/packages/daemon/src/tui/detect/manifests.ts +354 -0
  144. package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
  145. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  146. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  147. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  148. package/packages/daemon/src/tui/main.ts +82 -0
  149. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  150. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  151. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  152. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  153. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  154. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  155. package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
  156. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  157. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  158. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  159. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  160. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  161. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  162. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  163. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  164. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  165. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  166. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  167. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  168. package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
  169. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  170. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  171. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  172. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  173. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  174. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  175. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  176. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  177. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  178. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  179. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  180. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  181. package/packages/daemon/src/tui/team/entry.ts +38 -0
  182. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  183. package/packages/daemon/src/tui/team/home.ts +170 -0
  184. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  185. package/packages/daemon/src/tui/team/input.ts +34 -0
  186. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  187. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  188. package/packages/daemon/src/tui/team/nav.ts +31 -0
  189. package/packages/daemon/src/tui/team/preview.ts +34 -0
  190. package/packages/daemon/src/tui/team/projects.ts +191 -0
  191. package/packages/daemon/src/tui/team/report.ts +83 -0
  192. package/packages/daemon/src/tui/team/sessions.ts +433 -0
  193. package/packages/daemon/src/tui/team/tree.ts +62 -0
  194. package/packages/daemon/src/types.ts +13 -0
  195. package/packages/daemon/src/ui/index.ts +32 -0
  196. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  197. package/packages/daemon/src/ui/types.ts +91 -0
  198. package/packages/daemon/src/ui/web/base.css +80 -0
  199. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  200. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  201. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  202. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  203. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  204. package/packages/daemon/src/ui/web/index.ts +27 -0
  205. package/packages/daemon/src/ui/web/render.ts +77 -0
  206. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  207. package/packages/daemon/src/validate.ts +217 -0
  208. package/packages/daemon/src/widgets/changes/README.md +3 -0
  209. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  210. package/packages/daemon/src/widgets/config/README.md +3 -0
  211. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  212. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  213. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  214. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  215. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  216. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  217. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  218. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  219. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  220. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  221. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  222. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  223. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  224. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  225. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  226. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  227. package/packages/daemon/src/widgets/preview/README.md +3 -0
  228. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  229. package/packages/daemon/src/widgets/resolve.ts +121 -0
  230. package/packages/daemon/src/widgets/setup/README.md +3 -0
  231. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  232. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  233. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  234. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  235. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  236. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  237. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  238. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  239. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  240. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  241. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  242. package/packages/tmux-bridge/package.json +22 -0
  243. package/packages/tmux-bridge/src/errors.ts +28 -0
  244. package/packages/tmux-bridge/src/index.ts +31 -0
  245. package/packages/tmux-bridge/src/monitor.ts +77 -0
  246. package/packages/tmux-bridge/src/panes.ts +136 -0
  247. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  248. package/packages/tmux-bridge/src/runner.ts +91 -0
  249. package/packages/tmux-bridge/src/sessions.ts +126 -0
  250. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  251. package/packages/tmux-bridge/src/targeting.ts +90 -0
  252. package/scripts/build-tui.mjs +11 -4
  253. package/scripts/perf-mirror.mjs +313 -0
  254. package/scripts/postinstall.js +26 -2
  255. package/skill/SKILL.md +22 -0
  256. package/templates/AGENTS.md +14 -7
  257. package/templates/agent-team-monorepo.yml +8 -0
  258. package/templates/agent-team-nextjs.yml +8 -0
  259. package/templates/agent-team.yml +10 -0
  260. package/templates/convex.yml +2 -0
  261. package/templates/default.yml +11 -5
  262. package/templates/go.yml +4 -0
  263. package/templates/missions.yml +6 -0
  264. package/templates/nextjs.yml +4 -0
  265. package/templates/python.yml +4 -0
  266. package/templates/skills/backend.md +5 -12
  267. package/templates/skills/frontend.md +5 -12
  268. package/templates/skills/general-worker.md +5 -12
  269. package/templates/skills/researcher.md +7 -12
  270. package/templates/skills/reviewer.md +7 -16
  271. package/templates/vite.yml +4 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Dialog primitives — the PURE model (M22.4). Types + geometry + filtering for
3
+ * the three dialogs every setting is built from: {@link DialogSelectSpec} (a
4
+ * filterable list with a ● current-value marker and per-row key actions),
5
+ * {@link DialogPromptSpec} (one text input with validation), and
6
+ * {@link DialogConfirmSpec} (two options). The STACK that runs them lives in
7
+ * {@link ./dialog-stack.ts}; app.tsx mounts ONE overlay for whatever is on top.
8
+ *
9
+ * Geometry follows the palette's law (M21.9): the overlay is app-rendered, so
10
+ * its full geometry is knowable by pure math shared between the RENDER
11
+ * (placement) and the central mouse ROUTER (row hit-test / inside-vs-outside)
12
+ * — a click can never land where a row isn't drawn. Layout, in screen rows
13
+ * from `top`:
14
+ * border (top+0) · title (top+1) · [filter input, select only] · rule ·
15
+ * body rows (confirm only) · list/content rows … · footer hints · border.
16
+ * Rows span the box interior in x: [left+1, left+width-1).
17
+ */
18
+ import { fuzzyFilter } from "../team/fuzzy.ts";
19
+
20
+ // ── Specs ────────────────────────────────────────────────────────────────────
21
+
22
+ /** One row of a select dialog. */
23
+ export interface DialogSelectItem {
24
+ /** Stable id the caller dispatches on. */
25
+ id: string;
26
+ /** What the list shows and the fuzzy filter scores. */
27
+ label: string;
28
+ /** Dim, right-aligned annotation (current value, key name, …). */
29
+ detail?: string;
30
+ /** The current value — rendered with the ● marker. */
31
+ current?: boolean;
32
+ /** Destructive — enter/click arms an inline "press again to confirm" first. */
33
+ danger?: boolean;
34
+ /** Optional RGB swatch rendered as a colored ● before the label (theme rows). */
35
+ swatch?: [number, number, number];
36
+ }
37
+
38
+ /** A per-row action bound to a ctrl+key chord, shown in the footer. */
39
+ export interface DialogRowAction {
40
+ /** The letter — triggered as ctrl+<key> so it never collides with filter typing. */
41
+ key: string;
42
+ /** Footer label, e.g. `^d delete`. */
43
+ label: string;
44
+ }
45
+
46
+ export interface DialogSelectSpec {
47
+ kind: "select";
48
+ title: string;
49
+ items: DialogSelectItem[];
50
+ /** Type-to-filter (default true). Read-only viewers keep it on for search. */
51
+ filterable?: boolean;
52
+ /** Plain-language footer note (e.g. "applies after re-adopt — run tmux-ide adopt <session>"). */
53
+ footerHint?: string;
54
+ /** Per-row key-bound actions listed in the footer. */
55
+ actions?: DialogRowAction[];
56
+ /** Live-preview hook — fired by the stack whenever the SELECTION lands on a
57
+ * different item (keyboard and mouse both), never on open. */
58
+ onMove?: (item: DialogSelectItem) => void;
59
+ /** Start the selection here (defaults to the `current` item, else 0). */
60
+ initialSel?: number;
61
+ }
62
+
63
+ export interface DialogPromptSpec {
64
+ kind: "prompt";
65
+ title: string;
66
+ /** Dim example shown while the input is empty. */
67
+ placeholder?: string;
68
+ /** Pre-filled input. */
69
+ initial?: string;
70
+ /** Plain-language footer note shown while there is no error. */
71
+ footerHint?: string;
72
+ /** Return an error message to reject, null to accept. */
73
+ validate?: (value: string) => string | null;
74
+ }
75
+
76
+ export interface DialogConfirmSpec {
77
+ kind: "confirm";
78
+ title: string;
79
+ /** Optional explanation line(s) — wrapped to the box width. */
80
+ body?: string;
81
+ /** First option (the affirmative). Default "Yes". */
82
+ yesLabel?: string;
83
+ /** Second option. Default "No". */
84
+ noLabel?: string;
85
+ /** Start on the second option (the safe default for destructive asks). */
86
+ defaultNo?: boolean;
87
+ }
88
+
89
+ export type DialogSpec = DialogSelectSpec | DialogPromptSpec | DialogConfirmSpec;
90
+
91
+ /** What a select resolves with: the chosen item, plus the action key when a
92
+ * per-row ctrl+key action (not enter/click) triggered it. */
93
+ export interface DialogSelectResult {
94
+ item: DialogSelectItem;
95
+ action?: string;
96
+ }
97
+
98
+ // ── Constants ────────────────────────────────────────────────────────────────
99
+
100
+ /** Box width — matches the palette so the two overlays read as one family. */
101
+ export const DIALOG_W = 60;
102
+ /** Visible list rows before the wheel/follow-scroll windows the slice. */
103
+ export const DIALOG_ROWS = 10;
104
+ /** The inline destructive re-arm suffix (plain language, no modal). */
105
+ export const DIALOG_CONFIRM_SUFFIX = " — press again to confirm";
106
+ /** The current-value marker. */
107
+ export const DIALOG_CURRENT_MARK = "●";
108
+
109
+ // ── Filtering / selection ────────────────────────────────────────────────────
110
+
111
+ /** PURE — the visible rows for a query: fuzzy-filtered + score-sorted (the
112
+ * palette's idiom); an empty query returns every item in natural order. */
113
+ export function filterDialogItems(
114
+ query: string,
115
+ items: readonly DialogSelectItem[],
116
+ ): DialogSelectItem[] {
117
+ const q = query.trim();
118
+ if (q.length === 0) return [...items];
119
+ return fuzzyFilter(q, [...items], (i) => i.label).map((m) => m.item);
120
+ }
121
+
122
+ /** PURE — where the selection starts: `initialSel` when valid, else the
123
+ * `current` item, else 0. */
124
+ export function initialSelIndex(spec: DialogSelectSpec): number {
125
+ if (
126
+ spec.initialSel !== undefined &&
127
+ spec.initialSel >= 0 &&
128
+ spec.initialSel < spec.items.length
129
+ ) {
130
+ return spec.initialSel;
131
+ }
132
+ const cur = spec.items.findIndex((i) => i.current);
133
+ return cur >= 0 ? cur : 0;
134
+ }
135
+
136
+ /** PURE — keep `sel` visible: the window `top` that contains it, moving as
137
+ * little as possible (keyboard follow-scroll; the wheel moves `top` alone). */
138
+ export function followTop(sel: number, top: number, pageRows: number): number {
139
+ if (sel < top) return sel;
140
+ if (sel > top + pageRows - 1) return sel - pageRows + 1;
141
+ return top;
142
+ }
143
+
144
+ /** PURE — clamp a wheel-scrolled list top into [0, count - pageRows]. */
145
+ export function clampDialogTop(top: number, count: number, pageRows: number): number {
146
+ return Math.max(0, Math.min(top, count - pageRows));
147
+ }
148
+
149
+ // ── Geometry ─────────────────────────────────────────────────────────────────
150
+
151
+ /** The placed box + the row bands the router hit-tests. */
152
+ export interface DialogGeom {
153
+ left: number;
154
+ top: number;
155
+ width: number;
156
+ /** Chrome rows above the first hit-testable row: top border + title +
157
+ * (filter input) + rule + (confirm body). */
158
+ headerRows: number;
159
+ /** Hit-testable rows on screen right now (select window / confirm options /
160
+ * the prompt's input row). 0 renders one non-hittable placeholder row. */
161
+ visibleRows: number;
162
+ /** Footer hint rows (always 1 — hints/errors live there). */
163
+ footerRows: number;
164
+ }
165
+
166
+ /** PURE — placement: horizontally centered, top at a sixth of the height
167
+ * (min 1 — below the surface tab bar). Same law as the palette. */
168
+ export function dialogPos(
169
+ termW: number,
170
+ termH: number,
171
+ width: number,
172
+ ): { left: number; top: number } {
173
+ return {
174
+ left: Math.max(0, Math.floor((termW - width) / 2)),
175
+ top: Math.max(1, Math.floor(termH / 6)),
176
+ };
177
+ }
178
+
179
+ /** PURE — greedy word-wrap for the confirm body (never returns empty for
180
+ * non-empty text; words longer than the width hard-break). */
181
+ export function wrapText(text: string, width: number): string[] {
182
+ if (width <= 0) return [text];
183
+ const out: string[] = [];
184
+ for (const para of text.split("\n")) {
185
+ let line = "";
186
+ for (const word of para.split(/\s+/).filter(Boolean)) {
187
+ if (line.length === 0) {
188
+ let w = word;
189
+ while (w.length > width) {
190
+ out.push(w.slice(0, width));
191
+ w = w.slice(width);
192
+ }
193
+ line = w;
194
+ } else if (line.length + 1 + word.length <= width) {
195
+ line += ` ${word}`;
196
+ } else {
197
+ out.push(line);
198
+ let w = word;
199
+ while (w.length > width) {
200
+ out.push(w.slice(0, width));
201
+ w = w.slice(width);
202
+ }
203
+ line = w;
204
+ }
205
+ }
206
+ if (line.length > 0 || para.length === 0) out.push(line);
207
+ }
208
+ return out.length > 0 ? out : [""];
209
+ }
210
+
211
+ /** PURE — the interior text width of the box (borders + 1-cell padding each side). */
212
+ export function dialogInnerW(width: number): number {
213
+ return width - 4;
214
+ }
215
+
216
+ /** PURE — chrome rows above the first hit-testable row, per kind. */
217
+ export function dialogHeaderRows(spec: DialogSpec): number {
218
+ // top border + title + rule = 3 …
219
+ if (spec.kind === "select") return spec.filterable === false ? 3 : 4; // … + filter input
220
+ if (spec.kind === "prompt") return 3;
221
+ return 3 + (spec.body ? wrapText(spec.body, dialogInnerW(DIALOG_W)).length : 0);
222
+ }
223
+
224
+ /** PURE — total box height for a geometry (empty lists still show one
225
+ * placeholder row) — used for inside/outside containment. */
226
+ export function dialogHeight(g: DialogGeom): number {
227
+ return g.headerRows + Math.max(1, g.visibleRows) + g.footerRows + 1;
228
+ }
229
+
230
+ /** PURE — the VISIBLE hit-testable row index under (x, y), or -1. x must be
231
+ * inside the box interior (borders excluded); only real rows hit. */
232
+ export function dialogRowAt(g: DialogGeom, x: number, y: number): number {
233
+ if (x < g.left + 1 || x >= g.left + g.width - 1) return -1;
234
+ const row = y - (g.top + g.headerRows);
235
+ return row >= 0 && row < g.visibleRows ? row : -1;
236
+ }
237
+
238
+ /** PURE — whether (x, y) falls anywhere on the box (border included). A press
239
+ * outside pops ONE stack level; inside-but-not-a-row is a no-op. */
240
+ export function dialogContains(g: DialogGeom, x: number, y: number): boolean {
241
+ return x >= g.left && x < g.left + g.width && y >= g.top && y < g.top + dialogHeight(g);
242
+ }
243
+
244
+ // ── Row / footer text ────────────────────────────────────────────────────────
245
+
246
+ /** PURE — a select row's leading marker: `● ` on the current value (kept even
247
+ * while selected — the value marker is the point), else `› `/2 spaces. */
248
+ export function dialogMarker(item: { current?: boolean }, selected: boolean): string {
249
+ if (item.current) return `${DIALOG_CURRENT_MARK} `;
250
+ return selected ? "› " : " ";
251
+ }
252
+
253
+ /** PURE — a select row's body text: marker + label (+ the inline destructive
254
+ * re-arm suffix when armed), with `detail` right-aligned in the interior
255
+ * width; overlong labels truncate before the detail. */
256
+ export function dialogRowText(
257
+ item: DialogSelectItem,
258
+ opts: { selected: boolean; armed: boolean; innerW: number },
259
+ ): string {
260
+ const marker = dialogMarker(item, opts.selected);
261
+ const label = opts.armed ? `${item.label}${DIALOG_CONFIRM_SUFFIX}` : item.label;
262
+ const detail = opts.armed ? "" : (item.detail ?? "");
263
+ const bodyW = opts.innerW - marker.length;
264
+ if (detail.length === 0) return marker + label.slice(0, bodyW).padEnd(bodyW);
265
+ const labelW = Math.max(1, bodyW - detail.length - 2);
266
+ return `${marker}${label.slice(0, labelW).padEnd(labelW)} ${detail}`.slice(0, opts.innerW);
267
+ }
268
+
269
+ /** PURE — the select footer: key hints first, then the per-row actions, then
270
+ * the caller's plain-language note. */
271
+ export function selectFooter(spec: DialogSelectSpec): string {
272
+ const parts = ["enter select", "esc cancel"];
273
+ for (const a of spec.actions ?? []) parts.push(`^${a.key} ${a.label}`);
274
+ if (spec.footerHint) parts.push(spec.footerHint);
275
+ return parts.join(" · ");
276
+ }
277
+
278
+ /** PURE — the prompt footer: a busy note wins, then the validation error, then
279
+ * the hint, then the default keys line. The `error` flag tells the render to
280
+ * tint it as a problem. */
281
+ export function promptFooter(
282
+ spec: DialogPromptSpec,
283
+ state: { error: string | null; busy: boolean },
284
+ ): { text: string; error: boolean } {
285
+ if (state.busy) return { text: "saving…", error: false };
286
+ if (state.error) return { text: state.error, error: true };
287
+ return { text: spec.footerHint ?? "enter save · esc cancel", error: false };
288
+ }
289
+
290
+ /** PURE — the confirm footer. */
291
+ export function confirmFooter(): string {
292
+ return "enter choose · y/n · esc cancel";
293
+ }
294
+
295
+ /** PURE — the confirm option labels in row order (affirmative first). */
296
+ export function confirmOptions(spec: DialogConfirmSpec): [string, string] {
297
+ return [spec.yesLabel ?? "Yes", spec.noLabel ?? "No"];
298
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * The global dialog STACK (M22.4) — one overlay mount in app.tsx renders
3
+ * whatever is on top; everything else is state here, framework-free so it
4
+ * unit-tests without OpenTUI.
5
+ *
6
+ * The API is Promise-based one-shots, so multi-step flows read as sequential
7
+ * awaits:
8
+ *
9
+ * const choice = await DialogSelect.show({ title, items });
10
+ * if (choice && (await DialogConfirm.show({ title: "Sure?" }))) { … }
11
+ *
12
+ * `push` may be called while another dialog is open — the new one stacks on
13
+ * top and only IT renders/receives input; resolving it reveals the one below.
14
+ * Escape (and a click outside the box) pops ONE level, resolving that dialog
15
+ * with its cancel value (`null`, or `false` for confirms). `replace` swaps the
16
+ * top in place; `clear` cancels the whole stack (quit paths).
17
+ *
18
+ * INPUT while a dialog is open is fully owned here: app.tsx feeds every key to
19
+ * {@link dialogKey} and every pointer event to its dialog route block FIRST, so
20
+ * nothing leaks to panes/editor underneath. Selection changes — keyboard and
21
+ * mouse alike — funnel through one place, which is where a select's `onMove`
22
+ * live-preview hook fires (never on open) and where an armed destructive row
23
+ * disarms.
24
+ *
25
+ * Solid bridge: the stack is not reactive; app.tsx subscribes and bumps a
26
+ * `dialogRev` signal on every notification (the `editorRev` idiom).
27
+ */
28
+ import {
29
+ filterDialogItems,
30
+ initialSelIndex,
31
+ followTop,
32
+ clampDialogTop,
33
+ DIALOG_ROWS,
34
+ type DialogConfirmSpec,
35
+ type DialogPromptSpec,
36
+ type DialogSelectItem,
37
+ type DialogSelectResult,
38
+ type DialogSelectSpec,
39
+ type DialogSpec,
40
+ } from "./dialog-model.ts";
41
+
42
+ /** Mutable interaction state of one open dialog. */
43
+ export interface DialogEntryState {
44
+ /** select: the filter query. */
45
+ query: string;
46
+ /** select/confirm: the selected row (select: index into the FILTERED list). */
47
+ sel: number;
48
+ /** select: the scroll window top. */
49
+ top: number;
50
+ /** select: the armed destructive row (filtered index), disarmed on any move. */
51
+ armed: number | null;
52
+ /** prompt: the typed value. */
53
+ input: string;
54
+ /** prompt: the current validation error. */
55
+ error: string | null;
56
+ /** prompt: a busy flag the caller may set while persisting. */
57
+ busy: boolean;
58
+ }
59
+
60
+ /** One stack entry: the spec, its interaction state, and the pending resolver. */
61
+ export interface DialogEntry {
62
+ spec: DialogSpec;
63
+ state: DialogEntryState;
64
+ resolve: (result: unknown) => void;
65
+ }
66
+
67
+ function freshState(spec: DialogSpec): DialogEntryState {
68
+ return {
69
+ query: "",
70
+ sel:
71
+ spec.kind === "select"
72
+ ? initialSelIndex(spec)
73
+ : spec.kind === "confirm" && spec.defaultNo
74
+ ? 1
75
+ : 0,
76
+ top: 0,
77
+ armed: null,
78
+ input: spec.kind === "prompt" ? (spec.initial ?? "") : "",
79
+ error: null,
80
+ busy: false,
81
+ };
82
+ }
83
+
84
+ /** The cancel value a dismissed dialog resolves with. */
85
+ function cancelValue(spec: DialogSpec): unknown {
86
+ return spec.kind === "confirm" ? false : null;
87
+ }
88
+
89
+ export interface DialogStack {
90
+ /** The rendered/driven entry — the top of the stack, or null. */
91
+ top(): DialogEntry | null;
92
+ depth(): number;
93
+ /** Re-render notifications (app.tsx bumps a signal). Returns unsubscribe. */
94
+ subscribe(fn: () => void): () => void;
95
+ /** Open a dialog ON TOP; resolves when it closes. */
96
+ push(spec: DialogSpec): Promise<unknown>;
97
+ /** Swap the top dialog in place (its promise resolves with the cancel value). */
98
+ replace(spec: DialogSpec): Promise<unknown>;
99
+ /** Resolve + remove the top entry. */
100
+ pop(result: unknown): void;
101
+ /** Pop ONE level with its cancel value — what Escape and a click outside do. */
102
+ dismiss(): void;
103
+ /** Cancel every open dialog (bottom-up resolves with cancel values). */
104
+ clear(): void;
105
+ /** The top select's rows for its current query. */
106
+ filtered(): DialogSelectItem[];
107
+ /** Move the selection by delta (select/confirm) — fires onMove, follows scroll. */
108
+ moveSel(delta: number): void;
109
+ /** Set the selection to a FILTERED index (mouse motion) — fires onMove. */
110
+ setSel(index: number): void;
111
+ /** Wheel: shift the select window top by delta rows. */
112
+ scrollBy(delta: number): void;
113
+ /** Activate a filtered row (enter / click): arms danger rows first. */
114
+ activate(index: number): void;
115
+ /** Confirm: resolve option 0 (true) / 1 (false). */
116
+ choose(option: number): void;
117
+ /** Prompt: mark busy (footer shows "saving…", input ignores keys). */
118
+ setBusy(busy: boolean): void;
119
+ /** Notify subscribers after a direct state mutation (query/input edits). */
120
+ touch(): void;
121
+ }
122
+
123
+ export function createDialogStack(): DialogStack {
124
+ const stack: DialogEntry[] = [];
125
+ const listeners = new Set<() => void>();
126
+ const notify = () => {
127
+ for (const fn of listeners) fn();
128
+ };
129
+
130
+ const top = () => stack[stack.length - 1] ?? null;
131
+
132
+ const filtered = (): DialogSelectItem[] => {
133
+ const e = top();
134
+ if (!e || e.spec.kind !== "select") return [];
135
+ return filterDialogItems(e.state.query, e.spec.items);
136
+ };
137
+
138
+ /** Central selection change — the ONLY place `sel` moves, so onMove and the
139
+ * danger disarm can't be bypassed by one of the two input paths. */
140
+ const applySel = (next: number) => {
141
+ const e = top();
142
+ if (!e) return;
143
+ if (e.spec.kind === "confirm") {
144
+ e.state.sel = Math.max(0, Math.min(1, next));
145
+ notify();
146
+ return;
147
+ }
148
+ if (e.spec.kind !== "select") return;
149
+ const rows = filtered();
150
+ if (rows.length === 0) return;
151
+ const clamped = Math.max(0, Math.min(rows.length - 1, next));
152
+ if (clamped === e.state.sel) return;
153
+ e.state.sel = clamped;
154
+ e.state.armed = null;
155
+ e.state.top = followTop(clamped, e.state.top, DIALOG_ROWS);
156
+ e.spec.onMove?.(rows[clamped]!);
157
+ notify();
158
+ };
159
+
160
+ const pop = (result: unknown) => {
161
+ const e = stack.pop();
162
+ if (!e) return;
163
+ e.resolve(result);
164
+ notify();
165
+ };
166
+
167
+ return {
168
+ top,
169
+ depth: () => stack.length,
170
+ subscribe(fn) {
171
+ listeners.add(fn);
172
+ return () => listeners.delete(fn);
173
+ },
174
+ push(spec) {
175
+ return new Promise<unknown>((resolve) => {
176
+ stack.push({ spec, state: freshState(spec), resolve });
177
+ notify();
178
+ });
179
+ },
180
+ replace(spec) {
181
+ const e = stack.pop();
182
+ if (e) e.resolve(cancelValue(e.spec));
183
+ return new Promise<unknown>((resolve) => {
184
+ stack.push({ spec, state: freshState(spec), resolve });
185
+ notify();
186
+ });
187
+ },
188
+ pop,
189
+ dismiss() {
190
+ const e = top();
191
+ if (e) pop(cancelValue(e.spec));
192
+ },
193
+ clear() {
194
+ while (stack.length > 0) {
195
+ const e = stack.pop()!;
196
+ e.resolve(cancelValue(e.spec));
197
+ }
198
+ notify();
199
+ },
200
+ filtered,
201
+ moveSel(delta) {
202
+ const e = top();
203
+ if (!e) return;
204
+ applySel(e.state.sel + delta);
205
+ },
206
+ setSel(index) {
207
+ applySel(index);
208
+ },
209
+ scrollBy(delta) {
210
+ const e = top();
211
+ if (!e || e.spec.kind !== "select") return;
212
+ e.state.top = clampDialogTop(e.state.top + delta, filtered().length, DIALOG_ROWS);
213
+ notify();
214
+ },
215
+ activate(index) {
216
+ const e = top();
217
+ if (!e || e.spec.kind !== "select") return;
218
+ const rows = filtered();
219
+ const item = rows[index];
220
+ if (!item) return;
221
+ if (index !== e.state.sel) applySel(index);
222
+ if (item.danger && e.state.armed !== index) {
223
+ // Inline destructive confirm — the row itself asks again, no modal.
224
+ e.state.armed = index;
225
+ notify();
226
+ return;
227
+ }
228
+ pop({ item } satisfies DialogSelectResult);
229
+ },
230
+ choose(option) {
231
+ const e = top();
232
+ if (!e || e.spec.kind !== "confirm") return;
233
+ pop(option === 0);
234
+ },
235
+ setBusy(busy) {
236
+ const e = top();
237
+ if (!e || e.spec.kind !== "prompt") return;
238
+ e.state.busy = busy;
239
+ notify();
240
+ },
241
+ touch: notify,
242
+ };
243
+ }
244
+
245
+ /** THE stack — one per app process; the overlay mount and the routers all
246
+ * address this instance. */
247
+ export const dialogStack = createDialogStack();
248
+
249
+ // ── One-shot façades (the exploration's API shape) ───────────────────────────
250
+
251
+ export const DialogSelect = {
252
+ /** Open a select; resolves the chosen row (+ action key) or null on cancel. */
253
+ show(spec: Omit<DialogSelectSpec, "kind">, stack: DialogStack = dialogStack) {
254
+ return stack.push({ kind: "select", ...spec }) as Promise<DialogSelectResult | null>;
255
+ },
256
+ };
257
+
258
+ export const DialogPrompt = {
259
+ /** Open a prompt; resolves the (validated) text or null on cancel. */
260
+ show(spec: Omit<DialogPromptSpec, "kind">, stack: DialogStack = dialogStack) {
261
+ return stack.push({ kind: "prompt", ...spec }) as Promise<string | null>;
262
+ },
263
+ };
264
+
265
+ export const DialogConfirm = {
266
+ /** Open a two-option confirm; resolves true only on the affirmative. */
267
+ show(spec: Omit<DialogConfirmSpec, "kind">, stack: DialogStack = dialogStack) {
268
+ return stack.push({ kind: "confirm", ...spec }) as Promise<boolean>;
269
+ },
270
+ };
271
+
272
+ // ── The keyboard reducer ─────────────────────────────────────────────────────
273
+
274
+ /** The key-event shape app.tsx feeds (matches its useKeyboard events). */
275
+ export interface DialogKeyEvent {
276
+ name: string;
277
+ ctrl: boolean;
278
+ meta: boolean;
279
+ shift: boolean;
280
+ }
281
+
282
+ /**
283
+ * Feed one key to the stack's top dialog. Centralized ESCAPE lives here: it
284
+ * pops exactly one level. The caller must treat EVERY key as consumed while
285
+ * `stack.depth() > 0` (input suppression — nothing forwards to panes).
286
+ */
287
+ export function dialogKey(stack: DialogStack, evt: DialogKeyEvent): void {
288
+ const e = stack.top();
289
+ if (!e) return;
290
+ const { spec, state } = e;
291
+ if (evt.name === "escape") {
292
+ stack.dismiss();
293
+ return;
294
+ }
295
+ if (spec.kind === "select") {
296
+ if (evt.name === "up") return stack.moveSel(-1);
297
+ if (evt.name === "down") return stack.moveSel(1);
298
+ if (evt.name === "return") return stack.activate(state.sel);
299
+ // Per-row actions ride ctrl+<key> so they never collide with filter typing.
300
+ if (evt.ctrl && evt.name.length === 1) {
301
+ const action = (spec.actions ?? []).find((a) => a.key === evt.name);
302
+ const item = stack.filtered()[state.sel];
303
+ if (action && item) {
304
+ stack.pop({ item, action: action.key } satisfies DialogSelectResult);
305
+ }
306
+ return;
307
+ }
308
+ if (spec.filterable === false) return;
309
+ if (evt.name === "backspace") {
310
+ state.query = state.query.slice(0, -1);
311
+ state.sel = 0;
312
+ state.top = 0;
313
+ state.armed = null;
314
+ return stack.touch();
315
+ }
316
+ if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
317
+ state.query += evt.shift ? evt.name.toUpperCase() : evt.name;
318
+ state.sel = 0;
319
+ state.top = 0;
320
+ state.armed = null;
321
+ return stack.touch();
322
+ }
323
+ return;
324
+ }
325
+ if (spec.kind === "prompt") {
326
+ if (state.busy) return; // a persisting prompt ignores edits
327
+ if (evt.name === "return") {
328
+ const err = spec.validate?.(state.input) ?? null;
329
+ if (err) {
330
+ state.error = err;
331
+ return stack.touch();
332
+ }
333
+ stack.pop(state.input);
334
+ return;
335
+ }
336
+ if (evt.name === "backspace") {
337
+ state.input = state.input.slice(0, -1);
338
+ state.error = null;
339
+ return stack.touch();
340
+ }
341
+ if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
342
+ state.input += evt.shift ? evt.name.toUpperCase() : evt.name;
343
+ state.error = null;
344
+ return stack.touch();
345
+ }
346
+ return;
347
+ }
348
+ // confirm
349
+ if (evt.name === "up" || evt.name === "left") return stack.setSel(0);
350
+ if (evt.name === "down" || evt.name === "right") return stack.setSel(1);
351
+ if (evt.name === "return") return stack.choose(state.sel);
352
+ if (evt.name === "y") return stack.choose(0);
353
+ if (evt.name === "n") return stack.choose(1);
354
+ }