tmux-ide 2.6.1 → 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.
- package/README.md +14 -9
- package/bin/cli.js +1013 -516
- package/bin/cli.ts +62 -5
- package/package.json +9 -6
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +34 -6
- package/packages/daemon/src/lib/app-config.ts +93 -2
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/tui/chrome/notify.ts +221 -34
- package/packages/daemon/src/tui/chrome/updater.ts +89 -7
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +15 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
- package/packages/daemon/src/tui/detect/manifest.ts +15 -0
- package/packages/daemon/src/tui/detect/manifests.ts +161 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
- package/packages/daemon/src/tui/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +102 -13
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputCoalescer — the pure core of the input fast path (M21.5).
|
|
3
|
+
*
|
|
4
|
+
* Burst-typed LITERAL characters coalesce into one buffered run per pane and
|
|
5
|
+
* leave as a single `send-keys -H <hex…>` write per flush; named/ctrl keys
|
|
6
|
+
* (Enter, C-c, Up, …) are emitted immediately. THE ordering invariant: any
|
|
7
|
+
* buffered literal run flushes BEFORE a named key — or a literal for a
|
|
8
|
+
* DIFFERENT pane — is emitted, so bytes reach the pane in exactly the order
|
|
9
|
+
* the user produced them.
|
|
10
|
+
*
|
|
11
|
+
* Pure core, io shell: this class owns no timers and does no io. The caller
|
|
12
|
+
* injects `emit` (where flushed actions go — the control client's
|
|
13
|
+
* fire-and-forget write in production) and `schedule` (when a pending literal
|
|
14
|
+
* buffer should auto-flush — a microtask in production, a hand-cranked stub in
|
|
15
|
+
* tests). A microtask flush still happens inside the same macrotask that
|
|
16
|
+
* buffered the keystroke — strictly before the process can read more input —
|
|
17
|
+
* so coalescing adds no perceivable latency; it only merges keystrokes that
|
|
18
|
+
* arrived together.
|
|
19
|
+
*
|
|
20
|
+
* Flushes re-chunk the buffered run under {@link SEND_KEYS_CHUNK_BYTES} so a
|
|
21
|
+
* large paste routed through the same path never produces an oversized
|
|
22
|
+
* control-mode command.
|
|
23
|
+
*/
|
|
24
|
+
import { chunkByBytes } from "./selection.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Max UTF-8 payload bytes per `send-keys -H` write. Every byte travels as its
|
|
28
|
+
* own `xx` argv token, and each token hits tmux's yacc command parser — which
|
|
29
|
+
* is the REAL constraint, measured empirically against tmux 3.6b control mode
|
|
30
|
+
* (M21.5), not a line-length cap:
|
|
31
|
+
*
|
|
32
|
+
* - HARD LIMIT: a 16 KB payload (≈16k tokens) is REJECTED outright with
|
|
33
|
+
* `%error: yacc stack overflow` (yacc's ~10k-entry stack).
|
|
34
|
+
* - PARSE COST is superlinear in tokens: one command's reply takes ~3 ms at
|
|
35
|
+
* 1 KB, ~9 ms at 2 KB, ~56 ms at 4 KB, ~170 ms at 8 KB — big chunks STALL
|
|
36
|
+
* the tmux server (and every interleaved keystroke behind it).
|
|
37
|
+
* - END-TO-END, a 100 KB paste lands byte-perfect in ~246/155/180/233/255/
|
|
38
|
+
* 561/1483 ms at 64/128/256/512/1024/2048/4096-byte chunks — the optimum
|
|
39
|
+
* is ~128–256 B; the pre-M21.5 1024 was past the knee, and RAISING the
|
|
40
|
+
* chunk (the naive read of "fewer commands = faster") is strictly worse.
|
|
41
|
+
*
|
|
42
|
+
* 256 B is chosen: within noise of the throughput optimum, ~1 ms of server
|
|
43
|
+
* parse per command (imperceptible for input interleaving), 5× headroom from
|
|
44
|
+
* nothing, and 40× from the yacc cliff.
|
|
45
|
+
*/
|
|
46
|
+
export const SEND_KEYS_CHUNK_BYTES = 256;
|
|
47
|
+
|
|
48
|
+
/** One flushed input action, ready to become a control-mode write. */
|
|
49
|
+
export type InputAction =
|
|
50
|
+
| { kind: "literal"; pane: string; text: string }
|
|
51
|
+
| { kind: "key"; pane: string; key: string };
|
|
52
|
+
|
|
53
|
+
export class InputCoalescer {
|
|
54
|
+
private pane = "";
|
|
55
|
+
private buf = "";
|
|
56
|
+
private scheduled = false;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly emit: (action: InputAction) => void,
|
|
60
|
+
private readonly schedule: (flush: () => void) => void,
|
|
61
|
+
private readonly maxChunkBytes: number = SEND_KEYS_CHUNK_BYTES,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
/** Buffer literal text for `pane`; a pending run for ANOTHER pane flushes
|
|
65
|
+
* first so cross-pane order is preserved. Schedules an auto-flush once per
|
|
66
|
+
* pending run. */
|
|
67
|
+
literal(pane: string, text: string): void {
|
|
68
|
+
if (!pane || !text) return;
|
|
69
|
+
if (this.buf.length > 0 && this.pane !== pane) this.flush();
|
|
70
|
+
this.pane = pane;
|
|
71
|
+
this.buf += text;
|
|
72
|
+
if (!this.scheduled) {
|
|
73
|
+
this.scheduled = true;
|
|
74
|
+
this.schedule(() => {
|
|
75
|
+
this.scheduled = false;
|
|
76
|
+
this.flush();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Emit a named tmux key (Enter, C-c, Up, …) — pending literals flush first
|
|
82
|
+
* (synchronously) so the key can never overtake buffered characters. */
|
|
83
|
+
key(pane: string, key: string): void {
|
|
84
|
+
if (!pane || !key) return;
|
|
85
|
+
this.flush();
|
|
86
|
+
this.emit({ kind: "key", pane, key });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Drain the pending literal run now (chunked under the byte cap). Also the
|
|
90
|
+
* ordering barrier callers place before reply-matched structural commands. */
|
|
91
|
+
flush(): void {
|
|
92
|
+
if (this.buf.length === 0) return;
|
|
93
|
+
const pane = this.pane;
|
|
94
|
+
const text = this.buf;
|
|
95
|
+
this.buf = "";
|
|
96
|
+
for (const chunk of chunkByBytes(text, this.maxChunkBytes)) {
|
|
97
|
+
this.emit({ kind: "literal", pane, text: chunk });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Buffered-but-unflushed character count (tests/introspection only). */
|
|
102
|
+
pending(): number {
|
|
103
|
+
return this.buf.length;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The right-click context-menu model (M19.2) — PURE so it unit-tests without
|
|
3
|
+
* OpenTUI. app.tsx opens a small overlay at the pointer on a right-button press
|
|
4
|
+
* (SGR button 2); the ITEM DESCRIPTORS per region and all the geometry (dims,
|
|
5
|
+
* on-screen clamp, click hit-test) live here, while the closures that actually
|
|
6
|
+
* attach / kill / split / rename stay in the app (a `run` switch over the
|
|
7
|
+
* item's `id`).
|
|
8
|
+
*
|
|
9
|
+
* The overlay is late-mounted inside a <Show>, so — per the app's mouse
|
|
10
|
+
* landmine laws — it carries NO per-item handlers: clicks are routed centrally
|
|
11
|
+
* by coordinate math against {@link menuItemAt}, exactly the way the surface
|
|
12
|
+
* tab bar and window strip resolve their clicks by x-span math. Keeping that
|
|
13
|
+
* math in one tested place is the whole point.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The surfaces a right-click can target. Each maps to a fixed item list; the
|
|
17
|
+
* concrete payload (session name, file path, pane id) rides in the app's menu
|
|
18
|
+
* state, not here. */
|
|
19
|
+
export type MenuRegion = "session" | "file" | "difffile" | "pane" | "window";
|
|
20
|
+
|
|
21
|
+
/** One menu entry. `id` is what the app dispatches on; `danger` items rearm to
|
|
22
|
+
* a "confirm: y" state instead of firing immediately; `input` items open an
|
|
23
|
+
* inline text line (the string is the prompt) before firing; `children` items
|
|
24
|
+
* open a SUBMENU column beside the row (one nesting level); `checkbox` items
|
|
25
|
+
* carry a live ✓/✗ state the app supplies (the toggle keeps the menu open). */
|
|
26
|
+
export interface MenuItem {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
danger?: boolean;
|
|
30
|
+
input?: string;
|
|
31
|
+
children?: MenuItem[];
|
|
32
|
+
checkbox?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** PURE — the fixed item list for each region. The app supplies the context. */
|
|
36
|
+
export const MENU_ITEMS: Record<MenuRegion, MenuItem[]> = {
|
|
37
|
+
session: [
|
|
38
|
+
{ id: "attach", label: "Attach" },
|
|
39
|
+
{ id: "rename", label: "Rename", input: "rename to" },
|
|
40
|
+
{ id: "kill", label: "Kill session", danger: true },
|
|
41
|
+
],
|
|
42
|
+
file: [
|
|
43
|
+
{ id: "open", label: "Open" },
|
|
44
|
+
{ id: "newfile", label: "New file", input: "new file" },
|
|
45
|
+
{ id: "rename", label: "Rename", input: "rename to" },
|
|
46
|
+
{ id: "delete", label: "Delete", danger: true },
|
|
47
|
+
],
|
|
48
|
+
difffile: [
|
|
49
|
+
{ id: "open", label: "Open in editor" },
|
|
50
|
+
{ id: "copypath", label: "Copy path" },
|
|
51
|
+
],
|
|
52
|
+
pane: [
|
|
53
|
+
{ id: "split-h", label: "Split horizontal" },
|
|
54
|
+
{ id: "split-v", label: "Split vertical" },
|
|
55
|
+
{ id: "zoom", label: "Zoom toggle" },
|
|
56
|
+
{ id: "swap-next", label: "Swap with next" },
|
|
57
|
+
{ id: "break", label: "Break to window" },
|
|
58
|
+
{ id: "rotate", label: "Rotate panes" },
|
|
59
|
+
{
|
|
60
|
+
id: "layouts",
|
|
61
|
+
label: "Layouts",
|
|
62
|
+
children: [
|
|
63
|
+
{ id: "layout:even-horizontal", label: "even-horizontal" },
|
|
64
|
+
{ id: "layout:even-vertical", label: "even-vertical" },
|
|
65
|
+
{ id: "layout:main-horizontal", label: "main-horizontal" },
|
|
66
|
+
{ id: "layout:main-vertical", label: "main-vertical" },
|
|
67
|
+
{ id: "layout:tiled", label: "tiled" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{ id: "sync-toggle", label: "Synchronize panes", checkbox: true },
|
|
71
|
+
{ id: "kill", label: "Kill pane", danger: true },
|
|
72
|
+
],
|
|
73
|
+
window: [
|
|
74
|
+
{ id: "new", label: "New window" },
|
|
75
|
+
{ id: "rename", label: "Rename window", input: "rename to" },
|
|
76
|
+
{ id: "kill", label: "Kill window", danger: true },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** PURE — the pane menu's item list for a concrete pane (M22.9). App-mouse
|
|
81
|
+
* panes (the app turned mouse reporting on — presses are forwarded, so a drag
|
|
82
|
+
* can't start a selection) get a leading "Select text…" verb that pauses
|
|
83
|
+
* forwarding for that pane; while its select mode is active the entry flips to
|
|
84
|
+
* the exit verb. Ordinary panes keep the fixed list untouched. */
|
|
85
|
+
export function paneMenuItems(appMouse: boolean, selectModeOn: boolean): MenuItem[] {
|
|
86
|
+
if (!appMouse) return MENU_ITEMS.pane;
|
|
87
|
+
const entry: MenuItem = selectModeOn
|
|
88
|
+
? { id: "select-text-off", label: "Stop selecting" }
|
|
89
|
+
: { id: "select-text", label: "Select text…" };
|
|
90
|
+
return [entry, ...MENU_ITEMS.pane];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The overlay's border (1) + horizontal padding (1 each side). The header row
|
|
94
|
+
* and every item row live INSIDE this frame. */
|
|
95
|
+
const BORDER = 1;
|
|
96
|
+
const PAD = 1;
|
|
97
|
+
/** A minimum inner text width so a short menu still reads as a panel. */
|
|
98
|
+
const MIN_INNER = 16;
|
|
99
|
+
/** The suffix a destructive item rearms to (label + this). Reserved in the
|
|
100
|
+
* width so "confirm: y" is never clipped — the render appends the SAME string. */
|
|
101
|
+
export const CONFIRM_SUFFIX = " confirm: y";
|
|
102
|
+
/** The caret a `children` item renders flush-right ("› Layouts ▸"): a
|
|
103
|
+
* leading space + the glyph, reserved in the width so a submenu row's caret is
|
|
104
|
+
* never clipped. The base "› " prefix (2) already sits on every row. */
|
|
105
|
+
export const SUBMENU_CARET = " ▸";
|
|
106
|
+
|
|
107
|
+
/** PURE — the overlay's cell dimensions for `title` + `items`. Width fits the
|
|
108
|
+
* longest of the title and item rows (each item reserves 2 cells for the "› "
|
|
109
|
+
* selection prefix); height is the top border + header + items + bottom border.
|
|
110
|
+
* Item labels leave slack for the longer "confirm: y" / input renderings, which
|
|
111
|
+
* never need to exceed the base width in practice. */
|
|
112
|
+
export function menuDims(title: string, items: MenuItem[]): { width: number; height: number } {
|
|
113
|
+
const inner = Math.max(
|
|
114
|
+
MIN_INNER,
|
|
115
|
+
title.length,
|
|
116
|
+
// A danger item's rearmed "label confirm: y" must fit; a `children` item
|
|
117
|
+
// reserves the "› " prefix AND the flush-right submenu caret; a `checkbox`
|
|
118
|
+
// item's ✓/✗ mark is the same width as the "› " prefix it replaces. Others
|
|
119
|
+
// reserve 2 cells for the "› " selection prefix.
|
|
120
|
+
...items.map((it) => {
|
|
121
|
+
if (it.danger) return it.label.length + CONFIRM_SUFFIX.length;
|
|
122
|
+
if (it.children) return it.label.length + 2 + SUBMENU_CARET.length;
|
|
123
|
+
return it.label.length + 2;
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
return { width: inner + PAD * 2 + BORDER * 2, height: items.length + 3 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** PURE — the on-screen top-left for a menu of `width`×`height` opened at
|
|
130
|
+
* (`x`,`y`), clamped so the whole box stays on a `screenW`×`screenH` grid. */
|
|
131
|
+
export function clampMenuPos(
|
|
132
|
+
x: number,
|
|
133
|
+
y: number,
|
|
134
|
+
width: number,
|
|
135
|
+
height: number,
|
|
136
|
+
screenW: number,
|
|
137
|
+
screenH: number,
|
|
138
|
+
): { left: number; top: number } {
|
|
139
|
+
const left = Math.max(0, Math.min(x, screenW - width));
|
|
140
|
+
const top = Math.max(0, Math.min(y, screenH - height));
|
|
141
|
+
return { left, top };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The placed overlay's geometry — what the hit-tests need. */
|
|
145
|
+
export interface MenuGeom {
|
|
146
|
+
left: number;
|
|
147
|
+
top: number;
|
|
148
|
+
width: number;
|
|
149
|
+
height: number;
|
|
150
|
+
itemCount: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** PURE — the item index under (`x`,`y`), or -1 when the point is not on an item
|
|
154
|
+
* row (the border, the header, or outside the box). Item `i` renders one row
|
|
155
|
+
* below the top border + header, i.e. at screen y = top + 2 + i. */
|
|
156
|
+
export function menuItemAt(m: MenuGeom, x: number, y: number): number {
|
|
157
|
+
if (x < m.left || x >= m.left + m.width) return -1;
|
|
158
|
+
const i = y - (m.top + BORDER + 1);
|
|
159
|
+
return i >= 0 && i < m.itemCount ? i : -1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** PURE — is (`x`,`y`) anywhere inside the overlay box (item, header, or border)?
|
|
163
|
+
* A down INSIDE that isn't on an item is a no-op (menu stays); a down OUTSIDE
|
|
164
|
+
* closes the menu. */
|
|
165
|
+
export function pointInMenu(m: MenuGeom, x: number, y: number): boolean {
|
|
166
|
+
return x >= m.left && x < m.left + m.width && y >= m.top && y < m.top + m.height;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** PURE — the on-screen top-left for the SUBMENU column that opens beside the
|
|
170
|
+
* `parent` item at `parentItemIndex`. It opens to the RIGHT of the parent, its
|
|
171
|
+
* top border aligned with the parent item's row, then clamps fully on-screen
|
|
172
|
+
* (sliding left over the parent if the right edge would overflow — acceptable
|
|
173
|
+
* since the keyboard/coordinate router owns whichever column is focused). */
|
|
174
|
+
export function submenuPos(
|
|
175
|
+
parent: MenuGeom,
|
|
176
|
+
parentItemIndex: number,
|
|
177
|
+
width: number,
|
|
178
|
+
height: number,
|
|
179
|
+
screenW: number,
|
|
180
|
+
screenH: number,
|
|
181
|
+
): { left: number; top: number } {
|
|
182
|
+
const x = parent.left + parent.width;
|
|
183
|
+
// The parent item renders at y = parent.top + BORDER + header(1) + index; put
|
|
184
|
+
// the submenu's top border on that same row so the columns read as connected.
|
|
185
|
+
const y = parent.top + BORDER + 1 + parentItemIndex;
|
|
186
|
+
return clampMenuPos(x, y, width, height, screenW, screenH);
|
|
187
|
+
}
|