@tsuzuku/arise 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,9 @@ const git = require('../git');
4
4
  const herdr = require('../herdr');
5
5
  const layout = require('../layout');
6
6
  const { createContext } = require('../context');
7
+ const { resolveConfiguration } = require('../config');
7
8
  const { showUsage } = require('../cli');
9
+ const logger = require('../logger');
8
10
 
9
11
  async function executeCreate(flags, config, cwd = process.cwd()) {
10
12
  const branch = flags.branch;
@@ -19,13 +21,13 @@ async function executeCreate(flags, config, cwd = process.cwd()) {
19
21
  const dirname = flags.dirname || sanitizedBranch;
20
22
 
21
23
  const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
22
- const baseDir = config.repo.worktreesBase || repoRoot;
24
+ const baseDir = config.repo.worktreesBase || (git.isBareRepo(repoRoot) ? path.dirname(repoRoot) : repoRoot);
23
25
  const worktreePath = path.resolve(baseDir, dirname);
24
26
 
25
27
  // 2. Resolve Session / Workspace Name
26
28
  const baseWorkspace = flags.workspaceName || dirname.replace(/[\s.:]/g, '_');
27
- const prefix = config.workspace.labelPrefix || '';
28
- const sessionName = (prefix && !baseWorkspace.startsWith(prefix.trim()))
29
+ let prefix = config.workspace.labelPrefix || '';
30
+ let sessionName = (prefix && !baseWorkspace.startsWith(prefix.trim()))
29
31
  ? `${prefix}${baseWorkspace}`
30
32
  : baseWorkspace;
31
33
 
@@ -64,30 +66,60 @@ async function executeCreate(flags, config, cwd = process.cwd()) {
64
66
  bareRepo: config.repo.bareRepo,
65
67
  });
66
68
  } catch (err) {
67
- console.error(`Failed to create worktree: ${err.message}`);
69
+ logger.error(`Failed to create worktree: ${err.message}`, err);
68
70
  process.exit(1);
69
71
  }
70
72
  }
71
73
 
72
- // 5. Initialize Context for Hooks
74
+ // 5. Re-resolve config using target worktree directory for branch-specific overrides with caller cwd fallback
75
+ let activeConfig = resolveConfiguration(flags, worktreePathToUse, cwd);
76
+
77
+ // If the worktree was freshly created and caller had an active configuration file in cwd, sync it into the new worktree
78
+ if (!worktreeExists && config.configFile && fs.existsSync(config.configFile)) {
79
+ try {
80
+ const targetConfigPath = path.join(worktreePathToUse, path.basename(config.configFile));
81
+ fs.copyFileSync(config.configFile, targetConfigPath);
82
+ logger.debug(`Synced configuration file from caller to new worktree: ${config.configFile} -> ${targetConfigPath}`);
83
+ activeConfig = resolveConfiguration(flags, worktreePathToUse, cwd);
84
+ } catch (err) {
85
+ logger.warn(`Could not sync caller config file to new worktree: ${err.message}`);
86
+ }
87
+ }
88
+
89
+ if (config.layout && config.layout.length > 0 && config.configFile && (!activeConfig.configFile || activeConfig.configFile.includes('.bare'))) {
90
+ activeConfig.layout = config.layout;
91
+ }
92
+
93
+ logger.debug(`Active configuration for target="${worktreePathToUse}" (caller="${cwd}"):`, {
94
+ configFile: activeConfig.configFile,
95
+ layoutCount: activeConfig.layout?.length,
96
+ panes: activeConfig.layout?.map((p) => ({ id: p.id, title: p.title, from: p.from, split: p.split })),
97
+ });
98
+
99
+ prefix = activeConfig.workspace.labelPrefix || '';
100
+ sessionName = (prefix && !baseWorkspace.startsWith(prefix.trim()))
101
+ ? `${prefix}${baseWorkspace}`
102
+ : baseWorkspace;
103
+
104
+ // Initialize Context for Hooks
73
105
  const ctx = createContext({
74
106
  worktreePath: worktreePathToUse,
75
107
  repoRoot,
76
- bareRepo: config.repo.bareRepo,
108
+ bareRepo: activeConfig.repo.bareRepo,
77
109
  branch,
78
- source: flags.source || config.repo.defaultBaseBranch,
110
+ source: flags.source || activeConfig.repo.defaultBaseBranch,
79
111
  flags,
80
- preset: config.preset,
81
- config,
112
+ preset: activeConfig.preset,
113
+ config: activeConfig,
82
114
  });
83
115
 
84
116
  // 6. Run Preset / Config Hooks
85
- if (typeof config.hooks.onSyncPrimary === 'function') {
86
- await config.hooks.onSyncPrimary(ctx);
117
+ if (typeof activeConfig.hooks.onSyncPrimary === 'function') {
118
+ await activeConfig.hooks.onSyncPrimary(ctx);
87
119
  }
88
120
 
89
- if (!worktreeExists && typeof config.hooks.onScaffold === 'function') {
90
- await config.hooks.onScaffold(ctx);
121
+ if (!worktreeExists && typeof activeConfig.hooks.onScaffold === 'function') {
122
+ await activeConfig.hooks.onScaffold(ctx);
91
123
  }
92
124
 
93
125
  // 7. Orchestrate Herdr Workspace
@@ -105,10 +137,10 @@ async function executeCreate(flags, config, cwd = process.cwd()) {
105
137
  // 8. Render Terminal Layout
106
138
  console.log(`==> Configuring terminal panes...`);
107
139
  layout.renderLayout({
108
- layout: config.layout,
140
+ layout: activeConfig.layout,
109
141
  rootPaneId: ws.rootPaneId,
110
142
  cwd: worktreePathToUse,
111
- focusTarget: flags.focusTarget || config.workspace.defaultFocus,
143
+ focusTarget: flags.focusTarget || activeConfig.workspace.defaultFocus,
112
144
  });
113
145
 
114
146
  // Focus the workspace
@@ -4,7 +4,15 @@ const { execSync } = require('child_process');
4
4
  const git = require('../git');
5
5
  const herdr = require('../herdr');
6
6
  const { createContext } = require('../context');
7
+ const { resolveConfiguration } = require('../config');
7
8
  const { showUsage } = require('../cli');
9
+ const { promptConfirm } = require('../tui/prompt');
10
+
11
+ function getRepoDisplayName(repoPath) {
12
+ if (!repoPath) return 'unknown';
13
+ const cleaned = repoPath.replace(/[/\\]\.bare$/, '').replace(/[/\\]\.git$/, '');
14
+ return path.basename(cleaned) || repoPath;
15
+ }
8
16
 
9
17
  async function executeNuke(flags, config, cwd = process.cwd()) {
10
18
  const repoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
@@ -16,6 +24,7 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
16
24
  if (!target) {
17
25
  const currentCwd = path.resolve(cwd);
18
26
  const matchingWt = worktrees.find(wt => {
27
+ if (wt.isBare) return false;
19
28
  const resolvedWtPath = path.resolve(wt.path);
20
29
  return resolvedWtPath !== path.resolve(repoRoot) && (
21
30
  currentCwd === resolvedWtPath ||
@@ -50,6 +59,10 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
50
59
  const targetDirname = path.basename(targetWorktreePath);
51
60
  const targetSessionName = targetDirname.replace(/[\s.:]/g, '_');
52
61
 
62
+ const activeConfig = fs.existsSync(targetWorktreePath)
63
+ ? resolveConfiguration(flags, targetWorktreePath)
64
+ : config;
65
+
53
66
  let targetBranch = flags.branch;
54
67
  if (!targetBranch && existingWorktree && existingWorktree.branch) {
55
68
  targetBranch = existingWorktree.branch;
@@ -64,26 +77,60 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
64
77
  }
65
78
  if (!targetBranch) {
66
79
  try {
67
- const gitCmd = git.getGitPrefix({ repoDir: repoRoot, bareRepo: config.repo.bareRepo });
80
+ const gitCmd = git.getGitPrefix({ repoDir: repoRoot, bareRepo: activeConfig.repo.bareRepo });
68
81
  execSync(`${gitCmd} rev-parse --verify "${target}"`, { stdio: 'ignore' });
69
82
  targetBranch = target;
70
83
  } catch (e) {}
71
84
  }
72
85
 
73
86
  console.log(`\n=== Starting Worktree Nuke ===`);
74
- console.log(`Active Preset: ${config.preset ? config.preset.name : 'generic'}`);
87
+ console.log(`Active Preset: ${activeConfig.preset ? activeConfig.preset.name : 'generic'}`);
75
88
  console.log(`Repository Root: ${repoRoot}`);
76
89
  console.log(`Target Worktree Path: ${targetWorktreePath}`);
77
90
  console.log(`Associated Branch: ${targetBranch || '(None detected)'}`);
78
91
  console.log(`Directory Only Mode: ${flags.dirOnly ? 'Yes (branches will be preserved)' : 'No'}`);
79
92
  console.log(`Keep Remote Branch: ${flags.keepRemote ? 'Yes' : 'No'}\n`);
80
93
 
81
- // Safety check: Never delete root repository
94
+ // Safety check 1: Never delete root repository
82
95
  if (path.resolve(targetWorktreePath) === path.resolve(repoRoot)) {
83
96
  console.error(`Error: Cannot delete the repository root directory "${repoRoot}".`);
84
97
  process.exit(1);
85
98
  }
86
99
 
100
+ // Safety check 2: Cross-repository nuke detection
101
+ const callerRepoRoot = git.getRepoRootDir(cwd, config.repo.bareRepo);
102
+ const targetRepoRoot = fs.existsSync(targetWorktreePath)
103
+ ? git.getRepoRootDir(targetWorktreePath, activeConfig.repo.bareRepo)
104
+ : repoRoot;
105
+
106
+ const isCrossRepo = callerRepoRoot && targetRepoRoot &&
107
+ path.resolve(callerRepoRoot) !== path.resolve(targetRepoRoot);
108
+
109
+ if (isCrossRepo) {
110
+ const callerRepoName = getRepoDisplayName(callerRepoRoot);
111
+ const targetRepoName = getRepoDisplayName(targetRepoRoot);
112
+
113
+ console.warn(`\x1b[1m\x1b[33m⚠️ CROSS-REPOSITORY NUKE DETECTED\x1b[39m\x1b[22m`);
114
+ console.warn(`─────────────────────────────────────────────────────────────────`);
115
+ console.warn(`• Caller CWD: ${cwd} (Repo: ${callerRepoName})`);
116
+ console.warn(`• Target Worktree: ${targetWorktreePath} (Repo: ${targetRepoName})`);
117
+ console.warn(`• Associated Branch: ${targetBranch || '(None detected)'}`);
118
+ console.warn(`─────────────────────────────────────────────────────────────────`);
119
+ console.warn(`You are invoking --nuke from a different repository than the target worktree!\n`);
120
+
121
+ if (!flags.yes && !flags.force && process.stdin.isTTY && !process.env.CI) {
122
+ const confirmed = await promptConfirm({
123
+ question: `Are you sure you want to nuke this worktree from another repository?`,
124
+ defaultYes: false,
125
+ });
126
+
127
+ if (!confirmed) {
128
+ console.log(`\nAborted. No worktrees or branches were deleted.`);
129
+ return;
130
+ }
131
+ }
132
+ }
133
+
87
134
  // If cwd is inside the target worktree, switch to rootDir/baseDir
88
135
  if (path.resolve(process.cwd()).startsWith(path.resolve(targetWorktreePath))) {
89
136
  console.log(`Current working directory is inside target worktree. Switching to: ${baseDir}`);
@@ -96,16 +143,16 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
96
143
  const ctx = createContext({
97
144
  worktreePath: targetWorktreePath,
98
145
  repoRoot,
99
- bareRepo: config.repo.bareRepo,
146
+ bareRepo: activeConfig.repo.bareRepo,
100
147
  branch: targetBranch,
101
148
  flags,
102
- preset: config.preset,
103
- config,
149
+ preset: activeConfig.preset,
150
+ config: activeConfig,
104
151
  });
105
152
 
106
153
  // Pre-Nuke Hook
107
- if (typeof config.hooks.onPreNuke === 'function') {
108
- await config.hooks.onPreNuke(ctx);
154
+ if (typeof activeConfig.hooks.onPreNuke === 'function') {
155
+ await activeConfig.hooks.onPreNuke(ctx);
109
156
  }
110
157
 
111
158
  // 1. Remove Worktree & Directory
@@ -139,12 +186,12 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
139
186
  // Helper for final cleanup steps (Herdr workspace close + Post-Nuke hook)
140
187
  const finishCleanup = async () => {
141
188
  // Post-Nuke Hook
142
- if (typeof config.hooks.onPostNuke === 'function') {
143
- await config.hooks.onPostNuke(ctx);
189
+ if (typeof activeConfig.hooks.onPostNuke === 'function') {
190
+ await activeConfig.hooks.onPostNuke(ctx);
144
191
  }
145
192
 
146
193
  // Close Herdr Workspace if running (performed last so active pane is not terminated mid-cleanup)
147
- const prefix = config.workspace.labelPrefix || '';
194
+ const prefix = activeConfig.workspace.labelPrefix || '';
148
195
  const matchTargets = [
149
196
  targetSessionName,
150
197
  targetDirname,
@@ -153,7 +200,7 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
153
200
  prefix ? `${prefix}${targetDirname}` : null,
154
201
  ].filter(Boolean);
155
202
 
156
- herdr.closeWorkspacesMatching(matchTargets);
203
+ herdr.closeWorkspacesForWorktree(targetWorktreePath, matchTargets);
157
204
 
158
205
  console.log(`\nCleanup complete!`);
159
206
  };
@@ -165,7 +212,7 @@ async function executeNuke(flags, config, cwd = process.cwd()) {
165
212
  return;
166
213
  }
167
214
 
168
- const protectedBranches = config.repo.protectedBranches || ['main', 'master', 'develop', 'prod', 'staging', 'production'];
215
+ const protectedBranches = activeConfig.repo.protectedBranches || ['main', 'master', 'develop', 'prod', 'staging', 'production'];
169
216
  if (targetBranch && protectedBranches.includes(targetBranch)) {
170
217
  console.warn(`\n==> WARNING: "${targetBranch}" is a protected branch. Skipping local and remote branch deletion.`);
171
218
  await finishCleanup();
package/lib/logger.js ADDED
@@ -0,0 +1,85 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { ANSI } = require('./tui/ansi');
5
+
6
+ let isDebugEnabled = false;
7
+ let logFilePath = null;
8
+
9
+ function initLogger(flags = {}) {
10
+ isDebugEnabled = Boolean(
11
+ flags.debug ||
12
+ flags.verbose ||
13
+ process.env.ARISE_DEBUG === '1' ||
14
+ process.env.DEBUG === 'arise' ||
15
+ process.env.DEBUG === '*'
16
+ );
17
+
18
+ try {
19
+ const logDir = path.join(os.homedir(), '.config', 'arise', 'logs');
20
+ fs.mkdirSync(logDir, { recursive: true });
21
+ logFilePath = path.join(logDir, 'arise.log');
22
+ } catch (e) {
23
+ logFilePath = path.join(os.tmpdir(), 'arise-debug.log');
24
+ }
25
+ }
26
+
27
+ function writeToFile(level, message, details = null) {
28
+ if (!logFilePath) return;
29
+ try {
30
+ const timestamp = new Date().toISOString();
31
+ let line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
32
+ if (details) {
33
+ line += typeof details === 'object' ? JSON.stringify(details, null, 2) + '\n' : String(details) + '\n';
34
+ }
35
+ fs.appendFileSync(logFilePath, line, 'utf8');
36
+ } catch (e) {}
37
+ }
38
+
39
+ function debug(message, details = null) {
40
+ writeToFile('DEBUG', message, details);
41
+ if (isDebugEnabled) {
42
+ console.log(` ${ANSI.dim}[DEBUG] ${message}${ANSI.reset}`);
43
+ if (details) {
44
+ console.log(ANSI.dim + (typeof details === 'object' ? JSON.stringify(details, null, 2) : details) + ANSI.reset);
45
+ }
46
+ }
47
+ }
48
+
49
+ function info(message) {
50
+ writeToFile('INFO', message);
51
+ console.log(message);
52
+ }
53
+
54
+ function warn(message, details = null) {
55
+ writeToFile('WARN', message, details);
56
+ console.warn(`${ANSI.yellow}Warning: ${message}${ANSI.reset}`);
57
+ if (isDebugEnabled && details) {
58
+ console.warn(ANSI.dim + (typeof details === 'object' ? JSON.stringify(details, null, 2) : details) + ANSI.reset);
59
+ }
60
+ }
61
+
62
+ function error(message, err = null) {
63
+ writeToFile('ERROR', message, err && err.stack ? err.stack : err);
64
+ console.error(`${ANSI.red}Error: ${message}${ANSI.reset}`);
65
+ if (err) {
66
+ if (isDebugEnabled && err.stack) {
67
+ console.error(ANSI.dim + err.stack + ANSI.reset);
68
+ } else if (err.message) {
69
+ console.error(`${ANSI.dim}Details: ${err.message}${ANSI.reset}`);
70
+ }
71
+ }
72
+ if (!isDebugEnabled && logFilePath) {
73
+ console.error(`${ANSI.dim}(Detailed logs saved to ${logFilePath})${ANSI.reset}`);
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ initLogger,
79
+ isDebugEnabled: () => isDebugEnabled,
80
+ getLogFilePath: () => logFilePath,
81
+ debug,
82
+ info,
83
+ warn,
84
+ error,
85
+ };
package/lib/skill.js CHANGED
@@ -15,6 +15,12 @@ Use this skill to answer questions about \`arise\` and run worktree management c
15
15
 
16
16
  ## 1. CLI Quick Reference & Cheatsheet
17
17
 
18
+ ### Interactive Mode
19
+ \`\`\`bash
20
+ # Launch interactive menu (create, switch/open, list, nuke)
21
+ arise
22
+ \`\`\`
23
+
18
24
  ### Creating Worktrees
19
25
  \`\`\`bash
20
26
  # Create or open a worktree for a branch (auto-detects preset, boots Herdr workspace)
@@ -0,0 +1,294 @@
1
+ const ANSI = {
2
+ reset: '\x1b[0m',
3
+ bold: '\x1b[1m',
4
+ dim: '\x1b[2m',
5
+ italic: '\x1b[3m',
6
+ underline: '\x1b[4m',
7
+ inverse: '\x1b[7m',
8
+ hidden: '\x1b[8m',
9
+ strikethrough: '\x1b[9m',
10
+
11
+ // Foreground
12
+ black: '\x1b[30m',
13
+ red: '\x1b[31m',
14
+ green: '\x1b[32m',
15
+ yellow: '\x1b[33m',
16
+ blue: '\x1b[34m',
17
+ magenta: '\x1b[35m',
18
+ cyan: '\x1b[36m',
19
+ white: '\x1b[37m',
20
+ gray: '\x1b[90m',
21
+
22
+ // Bright Foreground
23
+ brightRed: '\x1b[91m',
24
+ brightGreen: '\x1b[92m',
25
+ brightYellow: '\x1b[93m',
26
+ brightBlue: '\x1b[94m',
27
+ brightMagenta: '\x1b[95m',
28
+ brightCyan: '\x1b[96m',
29
+ brightWhite: '\x1b[97m',
30
+
31
+ // Background
32
+ bgBlack: '\x1b[40m',
33
+ bgRed: '\x1b[41m',
34
+ bgGreen: '\x1b[42m',
35
+ bgYellow: '\x1b[43m',
36
+ bgBlue: '\x1b[44m',
37
+ bgMagenta: '\x1b[45m',
38
+ bgCyan: '\x1b[46m',
39
+ bgWhite: '\x1b[47m',
40
+ bgGray: '\x1b[100m',
41
+
42
+ // Cursor & Screen
43
+ clearScreen: '\x1b[2J\x1b[H',
44
+ clearLine: '\x1b[2K\r',
45
+ hideCursor: '\x1b[?25l',
46
+ showCursor: '\x1b[?25h',
47
+ altScreenEnter: '\x1b[?1049h',
48
+ altScreenExit: '\x1b[?1049l',
49
+ };
50
+
51
+ function stripAnsi(text) {
52
+ if (typeof text !== 'string') return '';
53
+ return text
54
+ // Strip OSC sequences
55
+ .replace(/\x1b\](?:[^\x07\x1b]|\x1b[^\\])*?(?:\x07|\x1b\\)/g, '')
56
+ // Strip CSI sequences
57
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
58
+ // Strip other escape sequences
59
+ .replace(/\x1b[@-Z\\-_]/g, '');
60
+ }
61
+
62
+ function visibleLength(text) {
63
+ return stripAnsi(text).length;
64
+ }
65
+
66
+ class AnsiStyleTracker {
67
+ constructor() {
68
+ this.fg = '';
69
+ this.bg = '';
70
+ this.bold = false;
71
+ this.dim = false;
72
+ this.italic = false;
73
+ this.underline = false;
74
+ this.strike = false;
75
+ }
76
+
77
+ processCode(codeStr) {
78
+ const match = codeStr.match(/^\x1b\[([0-9;?]*)m$/);
79
+ if (!match) return;
80
+ const rawCodes = match[1] ? match[1].split(';').map(Number) : [0];
81
+
82
+ for (const c of rawCodes) {
83
+ if (c === 0) {
84
+ this.fg = '';
85
+ this.bg = '';
86
+ this.bold = false;
87
+ this.dim = false;
88
+ this.italic = false;
89
+ this.underline = false;
90
+ this.strike = false;
91
+ } else if (c === 1) {
92
+ this.bold = true;
93
+ } else if (c === 2) {
94
+ this.dim = true;
95
+ } else if (c === 3) {
96
+ this.italic = true;
97
+ } else if (c === 4) {
98
+ this.underline = true;
99
+ } else if (c === 9) {
100
+ this.strike = true;
101
+ } else if (c === 22) {
102
+ this.bold = false;
103
+ this.dim = false;
104
+ } else if (c === 23) {
105
+ this.italic = false;
106
+ } else if (c === 24) {
107
+ this.underline = false;
108
+ } else if (c === 29) {
109
+ this.strike = false;
110
+ } else if (c === 39) {
111
+ this.fg = '';
112
+ } else if (c === 49) {
113
+ this.bg = '';
114
+ } else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) {
115
+ this.fg = `\x1b[${c}m`;
116
+ } else if ((c >= 40 && c <= 47) || (c >= 100 && c <= 107)) {
117
+ this.bg = `\x1b[${c}m`;
118
+ }
119
+ }
120
+ }
121
+
122
+ getActiveCodes() {
123
+ let result = '';
124
+ if (this.bold) result += '\x1b[1m';
125
+ if (this.dim) result += '\x1b[2m';
126
+ if (this.italic) result += '\x1b[3m';
127
+ if (this.underline) result += '\x1b[4m';
128
+ if (this.strike) result += '\x1b[9m';
129
+ if (this.fg) result += this.fg;
130
+ if (this.bg) result += this.bg;
131
+ return result;
132
+ }
133
+
134
+ hasActiveStyles() {
135
+ return this.bold || this.dim || this.italic || this.underline || this.strike || Boolean(this.fg) || Boolean(this.bg);
136
+ }
137
+ }
138
+
139
+ function wrapAnsiLine(line, maxWidth, hangingIndent = '') {
140
+ if (maxWidth <= 0) return [line];
141
+ const totalVisible = visibleLength(line);
142
+ if (totalVisible <= maxWidth) {
143
+ return [line];
144
+ }
145
+
146
+ const tokenRegex = /(?:\x1b\](?:[^\x07\x1b]|\x1b[^\\])*?(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-Z\\-_]|[^\s\x1b]+|\s+)/g;
147
+ const tokens = line.match(tokenRegex) || [line];
148
+
149
+ const result = [];
150
+ const tracker = new AnsiStyleTracker();
151
+
152
+ let currentLine = '';
153
+ let currentVisibleWidth = 0;
154
+ let isFirstLine = true;
155
+ const hangingVisibleWidth = visibleLength(hangingIndent);
156
+
157
+ for (let i = 0; i < tokens.length; i++) {
158
+ const token = tokens[i];
159
+
160
+ if (token.startsWith('\x1b')) {
161
+ tracker.processCode(token);
162
+ currentLine += token;
163
+ continue;
164
+ }
165
+
166
+ const tokenVisibleLen = stripAnsi(token).length;
167
+ const isWhitespace = /^\s+$/.test(token);
168
+
169
+ if (isWhitespace) {
170
+ if (currentVisibleWidth === hangingVisibleWidth && !isFirstLine) {
171
+ continue;
172
+ }
173
+ if (currentVisibleWidth + tokenVisibleLen <= maxWidth) {
174
+ currentLine += token;
175
+ currentVisibleWidth += tokenVisibleLen;
176
+ } else {
177
+ if (tracker.hasActiveStyles()) {
178
+ currentLine += ANSI.reset;
179
+ }
180
+ result.push(currentLine);
181
+ isFirstLine = false;
182
+ currentLine = hangingIndent + tracker.getActiveCodes();
183
+ currentVisibleWidth = hangingVisibleWidth;
184
+ }
185
+ continue;
186
+ }
187
+
188
+ // Word token
189
+ if (currentVisibleWidth + tokenVisibleLen <= maxWidth) {
190
+ currentLine += token;
191
+ currentVisibleWidth += tokenVisibleLen;
192
+ } else {
193
+ if (currentVisibleWidth > (isFirstLine ? 0 : hangingVisibleWidth)) {
194
+ if (tracker.hasActiveStyles()) {
195
+ currentLine += ANSI.reset;
196
+ }
197
+ result.push(currentLine);
198
+ isFirstLine = false;
199
+ currentLine = hangingIndent + tracker.getActiveCodes();
200
+ currentVisibleWidth = hangingVisibleWidth;
201
+ }
202
+
203
+ if (tokenVisibleLen > maxWidth - currentVisibleWidth) {
204
+ let remaining = token;
205
+ while (remaining.length > 0) {
206
+ const available = Math.max(1, maxWidth - currentVisibleWidth);
207
+ const chunk = remaining.slice(0, available);
208
+ remaining = remaining.slice(available);
209
+
210
+ currentLine += chunk;
211
+ currentVisibleWidth += chunk.length;
212
+
213
+ if (remaining.length > 0) {
214
+ if (tracker.hasActiveStyles()) {
215
+ currentLine += ANSI.reset;
216
+ }
217
+ result.push(currentLine);
218
+ isFirstLine = false;
219
+ currentLine = hangingIndent + tracker.getActiveCodes();
220
+ currentVisibleWidth = hangingVisibleWidth;
221
+ }
222
+ }
223
+ } else {
224
+ currentLine += token;
225
+ currentVisibleWidth += tokenVisibleLen;
226
+ }
227
+ }
228
+ }
229
+
230
+ if (currentLine.length > 0) {
231
+ if (tracker.hasActiveStyles()) {
232
+ currentLine += ANSI.reset;
233
+ }
234
+ result.push(currentLine);
235
+ }
236
+
237
+ return result.length > 0 ? result : [''];
238
+ }
239
+
240
+ function truncate(text, maxWidth) {
241
+ if (maxWidth <= 0) return '';
242
+ const totalVisible = visibleLength(text);
243
+ if (totalVisible <= maxWidth) return text;
244
+
245
+ const targetWidth = Math.max(1, maxWidth - 1);
246
+ const tokenRegex = /(?:\x1b\](?:[^\x07\x1b]|\x1b[^\\])*?(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-Z\\-_]|[^\x1b])/g;
247
+ const tokens = text.match(tokenRegex) || [];
248
+
249
+ let result = '';
250
+ let currentVisible = 0;
251
+
252
+ for (const token of tokens) {
253
+ if (token.startsWith('\x1b')) {
254
+ result += token;
255
+ } else {
256
+ if (currentVisible + 1 > targetWidth) {
257
+ break;
258
+ }
259
+ result += token;
260
+ currentVisible += 1;
261
+ }
262
+ }
263
+
264
+ return result + ANSI.reset + '…';
265
+ }
266
+
267
+ function drawBox(title, contentLines = [], width = 70) {
268
+ const safeWidth = Math.max(10, width);
269
+ const horizontal = '─'.repeat(Math.max(0, safeWidth - 2));
270
+ const titleFormatted = title ? ` ${ANSI.bold}${title}${ANSI.reset} ` : '';
271
+ const titleLen = visibleLength(titleFormatted);
272
+ const topBar = `┌${titleFormatted}${'─'.repeat(Math.max(0, safeWidth - 2 - titleLen))}┐`;
273
+ const bottomBar = `└${horizontal}┘`;
274
+
275
+ const maxInner = Math.max(0, safeWidth - 4);
276
+ const renderedLines = contentLines.map((line) => {
277
+ const truncatedLine = truncate(line, maxInner);
278
+ const plain = visibleLength(truncatedLine);
279
+ const padding = Math.max(0, maxInner - plain);
280
+ return `│ ${truncatedLine}${' '.repeat(padding)} │`;
281
+ });
282
+
283
+ return [topBar, ...renderedLines, bottomBar];
284
+ }
285
+
286
+ module.exports = {
287
+ ANSI,
288
+ stripAnsi,
289
+ visibleLength,
290
+ AnsiStyleTracker,
291
+ wrapAnsiLine,
292
+ truncate,
293
+ drawBox,
294
+ };