fix: banking shell screen rendering artifacts and transfer panic
Fix rendering issues where content from previous screens bled through when switching between views of different heights/widths: - Pad every line to full terminal width (ANSI-aware) so shorter lines overwrite leftover content from previous renders - Track terminal height via WindowSizeMsg and pad between content and footer to fill the screen - Send tea.ClearScreen on all screen transitions for height changes - Fix panic in transfer completion when routing number is < 4 chars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
@@ -520,7 +521,7 @@ func TestConfigString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScreenFrame(t *testing.T) {
|
||||
frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here")
|
||||
frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here", 0)
|
||||
if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") {
|
||||
t.Error("frame should contain bank name in header")
|
||||
}
|
||||
@@ -531,3 +532,29 @@ func TestScreenFrame(t *testing.T) {
|
||||
t.Error("frame should contain the content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenFramePadsLines(t *testing.T) {
|
||||
frame := screenFrame("TESTBANK", "TB-0001", "NE", "short\n", 0)
|
||||
for i, line := range strings.Split(frame, "\n") {
|
||||
w := lipgloss.Width(line)
|
||||
if w > 0 && w < termWidth {
|
||||
t.Errorf("line %d has visual width %d, want at least %d: %q", i, w, termWidth, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenFramePadsToHeight(t *testing.T) {
|
||||
short := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 30)
|
||||
lines := strings.Count(short, "\n")
|
||||
// Total newlines should be at least height-1 (since the last line has no trailing newline).
|
||||
if lines < 29 {
|
||||
t.Errorf("padded frame has %d newlines, want at least 29 for height=30", lines)
|
||||
}
|
||||
|
||||
// Without height, no padding.
|
||||
noPad := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 0)
|
||||
noPadLines := strings.Count(noPad, "\n")
|
||||
if noPadLines >= 29 {
|
||||
t.Errorf("unpadded frame has %d newlines, should be much less than 29", noPadLines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type model struct {
|
||||
state *bankState
|
||||
screen screen
|
||||
quitting bool
|
||||
height int
|
||||
|
||||
login loginModel
|
||||
menu menuModel
|
||||
@@ -76,8 +77,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.Type == tea.KeyCtrlC {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -130,7 +135,7 @@ func (m *model) View() string {
|
||||
content = m.admin.View()
|
||||
}
|
||||
|
||||
return screenFrame(m.bankName, m.terminalID, m.region, content)
|
||||
return screenFrame(m.bankName, m.terminalID, m.region, content, m.height)
|
||||
}
|
||||
|
||||
// --- Screen update handlers ---
|
||||
@@ -142,8 +147,8 @@ func (m *model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.login.stage == 2 {
|
||||
// Login always succeeds — this is a honeypot.
|
||||
logCmd := logAction(m.sess, fmt.Sprintf("LOGIN acct=%s", m.login.accountNum), "ACCESS GRANTED")
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, logCmd)
|
||||
clearCmd := m.goToMenu()
|
||||
return m, tea.Batch(cmd, logCmd, clearCmd)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -158,36 +163,36 @@ func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "1":
|
||||
m.screen = screenAccountSummary
|
||||
m.summary = newAccountSummaryModel(m.state.Accounts)
|
||||
return m, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY")
|
||||
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY"))
|
||||
case "2":
|
||||
m.screen = screenAccountDetail
|
||||
m.detail = newAccountDetailModel(m.state.Accounts, m.state.Transactions)
|
||||
return m, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL")
|
||||
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL"))
|
||||
case "3":
|
||||
m.screen = screenTransfer
|
||||
m.transfer = newTransferModel()
|
||||
return m, logAction(m.sess, "MENU 3", "WIRE TRANSFER")
|
||||
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 3", "WIRE TRANSFER"))
|
||||
case "4":
|
||||
m.screen = screenHistory
|
||||
m.history = newHistoryModel(m.state.Accounts, m.state.Transactions)
|
||||
return m, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY")
|
||||
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY"))
|
||||
case "5":
|
||||
m.screen = screenMessages
|
||||
m.messages = newMessagesModel(m.state.Messages)
|
||||
return m, logAction(m.sess, "MENU 5", "SECURE MESSAGES")
|
||||
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 5", "SECURE MESSAGES"))
|
||||
case "6":
|
||||
m.screen = screenChangePin
|
||||
m.pinInput = ""
|
||||
m.pinStage = 0
|
||||
m.pinMessage = ""
|
||||
return m, logAction(m.sess, "MENU 6", "CHANGE PIN")
|
||||
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 6", "CHANGE PIN"))
|
||||
case "7":
|
||||
m.quitting = true
|
||||
return m, tea.Batch(logAction(m.sess, "LOGOUT", "SESSION ENDED"), tea.Quit)
|
||||
case "99", "admin", "ADMIN":
|
||||
m.screen = screenAdmin
|
||||
m.admin = newAdminModel()
|
||||
return m, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN")
|
||||
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN"))
|
||||
}
|
||||
// Invalid choice, reset.
|
||||
m.menu.choice = ""
|
||||
@@ -197,7 +202,7 @@ func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *model) updateAccountSummary(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
m.goToMenu()
|
||||
return m, m.goToMenu()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -206,7 +211,7 @@ func (m *model) updateAccountDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.detail, cmd = m.detail.Update(msg)
|
||||
if m.detail.choice == "back" {
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -218,8 +223,14 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Transfer cancelled.
|
||||
if m.transfer.confirm == "cancelled" {
|
||||
m.goToMenu()
|
||||
return m, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED")
|
||||
clearCmd := m.goToMenu()
|
||||
return m, tea.Batch(clearCmd, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED"))
|
||||
}
|
||||
|
||||
// Clear screen when transfer steps change content height significantly
|
||||
// (e.g. confirm→authcode, fields→confirm, authcode→complete).
|
||||
if m.transfer.step != prevStep {
|
||||
cmd = tea.Batch(cmd, tea.ClearScreen)
|
||||
}
|
||||
|
||||
// Transfer completed — log it.
|
||||
@@ -234,7 +245,7 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Completed screen → any key goes back.
|
||||
if m.transfer.step == transferStepComplete {
|
||||
if _, ok := msg.(tea.KeyMsg); ok && prevStep == transferStepComplete {
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +256,7 @@ func (m *model) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.history, cmd = m.history.Update(msg)
|
||||
if m.history.choice == "back" {
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -254,8 +265,7 @@ func (m *model) updateMessages(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.messages, cmd = m.messages.Update(msg)
|
||||
if m.messages.choice == "back" {
|
||||
m.goToMenu()
|
||||
return m, cmd
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
// Log when viewing a message.
|
||||
if m.messages.viewing >= 0 {
|
||||
@@ -274,8 +284,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if m.pinStage == 3 {
|
||||
m.goToMenu()
|
||||
return m, nil
|
||||
return m, m.goToMenu()
|
||||
}
|
||||
|
||||
switch keyMsg.Type {
|
||||
@@ -302,8 +311,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.pinStage = 1
|
||||
}
|
||||
case tea.KeyEscape:
|
||||
m.goToMenu()
|
||||
return m, nil
|
||||
return m, m.goToMenu()
|
||||
case tea.KeyBackspace:
|
||||
if len(m.pinInput) > 0 {
|
||||
m.pinInput = m.pinInput[:len(m.pinInput)-1]
|
||||
@@ -367,8 +375,7 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Check for ESC before delegating.
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEscape && !m.admin.locked {
|
||||
m.goToMenu()
|
||||
return m, nil
|
||||
return m, m.goToMenu()
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
@@ -383,7 +390,7 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Log lockout.
|
||||
if m.admin.locked && !prevLocked {
|
||||
cmd = tea.Batch(cmd, logAction(m.sess,
|
||||
cmd = tea.Batch(cmd, tea.ClearScreen, logAction(m.sess,
|
||||
"ADMIN LOCKOUT",
|
||||
"TERMINAL LOCKED - INCIDENT LOGGED"))
|
||||
}
|
||||
@@ -391,14 +398,14 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// If locked and any key pressed, go back.
|
||||
if m.admin.locked {
|
||||
if _, ok := msg.(tea.KeyMsg); ok && prevLocked {
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *model) goToMenu() {
|
||||
func (m *model) goToMenu() tea.Cmd {
|
||||
unread := 0
|
||||
for _, msg := range m.state.Messages {
|
||||
if msg.Unread {
|
||||
@@ -407,6 +414,7 @@ func (m *model) goToMenu() {
|
||||
}
|
||||
m.screen = screenMenu
|
||||
m.menu = newMenuModel(m.bankName, unread)
|
||||
return tea.ClearScreen
|
||||
}
|
||||
|
||||
// logAction returns a tea.Cmd that logs an action to the session store.
|
||||
|
||||
@@ -165,8 +165,12 @@ func (m transferModel) View() string {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING"))
|
||||
b.WriteString("\n\n")
|
||||
routing := m.transfer.RoutingNumber
|
||||
if len(routing) > 4 {
|
||||
routing = routing[:4]
|
||||
}
|
||||
b.WriteString(baseStyle.Render(fmt.Sprintf(" CONFIRMATION #: WR-%s-%s",
|
||||
m.transfer.RoutingNumber[:4], "847291")))
|
||||
routing, "847291")))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT"))
|
||||
b.WriteString("\n")
|
||||
|
||||
@@ -100,11 +100,33 @@ func formatCurrency(cents int64) string {
|
||||
return fmt.Sprintf("$%s.%02d", ds, remainder)
|
||||
}
|
||||
|
||||
// padLine pads a single line (which may contain ANSI codes) to termWidth
|
||||
// using its visual width.
|
||||
func padLine(line string) string {
|
||||
w := lipgloss.Width(line)
|
||||
if w >= termWidth {
|
||||
return line
|
||||
}
|
||||
return line + strings.Repeat(" ", termWidth-w)
|
||||
}
|
||||
|
||||
// padLines pads every line in a multi-line string to termWidth so that
|
||||
// shorter lines fully overwrite previous content in the terminal.
|
||||
func padLines(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = padLine(line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// screenFrame wraps content in the persistent header and footer.
|
||||
func screenFrame(bankName, terminalID, region, content string) string {
|
||||
// The height parameter is used to pad the output to fill the terminal,
|
||||
// preventing leftover lines from previous renders bleeding through.
|
||||
func screenFrame(bankName, terminalID, region, content string, height int) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Header.
|
||||
// Header (4 lines).
|
||||
b.WriteString(divider())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM"))
|
||||
@@ -117,12 +139,29 @@ func screenFrame(bankName, terminalID, region, content string) string {
|
||||
// Content.
|
||||
b.WriteString(content)
|
||||
|
||||
// Footer.
|
||||
// Pad with blank lines between content and footer so the footer
|
||||
// stays at the bottom and the total output fills the terminal height.
|
||||
if height > 0 {
|
||||
const headerLines = 4
|
||||
const footerLines = 2
|
||||
// strings.Count gives newlines; add 1 for the line after the last \n.
|
||||
contentLines := strings.Count(content, "\n") + 1
|
||||
used := headerLines + contentLines + footerLines
|
||||
blankLine := strings.Repeat(" ", termWidth)
|
||||
for i := used; i < height; i++ {
|
||||
b.WriteString(blankLine)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Footer (2 lines).
|
||||
b.WriteString("\n")
|
||||
b.WriteString(divider())
|
||||
b.WriteString("\n")
|
||||
footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region)
|
||||
b.WriteString(dimStyle.Render(padRight(footer, termWidth)))
|
||||
|
||||
return b.String()
|
||||
// Pad every line to full terminal width so shorter lines overwrite
|
||||
// leftover content from previous renders.
|
||||
return padLines(b.String())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user