From 924db3dec80ce52f9f0c31f5793ba6f4e03bec53 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 29 Mar 2026 01:30:09 +1100 Subject: [PATCH 1/3] Limit sticky zone to terminal height with editor viewport scrolling (#134) --- src/terminal.ts | 64 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/src/terminal.ts b/src/terminal.ts index 240103f..bbf8c4f 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -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[] = []; @@ -306,27 +307,68 @@ export class Terminal { previewScreenLines = preview.screenLines; } - // Build editor lines + // Compute available rows for the editor (terminal height minus non-editor components) + const terminalRows = process.stdout.rows || 24; + const nonEditorRows = statusScreenLines + attachmentScreenLines + previewScreenLines + questionScreenLines; + const availableRows = Math.max(1, terminalRows - nonEditorRows); + + // 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; From 6e51b8992a34b5d01bd87ed4b80ef36c4c75c1d0 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 29 Mar 2026 01:35:14 +1100 Subject: [PATCH 2/3] Session log 2026-03-29: sticky zone viewport overflow (#134) --- .claude/CLAUDE.md | 4 ++-- .claude/sessions/2026-03-29.md | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f7f9e8c..98d6b2d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -72,8 +72,8 @@ Only update the `Status` field — do not modify any other frontmatter or prompt ## 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. diff --git a/.claude/sessions/2026-03-29.md b/.claude/sessions/2026-03-29.md index c876b3a..a48a3a8 100644 --- a/.claude/sessions/2026-03-29.md +++ b/.claude/sessions/2026-03-29.md @@ -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 From cc63c584bb2137b9f1a83ccc193753e583cdfd9d Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 29 Mar 2026 02:17:15 +1100 Subject: [PATCH 3/3] Reserve minimum editor space when sticky zone fills terminal --- src/terminal.ts | 83 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/src/terminal.ts b/src/terminal.ts index bbf8c4f..f251480 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -258,59 +258,90 @@ 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; } - // Compute available rows for the editor (terminal height minus non-editor components) - const terminalRows = process.stdout.rows || 24; - const nonEditorRows = statusScreenLines + attachmentScreenLines + previewScreenLines + questionScreenLines; - const availableRows = Math.max(1, terminalRows - nonEditorRows); + // 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[] = [];