fix(tui): mask secure extension input values in interactive mode

This commit is contained in:
Jeremy 2026-04-11 13:28:17 -05:00
parent bf4bcfadde
commit 2d531720f7
4 changed files with 21 additions and 5 deletions

View file

@ -11,6 +11,7 @@ import { keyHint } from "./keybinding-hints.js";
export interface ExtensionInputOptions {
tui?: TUI;
timeout?: number;
secure?: boolean;
}
export class ExtensionInputComponent extends Container implements Focusable {
@ -61,6 +62,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
}
this.input = new Input();
this.input.secure = opts?.secure === true;
if (placeholder) {
this.input.placeholder = placeholder;
}

View file

@ -1631,7 +1631,7 @@ export class InteractiveMode {
this.hideExtensionInput();
resolve(undefined);
},
{ tui: this.ui, timeout: opts?.timeout },
{ tui: this.ui, timeout: opts?.timeout, secure: opts?.secure },
);
this.editorContainer.clear();

View file

@ -32,4 +32,15 @@ describe("Input", () => {
input.focused = false;
assert.equal(input.focused, false);
});
it("secure mode obscures typed characters in render output", () => {
const input = new Input();
input.secure = true;
input.focused = true;
input.handleInput("secret123");
const line = input.render(40)[0] ?? "";
assert.ok(!line.includes("secret123"), "rendered line must not expose raw secret text");
assert.ok(line.includes("*********"), "rendered line should include masked characters");
});
});

View file

@ -21,6 +21,8 @@ export class Input implements Component, Focusable {
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
public placeholder: string = "";
/** When true, render obscured characters instead of the actual value. */
public secure: boolean = false;
/** Focusable interface - set by TUI when focus changes */
private _focused: boolean = false;
@ -446,6 +448,7 @@ export class Input implements Component, Focusable {
// Calculate visible window
const prompt = "> ";
const availableWidth = width - prompt.length;
const renderValue = this.secure ? "*".repeat(this.value.length) : this.value;
if (availableWidth <= 0) {
return [prompt];
@ -466,7 +469,7 @@ export class Input implements Component, Focusable {
if (this.value.length < availableWidth) {
// Everything fits (leave room for cursor at end)
visibleText = this.value;
visibleText = renderValue;
} else {
// Need horizontal scrolling
// Reserve one character for cursor if it's at the end
@ -501,17 +504,17 @@ export class Input implements Component, Focusable {
if (this.cursor < halfWidth) {
// Cursor near start
visibleText = this.value.slice(0, findValidEnd(scrollWidth));
visibleText = renderValue.slice(0, findValidEnd(scrollWidth));
cursorDisplay = this.cursor;
} else if (this.cursor > this.value.length - halfWidth) {
// Cursor near end
const start = findValidStart(this.value.length - scrollWidth);
visibleText = this.value.slice(start);
visibleText = renderValue.slice(start);
cursorDisplay = this.cursor - start;
} else {
// Cursor in middle
const start = findValidStart(this.cursor - halfWidth);
visibleText = this.value.slice(start, findValidEnd(start + scrollWidth));
visibleText = renderValue.slice(start, findValidEnd(start + scrollWidth));
cursorDisplay = halfWidth;
}
}