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
93 changes: 72 additions & 21 deletions src/codegen/cli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import sys
import termios
import tty
import select
from datetime import datetime
from typing import Any

Expand Down Expand Up @@ -179,37 +180,65 @@ def _display_header(self):
print()

def _display_agent_list(self):
"""Display the list of agent runs."""
"""Display the list of agent runs, fixed to 10 lines of main content."""
if not self.agent_runs:
print("No agent runs found.")
self._pad_to_lines(1)
return

for i, agent_run in enumerate(self.agent_runs):
# Determine how many extra lines the inline action menu will print (if open)
menu_lines = 0
if self.show_action_menu and 0 <= self.selected_index < len(self.agent_runs):
selected_run = self.agent_runs[self.selected_index]
github_prs = selected_run.get("github_pull_requests", [])
options_count = 1 # "open in web"
if github_prs:
options_count += 1 # "pull locally"
if github_prs and github_prs[0].get("url"):
options_count += 1 # "open PR"
menu_lines = options_count + 1 # +1 for the hint line

# We want total printed lines (rows + menu) to be 10
window_size = max(1, 10 - menu_lines)

total = len(self.agent_runs)
if total <= window_size:
start = 0
end = total
else:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic bug: Off-by-one highlighting when list is scrolled
prefix compares i against self.selected_index, but i is the sliced-window index while self.selected_index is absolute, so the selected row loses its arrow when start > 0.

Suggested change
else:
is_selected = (i == self.selected_index)
# After slicing, compare against absolute index
# Alternatively compute: is_selected = (i == self.selected_index)
# Fix by using the absolute index when iterating
for idx in range(start, end):
agent_run = self.agent_runs[idx]
is_selected = (idx == self.selected_index) and not self.show_action_menu
prefix = "\u2192 " if is_selected else " "
...

start = max(0, min(self.selected_index - window_size // 2, total - window_size))
end = start + window_size

printed_rows = 0
for i in range(start, end):
agent_run = self.agent_runs[i]
# Highlight selected item
prefix = "→ " if i == self.selected_index and not self.show_action_menu else " "

status = self._format_status(agent_run.get("status", "Unknown"), agent_run)
created = self._format_date(agent_run.get("created_at", "Unknown"))
summary = agent_run.get("summary", "No summary") or "No summary"

# No need to truncate summary as much since we removed the URL column
if len(summary) > 60:
summary = summary[:57] + "..."

# Color coding: indigo blue for selected, darker gray for others (but keep status colors)
if i == self.selected_index and not self.show_action_menu:
# Blue timestamp and summary for selected row, but preserve status colors
line = f"\033[34m{prefix}{created:<10}\033[0m {status} \033[34m{summary}\033[0m"
else:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic bug: Padding logic pads too many lines when list is empty
_pad_to_lines is called with total_printed instead of printed_rows, causing over-padding when menu_lines > 0 or under some conditions.

Suggested change
else:
# Pass the number of already printed content rows, not the total including menu
self._pad_to_lines(printed_rows + menu_lines)

# Gray text for non-selected rows, but preserve status colors
line = f"\033[90m{prefix}{created:<10}\033[0m {status} \033[90m{summary}\033[0m"

print(line)
printed_rows += 1

# Show action menu right below the selected row if it's expanded
if i == self.selected_index and self.show_action_menu:
self._display_inline_action_menu(agent_run)

# If fewer than needed to reach 10 lines, pad blank lines
total_printed = printed_rows + menu_lines
if total_printed < 10:
self._pad_to_lines(total_printed)

def _display_new_tab(self):
"""Display the new agent creation interface."""
print("Create new background agent (Claude Code):")
Expand Down Expand Up @@ -249,6 +278,9 @@ def _display_new_tab(self):
print(border_style + "└" + "─" * (box_width - 2) + "┘" + reset)
print()

# The new tab main content area should be a fixed 10 lines
self._pad_to_lines(6)

def _create_background_agent(self, prompt: str):
"""Create a background agent run."""
if not self.token or not self.org_id:
Expand Down Expand Up @@ -298,33 +330,36 @@ def _create_background_agent(self, prompt: str):

def _show_post_creation_menu(self, web_url: str):
"""Show menu after successful agent creation."""
print("\nWhat would you like to do next?")
print()
from codegen.cli.utils.inplace_print import inplace_print

print("\nWhat would you like to do next?")
options = ["open in web preview", "go to recents"]
selected = 0
prev_lines = 0

while True:
# Clear previous menu display and move cursor up
for i in range(len(options) + 2):
print("\033[K") # Clear line
print(f"\033[{len(options) + 2}A", end="") # Move cursor up

def build_lines():
menu_lines = []
# Options
for i, option in enumerate(options):
if i == selected:
print(f" \033[34m→ {option}\033[0m")
menu_lines.append(f" \033[34m→ {option}\033[0m")
else:
print(f" \033[90m {option}\033[0m")
menu_lines.append(f" \033[90m {option}\033[0m")
# Hint line last
menu_lines.append("\033[90m[Enter] select • [↑↓] navigate • [Esc] back to new tab\033[0m")
return menu_lines

print("\n\033[90m[Enter] select • [↑↓] navigate • [Esc] back to new tab\033[0m")
# Initial render
prev_lines = inplace_print(build_lines(), prev_lines)

# Get input
while True:
key = self._get_char()

if key == "\x1b[A" or key.lower() == "w": # Up arrow or W
selected = max(0, selected - 1)
selected = (selected - 1) % len(options)
prev_lines = inplace_print(build_lines(), prev_lines)
elif key == "\x1b[B" or key.lower() == "s": # Down arrow or S
selected = min(len(options) - 1, selected + 1)
selected = (selected + 1) % len(options)
prev_lines = inplace_print(build_lines(), prev_lines)
elif key == "\r" or key == "\n": # Enter - select option
if selected == 0: # open in web preview
try:
Expand All @@ -340,6 +375,8 @@ def _show_post_creation_menu(self, web_url: str):
self._load_agent_runs() # Refresh the data
break
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic bug: Selecting "go to recents" doesn’t switch to the recents tab
Currently it only refreshes the data. It should navigate to the recents tab and exit input mode.

Suggested change
break
self.current_tab = 0 # switch to recents
self.input_mode = False
self._load_agent_runs() # Refresh the data
break

elif key == "\x1b": # Esc - back to new tab
self.current_tab = 1 # 'new' tab index
self.input_mode = True
break

def _display_web_tab(self):
Expand All @@ -353,6 +390,8 @@ def _display_web_tab(self):
print(f" \033[34m→ Open Web ({display_url})\033[0m")
print()
print("Press Enter to open the web interface in your browser.")
# The web tab main content area should be a fixed 10 lines
self._pad_to_lines(5)

def _pull_agent_branch(self, agent_id: str):
"""Pull the PR branch for an agent run locally."""
Expand Down Expand Up @@ -386,6 +425,11 @@ def _display_content(self):
elif self.current_tab == 2: # web
self._display_web_tab()

def _pad_to_lines(self, lines_printed: int, target: int = 10):
"""Pad the main content area with blank lines to reach a fixed height."""
for _ in range(max(0, target - lines_printed)):
print()

def _display_inline_action_menu(self, agent_run: dict):
"""Display action menu inline below the selected row."""
agent_id = agent_run.get("id", "unknown")
Expand Down Expand Up @@ -432,8 +476,15 @@ def _get_char(self):

# Handle escape sequences (arrow keys)
if ch == "\x1b": # ESC
# Peek for additional bytes to distinguish bare ESC vs sequences
ready, _, _ = select.select([sys.stdin], [], [], 0.03)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic bug: Incomplete escape sequence returns "ESC[" which no handler recognizes
Treat incomplete sequences as a bare ESC to avoid emitting a partial key that downstream logic won't match.

Suggested change
ready, _, _ = select.select([sys.stdin], [], [], 0.03)
if not ready2:
return "\x1b" # treat as bare Esc on incomplete sequence

if not ready:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustness: _get_char swallows unexpected ESC sequences
Currently any ESC followed by a non-[ starts the read of ch3 only if [, otherwise it falls through without returning. Ensure a return path for other ESC-prefixed sequences.

Suggested change
if not ready:
else:
# Unknown ESC sequence, treat as bare ESC
return "\x1b"

return "\x1b" # bare Esc
ch2 = sys.stdin.read(1)
if ch2 == "[":
ready2, _, _ = select.select([sys.stdin], [], [], 0.03)
if not ready2:
return "\x1b["
ch3 = sys.stdin.read(1)
return f"\x1b[{ch3}"
else:
Expand Down
27 changes: 27 additions & 0 deletions src/codegen/cli/utils/inplace_print.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import sys
from typing import Iterable


def inplace_print(lines: Iterable[str], prev_lines_rendered: int) -> int:
"""Redraw a small block of text in-place without scrolling.
Args:
lines: The lines to render (each should NOT include a trailing newline)
prev_lines_rendered: How many lines were rendered in the previous frame. Pass 0 on first call.
Returns:
The number of lines rendered this call. Use as prev_lines_rendered on the next call.
"""
# Move cursor up to the start of the previous block (if any)
if prev_lines_rendered > 0:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminal control bug: Using CSI F moves to column 1; use Cursor Up (A) to preserve column
\x1b[{n}F moves to beginning of line n lines up, which can break positioning on terminals; \x1b[{n}A is the standard for moving up without changing column.

Suggested change
if prev_lines_rendered > 0:
sys.stdout.write(f"\x1b[{prev_lines_rendered}A") # Move cursor up N lines without changing column

sys.stdout.write(f"\x1b[{prev_lines_rendered}F") # Cursor up N lines

# Rewrite each line, clearing it first to avoid remnants from previous content
count = 0
for line in lines:
sys.stdout.write("\x1b[2K\r") # Clear entire line and return carriage
sys.stdout.write(f"{line}\n")
count += 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic bug: inplace_print leaves stale lines when new frame has fewer lines
When the current render has fewer lines than the previous one, the extra old lines are not cleared, causing visual artifacts.

Suggested change
count += 1
# Clear any leftover lines from previous render and reposition
for _ in range(prev_lines_rendered - count):
sys.stdout.write("\x1b[2K\r\n") # Clear and move to next line
if prev_lines_rendered > count:
sys.stdout.write(f"\x1b[{prev_lines_rendered - count}F") # Move back up to end of new block
sys.stdout.flush()


sys.stdout.flush()
return count
Loading