indent 0.1.8__py3-none-any.whl → 0.1.9__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.

Potentially problematic release.


This version of indent might be problematic. Click here for more details.

@@ -1,2840 +0,0 @@
1
- import asyncio
2
- import difflib
3
- import os
4
- import re
5
- import select
6
- import shutil
7
- import sys
8
- import time
9
- from asyncio.events import AbstractEventLoop
10
- from collections.abc import Callable, Coroutine, Iterable
11
-
12
- from exponent.commands.theme import Theme, bg_color_seq, fg_color_seq, get_theme
13
- from exponent.core.remote_execution.types import ChatSource
14
- from exponent.core.remote_execution.utils import safe_read_file
15
- from exponent.utils.version import check_exponent_version_and_upgrade
16
-
17
- # Import termios and tty conditionally for Windows compatibility
18
- try:
19
- import termios
20
- import tty
21
-
22
- POSIX_TERMINAL = True
23
- except ImportError:
24
- POSIX_TERMINAL = False
25
-
26
- import logging
27
- from concurrent.futures import Future
28
- from datetime import datetime
29
- from enum import Enum
30
- from typing import IO, Any
31
-
32
- import click
33
- import questionary
34
- from prompt_toolkit import PromptSession
35
- from prompt_toolkit.completion import (
36
- CompleteEvent,
37
- Completer,
38
- Completion,
39
- FuzzyCompleter,
40
- )
41
- from prompt_toolkit.document import Document
42
- from prompt_toolkit.formatted_text import FormattedText
43
- from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
44
- from prompt_toolkit.key_binding.defaults import load_key_bindings
45
- from prompt_toolkit.key_binding.key_processor import KeyPress
46
- from prompt_toolkit.keys import Keys
47
- from prompt_toolkit.lexers import Lexer
48
- from prompt_toolkit.styles import Style
49
- from rich.console import Console
50
- from rich.syntax import Syntax
51
-
52
- from exponent.commands.common import (
53
- check_inside_git_repo,
54
- check_running_from_home_directory,
55
- check_ssl,
56
- create_chat,
57
- inside_ssh_session,
58
- redirect_to_login,
59
- start_client,
60
- )
61
- from exponent.commands.settings import use_settings
62
- from exponent.commands.types import (
63
- StrategyChoice,
64
- StrategyOption,
65
- exponent_cli_group,
66
- )
67
- from exponent.commands.utils import (
68
- ConnectionTracker,
69
- Spinner,
70
- ThinkingSpinner,
71
- start_background_event_loop,
72
- )
73
- from exponent.core.config import Environment, Settings
74
- from exponent.core.graphql.client import GraphQLClient
75
- from exponent.core.graphql.mutations import HALT_CHAT_STREAM_MUTATION
76
- from exponent.core.graphql.queries import EVENTS_FOR_CHAT_QUERY
77
- from exponent.core.graphql.subscriptions import (
78
- CONFIRM_AND_CONTINUE_SUBSCRIPTION,
79
- INDENT_EVENTS_SUBSCRIPTION,
80
- )
81
- from exponent.core.remote_execution.exceptions import ExponentError, RateLimitError
82
- from exponent.core.remote_execution.files import FileCache
83
- from exponent.core.types.generated.strategy_info import (
84
- ENABLED_STRATEGY_INFO_LIST,
85
- )
86
-
87
- from .utils import (
88
- ask_for_quit_confirmation,
89
- get_short_git_commit_hash,
90
- launch_exponent_browser,
91
- )
92
-
93
- logger = logging.getLogger(__name__)
94
-
95
-
96
- SLASH_COMMANDS = {
97
- "/help": "Show available commands",
98
- "/autorun": "Toggle between autorun modes",
99
- "/web": "Move chat to a web browser",
100
- "/cmd": "Execute a terminal command directly and give the result as context",
101
- # Thinking is slow + not making chats better, disabling for now
102
- # "/thinking": "Toggle thinking mode to show/hide AI's thinking process",
103
- }
104
-
105
-
106
- class AutoConfirmMode(str, Enum):
107
- OFF = "off"
108
- READ_ONLY = "read_only"
109
- ALL = "all"
110
-
111
-
112
- ATTACHMENT_PATH_PATTERN = re.compile(r"(^|\s)@[^\s]+")
113
-
114
-
115
- @exponent_cli_group()
116
- def shell_cli() -> None:
117
- pass
118
-
119
-
120
- @shell_cli.command()
121
- @click.option(
122
- "--model",
123
- help="LLM model",
124
- required=True,
125
- default="CLAUDE_4_SONNET",
126
- )
127
- @click.option(
128
- "--strategy",
129
- prompt=True,
130
- prompt_required=False,
131
- type=StrategyChoice(ENABLED_STRATEGY_INFO_LIST),
132
- cls=StrategyOption,
133
- default="NATURAL_EDIT_CLAUDE_3_7_XML",
134
- )
135
- @click.option(
136
- "--autorun",
137
- is_flag=True,
138
- help="Enable autorun mode",
139
- )
140
- @click.option(
141
- "--depth",
142
- type=click.IntRange(1, 30, clamp=True),
143
- help="Depth limit of the chat if autorun mode is enabled",
144
- default=5,
145
- )
146
- @click.option(
147
- "--chat-id",
148
- help="ID of an existing chat session to reconnect",
149
- required=False,
150
- )
151
- @click.option(
152
- "--prompt",
153
- help="Initial prompt",
154
- )
155
- @click.option(
156
- "--headless",
157
- is_flag=True,
158
- help="Run single prompt in headless mode",
159
- )
160
- @use_settings
161
- def shell(
162
- settings: Settings,
163
- model: str,
164
- strategy: str,
165
- chat_id: str | None = None,
166
- autorun: bool = False,
167
- depth: int = 0,
168
- prompt: str | None = None,
169
- headless: bool = False,
170
- ) -> None:
171
- """Start an Exponent session in your current shell."""
172
-
173
- check_exponent_version_and_upgrade(settings)
174
-
175
- if not headless and not sys.stdin.isatty():
176
- print("Terminal not available, running in headless mode")
177
- headless = True
178
-
179
- if headless and not prompt:
180
- print("Error: --prompt option is required with headless mode")
181
- sys.exit(1)
182
-
183
- if not settings.api_key:
184
- redirect_to_login(settings)
185
- return
186
-
187
- is_running_from_home_directory = check_running_from_home_directory(
188
- # We don't require a confirmation for exponent shell because it's more likely
189
- # there's a legitimate reason for running Exponent from home directory when using shell
190
- require_confirmation=False
191
- )
192
-
193
- loop = start_background_event_loop()
194
-
195
- if not is_running_from_home_directory: # Prevent double warnings from being shown
196
- asyncio.run_coroutine_threadsafe(check_inside_git_repo(settings), loop).result()
197
-
198
- check_ssl()
199
-
200
- api_key = settings.api_key
201
- base_api_url = settings.get_base_api_url()
202
- base_ws_url = settings.get_base_ws_url()
203
- gql_client = GraphQLClient(api_key, base_api_url, base_ws_url)
204
- parent_event_uuid: str | None = None
205
- checkpoints: list[dict[str, Any]] = []
206
-
207
- if chat_id is None:
208
- chat_uuid = asyncio.run_coroutine_threadsafe(
209
- create_chat(api_key, base_api_url, base_ws_url, ChatSource.CLI_SHELL), loop
210
- ).result()
211
- else:
212
- chat_uuid = chat_id
213
-
214
- # events = asyncio.run_coroutine_threadsafe(
215
- # _get_events_for_chat(gql_client, chat_uuid), loop
216
- # ).result()
217
- # parent_event_uuid = _get_parent_event_uuid(events)
218
- # checkpoints = _get_checkpoints(events)
219
-
220
- if chat_uuid is None:
221
- sys.exit(1)
222
-
223
- working_directory = os.getcwd()
224
- file_cache = FileCache(working_directory)
225
- connection_tracker = ConnectionTracker()
226
-
227
- client_coro = start_client(
228
- api_key,
229
- settings.base_url,
230
- base_api_url,
231
- base_ws_url,
232
- chat_uuid,
233
- file_cache=file_cache,
234
- connection_tracker=connection_tracker,
235
- )
236
-
237
- theme = get_theme(settings.options.use_default_colors)
238
-
239
- if headless:
240
- assert prompt is not None
241
-
242
- chat = Chat(
243
- chat_uuid,
244
- parent_event_uuid,
245
- settings.base_url,
246
- working_directory,
247
- gql_client,
248
- model,
249
- strategy,
250
- autorun,
251
- depth,
252
- StaticView(theme),
253
- checkpoints=checkpoints,
254
- thinking=False,
255
- )
256
-
257
- client_task = loop.create_task(client_coro)
258
- turn_task = loop.create_task(chat.send_prompt(prompt))
259
-
260
- asyncio.run_coroutine_threadsafe(
261
- asyncio.wait({client_task, turn_task}, return_when=asyncio.FIRST_COMPLETED),
262
- loop,
263
- ).result()
264
- else:
265
- chat = Chat(
266
- chat_uuid,
267
- parent_event_uuid,
268
- settings.base_url,
269
- working_directory,
270
- gql_client,
271
- model,
272
- strategy,
273
- autorun,
274
- depth,
275
- LiveView(theme),
276
- checkpoints=checkpoints,
277
- thinking=False,
278
- )
279
-
280
- client_fut = asyncio.run_coroutine_threadsafe(client_coro, loop)
281
- input_handler = InputHandler(theme, loop, file_cache)
282
- _print_welcome_message(theme)
283
-
284
- shell = Shell(
285
- prompt, loop, input_handler, chat, connection_tracker, settings.environment
286
- )
287
-
288
- shell.run()
289
- client_fut.cancel()
290
-
291
- print("Jump back into this chat by running:")
292
- print(f" exponent shell --chat-id {chat_uuid}")
293
- print()
294
- print("Or continue in a web browser at:")
295
- print(f" {chat.url()}")
296
- print()
297
-
298
-
299
- async def _get_events_for_chat(
300
- gql_client: GraphQLClient, chat_uuid: str
301
- ) -> list[dict[str, Any]]:
302
- result = await gql_client.execute(EVENTS_FOR_CHAT_QUERY, {"chatUuid": chat_uuid})
303
- return result.get("eventsForChat", {}).get("events", []) # type: ignore
304
-
305
-
306
- def _get_parent_event_uuid(events: list[dict[str, Any]]) -> str | None:
307
- if len(events) > 0:
308
- uuid = events[-1]["eventUuid"]
309
- assert isinstance(uuid, str)
310
-
311
- return uuid
312
-
313
- return None
314
-
315
-
316
- def _get_checkpoints(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
317
- return [
318
- event for event in events if event.get("__typename") == "CheckpointCreatedEvent"
319
- ]
320
-
321
-
322
- def _print_welcome_message(theme: Theme) -> None:
323
- print("╭─────────────────────────────────╮")
324
- print(
325
- f"│ ✨ Welcome to {bold_seq()}{fg_color_seq(theme.exponent_green)}Indent \x1b[4mSHELL{reset_attrs_seq()} ✨ │"
326
- )
327
-
328
- print("╰─────────────────────────────────╯")
329
- print()
330
- print(
331
- f" Enter {bold_seq()}/help{not_bold_seq()} to see available commands and keyboard shortcuts"
332
- )
333
- print(
334
- f" Enter {bold_seq()}q{not_bold_seq()}, {bold_seq()}exit{not_bold_seq()} or press {bold_seq()}<ctrl+c>{not_bold_seq()} to quit"
335
- )
336
- print()
337
-
338
-
339
- def pause_spinner(func: Callable[..., Any]) -> Callable[..., Any]:
340
- def wrapper(self: "LiveView", *args: Any, **kwargs: Any) -> Any:
341
- self.spinner.hide()
342
- self.spinner = self.default_spinner
343
- result = func(self, *args, **kwargs)
344
- self.spinner.show()
345
- return result
346
-
347
- return wrapper
348
-
349
-
350
- def stop_spinner(func: Callable[..., Any]) -> Callable[..., Any]:
351
- def wrapper(self: "LiveView", *args: Any, **kwargs: Any) -> Any:
352
- self.spinner.hide()
353
- self.spinner = self.default_spinner
354
- result = func(self, *args, **kwargs)
355
- return result
356
-
357
- return wrapper
358
-
359
-
360
- class BaseView:
361
- def __init__(self, theme: Theme) -> None:
362
- self.theme = theme
363
-
364
- def render_event_summary(self, kind: str, event: dict[str, Any]) -> None:
365
- summary = event["messageData"]["__typename"]
366
- # if kind == "FileWriteEvent":
367
- # summary = "Writing file..."
368
-
369
- # elif kind == "FileWriteResultEvent":
370
- # summary = "File written"
371
-
372
- # elif kind == "CodeBlockEvent":
373
- # summary = "Executing code..."
374
-
375
- # elif kind == "CodeExecutionEvent":
376
- # summary = "Code executed"
377
-
378
- # elif kind == "CommandEvent":
379
- # if event["data"]["type"] == "FILE_READ":
380
- # summary = f"Reading file {event['data']['filePath']}..."
381
- # elif event["data"]["type"] == "FILE_OPEN":
382
- # summary = f"Opening file {event['data']['filePath']}..."
383
- # else:
384
- # summary = "Executing command..."
385
-
386
- # else:
387
- # return
388
-
389
- seqs = [
390
- clear_line_seq(),
391
- self._render_block_footer(summary, status="running", nl=False),
392
- ]
393
- render(seqs)
394
-
395
- def _render_block_header(self, text: str) -> list[str]:
396
- return [
397
- bg_color_seq(self.theme.block_header_bg),
398
- erase_line_seq(),
399
- bold_seq(),
400
- "\r", # Return to start of line
401
- f" {text} ",
402
- reset_attrs_seq(),
403
- "\n",
404
- ]
405
-
406
- def _render_block_footer(
407
- self, text: str, status: str | None = None, nl: bool = True
408
- ) -> list[str]:
409
- seqs: list[Any] = [
410
- bg_color_seq(self.theme.block_footer_bg),
411
- erase_line_seq(),
412
- italic_seq(),
413
- ]
414
-
415
- if status == "completed":
416
- seqs.append(
417
- [
418
- fg_color_seq(self.theme.green),
419
- bold_seq(),
420
- " ✓ ",
421
- not_bold_seq(),
422
- ]
423
- )
424
- elif status == "rejected":
425
- seqs.append(
426
- [
427
- fg_color_seq(self.theme.red),
428
- bold_seq(),
429
- " 𐄂 ",
430
- not_bold_seq(),
431
- ]
432
- )
433
- elif status == "running":
434
- seqs.append(
435
- [
436
- fg_color_seq(self.theme.blue),
437
- bold_seq(),
438
- " ",
439
- not_bold_seq(),
440
- ]
441
- )
442
- else:
443
- seqs.append(
444
- [
445
- bold_seq(),
446
- " ",
447
- not_bold_seq(),
448
- ]
449
- )
450
-
451
- seqs.append([fg_color_seq(self.theme.block_footer_fg), text, reset_attrs_seq()])
452
-
453
- if nl:
454
- seqs.append(["\n"])
455
-
456
- return seqs
457
-
458
- def _render_natural_edit_diff(self, write: dict[str, Any]) -> list[Any]:
459
- diff = generate_diff(write.get("originalFile") or "", write["newFile"]).strip()
460
-
461
- highlighted_diff = highlight_code(diff, "udiff", self.theme)
462
-
463
- return [
464
- self._render_block_header("Diff"),
465
- bg_color_seq(self.theme.block_body_bg),
466
- erase_line_seq(),
467
- "\n",
468
- highlighted_diff,
469
- "\n",
470
- ]
471
-
472
- def _render_natural_edit_error(self, error: str) -> list[str]:
473
- return [
474
- fg_color_seq(1),
475
- f"\nError: {error.strip()}\n\n",
476
- reset_attrs_seq(),
477
- ]
478
-
479
- def _render_code_block_start(self, lang: str) -> list[Any]:
480
- return [
481
- self._render_block_header(lang.capitalize()),
482
- ]
483
-
484
- def _render_code_block_content(self, content: str, lang: str) -> str:
485
- return highlight_code(content.strip(), lang, self.theme)
486
-
487
- def _render_code_block_output(self, content: str) -> list[Any]:
488
- content = re.sub("\n+$", "", content)
489
-
490
- if content.strip() == "":
491
- result = [
492
- [
493
- " ",
494
- fg_color_seq(self.theme.dimmed_text_fg),
495
- bg_color_seq(self.theme.block_body_bg),
496
- erase_line_seq(),
497
- "-- no output --\n",
498
- ]
499
- ]
500
- else:
501
- lines = pad_left(content, " ").split("\n")
502
-
503
- result = [
504
- [bg_color_seq(self.theme.block_body_bg), erase_line_seq(), line, "\n"]
505
- for line in lines
506
- ]
507
-
508
- return [
509
- self._render_block_header("Output"),
510
- bg_color_seq(self.theme.block_body_bg),
511
- erase_line_seq(),
512
- "\n",
513
- erase_line_seq(),
514
- result,
515
- bg_color_seq(self.theme.block_body_bg),
516
- erase_line_seq(),
517
- reset_attrs_seq(),
518
- "\n",
519
- ]
520
-
521
- def _render_file_write_block_start(self, path: str) -> list[Any]:
522
- return [
523
- self._render_block_header(f"Editing file {path}"),
524
- ]
525
-
526
- def _render_file_write_block_content(self, content: str, lang: str) -> str:
527
- return highlight_code(
528
- content.strip(),
529
- lang,
530
- self.theme,
531
- )
532
-
533
- def _render_file_write_block_result(self, result: str | None) -> list[Any]:
534
- result = result or "Edit applied"
535
- return [self._render_block_footer(f"{result}", status="completed")]
536
-
537
- def _render_command_block_start(self, type_: str) -> list[Any]:
538
- return [
539
- self._render_block_header(type_),
540
- ]
541
-
542
- def _render_command_block_content(self, data: dict[str, Any]) -> list[Any]:
543
- if data["type"] == "PROTOTYPE":
544
- content = data["contentRendered"]
545
- else:
546
- content = data["filePath"].strip()
547
-
548
- lines = pad_left(content, " ").split("\n")
549
- seqs: list[Any] = [bg_color_seq(self.theme.block_body_bg)]
550
- seqs.append([[erase_line_seq(), line, "\n"] for line in lines])
551
-
552
- return seqs
553
-
554
- def _render_command_block_output(
555
- self, content: str, type_: str, lang: str
556
- ) -> list[Any]:
557
- if content.strip() == "":
558
- result = [
559
- " ",
560
- fg_color_seq(self.theme.dimmed_text_fg),
561
- bg_color_seq(self.theme.block_body_bg),
562
- erase_line_seq(),
563
- "-- no output --\n",
564
- ]
565
- else:
566
- result = [
567
- highlight_code(
568
- content.strip(),
569
- lang,
570
- self.theme,
571
- )
572
- ]
573
-
574
- return [
575
- self._render_block_header("Output"),
576
- bg_color_seq(self.theme.block_body_bg),
577
- erase_line_seq(),
578
- "\n",
579
- result,
580
- bg_color_seq(self.theme.block_body_bg),
581
- erase_line_seq(),
582
- "\n",
583
- self._render_block_footer(
584
- f"{bold_seq()}{type_}{not_bold_seq()} command executed",
585
- status="completed",
586
- ),
587
- ]
588
-
589
- def _render_checkpoint_created_block_start(self) -> list[Any]:
590
- header_text = "".join(
591
- [
592
- bold_seq(),
593
- "✓ ",
594
- not_bold_seq(),
595
- "checkpoint created",
596
- ]
597
- )
598
- return [
599
- self._render_block_header(header_text),
600
- ]
601
-
602
- def _render_checkpoint_content(self, content: str) -> list[Any]:
603
- return [
604
- highlight_code(
605
- content.strip(),
606
- "text",
607
- self.theme,
608
- ),
609
- ]
610
-
611
- def _render_checkpoint_created_block(self, event: dict[str, Any]) -> list[Any]:
612
- content = f"{get_short_git_commit_hash(event['commitHash'])}: {event['commitMessage']}"
613
-
614
- return [
615
- self._render_checkpoint_created_block_start(),
616
- self._render_checkpoint_content(content),
617
- "\n",
618
- "\n",
619
- ]
620
-
621
- def _render_checkpoint_rollback_block_start(self) -> list[Any]:
622
- header_text = "".join(
623
- [
624
- bold_seq(),
625
- "✓ ",
626
- not_bold_seq(),
627
- "checkpoint rollback",
628
- ]
629
- )
630
- return [
631
- self._render_block_header(header_text),
632
- ]
633
-
634
- def _render_checkpoint_rollback_block(self, event: dict[str, Any]) -> list[Any]:
635
- pretty_date = datetime.fromisoformat(
636
- event["gitMetadata"]["commit_date"]
637
- ).strftime("%Y-%m-%d %H:%M:%S")
638
- content = (
639
- f"{get_short_git_commit_hash(event['commitHash'])}: {event['commitMessage']}\n"
640
- f"{event['gitMetadata']['author_name']} <{event['gitMetadata']['author_email']}> {pretty_date}"
641
- )
642
-
643
- return [
644
- self._render_checkpoint_rollback_block_start(),
645
- self._render_checkpoint_content(content),
646
- "\n",
647
- "\n",
648
- ]
649
-
650
- def _render_checkpoint_error_block_start(self) -> list[Any]:
651
- return [
652
- self._render_block_header(
653
- f"{bold_seq()}𐄂 {not_bold_seq()}checkpoint error"
654
- ),
655
- ]
656
-
657
- def _render_checkpoint_error_block(self, event: dict[str, Any]) -> list[Any]:
658
- return [
659
- self._render_checkpoint_error_block_start(),
660
- self._render_checkpoint_content(event["message"]),
661
- "\n",
662
- "\n",
663
- ]
664
-
665
- def _render_step_block_start(self, title: str) -> list[Any]:
666
- return [
667
- self._render_block_header(f"Step: {title}"),
668
- ]
669
-
670
- def _render_step_block_content(self, content: str) -> list[str]:
671
- """Render step content with proper formatting and background color.
672
- Returns a list of formatted lines ready for rendering.
673
- """
674
- # First reset any previous formatting
675
- reset_seq = reset_attrs_seq()
676
- erase_line_seq_str = erase_line_seq()
677
-
678
- # Apply padding and wrap lines with proper escape sequences
679
- formatted_lines = []
680
- lines = content.strip().split("\n")
681
-
682
- for line in lines:
683
- # erase_line_seq fills the entire line with the background color
684
- # We need to return to start of line first with \r
685
- formatted_line = f"\r{erase_line_seq_str} {line}{reset_seq}\n"
686
- formatted_lines.append(formatted_line)
687
-
688
- return formatted_lines
689
-
690
- def _render_step_result_content(self, content: str) -> list[Any]:
691
- formatted_content = pad_left(content, " ")
692
- return [
693
- self._render_block_header("Step result"),
694
- formatted_content,
695
- "\n",
696
- ]
697
-
698
-
699
- class LiveView(BaseView):
700
- def __init__(self, theme: Theme, render_user_messages: bool = False) -> None:
701
- super().__init__(theme)
702
- self.buffer: Buffer = NullBuffer()
703
- self.command_data: dict[str, Any] | None = None
704
- self.tool_uses: dict[str, dict[str, Any]] = {}
705
- self.confirmation_required: bool = True
706
- self.default_spinner = Spinner("Exponent is working...")
707
- self.msg_gen_spinner = Spinner("Exponent is thinking real hard...")
708
- (r, g, b) = theme.thinking_spinner_fg.rgb
709
- rgb = (round(r * 255), round(g * 255), round(b * 255))
710
- self.thinking_spinner = ThinkingSpinner(fg_color=rgb)
711
- self.step_spinner = Spinner("Exponent is working on a step...")
712
- self.thinking_start_time: float | None = None
713
- self.exec_spinner = Spinner(
714
- "Exponent is waiting for the code to finish running..."
715
- )
716
- self.diff_spinner = Spinner("Exponent is generating file diff...")
717
- self.spinner = self.default_spinner
718
- self.pending_event: dict[str, Any] | None = None
719
- self.pending_event_list: list[Any] = []
720
- self.render_user_messages = render_user_messages
721
-
722
- def render_event(self, kind: str, event: dict[str, Any]) -> None:
723
- message_type = event["messageData"]["__typename"]
724
- if kind == "UserEvent" and message_type == "TextMessage":
725
- self._handle_user_message_event(event)
726
- if (
727
- kind == "UserEvent" and message_type == "ToolResultMessage"
728
- ) or message_type == "PartialToolResultMessage":
729
- self._handle_command_result_event(event)
730
- elif kind == "AssistantEvent" and message_type == "TextMessage":
731
- self._handle_message_event(event)
732
- elif kind == "AssistantEvent" and message_type == "ToolCallMessage":
733
- self._handle_tool_call_event(event)
734
- elif kind == "SystemEvent" and message_type == "ToolPermissionStatusMessage":
735
- if event["messageData"]["permissionStatus"] == "requested":
736
- self._handle_confirmation_request_event(event)
737
- else:
738
- self._handle_confirmation_response_event(event)
739
- elif kind == "Error":
740
- print(event)
741
- # print(json.dumps(event))
742
- # elif kind == "CodeBlockEvent":
743
- # self._handle_code_block_event(event)
744
- # elif (
745
- # kind == "CommandChunkEvent"
746
- # and event.get("data", {}).get("__typename") == "ThinkingCommandData"
747
- # ):
748
- # # Start the thinking animation and record start time
749
- # if self.spinner != self.thinking_spinner:
750
- # if self.spinner:
751
- # self.spinner.hide()
752
- # self.spinner = self.thinking_spinner
753
- # # Record the start time before showing spinner
754
- # self.thinking_start_time = time.time()
755
- # # Pass the start time to spinner
756
- # self.thinking_spinner.start_time = self.thinking_start_time
757
- # self.spinner.show()
758
- # elif (
759
- # kind == "CommandEvent"
760
- # and event.get("data", {}).get("__typename") == "ThinkingCommandData"
761
- # ):
762
- # # Treat CommandEvent with ThinkingCommandData as ThinkingEvent
763
- # self._handle_thinking_event(
764
- # {
765
- # "eventUuid": event["uuid"],
766
- # "parentUuid": event.get("parentUuid"),
767
- # "turnUuid": event.get("turnUuid"),
768
- # "content": event["data"]["content"],
769
- # }
770
- # )
771
- # elif kind == "StepChunkEvent":
772
- # self._handle_step_chunk_event(event)
773
- # elif kind == "StepEvent":
774
- # self._handle_step_event(event)
775
- # elif kind == "StepConfirmationEvent":
776
- # self._handle_step_confirmation_event(event)
777
- # elif kind == "StepExecutionStartEvent":
778
- # self._handle_step_execution_start_event(event)
779
- # elif kind == "StepExecutionResultEvent":
780
- # self._handle_step_execution_result_event(event)
781
- # elif kind == "FileWriteChunkEvent":
782
- # self._handle_file_write_chunk_event(event)
783
- # elif kind == "FileWriteEvent":
784
- # self._handle_file_write_event(event)
785
- # elif kind == "FileWriteConfirmationEvent":
786
- # self._handle_file_write_confirmation_event(event)
787
- # elif kind == "FileWriteStartEvent":
788
- # self._handle_file_write_start_event(event)
789
- # elif kind == "FileWriteResultEvent":
790
- # self._handle_file_write_result_event(event)
791
- # elif kind == "CodeBlockChunkEvent":
792
- # self._handle_code_block_chunk_event(event)
793
- # elif kind == "CodeBlockEvent":
794
- # self._handle_code_block_event(event)
795
- # elif kind == "CodeBlockConfirmationEvent":
796
- # self._handle_code_block_confirmation_event(event)
797
- # elif kind == "CodeExecutionStartEvent":
798
- # self._handle_code_execution_start_event(event)
799
- # elif kind == "CodeExecutionEvent":
800
- # self._handle_code_execution_event(event)
801
- # elif kind == "CommandChunkEvent":
802
- # if event["data"]["type"] in ["FILE_READ", "FILE_OPEN", "PROTOTYPE"]:
803
- # self._handle_command_chunk_event(event)
804
- # elif kind == "CommandEvent":
805
- # if event["data"]["type"] in ["FILE_READ", "FILE_OPEN", "PROTOTYPE"]:
806
- # self._handle_tool_call_event(event)
807
- # elif kind == "CommandConfirmationEvent":
808
- # self._handle_command_confirmation_event(event)
809
- # elif kind == "CommandStartEvent":
810
- # self._handle_command_start_event(event)
811
- # elif kind == "CommandResultEvent":
812
- # self._handle_command_result_event(event)
813
- # elif kind == "Error" or kind == "ContextLimitExceededError":
814
- # error_message = event["message"]
815
- # raise ExponentError(error_message)
816
- # elif kind == "RateLimitError":
817
- # error_message = event["message"]
818
- # raise RateLimitError(error_message)
819
- # elif kind == "CheckpointCreatedEvent":
820
- # self._handle_checkpoint_created_event(event)
821
- # elif kind == "CheckpointRollbackEvent":
822
- # self._handle_checkpoint_rollback_event(event)
823
- # elif kind == "CheckpointError":
824
- # self._handle_checkpoint_error_event(event)
825
-
826
- def start_turn(self) -> None:
827
- self.spinner.show()
828
-
829
- def end_turn(self) -> None:
830
- self.spinner.hide()
831
-
832
- @pause_spinner
833
- def _handle_message_start_event(self, event: dict[str, Any]) -> None:
834
- self.spinner = self.msg_gen_spinner
835
-
836
- @pause_spinner
837
- def _handle_thinking_start_event(self, event: dict[str, Any]) -> None:
838
- # Use ThinkingSpinner for a specialized thinking animation
839
- self.spinner = self.thinking_spinner
840
- self.spinner.show()
841
- # Record the start time
842
- self.thinking_start_time = time.time()
843
-
844
- @pause_spinner
845
- def _handle_thinking_event(self, event: dict[str, Any]) -> None:
846
- # This handles the thinking event completion
847
- if self.spinner == self.thinking_spinner:
848
- self.spinner.hide()
849
- self.spinner = self.default_spinner
850
-
851
- # Calculate thinking duration
852
- if self.thinking_start_time is not None:
853
- duration = time.time() - self.thinking_start_time
854
- # Format duration nicely
855
- if duration < 1:
856
- duration_str = f"{int(duration * 1000)}ms"
857
- elif duration < 60:
858
- # Round to nearest second to match spinner display
859
- duration_str = f"{int(duration)}s"
860
- else:
861
- mins = int(duration // 60)
862
- secs = int(duration % 60)
863
- duration_str = f"{mins}m {secs}s"
864
-
865
- # Improved message with more natural language and decorations
866
- # Use render() instead of print() to ensure proper terminal rendering
867
- render(
868
- f"\r {fg_color_seq(self.theme.dimmed_text_fg)}Exponent thought for {duration_str}{reset_attrs_seq()}\n\n"
869
- )
870
-
871
- # Reset the start time
872
- self.thinking_start_time = None
873
-
874
- @stop_spinner
875
- def _handle_message_chunk_event(self, event: dict[str, Any]) -> None:
876
- event_uuid = event["uuid"]
877
- seqs = []
878
-
879
- if self.buffer.event_uuid != event_uuid:
880
- self.buffer = CharBuffer(event_uuid, get_term_width())
881
-
882
- assert isinstance(self.buffer, CharBuffer)
883
-
884
- seqs.append(self.buffer.render_new_chars(event["content"]))
885
- render(seqs)
886
-
887
- @pause_spinner
888
- def _handle_message_event(self, event: dict[str, Any]) -> None:
889
- event_uuid = event["uuid"]
890
- seqs = []
891
-
892
- if self.buffer.event_uuid != event_uuid:
893
- self.buffer = CharBuffer(event_uuid, get_term_width())
894
-
895
- assert isinstance(self.buffer, CharBuffer)
896
-
897
- seqs.append(self.buffer.render_new_chars(event["messageData"]["text"]))
898
- seqs.append(["\n\n"])
899
- render(seqs)
900
-
901
- def _handle_user_message_event(self, event: dict[str, Any]) -> None:
902
- event_uuid = event["uuid"]
903
- seqs = []
904
-
905
- if self.buffer.event_uuid != event_uuid:
906
- self.buffer = CharBuffer(event_uuid, get_term_width())
907
-
908
- assert isinstance(self.buffer, CharBuffer)
909
-
910
- seqs.append(self.buffer.render_new_chars(f"> {event['messageData']['text']}"))
911
- seqs.append(["\n\n"])
912
- render(seqs)
913
-
914
- @stop_spinner
915
- def _handle_file_write_chunk_event(self, event: dict[str, Any]) -> None:
916
- event_uuid = event["uuid"]
917
- seqs: list[Any] = []
918
-
919
- if self.buffer.event_uuid != event_uuid:
920
- self.buffer = LineBuffer(event_uuid)
921
-
922
- seqs.append(
923
- [
924
- self._render_block_header(f"Editing file {event['filePath']}"),
925
- bg_color_seq(self.theme.block_body_bg),
926
- erase_line_seq(),
927
- "\n",
928
- ]
929
- )
930
-
931
- assert isinstance(self.buffer, LineBuffer)
932
-
933
- write = event["writeContent"]
934
- content = write.get("naturalEdit") or write.get("content") or event["content"]
935
-
936
- formatted_content = self._render_file_write_block_content(
937
- content, event["language"]
938
- )
939
-
940
- seqs.append(self.buffer.render_new_lines(formatted_content, self.theme))
941
-
942
- if (
943
- "intermediateEdit" in write and write["intermediateEdit"] is not None
944
- ): # natural edit
945
- # when intermediateEdit is present in writeContent dict
946
- # it indicates the server is generating original/new file pair
947
- seqs.append(["\n", "\n"])
948
- render(seqs)
949
- self.spinner = self.diff_spinner
950
- self.spinner.show()
951
- else:
952
- render(seqs)
953
-
954
- @pause_spinner
955
- def _handle_file_write_event(self, event: dict[str, Any]) -> None:
956
- event_uuid = event["uuid"]
957
- seqs: list[Any] = []
958
-
959
- if self.buffer.event_uuid != event_uuid:
960
- self.buffer = LineBuffer(event_uuid)
961
- seqs.append(self._render_file_write_block_start(event["filePath"]))
962
-
963
- assert isinstance(self.buffer, LineBuffer)
964
-
965
- write = event["writeContent"]
966
-
967
- if "newFile" in write: # natural edit
968
- seqs.append(
969
- [
970
- "\r",
971
- move_cursor_up_seq(2),
972
- erase_display_seq(),
973
- ]
974
- )
975
-
976
- content = write.get("naturalEdit") or write.get("content") or event["content"]
977
-
978
- formatted_content = self._render_file_write_block_content(
979
- content, event["language"]
980
- )
981
-
982
- seqs.append(
983
- [
984
- self.buffer.render_new_lines(formatted_content, self.theme),
985
- bg_color_seq(self.theme.block_body_bg),
986
- erase_line_seq(),
987
- "\n",
988
- ]
989
- )
990
-
991
- error = write.get("errorContent")
992
-
993
- if error is None:
994
- if write.get("newFile") is not None:
995
- seqs.append(self._render_natural_edit_diff(write))
996
-
997
- if event["requireConfirmation"]:
998
- seqs.append(
999
- [
1000
- self._render_block_footer(
1001
- f"Confirm edit with {bold_seq()}<ctrl+y>{not_bold_seq()}, send a new message to dismiss code changes."
1002
- ),
1003
- "\n",
1004
- ]
1005
- )
1006
- else:
1007
- seqs.append(self._render_natural_edit_error(error))
1008
-
1009
- render(seqs)
1010
- self.confirmation_required = event["requireConfirmation"]
1011
-
1012
- @pause_spinner
1013
- def _handle_file_write_confirmation_event(self, event: dict[str, Any]) -> None:
1014
- if self.buffer.event_uuid != event["fileWriteUuid"]:
1015
- return
1016
-
1017
- seqs = []
1018
-
1019
- if event["accepted"]:
1020
- seqs.append(
1021
- [
1022
- move_cursor_up_seq(2),
1023
- self._render_block_footer("Applying edit...", status="running"),
1024
- "\n",
1025
- ]
1026
- )
1027
- elif self.confirmation_required:
1028
- # user entered new prompt, cursor moved down
1029
- # therefore we need to move it up, redraw status, and move it
1030
- # back where it was
1031
-
1032
- seqs.append(
1033
- [
1034
- move_cursor_up_seq(4),
1035
- self._render_block_footer("Edit dismissed", status="rejected"),
1036
- "\n\n\n",
1037
- ]
1038
- )
1039
-
1040
- render(seqs)
1041
-
1042
- @pause_spinner
1043
- def _handle_file_write_start_event(self, event: dict[str, Any]) -> None:
1044
- return
1045
-
1046
- @pause_spinner
1047
- def _handle_file_write_result_event(self, event: dict[str, Any]) -> None:
1048
- if self.buffer.event_uuid != event["fileWriteUuid"]:
1049
- return
1050
-
1051
- seqs: list[Any] = []
1052
-
1053
- if self.confirmation_required:
1054
- seqs.append([move_cursor_up_seq(2)])
1055
-
1056
- seqs.append([self._render_file_write_block_result(event["content"]), "\n"])
1057
-
1058
- render(seqs)
1059
-
1060
- @stop_spinner
1061
- def _handle_command_chunk_event(self, event: dict[str, Any]) -> None:
1062
- data = event["data"]
1063
-
1064
- # Ignore thinking chunks
1065
- if data["type"] == "THINKING":
1066
- return
1067
-
1068
- if (
1069
- data["type"] == "PROTOTYPE"
1070
- and data["commandName"] in ["search_files", "glob"]
1071
- ) or data["type"] == "FILE_READ":
1072
- # We want to render the _final_ regex in the header, so we don't render any chunk,
1073
- # instead we'll render the header below in _handle_command_event.
1074
- return
1075
-
1076
- type_ = self._get_command_type(data)
1077
-
1078
- event_uuid = event["uuid"]
1079
- seqs = []
1080
-
1081
- if self.buffer.event_uuid != event_uuid:
1082
- self.buffer = CommandBuffer(event_uuid)
1083
-
1084
- seqs.append(
1085
- [
1086
- self._render_block_header(type_),
1087
- bg_color_seq(self.theme.block_body_bg),
1088
- erase_line_seq(),
1089
- "\n",
1090
- ]
1091
- )
1092
-
1093
- assert isinstance(self.buffer, CommandBuffer)
1094
-
1095
- # NOTE: not rendering the contentRendered here incrementally
1096
- # because it's changing non-incrementally and breaks the layout
1097
-
1098
- render(seqs)
1099
-
1100
- @pause_spinner
1101
- def _handle_confirmation_request_event(self, event: dict[str, Any]) -> None:
1102
- self.pending_event = event
1103
- self.pending_event_list.append(event)
1104
- render(
1105
- [
1106
- self._render_block_footer(
1107
- f"Confirm with {bold_seq()}<ctrl+y>{not_bold_seq()}, send a new message to dismiss code changes."
1108
- ),
1109
- "\n",
1110
- ]
1111
- )
1112
-
1113
- @pause_spinner
1114
- def _handle_confirmation_response_event(self, event: dict[str, Any]) -> None:
1115
- self.pending_event_list.pop()
1116
-
1117
- @pause_spinner
1118
- def _handle_tool_call_event(self, event: dict[str, Any]) -> None:
1119
- data = event["messageData"]
1120
- event_uuid = event["uuid"]
1121
- command_name = data["toolName"]
1122
-
1123
- if self.buffer.event_uuid != event_uuid:
1124
- if command_name == "read_file":
1125
- self.buffer = CommandBuffer(event_uuid)
1126
- self._handle_read_file_event(event)
1127
- return
1128
- elif command_name == "bash":
1129
- self.buffer = LineBuffer(event_uuid)
1130
- self._handle_bash_event(event)
1131
- return
1132
-
1133
- @pause_spinner
1134
- def _handle_command_confirmation_event(self, event: dict[str, Any]) -> None:
1135
- if self.buffer.event_uuid != event["commandUuid"]:
1136
- return
1137
-
1138
- seqs = []
1139
-
1140
- if event["accepted"]:
1141
- seqs.append(
1142
- [
1143
- move_cursor_up_seq(2),
1144
- self._render_block_footer("Executing command...", status="running"),
1145
- "\n",
1146
- ]
1147
- )
1148
- else:
1149
- # user entered new prompt, cursor moved down
1150
- # therefore we need to move it up, redraw status, and move it
1151
- # back where it was
1152
-
1153
- seqs.append(
1154
- [
1155
- move_cursor_up_seq(4),
1156
- self._render_block_footer(
1157
- "Command did not execute", status="rejected"
1158
- ),
1159
- "\n\n\n",
1160
- ]
1161
- )
1162
-
1163
- render(seqs)
1164
-
1165
- def _handle_command_start_event(self, event: dict[str, Any]) -> None:
1166
- return
1167
-
1168
- @pause_spinner
1169
- def _handle_command_result_event(self, event: dict[str, Any]) -> None:
1170
- if self.command_data is None:
1171
- return
1172
-
1173
- seqs: list[Any] = []
1174
-
1175
- # For FILE_READ commands, only show that the command ran successfully, not the file contents
1176
- if self.command_data["toolName"] == "read_file":
1177
- seqs.append(
1178
- [
1179
- self._render_block_footer(
1180
- "File read successfully", status="completed"
1181
- ),
1182
- "\n",
1183
- ]
1184
- )
1185
- else:
1186
- seqs.append(
1187
- [
1188
- self._render_command_block_output(
1189
- event["messageData"]["text"],
1190
- self.command_data["toolName"],
1191
- "bash",
1192
- ),
1193
- "\n",
1194
- ]
1195
- )
1196
-
1197
- render(seqs)
1198
- self.command_data = None
1199
-
1200
- @stop_spinner
1201
- def _handle_code_block_chunk_event(self, event: dict[str, Any]) -> None:
1202
- event_uuid = event["uuid"]
1203
- seqs = []
1204
-
1205
- if self.buffer.event_uuid != event_uuid:
1206
- self.buffer = LineBuffer(event_uuid)
1207
-
1208
- seqs.append(
1209
- [
1210
- self._render_block_header(event["language"].capitalize()),
1211
- bg_color_seq(self.theme.block_body_bg),
1212
- erase_line_seq(),
1213
- "\n",
1214
- ]
1215
- )
1216
-
1217
- assert isinstance(self.buffer, LineBuffer)
1218
-
1219
- formatted_content = self._render_code_block_content(
1220
- event["content"], event["language"]
1221
- )
1222
-
1223
- seqs.append(self.buffer.render_new_lines(formatted_content, self.theme))
1224
- render(seqs)
1225
-
1226
- @pause_spinner
1227
- def _handle_read_file_event(self, event: dict[str, Any]) -> None:
1228
- data = event["messageData"]
1229
- tool_input = data["toolInput"]
1230
- seqs: list[Any] = []
1231
-
1232
- path = tool_input["filePath"]
1233
- seqs.append(self._render_command_block_start(f"Read file: {path}"))
1234
-
1235
- assert isinstance(self.buffer, CommandBuffer)
1236
-
1237
- render(seqs)
1238
- self.command_data = data
1239
- # self.tool_uses[tool_input["tool_use_id"]] =
1240
-
1241
- @pause_spinner
1242
- def _handle_bash_event(self, event: dict[str, Any]) -> None:
1243
- data = event["messageData"]
1244
- event_uuid = event["uuid"]
1245
- seqs: list[Any] = []
1246
-
1247
- if self.buffer.event_uuid != event_uuid:
1248
- self.buffer = LineBuffer(event_uuid)
1249
- seqs.append(self._render_code_block_start("bash"))
1250
-
1251
- assert isinstance(self.buffer, LineBuffer)
1252
-
1253
- formatted_content = self._render_code_block_content(
1254
- event["messageData"]["toolInput"]["command"], "bash"
1255
- )
1256
-
1257
- seqs.append(
1258
- [
1259
- self.buffer.render_new_lines(formatted_content, self.theme),
1260
- bg_color_seq(self.theme.block_body_bg),
1261
- erase_line_seq(),
1262
- "\n",
1263
- ]
1264
- )
1265
-
1266
- render(seqs)
1267
- self.command_data = data
1268
-
1269
- @pause_spinner
1270
- def _handle_code_block_event(self, event: dict[str, Any]) -> None:
1271
- event_uuid = event["uuid"]
1272
- seqs: list[Any] = []
1273
-
1274
- if self.buffer.event_uuid != event_uuid:
1275
- self.buffer = LineBuffer(event_uuid)
1276
- seqs.append(self._render_code_block_start(event["language"]))
1277
-
1278
- assert isinstance(self.buffer, LineBuffer)
1279
-
1280
- formatted_content = self._render_code_block_content(
1281
- event["content"], event["language"]
1282
- )
1283
-
1284
- seqs.append(
1285
- [
1286
- self.buffer.render_new_lines(formatted_content, self.theme),
1287
- bg_color_seq(self.theme.block_body_bg),
1288
- erase_line_seq(),
1289
- "\n",
1290
- ]
1291
- )
1292
-
1293
- if event["requireConfirmation"]:
1294
- seqs.append(
1295
- [
1296
- self._render_block_footer(
1297
- f"Run it with {bold_seq()}<ctrl+y>{not_bold_seq()}, send a new message to cancel this request."
1298
- ),
1299
- "\n",
1300
- ]
1301
- )
1302
-
1303
- render(seqs)
1304
- self.confirmation_required = event["requireConfirmation"]
1305
-
1306
- @pause_spinner
1307
- def _handle_code_block_confirmation_event(self, event: dict[str, Any]) -> None:
1308
- if self.buffer.event_uuid != event["codeBlockUuid"]:
1309
- return
1310
-
1311
- seqs = []
1312
-
1313
- if event["accepted"]:
1314
- seqs.append(
1315
- [
1316
- move_cursor_up_seq(2),
1317
- self._render_block_footer("Running code...", status="running"),
1318
- "\n",
1319
- ]
1320
- )
1321
- else:
1322
- # user entered new prompt, cursor moved down
1323
- # therefore we need to move it up, redraw status, and move it
1324
- # back where it was
1325
-
1326
- seqs.append(
1327
- [
1328
- move_cursor_up_seq(4),
1329
- self._render_block_footer(
1330
- "Code did not execute", status="rejected"
1331
- ),
1332
- "\n\n\n",
1333
- ]
1334
- )
1335
-
1336
- render(seqs)
1337
-
1338
- @pause_spinner
1339
- def _handle_code_execution_start_event(self, event: dict[str, Any]) -> None:
1340
- self.spinner = self.exec_spinner
1341
-
1342
- @pause_spinner
1343
- def _handle_code_execution_event(self, event: dict[str, Any]) -> None:
1344
- if self.buffer.event_uuid != event["codeBlockUuid"]:
1345
- return
1346
-
1347
- seqs: list[Any] = []
1348
-
1349
- if self.confirmation_required:
1350
- seqs.append([move_cursor_up_seq(2)])
1351
-
1352
- seqs.append([self._render_code_block_output(event["content"]), "\n"])
1353
-
1354
- render(seqs)
1355
-
1356
- def _get_command_type(self, data: dict[str, Any]) -> str:
1357
- if data["type"] == "PROTOTYPE":
1358
- return " ".join(
1359
- [word.capitalize() for word in data["commandName"].split("_")]
1360
- )
1361
- elif data["type"] == "FILE_READ":
1362
- return "Read File"
1363
- elif data["type"] == "FILE_OPEN":
1364
- return "Open File"
1365
- else:
1366
- return str(data["type"]).title().replace("_", " ")
1367
-
1368
- def _handle_checkpoint_created_event(self, event: dict[str, Any]) -> None:
1369
- render(self._render_checkpoint_created_block(event))
1370
-
1371
- def _handle_checkpoint_rollback_event(self, event: dict[str, Any]) -> None:
1372
- render(self._render_checkpoint_rollback_block(event))
1373
-
1374
- def _handle_checkpoint_error_event(self, event: dict[str, Any]) -> None:
1375
- render(self._render_checkpoint_error_block(event))
1376
-
1377
- @pause_spinner
1378
- def _handle_step_chunk_event(self, event: dict[str, Any]) -> None:
1379
- """Handle streaming chunks for step events.
1380
-
1381
- Simply shows a spinner while streaming step chunks.
1382
- We'll let the final StepEvent handle the full rendering.
1383
- """
1384
- # Just show a spinner for step chunks coming in
1385
- # Don't render partial content to avoid duplication
1386
- self.spinner = self.step_spinner
1387
- self.spinner.show()
1388
-
1389
- @pause_spinner
1390
- def _handle_step_event(self, event: dict[str, Any]) -> None:
1391
- """Handle a step event by rendering it with proper ANSI formatting.
1392
-
1393
- This is modeled after the file_write_event handler for consistency.
1394
- """
1395
- event_uuid = event["uuid"]
1396
- seqs = []
1397
-
1398
- self.buffer = LineBuffer(event_uuid)
1399
-
1400
- # Get title with fallback
1401
- title = event.get("stepTitle") or "Step"
1402
- seqs.append(self._render_block_header(f"Step: {title}"))
1403
-
1404
- # Get content with fallbacks and format it
1405
- content = (
1406
- event.get("stepContent")
1407
- or event.get("stepDescription", "")
1408
- or event.get("description", "")
1409
- )
1410
-
1411
- if content:
1412
- # Format lines with proper background color
1413
- lines = content.strip().split("\n")
1414
- formatted_lines = []
1415
- for line in lines:
1416
- formatted_lines.append(
1417
- [
1418
- erase_line_seq(),
1419
- " " + line,
1420
- reset_attrs_seq(),
1421
- "\n",
1422
- ]
1423
- )
1424
- seqs.extend(formatted_lines)
1425
-
1426
- seqs.append(["\n"])
1427
-
1428
- # Check if this step requires confirmation
1429
- require_confirmation = event.get("requireConfirmation", False)
1430
- if require_confirmation:
1431
- seqs.append(
1432
- [
1433
- *self._render_block_footer(
1434
- "Confirm step with <ctrl+y>. Sending a new message will cancel this step."
1435
- ),
1436
- "\n",
1437
- ]
1438
- )
1439
-
1440
- # Render it all at once
1441
- render(seqs)
1442
-
1443
- # Store confirmation state and step event for later
1444
- self.confirmation_required = require_confirmation
1445
- if require_confirmation:
1446
- self.pending_event = event
1447
-
1448
- @pause_spinner
1449
- def _handle_step_confirmation_event(self, event: dict[str, Any]) -> None:
1450
- """Handle confirmation events for steps.
1451
-
1452
- Updates the UI to show that a step has been confirmed or rejected.
1453
- """
1454
- # Copy exactly the file_write_confirmation_event handler pattern
1455
- # This pattern is known to work correctly
1456
- if self.buffer.event_uuid != event["stepUuid"]:
1457
- return
1458
-
1459
- seqs = []
1460
-
1461
- if event["accepted"]:
1462
- seqs.append(
1463
- [
1464
- move_cursor_up_seq(2),
1465
- self._render_block_footer("Executing step...", status="running"),
1466
- "\n",
1467
- ]
1468
- )
1469
- else:
1470
- # user entered new prompt, cursor moved down
1471
- # therefore we need to move it up, redraw status, and move it
1472
- # back where it was
1473
- seqs.append(
1474
- [
1475
- move_cursor_up_seq(4),
1476
- self._render_block_footer(
1477
- "Step did not execute", status="rejected"
1478
- ),
1479
- "\n\n\n",
1480
- ]
1481
- )
1482
-
1483
- render(seqs)
1484
-
1485
- # If the step was rejected, clear pending state
1486
- if not event["accepted"]:
1487
- self.pending_event = None
1488
- self.confirmation_required = False
1489
-
1490
- @pause_spinner
1491
- def _handle_step_execution_start_event(self, event: dict[str, Any]) -> None:
1492
- """Handle the start of step execution.
1493
-
1494
- Sets the appropriate spinner and does basic validation.
1495
- """
1496
- try:
1497
- # Log the event UUID for debugging
1498
- step_uuid = event.get("stepUuid")
1499
-
1500
- # Only change spinner if we have a step UUID
1501
- if step_uuid and self.buffer.event_uuid.startswith("step-"):
1502
- self.spinner = self.step_spinner
1503
- self.spinner.show()
1504
- except Exception as e:
1505
- # Log the error but don't crash the entire UI
1506
- render([f"\r\x1b[31mError handling step execution start: {e!s}\x1b[0m\n"])
1507
-
1508
- @pause_spinner
1509
- def _handle_step_execution_result_event(self, event: dict[str, Any]) -> None:
1510
- """Handle the result of a step execution.
1511
-
1512
- Shows the step output/result and marks the step as completed.
1513
- This is modeled after the file_write_result_event handler.
1514
- """
1515
- # Display results regardless of UUID matching to ensure we always see them
1516
- seqs = []
1517
-
1518
- # Check for result content with fallbacks
1519
- result = event.get("stepOutputRaw") or event.get("stepSummary", "")
1520
- if result:
1521
- # Render a result block with proper formatting
1522
- seqs.append(self._render_block_header("Step result"))
1523
-
1524
- # Format lines with proper background color
1525
- lines = result.strip().split("\n")
1526
- formatted_lines = []
1527
-
1528
- for line in lines:
1529
- formatted_lines.append(
1530
- [
1531
- erase_line_seq(),
1532
- " " + line,
1533
- reset_attrs_seq(),
1534
- "\n",
1535
- ]
1536
- )
1537
-
1538
- seqs.extend(formatted_lines)
1539
- seqs.append(["\n"])
1540
-
1541
- # Add the completion footer
1542
- seqs.append(self._render_block_footer("Step completed", status="completed"))
1543
- seqs.append(["\n"])
1544
-
1545
- render(seqs)
1546
-
1547
- # Reset all state now that execution is complete
1548
- self.pending_event = None
1549
- self.confirmation_required = False
1550
- self.spinner = self.default_spinner
1551
-
1552
- def handle_checkpoint_interrupt(self, event: dict[str, Any]) -> None:
1553
- self.buffer = NullBuffer()
1554
- render(["Now back to it:", "\n", "\n"])
1555
- self.render_event(event["__typename"], event)
1556
-
1557
- # rendering the event toggles on the spinner
1558
- self.end_turn()
1559
-
1560
-
1561
- class StaticView(BaseView):
1562
- def __init__(self, theme: Theme) -> None:
1563
- super().__init__(theme)
1564
- self.command_data: dict[str, Any] | None = None
1565
- self.thinking_start_time: float | None = None
1566
- self.pending_event: dict[str, Any] | None = None
1567
- self.confirmation_required: bool = False
1568
-
1569
- def render_event(self, kind: str, event: dict[str, Any]) -> None:
1570
- if kind == "MessageEvent":
1571
- if event["role"] == "assistant":
1572
- print(event["content"].strip())
1573
- print()
1574
-
1575
- elif (
1576
- kind == "CommandChunkEvent"
1577
- and event.get("data", {}).get("__typename") == "ThinkingCommandData"
1578
- ):
1579
- # Record start time for thinking
1580
- self.thinking_start_time = time.time()
1581
-
1582
- elif (
1583
- kind == "CommandEvent"
1584
- and event.get("data", {}).get("__typename") == "ThinkingCommandData"
1585
- ):
1586
- # Calculate thinking duration
1587
- if self.thinking_start_time is not None:
1588
- duration = time.time() - self.thinking_start_time
1589
- # Format duration nicely
1590
- if duration < 1:
1591
- duration_str = f"{int(duration * 1000)}ms"
1592
- elif duration < 60:
1593
- # Round to nearest second to match spinner display
1594
- duration_str = f"{int(duration)}s"
1595
- else:
1596
- mins = int(duration // 60)
1597
- secs = int(duration % 60)
1598
- duration_str = f"{mins}m {secs}s"
1599
-
1600
- # Improved message with more natural language and decorations
1601
- # Use render() instead of print() to ensure proper terminal rendering
1602
- render(
1603
- [
1604
- f"\x1b[2m\x1b[38;5;249m◇ Exponent thought for {duration_str} ◇\x1b[0m\n\n"
1605
- ]
1606
- )
1607
-
1608
- # Reset the start time
1609
- self.thinking_start_time = None
1610
-
1611
- elif kind == "StepChunkEvent" or kind == "StepEvent":
1612
- title = event.get("stepTitle") or "Step"
1613
- content = (
1614
- event.get("stepContent")
1615
- or event.get("stepDescription", "")
1616
- or event.get("description", "")
1617
- )
1618
-
1619
- if content:
1620
- # For StaticView, we use simpler formatting without ANSI sequences
1621
- formatted_content = ""
1622
- for line in content.strip().split("\n"):
1623
- formatted_content += f" {line}\n"
1624
-
1625
- seqs = [
1626
- self._render_block_header(f"Step: {title}"),
1627
- formatted_content,
1628
- "\n",
1629
- ]
1630
-
1631
- # Store for confirmation if needed
1632
- if kind == "StepEvent" and event.get("requireConfirmation", False):
1633
- self.pending_event = event
1634
- self.confirmation_required = True
1635
- seqs.append(
1636
- self._render_block_footer(
1637
- "Confirm step with <ctrl+y>. Sending a new message will cancel this step."
1638
- )
1639
- )
1640
-
1641
- render(seqs)
1642
-
1643
- elif kind == "StepExecutionResultEvent":
1644
- result = event.get("stepOutputRaw") or event.get("stepSummary", "")
1645
- if result:
1646
- # For StaticView, we use simpler formatting without ANSI sequences
1647
- formatted_result = ""
1648
- for line in result.strip().split("\n"):
1649
- formatted_result += f" {line}\n"
1650
-
1651
- seqs = [
1652
- self._render_block_header("Step result"),
1653
- formatted_result,
1654
- "\n",
1655
- self._render_block_footer("Step completed", status="completed"),
1656
- "\n",
1657
- ]
1658
- render(seqs)
1659
-
1660
- # Clear pending event and reset confirmation flag
1661
- self.pending_event = None
1662
- self.confirmation_required = False
1663
-
1664
- elif kind == "CommandEvent":
1665
- if event.get("data", {}).get("__typename") == "ThinkingCommandData":
1666
- # We already handled this case above
1667
- pass
1668
- elif event.get("data", {}).get("type") in ["FILE_READ", "FILE_OPEN"]:
1669
- command_type = (
1670
- "Read File"
1671
- if event.get("data", {}).get("type") == "FILE_READ"
1672
- else "Open File"
1673
- )
1674
-
1675
- seqs = [
1676
- self._render_command_block_start(command_type),
1677
- self._render_command_block_content(event["data"]),
1678
- "\n",
1679
- ]
1680
-
1681
- render(seqs)
1682
- self.command_data = event["data"]
1683
-
1684
- elif kind == "FileWriteEvent":
1685
- write = event["writeContent"]
1686
-
1687
- content = (
1688
- write.get("naturalEdit") or write.get("content") or event["content"]
1689
- )
1690
-
1691
- seqs = [
1692
- self._render_file_write_block_start(event["filePath"]),
1693
- self._render_file_write_block_content(content, event["language"]),
1694
- "\n",
1695
- ]
1696
-
1697
- error = write.get("errorContent")
1698
-
1699
- if error is None:
1700
- if write.get("newFile") is not None:
1701
- seqs.append(self._render_natural_edit_diff(write))
1702
- else:
1703
- seqs.append(self._render_natural_edit_error(error))
1704
-
1705
- render(seqs)
1706
-
1707
- elif kind == "FileWriteResultEvent":
1708
- seqs = [self._render_file_write_block_result(event["content"]), "\n"]
1709
- render(seqs)
1710
-
1711
- elif kind == "CodeBlockEvent":
1712
- seqs = [
1713
- self._render_code_block_start(event["language"]),
1714
- self._render_code_block_content(event["content"], event["language"]),
1715
- "\n",
1716
- ]
1717
-
1718
- render(seqs)
1719
-
1720
- elif kind == "CodeExecutionEvent":
1721
- render([self._render_code_block_output(event["content"]), "\n"])
1722
-
1723
- elif kind == "CommandEvent":
1724
- if event["data"]["type"] not in ["FILE_READ", "FILE_OPEN"]:
1725
- return
1726
-
1727
- command_type = (
1728
- "Read File" if event["data"]["type"] == "FILE_READ" else "Open File"
1729
- )
1730
-
1731
- seqs = [
1732
- self._render_command_block_start(command_type),
1733
- self._render_command_block_content(event["data"]),
1734
- "\n",
1735
- ]
1736
-
1737
- render(seqs)
1738
- self.command_data = event["data"]
1739
-
1740
- elif kind == "CommandResultEvent":
1741
- if self.command_data is not None:
1742
- # For FILE_READ commands, only show that the command ran successfully
1743
- if self.command_data["type"] == "FILE_READ":
1744
- seqs = [
1745
- self._render_block_footer(
1746
- "File read successfully", status="completed"
1747
- ),
1748
- "\n",
1749
- ]
1750
- else:
1751
- command_type = (
1752
- "Read File"
1753
- if self.command_data["type"] == "FILE_READ"
1754
- else "Open File"
1755
- )
1756
- seqs = [
1757
- self._render_command_block_output(
1758
- event["content"],
1759
- command_type,
1760
- self.command_data["language"],
1761
- ),
1762
- "\n",
1763
- ]
1764
-
1765
- render(seqs)
1766
- self.command_data = None
1767
-
1768
- elif kind == "Error":
1769
- error_message = event["message"]
1770
- print(event)
1771
- print(f"Error: {error_message}")
1772
- print()
1773
- elif kind == "RateLimitError":
1774
- error_message = event["message"]
1775
- print(event)
1776
- print(f"Error: {error_message}")
1777
- print(
1778
- "Visit https://www.exponent.run/settings to update your billing settings."
1779
- )
1780
- print()
1781
-
1782
- elif kind == "CheckpointError":
1783
- print(f"Checkpoint error: {event['message']}")
1784
- print()
1785
-
1786
- elif kind == "CheckpointCreatedEvent":
1787
- render(self._render_checkpoint_created_block(event))
1788
-
1789
- def start_turn(self) -> None:
1790
- pass
1791
-
1792
- def end_turn(self) -> None:
1793
- pass
1794
-
1795
- def handle_checkpoint_interrupt(self, event: dict[str, Any]) -> None:
1796
- pass
1797
-
1798
-
1799
- class Chat:
1800
- def __init__(
1801
- self,
1802
- chat_uuid: str,
1803
- parent_event_uuid: str | None,
1804
- base_url: str,
1805
- working_directory: str,
1806
- gql_client: GraphQLClient,
1807
- model: str,
1808
- strategy: str,
1809
- autorun: bool,
1810
- depth: int,
1811
- view: StaticView | LiveView,
1812
- checkpoints: list[dict[str, Any]],
1813
- thinking: bool = False,
1814
- ) -> None:
1815
- self.chat_uuid = chat_uuid
1816
- self.base_url = base_url
1817
- self.working_directory = working_directory
1818
- self.gql_client = gql_client
1819
- self.model = model
1820
- self.strategy = strategy
1821
- self.auto_confirm_mode: AutoConfirmMode = (
1822
- AutoConfirmMode.ALL if autorun else AutoConfirmMode.OFF
1823
- )
1824
- self.depth = depth
1825
- self.thinking = thinking
1826
- self.view = view
1827
- self.pending_event = None
1828
- self.pending_event_list: list[Any] = []
1829
- self.parent_uuid: str | None = parent_event_uuid
1830
- self.block_row_offset = 0
1831
- self.console = Console()
1832
- self.checkpoints = checkpoints
1833
- self.inside_step_execution = False
1834
-
1835
- async def send_prompt(self, prompt: str) -> None:
1836
- self.view.start_turn()
1837
- paths = self.extract_attachment_paths(prompt)
1838
- attachments = [(await self.build_attachment(path)) for path in paths]
1839
-
1840
- await self.process_chat_subscription(
1841
- {"prompt": {"message": prompt, "attachments": attachments}}
1842
- )
1843
-
1844
- def extract_attachment_paths(self, prompt: str) -> list[str]:
1845
- paths = [
1846
- prompt[match.start() : match.end()].strip()[1:]
1847
- for match in ATTACHMENT_PATH_PATTERN.finditer(prompt)
1848
- ]
1849
-
1850
- return list(filter(os.path.isfile, paths))
1851
-
1852
- async def build_attachment(self, path: str) -> dict[str, Any]:
1853
- content = await safe_read_file(path)
1854
-
1855
- return {
1856
- "fileAttachment": {
1857
- "file": {
1858
- "filePath": path,
1859
- "workingDirectory": self.working_directory,
1860
- },
1861
- "content": content,
1862
- }
1863
- }
1864
-
1865
- async def send_confirmation(self) -> None:
1866
- self.view.start_turn()
1867
- if not self.pending_event_list:
1868
- return
1869
-
1870
- pending_event = self.pending_event_list.pop()
1871
- await self.confirm_and_continue_subscription(
1872
- {
1873
- "requestUuid": pending_event["uuid"],
1874
- }
1875
- )
1876
-
1877
- async def send_direct_action(self, action_type: str, args: dict[str, Any]) -> None:
1878
- self.view.start_turn()
1879
- print()
1880
- if action_type == "shell":
1881
- await self.process_chat_subscription(
1882
- {"directAction": {"shellDirectAction": args}}
1883
- )
1884
- elif action_type == "checkpoint":
1885
- await self.process_chat_subscription(
1886
- {"directAction": {"createCheckpointDirectAction": args}}
1887
- )
1888
-
1889
- if self.pending_event:
1890
- self.view.handle_checkpoint_interrupt(self.pending_event)
1891
- elif action_type == "rollback":
1892
- await self.process_chat_subscription(
1893
- {"directAction": {"checkpointRollbackDirectAction": args}}
1894
- )
1895
- else:
1896
- raise ValueError(f"Unsupported direct action type: {action_type}")
1897
-
1898
- def toggle_autorun(self) -> None:
1899
- if self.auto_confirm_mode == AutoConfirmMode.OFF:
1900
- self.auto_confirm_mode = AutoConfirmMode.READ_ONLY
1901
- elif self.auto_confirm_mode == AutoConfirmMode.READ_ONLY:
1902
- self.auto_confirm_mode = AutoConfirmMode.ALL
1903
- else:
1904
- self.auto_confirm_mode = AutoConfirmMode.OFF
1905
-
1906
- def toggle_thinking(self) -> bool:
1907
- self.thinking = not self.thinking
1908
- return self.thinking
1909
-
1910
- async def halt_stream(self) -> None:
1911
- await self.gql_client.execute(
1912
- HALT_CHAT_STREAM_MUTATION, {"chatUuid": self.chat_uuid}, "HaltChatStream"
1913
- )
1914
-
1915
- def url(self) -> str:
1916
- return f"{self.base_url}/chats/{self.chat_uuid}"
1917
-
1918
- async def process_chat_subscription(self, extra_vars: dict[str, Any]) -> None:
1919
- vars = {
1920
- "chatUuid": self.chat_uuid,
1921
- "parentUuid": self.parent_uuid,
1922
- "model": self.model,
1923
- "strategyNameOverride": self.strategy,
1924
- "requireConfirmation": self.auto_confirm_mode == AutoConfirmMode.OFF,
1925
- "readOnly": self.auto_confirm_mode == AutoConfirmMode.READ_ONLY,
1926
- "depthLimit": self.depth,
1927
- "enableThinking": self.thinking,
1928
- }
1929
-
1930
- vars.update(extra_vars)
1931
-
1932
- try:
1933
- async for response in self.gql_client.subscribe(
1934
- INDENT_EVENTS_SUBSCRIPTION, vars
1935
- ):
1936
- # print(response)
1937
- event = response["indentChat"]
1938
- kind = event["__typename"]
1939
- message_kind = event["messageData"]["__typename"]
1940
- message_data = event["messageData"]
1941
-
1942
- if event["isSidechain"]:
1943
- self.inside_step_execution = True
1944
- else:
1945
- self.inside_step_execution = False
1946
-
1947
- if (
1948
- kind == "SystemEvent"
1949
- and message_kind == "ToolPermissionStatusMessage"
1950
- ):
1951
- if message_data["permissionStatus"] == "REQUESTED":
1952
- self.pending_event_list.append(event)
1953
-
1954
- # Render the event
1955
- if not self.inside_step_execution:
1956
- self.view.render_event(kind, event)
1957
- else:
1958
- self.view.render_event_summary(kind, event)
1959
-
1960
- # # Track parent UUID for future requests
1961
- self.parent_uuid = event.get("uuid") or self.parent_uuid
1962
-
1963
- except Exception as e:
1964
- # Log subscription processing errors only if needed
1965
- import traceback
1966
-
1967
- traceback.print_exc()
1968
- render([f"\r\x1b[31mError: {e!s}\x1b[0m\n"])
1969
- finally:
1970
- self.view.end_turn()
1971
-
1972
- async def confirm_and_continue_subscription(
1973
- self, extra_vars: dict[str, Any]
1974
- ) -> None:
1975
- vars = {
1976
- "chatUuid": self.chat_uuid,
1977
- "model": self.model,
1978
- "strategyNameOverride": self.strategy,
1979
- "requireConfirmation": self.auto_confirm_mode == AutoConfirmMode.OFF,
1980
- "readOnly": self.auto_confirm_mode == AutoConfirmMode.READ_ONLY,
1981
- "depthLimit": self.depth,
1982
- "enableThinking": self.thinking,
1983
- }
1984
-
1985
- vars.update(extra_vars)
1986
-
1987
- try:
1988
- async for response in self.gql_client.subscribe(
1989
- CONFIRM_AND_CONTINUE_SUBSCRIPTION, vars
1990
- ):
1991
- # print(response)
1992
- event = response["confirmAndContinue"]
1993
- kind = event["__typename"]
1994
- message_kind = event["messageData"]["__typename"]
1995
- message_data = event["messageData"]
1996
-
1997
- if event["isSidechain"]:
1998
- self.inside_step_execution = True
1999
- else:
2000
- self.inside_step_execution = False
2001
-
2002
- if (
2003
- kind == "SystemEvent"
2004
- and message_kind == "ToolPermissionStatusMessage"
2005
- ):
2006
- if message_data["permissionStatus"] == "requested":
2007
- self.pending_event_list.append(event)
2008
-
2009
- # Render the event
2010
- if not self.inside_step_execution:
2011
- self.view.render_event(kind, event)
2012
- else:
2013
- self.view.render_event_summary(kind, event)
2014
-
2015
- # # Track parent UUID for future requests
2016
- self.parent_uuid = event.get("uuid") or self.parent_uuid
2017
-
2018
- except Exception as e:
2019
- # Log subscription processing errors only if needed
2020
- import traceback
2021
-
2022
- traceback.print_exc()
2023
- render([f"\r\x1b[31mError: {e!s}\x1b[0m\n"])
2024
- finally:
2025
- self.view.end_turn()
2026
-
2027
-
2028
- class HighlightLexer(Lexer):
2029
- def lex_document(self, document: Document) -> Any:
2030
- def get_line(line_number: int) -> list[tuple[str, str]]:
2031
- line_text = document.lines[line_number]
2032
- tokens = []
2033
- last_index = 0
2034
-
2035
- for match in ATTACHMENT_PATH_PATTERN.finditer(line_text):
2036
- start, end = match.span()
2037
-
2038
- if start > last_index:
2039
- tokens.append(("", line_text[last_index:start]))
2040
-
2041
- tokens.append(("class:attachment-path", line_text[start:end]))
2042
- last_index = end
2043
-
2044
- if last_index < len(line_text):
2045
- tokens.append(("", line_text[last_index:]))
2046
-
2047
- return tokens
2048
-
2049
- return get_line
2050
-
2051
-
2052
- class InputHandler:
2053
- def __init__(
2054
- self, theme: Theme, loop: AbstractEventLoop, file_cache: FileCache
2055
- ) -> None:
2056
- self.theme = theme
2057
- self.bindings = KeyBindings()
2058
- self.shortcut = None
2059
- self.default = ""
2060
-
2061
- self.session: PromptSession[Any] = PromptSession(
2062
- completer=FuzzyCompleter(ExponentCompleter(loop, file_cache)),
2063
- complete_while_typing=True,
2064
- complete_in_thread=True,
2065
- key_bindings=merge_key_bindings([load_key_bindings(), self.bindings]),
2066
- lexer=HighlightLexer(),
2067
- )
2068
-
2069
- self.style = Style.from_dict(
2070
- {
2071
- "bottom-toolbar": "noreverse",
2072
- "attachment-path": "underline",
2073
- }
2074
- )
2075
-
2076
- handler = self
2077
-
2078
- @self.bindings.add("enter")
2079
- def _(event: Any) -> None:
2080
- buf = event.current_buffer
2081
- state = buf.complete_state
2082
-
2083
- if state and state.current_completion:
2084
- buf.apply_completion(state.current_completion)
2085
-
2086
- if event.app.current_buffer.document.text[0] == "/":
2087
- event.current_buffer.validate_and_handle()
2088
- else:
2089
- event.current_buffer.validate_and_handle()
2090
-
2091
- # shift-enter
2092
- @self.bindings.add("escape", "[", "1", "3", ";", "2", "u")
2093
- def _(event: Any) -> None:
2094
- event.app.current_buffer.newline()
2095
-
2096
- @self.bindings.add("c-a")
2097
- @self.bindings.add("escape", "[", "9", "7", ";", "5", "u")
2098
- def _(event: Any) -> None:
2099
- handler.default = event.app.current_buffer.document.text
2100
- self.shortcut = "<c-a>"
2101
- event.app.exit()
2102
-
2103
- @self.bindings.add("escape", "[", "9", "9", ";", "5", "u")
2104
- def _(event: Any) -> None:
2105
- self.shortcut = "<c-c>"
2106
- event.app.exit()
2107
-
2108
- @self.bindings.add("c-d")
2109
- @self.bindings.add("escape", "[", "1", "0", "0", ";", "5", "u")
2110
- def _(event: Any) -> None:
2111
- self.shortcut = "<c-d>"
2112
- event.app.exit()
2113
-
2114
- @self.bindings.add("c-e")
2115
- @self.bindings.add("escape", "[", "1", "0", "1", ";", "5", "u")
2116
- def _(event: Any) -> None:
2117
- event.app.current_buffer.open_in_editor()
2118
-
2119
- @self.bindings.add("escape", "[", "1", "0", "7", ";", "5", "u")
2120
- def _(event: Any) -> None:
2121
- event.app.key_processor.feed(KeyPress(Keys.ControlK))
2122
-
2123
- @self.bindings.add("escape", "[", "1", "0", "8", ";", "5", "u")
2124
- def _(event: Any) -> None:
2125
- event.app.key_processor.feed(KeyPress(Keys.ControlL))
2126
-
2127
- @self.bindings.add("escape", "[", "1", "1", "0", ";", "5", "u")
2128
- def _(event: Any) -> None:
2129
- event.app.key_processor.feed(KeyPress(Keys.ControlN))
2130
-
2131
- @self.bindings.add("escape", "[", "1", "1", "2", ";", "5", "u")
2132
- def _(event: Any) -> None:
2133
- event.app.key_processor.feed(KeyPress(Keys.ControlP))
2134
-
2135
- @self.bindings.add("escape", "[", "1", "1", "4", ";", "5", "u")
2136
- def _(event: Any) -> None:
2137
- pass
2138
-
2139
- @self.bindings.add("escape", "[", "1", "1", "5", ";", "5", "u")
2140
- def _(event: Any) -> None:
2141
- pass
2142
-
2143
- # Thinking is slow + not making chats better, disabling for now
2144
- # @self.bindings.add("c-t")
2145
- # @self.bindings.add("escape", "[", "1", "1", "6", ";", "5", "u")
2146
- # def _(event: Any) -> None:
2147
- # handler.default = event.app.current_buffer.document.text
2148
- # self.shortcut = "<c-t>"
2149
- # event.app.exit()
2150
-
2151
- @self.bindings.add("c-y")
2152
- @self.bindings.add("escape", "[", "1", "2", "1", ";", "5", "u")
2153
- def _(event: Any) -> None:
2154
- handler.default = event.app.current_buffer.document.text
2155
- self.shortcut = "<c-y>"
2156
- event.app.exit()
2157
-
2158
- @self.bindings.add("escape", "[", "1", "2", "7", ";", "3", "u")
2159
- def _(event: Any) -> None:
2160
- event.app.key_processor.feed(KeyPress(Keys.Escape))
2161
- event.app.key_processor.feed(KeyPress(Keys.Backspace))
2162
-
2163
- @self.bindings.add("escape", "[", "1", "2", "7", ";", "5", "u")
2164
- def _(event: Any) -> None:
2165
- event.app.key_processor.feed(KeyPress(Keys.ControlH))
2166
-
2167
- def prompt(
2168
- self, auto_confirm_mode: AutoConfirmMode, thinking: bool, seed: str
2169
- ) -> str:
2170
- toolbar = [("", " ")]
2171
- default_color = self.theme.statusbar_default_fg
2172
- # thinking_on_color = self.theme.statusbar_thinking_on
2173
- autorun_ro_color = self.theme.statusbar_autorun_ro
2174
- autorun_all_color = self.theme.statusbar_autorun_all
2175
-
2176
- if auto_confirm_mode == AutoConfirmMode.ALL:
2177
- toolbar.append((f"bold {autorun_all_color}", "➔ autorun "))
2178
- toolbar.append((f"bold {autorun_all_color} reverse", " all "))
2179
- elif auto_confirm_mode == AutoConfirmMode.READ_ONLY:
2180
- toolbar.append((f"bold {autorun_ro_color}", "➔ autorun "))
2181
- toolbar.append((f"bold {autorun_ro_color} reverse", " read only "))
2182
- else:
2183
- toolbar.append((f"bold {default_color}", "➔ autorun "))
2184
- toolbar.append((f"bold {default_color} reverse", " off "))
2185
-
2186
- toolbar.append(("", " "))
2187
- toolbar.append((f"{default_color}", "(toggle with "))
2188
- toolbar.append((f"{default_color} bold", "<ctrl+a>"))
2189
- toolbar.append((f"{default_color}", ")"))
2190
- toolbar.append(("", " "))
2191
-
2192
- # Thinking is slow + not making chats better, disabling for now
2193
- # if thinking:
2194
- # toolbar.append((f"bold {thinking_on_color}", "⋯ thinking "))
2195
- # toolbar.append((f"bold {thinking_on_color} reverse", " on "))
2196
- # else:
2197
- # toolbar.append((f"bold {default_color}", "⋯ thinking "))
2198
- # toolbar.append((f"bold {default_color} reverse", " off "))
2199
-
2200
- # toolbar.append(("", " "))
2201
- # toolbar.append((f"{default_color}", "(toggle with "))
2202
- # toolbar.append((f"{default_color} bold", "<ctrl+t>"))
2203
- # toolbar.append((f"{default_color}", ")"))
2204
-
2205
- self.shortcut = None
2206
- kitty_kbd_proto_enabled = enable_kitty_kbd_protocol()
2207
-
2208
- try:
2209
- default = self.default or seed
2210
- self.default = ""
2211
-
2212
- user_input = self.session.prompt(
2213
- FormattedText([(f"bold {self.theme.exponent_green}", "∷ ")]),
2214
- default=default,
2215
- multiline=kitty_kbd_proto_enabled,
2216
- bottom_toolbar=FormattedText(toolbar),
2217
- style=self.style,
2218
- )
2219
- finally:
2220
- if kitty_kbd_proto_enabled:
2221
- disable_kitty_kbd_protocol()
2222
-
2223
- if self.shortcut is not None:
2224
- return self.shortcut
2225
- else:
2226
- assert isinstance(user_input, str)
2227
- return user_input
2228
-
2229
-
2230
- class Shell:
2231
- def __init__(
2232
- self,
2233
- prompt: str | None,
2234
- loop: asyncio.AbstractEventLoop,
2235
- input_handler: InputHandler,
2236
- chat: Chat,
2237
- connection_tracker: ConnectionTracker,
2238
- environment: Environment,
2239
- ) -> None:
2240
- self.prompt = prompt
2241
- self.loop = loop
2242
- self.input_handler = input_handler
2243
- self.chat = chat
2244
- self.environment = environment
2245
- self.stream_fut: Future[Any] | None = None
2246
- self.connection_tracker = connection_tracker
2247
-
2248
- def run(self) -> None:
2249
- self._send_initial_prompt()
2250
- seed = ""
2251
-
2252
- while True:
2253
- try:
2254
- self._wait_for_stream_completion()
2255
-
2256
- text = self.input_handler.prompt(
2257
- self.chat.auto_confirm_mode, self.chat.thinking, seed
2258
- )
2259
-
2260
- seed = ""
2261
-
2262
- if text.startswith("/"):
2263
- self._run_command(text[1:].strip())
2264
- elif text == "<c-y>":
2265
- if not self._confirm_execution():
2266
- seed = "yes"
2267
- elif text == "<c-a>":
2268
- self._run_command("autorun")
2269
- elif text == "<c-t>":
2270
- self._run_command("thinking")
2271
- elif text in {"q", "exit"}:
2272
- print()
2273
- break
2274
- elif text in ("<c-c>", "<c-d>"):
2275
- print()
2276
- do_quit = ask_for_quit_confirmation()
2277
- print()
2278
-
2279
- if do_quit:
2280
- break
2281
- elif text:
2282
- print()
2283
- self._send_prompt(text)
2284
-
2285
- except KeyboardInterrupt:
2286
- if self._handle_keyboard_interrupt():
2287
- break
2288
-
2289
- except ExponentError as e:
2290
- self._print_error_message(e)
2291
- break
2292
- except RateLimitError as e:
2293
- self._print_rate_limit_error_message(e)
2294
- break
2295
-
2296
- def _handle_keyboard_interrupt(self) -> bool:
2297
- try:
2298
- if self.stream_fut is not None:
2299
- self._run_coroutine(self.chat.halt_stream()).result()
2300
- return False
2301
- return ask_for_quit_confirmation()
2302
- except KeyboardInterrupt:
2303
- return True
2304
-
2305
- def _ensure_connected(self) -> None:
2306
- if not self.connection_tracker.is_connected():
2307
- self._run_coroutine(self._wait_for_reconnection()).result()
2308
-
2309
- async def _wait_for_reconnection(self) -> None:
2310
- render([clear_line_seq(), "Disconnected..."])
2311
- await asyncio.sleep(1)
2312
- spinner = Spinner("Reconnecting...")
2313
- spinner.show()
2314
- await self.connection_tracker.wait_for_reconnection()
2315
- spinner.hide()
2316
- render([fg_color_seq(2), bold_seq(), "✓ Reconnected"])
2317
- await asyncio.sleep(1)
2318
- render([clear_line_seq()])
2319
-
2320
- def _print_error_message(self, e: ExponentError) -> None:
2321
- print(f"\n\n\x1b[1;31m{e}\x1b[0m")
2322
- print("\x1b[3;33m")
2323
- print("Reach out to team@exponent.run if you need support.")
2324
- print("\x1b[0m")
2325
-
2326
- def _print_rate_limit_error_message(self, e: RateLimitError) -> None:
2327
- print(f"\n\n\x1b[1;31m{e}\x1b[0m")
2328
- print("\x1b[3;33m")
2329
- print(
2330
- "Visit https://www.exponent.run/settings to update your billing settings."
2331
- )
2332
- print("\x1b[0m")
2333
-
2334
- def _show_help(self) -> None:
2335
- print()
2336
-
2337
- print(f" {bold_seq()}Commands:{not_bold_seq()}")
2338
- print()
2339
-
2340
- for command, description in sorted(SLASH_COMMANDS.items()):
2341
- if command != "/help":
2342
- print(f" {bold_seq()}{command}{not_bold_seq()} - {description}")
2343
-
2344
- print()
2345
- print(f" {bold_seq()}Keyboard shortcuts:{not_bold_seq()}")
2346
- print()
2347
- print(
2348
- f" {bold_seq()}shift{not_bold_seq()}+{bold_seq()}enter{not_bold_seq()} - Add new line to the prompt (on supported terminals)"
2349
- )
2350
- print(
2351
- f" {bold_seq()}ctrl{not_bold_seq()}+{bold_seq()}a{not_bold_seq()} - Toggle between autorun modes"
2352
- )
2353
- print(
2354
- f" {bold_seq()}ctrl{not_bold_seq()}+{bold_seq()}c{not_bold_seq()} / {bold_seq()}ctrl{not_bold_seq()}+{bold_seq()}d{not_bold_seq()} - Quit Exponent"
2355
- )
2356
- print(
2357
- f" {bold_seq()}ctrl{not_bold_seq()}+{bold_seq()}e{not_bold_seq()} - Edit prompt in $EDITOR"
2358
- )
2359
- print(
2360
- f" {bold_seq()}ctrl{not_bold_seq()}+{bold_seq()}t{not_bold_seq()} - Toggle thinking mode"
2361
- )
2362
- print(
2363
- f' {bold_seq()}ctrl{not_bold_seq()}+{bold_seq()}y{not_bold_seq()} - Confirm action or insert "yes" into the prompt'
2364
- )
2365
- print()
2366
-
2367
- print(f" {bold_seq()}Autorun modes:{not_bold_seq()}")
2368
- print()
2369
- print(
2370
- f" - {bold_seq()}off{not_bold_seq()} - You manually confirm each action (default)"
2371
- )
2372
- print(
2373
- f" - {bold_seq()}read only{not_bold_seq()} - Auto-confirm read-only actions like search and file access"
2374
- )
2375
- print(
2376
- f" - {bold_seq()}all{not_bold_seq()} - Auto-confirm all actions, including read, search, and edit"
2377
- )
2378
- print()
2379
-
2380
- print(
2381
- f" Tip: Type {bold_seq()}@{not_bold_seq()} to auto-complete project files."
2382
- )
2383
- print()
2384
-
2385
- def _run_command(self, command: str) -> None:
2386
- parts = command.split(maxsplit=1)
2387
- cmd = parts[0]
2388
- args = parts[1] if len(parts) > 1 else ""
2389
-
2390
- if cmd == "cmd":
2391
- self._execute_shell_command(args)
2392
- elif cmd == "help":
2393
- self._show_help()
2394
- elif cmd == "autorun":
2395
- self._toggle_autorun()
2396
- # Thinking is slow + not making chats better, disabling for now
2397
- # elif cmd == "thinking":
2398
- # self._toggle_thinking()
2399
- elif cmd == "web":
2400
- self._switch_cli_chat_to_web()
2401
- elif cmd == "c" or cmd == "checkpoint":
2402
- self._create_checkpoint()
2403
- elif cmd == "r" or cmd == "rollback":
2404
- self._checkpoint_rollback(args)
2405
- else:
2406
- print(f"\n Unknown command: /{command}\n")
2407
-
2408
- def _execute_shell_command(self, shell_command: str) -> None:
2409
- if not shell_command:
2410
- click.echo("\n Error: Command command is empty. Usage: /cmd <command>\n")
2411
- return
2412
-
2413
- self._ensure_connected()
2414
- self.stream_fut = self._run_coroutine(
2415
- self.chat.send_direct_action("shell", {"command": shell_command})
2416
- )
2417
-
2418
- def _create_checkpoint(self) -> None:
2419
- self._ensure_connected()
2420
- self.stream_fut = self._run_coroutine(
2421
- self.chat.send_direct_action("checkpoint", {"commitMessage": None})
2422
- )
2423
-
2424
- def _checkpoint_rollback(self, args: str) -> None:
2425
- self._ensure_connected()
2426
- print()
2427
-
2428
- if not self.chat.checkpoints:
2429
- print("No checkpoints to rollback to. Use /checkpoint to create one.\n")
2430
- return
2431
-
2432
- choices = [
2433
- questionary.Choice(
2434
- title=f"{get_short_git_commit_hash(checkpoint['commitHash'])}: {checkpoint['commitMessage']}",
2435
- value=index,
2436
- )
2437
- for index, checkpoint in enumerate(self.chat.checkpoints)
2438
- ]
2439
- checkpoint_index = questionary.select(
2440
- "Chose a checkpoint to rollback to:",
2441
- choices=choices,
2442
- qmark="",
2443
- ).ask()
2444
-
2445
- if checkpoint_index is None:
2446
- print()
2447
- return
2448
-
2449
- self.chat.checkpoints = self.chat.checkpoints[: checkpoint_index + 1]
2450
- checkpoint_uuid = self.chat.checkpoints[-1]["eventUuid"]
2451
-
2452
- self.chat.parent_uuid = checkpoint_uuid
2453
- self.stream_fut = self._run_coroutine(
2454
- self.chat.send_direct_action(
2455
- "rollback", {"checkpointCreatedEventUuid": checkpoint_uuid}
2456
- )
2457
- )
2458
-
2459
- def _toggle_autorun(self) -> None:
2460
- self.chat.toggle_autorun()
2461
-
2462
- render(
2463
- [
2464
- "\r",
2465
- move_cursor_up_seq(1),
2466
- clear_line_seq(),
2467
- ]
2468
- )
2469
-
2470
- def _toggle_thinking(self) -> None:
2471
- self.chat.toggle_thinking()
2472
-
2473
- render(
2474
- [
2475
- "\r",
2476
- move_cursor_up_seq(1),
2477
- clear_line_seq(),
2478
- ]
2479
- )
2480
-
2481
- def _switch_cli_chat_to_web(self) -> None:
2482
- url = self.chat.url()
2483
- print(f"\nThis chat has been moved to {url}\n")
2484
-
2485
- if not inside_ssh_session():
2486
- launch_exponent_browser(
2487
- self.environment, self.chat.base_url, self.chat.chat_uuid
2488
- )
2489
-
2490
- while True:
2491
- input()
2492
-
2493
- def _confirm_execution(self) -> bool:
2494
- render(
2495
- [
2496
- "\r",
2497
- move_cursor_up_seq(1),
2498
- clear_line_seq(),
2499
- ]
2500
- )
2501
-
2502
- success = len(self.chat.pending_event_list) > 0
2503
- self._ensure_connected()
2504
- self.stream_fut = self._run_coroutine(self.chat.send_confirmation())
2505
-
2506
- return success
2507
-
2508
- def _send_initial_prompt(self) -> None:
2509
- if self.prompt is not None:
2510
- self._send_prompt(self.prompt)
2511
-
2512
- def _send_prompt(self, text: str) -> None:
2513
- self._ensure_connected()
2514
- self.stream_fut = self._run_coroutine(self.chat.send_prompt(text))
2515
-
2516
- def _wait_for_stream_completion(self) -> None:
2517
- if self.stream_fut is not None:
2518
- self.stream_fut.result()
2519
- self.stream_fut = None
2520
-
2521
- def _run_coroutine(self, coro: Coroutine[Any, Any, Any]) -> Future[Any]:
2522
- return asyncio.run_coroutine_threadsafe(coro, self.loop)
2523
-
2524
-
2525
- class ExponentCompleter(Completer):
2526
- def __init__(self, loop: AbstractEventLoop, file_cache: FileCache):
2527
- self.loop = loop
2528
- self.file_cache = file_cache
2529
-
2530
- def get_completions(
2531
- self, document: Document, complete_event: CompleteEvent
2532
- ) -> Iterable[Completion]:
2533
- text = document.text
2534
-
2535
- if text.startswith("/"):
2536
- for command in SLASH_COMMANDS:
2537
- if command.startswith(text):
2538
- yield Completion(command, start_position=-len(text))
2539
-
2540
- elif text == "@" or text.endswith(" @"):
2541
- for path in asyncio.run_coroutine_threadsafe(
2542
- self.file_cache.get_files(), self.loop
2543
- ).result():
2544
- yield Completion(path + " ", start_position=0)
2545
-
2546
-
2547
- class Buffer:
2548
- def __init__(self, event_uuid: str) -> None:
2549
- self.event_uuid = event_uuid
2550
-
2551
-
2552
- class NullBuffer(Buffer):
2553
- def __init__(self) -> None:
2554
- super().__init__("")
2555
-
2556
-
2557
- class CharBuffer(Buffer):
2558
- def __init__(self, event_uuid: str, term_width: int) -> None:
2559
- super().__init__(event_uuid)
2560
- self.term_width = term_width
2561
- self.content_len = 0
2562
- self.current_term_line_len = 0
2563
- self.strip_next = False
2564
-
2565
- def render_new_chars(self, message: str) -> list[Any]:
2566
- message = message.strip()
2567
- new_text = message[self.content_len :]
2568
- self.content_len = len(message)
2569
- available_width = self.term_width - 3
2570
- seqs = []
2571
-
2572
- while new_text:
2573
- max_chunk_width = available_width - self.current_term_line_len
2574
- chunk = new_text[0:max_chunk_width]
2575
- new_text = new_text[max_chunk_width:]
2576
- lines = chunk.split("\n")
2577
-
2578
- for idx, line in enumerate(lines):
2579
- if self.current_term_line_len == 0:
2580
- seqs.append(" ")
2581
-
2582
- if self.strip_next:
2583
- self.strip_next = False
2584
- line = line.lstrip()
2585
-
2586
- seqs.append(line)
2587
-
2588
- if idx < len(lines) - 1:
2589
- seqs.append("\n")
2590
- self.current_term_line_len = 0
2591
- else:
2592
- self.current_term_line_len += len(line)
2593
-
2594
- if self.current_term_line_len == available_width:
2595
- seqs.append("\n")
2596
- self.current_term_line_len = 0
2597
- self.strip_next = True
2598
-
2599
- return seqs
2600
-
2601
-
2602
- class LineBuffer(Buffer):
2603
- def __init__(self, event_uuid: str) -> None:
2604
- super().__init__(event_uuid)
2605
- self.line_count = 0
2606
-
2607
- def render_new_lines(
2608
- self,
2609
- code: str,
2610
- theme: Theme,
2611
- ) -> list[Any]:
2612
- seqs: list[Any] = []
2613
- lines = code.split("\n")
2614
- lines = lines[0 : len(lines) - 1]
2615
- new_line_count = len(lines)
2616
-
2617
- seqs.append(bg_color_seq(theme.block_body_bg))
2618
-
2619
- if self.line_count > 0:
2620
- seqs.append([move_cursor_up_seq(1), "\r"])
2621
- lines = lines[self.line_count - 1 :]
2622
-
2623
- lines = [line + "\n" for line in lines]
2624
- seqs.append(lines)
2625
- self.line_count = new_line_count
2626
-
2627
- return seqs
2628
-
2629
-
2630
- # TODO maybe unify with LineBuffer?
2631
- class CommandBuffer(Buffer):
2632
- def __init__(self, event_uuid: str) -> None:
2633
- super().__init__(event_uuid)
2634
- self.line_count = 0
2635
-
2636
- def render_new_lines(self, paths: list[Any]) -> list[Any]:
2637
- seqs: list[Any] = []
2638
- new_line_count = len(paths)
2639
-
2640
- if self.line_count > 0:
2641
- seqs.append([move_cursor_up_seq(1), "\r"])
2642
- paths = paths[self.line_count - 1 :]
2643
-
2644
- seqs.append(paths)
2645
- self.line_count = new_line_count
2646
-
2647
- return seqs
2648
-
2649
-
2650
- def highlight_code(
2651
- code: str,
2652
- lang: str,
2653
- theme: Theme,
2654
- line_numbers: bool = False,
2655
- padding: tuple[int, int] = (0, 2),
2656
- ) -> str:
2657
- syntax = Syntax(
2658
- code,
2659
- lang,
2660
- theme=theme.hl_theme_name,
2661
- line_numbers=line_numbers,
2662
- word_wrap=True,
2663
- padding=padding,
2664
- )
2665
-
2666
- console = Console()
2667
-
2668
- with console.capture() as capture:
2669
- console.print(syntax)
2670
-
2671
- result = capture.get()
2672
-
2673
- return (
2674
- result.replace("\x1b[0m", f"\x1b[0m{bg_color_seq(theme.block_body_bg)}")
2675
- + "\x1b[49m"
2676
- )
2677
-
2678
-
2679
- def generate_diff(before_lines: str, after_lines: str) -> str:
2680
- diff = difflib.unified_diff(
2681
- before_lines.split("\n"),
2682
- after_lines.split("\n"),
2683
- fromfile="before",
2684
- tofile="after",
2685
- lineterm="",
2686
- )
2687
-
2688
- return "\n".join(list(diff)[2:])
2689
-
2690
-
2691
- def pad_left(text: str, padding: str) -> str:
2692
- return "\n".join([padding + line for line in text.strip().split("\n")])
2693
-
2694
-
2695
- def bold_seq() -> str:
2696
- return "\x1b[1m"
2697
-
2698
-
2699
- def not_bold_seq() -> str:
2700
- return "\x1b[22m"
2701
-
2702
-
2703
- def italic_seq() -> str:
2704
- return "\x1b[3m"
2705
-
2706
-
2707
- def reverse_seq() -> str:
2708
- return "\x1b[7m"
2709
-
2710
-
2711
- def not_reverse_seq() -> str:
2712
- return "\x1b[27m"
2713
-
2714
-
2715
- def erase_line_seq() -> str:
2716
- return "\x1b[2K"
2717
-
2718
-
2719
- def erase_display_seq() -> str:
2720
- return "\x1b[0J"
2721
-
2722
-
2723
- def reset_attrs_seq() -> str:
2724
- return "\x1b[0m"
2725
-
2726
-
2727
- def clear_line_seq() -> str:
2728
- return f"\r{reset_attrs_seq()}{erase_line_seq()}"
2729
-
2730
-
2731
- def move_cursor_up_seq(n: int) -> str:
2732
- if n > 0:
2733
- return f"\x1b[{n}A"
2734
- else:
2735
- return ""
2736
-
2737
-
2738
- def move_cursor_down_seq(n: int) -> str:
2739
- if n > 0:
2740
- return f"\x1b[{n}B"
2741
- else:
2742
- return ""
2743
-
2744
-
2745
- def render(seqs: str | list[Any] | None) -> None:
2746
- print(collect(seqs), end="")
2747
- sys.stdout.flush()
2748
-
2749
-
2750
- def collect(seqs: str | list[Any] | None) -> str:
2751
- if seqs is None:
2752
- return ""
2753
-
2754
- if isinstance(seqs, str):
2755
- return seqs
2756
-
2757
- assert isinstance(seqs, list)
2758
-
2759
- text = ""
2760
-
2761
- for seq in seqs:
2762
- text += collect(seq)
2763
-
2764
- return text
2765
-
2766
-
2767
- def enable_kitty_kbd_protocol() -> bool:
2768
- if not POSIX_TERMINAL or not sys.stdin.isatty():
2769
- return False # Not supported on Windows or when stdin is not a TTY
2770
-
2771
- with RawMode(sys.stdin.fileno()):
2772
- stdin_fd = sys.stdin.fileno()
2773
- stdout_fd = sys.stdout.fileno()
2774
-
2775
- if stdout_fd in select.select([], [stdout_fd], [])[1]:
2776
- # Send the following sequences to the terminal:
2777
- # CSI >1u - enable kitty keyboard protocol
2778
- # CSI ?u - query status of kitty keyboard protocol
2779
- # CSI c - query device attributes (DA)
2780
- os.write(stdout_fd, b"\x1b[>1u\x1b[?u\x1b[c")
2781
- # If the terminal doesn't support the protocol then adding the
2782
- # device attributes (DA) query forces it to respond anyway (it's
2783
- # widely supported), which let's us check if the response includes
2784
- # the kitty protocol status (CSI ?1u) without timing out.
2785
-
2786
- got_da_response = False
2787
- kitty_proto_supported = False
2788
-
2789
- try:
2790
- # Read terminal responses until we get DA response
2791
- while not got_da_response:
2792
- if stdin_fd in select.select([stdin_fd], [], [], 100)[0]:
2793
- reply = os.read(stdin_fd, 1024)
2794
- seqs = reply.split(b"\x1b[")
2795
-
2796
- for seq in seqs:
2797
- if seq == b"?1u":
2798
- kitty_proto_supported = True
2799
- elif seq.endswith(b"c"):
2800
- got_da_response = True
2801
-
2802
- except Exception as e:
2803
- logger.debug("Error enabling kitty kbd protocol", exc_info=e)
2804
-
2805
- return kitty_proto_supported
2806
-
2807
-
2808
- def disable_kitty_kbd_protocol() -> None:
2809
- if POSIX_TERMINAL:
2810
- sys.stdout.write("\x1b[<u")
2811
- sys.stdout.flush()
2812
-
2813
-
2814
- class RawMode:
2815
- def __init__(self, fd: IO[str] | int) -> None:
2816
- self.fd = fd
2817
- self.restore: bool = False
2818
- self.mode: list[Any] | None = None
2819
-
2820
- def __enter__(self) -> None:
2821
- if POSIX_TERMINAL:
2822
- try:
2823
- self.mode = termios.tcgetattr(self.fd)
2824
- tty.setraw(self.fd)
2825
- self.restore = True
2826
- except (termios.error, AttributeError):
2827
- pass
2828
- # On Windows, we don't modify terminal settings
2829
- # A more sophisticated implementation could use the Windows Console API
2830
-
2831
- def __exit__(self, _type: str, _value: str, _traceback: str) -> None:
2832
- if self.restore and POSIX_TERMINAL:
2833
- time.sleep(0.01) # give the terminal time to send answerbacks
2834
- assert isinstance(self.mode, list)
2835
- termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.mode)
2836
-
2837
-
2838
- def get_term_width() -> int:
2839
- (cols, _) = shutil.get_terminal_size((80, 24))
2840
- return cols