Skip to content
Open
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
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-06-17 - TUI Keyboard Interaction
**Learning:** Raw terminal reading mapping Esc but swallowing Ctrl-C (\x03) results in a keyboard trap, making users feel stuck. Similarly, `rich` parses brackets in prompts as markup causing errors unless escaped.
**Action:** When implementing custom raw input reading, explicitly raise `KeyboardInterrupt` for \x03. Always escape brackets when injecting choices into `rich` Prompts.
9 changes: 7 additions & 2 deletions libs/terminal_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def _read_key(self):
return 'enter'
if key == '\x1b':
return 'escape'
if key == '\x03':
raise KeyboardInterrupt()
return key

import termios
Expand All @@ -43,6 +45,8 @@ def _read_key(self):
return mapping.get(next_chars, 'escape')
if key in ('\r', '\n'):
return 'enter'
if key == '\x03':
raise KeyboardInterrupt()
return key
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
Expand All @@ -67,7 +71,8 @@ def _render_selector(self, title, options, selected_index, help_text, default):
style = 'bold green' if index == selected_index else ''
table.add_row(marker, label, style=style)

footer = help_text or 'Use Up/Down arrows and Enter to select.'
base_footer = help_text or 'Use Up/Down arrows and Enter to select.'
footer = f"{base_footer} (Esc/Ctrl-C to cancel)"
self.console.print(Panel.fit(footer, title='Interpreter TUI', border_style='green'))
self.console.print(f"[bold cyan]{title}[/bold cyan]")
self.console.print(table)
Expand All @@ -76,7 +81,7 @@ def _render_selector(self, title, options, selected_index, help_text, default):
def _select_option(self, title, options, default, help_text=None):
if not sys.stdin.isatty():
default_choice = default if default in options else options[0]
answer = Prompt.ask(f"{title}", default=default_choice).strip()
answer = Prompt.ask(f"{title} \\[{'|'.join(options)}]", default=default_choice).strip()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current prompt construction does not escape dynamic options.
rg -n -C2 "Prompt\.ask|join\(options\)|rich\.markup|escape\(" libs/terminal_ui.py

Repository: haseeb-heaven/code-interpreter

Length of output: 363


🏁 Script executed:

cat -n libs/terminal_ui.py | head -100

Repository: haseeb-heaven/code-interpreter

Length of output: 4413


🏁 Script executed:

rg -n "def.*title|def.*options" libs/terminal_ui.py -A5

Repository: haseeb-heaven/code-interpreter

Length of output: 1656


🏁 Script executed:

rg "from rich|import rich" libs/terminal_ui.py

Repository: haseeb-heaven/code-interpreter

Length of output: 196


🏁 Script executed:

rg "escape" --type py

Repository: haseeb-heaven/code-interpreter

Length of output: 257


🌐 Web query:

Rich library Prompt.ask markup escaping documentation

πŸ’‘ Result:

In the Rich library, Prompt.ask accepts strings that are interpreted as Console Markup [1][2]. To prevent Rich from interpreting parts of your prompt string as markup tags (e.g., [bold], [red]), you must escape them [3][4]. The primary methods for escaping are: 1. Using the escape function: The recommended approach when dealing with dynamic content or strings that may contain square brackets is to use the escape function from rich.markup [3][4]. This function automatically escapes all markup-sensitive characters in the string [5]. from rich.prompt import Prompt from rich.markup import escape user_input = "[malicious-tag]" # Escapes the brackets so they are rendered as literal text prompt_text = f"Enter value (ignore {escape(user_input)}): " value = Prompt.ask(prompt_text) 2. Using backslashes: You can manually escape a specific tag by placing a backslash before the opening square bracket [3]. from rich.prompt import Prompt # Renders as literal [bold] Prompt.ask("Enter value [bold]: ") If you need to output a literal backslash followed by a tag, use two backslashes [3]. Note that Prompt.ask internally uses Text.from_markup when receiving a string, which is why it parses markup by default [6][7]. If you have full control over the prompt text and do not require any markup features, you can also pass a rich.text.Text object directly to the prompt argument instead of a string, which bypasses markup parsing entirely [1][2].

Citations:


Escape dynamic content in non-TTY prompts to prevent Rich markup parsing errors.

The title and options parameters are passed unsanitized to Prompt.ask() at line 84. Rich's Prompt.ask() parses Console Markup by default, so if either parameter contains characters like [, ], {, or }, it will trigger a MarkupError in non-TTY environments.

While the literal opening bracket is escaped with \\[, this does not protect against markup characters in the dynamic content. Use rich.markup.escape() to safely handle user-controlled text:

Recommended fix
 from rich.console import Console
+from rich.markup import escape
 from rich.panel import Panel
 from rich.prompt import Prompt
@@
-            answer = Prompt.ask(f"{title} \\[{'|'.join(options)}]", default=default_choice).strip()
+            safe_title = escape(title)
+            safe_options = "|".join(escape(option) for option in options)
+            answer = Prompt.ask(f"{safe_title} \\[{safe_options}]", default=default_choice).strip()
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
answer = Prompt.ask(f"{title} \\[{'|'.join(options)}]", default=default_choice).strip()
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.prompt import Prompt
...
safe_title = escape(title)
safe_options = "|".join(escape(option) for option in options)
answer = Prompt.ask(f"{safe_title} \\[{safe_options}]", default=default_choice).strip()
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/terminal_ui.py` at line 84, The `Prompt.ask()` call at line 84 passes
unsanitized `title` and `options` parameters that could contain Rich markup
characters (like `[`, `]`, `{`, `}`) which will trigger MarkupError in non-TTY
environments. Wrap both the `title` parameter and the result of
`'|'.join(options)` with `rich.markup.escape()` before passing them into the
f-string for the `Prompt.ask()` call to safely escape any markup characters in
the dynamic content.

if answer in options:
return answer
for option in options:
Expand Down
198 changes: 198 additions & 0 deletions libs/terminal_ui.py.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
from argparse import Namespace
import os
import sys

from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table

from libs.utility_manager import UtilityManager


class TerminalUI:
def __init__(self):
self.console = Console()
self.utility_manager = UtilityManager()

def _read_key(self):
if os.name == 'nt':
import msvcrt
key = msvcrt.getwch()
if key in ('\x00', '\xe0'):
extended = msvcrt.getwch()
mapping = {'H': 'up', 'P': 'down', 'K': 'left', 'M': 'right'}
return mapping.get(extended, extended)
if key == '\r':
return 'enter'
if key == '\x1b':
return 'escape'
return key

import termios
import tty

fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
key = sys.stdin.read(1)
if key == '\x1b':
next_chars = sys.stdin.read(2)
mapping = {'[A': 'up', '[B': 'down', '[D': 'left', '[C': 'right'}
return mapping.get(next_chars, 'escape')
if key in ('\r', '\n'):
return 'enter'
return key
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

def _render_selector(self, title, options, selected_index, help_text, default):
self.utility_manager.clear_screen()
visible_rows = max(8, min(14, self.console.size.height - 10))
start_index = max(0, selected_index - visible_rows // 2)
end_index = min(len(options), start_index + visible_rows)
start_index = max(0, end_index - visible_rows)

table = Table(show_header=True, header_style='bold cyan')
table.add_column('', width=2)
table.add_column('Value', overflow='fold')

for index in range(start_index, end_index):
option = options[index]
marker = '>' if index == selected_index else ' '
label = option
if option == default:
label += ' (default)'
style = 'bold green' if index == selected_index else ''
table.add_row(marker, label, style=style)

footer = help_text or 'Use Up/Down arrows and Enter to select.'
self.console.print(Panel.fit(footer, title='Interpreter TUI', border_style='green'))
self.console.print(f"[bold cyan]{title}[/bold cyan]")
self.console.print(table)
self.console.print(f'Selected: [bold]{options[selected_index]}[/bold]')

def _select_option(self, title, options, default, help_text=None):
if not sys.stdin.isatty():
default_choice = default if default in options else options[0]
answer = Prompt.ask(f"{title}", default=default_choice).strip()
if answer in options:
return answer
for option in options:
if option.lower() == answer.lower():
return option
return default_choice

try:
selected_index = options.index(default)
except ValueError:
selected_index = 0

while True:
self._render_selector(title, options, selected_index, help_text, default)
key = self._read_key()

if key in ('up', 'k'):
selected_index = (selected_index - 1) % len(options)
elif key in ('down', 'j'):
selected_index = (selected_index + 1) % len(options)
elif key == 'enter':
return options[selected_index]
elif key == 'escape':
raise KeyboardInterrupt('Selection cancelled by user.')
elif isinstance(key, str) and len(key) == 1:
lowered = key.lower()
for index, option in enumerate(options):
if option.lower().startswith(lowered):
selected_index = index
break

def _select_boolean(self, title, default=False):
default_choice = 'yes' if default else 'no'
choice = self._select_option(title, ['yes', 'no'], default_choice, 'Use Up/Down arrows and Enter to choose.')
return choice == 'yes'

def select_mode(self, default_mode='code'):
return self._select_option('Mode', ['code', 'chat', 'script', 'command', 'vision'], default_mode)

def select_model(self, default_model=None):
models = self.utility_manager.list_available_models()
default_model = default_model or self.utility_manager.get_default_model_name()
if default_model not in models:
default_model = models[0]
return self._select_option('Model', models, default_model, 'Use Up/Down arrows, Enter, or type the first letter to jump.')

def select_language(self, default_lang='python'):
return self._select_option('Language', ['python', 'javascript'], default_lang)

def select_boolean(self, title, default=False):
return self._select_boolean(title, default=default)

def interactive_settings(self, interpreter):
current_model = getattr(interpreter, "INTERPRETER_MODEL_LABEL", None) or getattr(interpreter, "INTERPRETER_MODEL", None)
current_mode = getattr(interpreter, "INTERPRETER_MODE", "code")
current_lang = getattr(interpreter, "INTERPRETER_LANGUAGE", "python")

mode = self.select_mode(current_mode)
model = self.select_model(current_model)
language = self.select_language(current_lang)
display_code = self.select_boolean('Display generated code automatically?', default=getattr(interpreter, "DISPLAY_CODE", False))
execute_code = self.select_boolean('Execute generated code automatically?', default=getattr(interpreter, "EXECUTE_CODE", False))
save_code = self.select_boolean('Save generated output automatically?', default=getattr(interpreter, "SAVE_CODE", False))
history = self.select_boolean('Enable history memory?', default=getattr(interpreter, "INTERPRETER_HISTORY", False))

return {
"mode": mode,
"model": model,
"language": language,
"display_code": display_code,
"execute_code": execute_code,
"save_code": save_code,
"history": history,
}

def launch(self, args):
mode = self.select_mode(args.mode or 'code')
model = self.select_model(args.model or self.utility_manager.get_default_model_name())
language = self.select_language(args.lang or 'python')

self.utility_manager.clear_screen()
self.console.print(
Panel.fit(
f"Mode: [bold]{mode}[/bold] | Model: [bold]{model}[/bold] | Language: [bold]{language}[/bold]",
title='Interpreter Session',
border_style='blue',
)
)

display_code = args.display_code
if mode in ['code', 'script', 'command'] and not display_code:
display_code = self._select_boolean('Display generated code automatically?', default=True)

execute_code = args.exec
if mode == 'code' and not execute_code:
execute_code = self._select_boolean('Execute generated code automatically?', default=False)

save_code = args.save_code
if mode in ['code', 'script', 'command'] and not save_code:
save_code = self._select_boolean('Save generated output automatically?', default=False)

history = args.history
if not history:
history = self._select_boolean('Enable history memory?', default=False)

return Namespace(
exec=execute_code,
save_code=save_code,
mode=mode,
model=model,
display_code=display_code,
lang=language,
file=args.file,
history=history,
unsafe=getattr(args, "unsafe", False),
upgrade=args.upgrade,
cli=args.cli,
tui=args.tui,
)
Loading