streamdown 0.12.0__tar.gz → 0.14.0__tar.gz

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 (53) hide show
  1. {streamdown-0.12.0 → streamdown-0.14.0}/PKG-INFO +31 -4
  2. {streamdown-0.12.0 → streamdown-0.14.0}/README.md +30 -3
  3. {streamdown-0.12.0 → streamdown-0.14.0}/pyproject.toml +1 -1
  4. streamdown-0.14.0/streamdown/scrape/file_0.py +22 -0
  5. streamdown-0.14.0/streamdown/scrape/file_1.js +27 -0
  6. streamdown-0.14.0/streamdown/scrape/file_2.cpp +23 -0
  7. {streamdown-0.12.0 → streamdown-0.14.0}/streamdown/sd.py +200 -88
  8. streamdown-0.14.0/tests/block.md +10 -0
  9. {streamdown-0.12.0 → streamdown-0.14.0}/tests/chunk-buffer.sh +1 -1
  10. {streamdown-0.12.0 → streamdown-0.14.0}/.gitignore +0 -0
  11. {streamdown-0.12.0 → streamdown-0.14.0}/.vimrc +0 -0
  12. {streamdown-0.12.0 → streamdown-0.14.0}/24-bit-color.sh +0 -0
  13. {streamdown-0.12.0 → streamdown-0.14.0}/LICENSE.MIT +0 -0
  14. {streamdown-0.12.0 → streamdown-0.14.0}/configurable.png +0 -0
  15. {streamdown-0.12.0 → streamdown-0.14.0}/copyable.png +0 -0
  16. {streamdown-0.12.0 → streamdown-0.14.0}/dunder.png +0 -0
  17. {streamdown-0.12.0 → streamdown-0.14.0}/error.txt +0 -0
  18. {streamdown-0.12.0 → streamdown-0.14.0}/newdir/file_0.py +0 -0
  19. {streamdown-0.12.0 → streamdown-0.14.0}/newdir/file_1.rb +0 -0
  20. {streamdown-0.12.0 → streamdown-0.14.0}/newdir/file_2.jl +0 -0
  21. {streamdown-0.12.0 → streamdown-0.14.0}/passthrough.py +0 -0
  22. {streamdown-0.12.0 → streamdown-0.14.0}/somelog.txt +0 -0
  23. {streamdown-0.12.0 → streamdown-0.14.0}/streamdown/__init__.py +0 -0
  24. {streamdown-0.12.0 → streamdown-0.14.0}/streamdown/plugins/README.md +0 -0
  25. {streamdown-0.12.0 → streamdown-0.14.0}/streamdown/plugins/latex.py +0 -0
  26. {streamdown-0.12.0 → streamdown-0.14.0}/streamdown/tt.mds +0 -0
  27. {streamdown-0.12.0 → streamdown-0.14.0}/table.png +0 -0
  28. {streamdown-0.12.0 → streamdown-0.14.0}/temp.py +0 -0
  29. {streamdown-0.12.0 → streamdown-0.14.0}/test.py +0 -0
  30. {streamdown-0.12.0 → streamdown-0.14.0}/test_input.md +0 -0
  31. {streamdown-0.12.0 → streamdown-0.14.0}/tests/README.md +0 -0
  32. {streamdown-0.12.0 → streamdown-0.14.0}/tests/code.md +0 -0
  33. {streamdown-0.12.0 → streamdown-0.14.0}/tests/example.md +0 -0
  34. {streamdown-0.12.0 → streamdown-0.14.0}/tests/fizzbuzz.md +0 -0
  35. {streamdown-0.12.0 → streamdown-0.14.0}/tests/inline.md +0 -0
  36. {streamdown-0.12.0 → streamdown-0.14.0}/tests/line-buffer.sh +0 -0
  37. {streamdown-0.12.0 → streamdown-0.14.0}/tests/line-wrap.md +0 -0
  38. {streamdown-0.12.0 → streamdown-0.14.0}/tests/line.md +0 -0
  39. {streamdown-0.12.0 → streamdown-0.14.0}/tests/links.md +0 -0
  40. {streamdown-0.12.0 → streamdown-0.14.0}/tests/longer-example.md +0 -0
  41. {streamdown-0.12.0 → streamdown-0.14.0}/tests/mandlebrot.md +0 -0
  42. {streamdown-0.12.0 → streamdown-0.14.0}/tests/markdown.md +0 -0
  43. {streamdown-0.12.0 → streamdown-0.14.0}/tests/nested-example.md +0 -0
  44. {streamdown-0.12.0 → streamdown-0.14.0}/tests/new.md +0 -0
  45. {streamdown-0.12.0 → streamdown-0.14.0}/tests/outline.md +0 -0
  46. {streamdown-0.12.0 → streamdown-0.14.0}/tests/sd.log +0 -0
  47. {streamdown-0.12.0 → streamdown-0.14.0}/tests/table-break.md +0 -0
  48. {streamdown-0.12.0 → streamdown-0.14.0}/tests/table.md +0 -0
  49. {streamdown-0.12.0 → streamdown-0.14.0}/tests/table_test.md +0 -0
  50. {streamdown-0.12.0 → streamdown-0.14.0}/tests/test.md +0 -0
  51. {streamdown-0.12.0 → streamdown-0.14.0}/tests/test_input.md +0 -0
  52. {streamdown-0.12.0 → streamdown-0.14.0}/tests/white-space-code.md +0 -0
  53. {streamdown-0.12.0 → streamdown-0.14.0}/tests/wm.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamdown
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: A streaming markdown renderer for modern terminals with syntax highlighting
5
5
  Project-URL: Homepage, https://github.com/kristopolous/Streamdown
6
6
  Project-URL: Bug Tracker, https://github.com/kristopolous/Streamdown/issues
@@ -41,7 +41,11 @@ This will work with [simonw's llm](https://github.com/simonw/llm) unlike with [r
41
41
  ### Provides clean copyable code for long code blocks and short terminals.
42
42
  ![copyable](https://github.com/user-attachments/assets/4a3539c5-b5d1-4d6a-8bce-032724d8909d)
43
43
 
44
- ### Does OSC 8 links for modern terminals.
44
+ ### Supports images, why not?
45
+ Here's kitty and alacritty. Try to do that in glow...
46
+ ![doggie](https://github.com/user-attachments/assets/9a392929-b6c2-4204-b257-e09305acb7af)
47
+
48
+ ### Does OSC 8 links for modern terminals (and optionally OSC 52 for clipboard)
45
49
  [links.webm](https://github.com/user-attachments/assets/a5f71791-7c58-4183-ad3b-309f470c08a3)
46
50
 
47
51
  ### Doesn't consume characters like _ and * as style when they are in `blocks like this` because `_they_can_be_varaiables_`
@@ -109,6 +113,31 @@ Width = 120
109
113
  Timeout = 1.0
110
114
  ```
111
115
 
116
+ ## Invocation
117
+ The most exciting feature here is `--exec` with it you can do full readline support like this:
118
+
119
+ $ sd --exec "llm chat"
120
+
121
+ And now you have all your readline stuff. It's pretty great.
122
+
123
+ ```shell
124
+ Streamdown - A markdown renderer for modern terminals
125
+
126
+ positional arguments:
127
+ filenameList Input file to process (also takes stdin)
128
+
129
+ options:
130
+ -h, --help show this help message and exit
131
+ -l LOGLEVEL, --loglevel LOGLEVEL
132
+ Set the logging level
133
+ -c COLOR, --color COLOR
134
+ Set the hsv base: h,s,v
135
+ -w WIDTH, --width WIDTH
136
+ Set the width
137
+ -e EXEC, --exec EXEC Wrap a program for more 'proper' i/o handling
138
+
139
+ ```
140
+
112
141
  ## Demo
113
142
  Do this
114
143
 
@@ -131,5 +160,3 @@ I'm really considering using `tinycss2` and making an actual stylesheet engine.
131
160
  #### scrape
132
161
  This is already partially implemented. The idea is every code block can get extracted and put in a directory so you can have a conversation to generate every piece of a project, similar to Aider, Claude or Goose, but in the most hands-off yet still convenient way possible.
133
162
 
134
- #### exec
135
- I'm trying to get a readline capable wrapper so that interaction is as transparent as possible. After many days of research I've given up on trying to hack it through tty/pty hijacking and pipes and decided it has to be a standard wrapper. This should be low effort.
@@ -13,7 +13,11 @@ This will work with [simonw's llm](https://github.com/simonw/llm) unlike with [r
13
13
  ### Provides clean copyable code for long code blocks and short terminals.
14
14
  ![copyable](https://github.com/user-attachments/assets/4a3539c5-b5d1-4d6a-8bce-032724d8909d)
15
15
 
16
- ### Does OSC 8 links for modern terminals.
16
+ ### Supports images, why not?
17
+ Here's kitty and alacritty. Try to do that in glow...
18
+ ![doggie](https://github.com/user-attachments/assets/9a392929-b6c2-4204-b257-e09305acb7af)
19
+
20
+ ### Does OSC 8 links for modern terminals (and optionally OSC 52 for clipboard)
17
21
  [links.webm](https://github.com/user-attachments/assets/a5f71791-7c58-4183-ad3b-309f470c08a3)
18
22
 
19
23
  ### Doesn't consume characters like _ and * as style when they are in `blocks like this` because `_they_can_be_varaiables_`
@@ -81,6 +85,31 @@ Width = 120
81
85
  Timeout = 1.0
82
86
  ```
83
87
 
88
+ ## Invocation
89
+ The most exciting feature here is `--exec` with it you can do full readline support like this:
90
+
91
+ $ sd --exec "llm chat"
92
+
93
+ And now you have all your readline stuff. It's pretty great.
94
+
95
+ ```shell
96
+ Streamdown - A markdown renderer for modern terminals
97
+
98
+ positional arguments:
99
+ filenameList Input file to process (also takes stdin)
100
+
101
+ options:
102
+ -h, --help show this help message and exit
103
+ -l LOGLEVEL, --loglevel LOGLEVEL
104
+ Set the logging level
105
+ -c COLOR, --color COLOR
106
+ Set the hsv base: h,s,v
107
+ -w WIDTH, --width WIDTH
108
+ Set the width
109
+ -e EXEC, --exec EXEC Wrap a program for more 'proper' i/o handling
110
+
111
+ ```
112
+
84
113
  ## Demo
85
114
  Do this
86
115
 
@@ -103,5 +132,3 @@ I'm really considering using `tinycss2` and making an actual stylesheet engine.
103
132
  #### scrape
104
133
  This is already partially implemented. The idea is every code block can get extracted and put in a directory so you can have a conversation to generate every piece of a project, similar to Aider, Claude or Goose, but in the most hands-off yet still convenient way possible.
105
134
 
106
- #### exec
107
- I'm trying to get a readline capable wrapper so that interaction is as transparent as possible. After many days of research I've given up on trying to hack it through tty/pty hijacking and pipes and decided it has to be a standard wrapper. This should be low effort.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "streamdown"
7
- version = "0.12.0"
7
+ version = "0.14.0"
8
8
  description = "A streaming markdown renderer for modern terminals with syntax highlighting"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1,22 @@
1
+ def fizzbuzz(n):
2
+ for i in range(1, n + 1):
3
+ if i % 3 == 0 and i % 5 == 0:
4
+ print("FizzBuzz")
5
+ elif i % 3 == 0:
6
+ print("Fizz")
7
+ elif i % 5 == 0:
8
+ print("Buzz")
9
+ else:
10
+ print(i)
11
+
12
+ # Example usage: Print FizzBuzz up to 100 Example usage: Print FizzBuzz up to 100 Example usage: Print FizzBuzz up to 100 Example usage: Print FizzBuzz up to 100
13
+ fizzbuzz(100)
14
+
15
+ # Example usage: different range:
16
+ fizzbuzz(20)
17
+
18
+ #Example usage: one line output (list comprehension)
19
+ def fizzbuzz_oneline(n):
20
+ print(["FizzBuzz" if i%3==0 and i%5==0 else "Fizz" if i%3==0 else "Buzz" if i%5==0 else i for i in range(1,n+1)])
21
+
22
+ fizzbuzz_oneline(30)
@@ -0,0 +1,27 @@
1
+ function fizzBuzz(n) {
2
+ for (let i = 1; i <= n; i++) {
3
+ if (i % 3 === 0 && i % 5 === 0) {
4
+ console.log("FizzBuzz");
5
+ } else if (i % 3 === 0) {
6
+ console.log("Fizz");
7
+ } else if (i % 5 === 0) {
8
+ console.log("Buzz");
9
+ } else {
10
+ console.log(i);
11
+ }
12
+ }
13
+ }
14
+
15
+ // Example usage:
16
+ fizzBuzz(100);
17
+
18
+ // Example usage: different range
19
+ fizzBuzz(25);
20
+
21
+ // Example one-line output. (arrow function & ternary operator)
22
+ const fizzBuzzOneLine = n => {
23
+ for (let i = 1; i <= n; i++) {
24
+ console.log((i % 3 === 0 ? (i % 5 === 0 ? "FizzBuzz" : "Fizz") : (i % 5 === 0 ? "Buzz" : i)));
25
+ }
26
+ };
27
+ fizzBuzzOneLine(30);
@@ -0,0 +1,23 @@
1
+ #include <iostream>
2
+
3
+ void fizzBuzz(int n) {
4
+ for (int i = 1; i <= n; i++) {
5
+ if (i % 3 == 0 && i % 5 == 0) {
6
+ std::cout << "FizzBuzz" << std::endl;
7
+ } else if (i % 3 == 0) {
8
+ std::cout << "Fizz" << std::endl;
9
+ } else if (i % 5 == 0) {
10
+ std::cout << "Buzz" << std::endl;
11
+ } else {
12
+ std::cout << i << std::endl;
13
+ }
14
+ }
15
+ }
16
+
17
+ int main() {
18
+ fizzBuzz(100);
19
+
20
+ // Example usage: different range
21
+ fizzBuzz(35);
22
+ return 0;
23
+ }
@@ -5,6 +5,7 @@
5
5
  # "pygments",
6
6
  # "pylatexenc",
7
7
  # "appdirs",
8
+ # "term-image",
8
9
  # "toml"
9
10
  # ]
10
11
  # ///
@@ -12,6 +13,7 @@ import appdirs, toml
12
13
  import logging, tempfile
13
14
  import os, sys
14
15
  import pty, select
16
+ import termios, tty
15
17
 
16
18
  import math
17
19
  import re
@@ -20,7 +22,9 @@ import subprocess
20
22
  import traceback
21
23
  import colorsys
22
24
  import base64
25
+ import importlib
23
26
  from io import BytesIO
27
+ from term_image.image import from_file, from_url
24
28
  import pygments.util
25
29
  from argparse import ArgumentParser
26
30
  from pygments import highlight
@@ -28,7 +32,10 @@ from pygments.lexers import get_lexer_by_name
28
32
  from pygments.formatters import Terminal256Formatter
29
33
  from pygments.styles import get_style_by_name
30
34
 
31
- from .plugins import latex
35
+ if __package__ is None:
36
+ from plugins import latex
37
+ else:
38
+ from .plugins import latex
32
39
 
33
40
  default_toml = """
34
41
  [features]
@@ -47,7 +54,7 @@ Dark = { H = 1.00, S = 1.50, V = 0.25 }
47
54
  Mid = { H = 1.00, S = 1.00, V = 0.50 }
48
55
  Symbol = { H = 1.00, S = 1.00, V = 1.50 }
49
56
  Head = { H = 1.00, S = 2.00, V = 1.50 }
50
- Grey = { H = 1.00, S = 0.12, V = 1.25 }
57
+ Grey = { H = 1.00, S = 0.25, V = 1.37 }
51
58
  Bright = { H = 1.00, S = 2.00, V = 2.00 }
52
59
  Syntax = "monokai"
53
60
  """
@@ -75,9 +82,11 @@ BGRESET = "\033[49m"
75
82
  BOLD = ["\033[1m", "\033[22m"]
76
83
  UNDERLINE = ["\033[4m", "\033[24m"]
77
84
  ITALIC = ["\033[3m", "\033[23m"]
85
+ STRIKEOUT = ["\033[9m", "\033[29m"]
86
+ SUPER = [ 0x2070, 0x00B9, 0x00B2, 0x00B3, 0x2074, 0x2075, 0x2076, 0x2077, 0x2078, 0x2079 ]
78
87
 
79
88
  ESCAPE = r"\033\[[0-9;]*[mK]"
80
- ANSIESCAPE = r"\033(\[[0-9;]*[mK]|][0-9]*;;.*?\\|\\)"
89
+ ANSIESCAPE = r'\033(?:\[[0-9;?]*[a-zA-Z]|][0-9]*;;.*?\\|\\)'
81
90
  KEYCODE_RE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
82
91
 
83
92
  visible = lambda x: re.sub(ANSIESCAPE, "", x)
@@ -114,16 +123,22 @@ class ParseState:
114
123
  self.first_line = True
115
124
  self.last_line_empty = False
116
125
  self.is_pty = False
126
+ self.is_exec = False
117
127
  self.maybe_prompt = False
118
128
  self.emit_flag = None
119
129
  self.scrape = None
120
130
  self.scrape_ix = 0
131
+ self.terminal = None
121
132
 
122
133
  self.CodeSpaces = _features.get("CodeSpaces")
123
134
  self.Clipboard = _features.get("Clipboard")
124
135
  self.Logging = _features.get("Logging")
125
136
  self.Timeout = _features.get("Timeout")
126
137
 
138
+ self.WidthArg = None
139
+ self.WidthFull = None
140
+ self.WidthWrap = False
141
+
127
142
  # If the entire block is indented this will
128
143
  # tell us what that is
129
144
  self.first_indent = None
@@ -150,18 +165,27 @@ class ParseState:
150
165
  self.in_italic = False
151
166
  self.in_table = False # (Code.[Header|Body] | False)
152
167
  self.in_underline = False
153
- self.in_blockquote = False
168
+ self.in_strikeout = False
169
+ self.block_depth = 0
170
+
171
+ self.exec_sub = None
172
+ self.exec_master = None
173
+ self.exec_slave = None
174
+ self.exec_kb = 0
154
175
 
155
176
  self.exit = 0
156
177
  self.where_from = None
157
178
 
158
179
  def current(self):
159
- state = { 'code': self.in_code, 'bold': self.in_bold, 'italic': self.in_italic, 'underline': self.in_underline }
180
+ state = { 'inline': self.inline_code, 'code': self.in_code, 'bold': self.in_bold, 'italic': self.in_italic, 'underline': self.in_underline }
160
181
  state['none'] = all(item is False for item in state.values())
161
182
  return state
162
183
 
184
+ def reset_inline(self):
185
+ self.inline_code = self.in_bold = self.in_italic = self.in_underline = False
186
+
163
187
  def space_left(self):
164
- return (MARGIN_SPACES if len(self.current_line) == 0 else "") + (BQUOTE if self.in_blockquote else "")
188
+ return Style.MarginSpaces + (Style.Blockquote * self.block_depth) if len(self.current_line) == 0 else ""
165
189
 
166
190
  state = ParseState()
167
191
 
@@ -210,7 +234,7 @@ def format_table(rowList):
210
234
  # Correct indentation: This should be outside the c_idx loop
211
235
  joined_line = f"{BG}{bg_color}{extra}{FG}{Style.Symbol}│{RESET}".join(line_segments)
212
236
  # Correct indentation and add missing characters
213
- yield f"{MARGIN_SPACES}{joined_line}{RESET}"
237
+ yield f"{Style.MarginSpaces}{joined_line}{RESET}"
214
238
 
215
239
  state.bg = BGRESET
216
240
 
@@ -218,21 +242,24 @@ def emit_h(level, text):
218
242
  text = line_format(text)
219
243
  spaces_to_center = ((state.Width - visible_length(text)) / 2)
220
244
  if level == 1: #
221
- return f"\n{MARGIN_SPACES}{BOLD[0]}{' ' * math.floor(spaces_to_center)}{text}{' ' * math.ceil(spaces_to_center)}{BOLD[1]}\n"
245
+ return f"\n{state.space_left()}{BOLD[0]}{' ' * math.floor(spaces_to_center)}{text}{' ' * math.ceil(spaces_to_center)}{BOLD[1]}\n"
222
246
  elif level == 2: ##
223
- return f"\n{MARGIN_SPACES}{BOLD[0]}{FG}{Style.Bright}{' ' * math.floor(spaces_to_center)}{text}{' ' * math.ceil(spaces_to_center)}{RESET}\n\n"
247
+ return f"\n{state.space_left()}{BOLD[0]}{FG}{Style.Bright}{' ' * math.floor(spaces_to_center)}{text}{' ' * math.ceil(spaces_to_center)}{RESET}\n\n"
224
248
  elif level == 3: ###
225
- return f"{MARGIN_SPACES}{FG}{Style.Head}{BOLD[0]}{text}{RESET}"
249
+ return f"{state.space_left()}{FG}{Style.Head}{BOLD[0]}{text}{RESET}"
226
250
  elif level == 4: ####
227
- return f"{MARGIN_SPACES}{FG}{Style.Symbol}{text}{RESET}"
251
+ return f"{state.space_left()}{FG}{Style.Symbol}{text}{RESET}"
228
252
  else: # level 5 or 6
229
- return f"{MARGIN_SPACES}{text}{RESET}"
253
+ return f"{state.space_left()}{text}{RESET}"
230
254
 
231
255
  def code_wrap(text_in):
256
+ if state.WidthWrap and len(text_in) > state.WidthFull:
257
+ return (0, [text_in])
258
+
232
259
  # get the indentation of the first line
233
260
  indent = len(text_in) - len(text_in.lstrip())
234
261
  text = text_in.lstrip()
235
- mywidth = state.FullWidth - indent
262
+ mywidth = state.WidthFull - indent
236
263
 
237
264
  # We take special care to preserve empty lines
238
265
  if len(text) == 0:
@@ -322,8 +349,20 @@ def text_wrap(text, width = -1, indent = 0, first_line_prefix="", subsequent_lin
322
349
  return lines
323
350
 
324
351
  def line_format(line):
325
- def not_text(token):
326
- return not token or len(token.rstrip()) != len(token)
352
+ not_text = lambda token: not token or len(token.rstrip()) != len(token)
353
+ footnotes = lambda match: ''.join([chr(SUPER[int(i)]) for i in match.group(1)])
354
+
355
+ def process_images(match):
356
+ url = match.group(2)
357
+ try:
358
+ if re.match(r"https://", url.lower()):
359
+ image = from_url(url)
360
+ else:
361
+ image = from_file(url)
362
+ image.height = 20
363
+ print(f"{image:|.-1#}")
364
+ except:
365
+ return match.group(2)
327
366
 
328
367
  # Apply OSC 8 hyperlink formatting after other formatting
329
368
  def process_links(match):
@@ -331,8 +370,11 @@ def line_format(line):
331
370
  url = match.group(2)
332
371
  return f'\033]8;;{url}\033\\{Style.Link}{description}{UNDERLINE[1]}\033]8;;\033\\{FGRESET}'
333
372
 
373
+ line = re.sub(r"\!\[([^\]]*)\]\(([^\)]+)\)", process_images, line)
334
374
  line = re.sub(r"\[([^\]]+)\]\(([^\)]+)\)", process_links, line)
335
- tokenList = re.finditer(r"((\*\*|\*|_|`)|[^_*`]+)", line)
375
+ line = re.sub(r"\[\^(\d+)\]:?", footnotes, line)
376
+
377
+ tokenList = re.finditer(r"((~~|\*\*_|_\*\*|\*{1,3}|_{1,3}|`+)|[^~_*`]+)", line)
336
378
  result = ""
337
379
 
338
380
  for match in tokenList:
@@ -340,8 +382,13 @@ def line_format(line):
340
382
  next_token = line[match.end()] if match.end() < len(line) else ""
341
383
  prev_token = line[match.start()-1] if match.start() > 0 else ""
342
384
 
343
- if token == "`":
344
- state.inline_code = not state.inline_code
385
+ # This trick makes sure that things like `` ` `` render right.
386
+ if "`" in token and (not state.inline_code or state.inline_code == token):
387
+ if state.inline_code:
388
+ state.inline_code = False
389
+ else:
390
+ state.inline_code = token
391
+
345
392
  if state.inline_code:
346
393
  result += f'{BG}{Style.Mid}'
347
394
  else:
@@ -352,7 +399,17 @@ def line_format(line):
352
399
  elif state.inline_code:
353
400
  result += token
354
401
 
355
- elif token == "**" and (state.in_bold or not_text(prev_token)):
402
+ elif token == '~~' and (state.in_strikeout or not_text(prev_token)):
403
+ state.in_strikeout = not state.in_strikeout
404
+ result += STRIKEOUT[0] if state.in_strikeout else STRIKEOUT[1]
405
+
406
+ elif token in ['**_','_**','___','***'] and (state.in_bold or not_text(prev_token)):
407
+ state.in_bold = not state.in_bold
408
+ result += BOLD[0] if state.in_bold else BOLD[1]
409
+ state.in_italic = not state.in_italic
410
+ result += ITALIC[0] if state.in_italic else ITALIC[1]
411
+
412
+ elif (token == '__' or token == "**") and (state.in_bold or not_text(prev_token)):
356
413
  state.in_bold = not state.in_bold
357
414
  result += BOLD[0] if state.in_bold else BOLD[1]
358
415
 
@@ -365,7 +422,7 @@ def line_format(line):
365
422
  else:
366
423
  result += token
367
424
 
368
- elif token == "_" and (state.in_underline or not_text(prev_token)):
425
+ elif token == "_" and (state.in_underline or (not_text(prev_token) and next_token.isalnum())):
369
426
  state.in_underline = not state.in_underline
370
427
  result += UNDERLINE[0] if state.in_underline else UNDERLINE[1]
371
428
  else:
@@ -378,11 +435,37 @@ def parse(stream):
378
435
  byte = None
379
436
  TimeoutIx = 0
380
437
  while True:
381
- if state.is_pty:
438
+ if state.is_pty or state.is_exec:
382
439
  byte = None
383
- ready, _, _ = select.select([stream.fileno()], [], [], state.Timeout)
440
+ ready_in, _, _ = select.select(
441
+ [stream.fileno(), state.exec_master], [], [], state.Timeout)
442
+
443
+ if state.is_exec:
444
+ # This is keyboard input
445
+ if stream.fileno() in ready_in:
446
+ byte = os.read(stream.fileno(), 1)
447
+
448
+ state.exec_kb += 1
449
+ os.write(state.exec_master, byte)
450
+
451
+ if byte == b'\n':
452
+ state.buffer = b''
453
+ print("")
454
+ state.exec_kb = 0
455
+ else:
456
+ continue
457
+
458
+ if state.exec_master in ready_in:
459
+ TimeoutIx = 0
460
+ byte = os.read(state.exec_master, 1)
384
461
 
385
- if stream.fileno() in ready:
462
+ if state.exec_kb:
463
+ os.write(sys.stdout.fileno(), byte)
464
+
465
+ if len(ready_in) == 0:
466
+ TimeoutIx += 1
467
+
468
+ elif stream.fileno() in ready_in:
386
469
  byte = os.read(stream.fileno(), 1)
387
470
  TimeoutIx = 0
388
471
  elif TimeoutIx == 0:
@@ -402,14 +485,15 @@ def parse(stream):
402
485
 
403
486
  line = state.buffer.decode('utf-8')
404
487
  state.has_newline = line.endswith('\n')
405
- state.maybe_prompt = not state.has_newline and state.current()['none'] and re.match(r'^.*>\s+$', line)
488
+ # I hate this. There should be better ways.
489
+ state.maybe_prompt = not state.has_newline and state.current()['none'] and re.match(r'^.*>\s+$', visible(line))
406
490
 
407
491
  # let's wait for a newline
408
492
  if state.maybe_prompt:
409
493
  state.emit_flag = Code.Flush
410
494
  yield line
495
+ state.current_line = ''
411
496
  state.buffer = b''
412
- continue
413
497
 
414
498
  if not state.has_newline:
415
499
  continue
@@ -418,13 +502,30 @@ def parse(stream):
418
502
  # Run through the plugins first
419
503
  res = latex.Plugin(line, state, Style)
420
504
  if res is True:
421
- # This means everything was consumed by our plugin and
422
- # we should continue
423
- continue
424
- elif res is not None:
425
- for row in res:
426
- yield row
505
+ # This means everything was consumed by our plugin and
506
+ # we should continue
427
507
  continue
508
+ elif res is not None:
509
+ for row in res:
510
+ yield row
511
+ continue
512
+
513
+ # running this here avoids stray |
514
+ block_match = re.match(r"^\s*((>\s*)+|<.?think>)", line)
515
+ if not state.in_code and block_match:
516
+ if block_match.group(1) == '</think>':
517
+ state.block_depth = 0
518
+ yield RESET
519
+ elif block_match.group(1) == '<think>':
520
+ state.block_depth = 1
521
+ else:
522
+ state.block_depth = block_match.group(0).count('>')
523
+ # we also need to consume those tokens
524
+ line = line[len(block_match.group(0)):]
525
+ else:
526
+ if state.block_depth > 0:
527
+ line = FGRESET + line
528
+ state.block_depth = 0
428
529
 
429
530
  # --- Collapse Multiple Empty Lines if not in code blocks ---
430
531
  if not state.in_code:
@@ -439,7 +540,7 @@ def parse(stream):
439
540
  else:
440
541
  last_line_empty_cache = state.last_line_empty
441
542
  state.last_line_empty = False
442
-
543
+
443
544
  # This is to reset our top-level line-based systems
444
545
  # \n buffer
445
546
  if not state.in_list and len(state.ordered_list_numbers) > 0:
@@ -447,7 +548,7 @@ def parse(stream):
447
548
  else:
448
549
  state.in_list = False
449
550
 
450
- if state.first_indent == None:
551
+ if state.first_indent is None:
451
552
  state.first_indent = len(line) - len(line.lstrip())
452
553
  if len(line) - len(line.lstrip()) >= state.first_indent:
453
554
  line = line[state.first_indent:]
@@ -462,20 +563,11 @@ def parse(stream):
462
563
  if state.in_table and not state.in_code and not re.match(r"^\s*\|.+\|\s*$", line):
463
564
  state.in_table = False
464
565
 
465
- block_match = re.match(r"^<.?think>$", line)
466
- if block_match:
467
- state.in_blockquote = not state.in_blockquote
468
- # consume and don't emit
469
- if not state.in_blockquote:
470
- yield( RESET)
471
- continue
472
-
473
566
  #
474
567
  # <code><pre>
475
568
  #
476
- # This needs to be first
477
569
  if not state.in_code:
478
- code_match = re.match(r"\s*```\s*([^\s]+|$)", line)
570
+ code_match = re.match(r"\s*```\s*([^\s]+|$)$", line)
479
571
  if code_match:
480
572
  state.in_code = Code.Backtick
481
573
  state.code_language = code_match.group(1) or 'Bash'
@@ -511,6 +603,7 @@ def parse(stream):
511
603
  try:
512
604
  ext = get_lexer_by_name(state.code_language).filenames[0].split('.')[-1]
513
605
  except:
606
+ logging.warning(f"Can't find canonical extension for {state.code_language}")
514
607
  pass
515
608
 
516
609
  open(os.path.join(state.scrape, f"file_{state.scrape_ix}.{ext}"), 'w').write(state.code_buffer)
@@ -532,6 +625,7 @@ def parse(stream):
532
625
 
533
626
 
534
627
  if code_type == Code.Backtick:
628
+ state.code_indent = len(line) - len(line.lstrip())
535
629
  continue
536
630
  else:
537
631
  # otherwise we don't want to consume
@@ -548,11 +642,6 @@ def parse(stream):
548
642
  custom_style = get_style_by_name("default")
549
643
 
550
644
  formatter = Terminal256Formatter(style=custom_style)
551
- for i, char in enumerate(line):
552
- if char == " ":
553
- state.code_indent += 1
554
- else:
555
- break
556
645
  line = line[state.code_indent :]
557
646
 
558
647
  elif line.startswith(" " * state.code_indent):
@@ -600,10 +689,10 @@ def parse(stream):
600
689
 
601
690
  code_line = ' ' * indent + this_batch.strip()
602
691
 
603
- margin = state.FullWidth - visible_length(code_line)
692
+ margin = state.WidthFull - visible_length(code_line) % state.WidthFull
604
693
  yield f"{Style.Codebg}{code_line}{' ' * max(0, margin)}{BGRESET}"
605
694
  continue
606
- except Goto as ex:
695
+ except Goto:
607
696
  pass
608
697
 
609
698
  except Exception as ex:
@@ -662,18 +751,18 @@ def parse(stream):
662
751
  if list_type == "number":
663
752
  state.ordered_list_numbers[-1] += 1
664
753
 
665
- indent = len(state.list_item_stack) * 2
754
+ indent = (len(state.list_item_stack) - 1) * 2
666
755
 
667
756
  wrap_width = state.Width - indent - (2 * Style.ListIndent)
668
757
 
669
758
  bullet = '•'
670
759
  if list_type == "number":
671
760
  list_number = int(max(state.ordered_list_numbers[-1], float(list_item_match.group(2))))
672
- bullet = f"{list_number}"
761
+ bullet = str(list_number)
673
762
 
674
763
  wrapped_lineList = text_wrap(content, wrap_width, Style.ListIndent,
675
- first_line_prefix = f"{(' ' * (indent - len(bullet)))}{FG}{Style.Symbol}{bullet}{RESET} ",
676
- subsequent_line_prefix = " " * (indent - 1)
764
+ first_line_prefix = f"{(' ' * (indent ))}{FG}{Style.Symbol}{bullet}{RESET} ",
765
+ subsequent_line_prefix = " " * (indent)
677
766
  )
678
767
  for wrapped_line in wrapped_lineList:
679
768
  yield f"{state.space_left()}{wrapped_line}\n"
@@ -690,11 +779,11 @@ def parse(stream):
690
779
  #
691
780
  # <hr>
692
781
  #
693
- hr_match = re.match(r"^[\s]*([-=_]){3,}[\s]*$", line)
782
+ hr_match = re.match(r"^[\s]*([-\*=_]){3,}[\s]*$", line)
694
783
  if hr_match:
695
784
  if state.last_line_empty or last_line_empty_cache:
696
785
  # print a horizontal rule using a unicode midline
697
- yield f"{MARGIN_SPACES}{FG}{Style.Symbol}{'─' * state.Width}{RESET}"
786
+ yield f"{Style.MarginSpaces}{FG}{Style.Symbol}{'─' * state.Width}{RESET}"
698
787
  else:
699
788
  # We tell the next level up that the beginning of the buffer should be a flag.
700
789
  # Underneath this condition it will no longer yield
@@ -712,16 +801,11 @@ def parse(stream):
712
801
  for wrapped_line in wrapped_lines:
713
802
  yield f"{state.space_left()}{wrapped_line}\n"
714
803
 
715
- def get_terminal_width():
716
- try:
717
- return shutil.get_terminal_size().columns
718
- except (AttributeError, OSError):
719
- return 80
720
-
721
804
  def emit(inp):
722
805
  buffer = []
723
806
  flush = False
724
807
  for chunk in parse(inp):
808
+ width_calc()
725
809
  if state.emit_flag:
726
810
  if state.emit_flag == Code.Flush:
727
811
  flush = True
@@ -742,6 +826,9 @@ def emit(inp):
742
826
  state.current_line += chunk
743
827
 
744
828
  buffer.append(chunk)
829
+ # This *might* be dangerous
830
+ state.reset_inline()
831
+
745
832
  if flush:
746
833
  chunk = "\n".join(buffer)
747
834
  buffer = []
@@ -753,32 +840,47 @@ def emit(inp):
753
840
  else:
754
841
  chunk = buffer.pop(0)
755
842
 
756
- if state.is_pty:
757
- print(chunk, end="", flush=True)
758
- else:
759
- sys.stdout.write(chunk)
843
+ print(chunk, end="", flush=True)
760
844
 
761
845
  if len(buffer):
762
- chunk = buffer.pop(0)
763
- if state.is_pty:
764
- print(chunk, end="", flush=True)
765
- else:
766
- sys.stdout.write(chunk)
846
+ print(buffer.pop(0), end="", flush=True)
767
847
 
768
848
  def apply_multipliers(name, H, S, V):
769
849
  m = _style.get(name)
770
850
  r, g, b = colorsys.hsv_to_rgb(min(1.0, H * m["H"]), min(1.0, S * m["S"]), min(1.0, V * m["V"]))
771
851
  return ';'.join([str(int(x * 256)) for x in [r, g, b]]) + "m"
772
852
 
853
+ def width_calc():
854
+ if not state.WidthFull or not state.WidthArg:
855
+ if state.WidthArg:
856
+ state.WidthFull = state.WidthArg
857
+ else:
858
+ width = 80
859
+
860
+ try:
861
+ width = shutil.get_terminal_size().columns
862
+ state.WidthWrap = True
863
+ except (AttributeError, OSError):
864
+ pass
865
+
866
+ state.WidthFull = width
867
+
868
+ state.Width = state.WidthFull - 2 * Style.Margin
869
+ Style.Codepad = [
870
+ f"{RESET}{FG}{Style.Dark}{'▄' * state.WidthFull}{RESET}\n",
871
+ f"{RESET}{FG}{Style.Dark}{'▀' * state.WidthFull}{RESET}"
872
+ ]
873
+
773
874
  def main():
774
- global H, S, V, MARGIN_SPACES
875
+ global H, S, V
876
+
775
877
  parser = ArgumentParser(description="Streamdown - A markdown renderer for modern terminals")
776
878
  parser.add_argument("filenameList", nargs="*", help="Input file to process (also takes stdin)")
777
879
  parser.add_argument("-l", "--loglevel", default="INFO", help="Set the logging level")
778
880
  parser.add_argument("-c", "--color", default=None, help="Set the hsv base: h,s,v")
779
- parser.add_argument("-w", "--width", default="0", help="Set the width")
780
- parser.add_argument("-e", "--exec", help="Wrap a program for more 'proper' i/o handling")
781
- parser.add_argument("-s", "--scrape", help="Scrape code snippets to a directory")
881
+ parser.add_argument("-w", "--width", default="0", help="Set the width WIDTH")
882
+ parser.add_argument("-e", "--exec", help="Wrap a program EXEC for more 'proper' i/o handling")
883
+ parser.add_argument("-s", "--scrape", help="Scrape code snippets to a directory SCRAPE")
782
884
  args = parser.parse_args()
783
885
 
784
886
  if args.color:
@@ -796,24 +898,26 @@ def main():
796
898
  os.makedirs(args.scrape, exist_ok=True)
797
899
  state.scrape = args.scrape
798
900
 
799
- MARGIN_SPACES = " " * Style.Margin
800
- state.FullWidth = int(args.width) or _style.get("Width") or int(get_terminal_width())
801
- state.Width = state.FullWidth - 2 * Style.Margin
901
+ Style.MarginSpaces = " " * Style.Margin
902
+ state.WidthArg = int(args.width) or _style.get("Width") or 0
903
+ width_calc()
904
+
802
905
  Style.Codebg = f"{BG}{Style.Dark}"
803
906
  Style.Link = f"{FG}{Style.Symbol}{UNDERLINE[0]}"
804
- Style.Blockquote = f"{FG}{Style.Grey} \u258E "
805
-
806
- Style.Codepad = [
807
- f"{RESET}{FG}{Style.Dark}{'▄' * state.FullWidth}{RESET}\n",
808
- f"{RESET}{FG}{Style.Dark}{'▀' * state.FullWidth}{RESET}"
809
- ]
907
+ Style.Blockquote = f"{FG}{Style.Grey} "
810
908
 
811
909
  logging.basicConfig(stream=sys.stdout, level=args.loglevel.upper(), format=f'%(message)s')
910
+ state.exec_master, state.exec_slave = pty.openpty()
812
911
  try:
813
912
  inp = sys.stdin
814
913
  if args.exec:
815
- state.sub = subprocess.Popen(args.exec.split(' '), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
816
- inp = state.sub.stdout
914
+ state.terminal = termios.tcgetattr(sys.stdin)
915
+ state.is_exec = True
916
+ state.exec_sub = subprocess.Popen(args.exec.split(' '), stdin=state.exec_slave, stdout=state.exec_slave, stderr=state.exec_slave, close_fds=True)
917
+ os.close(state.exec_slave) # We don't need slave in parent
918
+ # Set stdin to raw mode so we don't need to press enter
919
+ tty.setcbreak(sys.stdin.fileno())
920
+ emit(sys.stdin)
817
921
 
818
922
  elif args.filenameList:
819
923
  # Let's say we only care about logging in streams
@@ -832,11 +936,13 @@ def main():
832
936
  os.set_blocking(inp.fileno(), False)
833
937
  emit(inp)
834
938
 
835
- except KeyboardInterrupt:
939
+ except (OSError, KeyboardInterrupt):
836
940
  state.exit = 130
837
941
 
838
942
  except Exception as ex:
839
- logging.warning(f"Exception thrown: {ex}")
943
+ if state.terminal:
944
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, state.terminal)
945
+ logging.warning(f"Exception thrown: {type(ex)} {ex}")
840
946
  traceback.print_exc()
841
947
 
842
948
  if state.Clipboard and state.code_buffer:
@@ -847,6 +953,12 @@ def main():
847
953
  base64_string = base64_bytes.decode('utf-8')
848
954
  print(f"\033]52;c;{base64_string}\a", end="", flush=True)
849
955
 
956
+
957
+ if state.terminal:
958
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, state.terminal)
959
+ os.close(state.exec_master)
960
+ if state.exec_sub:
961
+ state.exec_sub.wait()
850
962
  sys.exit(state.exit)
851
963
 
852
964
  if __name__ == "__main__":
@@ -0,0 +1,10 @@
1
+ So here is some text
2
+
3
+ > and technically we are in blockquote
4
+ > territory with these. Blockquote is one
5
+ > > of the few things that can be embedded
6
+ > > in markdown. Stylistically they are
7
+ > > different than lists, but
8
+ > not by much.
9
+
10
+ Now we are out of the blockquote
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- TIMEOUT=${TIMEOUT:-0.1}
2
+ TIMEOUT=${TIMEOUT:-0.01}
3
3
 
4
4
  while [[ $# -gt 0 ]]; do
5
5
  echo "## File: $1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes