diff --git a/.devcontainer.json b/.devcontainer.json index 4d87578..59fd391 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,3 +1,3 @@ { - "image": "mcr.microsoft.com/devcontainers/javascript-node" + "image": "devpit:latest" } \ No newline at end of file diff --git a/src/terminalService.js b/src/terminalService.js index bd53076..e33fa84 100644 --- a/src/terminalService.js +++ b/src/terminalService.js @@ -35,19 +35,24 @@ class TerminalService extends EventEmitter { }); this.ptyProcess.onData((data) => { - // Normalize line endings - const normalized = String(data).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const parts = normalized.split('\n'); - - // First part joins the existing partial - if (parts.length > 0) { - this.partial += parts[0]; - } - - // For each subsequent part before the last, we have completed lines - for (let i = 1; i < parts.length; i += 1) { - this.lines.push(this.partial); - this.partial = parts[i]; + const str = String(data); + for (let i = 0; i < str.length; i += 1) { + const ch = str[i]; + if (ch === '\\n') { + // Line feed completes the current line + this.lines.push(this.partial); + this.partial = ''; + } else if (ch === '\\r') { + // Carriage return: move to start of line; start overwriting + this.partial = ''; + } else if (ch === '\\b' || ch === '\\x7f') { + // Backspace or DEL: remove last char if present + if (this.partial.length > 0) { + this.partial = this.partial.slice(0, -1); + } + } else { + this.partial += ch; + } } // Enforce max lines buffer diff --git a/src/ui/InkApp.jsx b/src/ui/InkApp.jsx index 44c4c24..e7664ed 100644 --- a/src/ui/InkApp.jsx +++ b/src/ui/InkApp.jsx @@ -4,6 +4,26 @@ import TextInput from 'ink-text-input'; import terminalService from '../terminalService.js'; class Pane extends React.Component { + constructor(props) { + super(props); + this.state = { + cursorVisible: true, + }; + this._cursorTimer = null; + } + componentDidMount() { + if (this.props.showCursor) { + this._cursorTimer = setInterval(() => { + this.setState((s) => ({ cursorVisible: !s.cursorVisible })); + }, typeof this.props.cursorBlinkMs === 'number' && this.props.cursorBlinkMs > 0 ? this.props.cursorBlinkMs : 500); + } + } + componentWillUnmount() { + if (this._cursorTimer) { + clearInterval(this._cursorTimer); + this._cursorTimer = null; + } + } // Strip ANSI escape sequences so width measurement/truncation is accurate stripAnsi(input) { if (input == null) return ''; @@ -35,6 +55,26 @@ class Pane extends React.Component { } return out; } + // Apply a blinking cursor to the given line according to width constraints + withCursor(line, maxWidth) { + const cursorChar = typeof this.props.cursorChar === 'string' && this.props.cursorChar.length > 0 ? this.props.cursorChar[0] : '█'; + const visible = !!this.state.cursorVisible; + if (!visible) { + return line; + } + if (typeof maxWidth === 'number' && maxWidth > 0) { + if (line.length < maxWidth) { + return `${line}${cursorChar}`; + } + if (line.length === maxWidth) { + // Replace last char to avoid overflow + return `${line.slice(0, maxWidth - 1)}${cursorChar}`; + } + // If somehow longer, just ensure width + return `${line.slice(0, maxWidth - 1)}${cursorChar}`; + } + return `${line}${cursorChar}`; + } render() { const { title, lines, maxWidth } = this.props; return ( @@ -42,20 +82,17 @@ class Pane extends React.Component { {title} {(lines && lines.length > 0) - ? lines.map((line, index) => ( - { - (() => { - const clean = this.stripAnsi(line); - const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined; - // Expand tabs before slicing to visual width - const expanded = this.expandTabs(clean, 8, width); - if (width && expanded.length > width) { - return expanded.slice(0, width); - } - return expanded; - })() - } - )) + ? lines.map((line, index) => { + const isLast = index === lines.length - 1; + const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined; + const clean = this.stripAnsi(line); + const expanded = this.expandTabs(clean, 8, width); + const baseLine = (width && expanded.length > width) ? expanded.slice(0, width) : expanded; + const finalLine = (this.props.showCursor && isLast) ? this.withCursor(baseLine, width) : baseLine; + return ( + {finalLine} + ); + }) : } @@ -201,7 +238,7 @@ export default class InkApp extends React.Component { - +