diff --git a/internal/shell/banking/banking_test.go b/internal/shell/banking/banking_test.go index 1cd90d6..48878c5 100644 --- a/internal/shell/banking/banking_test.go +++ b/internal/shell/banking/banking_test.go @@ -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) + } +} diff --git a/internal/shell/banking/model.go b/internal/shell/banking/model.go index c45bb94..f9599a2 100644 --- a/internal/shell/banking/model.go +++ b/internal/shell/banking/model.go @@ -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. diff --git a/internal/shell/banking/screen_transfer.go b/internal/shell/banking/screen_transfer.go index 7be75c2..1449a79 100644 --- a/internal/shell/banking/screen_transfer.go +++ b/internal/shell/banking/screen_transfer.go @@ -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") diff --git a/internal/shell/banking/style.go b/internal/shell/banking/style.go index f516b6b..7847899 100644 --- a/internal/shell/banking/style.go +++ b/internal/shell/banking/style.go @@ -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()) }