tmux-ide 2.6.1 → 2.8.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 (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Pure model for the Files tab's expandable file list (M18.4, uplifted M24.6).
3
+ * Like {@link ./diff-model.ts}, the io (async `fs.readdir`, git subprocesses,
4
+ * the `ignore` matcher fed from .gitignore) stays in app.tsx; the ORDERING, the
5
+ * flat-tree splice/prune math, the ignore/hidden FILTERING, the git-status
6
+ * DECORATION merge, the changed-file WALK for `[`/`]`, the `/` FILTER view and
7
+ * the expansion-preserving REBUILD all live here so they unit-test as tables.
8
+ *
9
+ * The list is a FLAT array of {@link FileNode}s carrying a `depth`; a directory
10
+ * "expands" by splicing its freshly-read children in right after it and
11
+ * "collapses" by removing the contiguous run of deeper rows. Dirs sort before
12
+ * files, each group alphabetical (case-insensitive), so the tree reads the way
13
+ * a file manager does.
14
+ */
15
+
16
+ /** Names that are NEVER listed, whatever the toggles say (the explorer
17
+ * widget's battle-tested list — build artifacts and VCS internals that would
18
+ * only bury the code). */
19
+ export const ALWAYS_IGNORE: ReadonlySet<string> = new Set([
20
+ "node_modules",
21
+ ".git",
22
+ ".svn",
23
+ ".hg",
24
+ "dist",
25
+ "build",
26
+ "out",
27
+ ".next",
28
+ ".turbo",
29
+ ".cache",
30
+ "__pycache__",
31
+ "coverage",
32
+ ".nyc_output",
33
+ "target",
34
+ "vendor",
35
+ "bower_components",
36
+ ]);
37
+
38
+ /** One row in the flat file list. `path` is absolute; `depth` is the indent
39
+ * level (0 = the context dir's immediate children). `ignored` marks a
40
+ * gitignored entry — visible only when the I toggle shows them, dimmed. */
41
+ export interface FileNode {
42
+ name: string;
43
+ path: string;
44
+ isDir: boolean;
45
+ depth: number;
46
+ expanded: boolean;
47
+ ignored: boolean;
48
+ }
49
+
50
+ /** A raw `fs.readdir(..., { withFileTypes: true })` entry, reduced to what we
51
+ * need (kept minimal so callers can map Dirent → this trivially). `ignored`
52
+ * is stamped by the caller's gitignore matcher (io stays outside). */
53
+ export interface RawEntry {
54
+ name: string;
55
+ isDir: boolean;
56
+ ignored?: boolean;
57
+ }
58
+
59
+ /** The two Files-surface visibility toggles (both default OFF = filtered). */
60
+ export interface ListFilter {
61
+ /** Show dotfiles (H). */
62
+ showHidden: boolean;
63
+ /** Show gitignored entries (I) — they render dimmed. */
64
+ showIgnored: boolean;
65
+ }
66
+
67
+ /**
68
+ * PURE — drop the entries the current toggles hide: {@link ALWAYS_IGNORE}
69
+ * names always, dotfiles unless `showHidden`, gitignored entries (as stamped
70
+ * by the caller) unless `showIgnored`.
71
+ */
72
+ export function filterEntries(entries: readonly RawEntry[], filter: ListFilter): RawEntry[] {
73
+ return entries.filter((e) => {
74
+ if (ALWAYS_IGNORE.has(e.name)) return false;
75
+ if (!filter.showHidden && e.name.startsWith(".")) return false;
76
+ if (!filter.showIgnored && e.ignored) return false;
77
+ return true;
78
+ });
79
+ }
80
+
81
+ /**
82
+ * PURE — sort raw entries into display order: directories first, then files,
83
+ * each group alphabetical case-insensitively (ties broken by raw name for
84
+ * determinism). Dotfiles are kept (a hidden `.env` still matters to editing).
85
+ */
86
+ export function sortEntries(entries: RawEntry[]): RawEntry[] {
87
+ return [...entries].sort((a, b) => {
88
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
89
+ const an = a.name.toLowerCase();
90
+ const bn = b.name.toLowerCase();
91
+ if (an < bn) return -1;
92
+ if (an > bn) return 1;
93
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
94
+ });
95
+ }
96
+
97
+ /**
98
+ * PURE — build the flat node list for a directory's `entries` at `depth`,
99
+ * joining each name onto `dir` with a "/" (absolute-path aware; no node:path
100
+ * dependency so this stays trivially testable).
101
+ */
102
+ export function buildNodes(dir: string, entries: RawEntry[], depth: number): FileNode[] {
103
+ const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
104
+ return sortEntries(entries).map((e) => ({
105
+ name: e.name,
106
+ path: `${base}/${e.name}`,
107
+ isDir: e.isDir,
108
+ depth,
109
+ expanded: false,
110
+ ignored: e.ignored ?? false,
111
+ }));
112
+ }
113
+
114
+ /**
115
+ * PURE — expand the directory at `index` by marking it expanded and splicing
116
+ * its `children` (already built at the parent's depth+1) in right after it.
117
+ * A no-op (returns the list unchanged) when the row is missing, is not a
118
+ * directory, or is already expanded.
119
+ */
120
+ export function insertChildrenAt(
121
+ list: FileNode[],
122
+ index: number,
123
+ children: FileNode[],
124
+ ): FileNode[] {
125
+ const parent = list[index];
126
+ if (!parent || !parent.isDir || parent.expanded) return list;
127
+ const next = [...list];
128
+ next[index] = { ...parent, expanded: true };
129
+ next.splice(index + 1, 0, ...children);
130
+ return next;
131
+ }
132
+
133
+ /**
134
+ * PURE — collapse the directory at `index` by marking it un-expanded and
135
+ * removing the contiguous run of rows deeper than it (its whole subtree,
136
+ * however many levels were expanded). A no-op when the row is missing, is not a
137
+ * directory, or is not expanded.
138
+ */
139
+ export function removeSubtreeAt(list: FileNode[], index: number): FileNode[] {
140
+ const parent = list[index];
141
+ if (!parent || !parent.isDir || !parent.expanded) return list;
142
+ let end = index + 1;
143
+ while (end < list.length && list[end]!.depth > parent.depth) end++;
144
+ const next = [...list];
145
+ next.splice(index + 1, end - (index + 1));
146
+ next[index] = { ...parent, expanded: false };
147
+ return next;
148
+ }
149
+
150
+ // ── M24.6 — decoration, changed walk, filter view, rebuild ──────────────────
151
+
152
+ /** PURE — `abs` relative to `root` ("" when equal or not under root). Both are
153
+ * plain string paths; a trailing slash on root is tolerated. */
154
+ export function relPath(root: string, abs: string): string {
155
+ const base = root.endsWith("/") ? root.slice(0, -1) : root;
156
+ if (abs === base) return "";
157
+ return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : "";
158
+ }
159
+
160
+ /** PURE — every ancestor DIRECTORY of a repo-relative path, outermost first:
161
+ * `a/b/c.ts` → `["a", "a/b"]`. A top-level path has none. */
162
+ export function ancestorDirs(rel: string): string[] {
163
+ const out: string[] = [];
164
+ let idx = rel.indexOf("/");
165
+ while (idx !== -1) {
166
+ out.push(rel.slice(0, idx));
167
+ idx = rel.indexOf("/", idx + 1);
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * PURE — the per-path git status map from parsed porcelain entries
174
+ * (repo-relative `path` + one-letter `status`), with the explorer widget's
175
+ * parent-dir propagation: every ancestor directory inherits the FIRST child
176
+ * status seen, so a collapsed dir still shows that something changed inside.
177
+ */
178
+ export function statusMapFromEntries(
179
+ entries: readonly { path: string; status: string }[],
180
+ ): Map<string, string> {
181
+ const map = new Map<string, string>();
182
+ for (const file of entries) {
183
+ map.set(file.path, file.status);
184
+ let parent = file.path;
185
+ while (parent.includes("/")) {
186
+ parent = parent.slice(0, parent.lastIndexOf("/"));
187
+ if (!map.has(parent)) map.set(parent, file.status);
188
+ }
189
+ }
190
+ return map;
191
+ }
192
+
193
+ /** PURE — compare two repo-relative paths in TREE DISPLAY order: walk the
194
+ * segments; where they diverge, a directory component (more segments follow)
195
+ * sorts before a terminal file segment — matching {@link sortEntries}'
196
+ * dirs-first, case-insensitive ordering. */
197
+ export function treePathCompare(a: string, b: string): number {
198
+ const as = a.split("/");
199
+ const bs = b.split("/");
200
+ const n = Math.min(as.length, bs.length);
201
+ for (let i = 0; i < n; i++) {
202
+ const aDir = i < as.length - 1;
203
+ const bDir = i < bs.length - 1;
204
+ const av = as[i]!;
205
+ const bv = bs[i]!;
206
+ if (av === bv && aDir && bDir) continue;
207
+ // At the divergence point a directory component sorts before a file
208
+ // segment, regardless of name (dirs-first display order).
209
+ if (aDir !== bDir) return aDir ? -1 : 1;
210
+ const al = av.toLowerCase();
211
+ const bl = bv.toLowerCase();
212
+ if (al !== bl) return al < bl ? -1 : 1;
213
+ if (av !== bv) return av < bv ? -1 : 1;
214
+ }
215
+ return as.length - bs.length;
216
+ }
217
+
218
+ /**
219
+ * PURE — the ordered changed-FILE walk for `[`/`]`: the porcelain entries'
220
+ * paths, deduped, minus anything the surface can never show (a DELETED file
221
+ * has no row at all, an {@link ALWAYS_IGNORE} segment or a dot segment while
222
+ * hidden files are off is filtered out — hopping to an unrevealable row would
223
+ * strand the selection and wedge the chain), sorted in tree display order.
224
+ */
225
+ export function changedFileWalk(
226
+ entries: readonly { path: string; status?: string }[],
227
+ opts: { showHidden: boolean },
228
+ ): string[] {
229
+ const seen = new Set<string>();
230
+ for (const e of entries) {
231
+ if (!e.path) continue;
232
+ if (e.status === "D") continue;
233
+ const segs = e.path.split("/");
234
+ if (segs.some((s) => ALWAYS_IGNORE.has(s))) continue;
235
+ if (!opts.showHidden && segs.some((s) => s.startsWith("."))) continue;
236
+ seen.add(e.path);
237
+ }
238
+ return [...seen].sort(treePathCompare);
239
+ }
240
+
241
+ /**
242
+ * PURE — the next/previous changed path from `current` (repo-relative, or null
243
+ * when the selection is nowhere useful), wrapping around. `current` need not
244
+ * be IN the walk — the step lands on the nearest entry in walk order, so a hop
245
+ * from an unchanged file between two changed ones does the right thing.
246
+ */
247
+ export function nextChangedPath(
248
+ walk: readonly string[],
249
+ current: string | null,
250
+ dir: 1 | -1,
251
+ ): string | null {
252
+ if (walk.length === 0) return null;
253
+ if (current === null) return dir === 1 ? walk[0]! : walk[walk.length - 1]!;
254
+ if (dir === 1) {
255
+ for (const p of walk) if (treePathCompare(p, current) > 0) return p;
256
+ return walk[0]!;
257
+ }
258
+ for (let i = walk.length - 1; i >= 0; i--) {
259
+ if (treePathCompare(walk[i]!, current) < 0) return walk[i]!;
260
+ }
261
+ return walk[walk.length - 1]!;
262
+ }
263
+
264
+ /** One visible row of the `/`-filtered tree: the node plus its index into the
265
+ * UNDERLYING flat list (so activation/expansion math still applies there). */
266
+ export interface FilteredRow {
267
+ node: FileNode;
268
+ index: number;
269
+ }
270
+
271
+ /**
272
+ * PURE — the `/` filter view over the flat list: rows whose NAME contains the
273
+ * query case-insensitively, each carrying its underlying index. A null or
274
+ * empty query is "filter off" — every row, in order. The list itself is never
275
+ * touched, so expanded state trivially survives the filter.
276
+ */
277
+ export function filterView(list: readonly FileNode[], query: string | null): FilteredRow[] {
278
+ if (!query) return list.map((node, index) => ({ node, index }));
279
+ const q = query.toLowerCase();
280
+ const out: FilteredRow[] = [];
281
+ for (let index = 0; index < list.length; index++) {
282
+ const node = list[index]!;
283
+ if (node.name.toLowerCase().includes(q)) out.push({ node, index });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** PURE — the index of `path` in the flat list, or -1. */
289
+ export function indexOfPath(list: readonly FileNode[], path: string): number {
290
+ for (let i = 0; i < list.length; i++) if (list[i]!.path === path) return i;
291
+ return -1;
292
+ }
293
+
294
+ /**
295
+ * PURE — rebuild the whole flat tree from fresh directory listings while
296
+ * PRESERVING expansion: `listing` maps a directory's absolute path → its
297
+ * fresh (already filtered/annotated) entries, `expanded` is the set of dir
298
+ * paths that were expanded before the refresh. A dir stays expanded only when
299
+ * it survived the refresh AND its fresh listing was provided; a vanished or
300
+ * newly-hidden dir simply drops out. Keys must be the exact `FileNode.path`
301
+ * strings (and the root exactly as passed).
302
+ */
303
+ export function rebuildTree(
304
+ rootDir: string,
305
+ listing: ReadonlyMap<string, readonly RawEntry[]>,
306
+ expanded: ReadonlySet<string>,
307
+ ): FileNode[] {
308
+ const walk = (dir: string, depth: number): FileNode[] => {
309
+ const ents = listing.get(dir);
310
+ if (!ents) return [];
311
+ const out: FileNode[] = [];
312
+ for (const node of buildNodes(dir, [...ents], depth)) {
313
+ if (node.isDir && expanded.has(node.path) && listing.has(node.path)) {
314
+ out.push({ ...node, expanded: true }, ...walk(node.path, depth + 1));
315
+ } else {
316
+ out.push(node);
317
+ }
318
+ }
319
+ return out;
320
+ };
321
+ return walk(rootDir, 0);
322
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Focus-border geometry (M22.7): the accent strips drawn in the canvas GUTTER
3
+ * cells around the focused pane, so focus is pane-level obvious without
4
+ * consuming any terminal cells (a real box border would shrink the grid).
5
+ *
6
+ * Strips exist only where the pane has a neighboring gutter INSIDE the canvas —
7
+ * a pane flush to a canvas edge has no room on that side (tmux gives edge panes
8
+ * flush edges), and a single-pane window renders no strips at all (there is no
9
+ * ambiguity to resolve, and no gutters to paint). Horizontal strips extend one
10
+ * cell into the adjacent vertical gutters so the corners fill.
11
+ *
12
+ * PURE — rect math only; the app renders each strip as one background-tinted
13
+ * box, and the strips are handler-less so gutter presses still bubble to the
14
+ * central router (border drags keep working).
15
+ */
16
+
17
+ export interface PaneRectLike {
18
+ left: number;
19
+ top: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ export interface FocusStrip {
25
+ left: number;
26
+ top: number;
27
+ width: number;
28
+ height: number;
29
+ }
30
+
31
+ /**
32
+ * The gutter strips around `pane` within a `cols`×`rows` canvas — empty when
33
+ * the window has a single pane (`paneCount < 2`) or the rect is degenerate.
34
+ */
35
+ export function focusStrips(
36
+ pane: PaneRectLike,
37
+ cols: number,
38
+ rows: number,
39
+ paneCount: number,
40
+ ): FocusStrip[] {
41
+ if (paneCount < 2) return [];
42
+ if (pane.width <= 0 || pane.height <= 0) return [];
43
+ const strips: FocusStrip[] = [];
44
+ const hasLeft = pane.left > 0;
45
+ const hasRight = pane.left + pane.width < cols;
46
+ const hasTop = pane.top > 0;
47
+ const hasBottom = pane.top + pane.height < rows;
48
+ // Horizontal strips reach into the side gutters so corners fill.
49
+ const hx = hasLeft ? pane.left - 1 : pane.left;
50
+ const hw = pane.width + (hasLeft ? 1 : 0) + (hasRight ? 1 : 0);
51
+ if (hasTop) strips.push({ left: hx, top: pane.top - 1, width: hw, height: 1 });
52
+ if (hasBottom) strips.push({ left: hx, top: pane.top + pane.height, width: hw, height: 1 });
53
+ if (hasLeft) strips.push({ left: pane.left - 1, top: pane.top, width: 1, height: pane.height });
54
+ if (hasRight)
55
+ strips.push({ left: pane.left + pane.width, top: pane.top, width: 1, height: pane.height });
56
+ return strips;
57
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The "open a folder" picker's PURE model (M22.5) — the filesystem browser
3
+ * behind the home screen's non-technical entry point. The picker is a
4
+ * {@link ../mirror/dialog-stack.ts} DialogSelect flow: app.tsx does the async
5
+ * `readdir`/`stat` (the header's async-only fs law) and drives the browse loop;
6
+ * everything here — how a directory's entries become rows, how the breadcrumb
7
+ * reads, how a typed path expands, how an invalid path is explained — is pure so
8
+ * it unit-tests without OpenTUI or a filesystem.
9
+ *
10
+ * Row id scheme (what a chosen row dispatches on):
11
+ * picker:open → open the folder we are browsing
12
+ * picker:hidden → toggle whether dot-leading folders show
13
+ * picker:up → ascend to the parent
14
+ * picker:type → the "type a path…" escape hatch (a DialogPrompt)
15
+ * picker:dir:<name> → descend into <name>
16
+ *
17
+ * The hidden-folders toggle is a ROW (not a ctrl chord): terminal-native,
18
+ * `ctrl+h` is byte-identical to Backspace, so a chord can't carry it — and a
19
+ * row is mouse-clickable and keyboard-selectable, which suits the audience.
20
+ */
21
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
22
+ import type { DialogSelectItem } from "./dialog-model.ts";
23
+
24
+ export const PICKER_OPEN_ID = "picker:open";
25
+ export const PICKER_HIDDEN_ID = "picker:hidden";
26
+ export const PICKER_UP_ID = "picker:up";
27
+ export const PICKER_TYPE_ID = "picker:type";
28
+ export const PICKER_DIR_PREFIX = "picker:dir:";
29
+
30
+ /** How many breadcrumb segments the title keeps before eliding the front with
31
+ * an ellipsis (deep paths stay one readable line). */
32
+ const BREADCRUMB_MAX_SEGMENTS = 4;
33
+
34
+ /** PURE — expand a user-typed path: a leading `~` (or `~/…`) becomes `home`,
35
+ * then relative paths resolve against `base`. An absolute path is returned as
36
+ * given (normalized). Blank input resolves to `base`. */
37
+ export function expandUserPath(input: string, home: string, base: string): string {
38
+ const raw = input.trim();
39
+ if (raw.length === 0) return base;
40
+ if (raw === "~") return home;
41
+ if (raw.startsWith("~/")) return join(home, raw.slice(2));
42
+ if (isAbsolute(raw)) return resolve(raw);
43
+ return resolve(base, raw);
44
+ }
45
+
46
+ /** PURE — the parent directory, stopping at the filesystem root (dirname of
47
+ * `/` is `/`). */
48
+ export function pickerParent(dir: string): string {
49
+ return dirname(dir);
50
+ }
51
+
52
+ /** PURE — whether `dir` is the filesystem root (no higher parent). */
53
+ export function isPickerRoot(dir: string): boolean {
54
+ return dirname(dir) === dir;
55
+ }
56
+
57
+ /** PURE — the subdirectory names to show: hidden (dot-leading) folders are
58
+ * dropped unless `showHidden`, `.`/`..` never appear, and the rest sort
59
+ * case-insensitively so the list reads like a file manager. */
60
+ export function filterDirs(names: readonly string[], showHidden: boolean): string[] {
61
+ return names
62
+ .filter((n) => n !== "." && n !== "..")
63
+ .filter((n) => showHidden || !n.startsWith("."))
64
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
65
+ }
66
+
67
+ /** PURE — a compact breadcrumb for the dialog title: the home prefix collapses
68
+ * to `~`, segments join with ` › `, and a path deeper than
69
+ * {@link BREADCRUMB_MAX_SEGMENTS} elides its front with `…`. The root shows as
70
+ * `/`. */
71
+ export function pickerBreadcrumb(dir: string, home: string): string {
72
+ let rest = dir;
73
+ let head = "";
74
+ if (home.length > 0 && (dir === home || dir.startsWith(`${home}/`))) {
75
+ head = "~";
76
+ rest = dir.slice(home.length);
77
+ }
78
+ const segs = rest.split("/").filter(Boolean);
79
+ const parts = head ? [head, ...segs] : segs;
80
+ if (parts.length === 0) return "/";
81
+ if (parts.length <= BREADCRUMB_MAX_SEGMENTS) return parts.join(" › ");
82
+ return ["…", ...parts.slice(parts.length - BREADCRUMB_MAX_SEGMENTS)].join(" › ");
83
+ }
84
+
85
+ /** PURE — the picker rows for a directory: an explicit "open this folder" row
86
+ * first, then the hidden-folders toggle, the ascend row (unless at the root),
87
+ * the subdirectories (already filtered/sorted by {@link filterDirs}), and the
88
+ * "type a path…" escape hatch last. Descending happens on a `picker:dir:*` /
89
+ * `picker:up` row; opening on `picker:open`; the toggle flips `showHidden`. */
90
+ export function pickerRows(
91
+ dir: string,
92
+ subdirs: readonly string[],
93
+ showHidden: boolean,
94
+ ): DialogSelectItem[] {
95
+ const rows: DialogSelectItem[] = [
96
+ { id: PICKER_OPEN_ID, label: "+ Open this folder", detail: basename(dir) || dir },
97
+ {
98
+ id: PICKER_HIDDEN_ID,
99
+ label: showHidden ? "Hide hidden folders" : "Show hidden folders",
100
+ detail: showHidden ? "on" : "off",
101
+ },
102
+ ];
103
+ if (!isPickerRoot(dir)) rows.push({ id: PICKER_UP_ID, label: "..", detail: "up a level" });
104
+ for (const name of subdirs) rows.push({ id: `${PICKER_DIR_PREFIX}${name}`, label: `${name}/` });
105
+ rows.push({ id: PICKER_TYPE_ID, label: "Type a path…", detail: "enter a folder path" });
106
+ return rows;
107
+ }
108
+
109
+ /** PURE — the subdirectory name a `picker:dir:*` row descends into, or null for
110
+ * any other id. */
111
+ export function pickerDirName(id: string): string | null {
112
+ return id.startsWith(PICKER_DIR_PREFIX) ? id.slice(PICKER_DIR_PREFIX.length) : null;
113
+ }
114
+
115
+ /** The three outcomes of stat-ing a typed path. */
116
+ export type PathKind = "dir" | "file" | "missing";
117
+
118
+ /** PURE — the plain-language footer error for a typed path that isn't a folder
119
+ * (never called for `"dir"` — that path is accepted). */
120
+ export function pathKindHint(kind: "file" | "missing"): string {
121
+ return kind === "file"
122
+ ? "That's a file, not a folder — pick a directory"
123
+ : "No folder there — check the path and try again";
124
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The HOME panel's row model (M21.9) — PURE so it unit-tests without OpenTUI.
3
+ *
4
+ * Home used to be a flat list of live sessions; now it is a list of ITEMS:
5
+ * every live session (unchanged, first), then — when the project registry has
6
+ * projects with no live session — a section header followed by one launchable
7
+ * row per registered-but-not-running project. That surfaces the registry as a
8
+ * mouse-first "open project" affordance: clicking (or pressing enter on) a
9
+ * project row launches a detached session in its dir and opens it as the
10
+ * workspace.
11
+ *
12
+ * The item list is what the render walks AND what the router's `gy - 2` row
13
+ * math indexes, so selection/hover/click all share one geometry. Headers are
14
+ * not selectable; the step/clamp helpers keep the keyboard selection on real
15
+ * rows without the app hand-rolling skip loops.
16
+ */
17
+ import { basename } from "node:path";
18
+ import type { AgentStatus } from "../detect/classify.ts";
19
+ import type { AppKeys } from "../../lib/app-config.ts";
20
+ import { prefixTwinFor } from "./settings-model.ts";
21
+
22
+ /** A live tmux session row — click/enter opens it as the workspace. */
23
+ export interface HomeSessionItem {
24
+ kind: "session";
25
+ session: string;
26
+ project: string;
27
+ status: AgentStatus;
28
+ windows: number;
29
+ dir: string | null;
30
+ }
31
+ /** A registered project with no live session — click/enter launches it. */
32
+ export interface HomeProjectItem {
33
+ kind: "project";
34
+ name: string;
35
+ dir: string | null;
36
+ }
37
+ /** A recently-opened folder with no live session or registry entry (M22.5) —
38
+ * click/enter re-opens it (create-or-attach a session in `dir`). */
39
+ export interface HomeRecentItem {
40
+ kind: "recent";
41
+ name: string;
42
+ dir: string;
43
+ }
44
+ /** A non-selectable section label between the sessions and the registry. */
45
+ export interface HomeHeaderItem {
46
+ kind: "header";
47
+ label: string;
48
+ }
49
+ export type HomeItem = HomeSessionItem | HomeProjectItem | HomeRecentItem | HomeHeaderItem;
50
+
51
+ /** The slice of the `tmux-ide team --json` payload this model reads (kept
52
+ * structural so the app's locally-declared fleet shape satisfies it). */
53
+ export interface HomeFleetProject {
54
+ name: string;
55
+ dir: string | null;
56
+ registered: boolean;
57
+ running: boolean;
58
+ sessions: Array<{
59
+ name: string;
60
+ status: AgentStatus;
61
+ windows: Array<unknown>;
62
+ }>;
63
+ }
64
+
65
+ export const REGISTRY_HEADER_LABEL = "registered projects — not running";
66
+ export const RECENTS_HEADER_LABEL = "recently opened";
67
+
68
+ /** PURE — the ordered home items: every project's live sessions first (the
69
+ * exact rows home always showed), then a header + one row per registered
70
+ * project that has no live session, then (M22.5) a "recently opened" section.
71
+ * Recents dedupe against the REGISTRY (registry wins) — a folder you saved as
72
+ * a project shows only as its project row, never doubled as a recent. Recents
73
+ * are NOT deduped against live sessions: opening a folder spins up a session,
74
+ * yet the folder still belongs in "recent" for one-click reopen later. */
75
+ export function buildHomeItems(
76
+ projects: readonly HomeFleetProject[],
77
+ recents: readonly string[] = [],
78
+ ): HomeItem[] {
79
+ const items: HomeItem[] = [];
80
+ for (const p of projects) {
81
+ for (const s of p.sessions) {
82
+ items.push({
83
+ kind: "session",
84
+ session: s.name,
85
+ project: p.name,
86
+ status: s.status,
87
+ windows: s.windows.length,
88
+ dir: p.dir,
89
+ });
90
+ }
91
+ }
92
+ const idle = projects.filter((p) => p.registered && !p.running);
93
+ if (idle.length > 0) {
94
+ items.push({ kind: "header", label: REGISTRY_HEADER_LABEL });
95
+ for (const p of idle) items.push({ kind: "project", name: p.name, dir: p.dir });
96
+ }
97
+ // A folder already saved as a registered project is not repeated under
98
+ // "recent" (registry wins the dedupe).
99
+ const registeredDirs = new Set<string>();
100
+ for (const p of projects) if (p.dir && p.registered) registeredDirs.add(p.dir);
101
+ const freshRecents = recents.filter((d) => d.length > 0 && !registeredDirs.has(d));
102
+ if (freshRecents.length > 0) {
103
+ items.push({ kind: "header", label: RECENTS_HEADER_LABEL });
104
+ for (const dir of freshRecents) {
105
+ items.push({ kind: "recent", name: basename(dir) || dir, dir });
106
+ }
107
+ }
108
+ return items;
109
+ }
110
+
111
+ /** PURE — first-run when the fleet has no live sessions AND no registered
112
+ * projects (M22.5). Recently-opened folders alone do NOT count — the welcome
113
+ * still greets someone who has only browsed folders, and hides the moment a
114
+ * session or project exists. */
115
+ export function isFirstRun(projects: readonly HomeFleetProject[]): boolean {
116
+ const hasSession = projects.some((p) => p.sessions.length > 0);
117
+ const hasProject = projects.some((p) => p.registered);
118
+ return !hasSession && !hasProject;
119
+ }
120
+
121
+ /** PURE — the one-line first-run tip built from the USER'S ACTUAL keybindings
122
+ * (M22.5): the reliable `prefix <letter>` twin for each action, falling back
123
+ * to the raw Alt key when a letter is claimed by a stock tmux bind. */
124
+ export function firstRunTip(keys: AppKeys): string {
125
+ const k = (alt: string): string => {
126
+ const twin = prefixTwinFor(alt);
127
+ return twin ? `prefix ${twin}` : alt;
128
+ };
129
+ return `Your keys: ${k(keys.home)} home · ${k(keys.popup)} switch sessions · ${k(keys.menu)} actions`;
130
+ }
131
+
132
+ /** PURE — the leading-space padding that horizontally centers a `len`-wide run
133
+ * in a `width`-wide area (never negative). Shared by the welcome render and
134
+ * the click router so a centered clickable lands where it is drawn. */
135
+ export function centerPad(width: number, len: number): number {
136
+ return Math.max(0, Math.floor((width - len) / 2));
137
+ }
138
+
139
+ /** PURE — whether an item takes selection / clicks as a row. */
140
+ export function isSelectable(item: HomeItem | undefined): boolean {
141
+ return item !== undefined && item.kind !== "header";
142
+ }
143
+
144
+ /** PURE — clamp a selection index onto a selectable item: into range first,
145
+ * then the nearest selectable at-or-above, falling back downward. Returns 0
146
+ * for an empty list (matching the old `Math.min(sel, len - 1)` behavior). */
147
+ export function clampSelectable(items: readonly HomeItem[], sel: number): number {
148
+ if (items.length === 0) return 0;
149
+ const start = Math.max(0, Math.min(sel, items.length - 1));
150
+ for (let i = start; i < items.length; i++) if (isSelectable(items[i])) return i;
151
+ for (let i = start - 1; i >= 0; i--) if (isSelectable(items[i])) return i;
152
+ return 0;
153
+ }
154
+
155
+ /** PURE — move the selection one selectable row in `delta`'s direction (±1),
156
+ * skipping headers; stays put at either end. */
157
+ export function stepSelectable(items: readonly HomeItem[], from: number, delta: 1 | -1): number {
158
+ for (let i = from + delta; i >= 0 && i < items.length; i += delta) {
159
+ if (isSelectable(items[i])) return i;
160
+ }
161
+ return from;
162
+ }
163
+
164
+ /** PURE — a tmux-legal session name for a project: tmux forbids `:` and `.`
165
+ * in session names (target syntax), and spaces only invite quoting bugs —
166
+ * all collapse to `-`. */
167
+ export function sessionNameFor(project: string): string {
168
+ return project.replace(/[.:\s]+/g, "-");
169
+ }
170
+
171
+ /** PURE — whether a user-typed session name is directly usable. */
172
+ export function isValidSessionName(name: string): boolean {
173
+ return name.length > 0 && !/[.:\s]/.test(name);
174
+ }