closecode-ai 0.1.0__py3-none-any.whl
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.
- agent.py +74 -0
- closecode_ai-0.1.0.dist-info/METADATA +138 -0
- closecode_ai-0.1.0.dist-info/RECORD +19 -0
- closecode_ai-0.1.0.dist-info/WHEEL +5 -0
- closecode_ai-0.1.0.dist-info/entry_points.txt +2 -0
- closecode_ai-0.1.0.dist-info/top_level.txt +14 -0
- debug_response.py +25 -0
- guardrails.py +347 -0
- harness.py +200 -0
- llm.py +145 -0
- main.py +520 -0
- mcp_tools.py +27 -0
- modes.py +36 -0
- search.py +130 -0
- session.py +197 -0
- todos.py +99 -0
- token_tracker.py +42 -0
- tools.py +137 -0
- ui.py +422 -0
ui.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
|
|
2
|
+
import select
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from getpass import getpass
|
|
8
|
+
|
|
9
|
+
from rich.console import Console, Group
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.rule import Rule
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
import pyfiglet
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import termios
|
|
21
|
+
import tty
|
|
22
|
+
_HAS_TERMIOS = True
|
|
23
|
+
except ImportError: # pragma: no cover - Windows fallback
|
|
24
|
+
_HAS_TERMIOS = False
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import msvcrt
|
|
28
|
+
_HAS_MSVCRT = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_HAS_MSVCRT = False
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
# Simple, solid block wordmark for "OGBOT" — no fancy/decorative figlet
|
|
35
|
+
# fonts, just clean filled rectangles. Falls back to a plain bold pyfiglet
|
|
36
|
+
# render for any other banner text.
|
|
37
|
+
_BLOCK_GLYPHS = {
|
|
38
|
+
"O": ["█████", "█ █", "█ █", "█ █", "█████"],
|
|
39
|
+
"G": ["█████", "█ ", "█ ██", "█ █", "█████"],
|
|
40
|
+
"B": ["████ ", "█ █", "████ ", "█ █", "████ "],
|
|
41
|
+
"T": ["█████", " █ ", " █ ", " █ ", " █ "],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# ---- opencode default theme (dark variant) --------------------------------
|
|
45
|
+
BG = "#0a0a0a"
|
|
46
|
+
BG_PANEL = "#141414"
|
|
47
|
+
PRIMARY = "#fab283" # peach -> agent label / write arrows
|
|
48
|
+
SECONDARY = "#5c9cf5" # blue -> "you" label
|
|
49
|
+
ACCENT = "#9d7cd8" # purple -> mode accents
|
|
50
|
+
ERROR = "#e06c75"
|
|
51
|
+
WARNING = "#f5a742"
|
|
52
|
+
SUCCESS = "#7fd88f"
|
|
53
|
+
INFO = "#56b6c2" # cyan -> read arrows
|
|
54
|
+
TEXT = "#eeeeee"
|
|
55
|
+
TEXT_MUTED = "#808080"
|
|
56
|
+
BORDER = "#484848"
|
|
57
|
+
BORDER_SUBTLE = "#3c3c3c"
|
|
58
|
+
|
|
59
|
+
# Tool names containing these treat as read-only (->), everything else is
|
|
60
|
+
# a write (<-). Mirrors opencode's arrow direction for tool calls.
|
|
61
|
+
_READ_KINDS = ("read", "list", "status", "diff", "log", "show", "grep", "search")
|
|
62
|
+
|
|
63
|
+
# Map the loose rich color names used by callers onto the opencode palette.
|
|
64
|
+
_NAMED_STYLES = {
|
|
65
|
+
"dim": TEXT_MUTED,
|
|
66
|
+
"yellow": WARNING,
|
|
67
|
+
"red": ERROR,
|
|
68
|
+
"green": SUCCESS,
|
|
69
|
+
"cyan": INFO,
|
|
70
|
+
"magenta": ACCENT,
|
|
71
|
+
"blue": SECONDARY,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_context_mode = "build"
|
|
75
|
+
_context_model = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def set_context(mode: str, model_name: str) -> None:
|
|
79
|
+
"""Remember the current agent/mode and model for labels and markers."""
|
|
80
|
+
global _context_mode, _context_model
|
|
81
|
+
_context_mode = mode
|
|
82
|
+
_context_model = model_name
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _banner_art(text: str, font: str = "standard") -> Text:
|
|
86
|
+
"""Simple, solid banner. If every character in `text` has a hand-drawn
|
|
87
|
+
block glyph (currently just what's needed for "OGBOT"), render clean
|
|
88
|
+
filled rectangles. Otherwise fall back to a plain pyfiglet font for
|
|
89
|
+
arbitrary text."""
|
|
90
|
+
if text and all(ch in _BLOCK_GLYPHS for ch in text.upper()):
|
|
91
|
+
rows = ["" for _ in range(5)]
|
|
92
|
+
for ch in text.upper():
|
|
93
|
+
glyph = _BLOCK_GLYPHS[ch]
|
|
94
|
+
for i in range(5):
|
|
95
|
+
rows[i] += glyph[i] + " "
|
|
96
|
+
lines = [row.rstrip() for row in rows]
|
|
97
|
+
else:
|
|
98
|
+
art = pyfiglet.figlet_format(text, font=font)
|
|
99
|
+
lines = art.rstrip("\n").split("\n")
|
|
100
|
+
|
|
101
|
+
result = Text()
|
|
102
|
+
for line in lines:
|
|
103
|
+
result.append(line, style=f"bold {PRIMARY}")
|
|
104
|
+
result.append("\n")
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def print_banner(model_name: str, sandbox_path: str, tool_names: list[str]) -> None:
|
|
109
|
+
console.print()
|
|
110
|
+
console.print(_banner_art("OGBOT"), justify="center")
|
|
111
|
+
console.print()
|
|
112
|
+
|
|
113
|
+
info = Text()
|
|
114
|
+
info.append(f"model {model_name}\n", style=TEXT_MUTED)
|
|
115
|
+
info.append(f"workdir {sandbox_path}\n", style=TEXT_MUTED)
|
|
116
|
+
info.append(f"tools {', '.join(tool_names)}", style=TEXT_MUTED)
|
|
117
|
+
console.print(info, justify="center")
|
|
118
|
+
|
|
119
|
+
console.print()
|
|
120
|
+
console.print(Text("type a task, or 'exit' to quit.", style=TEXT_MUTED), justify="center")
|
|
121
|
+
console.print()
|
|
122
|
+
console.print(Rule(style=BORDER_SUBTLE))
|
|
123
|
+
console.print()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def print_user_message(content: str) -> None:
|
|
127
|
+
console.print()
|
|
128
|
+
console.print(Text("you", style=f"bold {SECONDARY}"))
|
|
129
|
+
console.print(Text(content))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _agent_header() -> Text:
|
|
133
|
+
head = Text()
|
|
134
|
+
head.append(_context_mode, style=f"bold {PRIMARY}")
|
|
135
|
+
return head
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _agent_renderable(text_so_far: str):
|
|
139
|
+
if text_so_far:
|
|
140
|
+
body = Markdown(text_so_far, style=TEXT)
|
|
141
|
+
else:
|
|
142
|
+
body = Text("")
|
|
143
|
+
return Group(_agent_header(), body)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def stream_start() -> Live:
|
|
147
|
+
"""Call when the first real content token of a response arrives.
|
|
148
|
+
Returns a Live object — pass it to stream_update() for each subsequent
|
|
149
|
+
chunk, and stream_stop() when the message is complete."""
|
|
150
|
+
live = Live(_agent_renderable(""), console=console, refresh_per_second=12)
|
|
151
|
+
live.start()
|
|
152
|
+
return live
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def stream_update(live: Live, text_so_far: str) -> None:
|
|
156
|
+
live.update(_agent_renderable(text_so_far))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def stream_stop(live: Live) -> None:
|
|
160
|
+
live.stop()
|
|
161
|
+
console.print()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def print_thinking() -> None:
|
|
165
|
+
line = Text()
|
|
166
|
+
line.append("thinking\u2026 ", style=TEXT_MUTED)
|
|
167
|
+
line.append("(esc to interrupt)", style=TEXT_MUTED)
|
|
168
|
+
console.print(line)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _direction(name: str) -> str:
|
|
172
|
+
return "read" if any(k in name for k in _READ_KINDS) else "write"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def print_tool_call(name: str, args: dict) -> None:
|
|
176
|
+
line = Text()
|
|
177
|
+
if _direction(name) == "read":
|
|
178
|
+
line.append("\u2192 ", style=f"bold {INFO}")
|
|
179
|
+
else:
|
|
180
|
+
line.append("\u2190 ", style=f"bold {PRIMARY}")
|
|
181
|
+
line.append(name, style=f"bold {TEXT}")
|
|
182
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
|
|
183
|
+
if len(args_str) > 140:
|
|
184
|
+
args_str = args_str[:140] + "\u2026"
|
|
185
|
+
if args_str:
|
|
186
|
+
line.append(f"({args_str})", style=TEXT_MUTED)
|
|
187
|
+
console.print(line)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def print_tool_result(content: str) -> None:
|
|
191
|
+
preview = content.strip().splitlines()[0] if content.strip() else ""
|
|
192
|
+
if len(preview) > 120:
|
|
193
|
+
preview = preview[:120] + "\u2026"
|
|
194
|
+
console.print(Text(preview, style=TEXT_MUTED))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def print_turn_complete(duration: float) -> None:
|
|
198
|
+
console.print()
|
|
199
|
+
marker = Text()
|
|
200
|
+
marker.append("\u25a3 ", style=TEXT_MUTED)
|
|
201
|
+
marker.append(_context_mode, style=f"bold {PRIMARY}")
|
|
202
|
+
marker.append(f" \u00b7 {duration:.1f}s", style=TEXT_MUTED)
|
|
203
|
+
console.print(marker)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def print_token_usage(summary: str) -> None:
|
|
207
|
+
console.print(Text(summary, style=TEXT_MUTED))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def print_notice(text: str, style: str = "dim") -> None:
|
|
211
|
+
color = _NAMED_STYLES.get(style, style)
|
|
212
|
+
console.print(f"[{color}]{text}[/{color}]", overflow="ellipsis")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def print_sessions(sessions: list) -> None:
|
|
216
|
+
"""Render saved sessions with their metadata. `sessions` items are
|
|
217
|
+
session.SessionInfo dataclasses."""
|
|
218
|
+
if not sessions:
|
|
219
|
+
console.print(Text("No saved sessions yet.", style=TEXT_MUTED))
|
|
220
|
+
return
|
|
221
|
+
table = Table(title="sessions", title_justify="left",
|
|
222
|
+
border_style=BORDER_SUBTLE, pad_edge=False)
|
|
223
|
+
table.add_column("#", justify="right", style=TEXT_MUTED, width=3)
|
|
224
|
+
table.add_column("name", no_wrap=True, style=TEXT, max_width=42, overflow="ellipsis")
|
|
225
|
+
table.add_column("mode", justify="center", style=ACCENT)
|
|
226
|
+
table.add_column("model", style=TEXT_MUTED, max_width=24, overflow="ellipsis")
|
|
227
|
+
table.add_column("msgs", justify="right", style=INFO)
|
|
228
|
+
table.add_column("updated", style=TEXT_MUTED)
|
|
229
|
+
for s in sessions:
|
|
230
|
+
when = datetime.fromtimestamp(s.updated_at).strftime("%b %d %H:%M")
|
|
231
|
+
table.add_row(
|
|
232
|
+
str(s.id),
|
|
233
|
+
s.name or "(unnamed)",
|
|
234
|
+
s.mode or "build",
|
|
235
|
+
s.model or "-",
|
|
236
|
+
str(s.message_count),
|
|
237
|
+
when,
|
|
238
|
+
)
|
|
239
|
+
console.print(table)
|
|
240
|
+
console.print(Text("/resume <id> to switch \u00b7 /delete <id> to remove",
|
|
241
|
+
style=TEXT_MUTED))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def print_help() -> None:
|
|
245
|
+
text = (
|
|
246
|
+
"/plan switch to read-only plan mode (explore only, no writes/commits)\n"
|
|
247
|
+
"/build switch to build mode (all tools enabled)\n"
|
|
248
|
+
"/models [q] list OpenRouter models (● = current); --refresh updates\n"
|
|
249
|
+
"/model <n|id> switch model by list number or any OpenRouter model id\n"
|
|
250
|
+
"/key paste a new OpenRouter API key (optionally saved to .env)\n"
|
|
251
|
+
"/sessions list saved sessions (SQLite) with metadata\n"
|
|
252
|
+
"/resume <id> resume a saved conversation\n"
|
|
253
|
+
"/delete <id> delete a saved session\n"
|
|
254
|
+
"/usage show cumulative token usage this session\n"
|
|
255
|
+
"/clear clear conversation history (session file untouched)\n"
|
|
256
|
+
"/help show this message\n"
|
|
257
|
+
"esc interrupt the agent mid-turn\n"
|
|
258
|
+
"exit / quit quit"
|
|
259
|
+
)
|
|
260
|
+
console.print(
|
|
261
|
+
Panel(Text(text), title="commands", title_align="left",
|
|
262
|
+
border_style=BORDER_SUBTLE)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def prompt_api_key() -> str:
|
|
267
|
+
"""Ask the user to paste their OpenRouter API key without echoing it
|
|
268
|
+
to the terminal. Returns the stripped key, or "" if nothing entered."""
|
|
269
|
+
console.print()
|
|
270
|
+
console.print(Text("Paste your OpenRouter API key (input is hidden).", style=f"bold {WARNING}"))
|
|
271
|
+
console.print(Text("Get one at https://openrouter.ai/settings/keys", style=TEXT_MUTED))
|
|
272
|
+
try:
|
|
273
|
+
key = getpass("key: ")
|
|
274
|
+
except Exception:
|
|
275
|
+
# getpass can fail when stdin isn't a real TTY — fall back to a
|
|
276
|
+
# visible prompt rather than crashing.
|
|
277
|
+
key = console.input("key: ")
|
|
278
|
+
return (key or "").strip()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def confirm_save_key() -> bool:
|
|
282
|
+
"""Ask whether the just-pasted key should persist into .env."""
|
|
283
|
+
answer = console.input("Save this key to .env for next time? [y/N] ").strip().lower()
|
|
284
|
+
return answer in ("y", "yes")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_MODELS_DISPLAY_LIMIT = 80
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def print_models(models: list, current: str, source: str = "live", query: str = None) -> None:
|
|
291
|
+
"""Render a model list (from /models), starring the active model.
|
|
292
|
+
|
|
293
|
+
The live OpenRouter list has hundreds of entries, so only the first
|
|
294
|
+
_MODELS_DISPLAY_LIMIT rows are shown — the footer says how to narrow
|
|
295
|
+
it. Numbering matches list position so `/model <number>` picks the
|
|
296
|
+
row the user sees.
|
|
297
|
+
"""
|
|
298
|
+
total = len(models)
|
|
299
|
+
shown = models[:_MODELS_DISPLAY_LIMIT]
|
|
300
|
+
table = Table(title="models", title_justify="left",
|
|
301
|
+
border_style=BORDER_SUBTLE, pad_edge=False)
|
|
302
|
+
table.add_column("#", justify="right", style=TEXT_MUTED, width=4)
|
|
303
|
+
table.add_column("", width=2)
|
|
304
|
+
table.add_column("model id", style=TEXT, no_wrap=True, max_width=44, overflow="ellipsis")
|
|
305
|
+
table.add_column("notes", style=TEXT_MUTED, max_width=40, overflow="ellipsis")
|
|
306
|
+
for i, (mid, note) in enumerate(shown, 1):
|
|
307
|
+
marker = Text("●", style=PRIMARY) if mid == current else Text(" ")
|
|
308
|
+
table.add_row(str(i), marker, mid, note)
|
|
309
|
+
console.print(table)
|
|
310
|
+
|
|
311
|
+
footer = Text()
|
|
312
|
+
if source == "cache":
|
|
313
|
+
footer.append("from cache (24h) · ", style=TEXT_MUTED)
|
|
314
|
+
elif source == "fallback":
|
|
315
|
+
footer.append("offline — showing curated shortlist · ", style=WARNING)
|
|
316
|
+
if query:
|
|
317
|
+
footer.append(f"{total} match '{query}'", style=TEXT_MUTED)
|
|
318
|
+
else:
|
|
319
|
+
footer.append(f"showing {len(shown)} of {total}", style=TEXT_MUTED)
|
|
320
|
+
console.print(footer)
|
|
321
|
+
hints = Text("/model <number> to switch · /models <query> to filter · /models --refresh to update",
|
|
322
|
+
style=TEXT_MUTED)
|
|
323
|
+
console.print(hints)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class EscListener:
|
|
327
|
+
"""Watches stdin for an Esc keypress on a background thread while a turn
|
|
328
|
+
is streaming, without blocking the asyncio event loop.
|
|
329
|
+
|
|
330
|
+
Terminal input is a blocking, thread-only affair (raw/cbreak mode via
|
|
331
|
+
termios), so this runs on its own thread and hands control back to the
|
|
332
|
+
event loop by calling `event.set()` through `loop.call_soon_threadsafe`.
|
|
333
|
+
Safe to call `start()`/`stop()` even when stdin isn't a real TTY (e.g.
|
|
334
|
+
piped input, some CI environments) — it just no-ops in that case.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
def __init__(self, loop, event) -> None:
|
|
338
|
+
self._loop = loop
|
|
339
|
+
self._event = event
|
|
340
|
+
self._stop = threading.Event()
|
|
341
|
+
self._thread: threading.Thread | None = None
|
|
342
|
+
|
|
343
|
+
def start(self) -> None:
|
|
344
|
+
if not sys.stdin.isatty():
|
|
345
|
+
return
|
|
346
|
+
if not _HAS_TERMIOS and not _HAS_MSVCRT:
|
|
347
|
+
return
|
|
348
|
+
self._stop.clear()
|
|
349
|
+
self._thread = threading.Thread(target=self._watch, daemon=True)
|
|
350
|
+
self._thread.start()
|
|
351
|
+
|
|
352
|
+
def _signal_esc(self) -> None:
|
|
353
|
+
self._loop.call_soon_threadsafe(self._event.set)
|
|
354
|
+
|
|
355
|
+
def _watch(self) -> None:
|
|
356
|
+
if _HAS_TERMIOS:
|
|
357
|
+
self._watch_termios()
|
|
358
|
+
elif _HAS_MSVCRT:
|
|
359
|
+
self._watch_msvcrt()
|
|
360
|
+
|
|
361
|
+
def _watch_termios(self) -> None:
|
|
362
|
+
fd = sys.stdin.fileno()
|
|
363
|
+
old_settings = termios.tcgetattr(fd)
|
|
364
|
+
try:
|
|
365
|
+
tty.setcbreak(fd)
|
|
366
|
+
while not self._stop.is_set():
|
|
367
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
368
|
+
if ready:
|
|
369
|
+
ch = sys.stdin.read(1)
|
|
370
|
+
if ch == "\x1b":
|
|
371
|
+
self._signal_esc()
|
|
372
|
+
return
|
|
373
|
+
finally:
|
|
374
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
375
|
+
|
|
376
|
+
def _watch_msvcrt(self) -> None: # pragma: no cover - Windows only
|
|
377
|
+
while not self._stop.is_set():
|
|
378
|
+
if msvcrt.kbhit():
|
|
379
|
+
ch = msvcrt.getch()
|
|
380
|
+
if ch == b"\x1b":
|
|
381
|
+
self._signal_esc()
|
|
382
|
+
return
|
|
383
|
+
else:
|
|
384
|
+
self._stop.wait(0.1)
|
|
385
|
+
|
|
386
|
+
def stop(self) -> None:
|
|
387
|
+
self._stop.set()
|
|
388
|
+
if self._thread is not None:
|
|
389
|
+
self._thread.join(timeout=0.3)
|
|
390
|
+
self._thread = None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def confirm(question: str) -> str:
|
|
394
|
+
"""Permission prompt styled like an opencode permission dialog.
|
|
395
|
+
Returns "allow", "always", or "deny"."""
|
|
396
|
+
console.print()
|
|
397
|
+
body = Text()
|
|
398
|
+
body.append("\u25b3 ", style=f"bold {WARNING}")
|
|
399
|
+
body.append(f"Allow agent to {question}?", style=TEXT)
|
|
400
|
+
console.print(Panel(body, title="permission", title_align="left",
|
|
401
|
+
border_style=BORDER_SUBTLE, padding=(0, 1)))
|
|
402
|
+
opts = Text()
|
|
403
|
+
opts.append("1", style=f"bold {SUCCESS}"); opts.append(") Allow ")
|
|
404
|
+
opts.append("2", style=f"bold {WARNING}"); opts.append(") Always Allow ")
|
|
405
|
+
opts.append("3", style=f"bold {ERROR}"); opts.append(") Don't Allow")
|
|
406
|
+
console.print(opts)
|
|
407
|
+
answer = console.input("[1/2/3] ").strip()
|
|
408
|
+
if answer == "1":
|
|
409
|
+
return "allow"
|
|
410
|
+
if answer == "2":
|
|
411
|
+
return "always"
|
|
412
|
+
return "deny"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def user_prompt(mode: str) -> str:
|
|
416
|
+
console.print()
|
|
417
|
+
console.print(Rule(style=BORDER_SUBTLE))
|
|
418
|
+
prompt = Text()
|
|
419
|
+
prompt.append("> ", style=f"bold {TEXT}")
|
|
420
|
+
prompt.append(mode, style=TEXT_MUTED)
|
|
421
|
+
prompt.append(" ")
|
|
422
|
+
return console.input(prompt).strip()
|