codeep 2.16.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,10 @@
5
5
  import { Screen } from './Screen.js';
6
6
  import { Input, LineEditor } from './Input.js';
7
7
  import { fg, style } from './ansi.js';
8
+ import { getActionColor, formatActionTarget, getActionLabel } from './components/ActionFormatting.js';
8
9
  import { PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES } from './components/uiConstants.js';
9
10
  import { bottomPanelHeight, chatLayout, messageOffsets, scrollOffsetForTarget, scrollWindow, formatTokenCount, statusBarRightHint, activePanel, computeInputDisplay, agentProgressBar, truncateNotification, shouldShowPasteDialog, buildPasteInfo } from './layout.js';
11
+ import { buildAgentTimelineModel, formatElapsed, truncateMiddle, } from './components/AgentTimeline.js';
10
12
  import { parseCommandInput } from './inputParsing.js';
11
13
  import { formatWelcomeMessage } from './components/WelcomeFormatter.js';
12
14
  import { filterCommands, detectMentionQuery } from './components/Autocomplete.js';
@@ -14,6 +16,7 @@ import { suggestMentions } from '../utils/mentions.js';
14
16
  import { handleInlineStatusKey, handleInlineHelpKey, handleMenuKey, handleInlinePermissionKey, handleInlineSessionPickerKey, handleInlineConfirmKey, handleLoginKey, } from './handlers.js';
15
17
  import clipboardy from 'clipboardy';
16
18
  import { readImageFromClipboard } from '../utils/clipboard.js';
19
+ import { estimateResourceImpact, formatResourceImpact } from '../utils/resourceImpact.js';
17
20
  // (PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES, LOGO_HEIGHT moved to
18
21
  // ./components/uiConstants — imported above.)
19
22
  // ─── Command metadata ────────────────────────────────────────────────────────
@@ -57,6 +60,10 @@ export class App {
57
60
  agentThinking = '';
58
61
  agentWaitingForAI = false;
59
62
  agentLog = [];
63
+ /** Process uptime shown in the persistent footer. */
64
+ appStartedAt = Date.now();
65
+ /** Start of the current agent run; unlike app uptime, resets per task. */
66
+ agentStartedAt = null;
60
67
  // Paste detection state
61
68
  pasteInfo = null;
62
69
  pasteInfoOpen = false;
@@ -323,8 +330,11 @@ export class App {
323
330
  * Set agent running state
324
331
  */
325
332
  setAgentRunning(running) {
333
+ const wasRunning = this.isAgentRunning;
326
334
  this.isAgentRunning = running;
327
335
  if (running) {
336
+ if (!wasRunning)
337
+ this.agentStartedAt = Date.now();
328
338
  this.agentIteration = 0;
329
339
  this.agentMaxIterations = 0;
330
340
  this.agentActions = [];
@@ -335,6 +345,7 @@ export class App {
335
345
  this.startSpinner();
336
346
  }
337
347
  else {
348
+ this.agentStartedAt = null;
338
349
  this.isLoading = false; // Ensure loading is cleared when agent finishes
339
350
  this.stopSpinner();
340
351
  }
@@ -1440,6 +1451,10 @@ export class App {
1440
1451
  return;
1441
1452
  }
1442
1453
  this.screen.clear();
1454
+ if (this.shouldRenderAgentTimeline(width, height)) {
1455
+ this.renderAgentTimelineScreen(width, height);
1456
+ return;
1457
+ }
1443
1458
  // If menu or settings is open, reserve space for it at bottom
1444
1459
  const panelHeight = bottomPanelHeight({
1445
1460
  height,
@@ -1473,11 +1488,15 @@ export class App {
1473
1488
  });
1474
1489
  const layout = chatLayout(height, panelHeight);
1475
1490
  const mainHeight = layout.mainHeight;
1476
- const messagesStart = layout.messagesStart;
1491
+ const headerHeight = width >= 60 && height >= 16 ? 2 : 0;
1492
+ const messagesStart = Math.min(layout.messagesEnd, headerHeight);
1477
1493
  const messagesEnd = layout.messagesEnd;
1478
1494
  const separatorLine = layout.separatorLine;
1479
1495
  const inputLine = layout.inputLine;
1480
1496
  const statusLine = layout.statusLine;
1497
+ if (headerHeight > 0) {
1498
+ this.renderPersistentHeader(width);
1499
+ }
1481
1500
  // Messages
1482
1501
  const messagesHeight = Math.max(1, messagesEnd - messagesStart + 1);
1483
1502
  const messagesToRender = this.getVisibleMessages(messagesHeight, width - 2);
@@ -1565,6 +1584,278 @@ export class App {
1565
1584
  }
1566
1585
  this.screen.render();
1567
1586
  }
1587
+ shouldRenderAgentTimeline(width, height) {
1588
+ if (!this.isAgentRunning || width < 92 || height < 26)
1589
+ return false;
1590
+ return !(this.pasteInfoOpen ||
1591
+ this.permissionOpen ||
1592
+ this.sessionPickerOpen ||
1593
+ this.confirmOpen ||
1594
+ this.hunkPickerOpen ||
1595
+ this.statusOpen ||
1596
+ this.helpOpen ||
1597
+ this.settingsOpen ||
1598
+ this.searchOpen ||
1599
+ this.exportOpen ||
1600
+ this.logoutOpen ||
1601
+ this.loginOpen ||
1602
+ this.menuOpen ||
1603
+ this.showAutocomplete ||
1604
+ this.showMentionAutocomplete);
1605
+ }
1606
+ renderPersistentHeader(width) {
1607
+ const status = this.options.getStatus();
1608
+ const project = status.projectPath.split('/').filter(Boolean).pop() || status.projectPath || 'no project';
1609
+ const session = status.sessionId ? status.sessionId.slice(0, 8) : 'new';
1610
+ const branch = status.branch || '';
1611
+ this.screen.writeLine(0, '');
1612
+ let x = 1;
1613
+ const writeSegment = (label, value, valueStyle = fg.white) => {
1614
+ const separator = x > 1 ? ' │ ' : '';
1615
+ const required = separator.length + label.length + value.length;
1616
+ if (x + required >= width - 1)
1617
+ return false;
1618
+ if (separator) {
1619
+ this.screen.write(x, 0, separator, fg.gray);
1620
+ x += separator.length;
1621
+ }
1622
+ this.screen.write(x, 0, label, fg.gray);
1623
+ x += label.length;
1624
+ this.screen.write(x, 0, value, valueStyle);
1625
+ x += value.length;
1626
+ return true;
1627
+ };
1628
+ const wordmark = 'CODEEP';
1629
+ this.screen.write(x, 0, wordmark, PRIMARY_COLOR + style.bold);
1630
+ x += wordmark.length;
1631
+ writeSegment('', `v${status.version}`);
1632
+ if (width >= 86)
1633
+ writeSegment('session: ', session);
1634
+ writeSegment('model: ', truncateMiddle(status.model || 'unknown', 22));
1635
+ if (width >= 112)
1636
+ writeSegment('provider: ', truncateMiddle(status.provider, 14));
1637
+ if (width >= 128)
1638
+ writeSegment('project: ', truncateMiddle(project, 18));
1639
+ if (width >= 148 && branch) {
1640
+ // Budget the separator and label before truncating. Sizing the value off
1641
+ // `width - x` alone always overshoots writeSegment's fit guard, so a
1642
+ // branch long enough to need truncating used to render nothing at all.
1643
+ const branchBudget = width - x - ' │ '.length - 'branch: '.length - 2;
1644
+ if (branchBudget >= 10) {
1645
+ writeSegment('branch: ', truncateMiddle(branch, branchBudget), PRIMARY_COLOR);
1646
+ }
1647
+ }
1648
+ this.screen.horizontalLine(1, '─', PRIMARY_COLOR);
1649
+ }
1650
+ renderAgentTimelineScreen(width, height) {
1651
+ this.screen.clear();
1652
+ this.renderPersistentHeader(width);
1653
+ const inputY = height - 4;
1654
+ const hintsY = height - 3;
1655
+ const footerDividerY = height - 2;
1656
+ const statusY = height - 1;
1657
+ const workspaceTop = 2;
1658
+ const workspaceBottom = inputY - 2;
1659
+ const railWidth = width >= 132 ? Math.min(44, Math.floor(width * 0.28)) : 0;
1660
+ const dividerX = railWidth > 0 ? width - railWidth : width;
1661
+ const leftWidth = railWidth > 0 ? dividerX - 1 : width;
1662
+ const timeline = buildAgentTimelineModel({
1663
+ actions: this.agentActions,
1664
+ thinking: this.agentThinking,
1665
+ waitingForAI: this.agentWaitingForAI,
1666
+ iteration: this.agentIteration,
1667
+ maxIterations: this.agentMaxIterations,
1668
+ });
1669
+ const task = this.currentAgentTask();
1670
+ this.screen.writeLine(workspaceTop, '');
1671
+ this.screen.write(1, workspaceTop, 'YOU', PRIMARY_COLOR + style.bold);
1672
+ this.screen.write(7, workspaceTop, 'Task:', PRIMARY_COLOR + style.bold);
1673
+ this.screen.write(13, workspaceTop, truncateMiddle(task, Math.max(8, leftWidth - 15)), fg.white);
1674
+ this.screen.horizontalLine(workspaceTop + 1, '─', fg.gray);
1675
+ this.screen.writeLine(workspaceTop + 2, '');
1676
+ this.screen.write(1, workspaceTop + 2, 'AGENT', PRIMARY_COLOR + style.bold);
1677
+ const runLabel = this.agentWaitingForAI ? 'Choosing the next step' : 'Executing a tool';
1678
+ this.screen.write(9, workspaceTop + 2, runLabel, fg.white);
1679
+ const stepLabel = this.agentMaxIterations > 0
1680
+ ? `step ${this.agentIteration}/${this.agentMaxIterations}`
1681
+ : `step ${this.agentIteration}`;
1682
+ if (stepLabel.length + 2 < leftWidth) {
1683
+ this.screen.write(leftWidth - stepLabel.length - 1, workspaceTop + 2, stepLabel, fg.gray);
1684
+ }
1685
+ const expandedTimeline = workspaceBottom - workspaceTop >= 30;
1686
+ let y = workspaceTop + 4;
1687
+ if (expandedTimeline) {
1688
+ this.screen.write(4, y, 'PLAN (HIGH LEVEL)', PRIMARY_COLOR + style.bold);
1689
+ this.screen.write(22, y++, 'Inspect the relevant project context', fg.white);
1690
+ this.screen.write(22, y++, 'Apply focused changes with permission checks', fg.white);
1691
+ this.screen.write(22, y++, 'Run verification and summarize the result', fg.white);
1692
+ this.screen.write(4, y, '─'.repeat(Math.max(8, leftWidth - 6)), fg.gray);
1693
+ y += 2;
1694
+ }
1695
+ for (const stage of timeline.stages) {
1696
+ if (y + 1 > workspaceBottom)
1697
+ break;
1698
+ const marker = stage.status === 'done' ? '●' : stage.status === 'active' ? '◆' : '○';
1699
+ const markerStyle = this.timelineStatusStyle(stage.status);
1700
+ this.screen.write(1, y, marker, markerStyle);
1701
+ this.screen.write(2, y + 1, '│', stage.status === 'pending' ? fg.gray : markerStyle);
1702
+ if (expandedTimeline && y + 2 <= workspaceBottom) {
1703
+ this.screen.write(2, y + 2, '│', stage.status === 'pending' ? fg.gray : markerStyle);
1704
+ }
1705
+ this.screen.write(4, y, stage.id.padEnd(8), markerStyle + (stage.status === 'active' ? style.bold : ''));
1706
+ this.screen.write(13, y, truncateMiddle(stage.summary, Math.max(8, leftWidth - 24)), stage.status === 'pending' ? fg.gray : fg.white);
1707
+ const stateLabel = stage.status === 'done' ? 'done' : stage.status === 'active' ? 'active' : 'pending';
1708
+ if (leftWidth > 50) {
1709
+ this.screen.write(leftWidth - stateLabel.length - 1, y, stateLabel, markerStyle);
1710
+ }
1711
+ this.screen.write(13, y + 1, truncateMiddle(stage.detail, Math.max(8, leftWidth - 16)), fg.gray);
1712
+ y += expandedTimeline ? 3 : 2;
1713
+ if (stage.status === 'active' && y + 2 <= workspaceBottom) {
1714
+ if (timeline.currentTarget) {
1715
+ const actionType = this.currentActionType();
1716
+ const actionLabel = actionType ? getActionLabel(actionType) : 'Working';
1717
+ this.screen.write(13, y, `${actionLabel}:`, PRIMARY_COLOR);
1718
+ this.screen.write(13 + actionLabel.length + 2, y, formatActionTarget(timeline.currentTarget, Math.max(12, leftWidth - actionLabel.length - 18)), fg.white);
1719
+ y++;
1720
+ }
1721
+ if (this.agentMaxIterations > 0) {
1722
+ const barWidth = Math.max(8, Math.min(36, leftWidth - 28));
1723
+ const bar = agentProgressBar(this.agentIteration, this.agentMaxIterations, barWidth);
1724
+ const percent = `${Math.round(timeline.progress * 100)}%`;
1725
+ this.screen.write(13, y, percent.padStart(4), PRIMARY_COLOR);
1726
+ this.screen.write(19, y, bar, PRIMARY_COLOR);
1727
+ y++;
1728
+ }
1729
+ if (expandedTimeline && y + 3 <= workspaceBottom) {
1730
+ this.screen.write(13, y, 'RECENT ACTIVITY', PRIMARY_COLOR + style.bold);
1731
+ this.screen.write(29, y, '─'.repeat(Math.max(4, leftWidth - 31)), fg.gray);
1732
+ y++;
1733
+ const recentActivity = this.agentLog.slice(-3);
1734
+ if (recentActivity.length === 0) {
1735
+ this.screen.write(13, y++, 'Waiting for the first completed action', fg.gray);
1736
+ }
1737
+ else {
1738
+ for (const entry of recentActivity) {
1739
+ if (y > workspaceBottom)
1740
+ break;
1741
+ const isError = entry.startsWith('✗') || entry.startsWith('!');
1742
+ const isActive = entry.startsWith('◆');
1743
+ const entryStyle = isError ? fg.red : isActive ? PRIMARY_COLOR : fg.green;
1744
+ this.screen.write(13, y, entry.slice(0, 1), entryStyle + style.bold);
1745
+ this.screen.write(15, y++, truncateMiddle(entry.slice(2), Math.max(8, leftWidth - 17)), isActive ? fg.white : fg.gray);
1746
+ }
1747
+ }
1748
+ y++;
1749
+ }
1750
+ }
1751
+ }
1752
+ if (railWidth > 0) {
1753
+ this.renderAgentContextRail(dividerX, workspaceTop, workspaceBottom, railWidth, timeline);
1754
+ }
1755
+ this.screen.horizontalLine(inputY - 1, '─', PRIMARY_COLOR);
1756
+ this.renderInput(inputY, width, false);
1757
+ this.renderAgentKeyHints(hintsY, width);
1758
+ this.screen.horizontalLine(footerDividerY, '─', fg.gray);
1759
+ this.renderStatusBar(statusY, width);
1760
+ this.screen.render();
1761
+ }
1762
+ renderAgentContextRail(dividerX, top, bottom, railWidth, timeline) {
1763
+ for (let y = top; y <= bottom; y++) {
1764
+ this.screen.write(dividerX, y, '│', PRIMARY_COLOR);
1765
+ }
1766
+ const x = dividerX + 2;
1767
+ const contentWidth = Math.max(8, railWidth - 3);
1768
+ let y = top + 1;
1769
+ this.screen.write(x, y++, `CURRENT: ${timeline.currentStage}`, PRIMARY_COLOR + style.bold);
1770
+ y++;
1771
+ this.screen.write(x, y++, `FILES (${timeline.files.length})`, PRIMARY_COLOR + style.bold);
1772
+ if (timeline.files.length === 0) {
1773
+ this.screen.write(x, y++, 'No file changes yet', fg.gray);
1774
+ }
1775
+ else {
1776
+ for (const file of timeline.files.slice(-6)) {
1777
+ if (y > bottom - 7)
1778
+ break;
1779
+ const marker = file.type === 'delete' ? 'D' : file.type === 'write' ? 'A' : 'M';
1780
+ const color = file.result === 'error' ? fg.red : getActionColor(file.type);
1781
+ this.screen.write(x, y, marker, color + style.bold);
1782
+ this.screen.write(x + 2, y++, formatActionTarget(file.target, contentWidth - 2), file.result === 'error' ? fg.red : fg.white);
1783
+ }
1784
+ }
1785
+ y++;
1786
+ if (y <= bottom - 5) {
1787
+ this.screen.write(x, y++, 'CHECKS', PRIMARY_COLOR + style.bold);
1788
+ if (timeline.checks.length === 0) {
1789
+ const activeCheck = timeline.currentStage === 'VERIFY' && timeline.currentTarget
1790
+ ? formatActionTarget(timeline.currentTarget, contentWidth)
1791
+ : 'Pending';
1792
+ this.screen.write(x, y++, activeCheck, timeline.currentStage === 'VERIFY' ? fg.yellow : fg.gray);
1793
+ }
1794
+ else {
1795
+ for (const check of timeline.checks.slice(-3)) {
1796
+ if (y > bottom - 3)
1797
+ break;
1798
+ const symbol = check.result === 'success' ? '✓' : '!';
1799
+ const color = check.result === 'success' ? fg.green : fg.red;
1800
+ this.screen.write(x, y, symbol, color + style.bold);
1801
+ this.screen.write(x + 2, y++, formatActionTarget(check.target, contentWidth - 2), fg.white);
1802
+ }
1803
+ }
1804
+ }
1805
+ const status = this.options.getStatus();
1806
+ const contextY = Math.max(y + 1, bottom - 3);
1807
+ if (contextY <= bottom) {
1808
+ this.screen.write(x, contextY, 'CONTEXT', PRIMARY_COLOR + style.bold);
1809
+ if (contextY + 1 <= bottom) {
1810
+ const project = status.projectPath.split('/').filter(Boolean).pop() || status.projectPath;
1811
+ this.screen.write(x, contextY + 1, truncateMiddle(project, contentWidth), fg.white);
1812
+ }
1813
+ if (contextY + 2 <= bottom) {
1814
+ const branch = status.branch ? `branch ${status.branch}` : `${status.provider} · ${status.model}`;
1815
+ this.screen.write(x, contextY + 2, truncateMiddle(branch, contentWidth), fg.gray);
1816
+ }
1817
+ }
1818
+ }
1819
+ renderAgentKeyHints(y, width) {
1820
+ this.screen.writeLine(y, '');
1821
+ const left = 'Keys: Enter reply · / commands · Esc stop · ↑↓ history';
1822
+ const runStartedAt = this.agentStartedAt ?? Date.now();
1823
+ const right = `agent: working · ${formatElapsed(Date.now() - runStartedAt)}`;
1824
+ this.screen.write(1, y, truncateMiddle(left, Math.max(12, width - right.length - 4)), fg.gray);
1825
+ if (right.length + 2 < width) {
1826
+ this.screen.write(width - right.length - 1, y, right, fg.gray);
1827
+ }
1828
+ }
1829
+ timelineStatusStyle(status) {
1830
+ if (status === 'done')
1831
+ return fg.green;
1832
+ if (status === 'active')
1833
+ return PRIMARY_COLOR;
1834
+ return fg.gray;
1835
+ }
1836
+ currentActionType() {
1837
+ const separator = this.agentThinking.indexOf(':');
1838
+ if (separator < 0)
1839
+ return '';
1840
+ const type = this.agentThinking.slice(0, separator).trim().toLowerCase();
1841
+ return ['read', 'search', 'list', 'fetch', 'write', 'edit', 'delete', 'mkdir', 'command'].includes(type)
1842
+ ? type
1843
+ : '';
1844
+ }
1845
+ currentAgentTask() {
1846
+ for (let index = this.messages.length - 1; index >= 0; index--) {
1847
+ const message = this.messages[index];
1848
+ if (message.role !== 'user')
1849
+ continue;
1850
+ const task = message.content
1851
+ .replace(/^\[DRY RUN]\s*/i, '')
1852
+ .replace(/^\[AGENT]\s*/i, '')
1853
+ .trim();
1854
+ if (task)
1855
+ return task;
1856
+ }
1857
+ return 'Autonomous coding task';
1858
+ }
1568
1859
  /**
1569
1860
  * Render inline confirmation dialog below status bar
1570
1861
  */
@@ -1717,12 +2008,12 @@ export class App {
1717
2008
  this.screen.showCursor(false);
1718
2009
  return;
1719
2010
  }
1720
- // Agent running state - show special prompt with gradient
2011
+ // Keep the composer available while the agent works so the user can steer
2012
+ // the run without losing context or waiting for the current tool to finish.
1721
2013
  if (this.isAgentRunning) {
2014
+ const promptSymbol = '❯ ';
2015
+ const maxInputWidth = Math.max(1, width - promptSymbol.length - 1);
1722
2016
  if (inputValue) {
1723
- // User is typing a reply — show their input with a prompt
1724
- const promptSymbol = '❯ ';
1725
- const maxInputWidth = width - promptSymbol.length - 1;
1726
2017
  const displayInput = inputValue.length <= maxInputWidth ? inputValue : '…' + inputValue.slice(-(maxInputWidth - 1));
1727
2018
  this.screen.write(0, y, promptSymbol, PRIMARY_COLOR);
1728
2019
  this.screen.write(promptSymbol.length, y, displayInput + ' ');
@@ -1733,13 +2024,12 @@ export class App {
1733
2024
  }
1734
2025
  }
1735
2026
  else {
1736
- const spinner = SPINNER_FRAMES[this.spinnerFrame];
1737
- const stepLabel = this.agentMaxIterations > 0
1738
- ? `step ${this.agentIteration}/${this.agentMaxIterations}`
1739
- : `step ${this.agentIteration}`;
1740
- const agentText = `${spinner} Agent working... ${stepLabel} | ${this.agentActions.length} actions (Esc · or type to reply)`;
1741
- this.screen.write(0, y, PRIMARY_COLOR + style.bold + agentText + style.reset);
1742
- this.screen.showCursor(false);
2027
+ this.screen.write(0, y, promptSymbol, PRIMARY_COLOR + style.bold);
2028
+ this.screen.write(promptSymbol.length, y, 'Reply to agent…', fg.gray);
2029
+ if (!hideCursor) {
2030
+ this.screen.setCursor(promptSymbol.length, y);
2031
+ this.screen.showCursor(true);
2032
+ }
1743
2033
  }
1744
2034
  return;
1745
2035
  }
@@ -2326,7 +2616,64 @@ export class App {
2326
2616
  }
2327
2617
  const status = this.options.getStatus();
2328
2618
  const stats = status.tokenStats;
2329
- // Left: model (gradient) · msg count · token count
2619
+ const rightText = statusBarRightHint({
2620
+ scrollOffset: this.scrollOffset,
2621
+ unseenWhileScrolled: this.unseenWhileScrolled,
2622
+ isStreaming: this.isStreaming,
2623
+ isLoading: this.isLoading,
2624
+ });
2625
+ if (this.scrollOffset > 0 && this.unseenWhileScrolled > 0) {
2626
+ this.screen.write(width - rightText.length, y, rightText, PRIMARY_COLOR);
2627
+ return;
2628
+ }
2629
+ // Wide terminals get an operational footer: elapsed time and honest,
2630
+ // explicitly-labelled resource ranges. These are estimates, never implied
2631
+ // to be provider measurements.
2632
+ if (width >= 110 && stats) {
2633
+ const totalTokens = Math.max(0, stats.totalTokens);
2634
+ const elapsed = formatElapsed(Date.now() - this.appStartedAt);
2635
+ const leftParts = [
2636
+ `runtime ${elapsed}`,
2637
+ `tokens ${formatTokenCount(totalTokens)}`,
2638
+ ];
2639
+ // Only pay-per-use tokens carry a real price; flat-fee providers get the
2640
+ // short "in plan" wording so the segment can't crowd the right-edge hint.
2641
+ const billable = typeof stats.billableCost === 'number' ? stats.billableCost : (stats.estimatedCost ?? 0);
2642
+ if (billable > 0) {
2643
+ leftParts.push(`cost $${billable < 0.01 ? billable.toFixed(4) : billable.toFixed(2)}${stats.hasFlatFeeUsage ? ' + in plan' : ''}`);
2644
+ }
2645
+ else if (stats.hasFlatFeeUsage) {
2646
+ leftParts.push('cost in plan');
2647
+ }
2648
+ // Thinking-effort tier, same chip the compact fallback shows beside the
2649
+ // model. Only present when set + supported — see getStatus.
2650
+ if (status.reasoningEffort) {
2651
+ leftParts.push(`effort ${status.reasoningEffort}`);
2652
+ }
2653
+ const leftText = leftParts.join(' · ');
2654
+ this.screen.write(1, y, leftText, fg.gray);
2655
+ // The right edge belongs to the hint: while streaming it reads
2656
+ // "Esc to stop", the only on-screen affordance for interrupting a run.
2657
+ // Claim it first, then spend whatever gap is left on the (decorative)
2658
+ // resource estimate — never the other way around.
2659
+ // Same right-edge column as the compact fallback and the scroll badge, so
2660
+ // the hint doesn't shift by one when the terminal crosses 110 columns.
2661
+ let rightEdge = width;
2662
+ if (rightText && width - rightText.length > leftText.length + 3) {
2663
+ rightEdge = width - rightText.length;
2664
+ this.screen.write(rightEdge, y, rightText, fg.gray);
2665
+ }
2666
+ if (totalTokens > 0 && width >= 138) {
2667
+ const impact = formatResourceImpact(estimateResourceImpact(totalTokens));
2668
+ const impactText = `energy ${impact.energy} est · water ${impact.water} est`;
2669
+ const impactX = rightEdge - impactText.length - 3;
2670
+ if (impactX > leftText.length + 3) {
2671
+ this.screen.write(impactX, y, impactText, fg.gray);
2672
+ }
2673
+ }
2674
+ return;
2675
+ }
2676
+ // Compact fallback: model · messages · token count.
2330
2677
  const modelName = status.model || '';
2331
2678
  const msgCount = `${this.messages.length} msg`;
2332
2679
  const tokenStr = stats && stats.totalTokens > 0
@@ -2353,19 +2700,6 @@ export class App {
2353
2700
  this.screen.write(leftX, y, ' · ', fg.gray);
2354
2701
  this.screen.write(leftX + 3, y, tokenStr, fg.gray);
2355
2702
  }
2356
- // Right: context-sensitive hints. While scrolled up, the "new
2357
- // messages below" badge takes priority — it's the only signal that
2358
- // the conversation moved on (addMessage no longer yanks the view).
2359
- const rightText = statusBarRightHint({
2360
- scrollOffset: this.scrollOffset,
2361
- unseenWhileScrolled: this.unseenWhileScrolled,
2362
- isStreaming: this.isStreaming,
2363
- isLoading: this.isLoading,
2364
- });
2365
- if (this.scrollOffset > 0 && this.unseenWhileScrolled > 0) {
2366
- this.screen.write(width - rightText.length, y, rightText, PRIMARY_COLOR);
2367
- return;
2368
- }
2369
2703
  this.screen.write(width - rightText.length, y, rightText, fg.gray);
2370
2704
  }
2371
2705
  /**
@@ -2374,20 +2708,6 @@ export class App {
2374
2708
  getVisibleMessages(height, width) {
2375
2709
  const allLines = [];
2376
2710
  this.codeBlockCounter.current = 0; // Reset block counter for each render pass
2377
- // Logo at the top, scrolls with content
2378
- if (height >= 20) {
2379
- const logoWidth = LOGO_LINES[0].length;
2380
- const logoX = Math.max(0, Math.floor((width - logoWidth) / 2));
2381
- const pad = ' '.repeat(logoX);
2382
- for (const line of LOGO_LINES) {
2383
- allLines.push({ text: pad + line, style: PRIMARY_COLOR, raw: false });
2384
- }
2385
- allLines.push({ text: '', style: '' });
2386
- }
2387
- else {
2388
- allLines.push({ text: ' Codeep', style: PRIMARY_COLOR, raw: false });
2389
- allLines.push({ text: '', style: '' });
2390
- }
2391
2711
  for (let i = 0; i < this.messages.length; i++) {
2392
2712
  const msg = this.messages[i];
2393
2713
  const cached = this.messageCache[i];
@@ -15,6 +15,7 @@ export declare class Screen {
15
15
  private cursorY;
16
16
  private cursorVisible;
17
17
  private resizeCallback;
18
+ private readonly resizeHandler;
18
19
  constructor();
19
20
  /**
20
21
  * Register a callback to be called on terminal resize
@@ -12,13 +12,15 @@ export class Screen {
12
12
  cursorY = 0;
13
13
  cursorVisible = true;
14
14
  resizeCallback = null;
15
+ resizeHandler;
15
16
  constructor() {
16
17
  this.width = process.stdout.columns || 80;
17
18
  this.height = process.stdout.rows || 24;
18
19
  this.buffer = this.createEmptyBuffer();
19
20
  this.rendered = this.createEmptyBuffer();
20
- // Handle resize
21
- process.stdout.on('resize', () => {
21
+ // Keep the exact handler so cleanup can detach it. This matters for
22
+ // embedders/tests that create more than one Screen in the same process.
23
+ this.resizeHandler = () => {
22
24
  this.width = process.stdout.columns || 80;
23
25
  this.height = process.stdout.rows || 24;
24
26
  this.buffer = this.createEmptyBuffer();
@@ -28,7 +30,8 @@ export class Screen {
28
30
  if (this.resizeCallback) {
29
31
  this.resizeCallback();
30
32
  }
31
- });
33
+ };
34
+ process.stdout.on('resize', this.resizeHandler);
32
35
  }
33
36
  /**
34
37
  * Register a callback to be called on terminal resize
@@ -318,6 +321,8 @@ export class Screen {
318
321
  * Cleanup (show cursor, clear)
319
322
  */
320
323
  cleanup() {
324
+ process.stdout.removeListener('resize', this.resizeHandler);
325
+ this.resizeCallback = null;
321
326
  process.stdout.write(style.reset + screen.clear + cursor.home + cursor.show);
322
327
  }
323
328
  }
@@ -77,6 +77,8 @@ export interface StatsTotals {
77
77
  totalTokens: number;
78
78
  totalPromptTokens: number;
79
79
  totalCompletionTokens: number;
80
+ /** Raw sum across every entry. The report's total is derived from the
81
+ * breakdown instead, so flat-fee rows don't contribute dollars. */
80
82
  estimatedCost: number;
81
83
  }
82
84
  export interface StatsCache {
@@ -104,6 +106,7 @@ export declare function formatStatsReport(args: {
104
106
  pricing: PricingRow[];
105
107
  currentProvider: string;
106
108
  fmt: TokenFormatter;
109
+ impactLines?: string[];
107
110
  }): string;
108
111
  /**
109
112
  * Extract every fenced code block body (the text inside ```…```) from a
@@ -7,6 +7,7 @@
7
7
  * alongside `ctx.app.*` calls. Pulling them here gives them direct unit
8
8
  * coverage.
9
9
  */
10
+ import { isFlatFeeProvider } from '../../config/providers.js';
10
11
  /** Snippet window: chars of context before / after the match. */
11
12
  export const SEARCH_SNIPPET_BEFORE = 30;
12
13
  export const SEARCH_SNIPPET_AFTER = 50;
@@ -121,6 +122,10 @@ export function formatMemoryList(notes) {
121
122
  export function formatModelCost(provider, estimatedCost) {
122
123
  if (provider === 'ollama')
123
124
  return 'free';
125
+ // Flat-fee providers meter real tokens but charge nothing per token — the
126
+ // dollar figure our pricing table computes for them is invented.
127
+ if (isFlatFeeProvider(provider))
128
+ return 'included in plan';
124
129
  return estimatedCost > 0 ? `~$${estimatedCost.toFixed(4)}` : '(no pricing data)';
125
130
  }
126
131
  /**
@@ -128,7 +133,7 @@ export function formatModelCost(provider, estimatedCost) {
128
133
  * whether the total shows "free" (ollama) or a dollar figure.
129
134
  */
130
135
  export function formatStatsReport(args) {
131
- const { totals, breakdown, cache, pricing, currentProvider, fmt } = args;
136
+ const { totals, breakdown, cache, pricing, currentProvider, fmt, impactLines = [] } = args;
132
137
  const lines = ['## Session Cost', ''];
133
138
  if (totals.requestCount === 0) {
134
139
  lines.push('*No API calls made yet this session.*', '');
@@ -143,11 +148,20 @@ export function formatStatsReport(args) {
143
148
  lines.push(`- **${b.model}** (${b.provider}): ${fmt(b.promptTokens)} in / ${fmt(b.completionTokens)} out — ${costStr}`);
144
149
  }
145
150
  lines.push('');
151
+ // The total prices only pay-per-use rows; flat-fee rows are called out
152
+ // rather than folded in (or quietly dropped) — see formatModelCost.
153
+ const planRows = breakdown.filter(b => isFlatFeeProvider(b.provider));
154
+ const billable = breakdown
155
+ .filter(b => !isFlatFeeProvider(b.provider))
156
+ .reduce((s, b) => s + b.estimatedCost, 0);
146
157
  if (currentProvider === 'ollama') {
147
158
  lines.push(`**Total: free · ${fmt(totals.totalTokens)} tokens**`);
148
159
  }
149
- else if (totals.estimatedCost > 0) {
150
- lines.push(`**Total: ~$${totals.estimatedCost.toFixed(4)}**`);
160
+ else if (planRows.length === breakdown.length) {
161
+ lines.push(`**Total: included in plan · ${fmt(totals.totalTokens)} tokens**`);
162
+ }
163
+ else if (billable > 0) {
164
+ lines.push(`**Total: ~$${billable.toFixed(4)}${planRows.length > 0 ? ' + usage included in plan' : ''}**`);
151
165
  }
152
166
  }
153
167
  if (cache.cacheReadTokens > 0 || cache.cacheCreationTokens > 0) {
@@ -160,6 +174,9 @@ export function formatStatsReport(args) {
160
174
  lines.push(`Estimated savings vs no caching: $${cache.estimatedSavingsUsd.toFixed(4)}`);
161
175
  }
162
176
  }
177
+ if (impactLines.length > 0) {
178
+ lines.push('', ...impactLines);
179
+ }
163
180
  lines.push('');
164
181
  }
165
182
  lines.push('### Pricing (per 1M tokens)');