Skip to content

Commit bcdf9ef

Browse files
fix(v1.2.1): input line-wrap bug, live bottom separator, restore header (#33)
* fix(v1.2.1): input line-wrap bug, live bottom separator, restore header - Fix extra blank line when user text fills exactly one terminal row (exact boundary detection in cursor position calculation) - Add bottom separator line that renders live while typing, growing with wrapped text (previously only appeared on Enter) - Restore styled purple header box for all users, not just first-time - Remove unused imports (existsSync, path) - Bump version to 1.2.1 * fix(v1.2.1): input line-wrap extra line, header box right border - Fix extra blank line when typed text fills exactly one terminal row by skipping the \n before the bottom separator when the terminal has already auto-wrapped the cursor. - Fix header box right border misalignment caused by emoji width variance: use ANSI absolute column positioning (\x1b[<col>G) to place the closing │ at a fixed column matching the top/bottom corners. * fix(repl): header box alignment and bottom separator persistence - Header box: use ANSI CHA (\x1b[<col>G) + EL (\x1b[K) to position the right │ at a fixed column (43) matching the top/bottom corners, regardless of terminal emoji width rendering (2 vs 3 cols for 🥥). - Bottom separator: preserve it on screen after the user presses Enter. clearMenu now moves the cursor past the separator before erasing, so the separator stays visible while the agent is thinking/responding. * fix(repl): use string-width for header box alignment (boxen approach) Replace custom visualWidth() with the industry-standard `string-width` package (6900+ npm dependents, used by boxen/ink/chalk ecosystem). The header box now uses pure space-padding (like boxen) instead of ANSI cursor positioning. stringWidth correctly measures emoji, ZWJ sequences, and CJK characters via Intl.Segmenter + grapheme clusters, so the right │ border always aligns with the ╮/╯ corners regardless of terminal emoji width rendering. * fix(repl): move emoji outside box for perfect right border alignment Emoji widths are unpredictable across terminals (🥥 renders as 2 or 3 cols depending on the emulator). No JS library — including string-width — can query the actual terminal rendering width. This mismatch caused the right │ to be offset on the title line. Move the 🥥 emoji outside the box (before the top-left corner) so only ASCII content lives inside. ASCII chars always have width 1, making the right border perfectly continuous across all terminals and emulators. * fix(repl): adjust top border indent to match emoji terminal width * fix(repl): nudge top border line right to align with box body * fix(repl): remove emoji from header box for clean alignment * refactor(repl): rename header to "COCO - Coding Agent" Simplify branding: the npm scope @corbat-tech already carries the company name, so the product name in the header is just "COCO". Added "- Coding Agent" descriptor for clarity. * refactor(repl): 3-line header with tagline and color hierarchy - COCO in bold white (protagonist) - "code that converges to quality" tagline in magenta (brand color) - "open source • corbat.tech" in dim (attribution) * chore: bump version to 1.2.2
1 parent 3ac6262 commit bcdf9ef

4 files changed

Lines changed: 109 additions & 115 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@corbat-tech/coco",
3-
"version": "1.2.0",
3+
"version": "1.2.2",
44
"description": "Autonomous Coding Agent with Self-Review, Quality Convergence, and Production-Ready Output",
55
"type": "module",
66
"main": "dist/index.js",
@@ -91,6 +91,7 @@
9191
"openai": "^6.17.0",
9292
"ora": "^9.2.0",
9393
"simple-git": "^3.27.0",
94+
"string-width": "^8.1.1",
9495
"tslog": "^4.9.3",
9596
"zod": "^4.3.6"
9697
},

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/repl/index.ts

Lines changed: 41 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
* REPL main entry point
33
*/
44

5-
import { existsSync } from "node:fs";
6-
import path from "node:path";
75
import chalk from "chalk";
6+
import stringWidth from "string-width";
87
import {
98
createSession,
109
initializeSessionTrust,
@@ -63,50 +62,9 @@ import {
6362
type CocoQualityResult,
6463
} from "./coco-mode.js";
6564

66-
/**
67-
* Calculate the visual width of a string, accounting for ANSI escape codes
68-
* and wide characters (emoji, CJK).
69-
*
70-
* - Strips ANSI escape sequences (zero width)
71-
* - Counts emoji and wide Unicode characters as width 2
72-
* - Counts all other printable characters as width 1
73-
*/
74-
function visualWidth(str: string): number {
75-
// Strip ANSI escape codes
76-
// eslint-disable-next-line no-control-regex
77-
const stripped = str.replace(/\x1b\[[0-9;]*m/g, "");
78-
let width = 0;
79-
for (const char of stripped) {
80-
const cp = char.codePointAt(0) || 0;
81-
// Emoji ranges (common emoji blocks)
82-
if (
83-
cp > 0x1f600 ||
84-
(cp >= 0x2600 && cp <= 0x27bf) ||
85-
(cp >= 0x1f300 && cp <= 0x1fad6) ||
86-
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
87-
(cp >= 0x2702 && cp <= 0x27b0) ||
88-
(cp >= 0xfe00 && cp <= 0xfe0f) ||
89-
cp === 0x200d
90-
) {
91-
width += 2;
92-
} else if (
93-
// CJK Unified Ideographs and other wide characters
94-
(cp >= 0x1100 && cp <= 0x115f) ||
95-
(cp >= 0x2e80 && cp <= 0x303e) ||
96-
(cp >= 0x3040 && cp <= 0x33bf) ||
97-
(cp >= 0x4e00 && cp <= 0x9fff) ||
98-
(cp >= 0xf900 && cp <= 0xfaff) ||
99-
(cp >= 0xfe30 && cp <= 0xfe6f) ||
100-
(cp >= 0xff01 && cp <= 0xff60) ||
101-
(cp >= 0xffe0 && cp <= 0xffe6)
102-
) {
103-
width += 2;
104-
} else {
105-
width += 1;
106-
}
107-
}
108-
return width;
109-
}
65+
// stringWidth (from 'string-width') is the industry-standard way to measure
66+
// visual terminal width of strings. It correctly handles ANSI codes, emoji
67+
// (including ZWJ sequences), CJK, and grapheme clusters via Intl.Segmenter.
11068

11169
/**
11270
* Start the REPL
@@ -521,55 +479,53 @@ export async function startRepl(
521479
* Brand color: Magenta/Purple
522480
*/
523481
async function printWelcome(session: { projectPath: string; config: ReplConfig }): Promise<void> {
524-
const providerType = session.config.provider.type;
525-
const model = session.config.provider.model || "default";
526-
527-
// Compact welcome for returning users
528-
const isReturningUser = existsSync(path.join(session.projectPath, ".coco"));
529-
if (isReturningUser) {
530-
const versionStr = chalk.dim(`v${VERSION}`);
531-
const providerStr = chalk.dim(`${providerType}/${model}`);
532-
console.log(
533-
`\n \u{1F965} Coco ${versionStr} ${chalk.dim("\u2502")} ${providerStr} ${chalk.dim("\u2502")} ${chalk.yellow("/help")}\n`,
534-
);
535-
return;
536-
}
537-
538482
const trustStore = createTrustStore();
539483
await trustStore.init();
540484
const trustLevel = trustStore.getLevel(session.projectPath);
541485

542-
// Box dimensions - fixed width for consistency
486+
// Box dimensions — fixed width for consistency.
487+
// Using the same approach as `boxen`: measure content with `stringWidth`,
488+
// pad with spaces to a uniform inner width, then wrap with border chars.
489+
// IMPORTANT: Emoji MUST stay outside the box. Terminal emoji widths are
490+
// unpredictable (some render 🥥 as 2 cols, others as 3) and no JS lib
491+
// can query the actual terminal width. Only ASCII content goes inside
492+
// so the right │ always aligns perfectly with the corners.
543493
const boxWidth = 41;
544-
const innerWidth = boxWidth - 4; // Account for "\u2502 " and " \u2502"
494+
const innerWidth = boxWidth - 2; // visible columns between the two │ chars
545495

546-
// Build content lines with proper padding using visualWidth()
547-
const titleContent = "\u{1F965} CORBAT-COCO";
548496
const versionText = `v${VERSION}`;
549-
const titleContentVisualWidth = visualWidth(titleContent);
550-
const versionVisualWidth = visualWidth(versionText);
551-
const titlePadding = innerWidth - titleContentVisualWidth - versionVisualWidth;
552-
553497
const subtitleText = "open source \u2022 corbat.tech";
554-
const subtitleVisualWidth = visualWidth(subtitleText);
555-
const subtitlePadding = innerWidth - subtitleVisualWidth;
556498

499+
// Helper: build a padded content line inside the box.
500+
// Measures the visual width of `content` with stringWidth, then pads it
501+
// with trailing spaces so every line has exactly `innerWidth` visible
502+
// columns. The right │ is always placed immediately after the padding.
503+
const boxLine = (content: string): string => {
504+
const pad = Math.max(0, innerWidth - stringWidth(content));
505+
return chalk.magenta("\u2502") + content + " ".repeat(pad) + chalk.magenta("\u2502");
506+
};
507+
508+
// Line 1: " COCO v1.2.x "
509+
const titleLeftRaw = " COCO";
510+
const titleRightRaw = versionText + " ";
511+
const titleLeftStyled = " " + chalk.bold.white("COCO");
512+
const titleGap = Math.max(1, innerWidth - stringWidth(titleLeftRaw) - stringWidth(titleRightRaw));
513+
const titleContent = titleLeftStyled + " ".repeat(titleGap) + chalk.dim(titleRightRaw);
514+
515+
// Line 2: tagline in brand color
516+
const taglineText = "code that converges to quality";
517+
const taglineContent = " " + chalk.magenta(taglineText) + " ";
518+
519+
// Line 3: attribution (dim)
520+
const subtitleContent = " " + chalk.dim(subtitleText) + " ";
521+
522+
// Always show the styled header box.
523+
// Only ASCII inside the box — emoji widths are unpredictable across terminals.
557524
console.log();
558525
console.log(chalk.magenta(" \u256D" + "\u2500".repeat(boxWidth - 2) + "\u256E"));
559-
console.log(
560-
chalk.magenta(" \u2502 ") +
561-
"\u{1F965} " +
562-
chalk.bold.white("CORBAT-COCO") +
563-
" ".repeat(Math.max(0, titlePadding)) +
564-
chalk.dim(versionText) +
565-
chalk.magenta(" \u2502"),
566-
);
567-
console.log(
568-
chalk.magenta(" \u2502 ") +
569-
chalk.dim(subtitleText) +
570-
" ".repeat(Math.max(0, subtitlePadding)) +
571-
chalk.magenta(" \u2502"),
572-
);
526+
console.log(" " + boxLine(titleContent));
527+
console.log(" " + boxLine(taglineContent));
528+
console.log(" " + boxLine(subtitleContent));
573529
console.log(chalk.magenta(" \u2570" + "\u2500".repeat(boxWidth - 2) + "\u256F"));
574530

575531
// Check for updates (non-blocking, with 3s timeout)
@@ -650,7 +606,7 @@ async function checkProjectTrust(projectPath: string): Promise<boolean> {
650606

651607
// Compact first-time access warning
652608
console.log();
653-
console.log(chalk.cyan.bold(" \u{1F965} Corbat-Coco") + chalk.dim(` v${VERSION}`));
609+
console.log(chalk.cyan.bold(" \u{1F965} Coco") + chalk.dim(` v${VERSION}`));
654610
console.log(chalk.dim(` \u{1F4C1} ${projectPath}`));
655611
console.log();
656612
console.log(chalk.yellow(" \u26A0 First time accessing this directory"));

src/cli/repl/input/handler.ts

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export function createInputHandler(_session: ReplSession): InputHandler {
140140
let tempLine = "";
141141
let lastMenuLines = 0;
142142
let lastCursorRow = 0; // Track cursor row from previous render for accurate clearing
143+
let lastContentRows = 1; // Track content rows so clearMenu can preserve the bottom separator
143144
let isFirstRender = true;
144145

145146
// Bracketed paste mode state
@@ -192,9 +193,9 @@ export function createInputHandler(_session: ReplSession): InputHandler {
192193
const termCols = process.stdout.columns || 80;
193194
const prompt = getPrompt();
194195

195-
// On re-renders, move cursor up from previous position to the separator line,
196-
// then clear everything. lastCursorRow tracks where the cursor was left,
197-
// +1 accounts for the separator line drawn above the prompt.
196+
// On re-renders, move cursor up from previous position to the top separator,
197+
// then clear everything. lastCursorRow tracks the cursor row relative to
198+
// the prompt start; +1 accounts for the top separator line.
198199
if (!isFirstRender) {
199200
const linesToGoUp = lastCursorRow + 1;
200201
if (linesToGoUp > 0) {
@@ -204,7 +205,7 @@ export function createInputHandler(_session: ReplSession): InputHandler {
204205
isFirstRender = false;
205206
process.stdout.write("\r" + ansiEscapes.eraseDown);
206207

207-
// Build separator line above input area
208+
// Build top separator line
208209
const separator = chalk.dim("\u2500".repeat(termCols));
209210
let output = separator + "\n";
210211

@@ -227,10 +228,27 @@ export function createInputHandler(_session: ReplSession): InputHandler {
227228
}
228229
}
229230

231+
// Calculate how many visual rows the content (prompt + full text) occupies.
232+
// This determines where to place the bottom separator.
233+
const totalContentLen = prompt.visualLen + currentLine.length;
234+
const contentRows = totalContentLen === 0 ? 1 : Math.ceil(totalContentLen / termCols);
235+
236+
// When content exactly fills a row (totalContentLen is a multiple of termCols),
237+
// the terminal auto-wraps the cursor to col 0 of the next row. In that case
238+
// we must NOT emit an extra "\n" before the bottom separator, because the
239+
// cursor is already on the next row. Emitting "\n" would create a blank line.
240+
const contentExactFill = totalContentLen > 0 && totalContentLen % termCols === 0;
241+
242+
// Bottom separator — always drawn below the input content
243+
output += (contentExactFill ? "" : "\n") + separator;
244+
230245
// Draw dropdown menu if we have completions
231246
const showMenu =
232247
completions.length > 0 && currentLine.startsWith("/") && currentLine.length >= 1;
233248

249+
// Count extra lines below the bottom separator (menu + margin)
250+
let extraLinesBelow = 0;
251+
234252
if (showMenu) {
235253
const cols = getColumnCount();
236254
const maxVisibleItems = MAX_ROWS * cols;
@@ -257,11 +275,13 @@ export function createInputHandler(_session: ReplSession): InputHandler {
257275
output += "\n";
258276
output += chalk.dim(` \u2191 ${startIndex} more above`);
259277
lastMenuLines++;
278+
extraLinesBelow++;
260279
}
261280

262281
// Render items in columns (items flow left-to-right, top-to-bottom)
263282
for (let row = 0; row < rowCount; row++) {
264283
output += "\n";
284+
extraLinesBelow++;
265285

266286
for (let col = 0; col < cols; col++) {
267287
const itemIndex = row * cols + col;
@@ -287,33 +307,38 @@ export function createInputHandler(_session: ReplSession): InputHandler {
287307
output += "\n";
288308
output += chalk.dim(` \u2193 ${completions.length - endIndex} more below`);
289309
lastMenuLines++;
310+
extraLinesBelow++;
290311
}
291-
292-
// Add bottom margin below menu, then move cursor back to prompt line
293-
for (let i = 0; i < BOTTOM_MARGIN; i++) {
294-
output += "\n";
295-
}
296-
// +1 for the separator line we added above the prompt
297-
output += ansiEscapes.cursorUp(lastMenuLines + BOTTOM_MARGIN + 1);
298312
} else {
299313
lastMenuLines = 0;
314+
}
300315

301-
// Add bottom margin below prompt, then move cursor back
302-
for (let i = 0; i < BOTTOM_MARGIN; i++) {
303-
output += "\n";
304-
}
305-
// +1 for the separator line
306-
output += ansiEscapes.cursorUp(BOTTOM_MARGIN + 1);
316+
// Add bottom margin
317+
for (let i = 0; i < BOTTOM_MARGIN; i++) {
318+
output += "\n";
319+
extraLinesBelow++;
307320
}
308321

309-
// Account for the separator line: cursor is now on the separator line.
310-
// Move down 1 to the prompt line.
322+
// Move cursor back up to the top separator line.
323+
// From current position we need to go up past:
324+
// extraLinesBelow + 1 (bottom separator) + contentRows (input) rows
325+
// to reach the top separator, then down 1 to the prompt start.
326+
const totalUp = extraLinesBelow + 1 + contentRows;
327+
output += ansiEscapes.cursorUp(totalUp);
328+
329+
// We're now on the top separator. Move down 1 to the prompt line.
311330
output += ansiEscapes.cursorDown(1);
312331

313-
// Position cursor correctly within the input, accounting for wrapping
332+
// Position cursor correctly within the input, accounting for wrapping.
333+
// When cursorAbsolutePos is an exact multiple of termCols, the terminal
334+
// auto-wraps to col 0 of the next row. We treat this as staying on the
335+
// last content row to prevent the "extra line" bug on next re-render.
314336
const cursorAbsolutePos = prompt.visualLen + cursorPos;
315-
const finalLine = Math.floor(cursorAbsolutePos / termCols);
316-
const finalCol = cursorAbsolutePos % termCols;
337+
const onExactBoundary = cursorAbsolutePos > 0 && cursorAbsolutePos % termCols === 0;
338+
const finalLine = onExactBoundary
339+
? cursorAbsolutePos / termCols - 1
340+
: Math.floor(cursorAbsolutePos / termCols);
341+
const finalCol = onExactBoundary ? 0 : cursorAbsolutePos % termCols;
317342

318343
output += "\r";
319344
if (finalLine > 0) {
@@ -323,19 +348,29 @@ export function createInputHandler(_session: ReplSession): InputHandler {
323348
output += ansiEscapes.cursorForward(finalCol);
324349
}
325350

326-
// Track cursor row for next render's clearing calculation
351+
// Track cursor row and content height for next render / clearMenu
327352
lastCursorRow = finalLine;
353+
lastContentRows = contentRows;
328354

329355
// Write everything at once
330356
process.stdout.write(output);
331357
}
332358

333359
/**
334-
* Clear the menu before exiting or submitting
360+
* Clear the menu and margin while preserving the bottom separator.
361+
* Moves the cursor from its current editing position down to one row
362+
* past the bottom separator, then erases everything below (menu + margin).
363+
* The bottom separator line remains on screen.
335364
*/
336365
function clearMenu() {
337-
// Always erase below to clear menu and/or bottom margin
338-
process.stdout.write(ansiEscapes.eraseDown);
366+
// Cursor is on row `lastCursorRow` (0-indexed, relative to prompt start).
367+
// Bottom separator is on row `lastContentRows` (one row past the content).
368+
// Move cursor to one row AFTER the separator, then erase below.
369+
const rowsDown = lastContentRows - lastCursorRow + 1; // +1 to go past the separator
370+
if (rowsDown > 0) {
371+
process.stdout.write(ansiEscapes.cursorDown(rowsDown));
372+
}
373+
process.stdout.write("\r" + ansiEscapes.eraseDown);
339374
lastMenuLines = 0;
340375
}
341376

@@ -372,6 +407,7 @@ export function createInputHandler(_session: ReplSession): InputHandler {
372407
tempLine = "";
373408
lastMenuLines = 0;
374409
lastCursorRow = 0;
410+
lastContentRows = 1;
375411
isFirstRender = true;
376412

377413
// Initial render
@@ -580,10 +616,8 @@ export function createInputHandler(_session: ReplSession): InputHandler {
580616
}
581617

582618
cleanup();
583-
// Print closing separator after submission
584-
const termWidth = process.stdout.columns || 80;
619+
// Bottom separator is already rendered live; just move past it
585620
console.log(); // New line after input
586-
console.log(chalk.dim("\u2500".repeat(termWidth)));
587621
const result = currentLine.trim();
588622
if (result) {
589623
sessionHistory.push(result);

0 commit comments

Comments
 (0)