Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Only update the `Status` field — do not modify any other frontmatter or prompt

<!-- BEGIN:REPO:current-state -->
## Current State
Branch: `fix/editor-cursor-wrapped-lines`
In-progress: Nothing. PR #144 (fix #135, cursor navigation in wrapped editor lines) open, auto-merge enabled.
Branch: `fix/sticky-zone-viewport-overflow`
In-progress: Nothing. PR #145 (fix #134, sticky zone viewport overflow) open, auto-merge enabled.
<!-- END:REPO:current-state -->

<!-- BEGIN:REPO:architecture -->
Expand Down
8 changes: 8 additions & 0 deletions .claude/sessions/2026-03-29.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@
- Decisions: No architectural decisions. Phase 1 (code changes, type-check, build, test) was completed by the previous session; this session handled commit, push, and PR.
- Next: Await PR #144 auto-merge.
- Violations: None

### 01:28 - fix/sticky-zone-viewport-overflow (#134) Stage 2

- Did: Staged and committed viewport scrolling changes to `src/terminal.ts`, pushed branch, created PR #145 with Closes #134, auto-merge enabled.
- Files: `src/terminal.ts`
- Decisions: Phase 1 (implementation, type-check, build, test, supervisor verification) was completed before this session. This session handled commit, push, and PR only.
- Next: Await PR #145 auto-merge.
- Violations: None
139 changes: 106 additions & 33 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class Terminal {
private stickyLineCount = 0;
private cursorLinesFromBottom = 0;
private cursorHidden = false;
private scrollOffset = 0;
private _paused = false;
private pauseBuffer: string[] = [];
private questionLines: string[] = [];
Expand Down Expand Up @@ -257,76 +258,148 @@ export class Terminal {

private buildSticky(): string {
const columns = process.stdout.columns || 80;
let output = '';
const terminalRows = process.stdout.rows || 24;

const attachmentLine = this.buildAttachmentLine(columns, this.commandMode.active);
const statusLine = this.buildStatusLine(columns, !attachmentLine);

// Build question lines first (instruction + options), then status at bottom
// Pre-build each non-editor component into discrete parts (no leading/trailing newlines).

const questionParts: string[] = [];
let questionScreenLines = 0;
let hasOutput = false;
for (const line of this.questionLines) {
if (hasOutput) {
output += '\n';
}
output += clearLine + line;
questionParts.push(clearLine + line);
questionScreenLines += Math.max(1, Math.ceil(stringWidth(line) / columns));
hasOutput = true;
}

let statusPart = '';
let statusScreenLines = 0;
if (statusLine) {
if (hasOutput) {
output += '\n';
}
output += clearLine + statusLine.line;
statusPart = clearLine + statusLine.line;
statusScreenLines = statusLine.screenLines;
hasOutput = true;
}

// Build attachment line
let attachmentPart = '';
let attachmentScreenLines = 0;
if (attachmentLine) {
if (hasOutput) {
output += '\n';
}
output += clearLine + attachmentLine.line;
attachmentPart = clearLine + attachmentLine.line;
attachmentScreenLines = attachmentLine.screenLines;
hasOutput = true;
}

// Build preview lines
const previewParts: string[] = [];
let previewScreenLines = 0;
const preview = this.buildPreviewLines(columns);
if (preview) {
for (const line of preview.lines) {
output += '\n';
output += clearLine + line;
previewParts.push(clearLine + line);
}
previewScreenLines = preview.screenLines;
}

// Build editor lines
// Budget allocation: reserve at least 1 row for the editor. Drop lowest-priority
// components (preview, then attachment, then question) if non-editor content alone
// would consume the entire terminal height.
let nonEditorRows = questionScreenLines + statusScreenLines + attachmentScreenLines + previewScreenLines;
const minEditorRows = 1;

if (nonEditorRows > terminalRows - minEditorRows) {
nonEditorRows -= previewScreenLines;
previewParts.length = 0;
previewScreenLines = 0;
}
if (nonEditorRows > terminalRows - minEditorRows) {
nonEditorRows -= attachmentScreenLines;
attachmentPart = '';
attachmentScreenLines = 0;
}
if (nonEditorRows > terminalRows - minEditorRows) {
nonEditorRows -= questionScreenLines;
questionParts.length = 0;
questionScreenLines = 0;
}

const availableRows = Math.max(minEditorRows, terminalRows - nonEditorRows);

// Assemble non-editor output. Preview always uses a leading newline (matches original behaviour).
const topParts = [...questionParts];
if (statusPart) {
topParts.push(statusPart);
}
if (attachmentPart) {
topParts.push(attachmentPart);
}

let output = '';
let hasOutput = false;
for (const part of topParts) {
if (hasOutput) {
output += '\n';
}
output += part;
hasOutput = true;
}
for (const part of previewParts) {
output += '\n';
output += part;
}

// Build a map from logical line index to its starting terminal row within the editor.
const lineStartRow: number[] = [];
let nextStartRow = 0;
for (let i = 0; i < this.editorContent.lines.length; i++) {
lineStartRow.push(nextStartRow);
nextStartRow += Math.max(1, Math.ceil(stringWidth(this.editorContent.lines[i]) / columns));
}

// Adjust scrollOffset so the cursor row stays within the visible window.
const cursorRow = this.editorContent.cursorRow;
if (cursorRow < this.scrollOffset) {
this.scrollOffset = cursorRow;
} else if (cursorRow >= this.scrollOffset + availableRows) {
this.scrollOffset = cursorRow - availableRows + 1;
}

// Cap scrollOffset so content is never scrolled past the end (no empty rows below content).
const maxScrollOffset = Math.max(0, nextStartRow - availableRows);
this.scrollOffset = Math.min(this.scrollOffset, maxScrollOffset);

// Snap scrollOffset backward to the nearest logical line boundary so we
// never start rendering mid-way through a wrapped logical line.
let snapped = 0;
for (let i = 0; i < lineStartRow.length; i++) {
if (lineStartRow[i] <= this.scrollOffset) {
snapped = lineStartRow[i];
} else {
break;
}
}
this.scrollOffset = snapped;

// Render logical lines whose start terminal row falls within the visible window.
let editorScreenLines = 0;
for (let i = 0; i < this.editorContent.lines.length; i++) {
output += '\n';
output += clearLine + this.editorContent.lines[i];
editorScreenLines += Math.max(1, Math.ceil(stringWidth(this.editorContent.lines[i]) / columns));
const start = lineStartRow[i];
const rows = Math.max(1, Math.ceil(stringWidth(this.editorContent.lines[i]) / columns));
if (start >= this.scrollOffset && start < this.scrollOffset + availableRows) {
output += '\n';
output += clearLine + this.editorContent.lines[i];
// Count how many of this line's terminal rows actually fit in the window.
const visibleRows = Math.min(rows, this.scrollOffset + availableRows - start);
editorScreenLines += visibleRows;
}
}

// Clear any leftover lines from previous render
output += clearDown;

// Position cursor within editor.
// cursorRow is a terminal row count (accounts for wrapping); editorScreenLines
// is the total terminal rows the editor occupies. Rows below cursor = the
// difference.
this.cursorLinesFromBottom = editorScreenLines - this.editorContent.cursorRow - 1;
// Position cursor within the visible editor window.
// cursorRow is the absolute terminal row within the full editor. Subtract
// scrollOffset to get the row within the rendered window.
const visibleCursorRow = cursorRow - this.scrollOffset;
this.cursorLinesFromBottom = editorScreenLines - visibleCursorRow - 1;
if (this.cursorLinesFromBottom > 0) {
output += cursorUp(this.cursorLinesFromBottom);
}
// cursorCol is the flat visual offset including prefix. Use modulo to get
// the column within the current terminal row when the line wraps.
output += cursorTo(this.editorContent.cursorCol % columns);
output += this.cursorHidden ? hideCursorSeq : showCursor;

Expand Down
Loading