@tdk-landscape/tdk-cli-core 1.3.117 → 1.3.119

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 CHANGED
@@ -142,3 +142,8 @@ Contributions are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and the
142
142
  TDK is MIT-licensed; see [LICENSE](LICENSE). The [license boundary](GOVERNANCE.md#license-boundary-and-donation-scope) says which code is MIT and which is downloaded with a key.
143
143
 
144
144
  Project governance: [GOVERNANCE.md](GOVERNANCE.md), [MAINTAINERS.md](MAINTAINERS.md), [ADOPTERS.md](ADOPTERS.md).
145
+
146
+ Use `tdk ui` to inspect stacks and resources. Arrow keys or `j`/`k` move the
147
+ selection; `g`/`G` or Home/End jump to the first/last item, and PageUp/PageDown
148
+ move one visible page. `/` searches; navigation letters remain search text
149
+ while searching. The selected row stays visible when the terminal is resized.
@@ -8,12 +8,14 @@ import { generateMasterConfigs, readProjectConfig, TemplateEngine, verifyMasterC
8
8
  import { isMasterConfigFileName } from "../types/index.js";
9
9
  import { assertValid } from "../utils/command-helpers.js";
10
10
  import { MASTER_CONFIG_FILES } from "../utils/constants.js";
11
+ import { checkPrismaConsistency } from "../utils/doctor-wiring.js";
11
12
  import { errorFactories, requireProjectRoot, runCommand } from "../utils/errors.js";
12
13
  import { writeJsonFile } from "../utils/file-helpers.js";
13
14
  import { createMachineEnvelope, writeMachineError } from "../utils/machine-output.js";
14
15
  import { findProjectRoot } from "../utils/paths.js";
15
16
  import { SERVICE_MANIFEST_SCHEMA_VERSION, validateServiceManifest, } from "../utils/service-manifest.js";
16
17
  import { discoverServiceManifestPaths } from "../utils/services.js";
18
+ import { evaluateSharedPlatformPostgres } from "../utils/shared-platform-postgres.js";
17
19
  import { validateOptionalInfraService } from "../utils/validation.js";
18
20
  /**
19
21
  * Serialize ProjectConfig to JSON-safe value.
@@ -182,14 +184,23 @@ export const configCommand = new Command("config")
182
184
  if (!projectRoot)
183
185
  throw errorFactories.notInProject();
184
186
  const result = verifyMasterConfigs(projectRoot);
187
+ // Same predicate as tdk doctor's Shared platform Postgres check.
188
+ const sharedPostgres = evaluateSharedPlatformPostgres(projectRoot);
189
+ const prismaConsistency = checkPrismaConsistency(projectRoot);
190
+ const sharedPostgresErrors = sharedPostgres.unknownDependsOnNames.map(({ resource, name }) => `dependsOn "${name}" on ${resource} is not a known service or stack (shared platform Postgres names are postgres and database-management)`);
185
191
  if (options.json) {
186
192
  console.log(JSON.stringify(createMachineEnvelope({
187
- valid: result.valid,
188
- errors: result.errors,
193
+ valid: result.valid && sharedPostgresErrors.length === 0 && prismaConsistency.didPass,
194
+ errors: [
195
+ ...result.errors,
196
+ ...sharedPostgresErrors,
197
+ ...(prismaConsistency.didPass ? [] : [prismaConsistency.message]),
198
+ ],
189
199
  warnings: result.warnings,
190
200
  diffs: result.diffs,
201
+ sharedPlatformPostgres: sharedPostgres,
191
202
  })));
192
- if (!result.valid)
203
+ if (!result.valid || sharedPostgresErrors.length > 0 || !prismaConsistency.didPass)
193
204
  process.exit(1);
194
205
  return;
195
206
  }
@@ -197,7 +208,32 @@ export const configCommand = new Command("config")
197
208
  for (const warning of result.warnings) {
198
209
  console.warn(chalk.yellow(`⚠️ ${warning}`));
199
210
  }
200
- if (result.valid) {
211
+ for (const error of sharedPostgresErrors) {
212
+ console.log(chalk.red(`❌ ${error}`));
213
+ }
214
+ if (!prismaConsistency.didPass)
215
+ console.log(chalk.red(`❌ ${prismaConsistency.message}`));
216
+ if (sharedPostgres.willStart) {
217
+ // Project resource set (discoverResourcesFromRoot), not a tdk up --only filter.
218
+ // Feature-on alone is not new behavior — print gray so default projects are not noisy.
219
+ // dependsOn is the reason Postgres starts when the feature is off — green.
220
+ if (sharedPostgres.reason === "feature" && sharedPostgres.dependsOnUsers.length === 0) {
221
+ const source = sharedPostgres.featureOnFromTiltfile || sharedPostgres.featureOnFromSpecMaster
222
+ ? " (generated Tiltfile/spec.master)"
223
+ : "";
224
+ console.log(chalk.gray(`ℹ️ Postgres will start because database-management is enabled${source} (project-wide; not a tdk up selection)`));
225
+ }
226
+ else if (sharedPostgres.reason === "feature") {
227
+ console.log(chalk.yellow(`ℹ️ Postgres will start because database-management is enabled and resource(s) ${sharedPostgres.dependsOnUsers.join(", ")} depend on postgres/database-management (project resource set; tdk up may select a subset)`));
228
+ }
229
+ else {
230
+ console.log(chalk.green(`ℹ️ Postgres will start because resource(s) ${sharedPostgres.dependsOnUsers.join(", ")} depend on postgres/database-management (project resource set; tdk up may select a subset)`));
231
+ }
232
+ }
233
+ else if (sharedPostgres.focusWouldEnableDatabaseManagement) {
234
+ console.log(chalk.yellow("ℹ️ Postgres will not start from project.json/generated Tiltfile, but default focus/CORE_INFRA expansion would enable database-management on a typical tdk up"));
235
+ }
236
+ if (result.valid && sharedPostgresErrors.length === 0 && prismaConsistency.didPass) {
201
237
  console.log(chalk.green("✅ All files are in sync!"));
202
238
  return;
203
239
  }
@@ -208,7 +244,9 @@ export const configCommand = new Command("config")
208
244
  }
209
245
  for (const { diff } of result.diffs)
210
246
  console.log(chalk.gray(`\n${diff}`));
211
- console.log(chalk.gray("\nRun `tdk config regenerate` to fix."));
247
+ if (!result.valid) {
248
+ console.log(chalk.gray("\nRun `tdk config regenerate` to fix."));
249
+ }
212
250
  process.exit(1);
213
251
  }
214
252
  };
@@ -9,7 +9,7 @@ import { MASTER_CONFIG_FILES, REQUIRED_PACKAGE_SCRIPTS } from "../utils/constant
9
9
  import { isPathDiscovered, readDiscoveryPaths } from "../utils/discovery-paths.js";
10
10
  import { collectDoctorChecks, createDoctorReport, getDoctorExitCode, } from "../utils/doctor-report.js";
11
11
  import { checkHealthRoutes, checkIngressPorts, checkPrivateNpmRegistry, checkTiltResourceHealth, projectConfigEnablesVerdaccio, summarizeServiceProbes, } from "../utils/doctor-runtime.js";
12
- import { checkDockerNetworkCapacity, checkDuplicateResourceNames, checkDuplicateResourcePorts, checkFrontendBackendUrls, checkMigrationsInApi, checkNatsBroker, checkResourcePackageJson, checkServiceUrlPorts, checkTiltInstances, } from "../utils/doctor-wiring.js";
12
+ import { checkDockerNetworkCapacity, checkDuplicateResourceNames, checkDuplicateResourcePorts, checkFrontendBackendUrls, checkMigrationsInApi, checkNatsBroker, checkPrismaConsistency, checkResourcePackageJson, checkServiceUrlPorts, checkSharedPlatformPostgres, checkTiltInstances, } from "../utils/doctor-wiring.js";
13
13
  import { validateEnvFile } from "../utils/env-validator.js";
14
14
  import { execAsync, isExecTimeout } from "../utils/exec-async.js";
15
15
  import { formatCount } from "../utils/formatting.js";
@@ -1143,6 +1143,9 @@ export const doctorCommand = new Command("doctor")
1143
1143
  () => checkServiceUrlPorts(),
1144
1144
  () => checkFrontendBackendUrls(),
1145
1145
  () => checkNatsBroker(),
1146
+ () => checkPrismaConsistency(),
1147
+ // Shared platform Postgres: will-start report + unknown dependsOn names stay errors.
1148
+ () => checkSharedPlatformPostgres(),
1146
1149
  () => checkMigrationsInApi(),
1147
1150
  () => checkTiltInstances(),
1148
1151
  checkEnvironmentVariables,
@@ -141,7 +141,7 @@ function requireGeneratedViteConfig(configPath) {
141
141
  "Run tdk up <stack> once, or point this script at your own Vite config to build on the host.";
142
142
  return `bun -e 'if(!require("fs").existsSync(${JSON.stringify(configPath)})){console.error(${JSON.stringify(message)});process.exit(1)}'`;
143
143
  }
144
- export function createPackageJson(name, type, frameworkId) {
144
+ export function createPackageJson(name, type, frameworkId, prismaEnabled = false) {
145
145
  const isFrontend = type === "frontend";
146
146
  const framework = resolveFrontendFramework(type, frameworkId);
147
147
  const backendFramework = resolveBackendFramework(type, frameworkId);
@@ -171,6 +171,14 @@ export function createPackageJson(name, type, frameworkId) {
171
171
  dependencies: {
172
172
  ...(isFrontend ? {} : (backendFramework?.dependencies ?? { hono: "^4.0.0" })),
173
173
  ...(framework?.dependencies ?? {}),
174
+ ...(prismaEnabled
175
+ ? {
176
+ prisma: "^7.5.0",
177
+ "@prisma/client": "^7.5.0",
178
+ "@prisma/adapter-pg": "^7.5.0",
179
+ pg: "^8.13.0",
180
+ }
181
+ : {}),
174
182
  },
175
183
  devDependencies: {
176
184
  "@types/bun": "^1.4.2",
@@ -186,6 +194,22 @@ export function createPackageJson(name, type, frameworkId) {
186
194
  },
187
195
  };
188
196
  }
197
+ export const PRISMA_SCHEMA_TEMPLATE = `generator client {
198
+ provider = "prisma-client"
199
+ output = "../generated/prisma"
200
+ }
201
+
202
+ datasource db {
203
+ provider = "postgresql"
204
+ }
205
+ `;
206
+ export const PRISMA_CONFIG_TEMPLATE = `export default {
207
+ schema: "prisma/schema.prisma",
208
+ datasource: {
209
+ url: process.env.DATABASE_URL,
210
+ },
211
+ };
212
+ `;
189
213
  export function createResourceTsconfig(resourceType, frameworkId) {
190
214
  const framework = resolveFrontendFramework(resourceType, frameworkId);
191
215
  const backendFramework = resolveBackendFramework(resourceType, frameworkId);
@@ -375,6 +399,7 @@ export const resourceCommand = new Command("resource")
375
399
  .option("--restart <policy>", "Compose restart policy: no, on-failure, unless-stopped, always (bring-your-own only; use no for one-shot jobs)")
376
400
  .option("--image <name>", "Docker image name instead of building from Dockerfile (for bring-your-own)")
377
401
  .option("--port <port>", "Port number (default: next free in 4000-5999)")
402
+ .option("--feature <feature...>", "Enable a resource feature (for example: prisma)")
378
403
  .action(async (name, options) => {
379
404
  if (options.frameworks) {
380
405
  for (const f of listFrontendFrameworks()) {
@@ -681,8 +706,13 @@ This file contains the resource configuration for TDK.
681
706
  }
682
707
  }
683
708
  // Prepare file generation tasks
684
- const serviceJson = createServiceJson(resourceName, resourceType, stackName, assignedPort, dddEnabled ? ["ddd"] : [], scaffoldFrameworkId, options.language === undefined ? undefined : backendLanguage?.id);
685
- const packageJson = createPackageJson(resourceName, resourceType, scaffoldFrameworkId);
709
+ const requestedFeatures = Array.isArray(options.feature) ? options.feature : [];
710
+ const prismaEnabled = requestedFeatures.includes("prisma");
711
+ const serviceJson = createServiceJson(resourceName, resourceType, stackName, assignedPort, [...(dddEnabled ? ["ddd"] : []), ...requestedFeatures], scaffoldFrameworkId, options.language === undefined ? undefined : backendLanguage?.id);
712
+ const packageJson = createPackageJson(resourceName, resourceType, scaffoldFrameworkId, prismaEnabled);
713
+ if (prismaEnabled && resourceType === "backend") {
714
+ serviceJson.dependsOn = [`${resourceName}-migrator`];
715
+ }
686
716
  const tasks = [
687
717
  {
688
718
  type: "json",
@@ -776,6 +806,76 @@ This file contains the resource configuration for TDK.
776
806
  // Execute all file writes with progress
777
807
  console.log(chalk.blue("💻 Generating source files..."));
778
808
  writeFilesWithProgress(fullPath, tasks);
809
+ if (prismaEnabled && resourceType === "backend") {
810
+ const migratorName = `${resourceName}-migrator`;
811
+ const migratorPath = resolve(projectRoot, `services/${stackName}/${migratorName}`);
812
+ mkdirSync(migratorPath, { recursive: true });
813
+ const migratorConfig = {
814
+ ...JSON.parse(JSON.stringify(BASE_TEMPLATE)),
815
+ $schema: SERVICE_MANIFEST_SCHEMA_URL,
816
+ schemaVersion: SERVICE_MANIFEST_SCHEMA_VERSION,
817
+ appName: migratorName,
818
+ appType: "migrator",
819
+ name: migratorName,
820
+ type: "migrator",
821
+ stack: stackName,
822
+ port: 0,
823
+ featuresEnabled: ["prisma"],
824
+ };
825
+ migratorConfig.dependsOn = ["postgres"];
826
+ migratorConfig.port = 0;
827
+ const migratorPackage = createPackageJson(migratorName, "migrator", undefined, true);
828
+ migratorPackage.scripts = {
829
+ ...migratorPackage.scripts,
830
+ start: "prisma migrate deploy",
831
+ };
832
+ writeFilesWithProgress(migratorPath, [
833
+ {
834
+ type: "json",
835
+ filename: "service.json",
836
+ content: migratorConfig,
837
+ description: "Generating Prisma migrator manifest",
838
+ emoji: "🗃️",
839
+ },
840
+ {
841
+ type: "json",
842
+ filename: "package.json",
843
+ content: migratorPackage,
844
+ description: "Generating Prisma migrator package",
845
+ emoji: "📦",
846
+ },
847
+ {
848
+ type: "text",
849
+ filename: "prisma/schema.prisma",
850
+ content: PRISMA_SCHEMA_TEMPLATE,
851
+ description: "Generating Prisma schema",
852
+ emoji: "🧬",
853
+ },
854
+ {
855
+ type: "text",
856
+ filename: "prisma.config.ts",
857
+ content: PRISMA_CONFIG_TEMPLATE,
858
+ description: "Generating Prisma config",
859
+ emoji: "⚙️",
860
+ },
861
+ ]);
862
+ writeFilesWithProgress(fullPath, [
863
+ {
864
+ type: "text",
865
+ filename: "prisma/schema.prisma",
866
+ content: PRISMA_SCHEMA_TEMPLATE,
867
+ description: "Generating Prisma schema",
868
+ emoji: "🧬",
869
+ },
870
+ {
871
+ type: "text",
872
+ filename: "prisma.config.ts",
873
+ content: PRISMA_CONFIG_TEMPLATE,
874
+ description: "Generating Prisma config",
875
+ emoji: "⚙️",
876
+ },
877
+ ]);
878
+ }
779
879
  console.log(chalk.green("\n✅ Resource created successfully!"));
780
880
  console.log(chalk.gray(`\nLocation: ${fullPath}`));
781
881
  const discoveryPaths = readDiscoveryPaths(projectRoot);
@@ -2,31 +2,48 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Command } from "commander";
3
3
  import { Box, render, Text, useApp, useInput, useStdin, useStdout } from "ink";
4
4
  import { useCallback, useEffect, useMemo, useState } from "react";
5
- import { DetailPanel, FileTree, ResourceSelectInput, ResourceTable, TabBar, } from "../components/index.js";
5
+ import { DetailPanel, FileTree, ResourceSelectInput, ResourceTable, TabBar, TUIHeader, } from "../components/index.js";
6
+ import { TABS } from "../components/TabBar.js";
7
+ import { createTUITheme, TUIThemeContext, useTUITheme } from "../components/ui-theme.js";
6
8
  import { errorFactories, requireProjectRoot } from "../utils/errors.js";
7
- import { findProjectRoot } from "../utils/paths.js";
9
+ import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
8
10
  import { describeSearch } from "../utils/search-status.js";
9
11
  import { clearMetadataCache, discoverResources, discoverStacks, getResourceMetadata, getStackMetadata, } from "../utils/services.js";
10
12
  import { createStatusMessageController } from "../utils/status-message.js";
11
13
  import { getListRowFromMouseY, getTerminalRuleWidth } from "../utils/terminal-layout.js";
12
14
  import { isTiltAvailable } from "../utils/tilt.js";
13
15
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
14
- const HelpPanel = ({ onClose }) => (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60, children: [_jsx(Text, { bold: true, color: "cyan", children: "Keyboard Shortcuts" }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, underline: true, children: "Navigation" }), _jsx(Text, { children: " \u2191/\u2193 Navigate list items" }), _jsx(Text, { children: " Enter Select item / Open detail" }), _jsx(Text, { children: " Space Toggle expand (tree view)" }), _jsx(Text, { children: " Tab Next tab" }), _jsx(Text, { children: " 1-5 Direct tab access" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, underline: true, children: "Actions" }) }), _jsx(Text, { children: " m Toggle mouse support" }), _jsx(Text, { children: " t Toggle tooltips" }), _jsx(Text, { children: " e Toggle enabled/disabled services" }), _jsx(Text, { children: " r Refresh data" }), _jsx(Text, { children: " / Search/filter" }), _jsx(Text, { children: " ? Show this help" }), _jsx(Text, { children: " q Quit \u2502 Esc Back" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to close..." }) })] }));
16
+ const HelpPanel = ({ onClose }) => {
17
+ const theme = useTUITheme();
18
+ return (_jsxs(Box, { ...(theme.ascii ? {} : { borderStyle: "single", borderColor: theme.accent }), paddingX: 2, paddingY: 1, flexDirection: "column", width: 60, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Keyboard Shortcuts" }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, underline: true, color: theme.foreground, children: "Navigation" }), _jsx(Text, { color: theme.foreground, children: theme.ascii
19
+ ? " [UP/DOWN] or j/k Navigate list items"
20
+ : " \u2191/\u2193 or j/k Navigate list items" }), _jsx(Text, { color: theme.foreground, children: " g/G or Home/End First/last item" }), _jsx(Text, { color: theme.foreground, children: " PgUp/PgDn Move one page" }), _jsx(Text, { color: theme.foreground, children: " Enter Select item / Open detail" }), _jsx(Text, { color: theme.foreground, children: " Tab Next tab" }), _jsxs(Text, { color: theme.foreground, children: [" ", TABS[0].shortcut, "-", TABS[TABS.length - 1].shortcut, " Direct tab access"] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, underline: true, color: theme.foreground, children: "Actions" }) }), _jsx(Text, { color: theme.foreground, children: " m Toggle mouse support" }), _jsx(Text, { color: theme.foreground, children: " t Toggle tooltips" }), _jsx(Text, { color: theme.foreground, children: " e Toggle enabled/disabled services" }), _jsx(Text, { color: theme.foreground, children: " r Refresh data" }), _jsx(Text, { color: theme.foreground, children: " / Search/filter" }), _jsx(Text, { color: theme.foreground, children: " ? Show this help" }), _jsx(Text, { color: theme.foreground, children: theme.ascii ? " q Quit | Esc Back" : " q Quit \u2502 Esc Back" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: "Press any key to close..." }) })] }));
21
+ };
15
22
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
23
+ const ASCII_SPINNER_FRAMES = ["-", "/", "|", "\\"];
16
24
  export const LoadingScreen = ({ message = "Discovering resources...", animated = true, }) => {
25
+ const theme = useTUITheme();
26
+ const spinnerFrames = theme.ascii ? ASCII_SPINNER_FRAMES : SPINNER_FRAMES;
17
27
  const [frame, setFrame] = useState(0);
18
28
  useEffect(() => {
19
29
  if (!animated)
20
30
  return;
21
- const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
31
+ const timer = setInterval(() => setFrame((f) => (f + 1) % spinnerFrames.length), 80);
22
32
  return () => clearInterval(timer);
23
- }, [animated]);
24
- return (_jsxs(Box, { flexDirection: "column", padding: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { children: [animated && _jsxs(Text, { color: "cyan", children: [SPINNER_FRAMES[frame], " "] }), message] })] }));
33
+ }, [animated, spinnerFrames.length]);
34
+ return (_jsxs(Box, { flexDirection: "column", padding: 2, children: [_jsxs(Text, { bold: true, color: theme.accent, children: [theme.bannerStart, " TDK NEON EDITION ", theme.bannerEnd] }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { color: theme.foreground, children: [animated && _jsxs(Text, { color: theme.accent, children: [spinnerFrames[frame], " "] }), message] })] }));
25
35
  };
26
36
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
27
- const ErrorScreen = ({ error, onRetry }) => (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: "red", children: "Could Not Load Services" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { color: "red", children: ["\u2717 ", error] }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "gray", children: "Troubleshooting:" }), _jsx(Text, { color: "gray", children: " 1. Check that every service.json is valid JSON" }), _jsx(Text, { color: "gray", children: " 2. Run tdk from your project (the folder with the Tiltfile)" }), _jsx(Text, { color: "gray", children: " 3. Try: tdk status --verbose" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "cyan", children: "Press [r] to retry or [q] to quit" })] }));
28
- const EmptyState = ({ message }) => (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: "yellow", children: "No Services Found" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "gray", children: "\u25C9 No service.json files found" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { children: "To get started:" }), _jsx(Text, { children: " 1. Run: tdk project" }), _jsx(Text, { children: " 2. Run: tdk resource api --type backend" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "cyan", children: "Press [r] to refresh or [q] to quit" }), message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "cyan", children: ["\u2593\u2592\u2591 ", message, " \u2591\u2592\u2593"] }) }))] }));
29
- const TUIApp = ({ animated = true }) => {
37
+ const ErrorScreen = ({ error, onRetry }) => {
38
+ const theme = useTUITheme();
39
+ return (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: theme.error, children: "Could Not Load Services" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { color: theme.error, children: [theme.ascii ? "[x]" : "\u2717", " ", error] }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: theme.muted, children: "Troubleshooting:" }), _jsx(Text, { color: theme.muted, children: " 1. Check that every service.json is valid JSON" }), _jsx(Text, { color: theme.muted, children: " 2. Run tdk from your project (the folder with the Tiltfile)" }), _jsx(Text, { color: theme.muted, children: " 3. Try: tdk status --verbose" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: theme.accent, children: "Press [r] to retry or [q] to quit" })] }));
40
+ };
41
+ const EmptyState = ({ message }) => {
42
+ const theme = useTUITheme();
43
+ return (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: theme.warning, children: "No Services Found" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { color: theme.muted, children: [theme.ascii ? "[*]" : "\u25c9", " No service.json files found"] }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: theme.foreground, children: "To get started:" }), _jsx(Text, { color: theme.foreground, children: " 1. Run: tdk project" }), _jsx(Text, { color: theme.foreground, children: " 2. Run: tdk resource api --type backend" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: theme.accent, children: "Press [r] to refresh or [q] to quit" }), message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: theme.accent, children: [theme.bannerStart, " ", message, " ", theme.bannerEnd] }) }))] }));
44
+ };
45
+ export const TUIApp = ({ animated = true }) => {
46
+ const theme = useTUITheme();
30
47
  const { exit } = useApp();
31
48
  const { stdout } = useStdout();
32
49
  const { stdin, setRawMode } = useStdin();
@@ -39,9 +56,17 @@ const TUIApp = ({ animated = true }) => {
39
56
  const [searchQuery, setSearchQuery] = useState("");
40
57
  const [isSearching, setIsSearching] = useState(false);
41
58
  const [terminalWidth, setTerminalWidth] = useState(stdout.columns || 120);
59
+ const [terminalRows, setTerminalRows] = useState(stdout.rows || 24);
42
60
  const [selectedFile, setSelectedFile] = useState(null);
43
61
  const [mouseEnabled, setMouseEnabled] = useState(true);
44
62
  const [listTop, setListTop] = useState(null);
63
+ const [listStart, setListStart] = useState(0);
64
+ // Reserve the header, tab bar, hints and footer; keep the cursor visible.
65
+ const pageSize = Math.max(1, terminalRows - 14);
66
+ const handleListLayout = useCallback((top, firstVisible = 0) => {
67
+ setListTop(top);
68
+ setListStart(firstVisible);
69
+ }, []);
45
70
  const [loading, setLoading] = useState(true);
46
71
  const [error, setError] = useState(null);
47
72
  const [showTooltips, setShowTooltips] = useState(true);
@@ -161,7 +186,7 @@ const TUIApp = ({ animated = true }) => {
161
186
  }
162
187
  return [];
163
188
  }, [activeTab, filteredStacks, filteredServices, selectedStackData, selectedServiceData]);
164
- const items = getItems();
189
+ const items = useMemo(getItems, [getItems]);
165
190
  const selectableListVisible = !loading &&
166
191
  !error &&
167
192
  !showHelp &&
@@ -244,9 +269,10 @@ const TUIApp = ({ animated = true }) => {
244
269
  const isLeftClick = (btn & 0b11) === 0;
245
270
  if (isLeftClick && !release) {
246
271
  const listRow = selectableListVisible && listTop !== null ? getListRowFromMouseY(y, listTop) : -1;
247
- if (listRow >= 0 && listRow < items.length) {
248
- setHighlightedIndex(listRow);
249
- const item = items[listRow];
272
+ const itemIndex = listStart + listRow;
273
+ if (listRow >= 0 && listRow < pageSize && itemIndex < items.length) {
274
+ setHighlightedIndex(itemIndex);
275
+ const item = items[itemIndex];
250
276
  if (item) {
251
277
  handleSelect(item);
252
278
  }
@@ -260,10 +286,20 @@ const TUIApp = ({ animated = true }) => {
260
286
  stdin.off("data", handleMouseData);
261
287
  stdin.removeAllListeners("data");
262
288
  };
263
- }, [stdin, items, listTop, selectableListVisible, mouseEnabled, handleSelect]);
289
+ }, [
290
+ stdin,
291
+ items,
292
+ listTop,
293
+ listStart,
294
+ pageSize,
295
+ selectableListVisible,
296
+ mouseEnabled,
297
+ handleSelect,
298
+ ]);
264
299
  const handleResize = useCallback(() => {
265
300
  setTerminalWidth(stdout.columns || 120);
266
- }, [stdout.columns]);
301
+ setTerminalRows(stdout.rows || 24);
302
+ }, [stdout]);
267
303
  useEffect(() => {
268
304
  stdout.on("resize", handleResize);
269
305
  return () => {
@@ -367,7 +403,7 @@ const TUIApp = ({ animated = true }) => {
367
403
  return;
368
404
  }
369
405
  if (key.tab) {
370
- const tabs = ["overview", "resources", "events", "files", "config"];
406
+ const tabs = TABS.map((tab) => tab.id);
371
407
  const currentIdx = tabs.indexOf(activeTab);
372
408
  const nextIdx = key.shift
373
409
  ? (currentIdx - 1 + tabs.length) % tabs.length
@@ -375,23 +411,26 @@ const TUIApp = ({ animated = true }) => {
375
411
  setActiveTab(tabs[nextIdx]);
376
412
  return;
377
413
  }
378
- if (/^[1-5]$/.test(input)) {
379
- const tabMap = {
380
- "1": "overview",
381
- "2": "resources",
382
- "3": "events",
383
- "4": "files",
384
- "5": "config",
385
- };
386
- setActiveTab(tabMap[input]);
414
+ const shortcutTab = TABS.find((tab) => tab.shortcut === input);
415
+ if (shortcutTab) {
416
+ setActiveTab(shortcutTab.id);
387
417
  return;
388
418
  }
389
- if (key.upArrow) {
419
+ const plainKey = !key.ctrl && !key.meta;
420
+ if (items.length > 0 && (key.upArrow || (plainKey && input === "k"))) {
390
421
  setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
391
422
  }
392
- if (key.downArrow) {
423
+ if (items.length > 0 && (key.downArrow || (plainKey && input === "j"))) {
393
424
  setHighlightedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
394
425
  }
426
+ if (key.home || (plainKey && input === "g"))
427
+ setHighlightedIndex(0);
428
+ if (key.end || (plainKey && input === "G"))
429
+ setHighlightedIndex(Math.max(0, items.length - 1));
430
+ if (key.pageUp)
431
+ setHighlightedIndex((prev) => Math.max(0, prev - pageSize));
432
+ if (key.pageDown)
433
+ setHighlightedIndex((prev) => Math.min(Math.max(0, items.length - 1), prev + pageSize));
395
434
  if (key.return || input === " ") {
396
435
  const currentItem = items[highlightedIndex];
397
436
  if (currentItem) {
@@ -433,25 +472,48 @@ const TUIApp = ({ animated = true }) => {
433
472
  if (error) {
434
473
  return _jsx(ErrorScreen, { error: error, onRetry: refresh });
435
474
  }
475
+ const header = (_jsxs(_Fragment, { children: [_jsx(TUIHeader, { projectRoot: projectRoot, resourceCount: services.length, terminalWidth: terminalWidth, version: getPackageVersion() }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.muted, children: (theme.ascii ? "-" : "\u2500").repeat(getTerminalRuleWidth(terminalWidth)) }) })] }));
436
476
  if (services.length === 0) {
437
- return _jsx(EmptyState, { message: message });
477
+ return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [header, _jsx(EmptyState, { message: message })] }));
438
478
  }
439
- return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [_jsx(Box, { paddingX: 1, paddingY: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: projectRoot }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsxs(Text, { color: "green", children: [services.length, " services ready"] })] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "─".repeat(getTerminalRuleWidth(terminalWidth)) }) }), isSearching && (_jsxs(Box, { paddingX: 1, height: 1, children: [_jsxs(Text, { color: "yellow", children: ["Search: ", searchQuery, "_"] }), searchStatus && _jsx(Text, { color: "gray", dimColor: true, children: ` ${searchStatus.summary}` })] })), searchStatus?.emptyMessage && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: "gray", children: searchStatus.emptyMessage }) })), !isSearching && message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "cyan", children: ["\u2593\u2592\u2591 ", message, " \u2591\u2592\u2593"] }) })), !isSearching && !message && showTooltips && !showHelp && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: activeTab === "overview" && selectedStack
440
- ? `Stack "${selectedStack}" selected. [Enter] view │ [Esc] back │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
441
- : activeTab === "overview" && !selectedStack
442
- ? `[↑/↓] Navigate │ [Enter] Select │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
443
- : activeTab === "resources"
444
- ? `[Tab] Tabs │ [r] Refresh │ [/] Search │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
445
- : activeTab === "events"
446
- ? `Event timeline │ [Tab] Switch tabs │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
447
- : activeTab === "files" && selectedService
448
- ? `Service "${selectedService}" │ [Space] Expand │ [Esc] Back │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
449
- : activeTab === "files"
450
- ? `Select service to view files │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
451
- : activeTab === "config"
452
- ? `View configurations │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
453
- : `[Tab] Next │ [1-5] Tabs │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help │ [q] Quit` }) })), showHelp && (_jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsx(HelpPanel, { onClose: () => setShowHelp(false) }) })), !showHelp && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, children: _jsx(TabBar, { activeTab: activeTab, onTabChange: setActiveTab, terminalWidth: terminalWidth }) }), _jsxs(Box, { flexDirection: "row", paddingX: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, width: mainPanelWidth, children: [activeTab === "overview" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "gray", children: "\u250C\u2500 Stacks \u2500" }) }), _jsx(Box, { marginTop: 1, flexGrow: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, width: mainPanelWidth, onLayout: setListTop }) })] })), activeTab === "resources" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "gray", children: "\u250C\u2500 Resources \u2500" }) }), selectedStackData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: ["Stack: ", selectedStackData.stack.name] }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceTable, { resources: selectedStackData.metadata.resources, maxWidth: terminalWidth - (showSidebar ? 50 : 10) }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: "Select a stack to view resources" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, width: mainPanelWidth, onLayout: setListTop }) })] }))] })), activeTab === "events" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "gray", children: "\u250C\u2500 Events \u2500" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "Events tab not yet implemented" }) })] })), activeTab === "files" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "gray", children: "\u250C\u2500 Autogenerated Files \u2500" }) }), selectedServiceData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: ["Service: ", selectedServiceData.service.name] }), _jsx(Box, { marginTop: 1, children: _jsx(FileTree, { nodes: fileTreeNodes, selectedPath: selectedFile || undefined }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: "Select a service to view files" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, width: mainPanelWidth, onLayout: setListTop }) })] }))] })), activeTab === "config" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "gray", children: "\u250C\u2500 Configuration \u2500" }) }), selectedServiceData ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: selectedServiceData.service.configPath }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: _jsx(Text, { color: "gray", wrap: "wrap", children: JSON.stringify(selectedServiceData.service.config, null, 2).slice(0, 1000) }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: "Select a service to view configuration" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, width: mainPanelWidth, onLayout: setListTop }) })] }))] }))] }), showSidebar && (_jsx(Box, { marginLeft: 2, children: _jsx(DetailPanel, { stack: selectedStackData?.stack || null, service: selectedServiceData?.service || null, stackMetadata: selectedStackData?.metadata || null, visible: !!selectedStack || !!selectedService }) }))] }), _jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, height: 3, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["\u2593\u2592\u2591 ", activeTab] }), _jsxs(Text, { color: "green", children: ["\u25CF ", services.filter((s) => s.stack).length, " in stack"] }), _jsxs(Text, { color: "yellow", children: ["\u25CB ", services.filter((s) => !s.stack).length, " no stack"] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: "gray", children: ["Stacks: ", stacks.length] }), _jsxs(Text, { color: "gray", children: ["Services: ", services.length] }), _jsxs(Text, { color: "gray", children: ["\uD83D\uDDB1\uFE0F ", mouseEnabled ? "ON" : "OFF", " \u2502 \u2139\uFE0F ", showTooltips ? "ON" : "OFF", " \u2502", _jsx(Text, { color: showEnabledOnly ? "green" : "yellow", children: showEnabledOnly ? "✓ enabled" : "✓ all" }), " │ ", "[?] Help \u2502 [q] Quit"] })] })] })] }))] }));
479
+ return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [header, isSearching && (_jsxs(Box, { paddingX: 1, height: 1, children: [_jsxs(Text, { color: theme.warning, children: ["Search: ", searchQuery, "_"] }), searchStatus && (_jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: ` ${searchStatus.summary}` }))] })), searchStatus?.emptyMessage && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: theme.muted, children: searchStatus.emptyMessage }) })), !isSearching && message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: theme.accent, children: [theme.bannerStart, " ", message, " ", theme.bannerEnd] }) })), !isSearching && !message && showTooltips && !showHelp && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: createHelpHint(activeTab, selectedStack, selectedService, showEnabledOnly, theme.ascii) }) })), showHelp && (_jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsx(HelpPanel, { onClose: () => setShowHelp(false) }) })), !showHelp && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, children: _jsx(TabBar, { activeTab: activeTab, onTabChange: setActiveTab, terminalWidth: terminalWidth }) }), _jsxs(Box, { flexDirection: "row", paddingX: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, width: mainPanelWidth, children: [activeTab === "overview" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Stacks ]" : "┌─ Stacks ─" }) }), _jsx(Box, { marginTop: 1, flexGrow: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] })), activeTab === "resources" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Resources ]" : "┌─ Resources ─" }) }), selectedStackData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Stack: ", selectedStackData.stack.name] }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceTable, { resources: selectedStackData.metadata.resources, maxWidth: terminalWidth - (showSidebar ? 50 : 10) }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a stack to view resources" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] })), activeTab === "files" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Autogenerated Files ]" : "┌─ Autogenerated Files ─" }) }), selectedServiceData ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Service: ", selectedServiceData.service.name] }), _jsx(Box, { marginTop: 1, children: _jsx(FileTree, { nodes: fileTreeNodes, selectedPath: selectedFile || undefined }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a service to view files" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] })), activeTab === "config" && (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.muted, children: theme.ascii ? "[ Configuration ]" : "┌─ Configuration ─" }) }), selectedServiceData ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.accent, children: selectedServiceData.service.configPath }), _jsx(Box, { marginTop: 1, ...(theme.ascii
480
+ ? {}
481
+ : { borderStyle: "single", borderColor: theme.border }), paddingX: 1, children: _jsx(Text, { color: theme.muted, wrap: "wrap", children: JSON.stringify(selectedServiceData.service.config, null, 2).slice(0, 1000) }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "Select a service to view configuration" }), _jsx(Box, { marginTop: 1, children: _jsx(ResourceSelectInput, { items: items, onSelect: handleSelect, highlightedIndex: highlightedIndex, isActive: !isSearching, maxVisibleItems: pageSize, width: mainPanelWidth, onLayout: handleListLayout }) })] }))] }))] }), showSidebar && (_jsx(Box, { marginLeft: 2, children: _jsx(DetailPanel, { stack: selectedStackData?.stack || null, service: selectedServiceData?.service || null, stackMetadata: selectedStackData?.metadata || null, visible: !!selectedStack || !!selectedService }) }))] }), _jsxs(Box, { ...(theme.ascii ? {} : { borderStyle: "single", borderColor: theme.border }), paddingX: 1, height: 3, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: theme.accent, bold: true, children: [theme.bannerStart, " ", activeTab] }), _jsxs(Text, { color: theme.success, children: [theme.ascii ? "[+]" : "\u25cf", " ", services.filter((s) => s.stack).length, " in stack"] }), _jsxs(Text, { color: theme.warning, children: [theme.ascii ? "-" : "\u25cb", " ", services.filter((s) => !s.stack).length, " no stack"] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: theme.muted, children: ["Stacks: ", stacks.length] }), _jsxs(Text, { color: theme.muted, children: ["Services: ", services.length] }), _jsxs(Text, { color: theme.muted, children: [theme.ascii ? "Mouse" : "🖱️", " ", mouseEnabled ? "ON" : "OFF", theme.ascii ? " | " : " \u2502 ", theme.ascii ? "Info" : "ℹ️", " ", showTooltips ? "ON" : "OFF", theme.ascii ? " | " : " \u2502 ", _jsx(Text, { color: showEnabledOnly ? theme.success : theme.warning, children: showEnabledOnly
482
+ ? theme.ascii
483
+ ? "[+] enabled"
484
+ : "\u2713 enabled"
485
+ : theme.ascii
486
+ ? "[+] all"
487
+ : "\u2713 all" }), theme.ascii ? " | " : " \u2502 ", "[?] Help", theme.ascii ? " | " : " \u2502 ", "[q] Quit"] })] })] })] }))] }));
454
488
  };
489
+ /** Compose footer guidance for the current tab and terminal character set. */
490
+ function createHelpHint(activeTab, selectedStack, selectedService, showEnabledOnly, ascii) {
491
+ const separator = ascii ? " | " : " \u2502 ";
492
+ const enabledHint = `[e] ${showEnabledOnly ? "show all" : "enabled only"}`;
493
+ const common = [enabledHint, "[?] help"];
494
+ const tabRange = `${TABS[0].shortcut}-${TABS[TABS.length - 1].shortcut}`;
495
+ if (activeTab === "overview") {
496
+ const introduction = selectedStack
497
+ ? `Stack "${selectedStack}" selected. [Enter] view`
498
+ : `${ascii ? "[UP/DOWN]" : "[\u2191/\u2193]"} Navigate`;
499
+ const controls = selectedStack ? ["[Esc] back", ...common] : ["[Enter] Select", ...common];
500
+ return [introduction, ...controls].join(separator);
501
+ }
502
+ if (activeTab === "resources") {
503
+ return ["[Tab] Tabs", "[r] Refresh", "[/] Search", ...common].join(separator);
504
+ }
505
+ if (activeTab === "files" && selectedService) {
506
+ return [`Service "${selectedService}"`, "[Esc] Back", ...common].join(separator);
507
+ }
508
+ if (activeTab === "files") {
509
+ return ["Select service to view files", ...common].join(separator);
510
+ }
511
+ if (activeTab === "config") {
512
+ return ["View configurations", ...common].join(separator);
513
+ }
514
+ return ["[Tab] Next", `[${tabRange}] Tabs`, ...common, "[q] Quit"].join(separator);
515
+ }
516
+ const TUIRoot = ({ animated, highContrast, }) => (_jsx(TUIThemeContext.Provider, { value: createTUITheme(Boolean(highContrast)), children: _jsx(TUIApp, { animated: animated }) }));
455
517
  export const uiCommand = new Command("ui")
456
518
  .description("Interactive TUI for managing stacks and services (Neon Edition)")
457
519
  .alias("interactive")
@@ -465,6 +527,8 @@ export const uiCommand = new Command("ui")
465
527
  }
466
528
  requireProjectRoot();
467
529
  // The app has no Static content, so its live layout starts at row 1 in the alternate screen.
468
- render(_jsx(TUIApp, { animated: options.animations }), { alternateScreen: true });
530
+ render(_jsx(TUIRoot, { animated: options.animations, highContrast: options.highContrast }), {
531
+ alternateScreen: true,
532
+ });
469
533
  });
470
534
  //# sourceMappingURL=ui.js.map
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { useTUITheme } from "./ui-theme.js";
3
4
  export const BaseTooltip = ({ content, shortcut, visible, maxWidth = 40, wrapText = false, prefix = "", marginTop = 0, }) => {
5
+ const theme = useTUITheme();
4
6
  if (!visible)
5
7
  return null;
6
- // Wrap text to maxWidth if requested
7
8
  const lines = wrapText ? wrapContent(content, maxWidth) : [content];
8
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "yellow", paddingX: 1, paddingY: 1, backgroundColor: "black", marginTop: marginTop, children: [lines.map((line, i) => (_jsxs(Text, { color: "yellow", children: [prefix, line] }, i))), shortcut && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Press " }), _jsx(Text, { color: "cyan", bold: true, children: shortcut.startsWith("[") ? shortcut : `[${shortcut}]` }), _jsx(Text, { color: "gray", children: " to use" })] }))] }));
9
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.ascii ? undefined : "single", borderColor: theme.warning, paddingX: 1, paddingY: 1, backgroundColor: theme.ascii ? undefined : "black", marginTop: marginTop, children: [lines.map((line, i) => (_jsxs(Text, { color: theme.warning, bold: theme.highContrast, children: [prefix, line] }, i))), shortcut && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.muted, children: "Press " }), _jsx(Text, { color: theme.accent, bold: true, children: shortcut.startsWith("[") ? shortcut : `[${shortcut}]` }), _jsx(Text, { color: theme.muted, children: " to use" })] }))] }));
9
10
  };
10
11
  function wrapContent(content, maxWidth) {
11
12
  const words = content.split(" ");
@@ -1,18 +1,32 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { formatDate, getStatusColor, getStatusIcon } from "../utils/formatting.js";
4
+ import { getTUIStatusColor, getTUIStatusIcon, useTUITheme } from "./ui-theme.js";
4
5
  export const DetailPanel = ({ stack, service, stackMetadata, visible, }) => {
6
+ const theme = useTUITheme();
5
7
  if (!visible) {
6
8
  return _jsx(Box, { width: 0 });
7
9
  }
8
10
  if (service) {
9
- return (_jsxs(Box, { width: 40, flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, justifyContent: "center", children: _jsxs(Text, { color: "cyan", bold: true, children: ["\u250C\u2500 ", service.name.toUpperCase(), " \u2500\u2510"] }) }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "Name: " }), _jsx(Text, { color: "white", children: service.name })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "Stack: " }), _jsx(Text, { color: "white", children: service.stack || "unknown" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "Type: " }), _jsx(Text, { color: "yellow", children: (service.stack || "") === "platform" ? "PLATFORM" : "PRODUCT" })] })] }), _jsx(Box, { marginTop: 2, children: _jsxs(Text, { color: "gray", dimColor: true, children: ["Press ", _jsx(Text, { color: "cyan", children: "[Esc]" }), " to close"] }) })] }));
11
+ const title = theme.ascii
12
+ ? `--- ${service.name.toUpperCase()} ---`
13
+ : `\u250C\u2500 ${service.name.toUpperCase()} \u2500\u2510`;
14
+ return (_jsxs(Box, { width: 40, flexDirection: "column", borderStyle: theme.ascii ? undefined : "single", borderColor: theme.border, paddingX: 1, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, justifyContent: "center", children: _jsx(Text, { color: theme.accent, bold: true, children: title }) }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Name: " }), _jsx(Text, { color: theme.foreground, children: service.name })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Stack: " }), _jsx(Text, { color: theme.foreground, children: service.stack || "unknown" })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Type: " }), _jsx(Text, { color: theme.warning, bold: theme.highContrast, children: (service.stack || "") === "platform" ? "PLATFORM" : "PRODUCT" })] })] }), _jsx(Box, { marginTop: 2, children: _jsxs(Text, { color: theme.muted, dimColor: theme.dimMuted, children: ["Press ", _jsx(Text, { color: theme.accent, children: "[Esc]" }), " to close"] }) })] }));
10
15
  }
11
16
  if (stack && stackMetadata) {
12
- const statusColor = getStatusColor(stackMetadata.overallStatus);
13
- const statusIcon = getStatusIcon(stackMetadata.overallStatus);
14
- return (_jsxs(Box, { width: 40, flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, justifyContent: "center", children: _jsxs(Text, { color: "cyan", bold: true, children: ["\u250C\u2500 ", stack.name.toUpperCase(), " \u2500\u2510"] }) }), _jsx(Box, { borderStyle: "single", borderColor: statusColor, paddingX: 1, paddingY: 1, marginY: 1, justifyContent: "center", children: _jsxs(Text, { color: statusColor, bold: true, children: [statusIcon, " ", stackMetadata.overallStatus.toUpperCase()] }) }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "Created: " }), _jsx(Text, { color: "white", children: formatDate(stackMetadata.createdAt) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "Resources: " }), _jsx(Text, { color: "cyan", children: stackMetadata.resourceCount })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: "gray", underline: true, children: "Resources" }) }), _jsx(Box, { flexDirection: "column", children: stack.resources.map((svc, index) => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: index === stack.resources.length - 1 ? "└─ " : "├─ " }), _jsx(Text, { color: "white", children: svc.name })] }, svc.name))) }), _jsx(Box, { marginTop: 2, children: _jsxs(Text, { color: "gray", dimColor: true, children: ["Press ", _jsx(Text, { color: "cyan", children: "[Esc]" }), " to close"] }) })] }));
17
+ const statusColor = getTUIStatusColor(theme, getStatusColor(stackMetadata.overallStatus));
18
+ const statusIcon = getTUIStatusIcon(theme, getStatusIcon(stackMetadata.overallStatus), getStatusColor(stackMetadata.overallStatus));
19
+ const title = theme.ascii
20
+ ? `--- ${stack.name.toUpperCase()} ---`
21
+ : `\u250C\u2500 ${stack.name.toUpperCase()} \u2500\u2510`;
22
+ return (_jsxs(Box, { width: 40, flexDirection: "column", borderStyle: theme.ascii ? undefined : "single", borderColor: theme.border, paddingX: 1, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, justifyContent: "center", children: _jsx(Text, { color: theme.accent, bold: true, children: title }) }), _jsx(Box, { borderStyle: theme.ascii ? undefined : "single", borderColor: statusColor, paddingX: 1, paddingY: 1, marginY: 1, justifyContent: "center", children: _jsxs(Text, { color: statusColor, bold: true, children: [statusIcon, " ", stackMetadata.overallStatus.toUpperCase()] }) }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Created: " }), _jsx(Text, { color: theme.foreground, children: formatDate(stackMetadata.createdAt) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "Resources: " }), _jsx(Text, { color: theme.accent, bold: theme.highContrast, children: stackMetadata.resourceCount })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: theme.muted, underline: true, children: "Resources" }) }), _jsx(Box, { flexDirection: "column", children: stack.resources.map((svc, index) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: theme.ascii
23
+ ? index === stack.resources.length - 1
24
+ ? "L- "
25
+ : "|- "
26
+ : index === stack.resources.length - 1
27
+ ? "\u2514\u2500 "
28
+ : "\u251C\u2500 " }), _jsx(Text, { color: theme.foreground, children: svc.name })] }, svc.name))) }), _jsx(Box, { marginTop: 2, children: _jsxs(Text, { color: theme.muted, dimColor: theme.dimMuted, children: ["Press ", _jsx(Text, { color: theme.accent, children: "[Esc]" }), " to close"] }) })] }));
15
29
  }
16
- return (_jsxs(Box, { width: 40, borderStyle: "single", borderColor: "gray", paddingX: 2, paddingY: 2, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, justifyContent: "center", children: _jsx(Text, { color: "gray", dimColor: true, children: "Select a stack or service" }) }), _jsx(Box, { justifyContent: "center", children: _jsx(Text, { color: "gray", dimColor: true, children: "to view details" }) })] }));
30
+ return (_jsxs(Box, { width: 40, borderStyle: theme.ascii ? undefined : "single", borderColor: theme.border, paddingX: 2, paddingY: 2, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, justifyContent: "center", children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: "Select a stack or service" }) }), _jsx(Box, { justifyContent: "center", children: _jsx(Text, { color: theme.muted, dimColor: theme.dimMuted, children: "to view details" }) })] }));
17
31
  };
18
32
  //# sourceMappingURL=DetailPanel.js.map