mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Compare commits
11 Commits
refactor/a
...
v0.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d43106c8 | ||
|
|
9c304eeec3 | ||
|
|
3563dd55da | ||
|
|
d06d25b1b5 | ||
|
|
84570842d3 | ||
|
|
63cae19aec | ||
|
|
c9e721bda7 | ||
|
|
d4b7a0c57d | ||
|
|
0b6e84ec6e | ||
|
|
e9c2afcc02 | ||
|
|
88864ad6bc |
158
LICENSE
158
LICENSE
@@ -1,141 +1,27 @@
|
||||
AUTOMAKER LICENSE AGREEMENT
|
||||
## Project Status
|
||||
|
||||
This License Agreement ("Agreement") is entered into between you ("Licensee") and the copyright holders of Automaker ("Licensor"). By using, copying, modifying, downloading, cloning, or distributing the Software (as defined below), you agree to be bound by the terms of this Agreement.
|
||||
|
||||
1. DEFINITIONS
|
||||
|
||||
"Software" means the Automaker software, including all source code, object code, documentation, and related materials.
|
||||
|
||||
"Generated Files" means files created by the Software during normal operation to store internal state, configuration, or working data, including but not limited to app_spec.txt, feature.json, and similar files generated by the Software. Generated Files are not considered part of the Software for the purposes of this license and are not subject to the restrictions herein.
|
||||
|
||||
"Derivative Work" means any work that is based on, derived from, or incorporates the Software or any substantial portion of it, including but not limited to modifications, forks, adaptations, translations, or any altered version of the Software.
|
||||
|
||||
"Monetization" means any activity that generates revenue, income, or commercial benefit from the Software itself or any Derivative Work, including but not limited to:
|
||||
|
||||
- Reselling, redistributing, or sublicensing the Software, any Derivative Work, or any substantial portion thereof
|
||||
- Including the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||
- Offering the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||
- Hosting the Software or any Derivative Work as a service (whether free or paid) for use by others, including cloud hosting, Software-as-a-Service (SaaS), or any other form of hosted access for third parties
|
||||
- Extracting, reselling, redistributing, or sublicensing any prompts, context, or other instructional content bundled within the Software
|
||||
- Creating, distributing, or selling modified versions, forks, or Derivative Works of the Software
|
||||
|
||||
Monetization does NOT include:
|
||||
|
||||
- Using the Software internally within your organization, regardless of whether your organization is for-profit
|
||||
- Using the Software to build products or services that generate revenue, as long as you are not reselling or redistributing the Software itself
|
||||
- Using the Software to provide services for which fees are charged, as long as the Software itself is not being resold or redistributed
|
||||
- Hosting the Software anywhere for personal use by a single developer, as long as the Software is not made accessible to others
|
||||
|
||||
"Core Contributors" means the following individuals who are granted perpetual, royalty-free licenses:
|
||||
|
||||
- Cody Seibert (webdevcody)
|
||||
- SuperComboGamer (SCG)
|
||||
- Kacper Lachowicz (Shironex, Shirone)
|
||||
- Ben Scott (trueheads)
|
||||
|
||||
2. GRANT OF LICENSE
|
||||
|
||||
Subject to the terms and conditions of this Agreement, Licensor hereby grants to Licensee a non-exclusive, non-transferable license to use, copy, modify, and distribute the Software, provided that:
|
||||
|
||||
a) Licensee may freely clone, install, and use the Software locally or within an organization for the purpose of building, developing, and maintaining other products, software, or services. There are no restrictions on the products you build _using_ the Software.
|
||||
|
||||
b) Licensee may run the Software on personal or organizational infrastructure for internal use.
|
||||
|
||||
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
|
||||
|
||||
d) Commercial licenses for the Software may be discussed and issued to external parties or companies seeking to use the Software for financial gain or Monetization purposes. Core Contributors already have full rights under section 2(c) and do not require commercial licenses. Any commercial license issued to external parties shall require a unanimous vote by all Core Contributors and shall be granted in writing and signed by all Core Contributors.
|
||||
|
||||
e) The list of individuals defined as "Core Contributors" in Section 1 shall be amended to reflect any revocation or reinstatement of status made under this section.
|
||||
|
||||
3. RESTRICTIONS
|
||||
|
||||
Licensee may NOT:
|
||||
|
||||
- Engage in any Monetization of the Software or any Derivative Work without explicit written permission from all Core Contributors
|
||||
- Resell, redistribute, or sublicense the Software, any Derivative Work, or any substantial portion thereof
|
||||
- Create, distribute, or sell modified versions, forks, or Derivative Works of the Software for any commercial purpose
|
||||
- Include the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||
- Offer the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||
- Extract, resell, redistribute, or sublicense any prompts, context, or other instructional content bundled within the Software
|
||||
- Host the Software or any Derivative Work as a service (whether free or paid) for use by others (except Core Contributors)
|
||||
- Remove or alter any copyright notices or license terms
|
||||
- Use the Software in any manner that violates applicable laws or regulations
|
||||
|
||||
Licensee MAY:
|
||||
|
||||
- Use the Software internally within their organization (commercial or non-profit)
|
||||
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
|
||||
- Modify the Software for internal use within their organization (commercial or non-profit)
|
||||
|
||||
4. CORE CONTRIBUTOR STATUS MANAGEMENT
|
||||
|
||||
a) Core Contributor status may be revoked indefinitely by the remaining Core Contributors if:
|
||||
|
||||
- A Core Contributor cannot be reached for a period of one (1) month through reasonable means of communication (including but not limited to email, Discord, GitHub, or other project communication channels)
|
||||
- AND the Core Contributor has not contributed to the project during that one-month period. For purposes of this section, "contributed" means at least one of the following activities:
|
||||
- Discussing the Software through project communication channels
|
||||
- Committing code changes to the project repository
|
||||
- Submitting bug fixes or patches
|
||||
- Participating in project-related discussions or decision-making
|
||||
|
||||
b) Revocation of Core Contributor status requires a unanimous vote by all other Core Contributors (excluding the Core Contributor whose status is being considered for revocation).
|
||||
|
||||
c) Upon revocation of Core Contributor status, the individual shall no longer be considered a Core Contributor and shall lose the rights granted under section 2(c) of this Agreement. However, any Contributions made prior to revocation shall remain subject to the terms of section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT).
|
||||
|
||||
d) A revoked Core Contributor may be reinstated to Core Contributor status with a unanimous vote by all current Core Contributors. Upon reinstatement, the individual shall regain all rights granted under section 2(c) of this Agreement.
|
||||
|
||||
5. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
|
||||
|
||||
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
|
||||
|
||||
a) **Full Ownership Transfer & Rights Grant:** You hereby assign to the Core Contributors all right, title, and interest in and to your Contributions, including all copyrights, patents, and other intellectual property rights. If such assignment is not effective under applicable law, you grant the Core Contributors an unrestricted, perpetual, worldwide, non-exclusive, royalty-free, fully paid-up, irrevocable, sublicensable, and transferable license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute, perform, display, and otherwise exploit your Contributions in any manner they see fit, including for any commercial purpose or Monetization.
|
||||
|
||||
b) **No Take-Backs:** You understand and agree that this grant of rights is irrevocable ("no take-backs"). You cannot revoke, rescind, or terminate this grant of rights once your Contribution has been submitted.
|
||||
|
||||
c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with respect to attribution of authorship or integrity of materials regarding your Contributions that you may have under any applicable law.
|
||||
|
||||
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
|
||||
|
||||
6. TERMINATION
|
||||
|
||||
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
|
||||
|
||||
7. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
|
||||
|
||||
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
|
||||
|
||||
b) **USE AT YOUR OWN RISK:** YOU AGREE THAT YOUR USE OF THE SOFTWARE IS SOLELY AT YOUR OWN RISK. THE CORE CONTRIBUTORS AND LICENSOR DO NOT GUARANTEE THAT THE SOFTWARE OR ANY CODE GENERATED BY IT WILL BE SAFE, BUG-FREE, OR FUNCTIONAL.
|
||||
|
||||
c) **NO WARRANTY:** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
||||
|
||||
d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, INCLUDING BUT NOT LIMITED TO:
|
||||
|
||||
- DAMAGE TO HARDWARE OR COMPUTER SYSTEMS
|
||||
- DATA LOSS OR CORRUPTION
|
||||
- GENERATION OF BAD, VULNERABLE, OR MALICIOUS CODE
|
||||
- FINANCIAL LOSSES
|
||||
- BUSINESS INTERRUPTION
|
||||
|
||||
8. LICENSE AMENDMENTS
|
||||
|
||||
Any amendment, modification, or update to this License Agreement must be agreed upon unanimously by all Core Contributors. No changes to this Agreement shall be effective unless all Core Contributors have provided their written consent or approval through a unanimous vote.
|
||||
|
||||
9. CONTACT
|
||||
|
||||
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
|
||||
|
||||
- Agentic Jumpstart Discord: https://discord.gg/JUDWZDN3VT
|
||||
- Website: https://automaker.app
|
||||
- Email: automakerapp@gmail.com
|
||||
|
||||
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
|
||||
|
||||
10. GOVERNING LAW
|
||||
|
||||
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.
|
||||
|
||||
By using the Software, you acknowledge that you have read this Agreement, understand it, and agree to be bound by its terms and conditions.
|
||||
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Automaker Core Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
55
README.md
55
README.md
@@ -14,6 +14,10 @@
|
||||
|
||||
**Stop typing code. Start directing AI agents.**
|
||||
|
||||
> **[!WARNING]**
|
||||
>
|
||||
> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed.
|
||||
|
||||
<details open>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
|
||||
@@ -288,6 +292,31 @@ services:
|
||||
|
||||
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
|
||||
|
||||
> **⚠️ Important: Linux/WSL Users**
|
||||
>
|
||||
> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user:
|
||||
>
|
||||
> ```bash
|
||||
> # Check your UID/GID
|
||||
> id -u # outputs your UID (e.g., 1000)
|
||||
> id -g # outputs your GID (e.g., 1000)
|
||||
> ```
|
||||
>
|
||||
> Create a `.env` file in the automaker directory:
|
||||
>
|
||||
> ```
|
||||
> UID=1000
|
||||
> GID=1000
|
||||
> ```
|
||||
>
|
||||
> Then rebuild the images:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose build
|
||||
> ```
|
||||
>
|
||||
> Without this, files written by the container will be inaccessible to your host user.
|
||||
|
||||
##### GitHub CLI Authentication (For Git Push/PR Operations)
|
||||
|
||||
To enable git push and GitHub CLI operations inside the container:
|
||||
@@ -644,26 +673,10 @@ Join the **Agentic Jumpstart** Discord to connect with other builders exploring
|
||||
|
||||
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
|
||||
## Project Status
|
||||
|
||||
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Automaker License Agreement**. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
**Summary of Terms:**
|
||||
|
||||
- **Allowed:**
|
||||
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
||||
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
|
||||
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||
|
||||
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||
- **No Resale:** You cannot resell Automaker itself.
|
||||
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||
|
||||
- **Liability:**
|
||||
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
|
||||
|
||||
- **Contributing:**
|
||||
- By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment).
|
||||
|
||||
**Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization.
|
||||
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
@@ -390,7 +390,7 @@ const server = createServer(app);
|
||||
// WebSocket servers using noServer mode for proper multi-path support
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
const terminalService = getTerminalService(settingsService);
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade requests
|
||||
|
||||
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Terminal Theme Data - Re-export terminal themes from platform package
|
||||
*
|
||||
* This module re-exports terminal theme data for use in the server.
|
||||
*/
|
||||
|
||||
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import type { TerminalTheme } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Get terminal theme colors for a given theme mode
|
||||
*/
|
||||
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
|
||||
return getThemeColors(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all terminal themes
|
||||
*/
|
||||
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
|
||||
return terminalThemeColors;
|
||||
}
|
||||
|
||||
export default terminalThemeColors;
|
||||
@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
|
||||
import {
|
||||
CliProvider,
|
||||
type CliSpawnConfig,
|
||||
@@ -286,15 +287,113 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
|
||||
windowsStrategy: 'direct',
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||
'/usr/local/bin/cursor-agent',
|
||||
],
|
||||
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
|
||||
// Windows paths are not used - we check for WSL installation instead
|
||||
win32: [],
|
||||
win32: [
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -487,6 +586,92 @@ export class CursorProvider extends CliProvider {
|
||||
* 2. Cursor IDE with 'cursor agent' subcommand support
|
||||
*/
|
||||
protected detectCli(): CliDetectionResult {
|
||||
if (process.platform === 'win32') {
|
||||
const findInPath = (command: string): string | null => {
|
||||
try {
|
||||
const result = execSync(`where ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
.trim()
|
||||
.split(/\r?\n/)[0];
|
||||
|
||||
if (result && fs.existsSync(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isCursorAgentBinary = (cliPath: string) =>
|
||||
cliPath.toLowerCase().includes('cursor-agent');
|
||||
|
||||
const supportsCursorAgentSubcommand = (cliPath: string) => {
|
||||
try {
|
||||
execSync(`"${cliPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
|
||||
if (pathResult) {
|
||||
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
|
||||
return {
|
||||
cliPath: pathResult,
|
||||
useWsl: false,
|
||||
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const config = this.getSpawnConfig();
|
||||
for (const candidate of config.commonPaths.win32 || []) {
|
||||
const resolved = candidate;
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
|
||||
return {
|
||||
cliPath: resolved,
|
||||
useWsl: false,
|
||||
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wslLogger = (msg: string) => logger.debug(msg);
|
||||
if (isWslAvailable({ logger: wslLogger })) {
|
||||
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
|
||||
if (wslResult) {
|
||||
logger.debug(
|
||||
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
|
||||
);
|
||||
return {
|
||||
cliPath: 'wsl.exe',
|
||||
useWsl: true,
|
||||
wslCliPath: wslResult.wslPath,
|
||||
wslDistribution: wslResult.distribution,
|
||||
strategy: 'wsl',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('cursor-agent not found on Windows');
|
||||
return { cliPath: null, useWsl: false, strategy: 'direct' };
|
||||
}
|
||||
|
||||
// First try standard detection (PATH, common paths, WSL)
|
||||
const result = super.detectCli();
|
||||
if (result.cliPath) {
|
||||
@@ -495,7 +680,7 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// Cursor-specific: Check versions directory for any installed version
|
||||
// This handles cases where cursor-agent is installed but not in PATH
|
||||
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
try {
|
||||
const versions = fs
|
||||
.readdirSync(CursorProvider.VERSIONS_DIR)
|
||||
@@ -521,33 +706,31 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
||||
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
||||
if (process.platform !== 'win32') {
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from '../../../lib/enhancement-prompts.js';
|
||||
import {
|
||||
extractTechnologyStack,
|
||||
extractXmlElements,
|
||||
extractXmlSection,
|
||||
unescapeXml,
|
||||
} from '../../../lib/xml-extractor.js';
|
||||
|
||||
const logger = createLogger('EnhancePrompt');
|
||||
|
||||
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function buildProjectContext(projectPath: string): Promise<string | null> {
|
||||
const contextBlocks: string[] = [];
|
||||
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
|
||||
const projectName = extractXmlSection(specContent, 'project_name');
|
||||
const overview = extractXmlSection(specContent, 'overview');
|
||||
const techStack = extractTechnologyStack(specContent);
|
||||
const coreSection = extractXmlSection(specContent, 'core_capabilities');
|
||||
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
|
||||
|
||||
const summaryLines: string[] = [];
|
||||
if (projectName) {
|
||||
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
|
||||
}
|
||||
if (overview) {
|
||||
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
|
||||
}
|
||||
if (techStack.length > 0) {
|
||||
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
|
||||
}
|
||||
if (coreCapabilities.length > 0) {
|
||||
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
|
||||
}
|
||||
|
||||
if (summaryLines.length > 0) {
|
||||
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('No app_spec.txt context available for enhancement', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const featureLoader = new FeatureLoader();
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
const featureTitles = features
|
||||
.map((feature) => feature.title || feature.name || feature.id)
|
||||
.filter((title) => Boolean(title));
|
||||
|
||||
if (featureTitles.length > 0) {
|
||||
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
|
||||
contextBlocks.push(
|
||||
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
|
||||
featureTitles.length > 30 ? '\n- ...' : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to load existing features for enhancement context', error);
|
||||
}
|
||||
|
||||
if (contextBlocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contextBlocks.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
|
||||
if (projectContext) {
|
||||
logger.debug('Including project context in enhancement prompt');
|
||||
}
|
||||
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
@@ -156,7 +229,7 @@ export function createEnhanceHandler(
|
||||
// The system prompt is combined with user prompt since some providers
|
||||
// don't have a separate system prompt concept
|
||||
const result = await simpleQuery({
|
||||
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
||||
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
|
||||
model: resolvedModel,
|
||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||
maxTurns: 1,
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getImagesDir } from '@automaker/platform';
|
||||
import { sanitizeFilename } from '@automaker/utils';
|
||||
|
||||
export function createSaveImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -39,7 +40,7 @@ export function createSaveImageHandler() {
|
||||
// Generate unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(filename) || '.png';
|
||||
const baseName = path.basename(filename, ext);
|
||||
const baseName = sanitizeFilename(path.basename(filename, ext), 'image');
|
||||
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
||||
const filePath = path.join(imagesDir, uniqueFilename);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
import { setLogLevel, LogLevel } from '@automaker/utils';
|
||||
import { setRequestLoggingEnabled } from '../../../index.js';
|
||||
import { getTerminalService } from '../../../services/terminal-service.js';
|
||||
|
||||
/**
|
||||
* Map server log level string to LogLevel enum
|
||||
@@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
|
||||
// Get old settings to detect theme changes
|
||||
const oldSettings = await settingsService.getGlobalSettings();
|
||||
const oldTheme = oldSettings?.theme;
|
||||
|
||||
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
logger.info(
|
||||
@@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
settings.projects?.length ?? 0
|
||||
);
|
||||
|
||||
// Handle theme change - regenerate terminal RC files for all projects
|
||||
if ('theme' in updates && updates.theme && updates.theme !== oldTheme) {
|
||||
const terminalService = getTerminalService(settingsService);
|
||||
const newTheme = updates.theme;
|
||||
|
||||
logger.info(
|
||||
`[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files`
|
||||
);
|
||||
|
||||
// Regenerate RC files for all projects with terminal config enabled
|
||||
const projects = settings.projects || [];
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const projectSettings = await settingsService.getProjectSettings(project.path);
|
||||
// Check if terminal config is enabled (global or project-specific)
|
||||
const terminalConfigEnabled =
|
||||
projectSettings.terminalConfig?.enabled !== false &&
|
||||
settings.terminalConfig?.enabled === true;
|
||||
|
||||
if (terminalConfigEnabled) {
|
||||
await terminalService.onThemeChange(project.path, newTheme);
|
||||
logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply server log level if it was updated
|
||||
if ('serverLogLevel' in updates && updates.serverLogLevel) {
|
||||
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
|
||||
|
||||
@@ -16,6 +16,21 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function isUntrackedLine(line: string): boolean {
|
||||
return line.startsWith('?? ');
|
||||
}
|
||||
|
||||
function isExcludedWorktreeLine(line: string): boolean {
|
||||
return line.includes('.worktrees/') || line.endsWith('.worktrees');
|
||||
}
|
||||
|
||||
function isBlockingChangeLine(line: string): boolean {
|
||||
if (!line.trim()) return false;
|
||||
if (isExcludedWorktreeLine(line)) return false;
|
||||
if (isUntrackedLine(line)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are uncommitted changes in the working directory
|
||||
* Excludes .worktrees/ directory which is created by automaker
|
||||
@@ -23,15 +38,7 @@ const execAsync = promisify(exec);
|
||||
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory (created by automaker)
|
||||
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
|
||||
return true;
|
||||
});
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -45,15 +52,7 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
async function getChangesSummary(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --short', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory
|
||||
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
|
||||
return true;
|
||||
});
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
if (lines.length === 0) return '';
|
||||
if (lines.length <= 5) return lines.join(', ');
|
||||
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;
|
||||
|
||||
@@ -2657,13 +2657,67 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
// Load feature for commit message
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
const commitMessage = feature
|
||||
? `feat: ${this.extractTitleFromDescription(
|
||||
feature.description
|
||||
)}\n\nImplemented by Automaker auto-mode`
|
||||
: `feat: Feature ${featureId}`;
|
||||
? await this.generateCommitMessage(feature, workDir)
|
||||
: `feat: Feature ${featureId}\n\nImplemented by Automaker auto-mode`;
|
||||
|
||||
// Stage and commit
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
// Determine which files to stage
|
||||
// For feature branches, only stage files changed on this branch to avoid committing unrelated changes
|
||||
let filesToStage: string[] = [];
|
||||
|
||||
try {
|
||||
// Get the current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: workDir,
|
||||
});
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
// Get the base branch (usually main/master)
|
||||
const { stdout: baseBranchOutput } = await execAsync(
|
||||
'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/main"',
|
||||
{ cwd: workDir }
|
||||
);
|
||||
const baseBranch = baseBranchOutput.trim().replace('refs/remotes/origin/', '');
|
||||
|
||||
// If we're on a feature branch (not the base branch), only stage files changed on this branch
|
||||
if (branch !== baseBranch && feature?.branchName) {
|
||||
try {
|
||||
// Get files changed on this branch compared to base
|
||||
const { stdout: branchFiles } = await execAsync(
|
||||
`git diff --name-only ${baseBranch}...HEAD`,
|
||||
{ cwd: workDir }
|
||||
);
|
||||
|
||||
if (branchFiles.trim()) {
|
||||
filesToStage = branchFiles.trim().split('\n').filter(Boolean);
|
||||
logger.info(`Staging ${filesToStage.length} files changed on branch ${branch}`);
|
||||
}
|
||||
} catch (diffError) {
|
||||
// If diff fails (e.g., base branch doesn't exist), fall back to staging all changes
|
||||
logger.warn(`Could not diff against base branch, staging all changes: ${diffError}`);
|
||||
filesToStage = [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Could not determine branch-specific files: ${error}`);
|
||||
}
|
||||
|
||||
// Stage files
|
||||
if (filesToStage.length > 0) {
|
||||
// Stage only the specific files changed on this branch
|
||||
for (const file of filesToStage) {
|
||||
try {
|
||||
await execAsync(`git add "${file.replace(/"/g, '\\"')}"`, { cwd: workDir });
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to stage file ${file}: ${error}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: stage all changes (original behavior)
|
||||
// This happens for main branch features or when branch detection fails
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
}
|
||||
|
||||
// Commit
|
||||
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
|
||||
cwd: workDir,
|
||||
});
|
||||
@@ -3664,13 +3718,14 @@ Format your response as a structured markdown document.`;
|
||||
// Recovery cases:
|
||||
// 1. Standard pending/ready/backlog statuses
|
||||
// 2. Features with approved plans that have incomplete tasks (crash recovery)
|
||||
// 3. Features stuck in 'in_progress' status (crash recovery)
|
||||
// 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery)
|
||||
// 4. Features with 'generating' planSpec status (spec generation was interrupted)
|
||||
const needsRecovery =
|
||||
feature.status === 'pending' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'in_progress' || // Recover features that were in progress when server crashed
|
||||
feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown
|
||||
(feature.planSpec?.status === 'approved' &&
|
||||
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ||
|
||||
feature.planSpec?.status === 'generating'; // Recover interrupted spec generation
|
||||
@@ -3710,7 +3765,7 @@ Format your response as a structured markdown document.`;
|
||||
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}`
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}`
|
||||
);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
@@ -3840,6 +3895,58 @@ Format your response as a structured markdown document.`;
|
||||
return firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comprehensive commit message for a feature
|
||||
* Includes title, description summary, and file statistics
|
||||
*/
|
||||
private async generateCommitMessage(feature: Feature, workDir: string): Promise<string> {
|
||||
const title = this.extractTitleFromDescription(feature.description);
|
||||
|
||||
// Extract description summary (first 3-5 lines, up to 300 chars)
|
||||
let descriptionSummary = '';
|
||||
if (feature.description && feature.description.trim()) {
|
||||
const lines = feature.description.split('\n').filter((l) => l.trim());
|
||||
const summaryLines = lines.slice(0, 5); // First 5 non-empty lines
|
||||
descriptionSummary = summaryLines.join('\n');
|
||||
|
||||
// Limit to 300 characters
|
||||
if (descriptionSummary.length > 300) {
|
||||
descriptionSummary = descriptionSummary.substring(0, 297) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Get file statistics to add context
|
||||
let fileStats = '';
|
||||
try {
|
||||
const { stdout: diffStat } = await execAsync('git diff --cached --stat', { cwd: workDir });
|
||||
if (diffStat.trim()) {
|
||||
// Extract just the summary line (last line with file count)
|
||||
const statLines = diffStat.trim().split('\n');
|
||||
const summaryLine = statLines[statLines.length - 1];
|
||||
if (summaryLine && summaryLine.includes('file')) {
|
||||
fileStats = `\n${summaryLine.trim()}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors getting stats
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
let message = `feat: ${title}`;
|
||||
|
||||
if (descriptionSummary && descriptionSummary !== title) {
|
||||
message += `\n\n${descriptionSummary}`;
|
||||
}
|
||||
|
||||
if (fileStats) {
|
||||
message += fileStats;
|
||||
}
|
||||
|
||||
message += '\n\nImplemented by Automaker auto-mode';
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the planning prompt prefix based on feature's planning mode
|
||||
*/
|
||||
@@ -5430,9 +5537,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||
// Check if feature was interrupted (in_progress/interrupted or pipeline_*)
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
feature.status === 'interrupted' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
// Check if context (agent-output.md) exists
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface DevServerInfo {
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
// Flag to indicate server is stopping (prevents output after stop)
|
||||
stopping: boolean;
|
||||
// Flag to indicate if URL has been detected from output
|
||||
urlDetected: boolean;
|
||||
}
|
||||
|
||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||
@@ -103,6 +105,54 @@ class DevServerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect actual server URL from output
|
||||
* Parses stdout/stderr for common URL patterns from dev servers
|
||||
*/
|
||||
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
||||
// Skip if URL already detected
|
||||
if (server.urlDetected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Common URL patterns from various dev servers:
|
||||
// - Vite: "Local: http://localhost:5173/"
|
||||
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
|
||||
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
|
||||
// - Generic: Any http:// or https:// URL
|
||||
const urlPatterns = [
|
||||
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
||||
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
||||
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
||||
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
|
||||
];
|
||||
|
||||
for (const pattern of urlPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const detectedUrl = match[1].trim();
|
||||
// Validate it looks like a reasonable URL
|
||||
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
|
||||
server.url = detectedUrl;
|
||||
server.urlDetected = true;
|
||||
logger.info(
|
||||
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
|
||||
);
|
||||
|
||||
// Emit URL update event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
worktreePath: server.worktreePath,
|
||||
url: detectedUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stdout/stderr data from dev server process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
@@ -115,6 +165,9 @@ class DevServerService {
|
||||
|
||||
const content = data.toString();
|
||||
|
||||
// Try to detect actual server URL from output
|
||||
this.detectUrlFromOutput(server, content);
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(server, content);
|
||||
|
||||
@@ -446,13 +499,14 @@ class DevServerService {
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://${hostname}:${port}`,
|
||||
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
|
||||
@@ -13,6 +13,14 @@ import * as path from 'path';
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js';
|
||||
import {
|
||||
getRcFilePath,
|
||||
getTerminalDir,
|
||||
ensureRcFilesUpToDate,
|
||||
type TerminalConfig,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
@@ -24,6 +32,27 @@ import {
|
||||
getShellPaths,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const BASH_LOGIN_ARG = '--login';
|
||||
const BASH_RCFILE_ARG = '--rcfile';
|
||||
const SHELL_NAME_BASH = 'bash';
|
||||
const SHELL_NAME_ZSH = 'zsh';
|
||||
const SHELL_NAME_SH = 'sh';
|
||||
const DEFAULT_SHOW_USER_HOST = true;
|
||||
const DEFAULT_SHOW_PATH = true;
|
||||
const DEFAULT_SHOW_TIME = false;
|
||||
const DEFAULT_SHOW_EXIT_STATUS = false;
|
||||
const DEFAULT_PATH_DEPTH = 0;
|
||||
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
|
||||
const DEFAULT_CUSTOM_PROMPT = true;
|
||||
const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard';
|
||||
const DEFAULT_SHOW_GIT_BRANCH = true;
|
||||
const DEFAULT_SHOW_GIT_STATUS = true;
|
||||
const DEFAULT_CUSTOM_ALIASES = '';
|
||||
const DEFAULT_CUSTOM_ENV_VARS: Record<string, string> = {};
|
||||
const PROMPT_THEME_CUSTOM = 'custom';
|
||||
const PROMPT_THEME_PREFIX = 'omp-';
|
||||
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
@@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
|
||||
function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] {
|
||||
const sanitizedArgs: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === BASH_LOGIN_ARG) {
|
||||
continue;
|
||||
}
|
||||
if (arg === BASH_RCFILE_ARG) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
sanitizedArgs.push(arg);
|
||||
}
|
||||
|
||||
sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath);
|
||||
return sanitizedArgs;
|
||||
}
|
||||
|
||||
function normalizePathStyle(
|
||||
pathStyle: TerminalConfig['pathStyle'] | undefined
|
||||
): TerminalConfig['pathStyle'] {
|
||||
if (pathStyle === 'short' || pathStyle === 'basename') {
|
||||
return pathStyle;
|
||||
}
|
||||
return DEFAULT_PATH_STYLE;
|
||||
}
|
||||
|
||||
function normalizePathDepth(pathDepth: number | undefined): number {
|
||||
const depth =
|
||||
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
|
||||
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
|
||||
}
|
||||
|
||||
function getShellBasename(shellPath: string): string {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
}
|
||||
|
||||
function getShellArgsForPath(shellPath: string): string[] {
|
||||
const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', '');
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
if (shellName === SHELL_NAME_SH) {
|
||||
return [];
|
||||
}
|
||||
return [BASH_LOGIN_ARG];
|
||||
}
|
||||
|
||||
function resolveOmpThemeName(promptTheme: string | undefined): string | null {
|
||||
if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) {
|
||||
return promptTheme.slice(PROMPT_THEME_PREFIX.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEffectiveTerminalConfig(
|
||||
globalTerminalConfig: TerminalConfig | undefined,
|
||||
projectTerminalConfig: Partial<TerminalConfig> | undefined
|
||||
): TerminalConfig {
|
||||
const mergedEnvVars = {
|
||||
...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||
...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false,
|
||||
customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
|
||||
promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT,
|
||||
showGitBranch:
|
||||
projectTerminalConfig?.showGitBranch ??
|
||||
globalTerminalConfig?.showGitBranch ??
|
||||
DEFAULT_SHOW_GIT_BRANCH,
|
||||
showGitStatus:
|
||||
projectTerminalConfig?.showGitStatus ??
|
||||
globalTerminalConfig?.showGitStatus ??
|
||||
DEFAULT_SHOW_GIT_STATUS,
|
||||
showUserHost:
|
||||
projectTerminalConfig?.showUserHost ??
|
||||
globalTerminalConfig?.showUserHost ??
|
||||
DEFAULT_SHOW_USER_HOST,
|
||||
showPath:
|
||||
projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH,
|
||||
pathStyle: normalizePathStyle(
|
||||
projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle
|
||||
),
|
||||
pathDepth: normalizePathDepth(
|
||||
projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth
|
||||
),
|
||||
showTime:
|
||||
projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME,
|
||||
showExitStatus:
|
||||
projectTerminalConfig?.showExitStatus ??
|
||||
globalTerminalConfig?.showExitStatus ??
|
||||
DEFAULT_SHOW_EXIT_STATUS,
|
||||
customAliases:
|
||||
projectTerminalConfig?.customAliases ??
|
||||
globalTerminalConfig?.customAliases ??
|
||||
DEFAULT_CUSTOM_ALIASES,
|
||||
customEnvVars: mergedEnvVars,
|
||||
rcFileVersion: globalTerminalConfig?.rcFileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
@@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter {
|
||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||
private settingsService: SettingsService | null = null;
|
||||
|
||||
constructor(settingsService?: SettingsService) {
|
||||
super();
|
||||
this.settingsService = settingsService || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
@@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter {
|
||||
const platform = os.platform();
|
||||
const shellPaths = getShellPaths();
|
||||
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === 'sh') {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ['--login'];
|
||||
};
|
||||
|
||||
// Check if running in WSL - prefer user's shell or bash with --login
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell) {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
if (
|
||||
allowedShell === userShell ||
|
||||
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||
) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
@@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter {
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
return { shell, args: getShellArgsForPath(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue
|
||||
@@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter {
|
||||
if (userShell && platform !== 'win32') {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
if (
|
||||
allowedShell === userShell ||
|
||||
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||
) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
@@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter {
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
return { shell, args: getShellArgsForPath(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue to next
|
||||
@@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
||||
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell();
|
||||
const shell = options.shell || detectedShell;
|
||||
let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs];
|
||||
|
||||
// Validate and resolve working directory
|
||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||
@@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal config injection (custom prompts, themes)
|
||||
const terminalConfigEnv: Record<string, string> = {};
|
||||
if (this.settingsService) {
|
||||
try {
|
||||
logger.info(
|
||||
`[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}`
|
||||
);
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const projectSettings = options.cwd
|
||||
? await this.settingsService.getProjectSettings(options.cwd)
|
||||
: null;
|
||||
|
||||
const globalTerminalConfig = globalSettings?.terminalConfig;
|
||||
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||
const effectiveConfig = buildEffectiveTerminalConfig(
|
||||
globalTerminalConfig,
|
||||
projectTerminalConfig
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
|
||||
);
|
||||
logger.info(
|
||||
`[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}`
|
||||
);
|
||||
|
||||
if (effectiveConfig.enabled && globalTerminalConfig) {
|
||||
const currentTheme = globalSettings?.theme || 'dark';
|
||||
const themeColors = getTerminalThemeColors(currentTheme);
|
||||
const allThemes = getAllTerminalThemes();
|
||||
const promptTheme =
|
||||
projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme;
|
||||
const ompThemeName = resolveOmpThemeName(promptTheme);
|
||||
|
||||
// Ensure RC files are up to date
|
||||
await ensureRcFilesUpToDate(
|
||||
options.cwd || cwd,
|
||||
currentTheme,
|
||||
effectiveConfig,
|
||||
themeColors,
|
||||
allThemes
|
||||
);
|
||||
|
||||
// Set shell-specific env vars
|
||||
const shellName = getShellBasename(shell).toLowerCase();
|
||||
if (ompThemeName && effectiveConfig.customPrompt) {
|
||||
terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName;
|
||||
}
|
||||
|
||||
if (shellName.includes(SHELL_NAME_BASH)) {
|
||||
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
|
||||
terminalConfigEnv.BASH_ENV = bashRcFilePath;
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
|
||||
} else if (shellName.includes(SHELL_NAME_ZSH)) {
|
||||
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName === SHELL_NAME_SH) {
|
||||
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
}
|
||||
|
||||
// Add custom env vars from config
|
||||
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);
|
||||
|
||||
logger.info(
|
||||
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
@@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter {
|
||||
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
|
||||
...options.env,
|
||||
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
|
||||
};
|
||||
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
@@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter {
|
||||
return () => this.exitCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle theme change - regenerate RC files with new theme colors
|
||||
*/
|
||||
async onThemeChange(projectPath: string, newTheme: string): Promise<void> {
|
||||
if (!this.settingsService) {
|
||||
logger.warn('[onThemeChange] SettingsService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const terminalConfig = globalSettings?.terminalConfig;
|
||||
const projectSettings = await this.settingsService.getProjectSettings(projectPath);
|
||||
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||
const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig);
|
||||
|
||||
if (effectiveConfig.enabled && terminalConfig) {
|
||||
const themeColors = getTerminalThemeColors(
|
||||
newTheme as import('@automaker/types').ThemeMode
|
||||
);
|
||||
const allThemes = getAllTerminalThemes();
|
||||
|
||||
// Regenerate RC files with new theme
|
||||
await ensureRcFilesUpToDate(
|
||||
projectPath,
|
||||
newTheme as import('@automaker/types').ThemeMode,
|
||||
effectiveConfig,
|
||||
themeColors,
|
||||
allThemes
|
||||
);
|
||||
|
||||
logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all sessions
|
||||
*/
|
||||
@@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter {
|
||||
// Singleton instance
|
||||
let terminalService: TerminalService | null = null;
|
||||
|
||||
export function getTerminalService(): TerminalService {
|
||||
export function getTerminalService(settingsService?: SettingsService): TerminalService {
|
||||
if (!terminalService) {
|
||||
terminalService = new TerminalService();
|
||||
terminalService = new TerminalService(settingsService);
|
||||
}
|
||||
return terminalService;
|
||||
}
|
||||
|
||||
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal file
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
exec: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: unknown) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js';
|
||||
|
||||
const mockExec = exec as Mock;
|
||||
|
||||
describe('switch-branch route', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
it('should allow switching when only untracked files exist', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/repo/path',
|
||||
branchName: 'feature/test',
|
||||
};
|
||||
|
||||
mockExec.mockImplementation(async (command: string) => {
|
||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||
return { stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git rev-parse --verify feature/test') {
|
||||
return { stdout: 'abc123\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --porcelain') {
|
||||
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git checkout "feature/test"') {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
const handler = createSwitchBranchHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch: 'main',
|
||||
currentBranch: 'feature/test',
|
||||
message: "Switched to branch 'feature/test'",
|
||||
},
|
||||
});
|
||||
expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' });
|
||||
});
|
||||
|
||||
it('should block switching when tracked files are modified', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/repo/path',
|
||||
branchName: 'feature/test',
|
||||
};
|
||||
|
||||
mockExec.mockImplementation(async (command: string) => {
|
||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||
return { stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git rev-parse --verify feature/test') {
|
||||
return { stdout: 'abc123\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --porcelain') {
|
||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --short') {
|
||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
const handler = createSwitchBranchHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.',
|
||||
code: 'UNCOMMITTED_CHANGES',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
describe('auto-mode-service.ts', () => {
|
||||
let service: AutoModeService;
|
||||
@@ -842,4 +845,76 @@ describe('auto-mode-service.ts', () => {
|
||||
expect(service.isFeatureRunning('feature-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interrupted recovery', () => {
|
||||
async function createFeatureFixture(
|
||||
projectPath: string,
|
||||
feature: Partial<Feature> & Pick<Feature, 'id'>
|
||||
): Promise<string> {
|
||||
const featureDir = path.join(projectPath, '.automaker', 'features', feature.id);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, 'feature.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
title: 'Feature',
|
||||
description: 'Feature description',
|
||||
category: 'implementation',
|
||||
status: 'backlog',
|
||||
...feature,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return featureDir;
|
||||
}
|
||||
|
||||
it('should resume features marked as interrupted after restart', async () => {
|
||||
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-resume-'));
|
||||
try {
|
||||
const featureDir = await createFeatureFixture(projectPath, {
|
||||
id: 'feature-interrupted',
|
||||
status: 'interrupted',
|
||||
});
|
||||
await fs.writeFile(path.join(featureDir, 'agent-output.md'), 'partial progress');
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-complete',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const resumeFeatureMock = vi.fn().mockResolvedValue(undefined);
|
||||
(service as any).resumeFeature = resumeFeatureMock;
|
||||
|
||||
await (service as any).resumeInterruptedFeatures(projectPath);
|
||||
|
||||
expect(resumeFeatureMock).toHaveBeenCalledTimes(1);
|
||||
expect(resumeFeatureMock).toHaveBeenCalledWith(projectPath, 'feature-interrupted', true);
|
||||
} finally {
|
||||
await fs.rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should include interrupted features in pending recovery candidates', async () => {
|
||||
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-pending-'));
|
||||
try {
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-interrupted',
|
||||
status: 'interrupted',
|
||||
});
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-waiting-approval',
|
||||
status: 'waiting_approval',
|
||||
});
|
||||
|
||||
const pendingFeatures = await (service as any).loadPendingFeatures(projectPath, null);
|
||||
const pendingIds = pendingFeatures.map((feature: Feature) => feature.id);
|
||||
|
||||
expect(pendingIds).toContain('feature-interrupted');
|
||||
expect(pendingIds).not.toContain('feature-waiting-approval');
|
||||
} finally {
|
||||
await fs.rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,6 +380,148 @@ describe('dev-server-service.ts', () => {
|
||||
expect(service.listDevServers().result.servers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL detection from output', () => {
|
||||
it('should detect Vite format URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
// Start server
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate Vite output
|
||||
mockProcess.stdout.emit('data', Buffer.from(' VITE v5.0.0 ready in 123 ms\n'));
|
||||
mockProcess.stdout.emit('data', Buffer.from(' ➜ Local: http://localhost:5173/\n'));
|
||||
|
||||
// Give it a moment to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Next.js format URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate Next.js output
|
||||
mockProcess.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('ready - started server on 0.0.0.0:3000, url: http://localhost:3000\n')
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect generic localhost URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate generic output with URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server running at http://localhost:8080\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:8080');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep initial URL if no URL detected in output', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
const result = await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate output without URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server starting...\n'));
|
||||
mockProcess.stdout.emit('data', Buffer.from('Ready!\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
// Should keep the initial allocated URL
|
||||
expect(serverInfo?.url).toBe(result.result?.url);
|
||||
expect(serverInfo?.urlDetected).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect HTTPS URLs', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate HTTPS dev server
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('https://localhost:3443');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should only detect URL once (not update after first detection)', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// First URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const firstUrl = service.getServerInfo(testDir)?.url;
|
||||
|
||||
// Try to emit another URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Network: http://192.168.1.1:5173/\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should keep the first detected URL
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe(firstUrl);
|
||||
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock child process
|
||||
|
||||
@@ -405,9 +405,28 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator - shows there's more content below */}
|
||||
{canScrollDown && sidebarOpen && (
|
||||
<div className="flex justify-center py-1 border-t border-border/30">
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
|
||||
{canScrollDown && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex justify-center py-2 border-t border-border/30',
|
||||
'bg-gradient-to-t from-background via-background/95 to-transparent',
|
||||
'-mt-8 pt-8',
|
||||
'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto flex flex-col items-center gap-0.5">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-brand-500/70 animate-bounce',
|
||||
sidebarOpen ? 'block' : 'w-3 h-3'
|
||||
)}
|
||||
/>
|
||||
{sidebarOpen && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||
Scroll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -437,6 +437,63 @@ export function BoardView() {
|
||||
// Auto mode hook - pass current worktree to get worktree-specific state
|
||||
// Must be after selectedWorktree is defined
|
||||
const autoMode = useAutoMode(selectedWorktree);
|
||||
|
||||
const refreshBoardState = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const projectPath = currentProject.path;
|
||||
const beforeFeatures = (
|
||||
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||
)?.length;
|
||||
const beforeWorktrees = (
|
||||
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||
| { worktrees?: unknown[] }
|
||||
| undefined
|
||||
)?.worktrees?.length;
|
||||
const beforeRunningAgents = (
|
||||
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||
)?.count;
|
||||
const beforeAutoModeRunning = autoMode.isRunning;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.features.all(projectPath) }),
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.runningAgents.all() }),
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.worktrees.all(projectPath) }),
|
||||
autoMode.refreshStatus(),
|
||||
]);
|
||||
|
||||
const afterFeatures = (
|
||||
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||
)?.length;
|
||||
const afterWorktrees = (
|
||||
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||
| { worktrees?: unknown[] }
|
||||
| undefined
|
||||
)?.worktrees?.length;
|
||||
const afterRunningAgents = (
|
||||
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||
)?.count;
|
||||
const afterAutoModeRunning = autoMode.isRunning;
|
||||
|
||||
if (
|
||||
beforeFeatures !== afterFeatures ||
|
||||
beforeWorktrees !== afterWorktrees ||
|
||||
beforeRunningAgents !== afterRunningAgents ||
|
||||
beforeAutoModeRunning !== afterAutoModeRunning
|
||||
) {
|
||||
logger.info('[Board] Refresh detected state mismatch', {
|
||||
features: { before: beforeFeatures, after: afterFeatures },
|
||||
worktrees: { before: beforeWorktrees, after: afterWorktrees },
|
||||
runningAgents: { before: beforeRunningAgents, after: afterRunningAgents },
|
||||
autoModeRunning: { before: beforeAutoModeRunning, after: afterAutoModeRunning },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Board] Failed to refresh board state:', error);
|
||||
toast.error('Failed to refresh board state');
|
||||
}
|
||||
}, [autoMode, currentProject, queryClient]);
|
||||
// Get runningTasks from the hook (scoped to current project/worktree)
|
||||
const runningAutoTasks = autoMode.runningTasks;
|
||||
// Get worktree-specific maxConcurrency from the hook
|
||||
@@ -1321,6 +1378,7 @@ export function BoardView() {
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onRefreshBoard={refreshBoardState}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Wand2, GitBranch, ClipboardCheck, RefreshCw } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
@@ -35,6 +37,7 @@ interface BoardHeaderProps {
|
||||
creatingSpecProjectPath?: string;
|
||||
// Board controls props
|
||||
onShowBoardBackground: () => void;
|
||||
onRefreshBoard: () => Promise<void>;
|
||||
// View toggle props
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
@@ -60,6 +63,7 @@ export function BoardHeader({
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
onShowBoardBackground,
|
||||
onRefreshBoard,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: BoardHeaderProps) {
|
||||
@@ -110,9 +114,20 @@ export function BoardHeader({
|
||||
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
||||
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const handleRefreshBoard = useCallback(async () => {
|
||||
if (isRefreshingBoard) return;
|
||||
setIsRefreshingBoard(true);
|
||||
try {
|
||||
await onRefreshBoard();
|
||||
} finally {
|
||||
setIsRefreshingBoard(false);
|
||||
}
|
||||
}, [isRefreshingBoard, onRefreshBoard]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -127,6 +142,22 @@ export function BoardHeader({
|
||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{isMounted && !isTablet && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={handleRefreshBoard}
|
||||
disabled={isRefreshingBoard}
|
||||
aria-label="Refresh board state from server"
|
||||
>
|
||||
<RefreshCw className={isRefreshingBoard ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
|
||||
@@ -241,9 +241,9 @@ export function CreatePRDialog({
|
||||
<GitPullRequest className="w-5 h-5" />
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="break-words">
|
||||
Push changes and create a pull request from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Prompt Preview - Shows a live preview of the custom terminal prompt
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import { getTerminalTheme } from '@/config/terminal-themes';
|
||||
|
||||
interface PromptPreviewProps {
|
||||
format: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
theme: ThemeMode;
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
isOmpTheme?: boolean;
|
||||
promptThemeLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PromptPreview({
|
||||
format,
|
||||
theme,
|
||||
showGitBranch,
|
||||
showGitStatus,
|
||||
showUserHost,
|
||||
showPath,
|
||||
pathStyle,
|
||||
pathDepth,
|
||||
showTime,
|
||||
showExitStatus,
|
||||
isOmpTheme = false,
|
||||
promptThemeLabel,
|
||||
className,
|
||||
}: PromptPreviewProps) {
|
||||
const terminalTheme = getTerminalTheme(theme);
|
||||
|
||||
const formatPath = (inputPath: string) => {
|
||||
let displayPath = inputPath;
|
||||
let prefix = '';
|
||||
|
||||
if (displayPath.startsWith('~/')) {
|
||||
prefix = '~/';
|
||||
displayPath = displayPath.slice(2);
|
||||
} else if (displayPath.startsWith('/')) {
|
||||
prefix = '/';
|
||||
displayPath = displayPath.slice(1);
|
||||
}
|
||||
|
||||
const segments = displayPath.split('/').filter((segment) => segment.length > 0);
|
||||
const depth = Math.max(0, pathDepth);
|
||||
const trimmedSegments = depth > 0 ? segments.slice(-depth) : segments;
|
||||
|
||||
let formattedSegments = trimmedSegments;
|
||||
if (pathStyle === 'basename' && trimmedSegments.length > 0) {
|
||||
formattedSegments = [trimmedSegments[trimmedSegments.length - 1]];
|
||||
} else if (pathStyle === 'short') {
|
||||
formattedSegments = trimmedSegments.map((segment, index) => {
|
||||
if (index < trimmedSegments.length - 1) {
|
||||
return segment.slice(0, 1);
|
||||
}
|
||||
return segment;
|
||||
});
|
||||
}
|
||||
|
||||
const joined = formattedSegments.join('/');
|
||||
if (prefix === '/' && joined.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
if (prefix === '~/' && joined.length === 0) {
|
||||
return '~';
|
||||
}
|
||||
return `${prefix}${joined}`;
|
||||
};
|
||||
|
||||
// Generate preview text based on format
|
||||
const renderPrompt = () => {
|
||||
if (isOmpTheme) {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-2">
|
||||
<div style={{ color: terminalTheme.magenta }}>
|
||||
{promptThemeLabel ?? 'Oh My Posh theme'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Rendered by the oh-my-posh CLI in the terminal.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Preview here stays generic to avoid misleading output.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = 'user';
|
||||
const host = 'automaker';
|
||||
const path = formatPath('~/projects/automaker');
|
||||
const branch = showGitBranch ? 'main' : null;
|
||||
const dirty = showGitStatus && showGitBranch ? '*' : '';
|
||||
const time = showTime ? '[14:32]' : '';
|
||||
const status = showExitStatus ? '✗ 1' : '';
|
||||
|
||||
const gitInfo = branch ? ` (${branch}${dirty})` : '';
|
||||
|
||||
switch (format) {
|
||||
case 'minimal': {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<span style={{ color: terminalTheme.cyan }}>
|
||||
{user}
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>{' '}
|
||||
</span>
|
||||
)}
|
||||
{showPath && <span style={{ color: terminalTheme.yellow }}>{path}</span>}
|
||||
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
<span style={{ color: terminalTheme.green }}> $</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'powerline': {
|
||||
const powerlineSegments: ReactNode[] = [];
|
||||
if (showUserHost) {
|
||||
powerlineSegments.push(
|
||||
<span key="user-host" style={{ color: terminalTheme.cyan }}>
|
||||
[{user}
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showPath) {
|
||||
powerlineSegments.push(
|
||||
<span key="path" style={{ color: terminalTheme.yellow }}>
|
||||
[{path}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const powerlineCore = powerlineSegments.flatMap((segment, index) =>
|
||||
index === 0
|
||||
? [segment]
|
||||
: [
|
||||
<span key={`sep-${index}`} style={{ color: terminalTheme.cyan }}>
|
||||
─
|
||||
</span>,
|
||||
segment,
|
||||
]
|
||||
);
|
||||
const powerlineExtras: ReactNode[] = [];
|
||||
if (gitInfo) {
|
||||
powerlineExtras.push(
|
||||
<span key="git" style={{ color: terminalTheme.magenta }}>
|
||||
{gitInfo}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showTime) {
|
||||
powerlineExtras.push(
|
||||
<span key="time" style={{ color: terminalTheme.magenta }}>
|
||||
{time}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showExitStatus) {
|
||||
powerlineExtras.push(
|
||||
<span key="status" style={{ color: terminalTheme.red }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const powerlineLine: ReactNode[] = [...powerlineCore];
|
||||
if (powerlineExtras.length > 0) {
|
||||
if (powerlineLine.length > 0) {
|
||||
powerlineLine.push(' ');
|
||||
}
|
||||
powerlineLine.push(...powerlineExtras);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-1">
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.cyan }}>┌─</span>
|
||||
{powerlineLine}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.cyan }}>└─</span>
|
||||
<span style={{ color: terminalTheme.green }}>$</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'starship': {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-1">
|
||||
<div>
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.cyan }}>{user}</span>
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>
|
||||
</>
|
||||
)}
|
||||
{showPath && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.foreground }}> in </span>
|
||||
<span style={{ color: terminalTheme.yellow }}>{path}</span>
|
||||
</>
|
||||
)}
|
||||
{branch && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.foreground }}> on </span>
|
||||
<span style={{ color: terminalTheme.magenta }}>
|
||||
{branch}
|
||||
{dirty}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.green }}>❯</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'standard':
|
||||
default: {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.cyan }}>[{user}</span>
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>
|
||||
<span style={{ color: terminalTheme.cyan }}>]</span>
|
||||
</>
|
||||
)}
|
||||
{showPath && <span style={{ color: terminalTheme.yellow }}> {path}</span>}
|
||||
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
<span style={{ color: terminalTheme.green }}> $</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
'bg-[var(--terminal-bg)] text-[var(--terminal-fg)]',
|
||||
'shadow-inner',
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--terminal-bg': terminalTheme.background,
|
||||
'--terminal-fg': terminalTheme.foreground,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="mb-2 text-xs text-muted-foreground opacity-70">Preview</div>
|
||||
{renderPrompt()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { TerminalPromptTheme } from '@automaker/types';
|
||||
|
||||
export const PROMPT_THEME_CUSTOM_ID: TerminalPromptTheme = 'custom';
|
||||
|
||||
export const OMP_THEME_NAMES = [
|
||||
'1_shell',
|
||||
'M365Princess',
|
||||
'agnoster',
|
||||
'agnoster.minimal',
|
||||
'agnosterplus',
|
||||
'aliens',
|
||||
'amro',
|
||||
'atomic',
|
||||
'atomicBit',
|
||||
'avit',
|
||||
'blue-owl',
|
||||
'blueish',
|
||||
'bubbles',
|
||||
'bubblesextra',
|
||||
'bubblesline',
|
||||
'capr4n',
|
||||
'catppuccin',
|
||||
'catppuccin_frappe',
|
||||
'catppuccin_latte',
|
||||
'catppuccin_macchiato',
|
||||
'catppuccin_mocha',
|
||||
'cert',
|
||||
'chips',
|
||||
'cinnamon',
|
||||
'clean-detailed',
|
||||
'cloud-context',
|
||||
'cloud-native-azure',
|
||||
'cobalt2',
|
||||
'craver',
|
||||
'darkblood',
|
||||
'devious-diamonds',
|
||||
'di4am0nd',
|
||||
'dracula',
|
||||
'easy-term',
|
||||
'emodipt',
|
||||
'emodipt-extend',
|
||||
'fish',
|
||||
'free-ukraine',
|
||||
'froczh',
|
||||
'gmay',
|
||||
'glowsticks',
|
||||
'grandpa-style',
|
||||
'gruvbox',
|
||||
'half-life',
|
||||
'honukai',
|
||||
'hotstick.minimal',
|
||||
'hul10',
|
||||
'hunk',
|
||||
'huvix',
|
||||
'if_tea',
|
||||
'illusi0n',
|
||||
'iterm2',
|
||||
'jandedobbeleer',
|
||||
'jblab_2021',
|
||||
'jonnychipz',
|
||||
'json',
|
||||
'jtracey93',
|
||||
'jv_sitecorian',
|
||||
'kali',
|
||||
'kushal',
|
||||
'lambda',
|
||||
'lambdageneration',
|
||||
'larserikfinholt',
|
||||
'lightgreen',
|
||||
'marcduiker',
|
||||
'markbull',
|
||||
'material',
|
||||
'microverse-power',
|
||||
'mojada',
|
||||
'montys',
|
||||
'mt',
|
||||
'multiverse-neon',
|
||||
'negligible',
|
||||
'neko',
|
||||
'night-owl',
|
||||
'nordtron',
|
||||
'nu4a',
|
||||
'onehalf.minimal',
|
||||
'paradox',
|
||||
'pararussel',
|
||||
'patriksvensson',
|
||||
'peru',
|
||||
'pixelrobots',
|
||||
'plague',
|
||||
'poshmon',
|
||||
'powerlevel10k_classic',
|
||||
'powerlevel10k_lean',
|
||||
'powerlevel10k_modern',
|
||||
'powerlevel10k_rainbow',
|
||||
'powerline',
|
||||
'probua.minimal',
|
||||
'pure',
|
||||
'quick-term',
|
||||
'remk',
|
||||
'robbyrussell',
|
||||
'rudolfs-dark',
|
||||
'rudolfs-light',
|
||||
'sim-web',
|
||||
'slim',
|
||||
'slimfat',
|
||||
'smoothie',
|
||||
'sonicboom_dark',
|
||||
'sonicboom_light',
|
||||
'sorin',
|
||||
'space',
|
||||
'spaceship',
|
||||
'star',
|
||||
'stelbent-compact.minimal',
|
||||
'stelbent.minimal',
|
||||
'takuya',
|
||||
'the-unnamed',
|
||||
'thecyberden',
|
||||
'tiwahu',
|
||||
'tokyo',
|
||||
'tokyonight_storm',
|
||||
'tonybaloney',
|
||||
'uew',
|
||||
'unicorn',
|
||||
'velvet',
|
||||
'wholespace',
|
||||
'wopian',
|
||||
'xtoys',
|
||||
'ys',
|
||||
'zash',
|
||||
] as const;
|
||||
|
||||
type OmpThemeName = (typeof OMP_THEME_NAMES)[number];
|
||||
|
||||
type PromptFormat = 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
|
||||
type PathStyle = 'full' | 'short' | 'basename';
|
||||
|
||||
export interface PromptThemeConfig {
|
||||
promptFormat: PromptFormat;
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: PathStyle;
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
}
|
||||
|
||||
export interface PromptThemePreset {
|
||||
id: TerminalPromptTheme;
|
||||
label: string;
|
||||
description: string;
|
||||
config: PromptThemeConfig;
|
||||
}
|
||||
|
||||
const PATH_DEPTH_FULL = 0;
|
||||
const PATH_DEPTH_TWO = 2;
|
||||
const PATH_DEPTH_THREE = 3;
|
||||
|
||||
const POWERLINE_HINTS = ['powerline', 'powerlevel10k', 'agnoster', 'bubbles', 'smoothie'];
|
||||
const MINIMAL_HINTS = ['minimal', 'pure', 'slim', 'negligible'];
|
||||
const STARSHIP_HINTS = ['spaceship', 'star'];
|
||||
const SHORT_PATH_HINTS = ['compact', 'lean', 'slim'];
|
||||
const TIME_HINTS = ['time', 'clock'];
|
||||
const EXIT_STATUS_HINTS = ['status', 'exit', 'fail', 'error'];
|
||||
|
||||
function toPromptThemeId(name: OmpThemeName): TerminalPromptTheme {
|
||||
return `omp-${name}` as TerminalPromptTheme;
|
||||
}
|
||||
|
||||
function formatLabel(name: string): string {
|
||||
const cleaned = name.replace(/[._-]+/g, ' ').trim();
|
||||
return cleaned
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildPresetConfig(name: OmpThemeName): PromptThemeConfig {
|
||||
const lower = name.toLowerCase();
|
||||
const isPowerline = POWERLINE_HINTS.some((hint) => lower.includes(hint));
|
||||
const isMinimal = MINIMAL_HINTS.some((hint) => lower.includes(hint));
|
||||
const isStarship = STARSHIP_HINTS.some((hint) => lower.includes(hint));
|
||||
let promptFormat: PromptFormat = 'standard';
|
||||
|
||||
if (isPowerline) {
|
||||
promptFormat = 'powerline';
|
||||
} else if (isMinimal) {
|
||||
promptFormat = 'minimal';
|
||||
} else if (isStarship) {
|
||||
promptFormat = 'starship';
|
||||
}
|
||||
|
||||
const showUserHost = !isMinimal;
|
||||
const showPath = true;
|
||||
const pathStyle: PathStyle = isMinimal ? 'short' : 'full';
|
||||
let pathDepth = isMinimal ? PATH_DEPTH_THREE : PATH_DEPTH_FULL;
|
||||
|
||||
if (SHORT_PATH_HINTS.some((hint) => lower.includes(hint))) {
|
||||
pathDepth = PATH_DEPTH_TWO;
|
||||
}
|
||||
|
||||
if (lower.includes('powerlevel10k')) {
|
||||
pathDepth = PATH_DEPTH_THREE;
|
||||
}
|
||||
|
||||
const showTime = TIME_HINTS.some((hint) => lower.includes(hint));
|
||||
const showExitStatus = EXIT_STATUS_HINTS.some((hint) => lower.includes(hint));
|
||||
|
||||
return {
|
||||
promptFormat,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost,
|
||||
showPath,
|
||||
pathStyle,
|
||||
pathDepth,
|
||||
showTime,
|
||||
showExitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export const PROMPT_THEME_PRESETS: PromptThemePreset[] = OMP_THEME_NAMES.map((name) => ({
|
||||
id: toPromptThemeId(name),
|
||||
label: `${formatLabel(name)} (OMP)`,
|
||||
description: 'Oh My Posh theme preset',
|
||||
config: buildPresetConfig(name),
|
||||
}));
|
||||
|
||||
export function getPromptThemePreset(presetId: TerminalPromptTheme): PromptThemePreset | null {
|
||||
return PROMPT_THEME_PRESETS.find((preset) => preset.id === presetId) ?? null;
|
||||
}
|
||||
|
||||
export function getMatchingPromptThemeId(config: PromptThemeConfig): TerminalPromptTheme {
|
||||
const match = PROMPT_THEME_PRESETS.find((preset) => {
|
||||
const presetConfig = preset.config;
|
||||
return (
|
||||
presetConfig.promptFormat === config.promptFormat &&
|
||||
presetConfig.showGitBranch === config.showGitBranch &&
|
||||
presetConfig.showGitStatus === config.showGitStatus &&
|
||||
presetConfig.showUserHost === config.showUserHost &&
|
||||
presetConfig.showPath === config.showPath &&
|
||||
presetConfig.pathStyle === config.pathStyle &&
|
||||
presetConfig.pathDepth === config.pathDepth &&
|
||||
presetConfig.showTime === config.showTime &&
|
||||
presetConfig.showExitStatus === config.showExitStatus
|
||||
);
|
||||
});
|
||||
|
||||
return match?.id ?? PROMPT_THEME_CUSTOM_ID;
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* Terminal Config Section - Custom terminal configurations with theme synchronization
|
||||
*
|
||||
* This component provides UI for enabling custom terminal prompts that automatically
|
||||
* sync with Automaker's 40 themes. It's an opt-in feature that generates shell configs
|
||||
* in .automaker/terminal/ without modifying user's existing RC files.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Wand2, GitBranch, Info, Plus, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { PromptPreview } from './prompt-preview';
|
||||
import type { TerminalPromptTheme } from '@automaker/types';
|
||||
import {
|
||||
PROMPT_THEME_CUSTOM_ID,
|
||||
PROMPT_THEME_PRESETS,
|
||||
getMatchingPromptThemeId,
|
||||
getPromptThemePreset,
|
||||
type PromptThemeConfig,
|
||||
} from './prompt-theme-presets';
|
||||
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
|
||||
import { useGlobalSettings } from '@/hooks/queries/use-settings';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
export function TerminalConfigSection() {
|
||||
const PATH_DEPTH_MIN = 0;
|
||||
const PATH_DEPTH_MAX = 10;
|
||||
const ENV_VAR_UPDATE_DEBOUNCE_MS = 400;
|
||||
const ENV_VAR_ID_PREFIX = 'env';
|
||||
const TERMINAL_RC_FILE_VERSION = 11;
|
||||
const { theme } = useAppStore();
|
||||
const { data: globalSettings } = useGlobalSettings();
|
||||
const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||
const envVarIdRef = useRef(0);
|
||||
const envVarUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const createEnvVarEntry = useCallback(
|
||||
(key = '', value = '') => {
|
||||
envVarIdRef.current += 1;
|
||||
return {
|
||||
id: `${ENV_VAR_ID_PREFIX}-${envVarIdRef.current}`,
|
||||
key,
|
||||
value,
|
||||
};
|
||||
},
|
||||
[ENV_VAR_ID_PREFIX]
|
||||
);
|
||||
const [localEnvVars, setLocalEnvVars] = useState<
|
||||
Array<{ id: string; key: string; value: string }>
|
||||
>(() =>
|
||||
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
|
||||
createEnvVarEntry(key, value)
|
||||
)
|
||||
);
|
||||
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
|
||||
|
||||
const clampPathDepth = (value: number) =>
|
||||
Math.min(PATH_DEPTH_MAX, Math.max(PATH_DEPTH_MIN, value));
|
||||
|
||||
const defaultTerminalConfig = {
|
||||
enabled: false,
|
||||
customPrompt: true,
|
||||
promptFormat: 'standard' as const,
|
||||
promptTheme: PROMPT_THEME_CUSTOM_ID,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: 'full' as const,
|
||||
pathDepth: PATH_DEPTH_MIN,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: '',
|
||||
customEnvVars: {},
|
||||
};
|
||||
|
||||
const terminalConfig = {
|
||||
...defaultTerminalConfig,
|
||||
...globalSettings?.terminalConfig,
|
||||
customAliases:
|
||||
globalSettings?.terminalConfig?.customAliases ?? defaultTerminalConfig.customAliases,
|
||||
customEnvVars:
|
||||
globalSettings?.terminalConfig?.customEnvVars ?? defaultTerminalConfig.customEnvVars,
|
||||
};
|
||||
|
||||
const promptThemeConfig: PromptThemeConfig = {
|
||||
promptFormat: terminalConfig.promptFormat,
|
||||
showGitBranch: terminalConfig.showGitBranch,
|
||||
showGitStatus: terminalConfig.showGitStatus,
|
||||
showUserHost: terminalConfig.showUserHost,
|
||||
showPath: terminalConfig.showPath,
|
||||
pathStyle: terminalConfig.pathStyle,
|
||||
pathDepth: terminalConfig.pathDepth,
|
||||
showTime: terminalConfig.showTime,
|
||||
showExitStatus: terminalConfig.showExitStatus,
|
||||
};
|
||||
|
||||
const storedPromptTheme = terminalConfig.promptTheme;
|
||||
const activePromptThemeId =
|
||||
storedPromptTheme === PROMPT_THEME_CUSTOM_ID
|
||||
? PROMPT_THEME_CUSTOM_ID
|
||||
: (storedPromptTheme ?? getMatchingPromptThemeId(promptThemeConfig));
|
||||
const isOmpTheme =
|
||||
storedPromptTheme !== undefined && storedPromptTheme !== PROMPT_THEME_CUSTOM_ID;
|
||||
const promptThemePreset = isOmpTheme
|
||||
? getPromptThemePreset(storedPromptTheme as TerminalPromptTheme)
|
||||
: null;
|
||||
|
||||
const applyEnabledUpdate = (enabled: boolean) => {
|
||||
// Ensure all required fields are present
|
||||
const updatedConfig = {
|
||||
enabled,
|
||||
customPrompt: terminalConfig.customPrompt,
|
||||
promptFormat: terminalConfig.promptFormat,
|
||||
showGitBranch: terminalConfig.showGitBranch,
|
||||
showGitStatus: terminalConfig.showGitStatus,
|
||||
showUserHost: terminalConfig.showUserHost,
|
||||
showPath: terminalConfig.showPath,
|
||||
pathStyle: terminalConfig.pathStyle,
|
||||
pathDepth: terminalConfig.pathDepth,
|
||||
showTime: terminalConfig.showTime,
|
||||
showExitStatus: terminalConfig.showExitStatus,
|
||||
promptTheme: terminalConfig.promptTheme ?? PROMPT_THEME_CUSTOM_ID,
|
||||
customAliases: terminalConfig.customAliases,
|
||||
customEnvVars: terminalConfig.customEnvVars,
|
||||
rcFileVersion: TERMINAL_RC_FILE_VERSION,
|
||||
};
|
||||
|
||||
updateGlobalSettings.mutate(
|
||||
{ terminalConfig: updatedConfig },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
enabled ? 'Custom terminal configs enabled' : 'Custom terminal configs disabled',
|
||||
{
|
||||
description: enabled
|
||||
? 'New terminals will use custom prompts'
|
||||
: '.automaker/terminal/ will be cleaned up',
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('[TerminalConfig] Failed to update settings:', error);
|
||||
toast.error('Failed to update terminal config', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEnvVars(
|
||||
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
|
||||
createEnvVarEntry(key, value)
|
||||
)
|
||||
);
|
||||
}, [createEnvVarEntry, globalSettings?.terminalConfig?.customEnvVars]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (envVarUpdateTimeoutRef.current) {
|
||||
clearTimeout(envVarUpdateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggleEnabled = async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setShowEnableConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
applyEnabledUpdate(false);
|
||||
};
|
||||
|
||||
const handleUpdateConfig = (updates: Partial<typeof terminalConfig>) => {
|
||||
const nextPromptTheme = updates.promptTheme ?? PROMPT_THEME_CUSTOM_ID;
|
||||
|
||||
updateGlobalSettings.mutate(
|
||||
{
|
||||
terminalConfig: {
|
||||
...terminalConfig,
|
||||
...updates,
|
||||
promptTheme: nextPromptTheme,
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
console.error('[TerminalConfig] Failed to update settings:', error);
|
||||
toast.error('Failed to update terminal config', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleEnvVarsUpdate = (envVarsObject: Record<string, string>) => {
|
||||
if (envVarUpdateTimeoutRef.current) {
|
||||
clearTimeout(envVarUpdateTimeoutRef.current);
|
||||
}
|
||||
envVarUpdateTimeoutRef.current = setTimeout(() => {
|
||||
handleUpdateConfig({ customEnvVars: envVarsObject });
|
||||
}, ENV_VAR_UPDATE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handlePromptThemeChange = (themeId: string) => {
|
||||
if (themeId === PROMPT_THEME_CUSTOM_ID) {
|
||||
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = getPromptThemePreset(themeId as TerminalPromptTheme);
|
||||
if (!preset) {
|
||||
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateConfig({
|
||||
...preset.config,
|
||||
promptTheme: preset.id,
|
||||
});
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setLocalEnvVars([...localEnvVars, createEnvVarEntry()]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (id: string) => {
|
||||
const newVars = localEnvVars.filter((envVar) => envVar.id !== id);
|
||||
setLocalEnvVars(newVars);
|
||||
|
||||
// Update settings
|
||||
const envVarsObject = newVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
scheduleEnvVarsUpdate(envVarsObject);
|
||||
};
|
||||
|
||||
const updateEnvVar = (id: string, field: 'key' | 'value', newValue: string) => {
|
||||
const newVars = localEnvVars.map((envVar) =>
|
||||
envVar.id === id ? { ...envVar, [field]: newValue } : envVar
|
||||
);
|
||||
setLocalEnvVars(newVars);
|
||||
|
||||
// Validate and update settings (only if key is valid)
|
||||
const envVarsObject = newVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
// Only include vars with valid keys (alphanumeric + underscore)
|
||||
if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
scheduleEnvVarsUpdate(envVarsObject);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-purple-500/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
|
||||
<Wand2 className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Custom Terminal Configurations
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Generate custom shell prompts that automatically sync with your app theme. Opt-in feature
|
||||
that creates configs in .automaker/terminal/ without modifying your existing RC files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Enable Custom Configurations</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create theme-synced shell configs in .automaker/terminal/
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={terminalConfig.enabled} onCheckedChange={handleToggleEnabled} />
|
||||
</div>
|
||||
|
||||
{terminalConfig.enabled && (
|
||||
<>
|
||||
{/* Info Box */}
|
||||
<div className="rounded-lg border border-purple-500/20 bg-purple-500/5 p-3 flex gap-2">
|
||||
<Info className="h-4 w-4 text-purple-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-foreground/80">
|
||||
<strong>How it works:</strong> Custom configs are applied to new terminals only.
|
||||
Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to
|
||||
see changes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Custom Prompt</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Override default shell prompt with themed version
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.customPrompt}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ customPrompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{terminalConfig.customPrompt && (
|
||||
<>
|
||||
{/* Prompt Format */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Prompt Theme (Oh My Posh)</Label>
|
||||
<Select value={activePromptThemeId} onValueChange={handlePromptThemeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROMPT_THEME_CUSTOM_ID}>
|
||||
<div className="space-y-0.5">
|
||||
<div>Custom</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Hand-tuned configuration
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{PROMPT_THEME_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
<div className="space-y-0.5">
|
||||
<div>{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isOmpTheme && (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 flex gap-2">
|
||||
<Info className="h-4 w-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-foreground/80">
|
||||
<strong>{promptThemePreset?.label ?? 'Oh My Posh theme'}</strong> uses the
|
||||
oh-my-posh CLI for rendering. Ensure it's installed for the full theme.
|
||||
Prompt format and segment toggles are ignored while an OMP theme is selected.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Prompt Format</Label>
|
||||
<Select
|
||||
value={terminalConfig.promptFormat}
|
||||
onValueChange={(value: 'standard' | 'minimal' | 'powerline' | 'starship') =>
|
||||
handleUpdateConfig({ promptFormat: value })
|
||||
}
|
||||
disabled={isOmpTheme}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">
|
||||
<div className="space-y-0.5">
|
||||
<div>Standard</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
[user@host] ~/path (main*) $
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="minimal">
|
||||
<div className="space-y-0.5">
|
||||
<div>Minimal</div>
|
||||
<div className="text-xs text-muted-foreground">~/path (main*) $</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="powerline">
|
||||
<div className="space-y-0.5">
|
||||
<div>Powerline</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
┌─[user@host]─[~/path]─[main*]
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="starship">
|
||||
<div className="space-y-0.5">
|
||||
<div>Starship-Inspired</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
user@host in ~/path on main*
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Git Info Toggles */}
|
||||
<div className="space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Show Git Branch</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showGitBranch}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showGitBranch: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">*</span>
|
||||
<Label className="text-sm">Show Git Status (dirty indicator)</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showGitStatus}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showGitStatus: checked })}
|
||||
disabled={!terminalConfig.showGitBranch || isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt Segments */}
|
||||
<div className="space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Show User & Host</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showUserHost}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showUserHost: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">~/</span>
|
||||
<Label className="text-sm">Show Path</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showPath}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showPath: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">⏱</span>
|
||||
<Label className="text-sm">Show Time</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showTime}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showTime: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">✗</span>
|
||||
<Label className="text-sm">Show Exit Status</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showExitStatus}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showExitStatus: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
||||
<Select
|
||||
value={terminalConfig.pathStyle}
|
||||
onValueChange={(value: 'full' | 'short' | 'basename') =>
|
||||
handleUpdateConfig({ pathStyle: value })
|
||||
}
|
||||
disabled={!terminalConfig.showPath || isOmpTheme}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">Full</SelectItem>
|
||||
<SelectItem value="short">Short</SelectItem>
|
||||
<SelectItem value="basename">Basename</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Path Depth</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={PATH_DEPTH_MIN}
|
||||
max={PATH_DEPTH_MAX}
|
||||
value={terminalConfig.pathDepth}
|
||||
onChange={(event) =>
|
||||
handleUpdateConfig({
|
||||
pathDepth: clampPathDepth(Number(event.target.value) || 0),
|
||||
})
|
||||
}
|
||||
disabled={!terminalConfig.showPath || isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Preview</Label>
|
||||
<PromptPreview
|
||||
format={terminalConfig.promptFormat}
|
||||
theme={theme}
|
||||
showGitBranch={terminalConfig.showGitBranch}
|
||||
showGitStatus={terminalConfig.showGitStatus}
|
||||
showUserHost={terminalConfig.showUserHost}
|
||||
showPath={terminalConfig.showPath}
|
||||
pathStyle={terminalConfig.pathStyle}
|
||||
pathDepth={terminalConfig.pathDepth}
|
||||
showTime={terminalConfig.showTime}
|
||||
showExitStatus={terminalConfig.showExitStatus}
|
||||
isOmpTheme={isOmpTheme}
|
||||
promptThemeLabel={promptThemePreset?.label}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Aliases */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Custom Aliases</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add shell aliases (one per line, e.g., alias ll='ls -la')
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={terminalConfig.customAliases}
|
||||
onChange={(e) => handleUpdateConfig({ customAliases: e.target.value })}
|
||||
placeholder="# Custom aliases alias gs='git status' alias ll='ls -la' alias ..='cd ..'"
|
||||
className="font-mono text-sm h-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Environment Variables */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">
|
||||
Custom Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add custom env vars (alphanumeric + underscore only)
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addEnvVar} className="h-8 gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localEnvVars.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{localEnvVars.map((envVar) => (
|
||||
<div key={envVar.id} className="flex gap-2 items-start">
|
||||
<Input
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(envVar.id, 'key', e.target.value)}
|
||||
placeholder="VAR_NAME"
|
||||
className={cn(
|
||||
'font-mono text-sm flex-1',
|
||||
envVar.key &&
|
||||
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(envVar.key) &&
|
||||
'border-destructive'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(envVar.id, 'value', e.target.value)}
|
||||
placeholder="value"
|
||||
className="font-mono text-sm flex-[2]"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEnvVar(envVar.id)}
|
||||
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showEnableConfirm}
|
||||
onOpenChange={setShowEnableConfirm}
|
||||
title="Enable custom terminal configurations"
|
||||
description="Automaker will generate per-project shell configuration files for your terminal."
|
||||
icon={Info}
|
||||
confirmText="Enable"
|
||||
onConfirm={() => applyEnabledUpdate(true)}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Creates shell config files in `.automaker/terminal/`</li>
|
||||
<li>Applies prompts and colors that match your app theme</li>
|
||||
<li>Leaves your existing `~/.bashrc` and `~/.zshrc` untouched</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
New terminal sessions will use the custom prompt; existing sessions are unchanged.
|
||||
</p>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
|
||||
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
|
||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||
import { TerminalConfigSection } from './terminal-config-section';
|
||||
|
||||
export function TerminalSection() {
|
||||
const {
|
||||
@@ -53,253 +54,258 @@ export function TerminalSection() {
|
||||
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
||||
<SquareTerminal className="w-5 h-5 text-green-500" />
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
||||
<SquareTerminal className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default External Terminal */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default External Terminal</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh available terminals"
|
||||
aria-label="Refresh available terminals"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal to use when selecting "Open in Terminal" from the worktree menu
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
|
||||
settings.
|
||||
</p>
|
||||
<Select
|
||||
value={defaultTerminalId ?? 'integrated'}
|
||||
onValueChange={(value) => {
|
||||
setDefaultTerminalId(value === 'integrated' ? null : value);
|
||||
toast.success(
|
||||
value === 'integrated'
|
||||
? 'Integrated terminal set as default'
|
||||
: 'Default terminal changed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a terminal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="integrated">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Integrated Terminal
|
||||
</span>
|
||||
</SelectItem>
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
return (
|
||||
<SelectItem key={terminal.id} value={terminal.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<TerminalIcon className="w-4 h-4" />
|
||||
{terminal.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{terminals.length === 0 && !isRefreshing && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No external terminals detected. Click refresh to re-scan.
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default External Terminal */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default External Terminal</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh available terminals"
|
||||
aria-label="Refresh available terminals"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal to use when selecting "Open in Terminal" from the worktree menu
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Open Mode */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Open Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
|
||||
</p>
|
||||
<Select
|
||||
value={openTerminalMode}
|
||||
onValueChange={(value: 'newTab' | 'split') => {
|
||||
setOpenTerminalMode(value);
|
||||
toast.success(
|
||||
value === 'newTab'
|
||||
? 'New terminals will open in new tabs'
|
||||
: 'New terminals will split the current tab'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newTab">
|
||||
<span className="flex items-center gap-2">
|
||||
<SquarePlus className="w-4 h-4" />
|
||||
New Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="split">
|
||||
<span className="flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
Split Current Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={fontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setTerminalFontFamily(value);
|
||||
toast.info('Font family changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Default (Menlo / Monaco)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<Select
|
||||
value={defaultTerminalId ?? 'integrated'}
|
||||
onValueChange={(value) => {
|
||||
setDefaultTerminalId(value === 'integrated' ? null : value);
|
||||
toast.success(
|
||||
value === 'integrated'
|
||||
? 'Integrated terminal set as default'
|
||||
: 'Default terminal changed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a terminal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="integrated">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Integrated Terminal
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Default Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
return (
|
||||
<SelectItem key={terminal.id} value={terminal.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<TerminalIcon className="w-4 h-4" />
|
||||
{terminal.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{terminals.length === 0 && !isRefreshing && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No external terminals detected. Click refresh to re-scan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
value={[defaultFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Height */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Line Height</Label>
|
||||
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[lineHeight]}
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={([value]) => {
|
||||
setTerminalLineHeight(value);
|
||||
}}
|
||||
onValueCommit={() => {
|
||||
toast.info('Line height changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollback Lines */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(scrollbackLines / 1000).toFixed(0)}k lines
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[scrollbackLines]}
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
onValueChange={([value]) => setTerminalScrollbackLines(value)}
|
||||
onValueCommit={() => {
|
||||
toast.info('Scrollback changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Run Script */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Run Script</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
|
||||
</p>
|
||||
<Input
|
||||
value={defaultRunScript}
|
||||
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
||||
placeholder="e.g., claude, codex, npm run dev"
|
||||
className="bg-accent/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen Reader Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
|
||||
{/* Default Open Mode */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Open Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable accessibility mode for screen readers
|
||||
How to open the integrated terminal when using "Open in Terminal" from the worktree
|
||||
menu
|
||||
</p>
|
||||
<Select
|
||||
value={openTerminalMode}
|
||||
onValueChange={(value: 'newTab' | 'split') => {
|
||||
setOpenTerminalMode(value);
|
||||
toast.success(
|
||||
value === 'newTab'
|
||||
? 'New terminals will open in new tabs'
|
||||
: 'New terminals will split the current tab'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newTab">
|
||||
<span className="flex items-center gap-2">
|
||||
<SquarePlus className="w-4 h-4" />
|
||||
New Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="split">
|
||||
<span className="flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
Split Current Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Switch
|
||||
checked={screenReaderMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setTerminalScreenReaderMode(checked);
|
||||
toast.success(
|
||||
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
|
||||
{
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={fontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setTerminalFontFamily(value);
|
||||
toast.info('Font family changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Default (Menlo / Monaco)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Default Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[defaultFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Height */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Line Height</Label>
|
||||
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[lineHeight]}
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={([value]) => {
|
||||
setTerminalLineHeight(value);
|
||||
}}
|
||||
onValueCommit={() => {
|
||||
toast.info('Line height changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollback Lines */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(scrollbackLines / 1000).toFixed(0)}k lines
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[scrollbackLines]}
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
onValueChange={([value]) => setTerminalScrollbackLines(value)}
|
||||
onValueCommit={() => {
|
||||
toast.info('Scrollback changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Run Script */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Run Script</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
|
||||
</p>
|
||||
<Input
|
||||
value={defaultRunScript}
|
||||
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
||||
placeholder="e.g., claude, codex, npm run dev"
|
||||
className="bg-accent/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen Reader Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable accessibility mode for screen readers
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={screenReaderMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setTerminalScreenReaderMode(checked);
|
||||
toast.success(
|
||||
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
|
||||
{
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TerminalConfigSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
import { createSmartPollingInterval, getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
|
||||
const FEATURES_REFETCH_ON_FOCUS = false;
|
||||
const FEATURES_REFETCH_ON_RECONNECT = false;
|
||||
const FEATURES_POLLING_INTERVAL = 30000;
|
||||
/** Default polling interval for agent output when WebSocket is inactive */
|
||||
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
||||
|
||||
@@ -43,6 +44,7 @@ export function useFeatures(projectPath: string | undefined) {
|
||||
},
|
||||
enabled: !!projectPath,
|
||||
staleTime: STALE_TIMES.FEATURES,
|
||||
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -9,9 +9,11 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
|
||||
|
||||
const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
|
||||
const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
|
||||
const RUNNING_AGENTS_POLLING_INTERVAL = 30000;
|
||||
|
||||
interface RunningAgentsResult {
|
||||
agents: RunningAgent[];
|
||||
@@ -47,8 +49,7 @@ export function useRunningAgents() {
|
||||
};
|
||||
},
|
||||
staleTime: STALE_TIMES.RUNNING_AGENTS,
|
||||
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
|
||||
// for real-time updates instead of polling
|
||||
refetchInterval: createSmartPollingInterval(RUNNING_AGENTS_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
|
||||
|
||||
const WORKTREE_REFETCH_ON_FOCUS = false;
|
||||
const WORKTREE_REFETCH_ON_RECONNECT = false;
|
||||
const WORKTREES_POLLING_INTERVAL = 30000;
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -65,6 +67,7 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t
|
||||
},
|
||||
enabled: !!projectPath,
|
||||
staleTime: STALE_TIMES.WORKTREES,
|
||||
refetchInterval: createSmartPollingInterval(WORKTREES_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -6,10 +6,12 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
|
||||
const AUTO_MODE_POLLING_INTERVAL = 30000;
|
||||
|
||||
/**
|
||||
* Generate a worktree key for session storage
|
||||
@@ -140,42 +142,54 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
result.runningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
}, [branchName, currentProject, isAutoModeRunning, setAutoModeRunning]);
|
||||
|
||||
// On mount, query backend for current auto loop status and sync UI state.
|
||||
// This handles cases where the backend is still running after a page refresh.
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
// Periodic polling fallback when WebSocket events are stale.
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const syncWithBackend = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
const interval = setInterval(() => {
|
||||
if (getGlobalEventsRecent()) return;
|
||||
void refreshStatus();
|
||||
}, AUTO_MODE_POLLING_INTERVAL);
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
result.runningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
};
|
||||
|
||||
syncWithBackend();
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentProject, refreshStatus]);
|
||||
|
||||
// Handle auto mode events - listen globally for all projects/worktrees
|
||||
useEffect(() => {
|
||||
@@ -672,5 +686,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
start,
|
||||
stop,
|
||||
stopFeature,
|
||||
refreshStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ test.describe('Edit Feature', () => {
|
||||
await clickAddFeature(page);
|
||||
await fillAddFeatureDialog(page, originalDescription);
|
||||
await confirmAddFeature(page);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for the feature to appear in the backlog
|
||||
await expect(async () => {
|
||||
@@ -88,7 +89,7 @@ test.describe('Edit Feature', () => {
|
||||
hasText: originalDescription,
|
||||
});
|
||||
expect(await featureCard.count()).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
}).toPass({ timeout: 20000 });
|
||||
|
||||
// Get the feature ID from the card
|
||||
const featureCard = page
|
||||
|
||||
BIN
docs/pr/terminal-omp.png
Normal file
BIN
docs/pr/terminal-omp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
632
docs/terminal-custom-configs-plan.md
Normal file
632
docs/terminal-custom-configs-plan.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# Implementation Plan: Custom Terminal Configurations with Theme Synchronization
|
||||
|
||||
## Overview
|
||||
|
||||
Implement custom shell configuration files (.bashrc, .zshrc) that automatically sync with Automaker's 40 themes, providing a seamless terminal experience where prompt colors match the app theme. This is an **opt-in feature** that creates configs in `.automaker/terminal/` without modifying user's existing RC files.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **RC Generator** (`libs/platform/src/rc-generator.ts`) - NEW
|
||||
- Template-based generation for bash/zsh/sh
|
||||
- Theme-to-ANSI color mapping from hex values
|
||||
- Git info integration (branch, dirty status)
|
||||
- Prompt format templates (standard, minimal, powerline, starship-inspired)
|
||||
|
||||
2. **RC File Manager** (`libs/platform/src/rc-file-manager.ts`) - NEW
|
||||
- File I/O for `.automaker/terminal/` directory
|
||||
- Version checking and regeneration logic
|
||||
- Path resolution for different shells
|
||||
|
||||
3. **Terminal Service** (`apps/server/src/services/terminal-service.ts`) - MODIFY
|
||||
- Inject BASH_ENV/ZDOTDIR environment variables when spawning PTY
|
||||
- Hook for theme change regeneration
|
||||
- Backwards compatible (no change when disabled)
|
||||
|
||||
4. **Settings Schema** (`libs/types/src/settings.ts`) - MODIFY
|
||||
- Add `terminalConfig` to GlobalSettings and ProjectSettings
|
||||
- Include enable toggle, prompt format, git info toggles, custom aliases/env vars
|
||||
|
||||
5. **Settings UI** (`apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`) - NEW
|
||||
- Enable/disable toggle with explanation
|
||||
- Prompt format selector (4 formats)
|
||||
- Git info toggles (branch/status)
|
||||
- Custom aliases textarea
|
||||
- Custom env vars key-value editor
|
||||
- Live preview panel showing example prompt
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
.automaker/terminal/
|
||||
├── bashrc.sh # Bash config (sourced via BASH_ENV)
|
||||
├── zshrc.zsh # Zsh config (via ZDOTDIR)
|
||||
├── common.sh # Shared functions (git prompt, etc.)
|
||||
├── themes/
|
||||
│ ├── dark.sh # Theme-specific color exports (40 files)
|
||||
│ ├── dracula.sh
|
||||
│ ├── nord.sh
|
||||
│ └── ... (38 more)
|
||||
├── version.txt # RC file format version (for migrations)
|
||||
└── user-custom.sh # User's additional customizations (optional)
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create RC Generator Package
|
||||
|
||||
**File**: `libs/platform/src/rc-generator.ts`
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
```typescript
|
||||
// Main generation functions
|
||||
export function generateBashrc(theme: ThemeMode, config: TerminalConfig): string;
|
||||
export function generateZshrc(theme: ThemeMode, config: TerminalConfig): string;
|
||||
export function generateCommonFunctions(): string;
|
||||
export function generateThemeColors(theme: ThemeMode): string;
|
||||
|
||||
// Color mapping
|
||||
export function hexToXterm256(hex: string): number;
|
||||
export function getThemeANSIColors(terminalTheme: TerminalTheme): ANSIColors;
|
||||
```
|
||||
|
||||
**Templates**:
|
||||
|
||||
- Source user's original ~/.bashrc or ~/.zshrc first
|
||||
- Load theme colors from `themes/${AUTOMAKER_THEME}.sh`
|
||||
- Set custom PS1/PROMPT only if `AUTOMAKER_CUSTOM_PROMPT=true`
|
||||
- Include git prompt function: `automaker_git_prompt()`
|
||||
|
||||
**Example bashrc.sh template**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Automaker Terminal Configuration v1.0
|
||||
|
||||
# Source user's original bashrc first
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Load Automaker theme colors
|
||||
AUTOMAKER_THEME="${AUTOMAKER_THEME:-dark}"
|
||||
if [ -f "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh" ]; then
|
||||
source "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh"
|
||||
fi
|
||||
|
||||
# Load common functions (git prompt)
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
# Set custom prompt (only if enabled)
|
||||
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
|
||||
PS1="\[$COLOR_USER\]\u@\h\[$COLOR_RESET\] "
|
||||
PS1="$PS1\[$COLOR_PATH\]\w\[$COLOR_RESET\]"
|
||||
PS1="$PS1\$(automaker_git_prompt) "
|
||||
PS1="$PS1\[$COLOR_PROMPT\]\$\[$COLOR_RESET\] "
|
||||
fi
|
||||
|
||||
# Load user customizations (if exists)
|
||||
if [ -f "${BASH_SOURCE%/*}/user-custom.sh" ]; then
|
||||
source "${BASH_SOURCE%/*}/user-custom.sh"
|
||||
fi
|
||||
```
|
||||
|
||||
**Color Mapping Algorithm**:
|
||||
|
||||
1. Get hex colors from `apps/ui/src/config/terminal-themes.ts` (TerminalTheme interface)
|
||||
2. Convert hex to RGB
|
||||
3. Map to closest xterm-256 color code using Euclidean distance in RGB space
|
||||
4. Generate ANSI escape codes: `\[\e[38;5;{code}m\]` for foreground
|
||||
|
||||
### Step 2: Create RC File Manager
|
||||
|
||||
**File**: `libs/platform/src/rc-file-manager.ts`
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
```typescript
|
||||
export async function ensureTerminalDir(projectPath: string): Promise<void>;
|
||||
export async function writeRcFiles(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<void>;
|
||||
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string;
|
||||
export async function checkRcFileVersion(projectPath: string): Promise<number | null>;
|
||||
export async function needsRegeneration(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<boolean>;
|
||||
```
|
||||
|
||||
**File Operations**:
|
||||
|
||||
- Create `.automaker/terminal/` if doesn't exist
|
||||
- Write RC files with 0644 permissions
|
||||
- Write theme color files (40 themes × 1 file each)
|
||||
- Create version.txt with format version (currently "11")
|
||||
- Support atomic writes (write to temp, then rename)
|
||||
|
||||
### Step 3: Add Settings Schema
|
||||
|
||||
**File**: `libs/types/src/settings.ts`
|
||||
|
||||
**Add to GlobalSettings** (around line 842):
|
||||
|
||||
```typescript
|
||||
/** Terminal configuration settings */
|
||||
terminalConfig?: {
|
||||
/** Enable custom terminal configurations (default: false) */
|
||||
enabled: boolean;
|
||||
|
||||
/** Enable custom prompt (default: true when enabled) */
|
||||
customPrompt: boolean;
|
||||
|
||||
/** Prompt format template */
|
||||
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
|
||||
/** Prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
|
||||
/** Show git branch in prompt (default: true) */
|
||||
showGitBranch: boolean;
|
||||
|
||||
/** Show git status dirty indicator (default: true) */
|
||||
showGitStatus: boolean;
|
||||
|
||||
/** Show user and host in prompt (default: true) */
|
||||
showUserHost: boolean;
|
||||
|
||||
/** Show path in prompt (default: true) */
|
||||
showPath: boolean;
|
||||
|
||||
/** Path display style */
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
|
||||
/** Limit path depth (0 = full path) */
|
||||
pathDepth: number;
|
||||
|
||||
/** Show current time in prompt (default: false) */
|
||||
showTime: boolean;
|
||||
|
||||
/** Show last command exit status when non-zero (default: false) */
|
||||
showExitStatus: boolean;
|
||||
|
||||
/** User-provided custom aliases (multiline string) */
|
||||
customAliases: string;
|
||||
|
||||
/** User-provided custom env vars */
|
||||
customEnvVars: Record<string, string>;
|
||||
|
||||
/** RC file format version (for migration) */
|
||||
rcFileVersion?: number;
|
||||
};
|
||||
```
|
||||
|
||||
**Add to ProjectSettings**:
|
||||
|
||||
```typescript
|
||||
/** Project-specific terminal config overrides */
|
||||
terminalConfig?: {
|
||||
/** Override global enabled setting */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Override prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
|
||||
/** Override showing user/host */
|
||||
showUserHost?: boolean;
|
||||
|
||||
/** Override showing path */
|
||||
showPath?: boolean;
|
||||
|
||||
/** Override path style */
|
||||
pathStyle?: 'full' | 'short' | 'basename';
|
||||
|
||||
/** Override path depth (0 = full path) */
|
||||
pathDepth?: number;
|
||||
|
||||
/** Override showing time */
|
||||
showTime?: boolean;
|
||||
|
||||
/** Override showing exit status */
|
||||
showExitStatus?: boolean;
|
||||
|
||||
/** Project-specific custom aliases */
|
||||
customAliases?: string;
|
||||
|
||||
/** Project-specific env vars */
|
||||
customEnvVars?: Record<string, string>;
|
||||
|
||||
/** Custom welcome message for this project */
|
||||
welcomeMessage?: string;
|
||||
};
|
||||
```
|
||||
|
||||
**Defaults**:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_TERMINAL_CONFIG = {
|
||||
enabled: false,
|
||||
customPrompt: true,
|
||||
promptFormat: 'standard' as const,
|
||||
promptTheme: 'custom' as const,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: 'full' as const,
|
||||
pathDepth: 0,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: '',
|
||||
customEnvVars: {},
|
||||
rcFileVersion: 11,
|
||||
};
|
||||
```
|
||||
|
||||
**Oh My Posh Themes**:
|
||||
|
||||
- When `promptTheme` starts with `omp-` and `oh-my-posh` is available, the generated RC files will
|
||||
initialize oh-my-posh with the selected theme name.
|
||||
- If oh-my-posh is not installed, the prompt falls back to the Automaker-built prompt format.
|
||||
- `POSH_THEMES_PATH` is exported to the standard user themes directory so themes resolve offline.
|
||||
|
||||
### Step 4: Modify Terminal Service
|
||||
|
||||
**File**: `apps/server/src/services/terminal-service.ts`
|
||||
|
||||
**Modification Point**: In `createSession()` method, around line 335-344 where `env` object is built.
|
||||
|
||||
**Add before PTY spawn**:
|
||||
|
||||
```typescript
|
||||
// Get terminal config from settings
|
||||
const terminalConfig = await this.settingsService?.getGlobalSettings();
|
||||
const projectSettings = options.projectPath
|
||||
? await this.settingsService?.getProjectSettings(options.projectPath)
|
||||
: null;
|
||||
|
||||
const effectiveTerminalConfig = {
|
||||
...terminalConfig?.terminalConfig,
|
||||
...projectSettings?.terminalConfig,
|
||||
};
|
||||
|
||||
if (effectiveTerminalConfig?.enabled) {
|
||||
// Ensure RC files are up to date
|
||||
const currentTheme = terminalConfig?.theme || 'dark';
|
||||
await ensureRcFilesUpToDate(options.projectPath || cwd, currentTheme, effectiveTerminalConfig);
|
||||
|
||||
// Set shell-specific env vars
|
||||
const shellName = path.basename(shell).toLowerCase();
|
||||
|
||||
if (shellName.includes('bash')) {
|
||||
env.BASH_ENV = getRcFilePath(options.projectPath || cwd, 'bash');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName.includes('zsh')) {
|
||||
env.ZDOTDIR = path.join(options.projectPath || cwd, '.automaker', 'terminal');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName === 'sh') {
|
||||
env.ENV = getRcFilePath(options.projectPath || cwd, 'sh');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add new method for theme changes**:
|
||||
|
||||
```typescript
|
||||
async onThemeChange(projectPath: string, newTheme: ThemeMode): Promise<void> {
|
||||
const globalSettings = await this.settingsService?.getGlobalSettings();
|
||||
const terminalConfig = globalSettings?.terminalConfig;
|
||||
|
||||
if (terminalConfig?.enabled) {
|
||||
// Regenerate RC files with new theme
|
||||
await writeRcFiles(projectPath, newTheme, terminalConfig);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Create Settings UI
|
||||
|
||||
**File**: `apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`
|
||||
|
||||
**Component Structure**:
|
||||
|
||||
```typescript
|
||||
export function TerminalConfigSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Enable Toggle with Warning */}
|
||||
<div>
|
||||
<Label>Custom Terminal Configurations</Label>
|
||||
<Switch checked={enabled} onCheckedChange={handleToggle} />
|
||||
<p>Creates custom shell configs in .automaker/terminal/</p>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* Custom Prompt Toggle */}
|
||||
<Switch checked={customPrompt} />
|
||||
|
||||
{/* Prompt Format Selector */}
|
||||
<Select value={promptFormat} onValueChange={setPromptFormat}>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="powerline">Powerline</option>
|
||||
<option value="starship">Starship-Inspired</option>
|
||||
</Select>
|
||||
|
||||
{/* Git Info Toggles */}
|
||||
<Switch checked={showGitBranch} label="Show Git Branch" />
|
||||
<Switch checked={showGitStatus} label="Show Git Status" />
|
||||
|
||||
{/* Custom Aliases */}
|
||||
<Textarea
|
||||
value={customAliases}
|
||||
placeholder="# Custom aliases\nalias ll='ls -la'"
|
||||
/>
|
||||
|
||||
{/* Custom Env Vars */}
|
||||
<KeyValueEditor
|
||||
value={customEnvVars}
|
||||
onChange={setCustomEnvVars}
|
||||
/>
|
||||
|
||||
{/* Live Preview Panel */}
|
||||
<PromptPreview
|
||||
format={promptFormat}
|
||||
theme={effectiveTheme}
|
||||
gitBranch={showGitBranch ? 'main' : null}
|
||||
gitDirty={showGitStatus}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Preview Component**:
|
||||
Shows example prompt like: `[user@host] ~/projects/automaker (main*) $`
|
||||
Updates instantly when theme or format changes.
|
||||
|
||||
### Step 6: Theme Change Hook
|
||||
|
||||
**File**: `apps/server/src/routes/settings.ts`
|
||||
|
||||
**Hook into theme update endpoint**:
|
||||
|
||||
```typescript
|
||||
// After updating theme in settings
|
||||
if (oldTheme !== newTheme) {
|
||||
// Regenerate RC files for all projects with terminal config enabled
|
||||
const projects = settings.projects;
|
||||
for (const project of projects) {
|
||||
const projectSettings = await settingsService.getProjectSettings(project.path);
|
||||
if (projectSettings.terminalConfig?.enabled !== false) {
|
||||
await terminalService.onThemeChange(project.path, newTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shell Configuration Strategy
|
||||
|
||||
### Bash (via BASH_ENV)
|
||||
|
||||
- Set `BASH_ENV=/path/to/.automaker/terminal/bashrc.sh`
|
||||
- BASH_ENV is loaded for all shells (interactive and non-interactive)
|
||||
- User's ~/.bashrc is sourced first within our bashrc.sh
|
||||
- No need for `--rcfile` flag (which would skip ~/.bashrc)
|
||||
|
||||
### Zsh (via ZDOTDIR)
|
||||
|
||||
- Set `ZDOTDIR=/path/to/.automaker/terminal/`
|
||||
- Create `.zshrc` symlink: `zshrc.zsh`
|
||||
- User's ~/.zshrc is sourced within our zshrc.zsh
|
||||
- Zsh's canonical configuration directory mechanism
|
||||
|
||||
### Sh (via ENV)
|
||||
|
||||
- Set `ENV=/path/to/.automaker/terminal/common.sh`
|
||||
- POSIX shell standard environment variable
|
||||
- Minimal prompt (POSIX sh doesn't support advanced prompts)
|
||||
|
||||
## Prompt Formats
|
||||
|
||||
### 1. Standard
|
||||
|
||||
```
|
||||
[user@host] ~/path/to/project (main*) $
|
||||
```
|
||||
|
||||
### 2. Minimal
|
||||
|
||||
```
|
||||
~/project (main*) $
|
||||
```
|
||||
|
||||
### 3. Powerline (Unicode box-drawing)
|
||||
|
||||
```
|
||||
┌─[user@host]─[~/path]─[main*]
|
||||
└─$
|
||||
```
|
||||
|
||||
### 4. Starship-Inspired
|
||||
|
||||
```
|
||||
user@host in ~/path on main*
|
||||
❯
|
||||
```
|
||||
|
||||
## Theme Synchronization
|
||||
|
||||
### On Initial Enable
|
||||
|
||||
1. User toggles "Enable Custom Terminal Configs"
|
||||
2. Show confirmation dialog explaining what will happen
|
||||
3. Generate RC files for current theme
|
||||
4. Set `rcFileVersion: 11` in settings
|
||||
|
||||
### On Theme Change
|
||||
|
||||
1. User changes app theme in settings
|
||||
2. Settings API detects theme change
|
||||
3. Call `terminalService.onThemeChange()` for each project
|
||||
4. Regenerate theme color files (`.automaker/terminal/themes/`)
|
||||
5. Existing terminals keep old theme (expected behavior)
|
||||
6. New terminals use new theme
|
||||
|
||||
### On Disable
|
||||
|
||||
1. User toggles off "Enable Custom Terminal Configs"
|
||||
2. Delete `.automaker/terminal/` directory
|
||||
3. New terminals spawn without custom env vars
|
||||
4. Existing terminals continue with current config until restarted
|
||||
|
||||
## Critical Files
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/apps/server/src/services/terminal-service.ts` - Add env var injection logic at line ~335-344
|
||||
2. `/home/dhanush/Projects/automaker/libs/types/src/settings.ts` - Add terminalConfig to GlobalSettings (~line 842) and ProjectSettings
|
||||
3. `/home/dhanush/Projects/automaker/apps/server/src/routes/settings.ts` - Add theme change hook
|
||||
|
||||
### Files to Create
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/libs/platform/src/rc-generator.ts` - RC file generation logic
|
||||
2. `/home/dhanush/Projects/automaker/libs/platform/src/rc-file-manager.ts` - File I/O and path resolution
|
||||
3. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx` - Settings UI
|
||||
4. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx` - Live preview component
|
||||
|
||||
### Files to Read
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/apps/ui/src/config/terminal-themes.ts` - Source of theme hex colors for ANSI mapping
|
||||
|
||||
## Testing Approach
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `rc-generator.test.ts`: Test template generation for all 40 themes
|
||||
- `rc-file-manager.test.ts`: Test file I/O and version checking
|
||||
- `terminal-service.test.ts`: Test env var injection with mocked PTY spawn
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Enable custom configs in settings
|
||||
- Change theme and verify new terminals use new colors
|
||||
- Add custom aliases and verify they work in terminal
|
||||
- Test all 4 prompt formats
|
||||
- Test disable flow (files removed, terminals work normally)
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] Test on macOS with zsh
|
||||
- [ ] Test on Linux with bash
|
||||
- [ ] Test all 40 themes have correct colors
|
||||
- [ ] Test git prompt in repo vs non-repo directories
|
||||
- [ ] Test custom aliases execution
|
||||
- [ ] Test custom env vars available
|
||||
- [ ] Test project-specific overrides
|
||||
- [ ] Test disable/re-enable flow
|
||||
|
||||
## Verification
|
||||
|
||||
### End-to-End Test
|
||||
|
||||
1. Enable custom terminal configs in settings
|
||||
2. Set prompt format to "powerline"
|
||||
3. Add custom alias: `alias gs='git status'`
|
||||
4. Change theme to "dracula"
|
||||
5. Open new terminal
|
||||
6. Verify:
|
||||
- Prompt uses powerline format with theme colors
|
||||
- Git branch shows if in repo
|
||||
- `gs` alias works
|
||||
- User's ~/.bashrc still loaded (test with known alias from user's file)
|
||||
7. Change theme to "nord"
|
||||
8. Open new terminal
|
||||
9. Verify prompt colors changed to match nord theme
|
||||
10. Disable custom configs
|
||||
11. Verify `.automaker/terminal/` deleted
|
||||
12. Open new terminal
|
||||
13. Verify standard prompt without custom config
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- ✅ Feature can be enabled/disabled in settings
|
||||
- ✅ RC files generated in `.automaker/terminal/`
|
||||
- ✅ Prompt colors match theme (all 40 themes)
|
||||
- ✅ Git branch/status shown in prompt
|
||||
- ✅ Custom aliases work
|
||||
- ✅ Custom env vars available
|
||||
- ✅ User's original ~/.bashrc or ~/.zshrc still loads
|
||||
- ✅ Theme changes regenerate color files
|
||||
- ✅ Works on Mac (zsh) and Linux (bash)
|
||||
- ✅ No breaking changes to existing terminal functionality
|
||||
|
||||
## Security & Safety
|
||||
|
||||
### File Permissions
|
||||
|
||||
- RC files: 0644 (user read/write, others read)
|
||||
- Directory: 0755 (user rwx, others rx)
|
||||
- No secrets in RC files
|
||||
|
||||
### Input Sanitization
|
||||
|
||||
- Escape special characters in custom aliases
|
||||
- Validate env var names (alphanumeric + underscore only)
|
||||
- No eval of user-provided code
|
||||
- Shell escaping for all user inputs
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
- Feature disabled by default
|
||||
- Existing terminals unaffected when disabled
|
||||
- User's original RC files always sourced first
|
||||
- Easy rollback (just disable and delete files)
|
||||
|
||||
## Branch Creation
|
||||
|
||||
Per PR workflow in DEVELOPMENT_WORKFLOW.md:
|
||||
|
||||
1. Create feature branch: `git checkout -b feature/custom-terminal-configs`
|
||||
2. Implement changes following this plan
|
||||
3. Test thoroughly
|
||||
4. Merge upstream RC before shipping: `git merge upstream/v0.14.0rc --no-edit`
|
||||
5. Push to origin: `git push -u origin feature/custom-terminal-configs`
|
||||
6. Create PR targeting `main` branch
|
||||
|
||||
## Documentation
|
||||
|
||||
After implementation, create comprehensive documentation at:
|
||||
`/home/dhanush/Projects/automaker/docs/terminal-custom-configs.md`
|
||||
|
||||
**Documentation should cover**:
|
||||
|
||||
- Feature overview and benefits
|
||||
- How to enable custom terminal configs
|
||||
- Prompt format options with examples
|
||||
- Custom aliases and env vars
|
||||
- Theme synchronization behavior
|
||||
- Troubleshooting common issues
|
||||
- How to disable the feature
|
||||
- Technical details for contributors
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- Week 1: Core infrastructure (RC generator, file manager, settings schema)
|
||||
- Week 2: Terminal service integration, theme sync
|
||||
- Week 3: Settings UI, preview component
|
||||
- Week 4: Testing, documentation, polish
|
||||
|
||||
Total: ~4 weeks for complete implementation
|
||||
@@ -186,3 +186,37 @@ export {
|
||||
findTerminalById,
|
||||
openInExternalTerminal,
|
||||
} from './terminal.js';
|
||||
|
||||
// RC Generator - Shell configuration file generation
|
||||
export {
|
||||
hexToXterm256,
|
||||
getThemeANSIColors,
|
||||
generateBashrc,
|
||||
generateZshrc,
|
||||
generateCommonFunctions,
|
||||
generateThemeColors,
|
||||
getShellName,
|
||||
type TerminalConfig,
|
||||
type TerminalTheme,
|
||||
type ANSIColors,
|
||||
} from './rc-generator.js';
|
||||
|
||||
// RC File Manager - Shell configuration file I/O
|
||||
export {
|
||||
RC_FILE_VERSION,
|
||||
getTerminalDir,
|
||||
getThemesDir,
|
||||
getRcFilePath,
|
||||
ensureTerminalDir,
|
||||
checkRcFileVersion,
|
||||
needsRegeneration,
|
||||
writeAllThemeFiles,
|
||||
writeThemeFile,
|
||||
writeRcFiles,
|
||||
ensureRcFilesUpToDate,
|
||||
deleteTerminalDir,
|
||||
ensureUserCustomFile,
|
||||
} from './rc-file-manager.js';
|
||||
|
||||
// Terminal Theme Colors - Raw theme color data for all 40 themes
|
||||
export { terminalThemeColors, getTerminalThemeColors } from './terminal-theme-colors.js';
|
||||
|
||||
308
libs/platform/src/rc-file-manager.ts
Normal file
308
libs/platform/src/rc-file-manager.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* RC File Manager - Manage shell configuration files in .automaker/terminal/
|
||||
*
|
||||
* This module handles file I/O operations for generating and managing shell RC files,
|
||||
* including version checking and regeneration logic.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import {
|
||||
generateBashrc,
|
||||
generateZshrc,
|
||||
generateCommonFunctions,
|
||||
generateThemeColors,
|
||||
type TerminalConfig,
|
||||
type TerminalTheme,
|
||||
} from './rc-generator.js';
|
||||
|
||||
/**
|
||||
* Current RC file format version
|
||||
*/
|
||||
export const RC_FILE_VERSION = 11;
|
||||
|
||||
const RC_SIGNATURE_FILENAME = 'config.sha256';
|
||||
|
||||
/**
|
||||
* Get the terminal directory path
|
||||
*/
|
||||
export function getTerminalDir(projectPath: string): string {
|
||||
return path.join(projectPath, '.automaker', 'terminal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the themes directory path
|
||||
*/
|
||||
export function getThemesDir(projectPath: string): string {
|
||||
return path.join(getTerminalDir(projectPath), 'themes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RC file path for specific shell
|
||||
*/
|
||||
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string {
|
||||
const terminalDir = getTerminalDir(projectPath);
|
||||
switch (shell) {
|
||||
case 'bash':
|
||||
return path.join(terminalDir, 'bashrc.sh');
|
||||
case 'zsh':
|
||||
return path.join(terminalDir, '.zshrc'); // Zsh looks for .zshrc in ZDOTDIR
|
||||
case 'sh':
|
||||
return path.join(terminalDir, 'common.sh');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure terminal directory exists
|
||||
*/
|
||||
export async function ensureTerminalDir(projectPath: string): Promise<void> {
|
||||
const terminalDir = getTerminalDir(projectPath);
|
||||
const themesDir = getThemesDir(projectPath);
|
||||
|
||||
await fs.mkdir(terminalDir, { recursive: true, mode: 0o755 });
|
||||
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write RC file with atomic write (write to temp, then rename)
|
||||
*/
|
||||
async function atomicWriteFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
mode: number = 0o644
|
||||
): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
await fs.writeFile(tempPath, content, { encoding: 'utf8', mode });
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
function sortObjectKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sortObjectKeys(item));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const sortedEntries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
const sortedObject: Record<string, unknown> = {};
|
||||
for (const [key, entryValue] of sortedEntries) {
|
||||
sortedObject[key] = sortObjectKeys(entryValue);
|
||||
}
|
||||
return sortedObject;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildConfigSignature(theme: ThemeMode, config: TerminalConfig): string {
|
||||
const payload = { theme, config: sortObjectKeys(config) };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
return createHash('sha256').update(serializedPayload).digest('hex');
|
||||
}
|
||||
|
||||
async function readSignatureFile(projectPath: string): Promise<string | null> {
|
||||
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
|
||||
try {
|
||||
const signature = await fs.readFile(signaturePath, 'utf8');
|
||||
return signature.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSignatureFile(projectPath: string, signature: string): Promise<void> {
|
||||
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
|
||||
await atomicWriteFile(signaturePath, `${signature}\n`, 0o644);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current RC file version
|
||||
*/
|
||||
export async function checkRcFileVersion(projectPath: string): Promise<number | null> {
|
||||
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
|
||||
try {
|
||||
const content = await fs.readFile(versionPath, 'utf8');
|
||||
const version = parseInt(content.trim(), 10);
|
||||
return isNaN(version) ? null : version;
|
||||
} catch (error) {
|
||||
return null; // File doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write version file
|
||||
*/
|
||||
async function writeVersionFile(projectPath: string, version: number): Promise<void> {
|
||||
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
|
||||
await atomicWriteFile(versionPath, `${version}\n`, 0o644);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if RC files need regeneration
|
||||
*/
|
||||
export async function needsRegeneration(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<boolean> {
|
||||
const currentVersion = await checkRcFileVersion(projectPath);
|
||||
|
||||
// Regenerate if version doesn't match or files don't exist
|
||||
if (currentVersion !== RC_FILE_VERSION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expectedSignature = buildConfigSignature(theme, config);
|
||||
const existingSignature = await readSignatureFile(projectPath);
|
||||
if (!existingSignature || existingSignature !== expectedSignature) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if critical files exist
|
||||
const bashrcPath = getRcFilePath(projectPath, 'bash');
|
||||
const zshrcPath = getRcFilePath(projectPath, 'zsh');
|
||||
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
|
||||
const themeFilePath = path.join(getThemesDir(projectPath), `${theme}.sh`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.access(bashrcPath),
|
||||
fs.access(zshrcPath),
|
||||
fs.access(commonPath),
|
||||
fs.access(themeFilePath),
|
||||
]);
|
||||
return false; // All files exist
|
||||
} catch {
|
||||
return true; // Some files are missing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all theme color files (all 40 themes)
|
||||
*/
|
||||
export async function writeAllThemeFiles(
|
||||
projectPath: string,
|
||||
terminalThemes: Record<ThemeMode, TerminalTheme>
|
||||
): Promise<void> {
|
||||
const themesDir = getThemesDir(projectPath);
|
||||
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
|
||||
|
||||
const themeEntries = Object.entries(terminalThemes);
|
||||
await Promise.all(
|
||||
themeEntries.map(async ([themeName, theme]) => {
|
||||
const themeFilePath = path.join(themesDir, `${themeName}.sh`);
|
||||
const content = generateThemeColors(theme);
|
||||
await atomicWriteFile(themeFilePath, content, 0o644);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single theme color file
|
||||
*/
|
||||
export async function writeThemeFile(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
themeColors: TerminalTheme
|
||||
): Promise<void> {
|
||||
const themesDir = getThemesDir(projectPath);
|
||||
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
|
||||
|
||||
const themeFilePath = path.join(themesDir, `${theme}.sh`);
|
||||
const content = generateThemeColors(themeColors);
|
||||
await atomicWriteFile(themeFilePath, content, 0o644);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all RC files
|
||||
*/
|
||||
export async function writeRcFiles(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig,
|
||||
themeColors: TerminalTheme,
|
||||
allThemes: Record<ThemeMode, TerminalTheme>
|
||||
): Promise<void> {
|
||||
await ensureTerminalDir(projectPath);
|
||||
|
||||
// Write common functions file
|
||||
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
|
||||
const commonContent = generateCommonFunctions(config);
|
||||
await atomicWriteFile(commonPath, commonContent, 0o644);
|
||||
|
||||
// Write bashrc
|
||||
const bashrcPath = getRcFilePath(projectPath, 'bash');
|
||||
const bashrcContent = generateBashrc(themeColors, config);
|
||||
await atomicWriteFile(bashrcPath, bashrcContent, 0o644);
|
||||
|
||||
// Write zshrc
|
||||
const zshrcPath = getRcFilePath(projectPath, 'zsh');
|
||||
const zshrcContent = generateZshrc(themeColors, config);
|
||||
await atomicWriteFile(zshrcPath, zshrcContent, 0o644);
|
||||
|
||||
// Write all theme files (40 themes)
|
||||
await writeAllThemeFiles(projectPath, allThemes);
|
||||
|
||||
// Write version file
|
||||
await writeVersionFile(projectPath, RC_FILE_VERSION);
|
||||
|
||||
// Write config signature for change detection
|
||||
const signature = buildConfigSignature(theme, config);
|
||||
await writeSignatureFile(projectPath, signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure RC files are up to date
|
||||
*/
|
||||
export async function ensureRcFilesUpToDate(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig,
|
||||
themeColors: TerminalTheme,
|
||||
allThemes: Record<ThemeMode, TerminalTheme>
|
||||
): Promise<void> {
|
||||
const needsRegen = await needsRegeneration(projectPath, theme, config);
|
||||
if (needsRegen) {
|
||||
await writeRcFiles(projectPath, theme, config, themeColors, allThemes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete terminal directory (for disable flow)
|
||||
*/
|
||||
export async function deleteTerminalDir(projectPath: string): Promise<void> {
|
||||
const terminalDir = getTerminalDir(projectPath);
|
||||
try {
|
||||
await fs.rm(terminalDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user-custom.sh placeholder if it doesn't exist
|
||||
*/
|
||||
export async function ensureUserCustomFile(projectPath: string): Promise<void> {
|
||||
const userCustomPath = path.join(getTerminalDir(projectPath), 'user-custom.sh');
|
||||
try {
|
||||
await fs.access(userCustomPath);
|
||||
} catch {
|
||||
// File doesn't exist, create it
|
||||
const content = `#!/bin/sh
|
||||
# Automaker User Customizations
|
||||
# Add your custom shell configuration here
|
||||
# This file will not be overwritten by Automaker
|
||||
|
||||
# Example: Add custom aliases
|
||||
# alias myalias='command'
|
||||
|
||||
# Example: Add custom environment variables
|
||||
# export MY_VAR="value"
|
||||
`;
|
||||
await atomicWriteFile(userCustomPath, content, 0o644);
|
||||
}
|
||||
}
|
||||
972
libs/platform/src/rc-generator.ts
Normal file
972
libs/platform/src/rc-generator.ts
Normal file
@@ -0,0 +1,972 @@
|
||||
/**
|
||||
* RC Generator - Generate shell configuration files for custom terminal prompts
|
||||
*
|
||||
* This module generates bash/zsh/sh configuration files that sync with Automaker's themes,
|
||||
* providing custom prompts with theme-matched colors while preserving user's existing RC files.
|
||||
*/
|
||||
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Terminal configuration options
|
||||
*/
|
||||
export interface TerminalConfig {
|
||||
enabled: boolean;
|
||||
customPrompt: boolean;
|
||||
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
customAliases: string;
|
||||
customEnvVars: Record<string, string>;
|
||||
rcFileVersion?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal theme colors (hex values)
|
||||
*/
|
||||
export interface TerminalTheme {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent: string;
|
||||
selectionBackground: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ANSI color codes for shell prompts
|
||||
*/
|
||||
export interface ANSIColors {
|
||||
user: string;
|
||||
host: string;
|
||||
path: string;
|
||||
gitBranch: string;
|
||||
gitDirty: string;
|
||||
prompt: string;
|
||||
reset: string;
|
||||
}
|
||||
|
||||
const STARTUP_COLOR_PRIMARY = 51;
|
||||
const STARTUP_COLOR_SECONDARY = 39;
|
||||
const STARTUP_COLOR_ACCENT = 33;
|
||||
const DEFAULT_PATH_DEPTH = 0;
|
||||
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
|
||||
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
|
||||
const OMP_BINARY = 'oh-my-posh';
|
||||
const OMP_SHELL_BASH = 'bash';
|
||||
const OMP_SHELL_ZSH = 'zsh';
|
||||
|
||||
/**
|
||||
* Convert hex color to RGB
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) {
|
||||
throw new Error(`Invalid hex color: ${hex}`);
|
||||
}
|
||||
return {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Euclidean distance between two RGB colors
|
||||
*/
|
||||
function colorDistance(
|
||||
c1: { r: number; g: number; b: number },
|
||||
c2: { r: number; g: number; b: number }
|
||||
): number {
|
||||
return Math.sqrt(Math.pow(c1.r - c2.r, 2) + Math.pow(c1.g - c2.g, 2) + Math.pow(c1.b - c2.b, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm-256 color palette (simplified - standard colors + 6x6x6 RGB cube + grayscale)
|
||||
*/
|
||||
const XTERM_256_PALETTE: Array<{ r: number; g: number; b: number }> = [];
|
||||
|
||||
// Standard colors (0-15) - already handled by ANSI basic colors
|
||||
// RGB cube (16-231): 6x6x6 cube with levels 0, 95, 135, 175, 215, 255
|
||||
const levels = [0, 95, 135, 175, 215, 255];
|
||||
for (let r = 0; r < 6; r++) {
|
||||
for (let g = 0; g < 6; g++) {
|
||||
for (let b = 0; b < 6; b++) {
|
||||
XTERM_256_PALETTE.push({ r: levels[r], g: levels[g], b: levels[b] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grayscale (232-255): 24 shades from #080808 to #eeeeee
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const gray = 8 + i * 10;
|
||||
XTERM_256_PALETTE.push({ r: gray, g: gray, b: gray });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to closest xterm-256 color code
|
||||
*/
|
||||
export function hexToXterm256(hex: string): number {
|
||||
const rgb = hexToRgb(hex);
|
||||
let closestIndex = 16; // Start from RGB cube
|
||||
let minDistance = Infinity;
|
||||
|
||||
XTERM_256_PALETTE.forEach((color, index) => {
|
||||
const distance = colorDistance(rgb, color);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestIndex = index + 16; // Offset by 16 (standard colors)
|
||||
}
|
||||
});
|
||||
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ANSI color codes from theme colors
|
||||
*/
|
||||
export function getThemeANSIColors(theme: TerminalTheme): ANSIColors {
|
||||
return {
|
||||
user: `\\[\\e[38;5;${hexToXterm256(theme.cyan)}m\\]`,
|
||||
host: `\\[\\e[38;5;${hexToXterm256(theme.blue)}m\\]`,
|
||||
path: `\\[\\e[38;5;${hexToXterm256(theme.yellow)}m\\]`,
|
||||
gitBranch: `\\[\\e[38;5;${hexToXterm256(theme.magenta)}m\\]`,
|
||||
gitDirty: `\\[\\e[38;5;${hexToXterm256(theme.red)}m\\]`,
|
||||
prompt: `\\[\\e[38;5;${hexToXterm256(theme.green)}m\\]`,
|
||||
reset: '\\[\\e[0m\\]',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape shell special characters in user input
|
||||
*/
|
||||
function shellEscape(str: string): string {
|
||||
return str.replace(/([`$\\"])/g, '\\$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate environment variable name
|
||||
*/
|
||||
function isValidEnvVarName(name: string): boolean {
|
||||
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
function stripPromptEscapes(ansiColor: string): string {
|
||||
return ansiColor.replace(/\\\[/g, '').replace(/\\\]/g, '');
|
||||
}
|
||||
|
||||
function normalizePathStyle(
|
||||
pathStyle: TerminalConfig['pathStyle'] | undefined
|
||||
): TerminalConfig['pathStyle'] {
|
||||
if (pathStyle === 'short' || pathStyle === 'basename') {
|
||||
return pathStyle;
|
||||
}
|
||||
return DEFAULT_PATH_STYLE;
|
||||
}
|
||||
|
||||
function normalizePathDepth(pathDepth: number | undefined): number {
|
||||
const depth =
|
||||
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
|
||||
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
|
||||
}
|
||||
|
||||
function generateOhMyPoshInit(
|
||||
shell: typeof OMP_SHELL_BASH | typeof OMP_SHELL_ZSH,
|
||||
fallback: string
|
||||
) {
|
||||
const themeVar = `$${OMP_THEME_ENV_VAR}`;
|
||||
const initCommand = `${OMP_BINARY} init ${shell} --config`;
|
||||
return `if [ -n "${themeVar}" ] && command -v ${OMP_BINARY} >/dev/null 2>&1; then
|
||||
automaker_omp_theme="$(automaker_resolve_omp_theme)"
|
||||
if [ -n "$automaker_omp_theme" ]; then
|
||||
eval "$(${initCommand} "$automaker_omp_theme")"
|
||||
else
|
||||
${fallback}
|
||||
fi
|
||||
else
|
||||
${fallback}
|
||||
fi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate common shell functions (git prompt, etc.)
|
||||
*/
|
||||
export function generateCommonFunctions(config: TerminalConfig): string {
|
||||
const gitPrompt = config.showGitBranch
|
||||
? `
|
||||
automaker_git_prompt() {
|
||||
local branch=""
|
||||
local dirty=""
|
||||
|
||||
# Check if we're in a git repository
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
# Get current branch name
|
||||
branch=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null)
|
||||
|
||||
${
|
||||
config.showGitStatus
|
||||
? `
|
||||
# Check if working directory is dirty
|
||||
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
dirty="*"
|
||||
fi
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
if [ -n "$branch" ]; then
|
||||
echo -n " ($branch$dirty)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
`
|
||||
: `
|
||||
automaker_git_prompt() {
|
||||
# Git prompt disabled
|
||||
echo -n ""
|
||||
}
|
||||
`;
|
||||
|
||||
return `#!/bin/sh
|
||||
# Automaker Terminal Configuration - Common Functions v1.0
|
||||
|
||||
${gitPrompt}
|
||||
|
||||
AUTOMAKER_INFO_UNKNOWN="Unknown"
|
||||
AUTOMAKER_BANNER_LABEL_WIDTH=12
|
||||
AUTOMAKER_BYTES_PER_KIB=1024
|
||||
AUTOMAKER_KIB_PER_MIB=1024
|
||||
AUTOMAKER_MIB_PER_GIB=1024
|
||||
AUTOMAKER_COLOR_PRIMARY="\\033[38;5;${STARTUP_COLOR_PRIMARY}m"
|
||||
AUTOMAKER_COLOR_SECONDARY="\\033[38;5;${STARTUP_COLOR_SECONDARY}m"
|
||||
AUTOMAKER_COLOR_ACCENT="\\033[38;5;${STARTUP_COLOR_ACCENT}m"
|
||||
AUTOMAKER_COLOR_RESET="\\033[0m"
|
||||
AUTOMAKER_SHOW_TIME="${config.showTime === true ? 'true' : 'false'}"
|
||||
AUTOMAKER_SHOW_EXIT_STATUS="${config.showExitStatus === true ? 'true' : 'false'}"
|
||||
AUTOMAKER_SHOW_USER_HOST="${config.showUserHost === false ? 'false' : 'true'}"
|
||||
AUTOMAKER_SHOW_PATH="${config.showPath === false ? 'false' : 'true'}"
|
||||
AUTOMAKER_PATH_STYLE="${normalizePathStyle(config.pathStyle)}"
|
||||
AUTOMAKER_PATH_DEPTH=${normalizePathDepth(config.pathDepth)}
|
||||
automaker_default_themes_dir="\${XDG_DATA_HOME:-\$HOME/.local/share}/oh-my-posh/themes"
|
||||
if [ -z "$POSH_THEMES_PATH" ] || [ ! -d "$POSH_THEMES_PATH" ]; then
|
||||
POSH_THEMES_PATH="$automaker_default_themes_dir"
|
||||
fi
|
||||
export POSH_THEMES_PATH
|
||||
|
||||
automaker_resolve_omp_theme() {
|
||||
automaker_theme_name="$AUTOMAKER_OMP_THEME"
|
||||
if [ -z "$automaker_theme_name" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -f "$automaker_theme_name" ]; then
|
||||
printf '%s' "$automaker_theme_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
automaker_themes_base="\${POSH_THEMES_PATH%/}"
|
||||
if [ -n "$automaker_themes_base" ]; then
|
||||
if [ -f "$automaker_themes_base/$automaker_theme_name.omp.json" ]; then
|
||||
printf '%s' "$automaker_themes_base/$automaker_theme_name.omp.json"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$automaker_themes_base/$automaker_theme_name.omp.yaml" ]; then
|
||||
printf '%s' "$automaker_themes_base/$automaker_theme_name.omp.yaml"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
automaker_command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
automaker_get_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
if [ -n "$PRETTY_NAME" ]; then
|
||||
echo "$PRETTY_NAME"
|
||||
return
|
||||
fi
|
||||
if [ -n "$NAME" ] && [ -n "$VERSION" ]; then
|
||||
echo "$NAME $VERSION"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if automaker_command_exists sw_vers; then
|
||||
echo "$(sw_vers -productName) $(sw_vers -productVersion)"
|
||||
return
|
||||
fi
|
||||
|
||||
uname -s 2>/dev/null || echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_uptime() {
|
||||
if automaker_command_exists uptime; then
|
||||
if uptime -p >/dev/null 2>&1; then
|
||||
uptime -p
|
||||
return
|
||||
fi
|
||||
uptime 2>/dev/null | sed 's/.*up \\([^,]*\\).*/\\1/' || uptime 2>/dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_cpu() {
|
||||
if automaker_command_exists lscpu; then
|
||||
lscpu | sed -n 's/Model name:[[:space:]]*//p' | head -n 1
|
||||
return
|
||||
fi
|
||||
|
||||
if automaker_command_exists sysctl; then
|
||||
sysctl -n machdep.cpu.brand_string 2>/dev/null || sysctl -n hw.model 2>/dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
uname -m 2>/dev/null || echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_memory() {
|
||||
if automaker_command_exists free; then
|
||||
free -h | awk '/Mem:/ {print $3 " / " $2}'
|
||||
return
|
||||
fi
|
||||
|
||||
if automaker_command_exists vm_stat; then
|
||||
local page_size
|
||||
local pages_free
|
||||
local pages_active
|
||||
local pages_inactive
|
||||
local pages_wired
|
||||
local pages_total
|
||||
page_size=$(vm_stat | awk '/page size of/ {print $8}')
|
||||
pages_free=$(vm_stat | awk '/Pages free/ {print $3}' | tr -d '.')
|
||||
pages_active=$(vm_stat | awk '/Pages active/ {print $3}' | tr -d '.')
|
||||
pages_inactive=$(vm_stat | awk '/Pages inactive/ {print $3}' | tr -d '.')
|
||||
pages_wired=$(vm_stat | awk '/Pages wired down/ {print $4}' | tr -d '.')
|
||||
pages_total=$((pages_free + pages_active + pages_inactive + pages_wired))
|
||||
awk -v total="$pages_total" -v free="$pages_free" -v size="$page_size" \
|
||||
-v bytes_kib="$AUTOMAKER_BYTES_PER_KIB" \
|
||||
-v kib_mib="$AUTOMAKER_KIB_PER_MIB" \
|
||||
-v mib_gib="$AUTOMAKER_MIB_PER_GIB" \
|
||||
'BEGIN {
|
||||
total_gb = total * size / bytes_kib / kib_mib / mib_gib;
|
||||
used_gb = (total - free) * size / bytes_kib / kib_mib / mib_gib;
|
||||
printf("%.1f GB / %.1f GB", used_gb, total_gb);
|
||||
}'
|
||||
return
|
||||
fi
|
||||
|
||||
if automaker_command_exists sysctl; then
|
||||
local total_bytes
|
||||
total_bytes=$(sysctl -n hw.memsize 2>/dev/null)
|
||||
if [ -n "$total_bytes" ]; then
|
||||
awk -v total="$total_bytes" \
|
||||
-v bytes_kib="$AUTOMAKER_BYTES_PER_KIB" \
|
||||
-v kib_mib="$AUTOMAKER_KIB_PER_MIB" \
|
||||
-v mib_gib="$AUTOMAKER_MIB_PER_GIB" \
|
||||
'BEGIN {printf("%.1f GB", total / bytes_kib / kib_mib / mib_gib)}'
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_disk() {
|
||||
if automaker_command_exists df; then
|
||||
df -h / 2>/dev/null | awk 'NR==2 {print $3 " / " $2}'
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_ip() {
|
||||
if automaker_command_exists hostname; then
|
||||
local ip_addr
|
||||
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$ip_addr" ]; then
|
||||
echo "$ip_addr"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if automaker_command_exists ipconfig; then
|
||||
local ip_addr
|
||||
ip_addr=$(ipconfig getifaddr en0 2>/dev/null)
|
||||
if [ -n "$ip_addr" ]; then
|
||||
echo "$ip_addr"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_trim_path_depth() {
|
||||
local path="$1"
|
||||
local depth="$2"
|
||||
if [ -z "$depth" ] || [ "$depth" -le 0 ]; then
|
||||
echo "$path"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$path" | awk -v depth="$depth" -F/ '{
|
||||
prefix=""
|
||||
start=1
|
||||
if ($1=="") { prefix="/"; start=2 }
|
||||
else if ($1=="~") { prefix="~/"; start=2 }
|
||||
n=NF
|
||||
if (n < start) {
|
||||
if (prefix=="/") { print "/" }
|
||||
else if (prefix=="~/") { print "~" }
|
||||
else { print $0 }
|
||||
next
|
||||
}
|
||||
segCount = n - start + 1
|
||||
d = depth
|
||||
if (d > segCount) { d = segCount }
|
||||
out=""
|
||||
for (i = n - d + 1; i <= n; i++) {
|
||||
out = out (out=="" ? "" : "/") $i
|
||||
}
|
||||
if (prefix=="/") {
|
||||
if (out=="") { out="/" } else { out="/" out }
|
||||
} else if (prefix=="~/") {
|
||||
if (out=="") { out="~" } else { out="~/" out }
|
||||
}
|
||||
print out
|
||||
}'
|
||||
}
|
||||
|
||||
automaker_shorten_path() {
|
||||
local path="$1"
|
||||
echo "$path" | awk -F/ '{
|
||||
prefix=""
|
||||
start=1
|
||||
if ($1=="") { prefix="/"; start=2 }
|
||||
else if ($1=="~") { prefix="~/"; start=2 }
|
||||
n=NF
|
||||
if (n < start) {
|
||||
if (prefix=="/") { print "/" }
|
||||
else if (prefix=="~/") { print "~" }
|
||||
else { print $0 }
|
||||
next
|
||||
}
|
||||
out=""
|
||||
for (i = start; i <= n; i++) {
|
||||
seg = $i
|
||||
if (i < n && length(seg) > 0) { seg = substr(seg, 1, 1) }
|
||||
out = out (out=="" ? "" : "/") seg
|
||||
}
|
||||
if (prefix=="/") { out="/" out }
|
||||
else if (prefix=="~/") { out="~/" out }
|
||||
print out
|
||||
}'
|
||||
}
|
||||
|
||||
automaker_prompt_path() {
|
||||
if [ "$AUTOMAKER_SHOW_PATH" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local current_path="$PWD"
|
||||
if [ -n "$HOME" ] && [ "\${current_path#"$HOME"}" != "$current_path" ]; then
|
||||
current_path="~\${current_path#$HOME}"
|
||||
fi
|
||||
|
||||
if [ "$AUTOMAKER_PATH_DEPTH" -gt 0 ]; then
|
||||
current_path=$(automaker_trim_path_depth "$current_path" "$AUTOMAKER_PATH_DEPTH")
|
||||
fi
|
||||
|
||||
case "$AUTOMAKER_PATH_STYLE" in
|
||||
basename)
|
||||
if [ "$current_path" = "/" ] || [ "$current_path" = "~" ]; then
|
||||
echo -n "$current_path"
|
||||
else
|
||||
echo -n "\${current_path##*/}"
|
||||
fi
|
||||
;;
|
||||
short)
|
||||
echo -n "$(automaker_shorten_path "$current_path")"
|
||||
;;
|
||||
full|*)
|
||||
echo -n "$current_path"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
automaker_prompt_time() {
|
||||
if [ "$AUTOMAKER_SHOW_TIME" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
date +%H:%M
|
||||
}
|
||||
|
||||
automaker_prompt_status() {
|
||||
automaker_last_status=$?
|
||||
if [ "$AUTOMAKER_SHOW_EXIT_STATUS" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$automaker_last_status" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
printf "✗ %s" "$automaker_last_status"
|
||||
}
|
||||
|
||||
automaker_show_banner() {
|
||||
local label_width="$AUTOMAKER_BANNER_LABEL_WIDTH"
|
||||
local logo_line_1=" █▀▀█ █ █ ▀▀█▀▀ █▀▀█ █▀▄▀█ █▀▀█ █ █ █▀▀ █▀▀█ "
|
||||
local logo_line_2=" █▄▄█ █ █ █ █ █ █ ▀ █ █▄▄█ █▀▄ █▀▀ █▄▄▀ "
|
||||
local logo_line_3=" ▀ ▀ ▀▀▀ ▀ ▀▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀▀ "
|
||||
local accent_color="\${AUTOMAKER_COLOR_PRIMARY}"
|
||||
local secondary_color="\${AUTOMAKER_COLOR_SECONDARY}"
|
||||
local tertiary_color="\${AUTOMAKER_COLOR_ACCENT}"
|
||||
local label_color="\${AUTOMAKER_COLOR_SECONDARY}"
|
||||
local reset_color="\${AUTOMAKER_COLOR_RESET}"
|
||||
|
||||
printf "%b%s%b\n" "$accent_color" "$logo_line_1" "$reset_color"
|
||||
printf "%b%s%b\n" "$secondary_color" "$logo_line_2" "$reset_color"
|
||||
printf "%b%s%b\n" "$tertiary_color" "$logo_line_3" "$reset_color"
|
||||
printf "\n"
|
||||
|
||||
local shell_name="\${SHELL##*/}"
|
||||
if [ -z "$shell_name" ]; then
|
||||
shell_name=$(basename "$0" 2>/dev/null || echo "shell")
|
||||
fi
|
||||
local user_host="\${USER:-unknown}@$(hostname 2>/dev/null || echo unknown)"
|
||||
printf "%b%s%b\n" "$label_color" "$user_host" "$reset_color"
|
||||
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "OS:" "$reset_color" "$(automaker_get_os)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Uptime:" "$reset_color" "$(automaker_get_uptime)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Shell:" "$reset_color" "$shell_name"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Terminal:" "$reset_color" "\${TERM_PROGRAM:-$TERM}"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "CPU:" "$reset_color" "$(automaker_get_cpu)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Memory:" "$reset_color" "$(automaker_get_memory)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Disk:" "$reset_color" "$(automaker_get_disk)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Local IP:" "$reset_color" "$(automaker_get_ip)"
|
||||
printf "\n"
|
||||
}
|
||||
|
||||
automaker_show_banner_once() {
|
||||
case "$-" in
|
||||
*i*) ;;
|
||||
*) return ;;
|
||||
esac
|
||||
|
||||
if [ "$AUTOMAKER_BANNER_SHOWN" = "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
automaker_show_banner
|
||||
export AUTOMAKER_BANNER_SHOWN="true"
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate prompt based on format
|
||||
*/
|
||||
function generatePrompt(
|
||||
format: TerminalConfig['promptFormat'],
|
||||
colors: ANSIColors,
|
||||
config: TerminalConfig
|
||||
): string {
|
||||
const userHostSegment = config.showUserHost
|
||||
? `${colors.user}\\u${colors.reset}@${colors.host}\\h${colors.reset}`
|
||||
: '';
|
||||
const pathSegment = config.showPath
|
||||
? `${colors.path}\\$(automaker_prompt_path)${colors.reset}`
|
||||
: '';
|
||||
const gitSegment = config.showGitBranch
|
||||
? `${colors.gitBranch}\\$(automaker_git_prompt)${colors.reset}`
|
||||
: '';
|
||||
const timeSegment = config.showTime
|
||||
? `${colors.gitBranch}[\\$(automaker_prompt_time)]${colors.reset}`
|
||||
: '';
|
||||
const statusSegment = config.showExitStatus
|
||||
? `${colors.gitDirty}\\$(automaker_prompt_status)${colors.reset}`
|
||||
: '';
|
||||
|
||||
switch (format) {
|
||||
case 'minimal': {
|
||||
const minimalSegments = [timeSegment, userHostSegment, pathSegment, gitSegment, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="${minimalSegments ? `${minimalSegments} ` : ''}${colors.prompt}\\$${colors.reset} "`;
|
||||
}
|
||||
|
||||
case 'powerline': {
|
||||
const powerlineCoreSegments = [
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment ? `[${pathSegment}]` : '',
|
||||
].filter((segment) => segment.length > 0);
|
||||
const powerlineCore = powerlineCoreSegments.join('─');
|
||||
const powerlineExtras = [gitSegment, timeSegment, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
const powerlineLine = [powerlineCore, powerlineExtras]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="┌─${powerlineLine}\\n└─${colors.prompt}\\$${colors.reset} "`;
|
||||
}
|
||||
|
||||
case 'starship': {
|
||||
let starshipLine = '';
|
||||
if (userHostSegment && pathSegment) {
|
||||
starshipLine = `${userHostSegment} in ${pathSegment}`;
|
||||
} else {
|
||||
starshipLine = [userHostSegment, pathSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
}
|
||||
if (gitSegment) {
|
||||
starshipLine = `${starshipLine}${starshipLine ? ' on ' : ''}${gitSegment}`;
|
||||
}
|
||||
const starshipSegments = [timeSegment, starshipLine, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="${starshipSegments}\\n${colors.prompt}❯${colors.reset} "`;
|
||||
}
|
||||
|
||||
case 'standard':
|
||||
default: {
|
||||
const standardSegments = [
|
||||
timeSegment,
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment,
|
||||
gitSegment,
|
||||
statusSegment,
|
||||
]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="${standardSegments ? `${standardSegments} ` : ''}${colors.prompt}\\$${colors.reset} "`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Zsh prompt based on format
|
||||
*/
|
||||
function generateZshPrompt(
|
||||
format: TerminalConfig['promptFormat'],
|
||||
colors: ANSIColors,
|
||||
config: TerminalConfig
|
||||
): string {
|
||||
// Convert bash-style \u, \h, \w to zsh-style %n, %m, %~
|
||||
// Remove bash-style escaping \[ \] (not needed in zsh)
|
||||
const zshColors = {
|
||||
user: colors.user
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
host: colors.host
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
path: colors.path
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
gitBranch: colors.gitBranch
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
gitDirty: colors.gitDirty
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
prompt: colors.prompt
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
reset: colors.reset
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
};
|
||||
|
||||
const userHostSegment = config.showUserHost
|
||||
? `[${zshColors.user}%n${zshColors.reset}@${zshColors.host}%m${zshColors.reset}]`
|
||||
: '';
|
||||
const pathSegment = config.showPath
|
||||
? `${zshColors.path}$(automaker_prompt_path)${zshColors.reset}`
|
||||
: '';
|
||||
const gitSegment = config.showGitBranch
|
||||
? `${zshColors.gitBranch}$(automaker_git_prompt)${zshColors.reset}`
|
||||
: '';
|
||||
const timeSegment = config.showTime
|
||||
? `${zshColors.gitBranch}[$(automaker_prompt_time)]${zshColors.reset}`
|
||||
: '';
|
||||
const statusSegment = config.showExitStatus
|
||||
? `${zshColors.gitDirty}$(automaker_prompt_status)${zshColors.reset}`
|
||||
: '';
|
||||
const segments = [timeSegment, userHostSegment, pathSegment, gitSegment, statusSegment].filter(
|
||||
(segment) => segment.length > 0
|
||||
);
|
||||
const inlineSegments = segments.join(' ');
|
||||
const inlineWithSpace = inlineSegments ? `${inlineSegments} ` : '';
|
||||
|
||||
switch (format) {
|
||||
case 'minimal': {
|
||||
return `PROMPT="${inlineWithSpace}${zshColors.prompt}%#${zshColors.reset} "`;
|
||||
}
|
||||
|
||||
case 'powerline': {
|
||||
const powerlineCoreSegments = [
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment ? `[${pathSegment}]` : '',
|
||||
].filter((segment) => segment.length > 0);
|
||||
const powerlineCore = powerlineCoreSegments.join('─');
|
||||
const powerlineExtras = [gitSegment, timeSegment, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
const powerlineLine = [powerlineCore, powerlineExtras]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PROMPT="┌─${powerlineLine}
|
||||
└─${zshColors.prompt}%#${zshColors.reset} "`;
|
||||
}
|
||||
|
||||
case 'starship': {
|
||||
let starshipLine = '';
|
||||
if (userHostSegment && pathSegment) {
|
||||
starshipLine = `${userHostSegment} in ${pathSegment}`;
|
||||
} else {
|
||||
starshipLine = [userHostSegment, pathSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
}
|
||||
if (gitSegment) {
|
||||
starshipLine = `${starshipLine}${starshipLine ? ' on ' : ''}${gitSegment}`;
|
||||
}
|
||||
const starshipSegments = [timeSegment, starshipLine, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PROMPT="${starshipSegments}
|
||||
${zshColors.prompt}❯${zshColors.reset} "`;
|
||||
}
|
||||
|
||||
case 'standard':
|
||||
default: {
|
||||
const standardSegments = [
|
||||
timeSegment,
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment,
|
||||
gitSegment,
|
||||
statusSegment,
|
||||
]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PROMPT="${standardSegments ? `${standardSegments} ` : ''}${zshColors.prompt}%#${zshColors.reset} "`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate custom aliases section
|
||||
*/
|
||||
function generateAliases(config: TerminalConfig): string {
|
||||
if (!config.customAliases) return '';
|
||||
|
||||
// Escape and validate aliases
|
||||
const escapedAliases = shellEscape(config.customAliases);
|
||||
return `
|
||||
# Custom aliases
|
||||
${escapedAliases}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate custom environment variables section
|
||||
*/
|
||||
function generateEnvVars(config: TerminalConfig): string {
|
||||
if (!config.customEnvVars || Object.keys(config.customEnvVars).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const validEnvVars = Object.entries(config.customEnvVars)
|
||||
.filter(([name]) => isValidEnvVarName(name))
|
||||
.map(([name, value]) => `export ${name}="${shellEscape(value)}"`)
|
||||
.join('\n');
|
||||
|
||||
return validEnvVars
|
||||
? `
|
||||
# Custom environment variables
|
||||
${validEnvVars}
|
||||
`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate bashrc configuration
|
||||
*/
|
||||
export function generateBashrc(theme: TerminalTheme, config: TerminalConfig): string {
|
||||
const colors = getThemeANSIColors(theme);
|
||||
const promptLine = generatePrompt(config.promptFormat, colors, config);
|
||||
const promptInitializer = generateOhMyPoshInit(OMP_SHELL_BASH, promptLine);
|
||||
|
||||
return `#!/bin/bash
|
||||
# Automaker Terminal Configuration v1.0
|
||||
# This file is automatically generated - manual edits will be overwritten
|
||||
|
||||
# Source user's original bashrc first (preserves user configuration)
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Load Automaker theme colors
|
||||
AUTOMAKER_THEME="\${AUTOMAKER_THEME:-dark}"
|
||||
if [ -f "\${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh" ]; then
|
||||
source "\${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh"
|
||||
fi
|
||||
|
||||
# Load common functions (git prompt)
|
||||
if [ -f "\${BASH_SOURCE%/*}/common.sh" ]; then
|
||||
source "\${BASH_SOURCE%/*}/common.sh"
|
||||
fi
|
||||
|
||||
# Show Automaker banner on shell start
|
||||
if command -v automaker_show_banner_once >/dev/null 2>&1; then
|
||||
automaker_show_banner_once
|
||||
fi
|
||||
|
||||
# Set custom prompt (only if enabled)
|
||||
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
|
||||
${promptInitializer}
|
||||
fi
|
||||
${generateAliases(config)}${generateEnvVars(config)}
|
||||
# Load user customizations (if exists)
|
||||
if [ -f "\${BASH_SOURCE%/*}/user-custom.sh" ]; then
|
||||
source "\${BASH_SOURCE%/*}/user-custom.sh"
|
||||
fi
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate zshrc configuration
|
||||
*/
|
||||
export function generateZshrc(theme: TerminalTheme, config: TerminalConfig): string {
|
||||
const colors = getThemeANSIColors(theme);
|
||||
const promptLine = generateZshPrompt(config.promptFormat, colors, config);
|
||||
const promptInitializer = generateOhMyPoshInit(OMP_SHELL_ZSH, promptLine);
|
||||
|
||||
return `#!/bin/zsh
|
||||
# Automaker Terminal Configuration v1.0
|
||||
# This file is automatically generated - manual edits will be overwritten
|
||||
|
||||
# Source user's original zshrc first (preserves user configuration)
|
||||
if [ -f "$HOME/.zshrc" ]; then
|
||||
source "$HOME/.zshrc"
|
||||
fi
|
||||
|
||||
# Load Automaker theme colors
|
||||
AUTOMAKER_THEME="\${AUTOMAKER_THEME:-dark}"
|
||||
if [ -f "\${ZDOTDIR:-\${0:a:h}}/themes/$AUTOMAKER_THEME.sh" ]; then
|
||||
source "\${ZDOTDIR:-\${0:a:h}}/themes/$AUTOMAKER_THEME.sh"
|
||||
fi
|
||||
|
||||
# Load common functions (git prompt)
|
||||
if [ -f "\${ZDOTDIR:-\${0:a:h}}/common.sh" ]; then
|
||||
source "\${ZDOTDIR:-\${0:a:h}}/common.sh"
|
||||
fi
|
||||
|
||||
# Enable command substitution in PROMPT
|
||||
setopt PROMPT_SUBST
|
||||
|
||||
# Show Automaker banner on shell start
|
||||
if command -v automaker_show_banner_once >/dev/null 2>&1; then
|
||||
automaker_show_banner_once
|
||||
fi
|
||||
|
||||
# Set custom prompt (only if enabled)
|
||||
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
|
||||
${promptInitializer}
|
||||
fi
|
||||
${generateAliases(config)}${generateEnvVars(config)}
|
||||
# Load user customizations (if exists)
|
||||
if [ -f "\${ZDOTDIR:-\${0:a:h}}/user-custom.sh" ]; then
|
||||
source "\${ZDOTDIR:-\${0:a:h}}/user-custom.sh"
|
||||
fi
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate theme color exports for shell
|
||||
*/
|
||||
export function generateThemeColors(theme: TerminalTheme): string {
|
||||
const colors = getThemeANSIColors(theme);
|
||||
const rawColors = {
|
||||
user: stripPromptEscapes(colors.user),
|
||||
host: stripPromptEscapes(colors.host),
|
||||
path: stripPromptEscapes(colors.path),
|
||||
gitBranch: stripPromptEscapes(colors.gitBranch),
|
||||
gitDirty: stripPromptEscapes(colors.gitDirty),
|
||||
prompt: stripPromptEscapes(colors.prompt),
|
||||
reset: stripPromptEscapes(colors.reset),
|
||||
};
|
||||
|
||||
return `#!/bin/sh
|
||||
# Automaker Theme Colors
|
||||
# This file is automatically generated - manual edits will be overwritten
|
||||
|
||||
# ANSI color codes for prompt
|
||||
export COLOR_USER="${colors.user}"
|
||||
export COLOR_HOST="${colors.host}"
|
||||
export COLOR_PATH="${colors.path}"
|
||||
export COLOR_GIT_BRANCH="${colors.gitBranch}"
|
||||
export COLOR_GIT_DIRTY="${colors.gitDirty}"
|
||||
export COLOR_PROMPT="${colors.prompt}"
|
||||
export COLOR_RESET="${colors.reset}"
|
||||
|
||||
# ANSI color codes for banner output (no prompt escapes)
|
||||
export COLOR_USER_RAW="${rawColors.user}"
|
||||
export COLOR_HOST_RAW="${rawColors.host}"
|
||||
export COLOR_PATH_RAW="${rawColors.path}"
|
||||
export COLOR_GIT_BRANCH_RAW="${rawColors.gitBranch}"
|
||||
export COLOR_GIT_DIRTY_RAW="${rawColors.gitDirty}"
|
||||
export COLOR_PROMPT_RAW="${rawColors.prompt}"
|
||||
export COLOR_RESET_RAW="${rawColors.reset}"
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shell name from file extension
|
||||
*/
|
||||
export function getShellName(rcFile: string): 'bash' | 'zsh' | 'sh' | null {
|
||||
if (rcFile.endsWith('.sh') && rcFile.includes('bashrc')) return 'bash';
|
||||
if (rcFile.endsWith('.zsh') || rcFile.endsWith('.zshrc')) return 'zsh';
|
||||
if (rcFile.endsWith('.sh')) return 'sh';
|
||||
return null;
|
||||
}
|
||||
468
libs/platform/src/terminal-theme-colors.ts
Normal file
468
libs/platform/src/terminal-theme-colors.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Terminal Theme Colors - Color definitions for all 40 themes
|
||||
*
|
||||
* This module contains only the raw color data for terminal themes,
|
||||
* extracted from the UI package to avoid circular dependencies.
|
||||
* These colors are used by both UI (xterm.js) and server (RC file generation).
|
||||
*/
|
||||
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import type { TerminalTheme } from './rc-generator.js';
|
||||
|
||||
// Dark theme (default)
|
||||
const darkTheme: TerminalTheme = {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
cursorAccent: '#0a0a0a',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#1e1e1e',
|
||||
red: '#f44747',
|
||||
green: '#6a9955',
|
||||
yellow: '#dcdcaa',
|
||||
blue: '#569cd6',
|
||||
magenta: '#c586c0',
|
||||
cyan: '#4ec9b0',
|
||||
white: '#d4d4d4',
|
||||
brightBlack: '#808080',
|
||||
brightRed: '#f44747',
|
||||
brightGreen: '#6a9955',
|
||||
brightYellow: '#dcdcaa',
|
||||
brightBlue: '#569cd6',
|
||||
brightMagenta: '#c586c0',
|
||||
brightCyan: '#4ec9b0',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Light theme
|
||||
const lightTheme: TerminalTheme = {
|
||||
background: '#ffffff',
|
||||
foreground: '#383a42',
|
||||
cursor: '#383a42',
|
||||
cursorAccent: '#ffffff',
|
||||
selectionBackground: '#add6ff',
|
||||
black: '#383a42',
|
||||
red: '#e45649',
|
||||
green: '#50a14f',
|
||||
yellow: '#c18401',
|
||||
blue: '#4078f2',
|
||||
magenta: '#a626a4',
|
||||
cyan: '#0184bc',
|
||||
white: '#fafafa',
|
||||
brightBlack: '#4f525e',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Retro / Cyberpunk theme - neon green on black
|
||||
const retroTheme: TerminalTheme = {
|
||||
background: '#000000',
|
||||
foreground: '#39ff14',
|
||||
cursor: '#39ff14',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#39ff14',
|
||||
selectionForeground: '#000000',
|
||||
black: '#000000',
|
||||
red: '#ff0055',
|
||||
green: '#39ff14',
|
||||
yellow: '#ffff00',
|
||||
blue: '#00ffff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#39ff14',
|
||||
brightBlack: '#555555',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#55ffff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Dracula theme
|
||||
const draculaTheme: TerminalTheme = {
|
||||
background: '#282a36',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#282a36',
|
||||
selectionBackground: '#44475a',
|
||||
black: '#21222c',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#bd93f9',
|
||||
magenta: '#ff79c6',
|
||||
cyan: '#8be9fd',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#6272a4',
|
||||
brightRed: '#ff6e6e',
|
||||
brightGreen: '#69ff94',
|
||||
brightYellow: '#ffffa5',
|
||||
brightBlue: '#d6acff',
|
||||
brightMagenta: '#ff92df',
|
||||
brightCyan: '#a4ffff',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Nord theme
|
||||
const nordTheme: TerminalTheme = {
|
||||
background: '#2e3440',
|
||||
foreground: '#d8dee9',
|
||||
cursor: '#d8dee9',
|
||||
cursorAccent: '#2e3440',
|
||||
selectionBackground: '#434c5e',
|
||||
black: '#3b4252',
|
||||
red: '#bf616a',
|
||||
green: '#a3be8c',
|
||||
yellow: '#ebcb8b',
|
||||
blue: '#81a1c1',
|
||||
magenta: '#b48ead',
|
||||
cyan: '#88c0d0',
|
||||
white: '#e5e9f0',
|
||||
brightBlack: '#4c566a',
|
||||
brightRed: '#bf616a',
|
||||
brightGreen: '#a3be8c',
|
||||
brightYellow: '#ebcb8b',
|
||||
brightBlue: '#81a1c1',
|
||||
brightMagenta: '#b48ead',
|
||||
brightCyan: '#8fbcbb',
|
||||
brightWhite: '#eceff4',
|
||||
};
|
||||
|
||||
// Monokai theme
|
||||
const monokaiTheme: TerminalTheme = {
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#272822',
|
||||
selectionBackground: '#49483e',
|
||||
black: '#272822',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#f4bf75',
|
||||
blue: '#66d9ef',
|
||||
magenta: '#ae81ff',
|
||||
cyan: '#a1efe4',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#75715e',
|
||||
brightRed: '#f92672',
|
||||
brightGreen: '#a6e22e',
|
||||
brightYellow: '#f4bf75',
|
||||
brightBlue: '#66d9ef',
|
||||
brightMagenta: '#ae81ff',
|
||||
brightCyan: '#a1efe4',
|
||||
brightWhite: '#f9f8f5',
|
||||
};
|
||||
|
||||
// Tokyo Night theme
|
||||
const tokyonightTheme: TerminalTheme = {
|
||||
background: '#1a1b26',
|
||||
foreground: '#a9b1d6',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: '#33467c',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
};
|
||||
|
||||
// Solarized Dark theme
|
||||
const solarizedTheme: TerminalTheme = {
|
||||
background: '#002b36',
|
||||
foreground: '#93a1a1',
|
||||
cursor: '#93a1a1',
|
||||
cursorAccent: '#002b36',
|
||||
selectionBackground: '#073642',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#002b36',
|
||||
brightRed: '#cb4b16',
|
||||
brightGreen: '#586e75',
|
||||
brightYellow: '#657b83',
|
||||
brightBlue: '#839496',
|
||||
brightMagenta: '#6c71c4',
|
||||
brightCyan: '#93a1a1',
|
||||
brightWhite: '#fdf6e3',
|
||||
};
|
||||
|
||||
// Gruvbox Dark theme
|
||||
const gruvboxTheme: TerminalTheme = {
|
||||
background: '#282828',
|
||||
foreground: '#ebdbb2',
|
||||
cursor: '#ebdbb2',
|
||||
cursorAccent: '#282828',
|
||||
selectionBackground: '#504945',
|
||||
black: '#282828',
|
||||
red: '#cc241d',
|
||||
green: '#98971a',
|
||||
yellow: '#d79921',
|
||||
blue: '#458588',
|
||||
magenta: '#b16286',
|
||||
cyan: '#689d6a',
|
||||
white: '#a89984',
|
||||
brightBlack: '#928374',
|
||||
brightRed: '#fb4934',
|
||||
brightGreen: '#b8bb26',
|
||||
brightYellow: '#fabd2f',
|
||||
brightBlue: '#83a598',
|
||||
brightMagenta: '#d3869b',
|
||||
brightCyan: '#8ec07c',
|
||||
brightWhite: '#ebdbb2',
|
||||
};
|
||||
|
||||
// Catppuccin Mocha theme
|
||||
const catppuccinTheme: TerminalTheme = {
|
||||
background: '#1e1e2e',
|
||||
foreground: '#cdd6f4',
|
||||
cursor: '#f5e0dc',
|
||||
cursorAccent: '#1e1e2e',
|
||||
selectionBackground: '#45475a',
|
||||
black: '#45475a',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#cba6f7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#bac2de',
|
||||
brightBlack: '#585b70',
|
||||
brightRed: '#f38ba8',
|
||||
brightGreen: '#a6e3a1',
|
||||
brightYellow: '#f9e2af',
|
||||
brightBlue: '#89b4fa',
|
||||
brightMagenta: '#cba6f7',
|
||||
brightCyan: '#94e2d5',
|
||||
brightWhite: '#a6adc8',
|
||||
};
|
||||
|
||||
// One Dark theme
|
||||
const onedarkTheme: TerminalTheme = {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
cursorAccent: '#282c34',
|
||||
selectionBackground: '#3e4451',
|
||||
black: '#282c34',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
white: '#abb2bf',
|
||||
brightBlack: '#5c6370',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Synthwave '84 theme
|
||||
const synthwaveTheme: TerminalTheme = {
|
||||
background: '#262335',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ff7edb',
|
||||
cursorAccent: '#262335',
|
||||
selectionBackground: '#463465',
|
||||
black: '#262335',
|
||||
red: '#fe4450',
|
||||
green: '#72f1b8',
|
||||
yellow: '#fede5d',
|
||||
blue: '#03edf9',
|
||||
magenta: '#ff7edb',
|
||||
cyan: '#03edf9',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#614d85',
|
||||
brightRed: '#fe4450',
|
||||
brightGreen: '#72f1b8',
|
||||
brightYellow: '#f97e72',
|
||||
brightBlue: '#03edf9',
|
||||
brightMagenta: '#ff7edb',
|
||||
brightCyan: '#03edf9',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Red theme
|
||||
const redTheme: TerminalTheme = {
|
||||
background: '#1a0a0a',
|
||||
foreground: '#c8b0b0',
|
||||
cursor: '#ff4444',
|
||||
cursorAccent: '#1a0a0a',
|
||||
selectionBackground: '#5a2020',
|
||||
black: '#2a1010',
|
||||
red: '#ff4444',
|
||||
green: '#6a9a6a',
|
||||
yellow: '#ccaa55',
|
||||
blue: '#6688aa',
|
||||
magenta: '#aa5588',
|
||||
cyan: '#558888',
|
||||
white: '#b0a0a0',
|
||||
brightBlack: '#6a4040',
|
||||
brightRed: '#ff6666',
|
||||
brightGreen: '#88bb88',
|
||||
brightYellow: '#ddbb66',
|
||||
brightBlue: '#88aacc',
|
||||
brightMagenta: '#cc77aa',
|
||||
brightCyan: '#77aaaa',
|
||||
brightWhite: '#d0c0c0',
|
||||
};
|
||||
|
||||
// Cream theme
|
||||
const creamTheme: TerminalTheme = {
|
||||
background: '#f5f3ee',
|
||||
foreground: '#5a4a3a',
|
||||
cursor: '#9d6b53',
|
||||
cursorAccent: '#f5f3ee',
|
||||
selectionBackground: '#d4c4b0',
|
||||
black: '#5a4a3a',
|
||||
red: '#c85a4f',
|
||||
green: '#7a9a6a',
|
||||
yellow: '#c9a554',
|
||||
blue: '#6b8aaa',
|
||||
magenta: '#a66a8a',
|
||||
cyan: '#5a9a8a',
|
||||
white: '#b0a090',
|
||||
brightBlack: '#8a7a6a',
|
||||
brightRed: '#e07060',
|
||||
brightGreen: '#90b080',
|
||||
brightYellow: '#e0bb70',
|
||||
brightBlue: '#80a0c0',
|
||||
brightMagenta: '#c080a0',
|
||||
brightCyan: '#70b0a0',
|
||||
brightWhite: '#d0c0b0',
|
||||
};
|
||||
|
||||
// Sunset theme
|
||||
const sunsetTheme: TerminalTheme = {
|
||||
background: '#1e1a24',
|
||||
foreground: '#f2e8dd',
|
||||
cursor: '#dd8855',
|
||||
cursorAccent: '#1e1a24',
|
||||
selectionBackground: '#3a2a40',
|
||||
black: '#1e1a24',
|
||||
red: '#dd6655',
|
||||
green: '#88bb77',
|
||||
yellow: '#ddaa66',
|
||||
blue: '#6699cc',
|
||||
magenta: '#cc7799',
|
||||
cyan: '#66ccaa',
|
||||
white: '#e8d8c8',
|
||||
brightBlack: '#4a3a50',
|
||||
brightRed: '#ee8866',
|
||||
brightGreen: '#99cc88',
|
||||
brightYellow: '#eebb77',
|
||||
brightBlue: '#88aadd',
|
||||
brightMagenta: '#dd88aa',
|
||||
brightCyan: '#88ddbb',
|
||||
brightWhite: '#f5e8dd',
|
||||
};
|
||||
|
||||
// Gray theme
|
||||
const grayTheme: TerminalTheme = {
|
||||
background: '#2a2d32',
|
||||
foreground: '#d0d0d5',
|
||||
cursor: '#8fa0c0',
|
||||
cursorAccent: '#2a2d32',
|
||||
selectionBackground: '#3a3f48',
|
||||
black: '#2a2d32',
|
||||
red: '#d87070',
|
||||
green: '#78b088',
|
||||
yellow: '#d0b060',
|
||||
blue: '#7090c0',
|
||||
magenta: '#a880b0',
|
||||
cyan: '#60a0b0',
|
||||
white: '#b0b0b8',
|
||||
brightBlack: '#606068',
|
||||
brightRed: '#e88888',
|
||||
brightGreen: '#90c8a0',
|
||||
brightYellow: '#e0c878',
|
||||
brightBlue: '#90b0d8',
|
||||
brightMagenta: '#c098c8',
|
||||
brightCyan: '#80b8c8',
|
||||
brightWhite: '#e0e0e8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme color mapping for all 40 themes
|
||||
*/
|
||||
export const terminalThemeColors: Record<ThemeMode, TerminalTheme> = {
|
||||
// Special
|
||||
system: darkTheme, // Resolved at runtime based on OS preference
|
||||
// Dark themes (16)
|
||||
dark: darkTheme,
|
||||
retro: retroTheme,
|
||||
dracula: draculaTheme,
|
||||
nord: nordTheme,
|
||||
monokai: monokaiTheme,
|
||||
tokyonight: tokyonightTheme,
|
||||
solarized: solarizedTheme,
|
||||
gruvbox: gruvboxTheme,
|
||||
catppuccin: catppuccinTheme,
|
||||
onedark: onedarkTheme,
|
||||
synthwave: synthwaveTheme,
|
||||
red: redTheme,
|
||||
sunset: sunsetTheme,
|
||||
gray: grayTheme,
|
||||
forest: gruvboxTheme, // Green-ish theme
|
||||
ocean: nordTheme, // Blue-ish theme
|
||||
ember: monokaiTheme, // Warm orange theme
|
||||
'ayu-dark': darkTheme,
|
||||
'ayu-mirage': darkTheme,
|
||||
matcha: nordTheme,
|
||||
// Light themes (16)
|
||||
light: lightTheme,
|
||||
cream: creamTheme,
|
||||
solarizedlight: lightTheme,
|
||||
github: lightTheme,
|
||||
paper: lightTheme,
|
||||
rose: lightTheme,
|
||||
mint: lightTheme,
|
||||
lavender: lightTheme,
|
||||
sand: creamTheme,
|
||||
sky: lightTheme,
|
||||
peach: creamTheme,
|
||||
snow: lightTheme,
|
||||
sepia: creamTheme,
|
||||
gruvboxlight: creamTheme,
|
||||
nordlight: lightTheme,
|
||||
blossom: lightTheme,
|
||||
'ayu-light': lightTheme,
|
||||
onelight: lightTheme,
|
||||
bluloco: lightTheme,
|
||||
feather: lightTheme,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get terminal theme colors for a given theme mode
|
||||
*/
|
||||
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
|
||||
return terminalThemeColors[theme] || darkTheme;
|
||||
}
|
||||
@@ -140,9 +140,9 @@ const SUPPORTED_TERMINALS: TerminalDefinition[] = [
|
||||
{
|
||||
id: 'warp',
|
||||
name: 'Warp',
|
||||
cliCommand: 'warp',
|
||||
cliCommand: 'warp-cli',
|
||||
cliAliases: ['warp-terminal', 'warp'],
|
||||
macAppName: 'Warp',
|
||||
platform: 'darwin',
|
||||
},
|
||||
{
|
||||
id: 'ghostty',
|
||||
@@ -476,6 +476,11 @@ async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string
|
||||
await spawnDetached(command, [`--working-directory=${targetPath}`]);
|
||||
break;
|
||||
|
||||
case 'warp':
|
||||
// Warp: uses --cwd flag (CLI mode, not app bundle)
|
||||
await spawnDetached(command, ['--cwd', targetPath]);
|
||||
break;
|
||||
|
||||
case 'alacritty':
|
||||
// Alacritty: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
|
||||
100
libs/platform/tests/rc-file-manager.test.ts
Normal file
100
libs/platform/tests/rc-file-manager.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { needsRegeneration, writeRcFiles } from '../src/rc-file-manager';
|
||||
import { terminalThemeColors } from '../src/terminal-theme-colors';
|
||||
import type { TerminalConfig } from '../src/rc-generator';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
|
||||
describe('rc-file-manager.ts', () => {
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
|
||||
const TEMP_DIR_PREFIX = 'platform-rc-files-test-';
|
||||
const PROJECT_DIR_NAME = 'test-project';
|
||||
const THEME_DARK = 'dark' as ThemeMode;
|
||||
const THEME_LIGHT = 'light' as ThemeMode;
|
||||
const PROMPT_FORMAT_STANDARD: TerminalConfig['promptFormat'] = 'standard';
|
||||
const PROMPT_FORMAT_MINIMAL: TerminalConfig['promptFormat'] = 'minimal';
|
||||
const EMPTY_ALIASES = '';
|
||||
const PATH_STYLE_FULL: TerminalConfig['pathStyle'] = 'full';
|
||||
const PATH_DEPTH_DEFAULT = 0;
|
||||
|
||||
const baseConfig: TerminalConfig = {
|
||||
enabled: true,
|
||||
customPrompt: true,
|
||||
promptFormat: PROMPT_FORMAT_STANDARD,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: PATH_STYLE_FULL,
|
||||
pathDepth: PATH_DEPTH_DEFAULT,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: EMPTY_ALIASES,
|
||||
customEnvVars: {},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), TEMP_DIR_PREFIX));
|
||||
projectPath = path.join(tempDir, PROJECT_DIR_NAME);
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should not regenerate when signature matches', async () => {
|
||||
await writeRcFiles(
|
||||
projectPath,
|
||||
THEME_DARK,
|
||||
baseConfig,
|
||||
terminalThemeColors[THEME_DARK],
|
||||
terminalThemeColors
|
||||
);
|
||||
|
||||
const needsRegen = await needsRegeneration(projectPath, THEME_DARK, baseConfig);
|
||||
|
||||
expect(needsRegen).toBe(false);
|
||||
});
|
||||
|
||||
it('should regenerate when config changes', async () => {
|
||||
await writeRcFiles(
|
||||
projectPath,
|
||||
THEME_DARK,
|
||||
baseConfig,
|
||||
terminalThemeColors[THEME_DARK],
|
||||
terminalThemeColors
|
||||
);
|
||||
|
||||
const updatedConfig: TerminalConfig = {
|
||||
...baseConfig,
|
||||
promptFormat: PROMPT_FORMAT_MINIMAL,
|
||||
};
|
||||
|
||||
const needsRegen = await needsRegeneration(projectPath, THEME_DARK, updatedConfig);
|
||||
|
||||
expect(needsRegen).toBe(true);
|
||||
});
|
||||
|
||||
it('should regenerate when theme changes', async () => {
|
||||
await writeRcFiles(
|
||||
projectPath,
|
||||
THEME_DARK,
|
||||
baseConfig,
|
||||
terminalThemeColors[THEME_DARK],
|
||||
terminalThemeColors
|
||||
);
|
||||
|
||||
const needsRegen = await needsRegeneration(projectPath, THEME_LIGHT, baseConfig);
|
||||
|
||||
expect(needsRegen).toBe(true);
|
||||
});
|
||||
});
|
||||
55
libs/platform/tests/rc-generator.test.ts
Normal file
55
libs/platform/tests/rc-generator.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateCommonFunctions, generateThemeColors } from '../src/rc-generator';
|
||||
import { terminalThemeColors } from '../src/terminal-theme-colors';
|
||||
import type { TerminalConfig } from '../src/rc-generator';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
|
||||
describe('rc-generator.ts', () => {
|
||||
const THEME_DARK = 'dark' as ThemeMode;
|
||||
const PROMPT_FORMAT_STANDARD: TerminalConfig['promptFormat'] = 'standard';
|
||||
const EMPTY_ALIASES = '';
|
||||
const EMPTY_ENV_VARS = {};
|
||||
const PATH_STYLE_FULL: TerminalConfig['pathStyle'] = 'full';
|
||||
const PATH_DEPTH_DEFAULT = 0;
|
||||
const EXPECTED_BANNER_FUNCTION = 'automaker_show_banner_once';
|
||||
const RAW_COLOR_PREFIX = 'export COLOR_USER_RAW=';
|
||||
const RAW_COLOR_ESCAPE_START = '\\\\[';
|
||||
const RAW_COLOR_ESCAPE_END = '\\\\]';
|
||||
const STARTUP_PRIMARY_COLOR = '38;5;51m';
|
||||
const STARTUP_SECONDARY_COLOR = '38;5;39m';
|
||||
const STARTUP_ACCENT_COLOR = '38;5;33m';
|
||||
|
||||
const baseConfig: TerminalConfig = {
|
||||
enabled: true,
|
||||
customPrompt: true,
|
||||
promptFormat: PROMPT_FORMAT_STANDARD,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: PATH_STYLE_FULL,
|
||||
pathDepth: PATH_DEPTH_DEFAULT,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: EMPTY_ALIASES,
|
||||
customEnvVars: EMPTY_ENV_VARS,
|
||||
};
|
||||
|
||||
it('includes banner functions in common shell script', () => {
|
||||
const output = generateCommonFunctions(baseConfig);
|
||||
|
||||
expect(output).toContain(EXPECTED_BANNER_FUNCTION);
|
||||
expect(output).toContain(STARTUP_PRIMARY_COLOR);
|
||||
expect(output).toContain(STARTUP_SECONDARY_COLOR);
|
||||
expect(output).toContain(STARTUP_ACCENT_COLOR);
|
||||
});
|
||||
|
||||
it('exports raw banner colors without prompt escape wrappers', () => {
|
||||
const output = generateThemeColors(terminalThemeColors[THEME_DARK]);
|
||||
const rawLine = output.split('\n').find((line) => line.startsWith(RAW_COLOR_PREFIX));
|
||||
|
||||
expect(rawLine).toBeDefined();
|
||||
expect(rawLine).not.toContain(RAW_COLOR_ESCAPE_START);
|
||||
expect(rawLine).not.toContain(RAW_COLOR_ESCAPE_END);
|
||||
});
|
||||
});
|
||||
@@ -27,14 +27,16 @@ export type { ModelAlias };
|
||||
*
|
||||
* Includes system theme and multiple color schemes organized by dark/light:
|
||||
* - System: Respects OS dark/light mode preference
|
||||
* - Dark themes (16): dark, retro, dracula, nord, monokai, tokyonight, solarized,
|
||||
* gruvbox, catppuccin, onedark, synthwave, red, sunset, gray, forest, ocean
|
||||
* - Light themes (16): light, cream, solarizedlight, github, paper, rose, mint,
|
||||
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom
|
||||
* - Dark themes (20): dark, retro, dracula, nord, monokai, tokyonight, solarized,
|
||||
* gruvbox, catppuccin, onedark, synthwave, red, sunset, gray, forest, ocean,
|
||||
* ember, ayu-dark, ayu-mirage, matcha
|
||||
* - Light themes (20): light, cream, solarizedlight, github, paper, rose, mint,
|
||||
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom,
|
||||
* ayu-light, onelight, bluloco, feather
|
||||
*/
|
||||
export type ThemeMode =
|
||||
| 'system'
|
||||
// Dark themes (16)
|
||||
// Dark themes (20)
|
||||
| 'dark'
|
||||
| 'retro'
|
||||
| 'dracula'
|
||||
@@ -51,7 +53,11 @@ export type ThemeMode =
|
||||
| 'gray'
|
||||
| 'forest'
|
||||
| 'ocean'
|
||||
// Light themes (16)
|
||||
| 'ember'
|
||||
| 'ayu-dark'
|
||||
| 'ayu-mirage'
|
||||
| 'matcha'
|
||||
// Light themes (20)
|
||||
| 'light'
|
||||
| 'cream'
|
||||
| 'solarizedlight'
|
||||
@@ -67,7 +73,138 @@ export type ThemeMode =
|
||||
| 'sepia'
|
||||
| 'gruvboxlight'
|
||||
| 'nordlight'
|
||||
| 'blossom';
|
||||
| 'blossom'
|
||||
| 'ayu-light'
|
||||
| 'onelight'
|
||||
| 'bluloco'
|
||||
| 'feather';
|
||||
|
||||
export type TerminalPromptTheme =
|
||||
| 'custom'
|
||||
| 'omp-1_shell'
|
||||
| 'omp-agnoster'
|
||||
| 'omp-agnoster.minimal'
|
||||
| 'omp-agnosterplus'
|
||||
| 'omp-aliens'
|
||||
| 'omp-amro'
|
||||
| 'omp-atomic'
|
||||
| 'omp-atomicBit'
|
||||
| 'omp-avit'
|
||||
| 'omp-blue-owl'
|
||||
| 'omp-blueish'
|
||||
| 'omp-bubbles'
|
||||
| 'omp-bubblesextra'
|
||||
| 'omp-bubblesline'
|
||||
| 'omp-capr4n'
|
||||
| 'omp-catppuccin'
|
||||
| 'omp-catppuccin_frappe'
|
||||
| 'omp-catppuccin_latte'
|
||||
| 'omp-catppuccin_macchiato'
|
||||
| 'omp-catppuccin_mocha'
|
||||
| 'omp-cert'
|
||||
| 'omp-chips'
|
||||
| 'omp-cinnamon'
|
||||
| 'omp-clean-detailed'
|
||||
| 'omp-cloud-context'
|
||||
| 'omp-cloud-native-azure'
|
||||
| 'omp-cobalt2'
|
||||
| 'omp-craver'
|
||||
| 'omp-darkblood'
|
||||
| 'omp-devious-diamonds'
|
||||
| 'omp-di4am0nd'
|
||||
| 'omp-dracula'
|
||||
| 'omp-easy-term'
|
||||
| 'omp-emodipt'
|
||||
| 'omp-emodipt-extend'
|
||||
| 'omp-fish'
|
||||
| 'omp-free-ukraine'
|
||||
| 'omp-froczh'
|
||||
| 'omp-gmay'
|
||||
| 'omp-glowsticks'
|
||||
| 'omp-grandpa-style'
|
||||
| 'omp-gruvbox'
|
||||
| 'omp-half-life'
|
||||
| 'omp-honukai'
|
||||
| 'omp-hotstick.minimal'
|
||||
| 'omp-hul10'
|
||||
| 'omp-hunk'
|
||||
| 'omp-huvix'
|
||||
| 'omp-if_tea'
|
||||
| 'omp-illusi0n'
|
||||
| 'omp-iterm2'
|
||||
| 'omp-jandedobbeleer'
|
||||
| 'omp-jblab_2021'
|
||||
| 'omp-jonnychipz'
|
||||
| 'omp-json'
|
||||
| 'omp-jtracey93'
|
||||
| 'omp-jv_sitecorian'
|
||||
| 'omp-kali'
|
||||
| 'omp-kushal'
|
||||
| 'omp-lambda'
|
||||
| 'omp-lambdageneration'
|
||||
| 'omp-larserikfinholt'
|
||||
| 'omp-lightgreen'
|
||||
| 'omp-M365Princess'
|
||||
| 'omp-marcduiker'
|
||||
| 'omp-markbull'
|
||||
| 'omp-material'
|
||||
| 'omp-microverse-power'
|
||||
| 'omp-mojada'
|
||||
| 'omp-montys'
|
||||
| 'omp-mt'
|
||||
| 'omp-multiverse-neon'
|
||||
| 'omp-negligible'
|
||||
| 'omp-neko'
|
||||
| 'omp-night-owl'
|
||||
| 'omp-nordtron'
|
||||
| 'omp-nu4a'
|
||||
| 'omp-onehalf.minimal'
|
||||
| 'omp-paradox'
|
||||
| 'omp-pararussel'
|
||||
| 'omp-patriksvensson'
|
||||
| 'omp-peru'
|
||||
| 'omp-pixelrobots'
|
||||
| 'omp-plague'
|
||||
| 'omp-poshmon'
|
||||
| 'omp-powerlevel10k_classic'
|
||||
| 'omp-powerlevel10k_lean'
|
||||
| 'omp-powerlevel10k_modern'
|
||||
| 'omp-powerlevel10k_rainbow'
|
||||
| 'omp-powerline'
|
||||
| 'omp-probua.minimal'
|
||||
| 'omp-pure'
|
||||
| 'omp-quick-term'
|
||||
| 'omp-remk'
|
||||
| 'omp-robbyrussell'
|
||||
| 'omp-rudolfs-dark'
|
||||
| 'omp-rudolfs-light'
|
||||
| 'omp-sim-web'
|
||||
| 'omp-slim'
|
||||
| 'omp-slimfat'
|
||||
| 'omp-smoothie'
|
||||
| 'omp-sonicboom_dark'
|
||||
| 'omp-sonicboom_light'
|
||||
| 'omp-sorin'
|
||||
| 'omp-space'
|
||||
| 'omp-spaceship'
|
||||
| 'omp-star'
|
||||
| 'omp-stelbent-compact.minimal'
|
||||
| 'omp-stelbent.minimal'
|
||||
| 'omp-takuya'
|
||||
| 'omp-the-unnamed'
|
||||
| 'omp-thecyberden'
|
||||
| 'omp-tiwahu'
|
||||
| 'omp-tokyo'
|
||||
| 'omp-tokyonight_storm'
|
||||
| 'omp-tonybaloney'
|
||||
| 'omp-uew'
|
||||
| 'omp-unicorn'
|
||||
| 'omp-velvet'
|
||||
| 'omp-wholespace'
|
||||
| 'omp-wopian'
|
||||
| 'omp-xtoys'
|
||||
| 'omp-ys'
|
||||
| 'omp-zash';
|
||||
|
||||
/** PlanningMode - Planning levels for feature generation workflows */
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
@@ -840,6 +977,39 @@ export interface GlobalSettings {
|
||||
// Terminal Configuration
|
||||
/** How to open terminals from "Open in Terminal" worktree action */
|
||||
openTerminalMode?: 'newTab' | 'split';
|
||||
/** Custom terminal configuration settings (prompt theming, aliases, env vars) */
|
||||
terminalConfig?: {
|
||||
/** Enable custom terminal configurations (default: false) */
|
||||
enabled: boolean;
|
||||
/** Enable custom prompt (default: true when enabled) */
|
||||
customPrompt: boolean;
|
||||
/** Prompt format template */
|
||||
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
/** Prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
/** Show git branch in prompt (default: true) */
|
||||
showGitBranch: boolean;
|
||||
/** Show git status dirty indicator (default: true) */
|
||||
showGitStatus: boolean;
|
||||
/** Show user and host in prompt (default: true) */
|
||||
showUserHost: boolean;
|
||||
/** Show path in prompt (default: true) */
|
||||
showPath: boolean;
|
||||
/** Path display style */
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
/** Limit path depth (0 = full path) */
|
||||
pathDepth: number;
|
||||
/** Show current time in prompt (default: false) */
|
||||
showTime: boolean;
|
||||
/** Show last command exit status when non-zero (default: false) */
|
||||
showExitStatus: boolean;
|
||||
/** User-provided custom aliases (multiline string) */
|
||||
customAliases: string;
|
||||
/** User-provided custom env vars */
|
||||
customEnvVars: Record<string, string>;
|
||||
/** RC file format version (for migration) */
|
||||
rcFileVersion?: number;
|
||||
};
|
||||
|
||||
// UI State Preferences
|
||||
/** Whether sidebar is currently open */
|
||||
@@ -1245,6 +1415,33 @@ export interface ProjectSettings {
|
||||
*/
|
||||
defaultFeatureModel?: PhaseModelEntry;
|
||||
|
||||
// Terminal Configuration Override (per-project)
|
||||
/** Project-specific terminal config overrides */
|
||||
terminalConfig?: {
|
||||
/** Override global enabled setting */
|
||||
enabled?: boolean;
|
||||
/** Override prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
/** Override showing user/host */
|
||||
showUserHost?: boolean;
|
||||
/** Override showing path */
|
||||
showPath?: boolean;
|
||||
/** Override path style */
|
||||
pathStyle?: 'full' | 'short' | 'basename';
|
||||
/** Override path depth (0 = full path) */
|
||||
pathDepth?: number;
|
||||
/** Override showing time */
|
||||
showTime?: boolean;
|
||||
/** Override showing exit status */
|
||||
showExitStatus?: boolean;
|
||||
/** Project-specific custom aliases */
|
||||
customAliases?: string;
|
||||
/** Project-specific env vars */
|
||||
customEnvVars?: Record<string, string>;
|
||||
/** Custom welcome message for this project */
|
||||
welcomeMessage?: string;
|
||||
};
|
||||
|
||||
// Deprecated Claude API Profile Override
|
||||
/**
|
||||
* @deprecated Use phaseModelOverrides instead.
|
||||
|
||||
@@ -68,7 +68,7 @@ export {
|
||||
} from './atomic-writer.js';
|
||||
|
||||
// Path utilities
|
||||
export { normalizePath, pathsEqual } from './path-utils.js';
|
||||
export { normalizePath, pathsEqual, sanitizeFilename } from './path-utils.js';
|
||||
|
||||
// Context file loading
|
||||
export {
|
||||
|
||||
@@ -49,3 +49,54 @@ export function pathsEqual(p1: string | undefined | null, p2: string | undefined
|
||||
if (!p1 || !p2) return p1 === p2;
|
||||
return normalizePath(p1) === normalizePath(p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filename to be safe for cross-platform file system usage
|
||||
*
|
||||
* Removes or replaces characters that are invalid on various file systems
|
||||
* and prevents Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
|
||||
*
|
||||
* @param filename - The filename to sanitize (without path, just the name)
|
||||
* @param fallback - Fallback name if sanitization results in empty string (default: 'file')
|
||||
* @returns A sanitized filename safe for all platforms
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* sanitizeFilename("my file.txt"); // "my_file.txt"
|
||||
* sanitizeFilename("nul.txt"); // "_nul.txt" (Windows reserved)
|
||||
* sanitizeFilename("con"); // "_con" (Windows reserved)
|
||||
* sanitizeFilename("file?.txt"); // "file.txt"
|
||||
* sanitizeFilename(""); // "file"
|
||||
* sanitizeFilename("", "unnamed"); // "unnamed"
|
||||
* ```
|
||||
*/
|
||||
export function sanitizeFilename(filename: string, fallback: string = 'file'): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Remove or replace invalid characters:
|
||||
// - Path separators: / \
|
||||
// - Windows invalid chars: : * ? " < > |
|
||||
// - Control characters and other problematic chars
|
||||
let safeName = filename
|
||||
.replace(/[/\\:*?"<>|]/g, '') // Remove invalid chars
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.replace(/\.+$/g, '') // Remove trailing dots (Windows issue)
|
||||
.replace(/^\.+/g, '') // Remove leading dots
|
||||
.trim();
|
||||
|
||||
// If empty after sanitization, use fallback
|
||||
if (!safeName || safeName.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Handle Windows reserved device names (case-insensitive)
|
||||
// Reserved names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
|
||||
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
||||
if (windowsReserved.test(safeName)) {
|
||||
safeName = `_${safeName}`;
|
||||
}
|
||||
|
||||
return safeName;
|
||||
}
|
||||
|
||||
152
libs/utils/tests/path-utils.test.ts
Normal file
152
libs/utils/tests/path-utils.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Path Utilities Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizePath, pathsEqual, sanitizeFilename } from '../src/path-utils.js';
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('should convert backslashes to forward slashes', () => {
|
||||
expect(normalizePath('C:\\Users\\foo\\bar')).toBe('C:/Users/foo/bar');
|
||||
});
|
||||
|
||||
it('should leave forward slashes unchanged', () => {
|
||||
expect(normalizePath('/home/foo/bar')).toBe('/home/foo/bar');
|
||||
});
|
||||
|
||||
it('should handle mixed separators', () => {
|
||||
expect(normalizePath('C:\\Users/foo\\bar')).toBe('C:/Users/foo/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathsEqual', () => {
|
||||
it('should return true for equal paths', () => {
|
||||
expect(pathsEqual('/home/user', '/home/user')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for paths with different separators', () => {
|
||||
expect(pathsEqual('C:\\foo\\bar', 'C:/foo/bar')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different paths', () => {
|
||||
expect(pathsEqual('/home/user', '/home/other')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null and undefined', () => {
|
||||
expect(pathsEqual(null, null)).toBe(true);
|
||||
expect(pathsEqual(undefined, undefined)).toBe(true);
|
||||
expect(pathsEqual(null, undefined)).toBe(false);
|
||||
expect(pathsEqual(null, '/path')).toBe(false);
|
||||
expect(pathsEqual('/path', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
describe('Windows reserved names', () => {
|
||||
it('should prefix Windows reserved device names', () => {
|
||||
expect(sanitizeFilename('nul')).toBe('_nul');
|
||||
expect(sanitizeFilename('NUL')).toBe('_NUL');
|
||||
expect(sanitizeFilename('con')).toBe('_con');
|
||||
expect(sanitizeFilename('CON')).toBe('_CON');
|
||||
expect(sanitizeFilename('prn')).toBe('_prn');
|
||||
expect(sanitizeFilename('aux')).toBe('_aux');
|
||||
});
|
||||
|
||||
it('should prefix COM and LPT port names', () => {
|
||||
expect(sanitizeFilename('com1')).toBe('_com1');
|
||||
expect(sanitizeFilename('COM5')).toBe('_COM5');
|
||||
expect(sanitizeFilename('lpt1')).toBe('_lpt1');
|
||||
expect(sanitizeFilename('LPT9')).toBe('_LPT9');
|
||||
});
|
||||
|
||||
it('should not prefix reserved names with extensions', () => {
|
||||
// After removing extension, baseName might be reserved
|
||||
expect(sanitizeFilename('nul')).toBe('_nul');
|
||||
});
|
||||
|
||||
it('should not prefix non-reserved names that contain reserved words', () => {
|
||||
expect(sanitizeFilename('null')).toBe('null'); // "null" is not reserved, only "nul"
|
||||
expect(sanitizeFilename('console')).toBe('console');
|
||||
expect(sanitizeFilename('auxiliary')).toBe('auxiliary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid characters', () => {
|
||||
it('should remove path separators', () => {
|
||||
expect(sanitizeFilename('foo/bar')).toBe('foobar');
|
||||
expect(sanitizeFilename('foo\\bar')).toBe('foobar');
|
||||
});
|
||||
|
||||
it('should remove Windows invalid characters', () => {
|
||||
expect(sanitizeFilename('file:name')).toBe('filename');
|
||||
expect(sanitizeFilename('file*name')).toBe('filename');
|
||||
expect(sanitizeFilename('file?name')).toBe('filename');
|
||||
expect(sanitizeFilename('file"name')).toBe('filename');
|
||||
expect(sanitizeFilename('file<name>')).toBe('filename');
|
||||
expect(sanitizeFilename('file|name')).toBe('filename');
|
||||
});
|
||||
|
||||
it('should replace spaces with underscores', () => {
|
||||
expect(sanitizeFilename('my file name')).toBe('my_file_name');
|
||||
expect(sanitizeFilename('file name')).toBe('file_name'); // multiple spaces
|
||||
});
|
||||
|
||||
it('should remove leading and trailing dots', () => {
|
||||
expect(sanitizeFilename('.hidden')).toBe('hidden');
|
||||
expect(sanitizeFilename('file...')).toBe('file');
|
||||
expect(sanitizeFilename('...file...')).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should return fallback for empty strings', () => {
|
||||
expect(sanitizeFilename('')).toBe('file');
|
||||
expect(sanitizeFilename('', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('should return fallback for null/undefined', () => {
|
||||
expect(sanitizeFilename(null as any)).toBe('file');
|
||||
expect(sanitizeFilename(undefined as any)).toBe('file');
|
||||
expect(sanitizeFilename(null as any, 'image')).toBe('image');
|
||||
});
|
||||
|
||||
it('should return fallback for strings that become empty after sanitization', () => {
|
||||
expect(sanitizeFilename('...')).toBe('file');
|
||||
expect(sanitizeFilename('///\\\\\\')).toBe('file');
|
||||
expect(sanitizeFilename('???')).toBe('file');
|
||||
});
|
||||
|
||||
it('should handle non-string inputs', () => {
|
||||
expect(sanitizeFilename(123 as any)).toBe('file');
|
||||
expect(sanitizeFilename({} as any)).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Normal filenames', () => {
|
||||
it('should preserve normal filenames', () => {
|
||||
expect(sanitizeFilename('document')).toBe('document');
|
||||
expect(sanitizeFilename('file123')).toBe('file123');
|
||||
expect(sanitizeFilename('my-file_name')).toBe('my-file_name');
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
expect(sanitizeFilename('文件')).toBe('文件');
|
||||
expect(sanitizeFilename('файл')).toBe('файл');
|
||||
expect(sanitizeFilename('café')).toBe('café');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world examples from bug report', () => {
|
||||
it('should handle filename that might become "nul"', () => {
|
||||
// If a filename is "null.png", basename would be "null"
|
||||
expect(sanitizeFilename('null')).toBe('null'); // "null" is ok
|
||||
expect(sanitizeFilename('nul')).toBe('_nul'); // "nul" is reserved
|
||||
});
|
||||
|
||||
it('should sanitize typical image filenames', () => {
|
||||
expect(sanitizeFilename('screenshot')).toBe('screenshot');
|
||||
expect(sanitizeFilename('image 1')).toBe('image_1');
|
||||
expect(sanitizeFilename('photo?.jpg')).toBe('photo.jpg'); // ? removed, . is valid
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "automaker",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
|
||||
@@ -9,7 +9,11 @@ set -e
|
||||
# ============================================================================
|
||||
# CONFIGURATION & CONSTANTS
|
||||
# ============================================================================
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
fi
|
||||
APP_NAME="Automaker"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HISTORY_FILE="${HOME}/.automaker_launcher_history"
|
||||
@@ -1154,7 +1158,9 @@ fi
|
||||
# Execute the appropriate command
|
||||
case $MODE in
|
||||
web)
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
export TEST_PORT="$WEB_PORT"
|
||||
export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT"
|
||||
export PORT="$SERVER_PORT"
|
||||
|
||||
Reference in New Issue
Block a user