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,155 @@
1
+ /**
2
+ * The sidebar AGENTS section's pure model (M22.2) — PURE so it unit-tests
3
+ * without OpenTUI. app.tsx flattens the fleet payload's per-session
4
+ * `agents: PaneAgentEntry[]` (M22.1) into one fleet-wide list, sorts it
5
+ * attention-first, renders one row per agent (reusing app.tsx's existing
6
+ * STATUS_GLYPH / STATUS_COLOR grammar keyed by state — NOT reinvented here), and
7
+ * lets a click JUMP to the exact session/window/pane. Ordering, row-label
8
+ * truncation, state-age formatting, and the sidebar row hit-test all live here;
9
+ * the render/router just read them.
10
+ *
11
+ * The sort order mirrors {@link ../team/home.ts}'s `ROLLUP_ORDER` so the sidebar
12
+ * list and the header rollup chips agree on what "attention-first" means.
13
+ */
14
+ import type { AgentStatus } from "../detect/classify.ts";
15
+ import { ROLLUP_ORDER } from "../team/home.ts";
16
+
17
+ /** The per-agent fields the sidebar needs — a structural subset of the fleet
18
+ * payload's `PaneAgentEntry` (app.tsx keeps its fleet types local + io-free, so
19
+ * this narrower shape is what it flattens into). */
20
+ export interface AgentRowInput {
21
+ /** tmux pane id, e.g. `%5` — the exact pane a click focuses. */
22
+ paneId: string;
23
+ /** `window_index` of the tab this pane lives in — the window a click selects. */
24
+ windowIndex: number;
25
+ /** Owning session name — the workspace a click switches to. */
26
+ session: string;
27
+ /** Resolved agent kind (manifest id: `claude`, `codex`, …). */
28
+ kind: string;
29
+ /** Final per-pane status (drives the glyph + color, reusing the app grammar). */
30
+ state: AgentStatus;
31
+ /** Authority state's epoch-seconds stamp, or null for a scraped pane. */
32
+ since: number | null;
33
+ /** Self-reported one-liner (`@agent_status_text`, sanitized by the report;
34
+ * dropped with a stale authority stamp). Rides to the pane chips (M25.4). */
35
+ statusText?: string;
36
+ /** Self-reported display name (`@agent_display_name`) — row-label precedence
37
+ * over the detected `kind` ({@link agentDisplayKind}). */
38
+ displayName?: string;
39
+ }
40
+
41
+ /** PURE — what an agent row is CALLED: the self-reported display name when one
42
+ * is stamped (and fresh — the report already applied staleness), else the
43
+ * detected kind. The one precedence rule every label surface shares. */
44
+ export function agentDisplayKind(a: Pick<AgentRowInput, "kind" | "displayName">): string {
45
+ return a.displayName ?? a.kind;
46
+ }
47
+
48
+ /** Attention-first rank: blocked, working, done, idle (from `ROLLUP_ORDER`), then
49
+ * `unknown` last. Lower sorts earlier. */
50
+ const STATE_RANK: Record<AgentStatus, number> = (() => {
51
+ const rank = {} as Record<AgentStatus, number>;
52
+ ROLLUP_ORDER.forEach((s, i) => (rank[s] = i));
53
+ rank.unknown = ROLLUP_ORDER.length;
54
+ return rank;
55
+ })();
56
+
57
+ /**
58
+ * PURE — order agents attention-first (blocked → working → done → idle →
59
+ * unknown), STABLE within a group so rows don't jitter between polls when states
60
+ * are unchanged (`Array.prototype.sort` is stable). Returns a new array; the
61
+ * input is untouched.
62
+ */
63
+ export function sortAgentRows<T extends { state: AgentStatus }>(agents: readonly T[]): T[] {
64
+ return [...agents].sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state]);
65
+ }
66
+
67
+ /**
68
+ * PURE — the label shown on a sidebar agent row: `"<kind> · <session>"`,
69
+ * truncated with a trailing ellipsis to at most `maxChars` columns (the sidebar
70
+ * is width-constrained and drag-resizable). `maxChars <= 0` yields "".
71
+ */
72
+ export function agentRowLabel(kind: string, session: string, maxChars: number): string {
73
+ const full = `${kind} · ${session}`;
74
+ if (maxChars <= 0) return "";
75
+ if (full.length <= maxChars) return full;
76
+ if (maxChars === 1) return "…";
77
+ return full.slice(0, maxChars - 1) + "…";
78
+ }
79
+
80
+ /**
81
+ * PURE — the AGENTS section header, `"agents · <count>"`, truncated to
82
+ * `maxChars`. The count is always shown so the header doubles as the tally the
83
+ * card asks for.
84
+ */
85
+ export function agentsHeaderLabel(count: number, maxChars: number): string {
86
+ const full = `agents · ${count}`;
87
+ if (maxChars <= 0) return "";
88
+ return full.length <= maxChars ? full : full.slice(0, Math.max(1, maxChars - 1)) + "…";
89
+ }
90
+
91
+ /** The quiet empty-state line shown under the header when no agents are running. */
92
+ export const AGENTS_EMPTY_LINE = "no agents running — panes running claude or codex show up here";
93
+
94
+ /** The always-present spawn chip on the AGENTS header row (and the empty-state
95
+ * row) — THE discoverable "start a new agent" entry (M24.1). Right-aligned;
96
+ * the render and the router share its span via `spansFromRight`. */
97
+ export const AGENTS_ADD_CHIP = "[+ agent]";
98
+
99
+ /**
100
+ * PURE — a compact state-age like `"blocked 4m"` from a `since` epoch-seconds
101
+ * stamp, for the hovered row. Returns null when there is no timestamp (a scraped
102
+ * pane) or the dwell rounds to zero — nothing worth showing. Granularity:
103
+ * seconds under a minute, then minutes, then hours.
104
+ */
105
+ export function agentAgeLabel(
106
+ state: AgentStatus,
107
+ since: number | null,
108
+ nowSec: number,
109
+ ): string | null {
110
+ if (since == null) return null;
111
+ const secs = Math.max(0, Math.floor(nowSec - since));
112
+ let span: string;
113
+ if (secs < 60) span = `${secs}s`;
114
+ else if (secs < 3600) span = `${Math.floor(secs / 60)}m`;
115
+ else span = `${Math.floor(secs / 3600)}h`;
116
+ return `${state} ${span}`;
117
+ }
118
+
119
+ /** Blank rows the render leaves between the session list and the agents header
120
+ * (the agents section's `marginTop`). Kept here so `sidebarHit` and the render
121
+ * agree on the row the header sits on — a mismatch lands clicks one row off. */
122
+ export const AGENTS_GAP_ROWS = 1;
123
+
124
+ /** What a sidebar content row (a tab-bar-adjusted `gy`) targets. The gap
125
+ * row(s) are inert; `agents-header` opens the Team dialog (its right-aligned
126
+ * `[+ agent]` chip spawns — the router x-tests the chip span first), and
127
+ * `agents-empty` is the empty-state row (inert except its chip). */
128
+ export type SidebarHit =
129
+ | { kind: "session"; index: number }
130
+ | { kind: "agent"; index: number }
131
+ | { kind: "agents-header" }
132
+ | { kind: "agents-empty" }
133
+ | null;
134
+
135
+ /**
136
+ * PURE — map a sidebar content row `gy` (already `y - TABBAR_H`) to what it
137
+ * targets, shared by the click router and the hover resolver so a click can
138
+ * never land where a row isn't drawn. Layout, top to bottom: `gy 0` title,
139
+ * `gy 1` rule, then `sessionCount` session rows from `gy 2`, then
140
+ * `AGENTS_GAP_ROWS` blank row(s), then the agents section — one header row, then
141
+ * `agentCount` agent rows (or a single empty-state row when `agentCount === 0`,
142
+ * hit-tested as `agents-empty` so its `[+ agent]` chip stays clickable).
143
+ */
144
+ export function sidebarHit(gy: number, sessionCount: number, agentCount: number): SidebarHit {
145
+ if (gy < 2) return null;
146
+ const si = gy - 2;
147
+ if (si < sessionCount) return { kind: "session", index: si };
148
+ const headerGy = 2 + sessionCount + AGENTS_GAP_ROWS;
149
+ if (gy < headerGy) return null; // the gap row(s) between sessions and agents
150
+ if (gy === headerGy) return { kind: "agents-header" };
151
+ const ai = gy - headerGy - 1;
152
+ if (agentCount === 0) return ai === 0 ? { kind: "agents-empty" } : null;
153
+ if (ai < 0 || ai >= agentCount) return null;
154
+ return { kind: "agent", index: ai };
155
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Persisted app state for the unified IDE (M18.4) — the one small JSON that
3
+ * makes the app remember where you were: the last surface TAB, the workspace
4
+ * CONTEXT session, and the file that was open in the editor / selected in the
5
+ * diff panel.
6
+ *
7
+ * PURE parse/serialize live here so they unit-test as tables (like
8
+ * {@link ./editor-buffer.ts} / {@link ./diff-model.ts}); the io wrappers
9
+ * ({@link loadAppState}/{@link saveAppState}) are thin. The file lives at
10
+ * `~/.tmux-ide/app-state.json`, overridable via `TMUX_IDE_HOME` (tests /
11
+ * per-run isolation point the whole home elsewhere).
12
+ *
13
+ * {@link parseAppState} is TOLERANT: a missing file, malformed JSON, or a
14
+ * mistyped field each falls back to its default and it never throws — the same
15
+ * discipline as {@link ../../lib/app-config.ts}. Restoring stale state must
16
+ * never crash the launch.
17
+ */
18
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
19
+ import { writeFile } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+ import { isSpawnWhere, type LastSpawn } from "./agent-lifecycle.ts";
23
+
24
+ /** The four top-level surfaces. `terminal` is the SessionMirror, `files` the
25
+ * editor + file list, `diff` the git panel, `home` the fleet cockpit. */
26
+ export type Tab = "home" | "terminal" | "files" | "diff";
27
+
28
+ const TABS: readonly Tab[] = ["home", "terminal", "files", "diff"];
29
+
30
+ /** PURE — is `x` one of the four tab keys? */
31
+ export function isTab(x: unknown): x is Tab {
32
+ return typeof x === "string" && (TABS as readonly string[]).includes(x);
33
+ }
34
+
35
+ /** Sidebar-width bounds (M19.3). The user drags the sidebar/main boundary; the
36
+ * resulting width is clamped to this range and persisted. `DEFAULT` is the
37
+ * historical fixed width. */
38
+ export const SIDEBAR_W_MIN = 16;
39
+ export const SIDEBAR_W_MAX = 48;
40
+ export const SIDEBAR_W_DEFAULT = 24;
41
+
42
+ /** PURE — clamp a candidate sidebar width to `[SIDEBAR_W_MIN, SIDEBAR_W_MAX]`,
43
+ * falling back to the default for a non-finite value. */
44
+ export function clampSidebarWidth(w: number): number {
45
+ if (!Number.isFinite(w)) return SIDEBAR_W_DEFAULT;
46
+ return Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, Math.round(w)));
47
+ }
48
+
49
+ /** How many recently-opened folders home remembers (M22.5). Oldest fall off. */
50
+ export const RECENTS_CAP = 8;
51
+
52
+ /** How many per-context "again" spawn memories persist (M24.1). Oldest fall off. */
53
+ export const SPAWN_MEMORY_CAP = 20;
54
+
55
+ /** How many custom spawn commands the recents list keeps (M24.1, global). */
56
+ export const CUSTOM_COMMANDS_CAP = 5;
57
+
58
+ /** How many palette actions the usage history remembers (M24.4). LRU by
59
+ * last use — the map is insertion-ordered oldest-first, like lastSpawns. */
60
+ export const PALETTE_USAGE_CAP = 50;
61
+
62
+ /** One palette action's usage record (M24.4): how often it ran and when last.
63
+ * Keyed by the STABLE action key ({@link ./palette.ts}'s paletteActionKey) so
64
+ * a relabeled action keeps its history. */
65
+ export interface PaletteUsageEntry {
66
+ count: number;
67
+ /** Epoch seconds of the most recent run. */
68
+ lastUsed: number;
69
+ }
70
+
71
+ /** The persisted shape. `null` means "nothing remembered" for that slot. */
72
+ export interface AppState {
73
+ /** The tab the app was showing when it last saved. */
74
+ lastTab: Tab;
75
+ /** The workspace-context session name (drives terminal target + files/diff dir). */
76
+ contextSession: string | null;
77
+ /** Absolute path of the file open in the editor. */
78
+ openFile: string | null;
79
+ /** Repo-relative path of the file selected in the diff panel. */
80
+ diffFile: string | null;
81
+ /** The sidebar column width (M19.3), clamped to the bounds above. */
82
+ sidebarW: number;
83
+ /** Recently-opened folder paths (M22.5), most-recent first, deduped and
84
+ * capped at {@link RECENTS_CAP}. Home renders these under a "recent" header. */
85
+ recentFolders: string[];
86
+ /** The "again" memory (M24.1): spawn-context key (a project/session dir, or
87
+ * `session:<name>` when no dir is known) → the last spawn there. Insertion-
88
+ * ordered LRU capped at {@link SPAWN_MEMORY_CAP}. */
89
+ lastSpawns: Record<string, LastSpawn>;
90
+ /** Recent CUSTOM spawn commands (M24.1), most-recent first, deduped, global
91
+ * (not per-project), capped at {@link CUSTOM_COMMANDS_CAP}. */
92
+ customCommands: string[];
93
+ /** Palette usage history (M24.4): stable action key → {count, lastUsed}.
94
+ * Insertion-ordered LRU (oldest first) capped at {@link PALETTE_USAGE_CAP};
95
+ * drives the empty-query "recent" group and the ranking tie-break. */
96
+ paletteUsage: Record<string, PaletteUsageEntry>;
97
+ /** Files surface (M24.6): show dotfiles (H toggle). Default hidden. */
98
+ filesShowHidden: boolean;
99
+ /** Files surface (M24.6): show gitignored entries (I toggle). Default hidden. */
100
+ filesShowIgnored: boolean;
101
+ }
102
+
103
+ export const DEFAULT_APP_STATE: AppState = {
104
+ lastTab: "home",
105
+ contextSession: null,
106
+ openFile: null,
107
+ diffFile: null,
108
+ sidebarW: SIDEBAR_W_DEFAULT,
109
+ recentFolders: [],
110
+ lastSpawns: {},
111
+ customCommands: [],
112
+ paletteUsage: {},
113
+ filesShowHidden: false,
114
+ filesShowIgnored: false,
115
+ };
116
+
117
+ /** PURE — the "again" memory key for a spawn context: the concrete dir when
118
+ * known, else the session name (namespaced so a dirless session can't collide
119
+ * with a path), else null — nothing stable to remember under. */
120
+ export function spawnMemoryKey(dir: string | null, session?: string): string | null {
121
+ if (dir && dir.length > 0) return dir;
122
+ if (session && session.length > 0) return `session:${session}`;
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * PURE — the spawn-memory map after remembering `spawn` under `key`: the key
128
+ * moves to newest (re-inserted last), and when the map outgrows `cap` the
129
+ * OLDEST entries drop (JS objects preserve string-key insertion order — the
130
+ * same order JSON round-trips, so the LRU survives restarts).
131
+ */
132
+ export function rememberSpawn(
133
+ map: Readonly<Record<string, LastSpawn>>,
134
+ key: string,
135
+ spawn: LastSpawn,
136
+ cap: number = SPAWN_MEMORY_CAP,
137
+ ): Record<string, LastSpawn> {
138
+ const out: Record<string, LastSpawn> = {};
139
+ for (const [k, v] of Object.entries(map)) if (k !== key) out[k] = v;
140
+ out[key] = spawn;
141
+ const keys = Object.keys(out);
142
+ for (let i = 0; i < keys.length - cap; i++) delete out[keys[i]!];
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * PURE — the palette-usage map after running the action keyed `key` at
148
+ * `nowSec`: count bumps, lastUsed updates, and the key moves to newest
149
+ * (re-inserted last — the rememberSpawn LRU idiom, so JSON round-trips keep the
150
+ * eviction order). Past `cap`, the OLDEST-used entries drop. Blank keys no-op.
151
+ */
152
+ export function recordPaletteUse(
153
+ map: Readonly<Record<string, PaletteUsageEntry>>,
154
+ key: string,
155
+ nowSec: number,
156
+ cap: number = PALETTE_USAGE_CAP,
157
+ ): Record<string, PaletteUsageEntry> {
158
+ if (key.length === 0) return { ...map };
159
+ const out: Record<string, PaletteUsageEntry> = {};
160
+ for (const [k, v] of Object.entries(map)) if (k !== key) out[k] = v;
161
+ out[key] = { count: (map[key]?.count ?? 0) + 1, lastUsed: nowSec };
162
+ const keys = Object.keys(out);
163
+ for (let i = 0; i < keys.length - cap; i++) delete out[keys[i]!];
164
+ return out;
165
+ }
166
+
167
+ /** PURE — a persisted palette-usage value coerced clean: non-object → {},
168
+ * entries with a mistyped/non-finite count or lastUsed drop, order kept,
169
+ * capped from the OLD end (like sanitizeSpawns). */
170
+ function sanitizePaletteUsage(v: unknown): Record<string, PaletteUsageEntry> {
171
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
172
+ const out: Record<string, PaletteUsageEntry> = {};
173
+ for (const [key, raw] of Object.entries(v as Record<string, unknown>)) {
174
+ if (key.length === 0) continue;
175
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
176
+ const o = raw as Record<string, unknown>;
177
+ if (typeof o.count !== "number" || !Number.isFinite(o.count) || o.count < 1) continue;
178
+ if (typeof o.lastUsed !== "number" || !Number.isFinite(o.lastUsed)) continue;
179
+ out[key] = { count: Math.floor(o.count), lastUsed: Math.floor(o.lastUsed) };
180
+ }
181
+ const keys = Object.keys(out);
182
+ for (let i = 0; i < keys.length - PALETTE_USAGE_CAP; i++) delete out[keys[i]!];
183
+ return out;
184
+ }
185
+
186
+ /** PURE — the custom-command recents after running `command`: moved to the
187
+ * front, deduped, capped. Blank commands leave the list unchanged. */
188
+ export function addCustomCommand(
189
+ list: readonly string[],
190
+ command: string,
191
+ cap: number = CUSTOM_COMMANDS_CAP,
192
+ ): string[] {
193
+ const cmd = command.trim();
194
+ if (cmd.length === 0) return [...list];
195
+ return [cmd, ...list.filter((c) => c !== cmd)].slice(0, cap);
196
+ }
197
+
198
+ /** PURE — one persisted spawn coerced to a clean {@link LastSpawn}, or null
199
+ * when any field is missing/mistyped (the whole entry drops). */
200
+ function sanitizeSpawn(v: unknown): LastSpawn | null {
201
+ if (!v || typeof v !== "object" || Array.isArray(v)) return null;
202
+ const o = v as Record<string, unknown>;
203
+ if (typeof o.kind !== "string" || o.kind.length === 0) return null;
204
+ if (typeof o.command !== "string" || o.command.length === 0) return null;
205
+ if (!isSpawnWhere(o.placement)) return null;
206
+ return { kind: o.kind, command: o.command, placement: o.placement };
207
+ }
208
+
209
+ /** PURE — a persisted spawn-memory value coerced clean: non-object → {},
210
+ * malformed entries drop, order kept, capped from the OLD end. */
211
+ function sanitizeSpawns(v: unknown): Record<string, LastSpawn> {
212
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
213
+ const out: Record<string, LastSpawn> = {};
214
+ for (const [key, raw] of Object.entries(v as Record<string, unknown>)) {
215
+ if (key.length === 0) continue;
216
+ const spawn = sanitizeSpawn(raw);
217
+ if (spawn) out[key] = spawn;
218
+ }
219
+ const keys = Object.keys(out);
220
+ for (let i = 0; i < keys.length - SPAWN_MEMORY_CAP; i++) delete out[keys[i]!];
221
+ return out;
222
+ }
223
+
224
+ /** PURE — a persisted string list coerced clean: non-empty, deduped (first
225
+ * wins), capped at `cap`. Anything not a string[] yields []. */
226
+ function sanitizeStringList(v: unknown, cap: number): string[] {
227
+ if (!Array.isArray(v)) return [];
228
+ const out: string[] = [];
229
+ for (const item of v) {
230
+ if (typeof item === "string" && item.length > 0 && !out.includes(item)) out.push(item);
231
+ if (out.length >= cap) break;
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /** PURE — the recents list after opening `dir`: it moves to the front,
237
+ * any earlier occurrence is removed (dedupe), and the tail past `cap` drops.
238
+ * Blank paths are ignored (return the list unchanged). */
239
+ export function addRecentFolder(
240
+ list: readonly string[],
241
+ dir: string,
242
+ cap: number = RECENTS_CAP,
243
+ ): string[] {
244
+ if (dir.length === 0) return [...list];
245
+ return [dir, ...list.filter((d) => d !== dir)].slice(0, cap);
246
+ }
247
+
248
+ /** PURE — a persisted recents value coerced to clean strings: non-empty,
249
+ * deduped (first wins), capped. Anything not a string[] yields []. */
250
+ function sanitizeRecents(v: unknown): string[] {
251
+ return sanitizeStringList(v, RECENTS_CAP);
252
+ }
253
+
254
+ /** The tmux-ide home dir: `TMUX_IDE_HOME` when set, else `~/.tmux-ide`. */
255
+ export function appStateHome(): string {
256
+ return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
257
+ }
258
+
259
+ /** Absolute path to `app-state.json` under {@link appStateHome}. */
260
+ export function appStatePath(): string {
261
+ return join(appStateHome(), "app-state.json");
262
+ }
263
+
264
+ /** A string field that must be non-empty, else `null`. */
265
+ function optString(v: unknown): string | null {
266
+ return typeof v === "string" && v.length > 0 ? v : null;
267
+ }
268
+
269
+ /**
270
+ * PURE — parse a raw JSON string into a fully-populated {@link AppState}. Any
271
+ * missing/mistyped field falls back to its default; invalid JSON yields the
272
+ * defaults. Never throws.
273
+ */
274
+ export function parseAppState(raw: string): AppState {
275
+ let obj: Record<string, unknown>;
276
+ try {
277
+ const parsed = JSON.parse(raw);
278
+ if (!parsed || typeof parsed !== "object") return { ...DEFAULT_APP_STATE };
279
+ obj = parsed as Record<string, unknown>;
280
+ } catch {
281
+ return { ...DEFAULT_APP_STATE };
282
+ }
283
+ return {
284
+ lastTab: isTab(obj.lastTab) ? obj.lastTab : DEFAULT_APP_STATE.lastTab,
285
+ contextSession: optString(obj.contextSession),
286
+ openFile: optString(obj.openFile),
287
+ diffFile: optString(obj.diffFile),
288
+ sidebarW:
289
+ typeof obj.sidebarW === "number"
290
+ ? clampSidebarWidth(obj.sidebarW)
291
+ : DEFAULT_APP_STATE.sidebarW,
292
+ recentFolders: sanitizeRecents(obj.recentFolders),
293
+ lastSpawns: sanitizeSpawns(obj.lastSpawns),
294
+ customCommands: sanitizeStringList(obj.customCommands, CUSTOM_COMMANDS_CAP),
295
+ paletteUsage: sanitizePaletteUsage(obj.paletteUsage),
296
+ filesShowHidden: obj.filesShowHidden === true,
297
+ filesShowIgnored: obj.filesShowIgnored === true,
298
+ };
299
+ }
300
+
301
+ /** PURE — serialize an {@link AppState} to the exact JSON shape we persist
302
+ * (extra runtime keys are dropped; stable key order for tidy diffs). */
303
+ export function serializeAppState(state: AppState): string {
304
+ const clean: AppState = {
305
+ lastTab: isTab(state.lastTab) ? state.lastTab : "home",
306
+ contextSession: optString(state.contextSession),
307
+ openFile: optString(state.openFile),
308
+ diffFile: optString(state.diffFile),
309
+ sidebarW: clampSidebarWidth(state.sidebarW),
310
+ recentFolders: sanitizeRecents(state.recentFolders),
311
+ lastSpawns: sanitizeSpawns(state.lastSpawns),
312
+ customCommands: sanitizeStringList(state.customCommands, CUSTOM_COMMANDS_CAP),
313
+ paletteUsage: sanitizePaletteUsage(state.paletteUsage),
314
+ filesShowHidden: state.filesShowHidden === true,
315
+ filesShowIgnored: state.filesShowIgnored === true,
316
+ };
317
+ return JSON.stringify(clean, null, 2);
318
+ }
319
+
320
+ /** Read the persisted state (one-shot at launch — NOT on the render loop).
321
+ * Missing/unreadable file → defaults. */
322
+ export function loadAppState(): AppState {
323
+ const path = appStatePath();
324
+ if (!existsSync(path)) return { ...DEFAULT_APP_STATE };
325
+ try {
326
+ return parseAppState(readFileSync(path, "utf8"));
327
+ } catch {
328
+ return { ...DEFAULT_APP_STATE };
329
+ }
330
+ }
331
+
332
+ /** Write the state asynchronously (debounced by the caller), creating the home
333
+ * dir if needed. Swallows io errors — persistence is best-effort. */
334
+ export async function saveAppState(state: AppState): Promise<void> {
335
+ const path = appStatePath();
336
+ try {
337
+ mkdirSync(dirname(path), { recursive: true });
338
+ await writeFile(path, serializeAppState(state));
339
+ } catch {
340
+ // best-effort: a failed save must never disturb the running app
341
+ }
342
+ }