batrachian-toad 0.5.22__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.
Files changed (120) hide show
  1. batrachian_toad-0.5.22.dist-info/METADATA +197 -0
  2. batrachian_toad-0.5.22.dist-info/RECORD +120 -0
  3. batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
  4. batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
  5. batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
  6. toad/__init__.py +46 -0
  7. toad/__main__.py +4 -0
  8. toad/_loop.py +86 -0
  9. toad/about.py +90 -0
  10. toad/acp/agent.py +671 -0
  11. toad/acp/api.py +47 -0
  12. toad/acp/encode_tool_call_id.py +12 -0
  13. toad/acp/messages.py +138 -0
  14. toad/acp/prompt.py +54 -0
  15. toad/acp/protocol.py +426 -0
  16. toad/agent.py +62 -0
  17. toad/agent_schema.py +70 -0
  18. toad/agents.py +45 -0
  19. toad/ansi/__init__.py +1 -0
  20. toad/ansi/_ansi.py +1612 -0
  21. toad/ansi/_ansi_colors.py +264 -0
  22. toad/ansi/_control_codes.py +37 -0
  23. toad/ansi/_keys.py +251 -0
  24. toad/ansi/_sgr_styles.py +64 -0
  25. toad/ansi/_stream_parser.py +418 -0
  26. toad/answer.py +22 -0
  27. toad/app.py +557 -0
  28. toad/atomic.py +37 -0
  29. toad/cli.py +257 -0
  30. toad/code_analyze.py +28 -0
  31. toad/complete.py +34 -0
  32. toad/constants.py +58 -0
  33. toad/conversation_markdown.py +19 -0
  34. toad/danger.py +371 -0
  35. toad/data/agents/ampcode.com.toml +51 -0
  36. toad/data/agents/augmentcode.com.toml +40 -0
  37. toad/data/agents/claude.com.toml +41 -0
  38. toad/data/agents/docker.com.toml +59 -0
  39. toad/data/agents/geminicli.com.toml +28 -0
  40. toad/data/agents/goose.ai.toml +51 -0
  41. toad/data/agents/inference.huggingface.co.toml +33 -0
  42. toad/data/agents/kimi.com.toml +35 -0
  43. toad/data/agents/openai.com.toml +53 -0
  44. toad/data/agents/opencode.ai.toml +61 -0
  45. toad/data/agents/openhands.dev.toml +44 -0
  46. toad/data/agents/stakpak.dev.toml +61 -0
  47. toad/data/agents/vibe.mistral.ai.toml +27 -0
  48. toad/data/agents/vtcode.dev.toml +62 -0
  49. toad/data/images/frog.png +0 -0
  50. toad/data/sounds/turn-over.wav +0 -0
  51. toad/db.py +5 -0
  52. toad/dec.py +332 -0
  53. toad/directory.py +234 -0
  54. toad/directory_watcher.py +96 -0
  55. toad/fuzzy.py +140 -0
  56. toad/gist.py +2 -0
  57. toad/history.py +138 -0
  58. toad/jsonrpc.py +576 -0
  59. toad/menus.py +14 -0
  60. toad/messages.py +74 -0
  61. toad/option_content.py +51 -0
  62. toad/os.py +0 -0
  63. toad/path_complete.py +145 -0
  64. toad/path_filter.py +124 -0
  65. toad/paths.py +71 -0
  66. toad/pill.py +23 -0
  67. toad/prompt/extract.py +19 -0
  68. toad/prompt/resource.py +68 -0
  69. toad/protocol.py +28 -0
  70. toad/screens/action_modal.py +94 -0
  71. toad/screens/agent_modal.py +172 -0
  72. toad/screens/command_edit_modal.py +58 -0
  73. toad/screens/main.py +192 -0
  74. toad/screens/permissions.py +390 -0
  75. toad/screens/permissions.tcss +72 -0
  76. toad/screens/settings.py +254 -0
  77. toad/screens/settings.tcss +101 -0
  78. toad/screens/store.py +476 -0
  79. toad/screens/store.tcss +261 -0
  80. toad/settings.py +354 -0
  81. toad/settings_schema.py +318 -0
  82. toad/shell.py +263 -0
  83. toad/shell_read.py +42 -0
  84. toad/slash_command.py +34 -0
  85. toad/toad.tcss +752 -0
  86. toad/version.py +80 -0
  87. toad/visuals/columns.py +273 -0
  88. toad/widgets/agent_response.py +79 -0
  89. toad/widgets/agent_thought.py +41 -0
  90. toad/widgets/command_pane.py +224 -0
  91. toad/widgets/condensed_path.py +93 -0
  92. toad/widgets/conversation.py +1626 -0
  93. toad/widgets/danger_warning.py +65 -0
  94. toad/widgets/diff_view.py +709 -0
  95. toad/widgets/flash.py +81 -0
  96. toad/widgets/future_text.py +126 -0
  97. toad/widgets/grid_select.py +223 -0
  98. toad/widgets/highlighted_textarea.py +180 -0
  99. toad/widgets/mandelbrot.py +294 -0
  100. toad/widgets/markdown_note.py +13 -0
  101. toad/widgets/menu.py +147 -0
  102. toad/widgets/non_selectable_label.py +5 -0
  103. toad/widgets/note.py +18 -0
  104. toad/widgets/path_search.py +381 -0
  105. toad/widgets/plan.py +180 -0
  106. toad/widgets/project_directory_tree.py +74 -0
  107. toad/widgets/prompt.py +741 -0
  108. toad/widgets/question.py +337 -0
  109. toad/widgets/shell_result.py +35 -0
  110. toad/widgets/shell_terminal.py +18 -0
  111. toad/widgets/side_bar.py +74 -0
  112. toad/widgets/slash_complete.py +211 -0
  113. toad/widgets/strike_text.py +66 -0
  114. toad/widgets/terminal.py +526 -0
  115. toad/widgets/terminal_tool.py +338 -0
  116. toad/widgets/throbber.py +90 -0
  117. toad/widgets/tool_call.py +303 -0
  118. toad/widgets/user_input.py +23 -0
  119. toad/widgets/version.py +5 -0
  120. toad/widgets/welcome.py +31 -0
toad/ansi/_ansi.py ADDED
@@ -0,0 +1,1612 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from itertools import accumulate
5
+ import re2 as re
6
+
7
+ from dataclasses import dataclass, field
8
+ from functools import lru_cache
9
+ from typing import Any, Awaitable, Callable, Iterable, Literal, Mapping, NamedTuple
10
+
11
+ import rich.repr
12
+
13
+ from textual import events
14
+ from textual.color import Color
15
+ from textual.content import Content, EMPTY_CONTENT
16
+ from textual.geometry import clamp
17
+ from textual.style import Style, NULL_STYLE
18
+
19
+ from toad.ansi._ansi_colors import ANSI_COLORS
20
+ from toad.ansi._keys import TERMINAL_KEY_MAP, CURSOR_KEYS_APPLICATION
21
+ from toad.ansi._control_codes import CONTROL_CODES
22
+ from toad.ansi._sgr_styles import SGR_STYLES
23
+ from toad.ansi._stream_parser import (
24
+ StreamParser,
25
+ SeparatorToken,
26
+ PatternToken,
27
+ Pattern,
28
+ PatternCheck,
29
+ ParseResult,
30
+ Token,
31
+ )
32
+
33
+ from toad.dec import CHARSET_MAP
34
+
35
+
36
+ def character_range(start: int, end: int) -> frozenset:
37
+ """Build a set of characters between to code-points.
38
+
39
+ Args:
40
+ start: Start codepoint.
41
+ end: End codepoint (inclusive)
42
+
43
+ Returns:
44
+ A frozenset of the characters..
45
+ """
46
+ return frozenset(map(chr, range(start, end + 1)))
47
+
48
+
49
+ class ANSIToken:
50
+ pass
51
+
52
+
53
+ class DEC(NamedTuple):
54
+ slot: int
55
+ character_set: str
56
+
57
+
58
+ class DECInvoke(NamedTuple):
59
+ gl: int | None = None
60
+ gr: int | None = None
61
+ shift: int | None = None
62
+
63
+
64
+ DEC_SLOTS = {"(": 0, ")": 1, "*": 2, "+": 3, "-": 1, ".": 2, "//": 3}
65
+
66
+
67
+ def show(obj: object) -> object:
68
+ print(obj)
69
+ return obj
70
+
71
+
72
+ class FEPattern(Pattern):
73
+ FINAL = character_range(0x30, 0x7E)
74
+ INTERMEDIATE = character_range(0x20, 0x2F)
75
+ CSI_TERMINATORS = character_range(0x40, 0x7E)
76
+ OSC_TERMINATORS = frozenset({"\x07", "\x9c"})
77
+ DSC_TERMINATORS = frozenset({"\x9c"})
78
+
79
+ def check(self) -> PatternCheck:
80
+ sequence = io.StringIO()
81
+ store = sequence.write
82
+ store(character := (yield))
83
+
84
+ match character:
85
+ # CSI
86
+ case "[":
87
+ CSI_TERMINATORS = self.CSI_TERMINATORS
88
+ while (character := (yield)) not in CSI_TERMINATORS:
89
+ store(character)
90
+ store(character)
91
+ return ("csi", sequence.getvalue())
92
+
93
+ # OSC
94
+ case "]":
95
+ last_character = ""
96
+ OSC_TERMINATORS = self.OSC_TERMINATORS
97
+ while (character := (yield)) not in OSC_TERMINATORS:
98
+ store(character)
99
+ if last_character == "\x1b" and character in {"\\", "\0x5c"}:
100
+ break
101
+ last_character = character
102
+ store(character)
103
+
104
+ return ("osc", sequence.getvalue())
105
+
106
+ # DCS
107
+ case "P":
108
+ print("TODO DCS")
109
+ last_character = ""
110
+ DSC_TERMINATORS = self.DSC_TERMINATORS
111
+ while (character := (yield)) not in DSC_TERMINATORS:
112
+ store(character)
113
+ if last_character == "\x1b" and character == "\\":
114
+ break
115
+ last_character = character
116
+ store(character)
117
+ return ("dcs", sequence.getvalue())
118
+
119
+ # Character set designation
120
+ case "(" | ")" | "*" | "+" | "-" | "." | "/":
121
+ if (character := (yield)) not in self.FINAL:
122
+ return False
123
+ store(character)
124
+ return ("dec", sequence.getvalue())
125
+
126
+ case "n" | "o" | "~" | "}" | "|" | "N" | "O":
127
+ return ("dec_invoke", sequence.getvalue())
128
+
129
+ # Line attribute
130
+ case "#":
131
+ print("LINE ATTRIBUTES")
132
+ store((yield))
133
+ return ("la", sequence.getvalue())
134
+ # ISO 2022: ESC SP
135
+ case " ":
136
+ store((yield))
137
+ return ("sp", sequence.getvalue())
138
+ case _:
139
+ return ("control", character)
140
+
141
+
142
+ class ANSIParser(StreamParser[tuple[str, str]]):
143
+ """Parse a stream of text containing escape sequences in to logical tokens."""
144
+
145
+ def parse(self) -> ParseResult[tuple[str, str]]:
146
+ NEW_LINE = "\n"
147
+ CARRIAGE_RETURN = "\r"
148
+ ESCAPE = "\x1b"
149
+ BACKSPACE = "\x08"
150
+
151
+ while True:
152
+ token = yield self.read_until(NEW_LINE, CARRIAGE_RETURN, ESCAPE, BACKSPACE)
153
+ if isinstance(token, SeparatorToken):
154
+ if token.text == ESCAPE:
155
+ token = yield self.read_patterns("\x1b", fe=FEPattern())
156
+ if isinstance(token, PatternToken):
157
+ yield token.value
158
+ else:
159
+ yield "separator", token.text
160
+ continue
161
+
162
+ yield "content", token.text
163
+
164
+
165
+ EMPTY_LINE = Content()
166
+
167
+
168
+ type ClearType = Literal["cursor_to_end", "cursor_to_beginning", "screen", "scrollback"]
169
+ ANSI_CLEAR: Mapping[int, ClearType] = {
170
+ 0: "cursor_to_end",
171
+ 1: "cursor_to_beginning",
172
+ 2: "screen",
173
+ 3: "scrollback",
174
+ }
175
+
176
+
177
+ @rich.repr.auto
178
+ class ANSIContent(NamedTuple):
179
+ """Content to be written to the terminal."""
180
+
181
+ text: str
182
+
183
+ def __rich_repr__(self) -> rich.repr.Result:
184
+ yield self.text
185
+
186
+
187
+ @rich.repr.auto
188
+ class ANSICursor(NamedTuple):
189
+ """Represents a single operation on the ANSI output.
190
+
191
+ All values may be `None` meaning "not set".
192
+ """
193
+
194
+ delta_x: int | None = None
195
+ """Relative x change."""
196
+ delta_y: int | None = None
197
+ """Relative y change."""
198
+ absolute_x: int | None = None
199
+ """Replace x."""
200
+ absolute_y: int | None = None
201
+ """Replace y."""
202
+ erase: bool = False
203
+ """Erase (replace with spaces)?"""
204
+ clear_range: tuple[int | None, int | None] | None = None
205
+ """Replace range (slice like)."""
206
+ relative: bool = False
207
+ """Should replace be relative (`False`) or absolute (`True`)"""
208
+ update_background: bool = False
209
+ """Optional style for remaining line."""
210
+ auto_scroll: bool = False
211
+ """Perform a scroll with the movement?"""
212
+
213
+ def __rich_repr__(self) -> rich.repr.Result:
214
+ yield "delta_x", self.delta_x, None
215
+ yield "delta_y", self.delta_y, None
216
+ yield "absolute_x", self.absolute_x, None
217
+ yield "absolute_y", self.absolute_y, None
218
+ yield "erase", self.erase, False
219
+ yield "clear_range", self.clear_range, None
220
+ yield "relative", self.relative, False
221
+ yield "update_background", self.update_background, False
222
+ yield "auto_scroll", self.auto_scroll, False
223
+
224
+ @lru_cache(maxsize=1024)
225
+ def get_clear_offsets(
226
+ self, cursor_offset: int, line_length: int
227
+ ) -> tuple[int, int]:
228
+ """Get replace offsets.
229
+
230
+ Args:
231
+ cursor_offset: Current cursor offset.
232
+ line_length: Length of line.
233
+
234
+ Returns:
235
+ A pair of offsets (inclusive).
236
+ """
237
+ assert self.clear_range is not None, (
238
+ "Only call this if the replace attribute has a value"
239
+ )
240
+ replace_start, replace_end = self.clear_range
241
+ if replace_start is None:
242
+ replace_start = cursor_offset
243
+ if replace_end is None:
244
+ replace_end = cursor_offset
245
+ if replace_start < 0:
246
+ replace_start = line_length + replace_start
247
+ if replace_end < 0:
248
+ replace_end = line_length + replace_end
249
+ if self.relative:
250
+ return (cursor_offset + replace_start, cursor_offset + replace_end)
251
+ else:
252
+ return (replace_start, replace_end)
253
+
254
+
255
+ @rich.repr.auto
256
+ class ANSINewLine:
257
+ """New line (diffrent in alternate buffer)"""
258
+
259
+
260
+ @rich.repr.auto
261
+ class ANSIStyle(NamedTuple):
262
+ """Update style."""
263
+
264
+ style: Style
265
+
266
+ def __rich_repr__(self) -> rich.repr.Result:
267
+ yield self.style
268
+
269
+
270
+ @rich.repr.auto
271
+ class ANSIClear(NamedTuple):
272
+ """Enumeration for clearing the 'screen'."""
273
+
274
+ clear: ClearType
275
+
276
+ def __rich_repr__(self) -> rich.repr.Result:
277
+ yield self.clear
278
+
279
+
280
+ @rich.repr.auto
281
+ class ANSIScrollMargin(NamedTuple):
282
+ """Set the scroll margin."""
283
+
284
+ top: int | None = None
285
+ bottom: int | None = None
286
+
287
+ def __rich_repr__(self) -> rich.repr.Result:
288
+ yield self.top
289
+ yield self.bottom
290
+
291
+
292
+ @rich.repr.auto
293
+ class ANSIScroll(NamedTuple):
294
+ """Scroll buffer."""
295
+
296
+ direction: Literal[+1, -1]
297
+ lines: int
298
+
299
+ def __rich_repr__(self) -> rich.repr.Result:
300
+ yield self.direction
301
+ yield self.lines
302
+
303
+
304
+ class ANSIFeatures(NamedTuple):
305
+ """Terminal feature flags."""
306
+
307
+ show_cursor: bool | None = None
308
+ alternate_screen: bool | None = None
309
+ bracketed_paste: bool | None = None
310
+ cursor_blink: bool | None = None
311
+ cursor_keys: bool | None = None
312
+ replace_mode: bool | None = None
313
+ auto_wrap: bool | None = None
314
+
315
+
316
+ MOUSE_TRACKING_MODES = Literal["button", "drag", "all"]
317
+ MOUSE_FORMAT = Literal["normal", "utf8", "sgr", "urxvt"]
318
+
319
+
320
+ class ANSIMouseTracking(NamedTuple):
321
+ """Set mouse tracking."""
322
+
323
+ mode: Literal["none"] | MOUSE_TRACKING_MODES | None = None
324
+ format: MOUSE_FORMAT | None = None
325
+ focus_events: bool | None = None
326
+ alternate_scroll: bool | None = None
327
+
328
+
329
+ # Not technically part of the terminal protocol
330
+ @rich.repr.auto
331
+ class ANSIWorkingDirectory(NamedTuple):
332
+ """Working directory changed"""
333
+
334
+ path: str
335
+
336
+ def __rich_repr__(self) -> rich.repr.Result:
337
+ yield self.path
338
+
339
+
340
+ @rich.repr.auto
341
+ class ANSICharacterSet(NamedTuple):
342
+ """Updated character set state."""
343
+
344
+ dec: DEC | None = None
345
+ dec_invoke: DECInvoke | None = None
346
+
347
+
348
+ @rich.repr.auto
349
+ class ANSICursorPositionRequest(NamedTuple):
350
+ pass
351
+
352
+
353
+ type ANSICommand = (
354
+ ANSIStyle
355
+ | ANSIContent
356
+ | ANSICursor
357
+ | ANSINewLine
358
+ | ANSIClear
359
+ | ANSIScrollMargin
360
+ | ANSIScroll
361
+ | ANSIWorkingDirectory
362
+ | ANSICharacterSet
363
+ | ANSIFeatures
364
+ | ANSIMouseTracking
365
+ | ANSICursorPositionRequest
366
+ )
367
+
368
+
369
+ class ANSIStream:
370
+ def __init__(self) -> None:
371
+ self.parser = ANSIParser()
372
+ self.style = NULL_STYLE
373
+ self.show_cursor = True
374
+
375
+ @classmethod
376
+ @lru_cache(maxsize=1024)
377
+ def _parse_sgr(cls, sgr: str) -> Style | None:
378
+ """Parse a SGR (Select Graphics Rendition) code in to a Style instance,
379
+ or `None` to indicate a reset.
380
+
381
+ Args:
382
+ sgr: SGR sequence.
383
+
384
+ Returns:
385
+ A Visual Style, or `None`.
386
+ """
387
+ codes = [
388
+ code if code < 255 else 255
389
+ for code in map(int, [sgr_code or "0" for sgr_code in sgr.split(";")])
390
+ ]
391
+ style = NULL_STYLE
392
+ while codes:
393
+ match codes:
394
+ case [38, 2, red, green, blue, *codes]:
395
+ # Foreground RGB
396
+ style += Style(foreground=Color(red, green, blue))
397
+ case [48, 2, red, green, blue, *codes]:
398
+ # Background RGB
399
+ style += Style(background=Color(red, green, blue))
400
+ case [38, 5, ansi_color, *codes]:
401
+ # Foreground ANSI
402
+ style += Style(foreground=ANSI_COLORS[ansi_color])
403
+ case [48, 5, ansi_color, *codes]:
404
+ # Background ANSI
405
+ style += Style(background=ANSI_COLORS[ansi_color])
406
+ case [0, *codes]:
407
+ # reset
408
+ return None
409
+ case [code, *codes]:
410
+ if sgr_style := SGR_STYLES.get(code):
411
+ style += sgr_style
412
+
413
+ return style
414
+
415
+ def feed(self, text: str) -> Iterable[ANSICommand]:
416
+ """Feed text potentially containing ANSI sequences, and parse in to
417
+ an iterable of ansi commands.
418
+
419
+ Args:
420
+ text: Text to feed.
421
+
422
+ Yields:
423
+ `ANSICommand` instances.
424
+ """
425
+
426
+ for token in self.parser.feed(text):
427
+ if not isinstance(token, Token):
428
+ yield from self.on_token(token)
429
+
430
+ ANSI_SEPARATORS = {
431
+ "\n": ANSICursor(delta_y=+1, absolute_x=0),
432
+ "\r": ANSICursor(absolute_x=0),
433
+ "\x08": ANSICursor(delta_x=-1),
434
+ }
435
+ CLEAR_LINE_CURSOR_TO_END = ANSICursor(
436
+ clear_range=(None, -1), erase=True, update_background=True
437
+ )
438
+ CLEAR_LINE_CURSOR_TO_BEGINNING = ANSICursor(
439
+ clear_range=(0, None), erase=True, update_background=True
440
+ )
441
+ CLEAR_LINE = ANSICursor(clear_range=(0, -1), erase=True, update_background=True)
442
+ CLEAR_SCREEN_CURSOR_TO_END = ANSIClear("cursor_to_end")
443
+ CLEAR_SCREEN_CURSOR_TO_BEGINNING = ANSIClear("cursor_to_beginning")
444
+ CLEAR_SCREEN = ANSIClear("screen")
445
+ CLEAR_SCREEN_SCROLLBACK = ANSIClear("scrollback")
446
+ SHOW_CURSOR = ANSIFeatures(show_cursor=True)
447
+ HIDE_CURSOR = ANSIFeatures(show_cursor=False)
448
+ ENABLE_ALTERNATE_SCREEN = ANSIFeatures(alternate_screen=True)
449
+ DISABLE_ALTERNATE_SCREEN = ANSIFeatures(alternate_screen=False)
450
+ ENABLE_BRACKETED_PASTE = ANSIFeatures(bracketed_paste=True)
451
+ DISABLE_BRACKETED_PASTE = ANSIFeatures(bracketed_paste=False)
452
+ ENABLE_CURSOR_BLINK = ANSIFeatures(cursor_blink=True)
453
+ DISABLE_CURSOR_BLINK = ANSIFeatures(cursor_blink=False)
454
+ ENABLE_CURSOR_KEYS_APPLICATION_MODE = ANSIFeatures(cursor_keys=True)
455
+ DISABLE_CURSOR_KEYS_APPLICATION_MODE = ANSIFeatures(cursor_keys=False)
456
+ ENABLE_REPLACE_MODE = ANSIFeatures(replace_mode=True)
457
+ DISABLE_REPLACE_MODE = ANSIFeatures(replace_mode=False)
458
+ ENABLE_AUTO_WRAP = ANSIFeatures(auto_wrap=True)
459
+ DISABLE_AUTO_WRAP = ANSIFeatures(auto_wrap=False)
460
+
461
+ INVOKE_G2_INTO_GL = DECInvoke(gl=2)
462
+ INVOKE_G3_INTO_GL = DECInvoke(gl=3)
463
+ INVOKE_G1_INTO_GR = DECInvoke(gr=1)
464
+ INVOKE_G2_INTO_GR = DECInvoke(gr=2)
465
+ INVOKE_G3_INTO_GR = DECInvoke(gr=3)
466
+ SHIFT_G2 = DECInvoke(shift=2)
467
+ SHIFT_G3 = DECInvoke(shift=3)
468
+
469
+ DEC_INVOKE_MAP = {
470
+ "n": INVOKE_G2_INTO_GL,
471
+ "o": INVOKE_G3_INTO_GL,
472
+ "~": INVOKE_G1_INTO_GR,
473
+ "}": INVOKE_G2_INTO_GR,
474
+ "|": INVOKE_G3_INTO_GR,
475
+ "N": SHIFT_G2,
476
+ "O": SHIFT_G3,
477
+ }
478
+
479
+ @classmethod
480
+ @lru_cache(maxsize=1024)
481
+ def _parse_csi(cls, csi: str) -> ANSICommand | None:
482
+ """Parse CSI sequence in to an ansi segment.
483
+
484
+ Args:
485
+ csi: CSI sequence.
486
+
487
+ Returns:
488
+ Ansi segment, or `None` if one couldn't be decoded.
489
+ """
490
+
491
+ if match := re.fullmatch(r"\[(\d+)?(?:;)?(\d*)?(\w)", csi):
492
+ match_groups = match.groups(default="")
493
+ match match_groups:
494
+ case [lines, _, "A"]:
495
+ # CUU - Cursor Up: ESC[nA
496
+ return ANSICursor(delta_y=-int(lines or 1))
497
+ case [lines, _, "B"]:
498
+ # CUD - Cursor Down: ESC[nB
499
+ return ANSICursor(delta_y=+int(lines or 1))
500
+ case [cells, _, "C"]:
501
+ # CUF - Cursor Forward: ESC[nC
502
+ return ANSICursor(delta_x=+int(cells or 1))
503
+ case [cells, _, "D"]:
504
+ # CUB - Cursor Back: ESC[nD
505
+ return ANSICursor(delta_x=-int(cells or 1))
506
+ case [lines, _, "E"]:
507
+ # CNL - Cursor Next Line: ESC[nE
508
+ return ANSICursor(absolute_x=0, delta_y=+int(lines or 1))
509
+ case [lines, _, "F"]:
510
+ # CPL - Cursor Previous Line: ESC[nF
511
+ return ANSICursor(absolute_x=0, delta_y=-int(lines or 1))
512
+ case [cells, _, "G"]:
513
+ # CHA - Cursor Horizontal Absolute: ESC[nG
514
+ return ANSICursor(absolute_x=+int(cells or 1) - 1)
515
+ case [row, column, "H" | "f"]:
516
+ # CUP - Cursor Position: ESC[n;mH
517
+ # HVP - Horizontal Vertical Position: ESC[n;mf
518
+ return ANSICursor(
519
+ absolute_x=int(column or 1) - 1,
520
+ absolute_y=int(row or 1) - 1,
521
+ )
522
+ case [characters, _, "P"]:
523
+ return ANSICursor(
524
+ clear_range=(0, int(characters or 1) - 1),
525
+ relative=True,
526
+ erase=True,
527
+ )
528
+ case [lines, _, "S"]:
529
+ return ANSIScroll(-1, int(lines))
530
+ case [lines, _, "T"]:
531
+ return ANSIScroll(+1, int(lines))
532
+ case [row, _, "d"]:
533
+ # VPA - Vertical Position Absolute: ESC[nd
534
+ return ANSICursor(absolute_y=int(row or 1) - 1)
535
+ case [characters, _, "X"]:
536
+ return ANSICursor(
537
+ clear_range=(0, int(characters or 1) - 1),
538
+ relative=True,
539
+ erase=False,
540
+ )
541
+ case ["0" | "", _, "J"]:
542
+ return cls.CLEAR_SCREEN_CURSOR_TO_END
543
+ case ["1", _, "J"]:
544
+ return cls.CLEAR_SCREEN_CURSOR_TO_BEGINNING
545
+ case ["2", _, "J"]:
546
+ return cls.CLEAR_SCREEN
547
+ case ["3", _, "J"]:
548
+ return cls.CLEAR_SCREEN_SCROLLBACK
549
+ case ["0" | "", _, "K"]:
550
+ return cls.CLEAR_LINE_CURSOR_TO_END
551
+ case ["1", _, "K"]:
552
+ return cls.CLEAR_LINE_CURSOR_TO_BEGINNING
553
+ case ["2", _, "K"]:
554
+ return cls.CLEAR_LINE
555
+ case [top, bottom, "r"]:
556
+ return ANSIScrollMargin(
557
+ int(top or "1") - 1 if top else None,
558
+ int(bottom or "1") - 1 if top else None,
559
+ )
560
+ case ["4", _, "h" | "l" as replace_mode]:
561
+ return (
562
+ cls.ENABLE_REPLACE_MODE
563
+ if replace_mode == "h"
564
+ else cls.DISABLE_REPLACE_MODE
565
+ )
566
+
567
+ case ["6", _, "n"]:
568
+ return ANSICursorPositionRequest()
569
+
570
+ case _:
571
+ print("Unknown CSI (a)", repr(csi))
572
+ return None
573
+
574
+ elif match := re.fullmatch(r"\[([0-9:;<=>?]*)([!-/]*)([@-~])", csi):
575
+ match match.groups(default=""):
576
+ case ["?25", "", "h"]:
577
+ return cls.SHOW_CURSOR
578
+ case ["?25", "", "l"]:
579
+ return cls.HIDE_CURSOR
580
+ case ["?1049", "", "h"]:
581
+ return cls.ENABLE_ALTERNATE_SCREEN
582
+ case ["?1049", "", "l"]:
583
+ return cls.DISABLE_ALTERNATE_SCREEN
584
+ case ["?2004", "", "h"]:
585
+ return cls.ENABLE_BRACKETED_PASTE
586
+ case ["?2004", "", "l"]:
587
+ return cls.DISABLE_BRACKETED_PASTE
588
+ case ["?12", "", "h"]:
589
+ return cls.ENABLE_CURSOR_BLINK
590
+ case ["?12", "", "l"]:
591
+ return cls.DISABLE_CURSOR_BLINK
592
+ case ["?1", "", "h"]:
593
+ return cls.ENABLE_CURSOR_KEYS_APPLICATION_MODE
594
+ case ["?1", "", "l"]:
595
+ return cls.DISABLE_CURSOR_KEYS_APPLICATION_MODE
596
+ case ["?7", "", "h"]:
597
+ return cls.ENABLE_AUTO_WRAP
598
+ case ["?7", "", "l"]:
599
+ return cls.DISABLE_AUTO_WRAP
600
+
601
+ # \x1b[22;0;0t
602
+ case [param1, param2, "t"]:
603
+ print("TODO", "XTWINOPS", param1, param2)
604
+ # 't' = XTWINOPS (Window manipulation)
605
+ return None
606
+ case _:
607
+ if match := re.fullmatch(r"\[\?([0-9;]+)([hl])", csi):
608
+ modes = [m for m in match.group(1).split(";")]
609
+ enable = match.group(2) == "h"
610
+ tracking: Literal["none"] | MOUSE_TRACKING_MODES | None = None
611
+ format: MOUSE_FORMAT | None = None
612
+ focus_events: bool | None = None
613
+ alternate_scroll: bool | None = None
614
+ for mode in modes:
615
+ if mode == "1000":
616
+ tracking = "button" if enable else "none"
617
+ elif mode == "1002":
618
+ tracking = "drag" if enable else "none"
619
+ elif mode == "1003":
620
+ tracking = "all" if enable else "none"
621
+ elif mode == "1006":
622
+ format = "sgr"
623
+ elif mode == "1015":
624
+ format = "urxvt"
625
+ elif mode == "1004":
626
+ focus_events = enable
627
+ elif mode == "1007":
628
+ alternate_scroll = enable
629
+ return ANSIMouseTracking(
630
+ mode=tracking,
631
+ format=format,
632
+ focus_events=focus_events,
633
+ alternate_scroll=alternate_scroll,
634
+ )
635
+ else:
636
+ print("Unknown CSI (b)", repr(csi))
637
+ return None
638
+
639
+ print("Unknown CSI (c)", repr(csi))
640
+ return None
641
+
642
+ def on_token(self, token: tuple[str, str]) -> Iterable[ANSICommand]:
643
+ match token:
644
+ case ["separator", separator]:
645
+ if separator == "\n":
646
+ yield ANSINewLine()
647
+ else:
648
+ yield self.ANSI_SEPARATORS[separator]
649
+
650
+ case ["osc", osc]:
651
+ match osc[1:].split(";"):
652
+ case ["8", *_, link]:
653
+ self.style += Style(link=link or None)
654
+ case ["2025", current_directory, *_]:
655
+ self.current_directory = current_directory
656
+ yield ANSIWorkingDirectory(current_directory)
657
+
658
+ case ["csi", csi]:
659
+ if csi.endswith("m"):
660
+ if (sgr_style := self._parse_sgr(csi[1:-1])) is None:
661
+ self.style = NULL_STYLE
662
+ else:
663
+ self.style += sgr_style
664
+ # Special case to use widget background rather
665
+ # than theme background
666
+ if (
667
+ sgr_style.background is not None
668
+ and sgr_style.background.ansi == -1
669
+ ):
670
+ self.style = (
671
+ Style(foreground=self.style.foreground)
672
+ + sgr_style.without_color
673
+ )
674
+ yield ANSIStyle(self.style)
675
+ else:
676
+ if (ansi_segment := self._parse_csi(csi)) is not None:
677
+ yield ansi_segment
678
+
679
+ case ["dec", dec]:
680
+ slot, character_set = list(dec)
681
+ yield ANSICharacterSet(DEC(DEC_SLOTS[slot], character_set))
682
+
683
+ case ["dec_invoke", dec_invoke]:
684
+ yield ANSICharacterSet(dec_invoke=self.DEC_INVOKE_MAP[dec_invoke[0]])
685
+
686
+ case ["control", code]:
687
+ if (control := CONTROL_CODES.get(code)) is not None:
688
+ if control == "ri": # control code
689
+ yield ANSICursor(delta_y=-1, auto_scroll=True)
690
+ elif control == "ind":
691
+ yield ANSICursor(delta_y=+1, auto_scroll=True)
692
+ else:
693
+ print("CONTROL", repr(code), repr(control))
694
+ else:
695
+ print("NOT HANDLED", code)
696
+
697
+ case ["content", text]:
698
+ yield ANSIContent(text)
699
+
700
+ case _:
701
+ print("UNKNWON TOKEN", repr(token))
702
+
703
+
704
+ class LineFold(NamedTuple):
705
+ """A line from the terminal, folded for presentation."""
706
+
707
+ line_no: int
708
+ """The (unfolded) line number."""
709
+
710
+ line_offset: int
711
+ """The index of the folded line."""
712
+
713
+ offset: int
714
+ """The offset within the original line."""
715
+
716
+ content: Content
717
+ """The content."""
718
+
719
+ updates: int = 0
720
+ """Integer that increments on update."""
721
+
722
+
723
+ @dataclass
724
+ class LineRecord:
725
+ """A single line in the terminal."""
726
+
727
+ content: Content
728
+ """The content."""
729
+
730
+ style: Style = NULL_STYLE
731
+ """The style for the remaining line."""
732
+
733
+ folds: list[LineFold] = field(default_factory=list)
734
+ """Line "folds" for wrapped lines."""
735
+
736
+ updates: int = 0
737
+ """An integer used for caching."""
738
+
739
+
740
+ @rich.repr.auto
741
+ class ScrollMargin(NamedTuple):
742
+ """Margins at the top and bottom of a window that won't scroll."""
743
+
744
+ top: int | None = None
745
+ """Margin at the top (in lines), or `None` for no scroll margin set."""
746
+ bottom: int | None = None
747
+ """Margin at the bottom (in lines), or `None` for no scroll margin set."""
748
+
749
+ def __rich_repr__(self) -> rich.repr.Result:
750
+ yield self.top
751
+ yield self.bottom
752
+
753
+ def get_line_range(self, height: int) -> tuple[int, int]:
754
+ """Get the scrollable line range (inclusive).
755
+
756
+ Args:
757
+ height: terminal height.
758
+
759
+ Returns:
760
+ A tuple of the (exclusive) top and bottom line numbers that scroll.
761
+ """
762
+ return (
763
+ self.top or 0,
764
+ height - 1 if self.bottom is None else self.bottom,
765
+ )
766
+
767
+
768
+ @dataclass
769
+ class Buffer:
770
+ """A terminal buffer (scrollback or alternate)"""
771
+
772
+ name: str = "buffer"
773
+ """Name of the buffer (debugging aid)."""
774
+ lines: list[LineRecord] = field(default_factory=list)
775
+ """unfolded lines."""
776
+ line_to_fold: list[int] = field(default_factory=list)
777
+ """An index from folded lines on to unfolded lines."""
778
+ folded_lines: list[LineFold] = field(default_factory=list)
779
+ """Folded lines."""
780
+ scroll_margin: ScrollMargin = ScrollMargin(None, None)
781
+ """Scroll margins"""
782
+ cursor_line: int = 0
783
+ """Folded line index."""
784
+ cursor_offset: int = 0
785
+ """Folded line offset."""
786
+ max_line_width: int = 0
787
+ """The longest line in the buffer."""
788
+ updates: int = 0
789
+ """Updates count (used in caching)."""
790
+ _updated_lines: set[int] | None = None
791
+
792
+ @property
793
+ def line_count(self) -> int:
794
+ """Total number of lines."""
795
+ return len(self.lines)
796
+
797
+ @property
798
+ def height(self) -> int:
799
+ """Height of the buffer (number of folded lines)."""
800
+ height = len(self.folded_lines)
801
+ return height
802
+
803
+ @property
804
+ def last_line_no(self) -> int:
805
+ """Index of last lines."""
806
+ return len(self.lines) - 1
807
+
808
+ @property
809
+ def unfolded_line(self) -> int:
810
+ """THh unfolded line index under the cursor."""
811
+ cursor_folded_line = self.folded_lines[self.cursor_line]
812
+ return cursor_folded_line.line_no
813
+
814
+ @property
815
+ def cursor(self) -> tuple[int, int]:
816
+ """The cursor offset within the un-folded lines."""
817
+
818
+ if self.cursor_line >= len(self.folded_lines):
819
+ return (len(self.folded_lines), 0)
820
+ cursor_folded_line = self.folded_lines[self.cursor_line]
821
+ cursor_line_offset = cursor_folded_line.line_offset
822
+ line_no = cursor_folded_line.line_no
823
+ line = self.lines[line_no]
824
+ position = 0
825
+ for folded_line_offset, folded_line in enumerate(line.folds):
826
+ if folded_line_offset == cursor_line_offset:
827
+ position += self.cursor_offset
828
+ break
829
+ position += len(folded_line.content)
830
+
831
+ return (line_no, position)
832
+
833
+ @property
834
+ def is_blank(self) -> bool:
835
+ """Is this buffer blank (spaces in all lines)?"""
836
+ return not any(
837
+ (line.content.plain.strip() or line.content.spans) for line in self.lines
838
+ )
839
+
840
+ def update_cursor(self, line_no: int, cursor_line_offset: int) -> None:
841
+ """Move the cursor to the given unfolded line and offset.
842
+
843
+ Sets `cursor_line` and `cursor_offset`.
844
+
845
+ Args:
846
+ line_no: Unfolded line number.
847
+ cursor_line_offset: Offset within the line.
848
+ """
849
+ line = self.lines[line_no]
850
+ fold_line_start = self.line_to_fold[line_no]
851
+ position = 0
852
+ fold_offset = 0
853
+ for fold_offset, fold in enumerate(line.folds):
854
+ line_length = len(fold.content)
855
+ if (
856
+ cursor_line_offset >= position
857
+ and cursor_line_offset < position + line_length
858
+ ):
859
+ self.cursor_line = fold_line_start + fold_offset
860
+ self.cursor_offset = cursor_line_offset - position
861
+ break
862
+ position += line_length
863
+ else:
864
+ self.cursor_line = fold_line_start + len(line.folds) - 1
865
+ self.cursor_offset = len(line.folds[-1].content)
866
+
867
+ def update_line(self, line_no: int) -> None:
868
+ """Record an updated line.
869
+
870
+ Args:
871
+ line_no: Line number to update.
872
+ """
873
+ if self._updated_lines is not None:
874
+ self._updated_lines.add(line_no)
875
+
876
+ def clear(self, updates: int) -> None:
877
+ """Clear the buffer to its initial state.
878
+
879
+ Args:
880
+ updates: the initial updates index.
881
+
882
+ """
883
+ del self.lines[:]
884
+ del self.line_to_fold[:]
885
+ del self.folded_lines[:]
886
+ self.cursor_line = 0
887
+ self.cursor_offset = 0
888
+ self.max_line_width = 0
889
+ self.updates = updates
890
+
891
+ def remove_last_line(self) -> None:
892
+ if not self.lines:
893
+ return
894
+ last_line_index = len(self.lines) - 1
895
+ del self.lines[-1]
896
+ del self.folded_lines[self.line_to_fold[last_line_index] :]
897
+ del self.line_to_fold[last_line_index]
898
+ self.updates += 1
899
+
900
+
901
+ @dataclass
902
+ class DECState:
903
+ """The (somewhat bonkers) mechanism for switching characters sets pre-unicode."""
904
+
905
+ slots: list[str] = field(default_factory=lambda: ["B", "B", "<", "0"])
906
+ gl_slot: int = 0
907
+ gr_slot: int = 2
908
+ shift: int | None = None
909
+
910
+ @property
911
+ def gl(self) -> str:
912
+ return self.slots[self.gl_slot]
913
+
914
+ @property
915
+ def gr(self) -> str:
916
+ return self.slots[self.gr_slot]
917
+
918
+ def update(self, dec: DEC | None, dec_invoke: DECInvoke | None) -> None:
919
+ if dec is not None:
920
+ self.slots[dec.slot] = dec.character_set
921
+ elif dec_invoke is not None:
922
+ if dec_invoke.shift:
923
+ self.shift = dec_invoke.shift
924
+ else:
925
+ if dec_invoke.gl is not None:
926
+ self.gl_slot = dec_invoke.gl
927
+ elif dec_invoke.gr is not None:
928
+ self.gr_slot = dec_invoke.gr
929
+
930
+ def translate(self, text: str) -> str:
931
+ translate_table: dict[int, str] | None
932
+ first_character: str | None = None
933
+ if self.shift is not None and (
934
+ translate_table := CHARSET_MAP.get(self.slots[self.shift], None)
935
+ ):
936
+ first_character = text[0].translate(translate_table)
937
+ self.shift = None
938
+
939
+ if translate_table := CHARSET_MAP.get(self.gl, None):
940
+ text = text.translate(translate_table)
941
+ if first_character is None:
942
+ return text
943
+ return f"{first_character}{text}"
944
+
945
+
946
+ @dataclass
947
+ class MouseTracking:
948
+ """The mouse tracking state."""
949
+
950
+ tracking: MOUSE_TRACKING_MODES = "all"
951
+ format: MOUSE_FORMAT = "normal"
952
+ focus_events: bool = False
953
+ alternate_scroll: bool = False
954
+
955
+
956
+ @rich.repr.auto
957
+ class TerminalState:
958
+ """Abstract terminal state."""
959
+
960
+ def __init__(
961
+ self,
962
+ write_stdin: Callable[[str], Awaitable],
963
+ *,
964
+ width: int = 80,
965
+ height: int = 24,
966
+ ) -> None:
967
+ """
968
+ Args:
969
+ width: Initial width.
970
+ height: Initial height.
971
+ """
972
+ self._write_stdin = write_stdin
973
+
974
+ self._ansi_stream = ANSIStream()
975
+ """ANSI stream processor."""
976
+
977
+ self.width = width
978
+ """Width of the terminal."""
979
+ self.height = height
980
+ """Height of the terminal."""
981
+ self.style = NULL_STYLE
982
+ """The current style."""
983
+ self.show_cursor = True
984
+ """Is the cursor visible?"""
985
+ self.alternate_screen = False
986
+ """Is the terminal in the alternate buffer state?"""
987
+ self.bracketed_paste = False
988
+ """Is bracketed pase enabled?"""
989
+ self.cursor_blink = False
990
+ """Should the cursor blink?"""
991
+ self.cursor_keys = False
992
+ """Is cursor keys application mode enabled?"""
993
+ self.replace_mode = True
994
+ """Should content replaces characters (`True`) or insert (`False`)?"""
995
+ self.auto_wrap = True
996
+ """Should content wrap?"""
997
+ self.current_directory: str = ""
998
+ """Current working directory."""
999
+ self.scrollback_buffer = Buffer("scrollback")
1000
+ """Scrollbar buffer lines."""
1001
+ self.alternate_buffer = Buffer("alternate")
1002
+ """Alternate buffer lines."""
1003
+ self.dec_state = DECState()
1004
+ """The DEC (character set) state."""
1005
+ self.mouse_tracking: MouseTracking | None = None
1006
+ """The mouse tracking state."""
1007
+
1008
+ self._updates: int = 0
1009
+ """Incrementing integer used in caching."""
1010
+
1011
+ def __rich_repr__(self) -> rich.repr.Result:
1012
+ yield "width", self.width
1013
+ yield "height", self.height
1014
+ yield "style", self.style, NULL_STYLE
1015
+ yield "show_cursor", self.show_cursor, True
1016
+ yield "alternate_screen", self.alternate_screen, False
1017
+ yield "bracketed_paste", self.bracketed_paste, False
1018
+ yield "cursor_blink", self.cursor_blink, False
1019
+ yield "replace_mode", self.replace_mode, True
1020
+ yield "auto_wrap", self.auto_wrap, True
1021
+ yield "dec_state", self.dec_state
1022
+ yield "mouse_tracking", self.mouse_tracking, None
1023
+
1024
+ async def write_stdin(self, text: str) -> bool:
1025
+ if self._write_stdin is not None:
1026
+ return await self._write_stdin(text)
1027
+ return False
1028
+ return True
1029
+
1030
+ @property
1031
+ def screen_start_line_no(self) -> int:
1032
+ return self.buffer.line_count - self.height
1033
+
1034
+ @property
1035
+ def screen_end_line_no(self) -> int:
1036
+ return self.buffer.line_count
1037
+
1038
+ @property
1039
+ def updates(self) -> int:
1040
+ """An integer that advanvces when the state is changed."""
1041
+ return self._updates
1042
+
1043
+ @property
1044
+ def buffer(self) -> Buffer:
1045
+ """The buffer (scrollack or alternate)"""
1046
+ if self.alternate_screen:
1047
+ return self.alternate_buffer
1048
+ return self.scrollback_buffer
1049
+
1050
+ @property
1051
+ def max_line_width(self) -> int | None:
1052
+ return self.scrollback_buffer.max_line_width
1053
+
1054
+ def advance_updates(self) -> int:
1055
+ """Advance the `updates` integer and return it.
1056
+
1057
+ Returns:
1058
+ int: Updates.
1059
+ """
1060
+ self._updates += 1
1061
+ return self._updates
1062
+
1063
+ def update_size(self, width: int | None = None, height: int | None = None) -> None:
1064
+ """Update the dimensions of the terminal.
1065
+
1066
+ Args:
1067
+ width: New width, or `None` for no change.
1068
+ height: New height, or `None` for no change.
1069
+ """
1070
+ previous_width = self.width
1071
+ if width is not None:
1072
+ self.width = width
1073
+ if height is not None:
1074
+ self.height = height
1075
+
1076
+ if previous_width != width:
1077
+ self._reflow()
1078
+
1079
+ def key_event_to_stdin(self, event: events.Key) -> str | None:
1080
+ """Get the stdin string for a key event.
1081
+
1082
+ This will depend on the terminal state.
1083
+
1084
+ Args:
1085
+ event: Key event.
1086
+
1087
+ Returns:
1088
+ A string to be sent to stdin, or `None` if no key was produced.
1089
+ """
1090
+ if (
1091
+ self.cursor_keys
1092
+ and (sequence := CURSOR_KEYS_APPLICATION.get(event.key)) is not None
1093
+ ):
1094
+ return sequence
1095
+
1096
+ if (mapped_key := TERMINAL_KEY_MAP.get(event.key)) is not None:
1097
+ return mapped_key
1098
+ if event.character:
1099
+ return event.character
1100
+ return None
1101
+
1102
+ def key_escape(self) -> str:
1103
+ """Generate the escape sequence for the escape key.
1104
+
1105
+ Returns:
1106
+ str: ANSI escape sequences.
1107
+ """
1108
+ return "\x1b"
1109
+
1110
+ def remove_trailing_blank_lines_from_scrollback(self) -> None:
1111
+ """Remove blank lines at the end of the scrollback buffer.
1112
+
1113
+ A line is considered blank if it is whitespace and has no color or style applied.
1114
+
1115
+ """
1116
+ buffer = self.scrollback_buffer
1117
+ while buffer.lines:
1118
+ last_line_content = buffer.lines[-1].content
1119
+ if last_line_content.spans or last_line_content.plain.rstrip():
1120
+ break
1121
+ buffer.remove_last_line()
1122
+
1123
+ def _reflow(self) -> None:
1124
+ buffer = self.buffer
1125
+ if not buffer.lines:
1126
+ return
1127
+
1128
+ buffer._updated_lines = None
1129
+ # Unfolded cursor position
1130
+ cursor_line, cursor_offset = buffer.cursor
1131
+
1132
+ buffer.folded_lines.clear()
1133
+ buffer.line_to_fold.clear()
1134
+ width = self.width
1135
+
1136
+ for line_no, line_record in enumerate(buffer.lines):
1137
+ line_expanded_tabs = line_record.content.expand_tabs(8)
1138
+ line_record.folds[:] = self._fold_line(line_no, line_expanded_tabs, width)
1139
+ line_record.updates = self.advance_updates()
1140
+ buffer.line_to_fold.append(len(buffer.folded_lines))
1141
+ buffer.folded_lines.extend(line_record.folds)
1142
+
1143
+ # After reflow, we need to work out where the cursor is within the folded lines
1144
+ # cursor_line = min(cursor_line, len(buffer.lines) - 1)
1145
+ if cursor_line >= len(buffer.lines):
1146
+ buffer.cursor_line = len(buffer.lines)
1147
+ buffer.cursor_offset = 0
1148
+ else:
1149
+ line = buffer.lines[cursor_line]
1150
+ fold_cursor_line = buffer.line_to_fold[cursor_line]
1151
+
1152
+ fold_cursor_offset = 0
1153
+ for fold in reversed(line.folds):
1154
+ if cursor_offset >= fold.offset:
1155
+ fold_cursor_line += fold.line_offset
1156
+ fold_cursor_offset = cursor_offset - fold.offset
1157
+ break
1158
+
1159
+ buffer.cursor_line = fold_cursor_line
1160
+ buffer.cursor_offset = fold_cursor_offset
1161
+
1162
+ async def write(
1163
+ self, text: str, *, hide_output: bool = False
1164
+ ) -> tuple[set[int] | None, set[int] | None]:
1165
+ """Write to the terminal.
1166
+
1167
+ Args:
1168
+ text: Text to write.
1169
+ hide_output: Hide visible output from buffers.
1170
+
1171
+ Returns:
1172
+ A pair of deltas or `None for full refresh, for scrollback and alternate screen.
1173
+ """
1174
+ alternate_buffer = self.alternate_buffer
1175
+ scrollback_buffer = self.scrollback_buffer
1176
+
1177
+ # Reset updated lines delta
1178
+ alternate_buffer._updated_lines = set()
1179
+ scrollback_buffer._updated_lines = set()
1180
+ # Write sequences and update
1181
+ if hide_output:
1182
+ for ansi_command in self._ansi_stream.feed(text):
1183
+ if not isinstance(ansi_command, (ANSIContent, ANSICursor)):
1184
+ await self._handle_ansi_command(ansi_command)
1185
+ else:
1186
+ for ansi_command in self._ansi_stream.feed(text):
1187
+ await self._handle_ansi_command(ansi_command)
1188
+
1189
+ # Get deltas
1190
+ scrollback_updates = (
1191
+ None
1192
+ if scrollback_buffer._updated_lines is None
1193
+ else scrollback_buffer._updated_lines.copy()
1194
+ )
1195
+ alternate_updates = (
1196
+ None
1197
+ if alternate_buffer._updated_lines is None
1198
+ else alternate_buffer._updated_lines.copy()
1199
+ )
1200
+ # Reset deltas
1201
+ self.alternate_buffer._updated_lines = set()
1202
+ self.scrollback_buffer._updated_lines = set()
1203
+ # Return deltas accumulated during write
1204
+ return (scrollback_updates, alternate_updates)
1205
+
1206
+ def get_cursor_line_offset(self, buffer: Buffer) -> int:
1207
+ """The cursor offset within the un-folded lines."""
1208
+ cursor_folded_line = buffer.folded_lines[buffer.cursor_line]
1209
+ cursor_line_offset = cursor_folded_line.line_offset
1210
+ line_no = cursor_folded_line.line_no
1211
+ line = buffer.lines[line_no]
1212
+ position = 0
1213
+ for folded_line_offset, folded_line in enumerate(line.folds):
1214
+ if folded_line_offset == cursor_line_offset:
1215
+ position += buffer.cursor_offset
1216
+ break
1217
+ position += len(folded_line.content)
1218
+ return position
1219
+
1220
+ def clear_buffer(self, clear: ClearType) -> None:
1221
+ buffer = self.buffer
1222
+ if clear == "screen":
1223
+ buffer.clear(self.advance_updates())
1224
+ # for _ in range(self.height):
1225
+ # self.add_line(buffer, EMPTY_CONTENT)
1226
+ elif clear == "cursor_to_end":
1227
+ buffer._updated_lines = None
1228
+ folded_cursor_line = buffer.cursor_line
1229
+ cursor_line, cursor_line_offset = buffer.cursor
1230
+ while buffer.cursor_line >= len(buffer.folded_lines):
1231
+ self.add_line(buffer, EMPTY_LINE)
1232
+ line = buffer.lines[cursor_line]
1233
+ del buffer.lines[cursor_line + 1 :]
1234
+ del buffer.line_to_fold[cursor_line + 1 :]
1235
+ del buffer.folded_lines[folded_cursor_line + 1 :]
1236
+ self.update_line(buffer, cursor_line, line.content[:cursor_line_offset])
1237
+ else:
1238
+ # print(f"TODO: clear_buffer({clear!r})")
1239
+ buffer.clear(self.advance_updates())
1240
+
1241
+ def scroll_buffer(self, direction: int, lines: int) -> None:
1242
+ """Scroll the buffer.
1243
+
1244
+ Args:
1245
+ direction: +1 for down, -1 for up.
1246
+ lines: Number of lines.
1247
+ """
1248
+ buffer = self.buffer
1249
+ margin_top, margin_bottom = buffer.scroll_margin.get_line_range(self.height)
1250
+
1251
+ if direction == -1:
1252
+ # up (first in test)
1253
+ for line_no in range(margin_top, margin_bottom + 1):
1254
+ copy_line_no = line_no + lines
1255
+ copy_content = EMPTY_CONTENT
1256
+ copy_style = NULL_STYLE
1257
+ if copy_line_no <= margin_bottom:
1258
+ try:
1259
+ copy_line = buffer.lines[copy_line_no]
1260
+ except IndexError:
1261
+ pass
1262
+ else:
1263
+ copy_content = copy_line.content
1264
+ copy_style = copy_line.style
1265
+
1266
+ self.update_line(buffer, line_no, copy_content, copy_style)
1267
+ else:
1268
+ # down
1269
+ for line_no in reversed(range(margin_top, margin_bottom + 1)):
1270
+ copy_line_no = line_no - lines
1271
+ copy_content = EMPTY_CONTENT
1272
+ copy_style = NULL_STYLE
1273
+ if copy_line_no >= margin_top:
1274
+ try:
1275
+ copy_line = buffer.lines[copy_line_no]
1276
+ except IndexError:
1277
+ pass
1278
+ else:
1279
+ copy_content = copy_line.content
1280
+ copy_style = copy_line.style
1281
+ self.update_line(buffer, line_no, copy_content, copy_style)
1282
+
1283
+ @classmethod
1284
+ def _expand_content(cls, content: Content, offset: int, style: Style) -> Content:
1285
+ """Expand content to be at least as long as a given offset.
1286
+
1287
+ Args:
1288
+ content: Content to expand.
1289
+ offset: Offset within the content.
1290
+ style: Style of padding.
1291
+
1292
+ Returns:
1293
+ New Content.
1294
+ """
1295
+ if offset > len(content):
1296
+ content += Content.blank(offset - len(content), style)
1297
+ return content
1298
+
1299
+ async def _handle_ansi_command(self, ansi_command: ANSICommand) -> None:
1300
+ if isinstance(ansi_command, ANSINewLine):
1301
+ if self.alternate_screen:
1302
+ # New line behaves differently in alternate screen
1303
+ ansi_command = ANSICursor(delta_y=+1, auto_scroll=True)
1304
+ else:
1305
+ ansi_command = ANSICursor(delta_y=+1, absolute_x=0)
1306
+
1307
+ match ansi_command:
1308
+ case ANSIStyle(style):
1309
+ self.style = style
1310
+
1311
+ case ANSIContent(text):
1312
+ buffer = self.buffer
1313
+ folded_lines = buffer.folded_lines
1314
+ while buffer.cursor_line >= len(folded_lines):
1315
+ self.add_line(buffer, EMPTY_LINE)
1316
+ folded_line = folded_lines[buffer.cursor_line]
1317
+ previous_content = folded_line.content
1318
+ line_no = folded_line.line_no
1319
+ line = buffer.lines[line_no]
1320
+
1321
+ cursor_line_offset = self.get_cursor_line_offset(buffer)
1322
+ line_content = line.content
1323
+ if cursor_line_offset > len(line_content):
1324
+ line_content = self._expand_content(
1325
+ line_content, cursor_line_offset, line.style
1326
+ )
1327
+ content = Content.styled(
1328
+ self.dec_state.translate(text),
1329
+ self.style,
1330
+ strip_control_codes=False,
1331
+ )
1332
+ if self.replace_mode:
1333
+ updated_line = Content.assemble(
1334
+ line_content[:cursor_line_offset],
1335
+ content,
1336
+ line_content[cursor_line_offset + len(content) :],
1337
+ strip_control_codes=False,
1338
+ )
1339
+ else:
1340
+ updated_line = Content.assemble(
1341
+ line_content[:cursor_line_offset],
1342
+ content,
1343
+ line_content[cursor_line_offset:],
1344
+ strip_control_codes=False,
1345
+ )
1346
+ self.update_line(buffer, line_no, updated_line)
1347
+ buffer.update_cursor(line_no, cursor_line_offset + len(content))
1348
+ buffer.updates = self.advance_updates()
1349
+
1350
+ case ANSICursor(
1351
+ delta_x,
1352
+ delta_y,
1353
+ absolute_x,
1354
+ absolute_y,
1355
+ erase,
1356
+ clear_range,
1357
+ _relative,
1358
+ update_background,
1359
+ auto_scroll,
1360
+ ):
1361
+ # print(repr(ansi_command))
1362
+ buffer = self.buffer
1363
+ folded_lines = buffer.folded_lines
1364
+ while buffer.cursor_line >= len(folded_lines):
1365
+ self.add_line(buffer, EMPTY_LINE)
1366
+
1367
+ if auto_scroll and delta_y is not None:
1368
+ margins = buffer.scroll_margin.get_line_range(self.height)
1369
+ margin_top, margin_bottom = margins
1370
+
1371
+ if (
1372
+ buffer.cursor_line >= margin_top
1373
+ and buffer.cursor_line <= margin_bottom
1374
+ ):
1375
+ start_line_no = self.screen_start_line_no
1376
+ start_line_no = 0
1377
+ scroll_cursor = buffer.cursor_line + delta_y
1378
+ if scroll_cursor > (start_line_no + margin_bottom):
1379
+ self.scroll_buffer(-1, 1)
1380
+ return
1381
+ elif scroll_cursor < (start_line_no + margin_top):
1382
+ self.scroll_buffer(+1, 1)
1383
+ return
1384
+
1385
+ folded_line = folded_lines[buffer.cursor_line]
1386
+ previous_content = folded_line.content
1387
+ line = buffer.lines[folded_line.line_no]
1388
+ if update_background:
1389
+ line.style = self.style
1390
+
1391
+ if clear_range is not None:
1392
+ cursor_line_offset = self.get_cursor_line_offset(buffer)
1393
+
1394
+ line_content = line.content
1395
+ if cursor_line_offset > len(line.content):
1396
+ line_content = self._expand_content(
1397
+ line.content, cursor_line_offset, line.style
1398
+ )
1399
+
1400
+ # Start and end replace are *inclusive*
1401
+ clear_start, clear_end = ansi_command.get_clear_offsets(
1402
+ cursor_line_offset, len(line_content)
1403
+ )
1404
+
1405
+ before_clear = line_content[:clear_start]
1406
+ after_clear = line_content[clear_end + 1 :]
1407
+
1408
+ if erase:
1409
+ # Range is remove
1410
+ updated_line = Content.assemble(
1411
+ before_clear,
1412
+ after_clear,
1413
+ strip_control_codes=False,
1414
+ )
1415
+ self.update_line(buffer, folded_line.line_no, updated_line)
1416
+ else:
1417
+ # Range is replaced with spaces
1418
+ blank_width = clear_end - clear_start + 1
1419
+
1420
+ updated_line = Content.assemble(
1421
+ before_clear,
1422
+ Content.blank(blank_width, self.style),
1423
+ after_clear,
1424
+ strip_control_codes=False,
1425
+ )
1426
+ self.update_line(buffer, folded_line.line_no, updated_line)
1427
+
1428
+ if not previous_content.is_same(folded_line.content):
1429
+ buffer.updates = self.advance_updates()
1430
+
1431
+ if delta_x is not None:
1432
+ buffer.cursor_offset = clamp(
1433
+ buffer.cursor_offset + delta_x, 0, self.width - 1
1434
+ )
1435
+ buffer.update_line(buffer.cursor_line)
1436
+ if absolute_x is not None:
1437
+ buffer.cursor_offset = clamp(absolute_x, 0, self.width - 1)
1438
+ buffer.update_line(buffer.cursor_line)
1439
+
1440
+ current_cursor_line = buffer.cursor_line
1441
+ if delta_y is not None:
1442
+ buffer.update_line(buffer.cursor_line)
1443
+ buffer.cursor_line = max(0, buffer.cursor_line + delta_y)
1444
+ buffer.update_line(buffer.cursor_line)
1445
+ if absolute_y is not None:
1446
+ buffer.update_line(buffer.cursor_line)
1447
+ buffer.cursor_line = max(0, absolute_y)
1448
+ buffer.update_line(buffer.cursor_line)
1449
+
1450
+ if current_cursor_line != buffer.cursor_line:
1451
+ # Simplify when the cursor moves away from the current line
1452
+ line.content.simplify() # Reduce segments
1453
+ self._line_updated(buffer, current_cursor_line)
1454
+ self._line_updated(buffer, buffer.cursor_line)
1455
+
1456
+ case ANSIFeatures() as features:
1457
+ if features.show_cursor is not None:
1458
+ self.show_cursor = features.show_cursor
1459
+ if features.alternate_screen is not None:
1460
+ self.alternate_screen = features.alternate_screen
1461
+ if features.bracketed_paste is not None:
1462
+ self.bracketed_paste = features.bracketed_paste
1463
+ if features.cursor_blink is not None:
1464
+ self.cursor_blink = features.cursor_blink
1465
+ if features.cursor_keys is not None:
1466
+ self.cursor_keys = features.cursor_keys
1467
+ if features.auto_wrap is not None:
1468
+ self.auto_wrap = features.auto_wrap
1469
+ self.advance_updates()
1470
+
1471
+ case ANSIClear(clear):
1472
+ self.clear_buffer(clear)
1473
+
1474
+ case ANSIScrollMargin(top, bottom):
1475
+ self.buffer.scroll_margin = ScrollMargin(top, bottom)
1476
+ # Setting the scroll margins moves the cursor to (1, 1)
1477
+ buffer = self.buffer
1478
+ self._line_updated(buffer, buffer.cursor_line)
1479
+ buffer.cursor_line = 0
1480
+ buffer.cursor_offset = 0
1481
+ self._line_updated(buffer, buffer.cursor_line)
1482
+
1483
+ case ANSIScroll(direction, lines):
1484
+ self.scroll_buffer(direction, lines)
1485
+
1486
+ case ANSICharacterSet(dec, dec_invoke):
1487
+ self.dec_state.update(dec, dec_invoke)
1488
+
1489
+ case ANSIWorkingDirectory(path):
1490
+ self.current_directory = path
1491
+
1492
+ case ANSIMouseTracking(tracking, format, focus_events, alternate_scroll):
1493
+ if tracking == "none":
1494
+ self.mouse_tracking = None
1495
+ return
1496
+ if (mouse_tracking := self.mouse_tracking) is None:
1497
+ mouse_tracking = self.mouse_tracking = MouseTracking()
1498
+ if tracking is not None:
1499
+ mouse_tracking.tracking = tracking
1500
+ if format is not None:
1501
+ mouse_tracking.format = format
1502
+ if focus_events is not None:
1503
+ mouse_tracking.focus_events = focus_events
1504
+ if alternate_scroll is not None:
1505
+ mouse_tracking.alternate_scroll = alternate_scroll
1506
+
1507
+ case ANSICursorPositionRequest():
1508
+ row = self.buffer.cursor_line + 1
1509
+ column = self.buffer.cursor_offset + 1
1510
+ await self.write_stdin(f"\x1b[{row};{column}R")
1511
+
1512
+ case _:
1513
+ print("Unhandled", ansi_command)
1514
+
1515
+ def _line_updated(self, buffer: Buffer, line_no: int) -> None:
1516
+ """Mark a line has having been udpated.
1517
+
1518
+ Args:
1519
+ buffer: Buffer to use.
1520
+ line_no: Line number to mark as updated.
1521
+ """
1522
+ try:
1523
+ buffer.lines[line_no].updates = self.advance_updates()
1524
+ if buffer._updated_lines is not None:
1525
+ buffer._updated_lines.add(line_no)
1526
+ except IndexError:
1527
+ pass
1528
+
1529
+ def _fold_line(self, line_no: int, line: Content, width: int) -> list[LineFold]:
1530
+ updates = self._updates
1531
+ if not self.auto_wrap:
1532
+ return [LineFold(line_no, 0, 0, line, updates)]
1533
+ if not width:
1534
+ return [LineFold(0, 0, 0, line, updates)]
1535
+ line_length = line.cell_length
1536
+ if line_length <= width:
1537
+ return [LineFold(line_no, 0, 0, line, updates)]
1538
+
1539
+ folded_lines = line.fold(width)
1540
+ offsets = [0, *accumulate(len(line) for line in folded_lines)][:-1]
1541
+ folds = [
1542
+ LineFold(line_no, line_offset, offset, folded_line, updates)
1543
+ for line_offset, (offset, folded_line) in enumerate(
1544
+ zip(offsets, folded_lines)
1545
+ )
1546
+ ]
1547
+ assert len(folds)
1548
+ return folds
1549
+
1550
+ def add_line(
1551
+ self, buffer: Buffer, content: Content, style: Style = NULL_STYLE
1552
+ ) -> None:
1553
+ updates = self.advance_updates()
1554
+ line_no = buffer.line_count
1555
+ width = self.width
1556
+ line_record = LineRecord(
1557
+ content,
1558
+ style,
1559
+ self._fold_line(line_no, content, width),
1560
+ updates,
1561
+ )
1562
+ buffer.lines.append(line_record)
1563
+ folds = line_record.folds
1564
+ buffer.line_to_fold.append(len(buffer.folded_lines))
1565
+ fold_count = len(buffer.folded_lines)
1566
+ if buffer._updated_lines is not None:
1567
+ buffer._updated_lines.update(range(fold_count, fold_count + len(folds)))
1568
+ buffer.folded_lines.extend(folds)
1569
+ buffer.updates = updates
1570
+
1571
+ def update_line(
1572
+ self, buffer: Buffer, line_index: int, line: Content, style: Style | None = None
1573
+ ) -> None:
1574
+ """Update a line (potentially refolding and moving subsequencte lines down).
1575
+
1576
+ Args:
1577
+ buffer: Buffer.
1578
+ line_index: Line index (unfolded).
1579
+ line: New line content.
1580
+ style: New background style, or `None` not to update.
1581
+ """
1582
+ while line_index >= len(buffer.lines):
1583
+ self.add_line(buffer, EMPTY_LINE)
1584
+
1585
+ line_expanded_tabs = line.expand_tabs(8)
1586
+ buffer.max_line_width = max(
1587
+ line_expanded_tabs.cell_length, buffer.max_line_width
1588
+ )
1589
+ line_record = buffer.lines[line_index]
1590
+ line_record.content = line
1591
+ if style is not None:
1592
+ line_record.style = style
1593
+ line_record.folds[:] = self._fold_line(
1594
+ line_index, line_expanded_tabs, self.width
1595
+ )
1596
+ line_record.updates = self.advance_updates()
1597
+
1598
+ if buffer._updated_lines is not None:
1599
+ fold_start = buffer.line_to_fold[line_index]
1600
+ buffer._updated_lines.update(
1601
+ range(fold_start, fold_start + len(line_record.folds))
1602
+ )
1603
+
1604
+ fold_line = buffer.line_to_fold[line_index]
1605
+ del buffer.line_to_fold[line_index:]
1606
+ del buffer.folded_lines[fold_line:]
1607
+
1608
+ for line_no in range(line_index, buffer.line_count):
1609
+ line_record = buffer.lines[line_no]
1610
+ buffer.line_to_fold.append(len(buffer.folded_lines))
1611
+ for fold in line_record.folds:
1612
+ buffer.folded_lines.append(fold)