tmux-ide 2.6.0 → 2.6.1
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/bin/cli.js +13 -5
- package/bin/cli.ts +1 -1
- package/bunfig.toml +4 -0
- package/package.json +11 -3
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/src/agent-explain.ts +270 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +336 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +195 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +428 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +113 -0
- package/packages/daemon/src/tui/detect/classify.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
- package/packages/daemon/src/tui/detect/manifest.ts +184 -0
- package/packages/daemon/src/tui/detect/manifests.ts +226 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +70 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +11 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +73 -0
- package/packages/daemon/src/tui/team/sessions.ts +344 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/postinstall.js +26 -2
|
@@ -0,0 +1,1521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The team TUI — a cockpit over every tmux session.
|
|
3
|
+
*
|
|
4
|
+
* An app shell: a persistent left SIDEBAR lists every registered project
|
|
5
|
+
* (including ones with no running session = "stopped"), and a MAIN pane shows
|
|
6
|
+
* the selected ("active") project's detail — its live tmux sessions plus a
|
|
7
|
+
* read-only preview of the active session's pane. Unregistered live sessions
|
|
8
|
+
* surface as ad-hoc project rows in the sidebar so nothing is hidden. Runs
|
|
9
|
+
* under bun (JSX via the @opentui/solid preload) and is spawned by
|
|
10
|
+
* `tmux-ide team`.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { parseArgs } from "node:util";
|
|
14
|
+
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
|
|
15
|
+
import { RGBA, TextAttributes, type MouseEvent } from "@opentui/core";
|
|
16
|
+
import { createSignal, createEffect, onMount, onCleanup, For, Show } from "solid-js";
|
|
17
|
+
import {
|
|
18
|
+
capturePane,
|
|
19
|
+
createDetachedSession,
|
|
20
|
+
hasSession,
|
|
21
|
+
killSession,
|
|
22
|
+
runTmux,
|
|
23
|
+
setSessionEnvironment,
|
|
24
|
+
splitPane,
|
|
25
|
+
} from "@tmux-ide/tmux-bridge";
|
|
26
|
+
import { createTheme } from "../../widgets/lib/theme.ts";
|
|
27
|
+
import { getAppConfig } from "../../lib/app-config.ts";
|
|
28
|
+
import { adoptSession } from "../chrome/statusline.ts";
|
|
29
|
+
import { previewLines } from "./preview.ts";
|
|
30
|
+
import { type TeamSession, type TeamWindow } from "./sessions.ts";
|
|
31
|
+
import { listTeamProjects, type TeamProject } from "./projects.ts";
|
|
32
|
+
import { registerProject, unregisterProject } from "../../lib/project-registry.ts";
|
|
33
|
+
import { createStatusTracker, type AgentStatus } from "../detect/classify.ts";
|
|
34
|
+
import { nextInput, suggestSessionName } from "./input.ts";
|
|
35
|
+
import { fuzzyFilter } from "./fuzzy.ts";
|
|
36
|
+
import { clampIndex, wrapIndex } from "./nav.ts";
|
|
37
|
+
import { type ActionId, loadKeymap, resolveAction } from "./keymap.ts";
|
|
38
|
+
import { matchGrammar } from "../../widgets/lib/grammar.ts";
|
|
39
|
+
import { HelpOverlay, type WidgetKey } from "../../widgets/lib/help-overlay.tsx";
|
|
40
|
+
import { isDoubleClick, type ClickRecord } from "./mouse.ts";
|
|
41
|
+
import { treeNodes, findCursor } from "./tree.ts";
|
|
42
|
+
import {
|
|
43
|
+
fleetRollup,
|
|
44
|
+
rollupChips,
|
|
45
|
+
isFleetEmpty,
|
|
46
|
+
emptyFleetActions,
|
|
47
|
+
panelForKey,
|
|
48
|
+
panelHints,
|
|
49
|
+
homeFooterHints,
|
|
50
|
+
pickerFooterHints,
|
|
51
|
+
} from "./home.ts";
|
|
52
|
+
import { PANEL_POPUPS, panelPopupCli, type PanelPopup } from "../chrome/panels.ts";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The team app's OWN keys, shown under the shared grammar in the `?` overlay —
|
|
56
|
+
* the configurable keymap actions minus the universal verbs (nav/enter/filter/
|
|
57
|
+
* help/quit) that the grammar already documents. Derived from the live keymap
|
|
58
|
+
* so a rebind in `~/.tmux-ide/team-keys.json` re-labels the overlay too.
|
|
59
|
+
*/
|
|
60
|
+
const TEAM_WIDGET_ACTIONS: ActionId[] = [
|
|
61
|
+
"launch",
|
|
62
|
+
"new",
|
|
63
|
+
"rename",
|
|
64
|
+
"split",
|
|
65
|
+
"register",
|
|
66
|
+
"unregister",
|
|
67
|
+
"kill",
|
|
68
|
+
"refresh",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// Theme pass-through: `bin/cli.ts` forwards the project's ide.yml `theme` as
|
|
72
|
+
// `--theme=<json>`. A malformed value must never crash the cockpit, so the
|
|
73
|
+
// parse is guarded and falls back to the default theme.
|
|
74
|
+
const { values: argv } = parseArgs({ options: { theme: { type: "string" } } });
|
|
75
|
+
function parseThemeArg(raw: string | undefined): Record<string, string> | undefined {
|
|
76
|
+
if (!raw) return undefined;
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const themeConfig = parseThemeArg(argv.theme);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the tmux client the popup was invoked on, from INSIDE the popup.
|
|
87
|
+
*
|
|
88
|
+
* `display-message -p '#{client_name}'` uses the popup's inherited `$TMUX` /
|
|
89
|
+
* `$TMUX_PANE` to identify the invoking client — it returns the right client
|
|
90
|
+
* even with several attached to the same session (verified live on tmux 3.6).
|
|
91
|
+
* Must be called BEFORE `$TMUX` is cleared. Returns null on any failure, in
|
|
92
|
+
* which case the switcher falls back to a client-less `switch-client` (which
|
|
93
|
+
* still targets the current client when `$TMUX` is intact).
|
|
94
|
+
*/
|
|
95
|
+
function resolvePopupClient(): string | null {
|
|
96
|
+
try {
|
|
97
|
+
const name = runTmux(["display-message", "-p", "#{client_name}"]).toString().trim();
|
|
98
|
+
return name.length > 0 ? name : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Fixed sidebar width in columns — narrow enough to leave the detail pane room
|
|
105
|
+
// on an 80-col terminal, wide enough for a project name + session count.
|
|
106
|
+
const SIDEBAR_WIDTH = 34;
|
|
107
|
+
|
|
108
|
+
function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
|
|
109
|
+
return RGBA.fromInts(c.r, c.g, c.b, c.a);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const STATUS: Record<AgentStatus, { glyph: string; label: string }> = {
|
|
113
|
+
blocked: { glyph: "●", label: "blocked" },
|
|
114
|
+
working: { glyph: "●", label: "working" },
|
|
115
|
+
done: { glyph: "●", label: "done" },
|
|
116
|
+
idle: { glyph: "●", label: "idle" },
|
|
117
|
+
unknown: { glyph: "·", label: "unknown" },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Which inline text prompt is open (they all submit an action). */
|
|
121
|
+
type PromptKind = "register" | "newSession" | "rename";
|
|
122
|
+
|
|
123
|
+
/** Searchable label for the sidebar fuzzy filter — the project name. */
|
|
124
|
+
function projectName(project: TeamProject): string {
|
|
125
|
+
return project.name;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Prefix shown on the inline prompt line for each prompt kind. */
|
|
129
|
+
function promptLabel(kind: PromptKind): string {
|
|
130
|
+
if (kind === "register") return "register dir:";
|
|
131
|
+
if (kind === "newSession") return "new session:";
|
|
132
|
+
return "rename to:";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
render(() => {
|
|
136
|
+
const theme = createTheme(themeConfig, getAppConfig().theme);
|
|
137
|
+
// One tracker persists across refreshes so the cross-tick `done` state
|
|
138
|
+
// (working→idle without being viewed) can be inferred.
|
|
139
|
+
const tracker = createStatusTracker();
|
|
140
|
+
// The dir the user actually ran `tmux-ide` from. The CLI spawns this widget
|
|
141
|
+
// with cwd set to the repo root (for the bun JSX preload), so it forwards the
|
|
142
|
+
// real cwd via env; fall back to process.cwd() when run directly.
|
|
143
|
+
const invokeCwd = process.env.TMUX_IDE_CWD ?? process.cwd();
|
|
144
|
+
// Two ways the cockpit runs inside a tmux `display-popup` over a client:
|
|
145
|
+
// - PICKER mode (`M-p`): `TMUX_IDE_PICKER_CLIENT` present → the compact
|
|
146
|
+
// single-column switcher.
|
|
147
|
+
// - HOME-POPUP mode (`M-h`): `TMUX_IDE_POPUP_CLIENT` present → the FULL
|
|
148
|
+
// two-column home, floated over the session instead of owning the terminal.
|
|
149
|
+
// Both share "SWITCH mode": Enter `switch-client`s the invoking client + exits
|
|
150
|
+
// (closing the popup) instead of attaching in place, and a bare Esc closes.
|
|
151
|
+
//
|
|
152
|
+
// Each env var's PRESENCE flips its mode on; its VALUE is an optional explicit
|
|
153
|
+
// client name. When empty we resolve the invoking client ourselves from inside
|
|
154
|
+
// the popup — `display-message -p '#{client_name}'` returns it correctly
|
|
155
|
+
// (verified live on tmux 3.6), unlike a `#{client_name}` in the bind command,
|
|
156
|
+
// which does not format-expand. We must resolve BEFORE clearing $TMUX
|
|
157
|
+
// (display-message needs the popup's tmux env to know which client it's on).
|
|
158
|
+
// Like host mode we then clear $TMUX so fleet queries hit the DEFAULT server;
|
|
159
|
+
// the explicit `-c <client>` on switch-client keeps the switch correct.
|
|
160
|
+
const pickerClientEnv = process.env.TMUX_IDE_PICKER_CLIENT ?? null;
|
|
161
|
+
const popupClientEnv = process.env.TMUX_IDE_POPUP_CLIENT ?? null;
|
|
162
|
+
const pickerMode = pickerClientEnv !== null;
|
|
163
|
+
// Home-popup only when it's not also picker (picker wins if both are somehow set).
|
|
164
|
+
const popupHomeMode = popupClientEnv !== null && !pickerMode;
|
|
165
|
+
const switchMode = pickerMode || popupHomeMode;
|
|
166
|
+
let switchClient: string | null = null;
|
|
167
|
+
if (switchMode) {
|
|
168
|
+
const explicit = pickerClientEnv ?? popupClientEnv ?? "";
|
|
169
|
+
switchClient = explicit.length > 0 ? explicit : resolvePopupClient();
|
|
170
|
+
delete process.env.TMUX;
|
|
171
|
+
}
|
|
172
|
+
// Only the picker renders the compact single-column layout; home-popup keeps
|
|
173
|
+
// the full two-column app (just with switch-on-Enter behaviour).
|
|
174
|
+
const compactMode = pickerMode;
|
|
175
|
+
// Shared with the tmux chrome via the app theme tokens (blocked red, working
|
|
176
|
+
// amber, done blue, idle green) so the cockpit matches the status bar palette.
|
|
177
|
+
const statusColor: Record<AgentStatus, RGBA> = {
|
|
178
|
+
blocked: toRGBA(theme.statusBlocked),
|
|
179
|
+
working: toRGBA(theme.statusWorking),
|
|
180
|
+
done: toRGBA(theme.statusDone),
|
|
181
|
+
idle: toRGBA(theme.statusIdle),
|
|
182
|
+
unknown: toRGBA(theme.statusUnknown),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Central keymap (defaults + optional ~/.tmux-ide/team-keys.json overrides),
|
|
186
|
+
// resolved once at startup — the key handler routes through it.
|
|
187
|
+
const keymap = loadKeymap();
|
|
188
|
+
|
|
189
|
+
// The OpenTUI renderer, captured once so the attach/launch paths can hand the
|
|
190
|
+
// terminal to a full-screen child and take it back afterwards.
|
|
191
|
+
const renderer = useRenderer();
|
|
192
|
+
|
|
193
|
+
const [projects, setProjects] = createSignal<TeamProject[]>(listTeamProjects(tracker));
|
|
194
|
+
// The two shell cursors: the active PROJECT (index into the visible project
|
|
195
|
+
// list — filtered while filtering, else all) and the active SESSION (index
|
|
196
|
+
// into the active project's sessions, default 0).
|
|
197
|
+
const [activeProject, setActiveProject] = createSignal(0);
|
|
198
|
+
const [activeSession, setActiveSession] = createSignal(0);
|
|
199
|
+
// Picker-only third cursor tier: which WINDOW (tab) of the active session is
|
|
200
|
+
// selected. `-1` = the session row itself (no window), `0..n-1` = a window
|
|
201
|
+
// row. Standalone never sets it past -1 (its window lines are read-only).
|
|
202
|
+
const [activeWindow, setActiveWindow] = createSignal(-1);
|
|
203
|
+
// Whether the `?` keybindings overlay is showing.
|
|
204
|
+
const [helpOpen, setHelpOpen] = createSignal(false);
|
|
205
|
+
// The single inline text prompt (register dir / new session / rename). null =
|
|
206
|
+
// no prompt open. Its submit action switches on `kind`.
|
|
207
|
+
const [prompt, setPrompt] = createSignal<{ kind: PromptKind; value: string } | null>(null);
|
|
208
|
+
// Target captured when the new-session / rename prompt opens, so the async
|
|
209
|
+
// submit stays correct even if the 2s refresh moves the selection.
|
|
210
|
+
const [newSessionDir, setNewSessionDir] = createSignal(invokeCwd);
|
|
211
|
+
const [renameTarget, setRenameTarget] = createSignal("");
|
|
212
|
+
// Pending destructive-action confirmation (e.g. kill); intercepts y/n.
|
|
213
|
+
const [confirm, setConfirm] = createSignal<{ message: string; onYes: () => void } | null>(null);
|
|
214
|
+
// Transient status line (errors / confirmations); lingers until next action.
|
|
215
|
+
const [message, setMessage] = createSignal("");
|
|
216
|
+
// Quick-jump fuzzy filter (`/`): narrows the visible SIDEBAR projects as you type.
|
|
217
|
+
const [filterMode, setFilterMode] = createSignal(false);
|
|
218
|
+
const [filterQuery, setFilterQuery] = createSignal("");
|
|
219
|
+
// Live preview of the active session's active pane (read-only mirror).
|
|
220
|
+
const dimensions = useTerminalDimensions();
|
|
221
|
+
const [preview, setPreview] = createSignal<string[]>([]);
|
|
222
|
+
const [previewTitle, setPreviewTitle] = createSignal("");
|
|
223
|
+
|
|
224
|
+
/** The sidebar's project list: fuzzy-filtered while filtering, else all. */
|
|
225
|
+
const visibleProjects = () =>
|
|
226
|
+
filterMode()
|
|
227
|
+
? fuzzyFilter(filterQuery(), projects(), projectName).map((m) => m.item)
|
|
228
|
+
: projects();
|
|
229
|
+
|
|
230
|
+
/** The active project (into the visible list), or undefined when empty. */
|
|
231
|
+
const activeProj = (): TeamProject | undefined => visibleProjects()[activeProject()];
|
|
232
|
+
/** The active session within the active project, or undefined when none. */
|
|
233
|
+
const activeSess = (): TeamSession | undefined => activeProj()?.sessions[activeSession()];
|
|
234
|
+
/** The selected window of the active session (picker only), or undefined. */
|
|
235
|
+
const activeWin = (): TeamWindow | undefined => {
|
|
236
|
+
const wi = activeWindow();
|
|
237
|
+
return wi >= 0 ? activeSess()?.windowList[wi] : undefined;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* PICKER navigation is a single flat cursor over a THREE-tier row union:
|
|
242
|
+
* every project row, the active project's session rows, and the active
|
|
243
|
+
* session's window rows. This builds that ordered node list — the cursor is
|
|
244
|
+
* the `(activeProject, activeSession, activeWindow)` triple, where `si:-1`
|
|
245
|
+
* marks a project row and `wi:-1` a session (or project) row.
|
|
246
|
+
*/
|
|
247
|
+
function pickerNodes(): Array<{ pi: number; si: number; wi: number }> {
|
|
248
|
+
return treeNodes(visibleProjects(), activeProject(), activeSession());
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Move the picker's flat cursor by `delta`, wrapping across the row union. */
|
|
252
|
+
function movePicker(delta: number) {
|
|
253
|
+
const nodes = pickerNodes();
|
|
254
|
+
if (nodes.length === 0) return;
|
|
255
|
+
const cur = findCursor(nodes, {
|
|
256
|
+
pi: activeProject(),
|
|
257
|
+
si: activeSession(),
|
|
258
|
+
wi: activeWindow(),
|
|
259
|
+
});
|
|
260
|
+
const next = nodes[wrapIndex(cur >= 0 ? cur : 0, delta, nodes.length)]!;
|
|
261
|
+
setActiveProject(next.pi);
|
|
262
|
+
setActiveSession(next.si);
|
|
263
|
+
setActiveWindow(next.wi);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function refresh(viewed?: string) {
|
|
267
|
+
const next = listTeamProjects(tracker, viewed ? { viewed } : {});
|
|
268
|
+
setProjects(next);
|
|
269
|
+
// Clamp both cursors against the freshly-loaded lists so neither dangles.
|
|
270
|
+
const vis = filterMode()
|
|
271
|
+
? fuzzyFilter(filterQuery(), next, projectName).map((m) => m.item)
|
|
272
|
+
: next;
|
|
273
|
+
const pi = clampIndex(activeProject(), vis.length);
|
|
274
|
+
setActiveProject(pi);
|
|
275
|
+
const si = clampIndex(activeSession(), vis[pi]?.sessions.length ?? 0);
|
|
276
|
+
setActiveSession(activeSession() < 0 ? -1 : si);
|
|
277
|
+
// Keep the window cursor valid: drop to the session row (-1) when it points
|
|
278
|
+
// past the (possibly shrunk) window list, so it never dangles after a tick.
|
|
279
|
+
const winCount = vis[pi]?.sessions[si]?.windowList.length ?? 0;
|
|
280
|
+
setActiveWindow((w) => (w >= 0 && w < winCount ? w : -1));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
onMount(() => {
|
|
284
|
+
const timer = setInterval(refresh, 2000);
|
|
285
|
+
onCleanup(() => clearInterval(timer));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/** Select a project by its index in the visible list, resetting the session cursor. */
|
|
289
|
+
function selectProject(index: number) {
|
|
290
|
+
setActiveProject(index);
|
|
291
|
+
setActiveSession(0);
|
|
292
|
+
setActiveWindow(-1);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Which tmux session (if any) the preview should mirror for the selection. */
|
|
296
|
+
function previewTarget(): string | null {
|
|
297
|
+
const proj = activeProj();
|
|
298
|
+
if (!proj) return null;
|
|
299
|
+
const sess = activeSess() ?? (proj.running ? proj.sessions[0] : undefined);
|
|
300
|
+
return sess?.name ?? null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Capture the active session's active pane and shape it to the preview box. */
|
|
304
|
+
function updatePreview() {
|
|
305
|
+
// The compact picker layout has no preview box (it's a transient popup), so
|
|
306
|
+
// skip the capture-pane work entirely (it runs on every selection and every
|
|
307
|
+
// 2s refresh).
|
|
308
|
+
if (compactMode) return;
|
|
309
|
+
const target = previewTarget();
|
|
310
|
+
if (!target) {
|
|
311
|
+
setPreview([]);
|
|
312
|
+
setPreviewTitle("");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const dims = dimensions();
|
|
316
|
+
const budget = Math.max(5, dims.height - 10);
|
|
317
|
+
const width = Math.max(20, dims.width - SIDEBAR_WIDTH - 6);
|
|
318
|
+
try {
|
|
319
|
+
const raw = capturePane(target, { lines: budget });
|
|
320
|
+
setPreview(previewLines(raw, budget, width));
|
|
321
|
+
setPreviewTitle(target);
|
|
322
|
+
} catch {
|
|
323
|
+
setPreview([]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Keep the preview live: reruns on the active project/session, filter state,
|
|
328
|
+
// terminal resize, and the 2s refresh (which replaces projects() each tick).
|
|
329
|
+
createEffect(updatePreview);
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Hand the host terminal to a full-screen child process (a `tmux attach`, or
|
|
333
|
+
* a nested `tmux-ide` launch) and take it back when the child returns.
|
|
334
|
+
*
|
|
335
|
+
* `renderer.suspend()` / `renderer.resume()` are OpenTUI 0.1.88's built-in
|
|
336
|
+
* pair for exactly this: `suspend()` stops the render loop, disables
|
|
337
|
+
* mouse/raw mode, detaches the stdin listener and calls the native
|
|
338
|
+
* `suspendRenderer` (which restores the host terminal — leaves the alt-screen
|
|
339
|
+
* and shows the cursor); `resume()` re-enables raw mode + the stdin listener,
|
|
340
|
+
* calls `resumeRenderer` (re-enters the alt-screen), clears the buffer and
|
|
341
|
+
* restarts the render loop. So the cockpit survives the hand-off and repaints
|
|
342
|
+
* itself once the child exits — no `process.exit`. `finally` guarantees we
|
|
343
|
+
* always resume even if the child throws.
|
|
344
|
+
*/
|
|
345
|
+
function withSuspendedTerminal(fn: () => void) {
|
|
346
|
+
renderer.suspend();
|
|
347
|
+
try {
|
|
348
|
+
fn();
|
|
349
|
+
} finally {
|
|
350
|
+
renderer.resume();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Attach the terminal to a session by name; returns to the cockpit after the
|
|
356
|
+
* user detaches (prefix+d) rather than exiting. A missing session or a normal
|
|
357
|
+
* detach both just return from `execFileSync`, so both land back here.
|
|
358
|
+
*/
|
|
359
|
+
function attachSessionName(name: string) {
|
|
360
|
+
withSuspendedTerminal(() => {
|
|
361
|
+
try {
|
|
362
|
+
execFileSync("tmux", ["attach", "-t", name], { stdio: "inherit" });
|
|
363
|
+
} catch {
|
|
364
|
+
// detached or session gone — fall through
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
// Back in the cockpit: acknowledge the session we just viewed (clears any
|
|
368
|
+
// pending `done` for its panes) and repaint from fresh state.
|
|
369
|
+
refresh(name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Best-effort: adopt a cockpit-created session into the native chrome (status
|
|
374
|
+
* bar + switcher popup + shared updater). Chrome is optional — a failure here
|
|
375
|
+
* must never block session creation.
|
|
376
|
+
*/
|
|
377
|
+
function bestEffortAdopt(session: string) {
|
|
378
|
+
try {
|
|
379
|
+
adoptSession(session);
|
|
380
|
+
} catch {
|
|
381
|
+
// chrome is optional
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Open a widget PANEL (explorer / changes / config) from the home screen.
|
|
387
|
+
*
|
|
388
|
+
* The panel lands on the SELECTED project's dir when a project row is active,
|
|
389
|
+
* else the invoke cwd — so the explorer/changes/config open on whatever you're
|
|
390
|
+
* looking at. Two hand-offs, chosen by whether we're inside tmux:
|
|
391
|
+
*
|
|
392
|
+
* - INSIDE tmux: float the widget as a `display-popup -E` over the cockpit
|
|
393
|
+
* pane — identical to the root-table panel binds (`M-e`/`M-g`/`M-,`). The
|
|
394
|
+
* call blocks until the popup closes, then the cockpit repaints underneath.
|
|
395
|
+
* - OUTSIDE tmux (bare terminal): the cockpit owns the whole screen, so we
|
|
396
|
+
* suspend the renderer and run the widget full-screen IN PLACE via
|
|
397
|
+
* `tmux-ide popup <widget>` (which bun-spawns the widget with stdio
|
|
398
|
+
* inherited), resuming when it exits (esc/q). Same suspend/resume the
|
|
399
|
+
* attach path uses.
|
|
400
|
+
*/
|
|
401
|
+
function openPanel(widget: PanelPopup["widget"]) {
|
|
402
|
+
const panel = PANEL_POPUPS.find((p) => p.widget === widget);
|
|
403
|
+
if (!panel) return;
|
|
404
|
+
const dir = activeProj()?.dir ?? invokeCwd;
|
|
405
|
+
if (process.env.TMUX) {
|
|
406
|
+
try {
|
|
407
|
+
runTmux([
|
|
408
|
+
"display-popup",
|
|
409
|
+
"-E",
|
|
410
|
+
"-d",
|
|
411
|
+
dir,
|
|
412
|
+
"-w",
|
|
413
|
+
panel.width,
|
|
414
|
+
"-h",
|
|
415
|
+
panel.height,
|
|
416
|
+
panelPopupCli(widget),
|
|
417
|
+
]);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
420
|
+
}
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
withSuspendedTerminal(() => {
|
|
424
|
+
try {
|
|
425
|
+
execFileSync("tmux-ide", ["popup", widget], { cwd: dir, stdio: "inherit" });
|
|
426
|
+
} catch {
|
|
427
|
+
// widget exited nonzero or the hand-off failed — resume regardless
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
refresh();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* SWITCH MODE (picker `M-p` or home-popup `M-h`): `switch-client` the invoking
|
|
435
|
+
* client to `sessionName`, then exit so tmux closes the popup. We pass
|
|
436
|
+
* `-c <switchClient>` explicitly — the one incantation that works from the
|
|
437
|
+
* popup regardless of `$TMUX` state (verified live). On failure keep the popup
|
|
438
|
+
* open with the error on the status line rather than exiting into a broken state.
|
|
439
|
+
*/
|
|
440
|
+
function pickerSwitch(sessionName: string) {
|
|
441
|
+
try {
|
|
442
|
+
const args = ["switch-client"];
|
|
443
|
+
if (switchClient) args.push("-c", switchClient);
|
|
444
|
+
args.push("-t", sessionName);
|
|
445
|
+
runTmux(args);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
process.exit(0);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* PICKER MODE: bring a stopped project up detached, then switch to it and
|
|
455
|
+
* exit. An `ide.yml` project gets its full layout via `launch(dir, { attach:
|
|
456
|
+
* false })`; a plain project just needs a bare detached session. Mirrors
|
|
457
|
+
* ends in `pickerSwitch` (no main pane to drive). An `ide.yml` launch adopts
|
|
458
|
+
* itself; a bare detached session is adopted here.
|
|
459
|
+
*/
|
|
460
|
+
function pickerLaunchAndSwitch(project: TeamProject) {
|
|
461
|
+
// const-capture so the non-null narrowing survives into the closure below
|
|
462
|
+
const dir = project.dir;
|
|
463
|
+
if (!dir) return;
|
|
464
|
+
if (project.hasIdeYml) {
|
|
465
|
+
import("../../launch.ts")
|
|
466
|
+
.then(({ launch }) => launch(dir, { attach: false }))
|
|
467
|
+
.then(() => pickerSwitch(project.name))
|
|
468
|
+
.catch((e) => setMessage(String((e as { message?: string })?.message ?? e)));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
createDetachedSession(project.name, dir);
|
|
473
|
+
// Flag cockpit-created sessions so agents inside can detect tmux-ide.
|
|
474
|
+
try {
|
|
475
|
+
setSessionEnvironment(project.name, "TMUX_IDE", "1");
|
|
476
|
+
} catch {}
|
|
477
|
+
bestEffortAdopt(project.name);
|
|
478
|
+
} catch (e) {
|
|
479
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
pickerSwitch(project.name);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* PICKER MODE enter: switch to the active project's live session, or launch
|
|
487
|
+
* the stopped project and switch to it. No preview / main pane / suspend —
|
|
488
|
+
* the popup only ever ends in a `switch-client` + exit.
|
|
489
|
+
*/
|
|
490
|
+
function pickerEnter() {
|
|
491
|
+
const proj = activeProj();
|
|
492
|
+
if (!proj) return;
|
|
493
|
+
if (proj.running) {
|
|
494
|
+
const sess = activeSess() ?? proj.sessions[0];
|
|
495
|
+
if (sess) {
|
|
496
|
+
// A selected WINDOW row switches to that tab (`<session>:<index>`); a
|
|
497
|
+
// session (or project) row switches to the session as a whole.
|
|
498
|
+
const win = activeWin();
|
|
499
|
+
pickerSwitch(win ? `${sess.name}:${win.index}` : sess.name);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
pickerLaunchAndSwitch(proj);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Launch a project (standalone cockpit). When it has an `ide.yml`, run the
|
|
508
|
+
* full `tmux-ide` launch (builds the layout and attaches) — this blocks until
|
|
509
|
+
* the user detaches, so we refresh and stay in the cockpit rather than exit.
|
|
510
|
+
* Otherwise spin up a bare detached session so it appears in place.
|
|
511
|
+
*/
|
|
512
|
+
function launchProject(project: TeamProject) {
|
|
513
|
+
// const-capture so the non-null narrowing survives into the closure below
|
|
514
|
+
const dir = project.dir;
|
|
515
|
+
if (!dir) return;
|
|
516
|
+
if (project.hasIdeYml) {
|
|
517
|
+
withSuspendedTerminal(() => {
|
|
518
|
+
try {
|
|
519
|
+
execFileSync("tmux-ide", [], { cwd: dir, stdio: "inherit" });
|
|
520
|
+
} catch {
|
|
521
|
+
// launch failed or user detached — fall through to refresh
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
refresh();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
createDetachedSession(project.name, dir);
|
|
529
|
+
// Flag cockpit-created sessions so agents inside can detect tmux-ide.
|
|
530
|
+
try {
|
|
531
|
+
setSessionEnvironment(project.name, "TMUX_IDE", "1");
|
|
532
|
+
} catch {}
|
|
533
|
+
bestEffortAdopt(project.name);
|
|
534
|
+
setMessage(`launched ${project.name}`);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
537
|
+
}
|
|
538
|
+
refresh();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Last click on a project / session row, tracked so a second click on the
|
|
542
|
+
// same row within the double-click window activates it (mirrors Enter). Plain
|
|
543
|
+
// closure vars — they need no reactivity, they only feed the next click.
|
|
544
|
+
let lastProjectClick: ClickRecord | null = null;
|
|
545
|
+
let lastSessionClick: ClickRecord | null = null;
|
|
546
|
+
let lastWindowClick: ClickRecord | null = null;
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* A sidebar project row's mousedown: select it, and on a same-row
|
|
550
|
+
* double-click within the window, activate it via `enter()` (attach / launch).
|
|
551
|
+
*/
|
|
552
|
+
function clickProject(index: number) {
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
selectProject(index);
|
|
555
|
+
if (isDoubleClick(lastProjectClick, index, now)) {
|
|
556
|
+
lastProjectClick = null;
|
|
557
|
+
enter();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
lastProjectClick = { index, at: now };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* A main-pane session row's mousedown: make it the active session, and on a
|
|
565
|
+
* same-row double-click attach it.
|
|
566
|
+
*/
|
|
567
|
+
function clickSession(index: number) {
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
setActiveSession(index);
|
|
570
|
+
setActiveWindow(-1);
|
|
571
|
+
if (isDoubleClick(lastSessionClick, index, now)) {
|
|
572
|
+
lastSessionClick = null;
|
|
573
|
+
const sess = activeProj()?.sessions[index];
|
|
574
|
+
if (sess) {
|
|
575
|
+
if (switchMode) {
|
|
576
|
+
pickerSwitch(sess.name);
|
|
577
|
+
} else {
|
|
578
|
+
attachSessionName(sess.name);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
lastSessionClick = { index, at: now };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* PICKER: a window row's mousedown selects it (its parent session too), and
|
|
588
|
+
* on a same-row double-click switches the client to `<session>:<index>` and
|
|
589
|
+
* closes the popup — the window-scoped analogue of {@link clickSession}.
|
|
590
|
+
*/
|
|
591
|
+
function clickWindow(sessionIndex: number, windowIndex: number) {
|
|
592
|
+
const now = Date.now();
|
|
593
|
+
setActiveSession(sessionIndex);
|
|
594
|
+
setActiveWindow(windowIndex);
|
|
595
|
+
// Composite key so a double-click is only counted within the same window row.
|
|
596
|
+
const key = sessionIndex * 10000 + windowIndex;
|
|
597
|
+
if (isDoubleClick(lastWindowClick, key, now)) {
|
|
598
|
+
lastWindowClick = null;
|
|
599
|
+
const sess = activeProj()?.sessions[sessionIndex];
|
|
600
|
+
const win = sess?.windowList[windowIndex];
|
|
601
|
+
if (sess && win) pickerSwitch(`${sess.name}:${win.index}`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
lastWindowClick = { index: key, at: now };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Wheel over the sidebar moves the project selection, wrapping like the arrows. */
|
|
608
|
+
function scrollProjects(evt: MouseEvent) {
|
|
609
|
+
const dir = evt.scroll?.direction;
|
|
610
|
+
if (dir !== "up" && dir !== "down") return;
|
|
611
|
+
const n = visibleProjects().length;
|
|
612
|
+
if (n === 0) return;
|
|
613
|
+
setActiveProject((p) => wrapIndex(p, dir === "up" ? -1 : 1, n));
|
|
614
|
+
setActiveSession(0);
|
|
615
|
+
setActiveWindow(-1);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Enter on the active project: when it's running, suspend + attach in place;
|
|
620
|
+
* when stopped, launch it. In picker mode this instead switch-clients + exits.
|
|
621
|
+
*/
|
|
622
|
+
function enter() {
|
|
623
|
+
if (switchMode) {
|
|
624
|
+
pickerEnter();
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const proj = activeProj();
|
|
628
|
+
if (!proj) return;
|
|
629
|
+
if (proj.running) {
|
|
630
|
+
const sess = activeSess() ?? proj.sessions[0];
|
|
631
|
+
if (sess) {
|
|
632
|
+
attachSessionName(sess.name);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
launchProject(proj);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Gate a kill behind a y/n confirm: the active session, or a running project's sessions. */
|
|
640
|
+
function requestKill() {
|
|
641
|
+
const proj = activeProj();
|
|
642
|
+
if (!proj) return;
|
|
643
|
+
const sess = activeSess();
|
|
644
|
+
if (sess) {
|
|
645
|
+
setMessage("");
|
|
646
|
+
setConfirm({
|
|
647
|
+
message: `kill ${sess.name}? (y/n)`,
|
|
648
|
+
onYes: () => {
|
|
649
|
+
killSession(sess.name);
|
|
650
|
+
refresh();
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
// No session cursor (empty project) but running — kill all its sessions.
|
|
656
|
+
if (proj.running) {
|
|
657
|
+
setMessage("");
|
|
658
|
+
setConfirm({
|
|
659
|
+
message: `kill ${proj.name}? (y/n)`,
|
|
660
|
+
onYes: () => {
|
|
661
|
+
for (const s of proj.sessions) killSession(s.name);
|
|
662
|
+
refresh();
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Unregister the active project when it's registered and stopped — never orphan a live session. */
|
|
669
|
+
function unregister() {
|
|
670
|
+
const proj = activeProj();
|
|
671
|
+
if (!proj || !proj.registered || proj.running) return;
|
|
672
|
+
try {
|
|
673
|
+
unregisterProject(proj.name);
|
|
674
|
+
setMessage(`unregistered ${proj.name}`);
|
|
675
|
+
} catch (e) {
|
|
676
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
677
|
+
}
|
|
678
|
+
refresh();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Open the register-dir prompt seeded with the current working directory. */
|
|
682
|
+
function openRegister() {
|
|
683
|
+
setMessage("");
|
|
684
|
+
setPrompt({ kind: "register", value: invokeCwd });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Open the new-session prompt in the active project's dir, seeded with a unique name. */
|
|
688
|
+
function openNewSession() {
|
|
689
|
+
const proj = activeProj();
|
|
690
|
+
const base = proj ? proj.name : "session";
|
|
691
|
+
const dir = (proj ? proj.dir : null) ?? invokeCwd;
|
|
692
|
+
setNewSessionDir(dir);
|
|
693
|
+
setMessage("");
|
|
694
|
+
setPrompt({ kind: "newSession", value: suggestSessionName(base, hasSession) });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Open the rename prompt when the active session resolves to a live session. */
|
|
698
|
+
function openRename() {
|
|
699
|
+
const target = previewTarget();
|
|
700
|
+
if (!target) return;
|
|
701
|
+
setRenameTarget(target);
|
|
702
|
+
setMessage("");
|
|
703
|
+
setPrompt({ kind: "rename", value: target });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Split the active session's active pane (right, 50%). */
|
|
707
|
+
function splitSelected() {
|
|
708
|
+
const target = previewTarget();
|
|
709
|
+
if (!target) return;
|
|
710
|
+
const dir = activeProj()?.dir ?? invokeCwd;
|
|
711
|
+
try {
|
|
712
|
+
splitPane(target, "horizontal", dir, 50);
|
|
713
|
+
setMessage(`split ${target}`);
|
|
714
|
+
} catch (e) {
|
|
715
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
716
|
+
}
|
|
717
|
+
refresh();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Dispatch the open prompt's submit action by kind. */
|
|
721
|
+
function submitPrompt() {
|
|
722
|
+
const p = prompt();
|
|
723
|
+
if (!p) return;
|
|
724
|
+
if (p.kind === "register") {
|
|
725
|
+
// registerProject is async; resolve/clear after it lands.
|
|
726
|
+
registerProject({ dir: p.value.trim() })
|
|
727
|
+
.then(() => {
|
|
728
|
+
setPrompt(null);
|
|
729
|
+
setMessage("registered");
|
|
730
|
+
refresh();
|
|
731
|
+
})
|
|
732
|
+
.catch((e) => setMessage(String((e as { message?: string })?.message ?? e)));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (p.kind === "newSession") {
|
|
736
|
+
const name = p.value.trim();
|
|
737
|
+
if (!name) {
|
|
738
|
+
setMessage("session name required");
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
createDetachedSession(name, newSessionDir());
|
|
743
|
+
// Flag cockpit-created sessions so agents inside can detect tmux-ide.
|
|
744
|
+
try {
|
|
745
|
+
setSessionEnvironment(name, "TMUX_IDE", "1");
|
|
746
|
+
} catch {}
|
|
747
|
+
bestEffortAdopt(name);
|
|
748
|
+
setMessage(`created ${name}`);
|
|
749
|
+
} catch (e) {
|
|
750
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
751
|
+
}
|
|
752
|
+
setPrompt(null);
|
|
753
|
+
refresh();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// rename
|
|
757
|
+
const oldName = renameTarget();
|
|
758
|
+
const newName = p.value.trim();
|
|
759
|
+
if (!oldName || !newName || newName === oldName) {
|
|
760
|
+
setPrompt(null);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
runTmux(["rename-session", "-t", oldName, newName]);
|
|
765
|
+
setMessage(`renamed ${oldName} → ${newName}`);
|
|
766
|
+
} catch (e) {
|
|
767
|
+
setMessage(String((e as { message?: string })?.message ?? e));
|
|
768
|
+
}
|
|
769
|
+
setPrompt(null);
|
|
770
|
+
refresh();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
useKeyboard((evt) => {
|
|
774
|
+
// Destructive-action confirm swallows all keys: y runs it, anything else cancels.
|
|
775
|
+
if (confirm()) {
|
|
776
|
+
const c = confirm()!;
|
|
777
|
+
setConfirm(null);
|
|
778
|
+
if (evt.name === "y") {
|
|
779
|
+
c.onYes();
|
|
780
|
+
refresh();
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Inline text prompt swallows all keys while open.
|
|
786
|
+
if (prompt()) {
|
|
787
|
+
if (evt.name === "escape") {
|
|
788
|
+
setPrompt(null);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (evt.name === "return") {
|
|
792
|
+
submitPrompt();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const next = nextInput(prompt()!.value, evt);
|
|
796
|
+
if (next !== null) setPrompt({ ...prompt()!, value: next });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Filter prompt intercepts navigation while open — it narrows the sidebar.
|
|
801
|
+
if (filterMode()) {
|
|
802
|
+
if (evt.name === "escape") {
|
|
803
|
+
setFilterMode(false);
|
|
804
|
+
setFilterQuery("");
|
|
805
|
+
setActiveProject(0);
|
|
806
|
+
setActiveSession(0);
|
|
807
|
+
setActiveWindow(-1);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (evt.name === "return") {
|
|
811
|
+
// Act on the filtered active project, then drop the filter and re-anchor
|
|
812
|
+
// the cursor onto that same project in the full list.
|
|
813
|
+
const proj = activeProj();
|
|
814
|
+
enter();
|
|
815
|
+
setFilterMode(false);
|
|
816
|
+
setFilterQuery("");
|
|
817
|
+
const idx = proj ? projects().findIndex((p) => p.name === proj.name) : -1;
|
|
818
|
+
setActiveProject(idx >= 0 ? idx : 0);
|
|
819
|
+
setActiveSession(0);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const fn = visibleProjects().length;
|
|
823
|
+
if (evt.name === "up" || evt.name === "k") {
|
|
824
|
+
if (fn > 0) setActiveProject((s) => wrapIndex(s, -1, fn));
|
|
825
|
+
setActiveSession(0);
|
|
826
|
+
setActiveWindow(-1);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (evt.name === "down" || evt.name === "j") {
|
|
830
|
+
if (fn > 0) setActiveProject((s) => wrapIndex(s, 1, fn));
|
|
831
|
+
setActiveSession(0);
|
|
832
|
+
setActiveWindow(-1);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const next = nextInput(filterQuery(), evt);
|
|
836
|
+
if (next !== null) {
|
|
837
|
+
setFilterQuery(next);
|
|
838
|
+
setActiveProject(0);
|
|
839
|
+
setActiveSession(0);
|
|
840
|
+
setActiveWindow(-1);
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// The help overlay swallows keys: esc / q / ? close it (grammar dismiss/quit/help).
|
|
846
|
+
if (helpOpen()) {
|
|
847
|
+
const g = matchGrammar(evt);
|
|
848
|
+
if (g === "dismiss" || g === "quit" || g === "help") setHelpOpen(false);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ctrl+c always quits, independent of the (rebindable) quit key.
|
|
853
|
+
if (evt.ctrl && evt.name === "c") {
|
|
854
|
+
process.exit(0);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Switch mode (picker / home-popup): Esc just closes the popup. This sits
|
|
858
|
+
// AFTER the modal guards above (confirm / prompt / filter / help), so those
|
|
859
|
+
// still consume Esc to dismiss themselves first — only a "bare" Esc closes it.
|
|
860
|
+
if (switchMode && evt.name === "escape") {
|
|
861
|
+
process.exit(0);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const n = visibleProjects().length;
|
|
865
|
+
|
|
866
|
+
// The shared interaction grammar takes PRECEDENCE over the configurable
|
|
867
|
+
// keymap: the universal verbs (nav/enter/filter/help/quit + esc) mean the
|
|
868
|
+
// same here as in every widget. Their default keys agree with DEFAULT_KEYMAP,
|
|
869
|
+
// so this only changes behaviour for a bare `esc` (now quits the cockpit,
|
|
870
|
+
// matching the widgets). Custom rebinds fall through to `resolveAction`.
|
|
871
|
+
const grammar = matchGrammar(evt);
|
|
872
|
+
if (grammar === "navUp") {
|
|
873
|
+
if (pickerMode) movePicker(-1);
|
|
874
|
+
else if (n > 0) {
|
|
875
|
+
setActiveProject((s) => wrapIndex(s, -1, n));
|
|
876
|
+
setActiveSession(0);
|
|
877
|
+
setActiveWindow(-1);
|
|
878
|
+
}
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (grammar === "navDown") {
|
|
882
|
+
if (pickerMode) movePicker(1);
|
|
883
|
+
else if (n > 0) {
|
|
884
|
+
setActiveProject((s) => wrapIndex(s, 1, n));
|
|
885
|
+
setActiveSession(0);
|
|
886
|
+
setActiveWindow(-1);
|
|
887
|
+
}
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (grammar === "activate") {
|
|
891
|
+
enter();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (grammar === "filter") {
|
|
895
|
+
setMessage("");
|
|
896
|
+
setFilterQuery("");
|
|
897
|
+
setFilterMode(true);
|
|
898
|
+
setActiveProject(0);
|
|
899
|
+
setActiveSession(0);
|
|
900
|
+
setActiveWindow(-1);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (grammar === "help") {
|
|
904
|
+
setHelpOpen(true);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (grammar === "dismiss" || grammar === "quit") {
|
|
908
|
+
process.exit(0);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Standalone-only: e/g/, open the widget panels (explorer/changes/config) —
|
|
912
|
+
// the in-app echo of the tmux `M-e`/`M-g`/`M-,` binds. Both popup variants
|
|
913
|
+
// (picker + home-popup) stay pure navigators — a nested popup over a popup is
|
|
914
|
+
// avoided — so panels are gated to the terminal-owning cockpit.
|
|
915
|
+
if (!switchMode) {
|
|
916
|
+
const panel = panelForKey(evt.name);
|
|
917
|
+
if (panel) {
|
|
918
|
+
openPanel(panel);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// The rename default is Shift+R; single-char keys arrive lowercase with
|
|
924
|
+
// shift as a modifier (per the @opentui convention), so map it explicitly.
|
|
925
|
+
const keyName = evt.name === "r" && evt.shift ? "R" : evt.name;
|
|
926
|
+
const action = resolveAction(keymap, keyName);
|
|
927
|
+
switch (action) {
|
|
928
|
+
case "up":
|
|
929
|
+
// Picker walks the flat project→session→window row union; standalone
|
|
930
|
+
// just pages the project list (its window lines are read-only).
|
|
931
|
+
if (pickerMode) movePicker(-1);
|
|
932
|
+
else if (n > 0) {
|
|
933
|
+
setActiveProject((s) => wrapIndex(s, -1, n));
|
|
934
|
+
setActiveSession(0);
|
|
935
|
+
setActiveWindow(-1);
|
|
936
|
+
}
|
|
937
|
+
break;
|
|
938
|
+
case "down":
|
|
939
|
+
if (pickerMode) movePicker(1);
|
|
940
|
+
else if (n > 0) {
|
|
941
|
+
setActiveProject((s) => wrapIndex(s, 1, n));
|
|
942
|
+
setActiveSession(0);
|
|
943
|
+
setActiveWindow(-1);
|
|
944
|
+
}
|
|
945
|
+
break;
|
|
946
|
+
case "enter":
|
|
947
|
+
enter();
|
|
948
|
+
break;
|
|
949
|
+
case "launch": {
|
|
950
|
+
const proj = activeProj();
|
|
951
|
+
if (proj) {
|
|
952
|
+
if (switchMode) pickerLaunchAndSwitch(proj);
|
|
953
|
+
else launchProject(proj);
|
|
954
|
+
} else if (!switchMode) {
|
|
955
|
+
// Nothing selected (e.g. the empty-fleet hero): `l` is the entry point
|
|
956
|
+
// to get a launchable project into the fleet — open the add-dir prompt.
|
|
957
|
+
openRegister();
|
|
958
|
+
}
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
case "new":
|
|
962
|
+
openNewSession();
|
|
963
|
+
break;
|
|
964
|
+
case "rename":
|
|
965
|
+
openRename();
|
|
966
|
+
break;
|
|
967
|
+
case "split":
|
|
968
|
+
splitSelected();
|
|
969
|
+
break;
|
|
970
|
+
case "register":
|
|
971
|
+
openRegister();
|
|
972
|
+
break;
|
|
973
|
+
case "unregister":
|
|
974
|
+
unregister();
|
|
975
|
+
break;
|
|
976
|
+
case "kill":
|
|
977
|
+
requestKill();
|
|
978
|
+
break;
|
|
979
|
+
case "filter":
|
|
980
|
+
setMessage("");
|
|
981
|
+
setFilterQuery("");
|
|
982
|
+
setFilterMode(true);
|
|
983
|
+
setActiveProject(0);
|
|
984
|
+
setActiveSession(0);
|
|
985
|
+
setActiveWindow(-1);
|
|
986
|
+
break;
|
|
987
|
+
case "refresh":
|
|
988
|
+
refresh();
|
|
989
|
+
break;
|
|
990
|
+
case "help":
|
|
991
|
+
setHelpOpen(true);
|
|
992
|
+
break;
|
|
993
|
+
case "quit":
|
|
994
|
+
process.exit(0);
|
|
995
|
+
break;
|
|
996
|
+
case null:
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
// The keybindings help overlay — shared with every widget via the common
|
|
1002
|
+
// HelpOverlay. Replaces the middle body while `?` is open. The universal verbs
|
|
1003
|
+
// come from the grammar; the team's own (configurable) keys are listed below
|
|
1004
|
+
// them, sourced from the live keymap so a rebind re-labels the overlay.
|
|
1005
|
+
function helpOverlay() {
|
|
1006
|
+
const widgetKeys: WidgetKey[] = [
|
|
1007
|
+
...TEAM_WIDGET_ACTIONS.map((action) => ({
|
|
1008
|
+
key: keymap[action].keys.join("/"),
|
|
1009
|
+
label: keymap[action].description,
|
|
1010
|
+
})),
|
|
1011
|
+
// The in-app panel keys (e/g/,) live outside the configurable keymap, so
|
|
1012
|
+
// list them from the shared panel registry with their readable labels.
|
|
1013
|
+
...panelHints("label").map((h) => ({ key: h.keys, label: h.label })),
|
|
1014
|
+
];
|
|
1015
|
+
return <HelpOverlay theme={theme} title="cockpit" widgetKeys={widgetKeys} />;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// The inline text prompt (register / new session / rename) and the fuzzy
|
|
1019
|
+
// filter line — both shared by the two layouts, both a single line each.
|
|
1020
|
+
function promptRow() {
|
|
1021
|
+
return (
|
|
1022
|
+
<Show when={prompt()}>
|
|
1023
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
|
|
1024
|
+
<text fg={toRGBA(theme.accent)}>{promptLabel(prompt()!.kind)}</text>
|
|
1025
|
+
<text fg={toRGBA(theme.fg)}>{prompt()!.value}</text>
|
|
1026
|
+
<text fg={toRGBA(theme.fgMuted)}>_</text>
|
|
1027
|
+
</box>
|
|
1028
|
+
</Show>
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
function filterRow() {
|
|
1032
|
+
return (
|
|
1033
|
+
<Show when={filterMode()}>
|
|
1034
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
|
|
1035
|
+
<text fg={toRGBA(theme.accent)}>/</text>
|
|
1036
|
+
<text fg={toRGBA(theme.fg)}>{filterQuery()}</text>
|
|
1037
|
+
<text fg={toRGBA(theme.fgMuted)}>_</text>
|
|
1038
|
+
<box flexGrow={1} />
|
|
1039
|
+
<text
|
|
1040
|
+
fg={toRGBA(theme.fgMuted)}
|
|
1041
|
+
>{`${visibleProjects().length}/${projects().length}`}</text>
|
|
1042
|
+
</box>
|
|
1043
|
+
</Show>
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
function statusRow() {
|
|
1047
|
+
return (
|
|
1048
|
+
<Show when={confirm() || message().length > 0}>
|
|
1049
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
1050
|
+
<text fg={confirm() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}>
|
|
1051
|
+
{confirm() ? confirm()!.message : message()}
|
|
1052
|
+
</text>
|
|
1053
|
+
</box>
|
|
1054
|
+
</Show>
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* The empty-fleet hero — the home screen with nothing running yet. A friendly
|
|
1060
|
+
* centered card naming the three ways forward (new session / launch a project
|
|
1061
|
+
* dir / quit); the keys are live, handled by the same `n` / `l` / `q` paths.
|
|
1062
|
+
*/
|
|
1063
|
+
function emptyHero() {
|
|
1064
|
+
return (
|
|
1065
|
+
<box flexDirection="column" flexGrow={1} alignItems="center" paddingTop={2}>
|
|
1066
|
+
<box
|
|
1067
|
+
flexDirection="column"
|
|
1068
|
+
border
|
|
1069
|
+
borderColor={toRGBA(theme.accent)}
|
|
1070
|
+
backgroundColor={toRGBA(theme.selected)}
|
|
1071
|
+
paddingLeft={3}
|
|
1072
|
+
paddingRight={3}
|
|
1073
|
+
paddingTop={1}
|
|
1074
|
+
paddingBottom={1}
|
|
1075
|
+
>
|
|
1076
|
+
<text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
|
|
1077
|
+
no sessions yet
|
|
1078
|
+
</text>
|
|
1079
|
+
<box paddingTop={1} flexDirection="column">
|
|
1080
|
+
<For each={emptyFleetActions()}>
|
|
1081
|
+
{(action) => (
|
|
1082
|
+
<box flexDirection="row" gap={1}>
|
|
1083
|
+
<text fg={toRGBA(theme.accent)}>{action.key}</text>
|
|
1084
|
+
<text fg={toRGBA(theme.fg)}>{action.label}</text>
|
|
1085
|
+
</box>
|
|
1086
|
+
)}
|
|
1087
|
+
</For>
|
|
1088
|
+
</box>
|
|
1089
|
+
</box>
|
|
1090
|
+
</box>
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* PICKER layout — a compact single-column switcher for the `display-popup`
|
|
1096
|
+
* (bound to M-p on adopted sessions). This narrow popup is just the
|
|
1097
|
+
* project/session list + live status; no sidebar/preview split. The active
|
|
1098
|
+
* project's sessions expand inline so the user can pick one to switch to;
|
|
1099
|
+
* other projects collapse to their row to save vertical space. Picking ends
|
|
1100
|
+
* in a `switch-client` + close.
|
|
1101
|
+
*/
|
|
1102
|
+
function CompactSwitcher() {
|
|
1103
|
+
return (
|
|
1104
|
+
<box flexDirection="column" flexGrow={1} backgroundColor={toRGBA(theme.bg)}>
|
|
1105
|
+
{/* header — one tight line */}
|
|
1106
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
|
|
1107
|
+
<text fg={toRGBA(theme.accent)}>tmux-ide</text>
|
|
1108
|
+
<box flexGrow={1} />
|
|
1109
|
+
<text fg={toRGBA(theme.fgMuted)}>{`${projects().length} projects`}</text>
|
|
1110
|
+
</box>
|
|
1111
|
+
|
|
1112
|
+
{promptRow()}
|
|
1113
|
+
{filterRow()}
|
|
1114
|
+
|
|
1115
|
+
{/* body: help overlay, or the single-column project/session list */}
|
|
1116
|
+
<Show
|
|
1117
|
+
when={helpOpen()}
|
|
1118
|
+
fallback={
|
|
1119
|
+
<box
|
|
1120
|
+
flexDirection="column"
|
|
1121
|
+
flexGrow={1}
|
|
1122
|
+
paddingLeft={1}
|
|
1123
|
+
paddingRight={1}
|
|
1124
|
+
paddingTop={1}
|
|
1125
|
+
onMouseScroll={scrollProjects}
|
|
1126
|
+
>
|
|
1127
|
+
<Show
|
|
1128
|
+
when={visibleProjects().length > 0}
|
|
1129
|
+
fallback={
|
|
1130
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1131
|
+
{filterMode() ? "no match" : "no projects — a to add"}
|
|
1132
|
+
</text>
|
|
1133
|
+
}
|
|
1134
|
+
>
|
|
1135
|
+
<For each={visibleProjects()}>
|
|
1136
|
+
{(project, i) => {
|
|
1137
|
+
const isActive = () => i() === activeProject();
|
|
1138
|
+
// The flat cursor sits on the project row only when no
|
|
1139
|
+
// session/window under it is selected (activeSession === -1).
|
|
1140
|
+
const isCursor = () => isActive() && activeSession() === -1;
|
|
1141
|
+
const running = project.running;
|
|
1142
|
+
return (
|
|
1143
|
+
<box flexDirection="column">
|
|
1144
|
+
{/* project row */}
|
|
1145
|
+
<box
|
|
1146
|
+
flexDirection="row"
|
|
1147
|
+
gap={1}
|
|
1148
|
+
backgroundColor={isCursor() ? toRGBA(theme.border) : undefined}
|
|
1149
|
+
onMouseDown={() => clickProject(i())}
|
|
1150
|
+
>
|
|
1151
|
+
<text fg={isCursor() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}>
|
|
1152
|
+
{isCursor() ? "▸" : " "}
|
|
1153
|
+
</text>
|
|
1154
|
+
<text fg={running ? statusColor[project.status] : toRGBA(theme.fgMuted)}>
|
|
1155
|
+
{running ? STATUS[project.status].glyph : "○"}
|
|
1156
|
+
</text>
|
|
1157
|
+
<text
|
|
1158
|
+
fg={
|
|
1159
|
+
isActive()
|
|
1160
|
+
? toRGBA(theme.accent)
|
|
1161
|
+
: running
|
|
1162
|
+
? toRGBA(theme.fg)
|
|
1163
|
+
: toRGBA(theme.fgMuted)
|
|
1164
|
+
}
|
|
1165
|
+
attributes={isActive() ? TextAttributes.BOLD : 0}
|
|
1166
|
+
>
|
|
1167
|
+
{project.name}
|
|
1168
|
+
</text>
|
|
1169
|
+
<box flexGrow={1} />
|
|
1170
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1171
|
+
{running ? `${project.sessions.length}` : "○ stopped"}
|
|
1172
|
+
</text>
|
|
1173
|
+
</box>
|
|
1174
|
+
{/* git branch — dim indented line, when present */}
|
|
1175
|
+
<Show when={project.gitBranch}>
|
|
1176
|
+
<box flexDirection="row" paddingLeft={2}>
|
|
1177
|
+
<text fg={toRGBA(theme.fgMuted)}>{project.gitBranch ?? ""}</text>
|
|
1178
|
+
</box>
|
|
1179
|
+
</Show>
|
|
1180
|
+
{/* sessions — expanded only under the active project */}
|
|
1181
|
+
<Show when={isActive() && project.sessions.length > 0}>
|
|
1182
|
+
<For each={project.sessions}>
|
|
1183
|
+
{(session, si) => {
|
|
1184
|
+
// The session row is the cursor only when no window
|
|
1185
|
+
// under it is selected; its windows expand whenever
|
|
1186
|
+
// it's the active session.
|
|
1187
|
+
const sExpanded = () => si() === activeSession();
|
|
1188
|
+
const sActive = () => sExpanded() && activeWindow() === -1;
|
|
1189
|
+
return (
|
|
1190
|
+
<box flexDirection="column">
|
|
1191
|
+
<box
|
|
1192
|
+
flexDirection="row"
|
|
1193
|
+
gap={1}
|
|
1194
|
+
paddingLeft={2}
|
|
1195
|
+
backgroundColor={sActive() ? toRGBA(theme.border) : undefined}
|
|
1196
|
+
onMouseDown={() => clickSession(si())}
|
|
1197
|
+
>
|
|
1198
|
+
<text
|
|
1199
|
+
fg={sActive() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}
|
|
1200
|
+
>
|
|
1201
|
+
{sActive() ? "▸" : " "}
|
|
1202
|
+
</text>
|
|
1203
|
+
<text fg={statusColor[session.status]}>
|
|
1204
|
+
{STATUS[session.status].glyph}
|
|
1205
|
+
</text>
|
|
1206
|
+
<text fg={toRGBA(theme.fg)}>{session.name}</text>
|
|
1207
|
+
<box flexGrow={1} />
|
|
1208
|
+
<text fg={toRGBA(theme.fgMuted)}>{`${session.panes}p`}</text>
|
|
1209
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1210
|
+
{session.attached ? "·a" : ""}
|
|
1211
|
+
</text>
|
|
1212
|
+
</box>
|
|
1213
|
+
{/* windows (tabs) — expanded under the active session */}
|
|
1214
|
+
<Show when={sExpanded() && session.windowList.length > 0}>
|
|
1215
|
+
<For each={session.windowList}>
|
|
1216
|
+
{(win, wi) => {
|
|
1217
|
+
const wActive = () => wi() === activeWindow();
|
|
1218
|
+
return (
|
|
1219
|
+
<box
|
|
1220
|
+
flexDirection="row"
|
|
1221
|
+
gap={1}
|
|
1222
|
+
paddingLeft={4}
|
|
1223
|
+
backgroundColor={
|
|
1224
|
+
wActive() ? toRGBA(theme.border) : undefined
|
|
1225
|
+
}
|
|
1226
|
+
onMouseDown={() => clickWindow(si(), wi())}
|
|
1227
|
+
>
|
|
1228
|
+
<text
|
|
1229
|
+
fg={
|
|
1230
|
+
wActive()
|
|
1231
|
+
? toRGBA(theme.accent)
|
|
1232
|
+
: toRGBA(theme.fgMuted)
|
|
1233
|
+
}
|
|
1234
|
+
>
|
|
1235
|
+
{wActive() ? "▸" : " "}
|
|
1236
|
+
</text>
|
|
1237
|
+
<text fg={statusColor[win.status]}>
|
|
1238
|
+
{STATUS[win.status].glyph}
|
|
1239
|
+
</text>
|
|
1240
|
+
<text fg={toRGBA(theme.fg)}>
|
|
1241
|
+
{`${win.index}:${win.name}${win.active ? " *" : ""}`}
|
|
1242
|
+
</text>
|
|
1243
|
+
<box flexGrow={1} />
|
|
1244
|
+
<text
|
|
1245
|
+
fg={toRGBA(theme.fgMuted)}
|
|
1246
|
+
>{`${win.panes}p`}</text>
|
|
1247
|
+
</box>
|
|
1248
|
+
);
|
|
1249
|
+
}}
|
|
1250
|
+
</For>
|
|
1251
|
+
</Show>
|
|
1252
|
+
</box>
|
|
1253
|
+
);
|
|
1254
|
+
}}
|
|
1255
|
+
</For>
|
|
1256
|
+
</Show>
|
|
1257
|
+
</box>
|
|
1258
|
+
);
|
|
1259
|
+
}}
|
|
1260
|
+
</For>
|
|
1261
|
+
</Show>
|
|
1262
|
+
</box>
|
|
1263
|
+
}
|
|
1264
|
+
>
|
|
1265
|
+
{helpOverlay()}
|
|
1266
|
+
</Show>
|
|
1267
|
+
|
|
1268
|
+
{statusRow()}
|
|
1269
|
+
|
|
1270
|
+
{/* compact footer — shortened labels to fit ~34 cols. The picker ends in
|
|
1271
|
+
a switch-client + close, so it advertises that. */}
|
|
1272
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
|
|
1273
|
+
<For each={pickerFooterHints()}>
|
|
1274
|
+
{(hint) => <text fg={toRGBA(theme.fgMuted)}>{`${hint.keys} ${hint.label}`}</text>}
|
|
1275
|
+
</For>
|
|
1276
|
+
</box>
|
|
1277
|
+
</box>
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* STANDALONE layout — the full two-column app: a persistent SIDEBAR project
|
|
1283
|
+
* list plus a MAIN detail pane (active project's sessions + a live capture
|
|
1284
|
+
* preview). Used when the switcher runs on its own (`bun index.tsx`), where
|
|
1285
|
+
* it owns the whole terminal.
|
|
1286
|
+
*/
|
|
1287
|
+
function FullApp() {
|
|
1288
|
+
return (
|
|
1289
|
+
<box flexDirection="column" flexGrow={1} backgroundColor={toRGBA(theme.bg)}>
|
|
1290
|
+
{/* header: product name + a live fleet rollup (blocked/working/done/idle
|
|
1291
|
+
session counts, colored by status token) + the project total */}
|
|
1292
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
|
|
1293
|
+
<text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
|
|
1294
|
+
tmux-ide
|
|
1295
|
+
</text>
|
|
1296
|
+
<text fg={toRGBA(theme.fgMuted)}>· team</text>
|
|
1297
|
+
<box flexGrow={1} />
|
|
1298
|
+
<For each={rollupChips(fleetRollup(projects()))}>
|
|
1299
|
+
{(chip) => (
|
|
1300
|
+
<text fg={statusColor[chip.status]}>
|
|
1301
|
+
{`${STATUS[chip.status].glyph}${chip.count}`}
|
|
1302
|
+
</text>
|
|
1303
|
+
)}
|
|
1304
|
+
</For>
|
|
1305
|
+
<text fg={toRGBA(theme.fgMuted)}>{`${projects().length}p`}</text>
|
|
1306
|
+
</box>
|
|
1307
|
+
|
|
1308
|
+
{promptRow()}
|
|
1309
|
+
{filterRow()}
|
|
1310
|
+
|
|
1311
|
+
{/* middle: keybindings overlay, else the empty-fleet hero when nothing is
|
|
1312
|
+
running, else the sidebar (left) + main detail (right) */}
|
|
1313
|
+
<Show
|
|
1314
|
+
when={helpOpen()}
|
|
1315
|
+
fallback={
|
|
1316
|
+
<Show when={!isFleetEmpty(projects())} fallback={emptyHero()}>
|
|
1317
|
+
<box flexDirection="row" flexGrow={1}>
|
|
1318
|
+
{/* SIDEBAR — the project list */}
|
|
1319
|
+
<box
|
|
1320
|
+
flexDirection="column"
|
|
1321
|
+
width={SIDEBAR_WIDTH}
|
|
1322
|
+
paddingLeft={1}
|
|
1323
|
+
paddingRight={1}
|
|
1324
|
+
paddingTop={1}
|
|
1325
|
+
onMouseScroll={scrollProjects}
|
|
1326
|
+
>
|
|
1327
|
+
<text fg={toRGBA(theme.fgMuted)} attributes={TextAttributes.BOLD}>
|
|
1328
|
+
PROJECTS
|
|
1329
|
+
</text>
|
|
1330
|
+
<box flexDirection="column" paddingTop={1}>
|
|
1331
|
+
<Show
|
|
1332
|
+
when={visibleProjects().length > 0}
|
|
1333
|
+
fallback={
|
|
1334
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1335
|
+
{filterMode() ? "no match" : "no projects — a to add"}
|
|
1336
|
+
</text>
|
|
1337
|
+
}
|
|
1338
|
+
>
|
|
1339
|
+
<For each={visibleProjects()}>
|
|
1340
|
+
{(project, i) => {
|
|
1341
|
+
const isActive = () => i() === activeProject();
|
|
1342
|
+
const running = project.running;
|
|
1343
|
+
return (
|
|
1344
|
+
<box
|
|
1345
|
+
flexDirection="column"
|
|
1346
|
+
paddingLeft={1}
|
|
1347
|
+
paddingRight={1}
|
|
1348
|
+
backgroundColor={isActive() ? toRGBA(theme.border) : undefined}
|
|
1349
|
+
onMouseDown={() => clickProject(i())}
|
|
1350
|
+
>
|
|
1351
|
+
<box flexDirection="row" gap={1}>
|
|
1352
|
+
<text
|
|
1353
|
+
fg={isActive() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}
|
|
1354
|
+
>
|
|
1355
|
+
{isActive() ? "▸" : " "}
|
|
1356
|
+
</text>
|
|
1357
|
+
<text
|
|
1358
|
+
fg={running ? statusColor[project.status] : toRGBA(theme.fgMuted)}
|
|
1359
|
+
>
|
|
1360
|
+
{running ? STATUS[project.status].glyph : "○"}
|
|
1361
|
+
</text>
|
|
1362
|
+
<text
|
|
1363
|
+
fg={
|
|
1364
|
+
isActive()
|
|
1365
|
+
? toRGBA(theme.accent)
|
|
1366
|
+
: running
|
|
1367
|
+
? toRGBA(theme.fg)
|
|
1368
|
+
: toRGBA(theme.fgMuted)
|
|
1369
|
+
}
|
|
1370
|
+
attributes={isActive() ? TextAttributes.BOLD : 0}
|
|
1371
|
+
>
|
|
1372
|
+
{project.name.padEnd(18).slice(0, 18)}
|
|
1373
|
+
</text>
|
|
1374
|
+
<box flexGrow={1} />
|
|
1375
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1376
|
+
{running ? `${project.sessions.length}` : "○ stopped"}
|
|
1377
|
+
</text>
|
|
1378
|
+
</box>
|
|
1379
|
+
<Show when={project.gitBranch}>
|
|
1380
|
+
<box flexDirection="row" paddingLeft={2}>
|
|
1381
|
+
<text fg={toRGBA(theme.fgMuted)}>{project.gitBranch ?? ""}</text>
|
|
1382
|
+
</box>
|
|
1383
|
+
</Show>
|
|
1384
|
+
</box>
|
|
1385
|
+
);
|
|
1386
|
+
}}
|
|
1387
|
+
</For>
|
|
1388
|
+
</Show>
|
|
1389
|
+
</box>
|
|
1390
|
+
</box>
|
|
1391
|
+
|
|
1392
|
+
{/* vertical separator */}
|
|
1393
|
+
<box width={1} backgroundColor={toRGBA(theme.border)} />
|
|
1394
|
+
|
|
1395
|
+
{/* MAIN — the active project's detail: sessions + live preview */}
|
|
1396
|
+
<box flexDirection="column" flexGrow={1} paddingLeft={1} paddingTop={1}>
|
|
1397
|
+
<Show
|
|
1398
|
+
when={activeProj()}
|
|
1399
|
+
fallback={<text fg={toRGBA(theme.fgMuted)}>no project selected</text>}
|
|
1400
|
+
>
|
|
1401
|
+
{/* header: name · dir · branch · ide.yml */}
|
|
1402
|
+
<box flexDirection="row" gap={1} paddingRight={1}>
|
|
1403
|
+
<text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
|
|
1404
|
+
{activeProj()!.name}
|
|
1405
|
+
</text>
|
|
1406
|
+
<text fg={toRGBA(theme.fgMuted)}>{activeProj()!.dir ?? ""}</text>
|
|
1407
|
+
<text fg={toRGBA(theme.fgMuted)}>{activeProj()!.gitBranch ?? ""}</text>
|
|
1408
|
+
<Show when={activeProj()!.hasIdeYml}>
|
|
1409
|
+
<text fg={toRGBA(theme.fgMuted)}>ide.yml</text>
|
|
1410
|
+
</Show>
|
|
1411
|
+
</box>
|
|
1412
|
+
|
|
1413
|
+
{/* sessions sub-list */}
|
|
1414
|
+
<box flexDirection="column" paddingTop={1}>
|
|
1415
|
+
<Show
|
|
1416
|
+
when={(activeProj()?.sessions ?? []).length > 0}
|
|
1417
|
+
fallback={<text fg={toRGBA(theme.fgMuted)}>no sessions — l to launch</text>}
|
|
1418
|
+
>
|
|
1419
|
+
<For each={activeProj()?.sessions ?? []}>
|
|
1420
|
+
{(session, i) => {
|
|
1421
|
+
const isActive = () => i() === activeSession();
|
|
1422
|
+
return (
|
|
1423
|
+
<box flexDirection="column">
|
|
1424
|
+
<box
|
|
1425
|
+
flexDirection="row"
|
|
1426
|
+
gap={1}
|
|
1427
|
+
paddingLeft={1}
|
|
1428
|
+
paddingRight={1}
|
|
1429
|
+
backgroundColor={isActive() ? toRGBA(theme.border) : undefined}
|
|
1430
|
+
onMouseDown={() => clickSession(i())}
|
|
1431
|
+
>
|
|
1432
|
+
<text
|
|
1433
|
+
fg={isActive() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}
|
|
1434
|
+
>
|
|
1435
|
+
{isActive() ? "▸" : " "}
|
|
1436
|
+
</text>
|
|
1437
|
+
<text fg={statusColor[session.status]}>
|
|
1438
|
+
{STATUS[session.status].glyph}
|
|
1439
|
+
</text>
|
|
1440
|
+
<text fg={toRGBA(theme.fg)}>
|
|
1441
|
+
{session.name.padEnd(22).slice(0, 22)}
|
|
1442
|
+
</text>
|
|
1443
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1444
|
+
{STATUS[session.status].label.padEnd(8)}
|
|
1445
|
+
</text>
|
|
1446
|
+
<text fg={toRGBA(theme.fgMuted)}>{`${session.panes}p`}</text>
|
|
1447
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1448
|
+
{session.attached ? "· attached" : ""}
|
|
1449
|
+
</text>
|
|
1450
|
+
</box>
|
|
1451
|
+
{/* windows (tabs) — read-only breakdown under each session */}
|
|
1452
|
+
<For each={session.windowList}>
|
|
1453
|
+
{(win) => (
|
|
1454
|
+
<box
|
|
1455
|
+
flexDirection="row"
|
|
1456
|
+
gap={1}
|
|
1457
|
+
paddingLeft={4}
|
|
1458
|
+
paddingRight={1}
|
|
1459
|
+
>
|
|
1460
|
+
<text fg={statusColor[win.status]}>
|
|
1461
|
+
{STATUS[win.status].glyph}
|
|
1462
|
+
</text>
|
|
1463
|
+
<text fg={toRGBA(theme.fgMuted)}>
|
|
1464
|
+
{`${win.index}:${win.name}${win.active ? " *" : ""}`}
|
|
1465
|
+
</text>
|
|
1466
|
+
<box flexGrow={1} />
|
|
1467
|
+
<text fg={toRGBA(theme.fgMuted)}>{`${win.panes}p`}</text>
|
|
1468
|
+
</box>
|
|
1469
|
+
)}
|
|
1470
|
+
</For>
|
|
1471
|
+
</box>
|
|
1472
|
+
);
|
|
1473
|
+
}}
|
|
1474
|
+
</For>
|
|
1475
|
+
</Show>
|
|
1476
|
+
</box>
|
|
1477
|
+
|
|
1478
|
+
{/* live preview of the active session's active pane */}
|
|
1479
|
+
<box flexDirection="column" flexGrow={1} paddingTop={1}>
|
|
1480
|
+
<Show
|
|
1481
|
+
when={previewTitle().length > 0}
|
|
1482
|
+
fallback={<text fg={toRGBA(theme.fgMuted)}>no live session</text>}
|
|
1483
|
+
>
|
|
1484
|
+
<text fg={toRGBA(theme.accent)}>{previewTitle()}</text>
|
|
1485
|
+
</Show>
|
|
1486
|
+
<box flexDirection="column" flexGrow={1} paddingTop={1}>
|
|
1487
|
+
<For each={preview()}>
|
|
1488
|
+
{(line) => <text fg={toRGBA(theme.fgMuted)}>{line}</text>}
|
|
1489
|
+
</For>
|
|
1490
|
+
</box>
|
|
1491
|
+
</box>
|
|
1492
|
+
</Show>
|
|
1493
|
+
</box>
|
|
1494
|
+
</box>
|
|
1495
|
+
</Show>
|
|
1496
|
+
}
|
|
1497
|
+
>
|
|
1498
|
+
{helpOverlay()}
|
|
1499
|
+
</Show>
|
|
1500
|
+
|
|
1501
|
+
{statusRow()}
|
|
1502
|
+
|
|
1503
|
+
{/* footer — grammar-sourced hint line: the universal verbs' key glyphs
|
|
1504
|
+
come from grammar.ts, interleaved with the cockpit's own session +
|
|
1505
|
+
panel keys. The full key set stays in the `?` overlay. */}
|
|
1506
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="row" gap={2}>
|
|
1507
|
+
<For each={homeFooterHints()}>
|
|
1508
|
+
{(hint) => (
|
|
1509
|
+
<box flexDirection="row" gap={1}>
|
|
1510
|
+
<text fg={toRGBA(theme.accent)}>{hint.keys}</text>
|
|
1511
|
+
<text fg={toRGBA(theme.fgMuted)}>{hint.label}</text>
|
|
1512
|
+
</box>
|
|
1513
|
+
)}
|
|
1514
|
+
</For>
|
|
1515
|
+
</box>
|
|
1516
|
+
</box>
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return compactMode ? <CompactSwitcher /> : <FullApp />;
|
|
1521
|
+
});
|