zettcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/__init__.py +6 -0
- zettcode/cli.py +78 -0
- zettcode/config.py +72 -0
- zettcode/runtime.py +121 -0
- zettcode/tui.py +924 -0
- zettcode/tui_framework/__init__.py +29 -0
- zettcode/tui_framework/application.py +91 -0
- zettcode/tui_framework/components.py +821 -0
- zettcode/tui_framework/events.py +37 -0
- zettcode/tui_framework/input.py +163 -0
- zettcode/tui_framework/screen.py +214 -0
- zettcode/tui_framework/terminal.py +118 -0
- zettcode-0.1.0.dist-info/METADATA +109 -0
- zettcode-0.1.0.dist-info/RECORD +17 -0
- zettcode-0.1.0.dist-info/WHEEL +4 -0
- zettcode-0.1.0.dist-info/entry_points.txt +2 -0
- zettcode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Framework input events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EventType(StrEnum):
|
|
10
|
+
KEY = "key"
|
|
11
|
+
TEXT = "text"
|
|
12
|
+
PASTE = "paste"
|
|
13
|
+
MOUSE = "mouse"
|
|
14
|
+
RESIZE = "resize"
|
|
15
|
+
RENDER = "render"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MouseAction(StrEnum):
|
|
19
|
+
DOWN = "down"
|
|
20
|
+
UP = "up"
|
|
21
|
+
MOVE = "move"
|
|
22
|
+
SCROLL_UP = "scroll_up"
|
|
23
|
+
SCROLL_DOWN = "scroll_down"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class InputEvent:
|
|
28
|
+
type: EventType
|
|
29
|
+
key: str = ""
|
|
30
|
+
text: str = ""
|
|
31
|
+
x: int = 0
|
|
32
|
+
y: int = 0
|
|
33
|
+
button: int = 0
|
|
34
|
+
action: MouseAction | None = None
|
|
35
|
+
shift: bool = False
|
|
36
|
+
alt: bool = False
|
|
37
|
+
control: bool = False
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Incremental terminal byte-stream decoder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .events import EventType, InputEvent, MouseAction
|
|
8
|
+
|
|
9
|
+
_MOUSE = re.compile(rb"^\x1b\[<(\d+);(\d+);(\d+)([Mm])")
|
|
10
|
+
_KEYS = {
|
|
11
|
+
b"\x1b[A": "up",
|
|
12
|
+
b"\x1b[B": "down",
|
|
13
|
+
b"\x1b[C": "right",
|
|
14
|
+
b"\x1b[D": "left",
|
|
15
|
+
b"\x1b[H": "home",
|
|
16
|
+
b"\x1b[F": "end",
|
|
17
|
+
b"\x1b[1~": "home",
|
|
18
|
+
b"\x1b[4~": "end",
|
|
19
|
+
b"\x1b[5~": "page_up",
|
|
20
|
+
b"\x1b[6~": "page_down",
|
|
21
|
+
b"\x1b[3~": "delete",
|
|
22
|
+
b"\x1b[2~": "insert",
|
|
23
|
+
b"\x1b[Z": "backtab",
|
|
24
|
+
b"\x1b[1;5D": "ctrl_left",
|
|
25
|
+
b"\x1b[1;5C": "ctrl_right",
|
|
26
|
+
b"\x1b[1;3D": "alt_left",
|
|
27
|
+
b"\x1b[1;3C": "alt_right",
|
|
28
|
+
b"\x1b[1;5H": "ctrl_home",
|
|
29
|
+
b"\x1b[1;5F": "ctrl_end",
|
|
30
|
+
}
|
|
31
|
+
_CONTROL_KEYS = {
|
|
32
|
+
0x01: "ctrl_a",
|
|
33
|
+
0x02: "ctrl_b",
|
|
34
|
+
0x03: "ctrl_c",
|
|
35
|
+
0x04: "ctrl_d",
|
|
36
|
+
0x05: "ctrl_e",
|
|
37
|
+
0x06: "ctrl_f",
|
|
38
|
+
0x07: "ctrl_g",
|
|
39
|
+
0x09: "tab",
|
|
40
|
+
0x0A: "enter",
|
|
41
|
+
0x0B: "ctrl_k",
|
|
42
|
+
0x0C: "ctrl_l",
|
|
43
|
+
0x0D: "enter",
|
|
44
|
+
0x0E: "ctrl_n",
|
|
45
|
+
0x10: "ctrl_p",
|
|
46
|
+
0x12: "ctrl_r",
|
|
47
|
+
0x14: "ctrl_t",
|
|
48
|
+
0x15: "ctrl_u",
|
|
49
|
+
0x17: "ctrl_w",
|
|
50
|
+
0x19: "ctrl_y",
|
|
51
|
+
0x1A: "ctrl_z",
|
|
52
|
+
0x1F: "ctrl_underscore",
|
|
53
|
+
0x7F: "backspace",
|
|
54
|
+
0x08: "backspace",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InputDecoder:
|
|
59
|
+
"""Decode fragmented UTF-8, keyboard, paste, and SGR mouse input."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self.buffer = bytearray()
|
|
63
|
+
|
|
64
|
+
def feed(self, data: bytes) -> list[InputEvent]:
|
|
65
|
+
self.buffer.extend(data)
|
|
66
|
+
events: list[InputEvent] = []
|
|
67
|
+
while self.buffer:
|
|
68
|
+
event, consumed, incomplete = self._next()
|
|
69
|
+
if incomplete:
|
|
70
|
+
break
|
|
71
|
+
if consumed == 0:
|
|
72
|
+
consumed = 1
|
|
73
|
+
del self.buffer[:consumed]
|
|
74
|
+
if event is not None:
|
|
75
|
+
events.append(event)
|
|
76
|
+
return events
|
|
77
|
+
|
|
78
|
+
def flush_escape(self) -> InputEvent | None:
|
|
79
|
+
"""Resolve an isolated ESC after the terminal's sequence timeout."""
|
|
80
|
+
if self.buffer == b"\x1b":
|
|
81
|
+
self.buffer.clear()
|
|
82
|
+
return InputEvent(EventType.KEY, key="escape")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def _next(self) -> tuple[InputEvent | None, int, bool]:
|
|
86
|
+
data = bytes(self.buffer)
|
|
87
|
+
if data.startswith(b"\x1b[200~"):
|
|
88
|
+
end = data.find(b"\x1b[201~", 6)
|
|
89
|
+
if end < 0:
|
|
90
|
+
return None, 0, True
|
|
91
|
+
return InputEvent(EventType.PASTE, text=data[6:end].decode("utf-8", "replace")), end + 6, False
|
|
92
|
+
|
|
93
|
+
mouse = _MOUSE.match(data)
|
|
94
|
+
if mouse:
|
|
95
|
+
code, x, y = (int(mouse.group(index)) for index in (1, 2, 3))
|
|
96
|
+
suffix = mouse.group(4)
|
|
97
|
+
action = _mouse_action(code, suffix)
|
|
98
|
+
return (
|
|
99
|
+
InputEvent(
|
|
100
|
+
EventType.MOUSE,
|
|
101
|
+
x=max(0, x - 1),
|
|
102
|
+
y=max(0, y - 1),
|
|
103
|
+
button=code & 3,
|
|
104
|
+
action=action,
|
|
105
|
+
shift=bool(code & 4),
|
|
106
|
+
alt=bool(code & 8),
|
|
107
|
+
control=bool(code & 16),
|
|
108
|
+
),
|
|
109
|
+
mouse.end(),
|
|
110
|
+
False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
for sequence, key in sorted(_KEYS.items(), key=lambda item: len(item[0]), reverse=True):
|
|
114
|
+
if data.startswith(sequence):
|
|
115
|
+
return InputEvent(EventType.KEY, key=key), len(sequence), False
|
|
116
|
+
if sequence.startswith(data):
|
|
117
|
+
return None, 0, True
|
|
118
|
+
|
|
119
|
+
first = data[0]
|
|
120
|
+
if first in _CONTROL_KEYS:
|
|
121
|
+
return InputEvent(EventType.KEY, key=_CONTROL_KEYS[first]), 1, False
|
|
122
|
+
if first == 0x1B:
|
|
123
|
+
if len(data) == 1:
|
|
124
|
+
return None, 0, True
|
|
125
|
+
if data[1] in (0x0A, 0x0D):
|
|
126
|
+
return InputEvent(EventType.KEY, key="alt_enter"), 2, False
|
|
127
|
+
if data[1] in (0x08, 0x7F):
|
|
128
|
+
return InputEvent(EventType.KEY, key="alt_backspace"), 2, False
|
|
129
|
+
if 0x20 <= data[1] < 0x7F:
|
|
130
|
+
return InputEvent(EventType.KEY, key=chr(data[1]), alt=True), 2, False
|
|
131
|
+
return InputEvent(EventType.KEY, key="escape"), 1, False
|
|
132
|
+
if first < 0x20:
|
|
133
|
+
return None, 1, False
|
|
134
|
+
|
|
135
|
+
length = _utf8_length(first)
|
|
136
|
+
if len(data) < length:
|
|
137
|
+
return None, 0, True
|
|
138
|
+
try:
|
|
139
|
+
text = data[:length].decode("utf-8")
|
|
140
|
+
except UnicodeDecodeError:
|
|
141
|
+
text = "�"
|
|
142
|
+
length = 1
|
|
143
|
+
return InputEvent(EventType.TEXT, text=text), length, False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _utf8_length(first: int) -> int:
|
|
147
|
+
if first < 0x80:
|
|
148
|
+
return 1
|
|
149
|
+
if first & 0xE0 == 0xC0:
|
|
150
|
+
return 2
|
|
151
|
+
if first & 0xF0 == 0xE0:
|
|
152
|
+
return 3
|
|
153
|
+
if first & 0xF8 == 0xF0:
|
|
154
|
+
return 4
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _mouse_action(code: int, suffix: bytes) -> MouseAction:
|
|
159
|
+
if code & 64:
|
|
160
|
+
return MouseAction.SCROLL_DOWN if code & 1 else MouseAction.SCROLL_UP
|
|
161
|
+
if code & 32:
|
|
162
|
+
return MouseAction.MOVE
|
|
163
|
+
return MouseAction.UP if suffix == b"m" else MouseAction.DOWN
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Cell canvas and ANSI differential renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from wcwidth import wcwidth
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Style:
|
|
14
|
+
"""Terminal style independent from ANSI encoding."""
|
|
15
|
+
|
|
16
|
+
foreground: str | None = None
|
|
17
|
+
background: str | None = None
|
|
18
|
+
bold: bool = False
|
|
19
|
+
italic: bool = False
|
|
20
|
+
dim: bool = False
|
|
21
|
+
reverse: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_STYLE = Style()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Span:
|
|
29
|
+
"""One styled text fragment."""
|
|
30
|
+
|
|
31
|
+
text: str
|
|
32
|
+
style: Style = DEFAULT_STYLE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class TextLine:
|
|
37
|
+
"""One logical line with optional component-owned metadata."""
|
|
38
|
+
|
|
39
|
+
spans: tuple[Span, ...] = ()
|
|
40
|
+
metadata: object | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def text(self) -> str:
|
|
44
|
+
return "".join(span.text for span in self.spans)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Cell:
|
|
49
|
+
"""One terminal column; wide glyph tails use continuation=True."""
|
|
50
|
+
|
|
51
|
+
character: str = " "
|
|
52
|
+
style: Style = DEFAULT_STYLE
|
|
53
|
+
continuation: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Canvas:
|
|
58
|
+
"""Clipped two-dimensional cell buffer."""
|
|
59
|
+
|
|
60
|
+
width: int
|
|
61
|
+
height: int
|
|
62
|
+
cells: list[list[Cell]] = field(init=False)
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
self.cells = [[Cell() for _ in range(self.width)] for _ in range(self.height)]
|
|
66
|
+
|
|
67
|
+
def set_cell(self, x: int, y: int, character: str, style: Style = DEFAULT_STYLE) -> int:
|
|
68
|
+
"""Draw one printable glyph and return its display width."""
|
|
69
|
+
character_width = wcwidth(character)
|
|
70
|
+
# Never let model or tool output inject terminal control sequences into
|
|
71
|
+
# the renderer. Combining marks are the only zero-width input retained.
|
|
72
|
+
if character_width < 0:
|
|
73
|
+
character = "�"
|
|
74
|
+
character_width = 1
|
|
75
|
+
if not (0 <= y < self.height) or x >= self.width:
|
|
76
|
+
return character_width
|
|
77
|
+
width = character_width
|
|
78
|
+
if width == 0:
|
|
79
|
+
if x > 0:
|
|
80
|
+
previous = self.cells[y][x - 1]
|
|
81
|
+
self.cells[y][x - 1] = Cell(previous.character + character, previous.style, previous.continuation)
|
|
82
|
+
return 0
|
|
83
|
+
if x < 0 or x + width > self.width:
|
|
84
|
+
return width
|
|
85
|
+
self.cells[y][x] = Cell(character, style)
|
|
86
|
+
for offset in range(1, width):
|
|
87
|
+
self.cells[y][x + offset] = Cell("", style, continuation=True)
|
|
88
|
+
return width
|
|
89
|
+
|
|
90
|
+
def draw_text(
|
|
91
|
+
self,
|
|
92
|
+
x: int,
|
|
93
|
+
y: int,
|
|
94
|
+
text: str,
|
|
95
|
+
style: Style = DEFAULT_STYLE,
|
|
96
|
+
*,
|
|
97
|
+
max_width: int | None = None,
|
|
98
|
+
tab_size: int = 4,
|
|
99
|
+
) -> int:
|
|
100
|
+
"""Draw one unwrapped line and return consumed columns."""
|
|
101
|
+
used = 0
|
|
102
|
+
limit = self.width - x if max_width is None else min(max_width, self.width - x)
|
|
103
|
+
for character in text:
|
|
104
|
+
if character in "\r\n":
|
|
105
|
+
break
|
|
106
|
+
if character == "\t":
|
|
107
|
+
spaces = tab_size - ((x + used) % tab_size)
|
|
108
|
+
spaces = min(spaces, max(0, limit - used))
|
|
109
|
+
self.fill(x + used, y, spaces, 1, style)
|
|
110
|
+
used += spaces
|
|
111
|
+
continue
|
|
112
|
+
width = wcwidth(character)
|
|
113
|
+
width = width if width >= 0 else 1
|
|
114
|
+
if used + width > limit:
|
|
115
|
+
break
|
|
116
|
+
self.set_cell(x + used, y, character, style)
|
|
117
|
+
used += width
|
|
118
|
+
return used
|
|
119
|
+
|
|
120
|
+
def draw_spans(self, x: int, y: int, spans: tuple[Span, ...], *, max_width: int | None = None) -> int:
|
|
121
|
+
"""Draw styled fragments without crossing the requested width."""
|
|
122
|
+
used = 0
|
|
123
|
+
limit = self.width - x if max_width is None else min(max_width, self.width - x)
|
|
124
|
+
for span in spans:
|
|
125
|
+
if used >= limit:
|
|
126
|
+
break
|
|
127
|
+
used += self.draw_text(x + used, y, span.text, span.style, max_width=limit - used)
|
|
128
|
+
return used
|
|
129
|
+
|
|
130
|
+
def fill(self, x: int, y: int, width: int, height: int, style: Style, character: str = " ") -> None:
|
|
131
|
+
"""Fill one clipped rectangle."""
|
|
132
|
+
for row in range(max(0, y), min(self.height, y + height)):
|
|
133
|
+
for column in range(max(0, x), min(self.width, x + width)):
|
|
134
|
+
self.cells[row][column] = Cell(character, style)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class DifferentialRenderer:
|
|
138
|
+
"""Write only changed cell ranges since the previous frame."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, output: TextIO) -> None:
|
|
141
|
+
self.output = output
|
|
142
|
+
self.previous: Canvas | None = None
|
|
143
|
+
|
|
144
|
+
def reset(self) -> None:
|
|
145
|
+
self.previous = None
|
|
146
|
+
|
|
147
|
+
def render(self, canvas: Canvas, *, cursor: tuple[int, int] | None = None) -> None:
|
|
148
|
+
full = self.previous is None or (self.previous.width, self.previous.height) != (canvas.width, canvas.height)
|
|
149
|
+
stream = StringIO()
|
|
150
|
+
if full:
|
|
151
|
+
stream.write("\x1b[2J")
|
|
152
|
+
for y, row in enumerate(canvas.cells):
|
|
153
|
+
previous_row = None if full or self.previous is None else self.previous.cells[y]
|
|
154
|
+
if previous_row == row:
|
|
155
|
+
continue
|
|
156
|
+
start, end = _changed_bounds(row, previous_row)
|
|
157
|
+
stream.write(f"\x1b[{y + 1};{start + 1}H")
|
|
158
|
+
active_style: Style | None = None
|
|
159
|
+
for cell in row[start : end + 1]:
|
|
160
|
+
if cell.continuation:
|
|
161
|
+
continue
|
|
162
|
+
if cell.style != active_style:
|
|
163
|
+
stream.write(_ansi_style(cell.style))
|
|
164
|
+
active_style = cell.style
|
|
165
|
+
stream.write(cell.character or " ")
|
|
166
|
+
stream.write("\x1b[0m")
|
|
167
|
+
if cursor is None:
|
|
168
|
+
stream.write("\x1b[?25l")
|
|
169
|
+
else:
|
|
170
|
+
x, y = cursor
|
|
171
|
+
stream.write(f"\x1b[{y + 1};{x + 1}H\x1b[?25h")
|
|
172
|
+
self.output.write(stream.getvalue())
|
|
173
|
+
self.output.flush()
|
|
174
|
+
self.previous = canvas
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _changed_bounds(row: list[Cell], previous: list[Cell] | None) -> tuple[int, int]:
|
|
178
|
+
"""Return a glyph-safe inclusive range containing every changed cell."""
|
|
179
|
+
if previous is None:
|
|
180
|
+
return 0, len(row) - 1
|
|
181
|
+
|
|
182
|
+
changed = [index for index, cell in enumerate(row) if cell != previous[index]]
|
|
183
|
+
start, end = changed[0], changed[-1]
|
|
184
|
+
|
|
185
|
+
# A wide glyph occupies a leading cell plus one or more continuation cells.
|
|
186
|
+
# Include its leader so replacing either half never leaves a stale terminal glyph.
|
|
187
|
+
while start > 0 and (row[start].continuation or previous[start].continuation):
|
|
188
|
+
start -= 1
|
|
189
|
+
while end + 1 < len(row) and (row[end + 1].continuation or previous[end + 1].continuation):
|
|
190
|
+
end += 1
|
|
191
|
+
return start, end
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _ansi_style(style: Style) -> str:
|
|
195
|
+
codes = ["0"]
|
|
196
|
+
if style.bold:
|
|
197
|
+
codes.append("1")
|
|
198
|
+
if style.dim:
|
|
199
|
+
codes.append("2")
|
|
200
|
+
if style.italic:
|
|
201
|
+
codes.append("3")
|
|
202
|
+
if style.reverse:
|
|
203
|
+
codes.append("7")
|
|
204
|
+
if style.foreground:
|
|
205
|
+
codes.append(_truecolor(style.foreground, foreground=True))
|
|
206
|
+
if style.background:
|
|
207
|
+
codes.append(_truecolor(style.background, foreground=False))
|
|
208
|
+
return f"\x1b[{';'.join(codes)}m"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _truecolor(value: str, *, foreground: bool) -> str:
|
|
212
|
+
value = value.removeprefix("#")
|
|
213
|
+
red, green, blue = (int(value[index : index + 2], 16) for index in (0, 2, 4))
|
|
214
|
+
return f"{'38' if foreground else '48'};2;{red};{green};{blue}"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""TTY ownership and asynchronous input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import TextIO
|
|
13
|
+
|
|
14
|
+
from .events import EventType, InputEvent
|
|
15
|
+
from .input import InputDecoder
|
|
16
|
+
|
|
17
|
+
# Raw-mode input, SIGWINCH, and add_reader on stdin are POSIX-only. Keep the
|
|
18
|
+
# import safe on Windows so the rest of ZettCode still loads, and fail with a
|
|
19
|
+
# clear message when a TUI is actually requested there.
|
|
20
|
+
POSIX = os.name == "posix"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Terminal:
|
|
24
|
+
"""Own raw mode and restore the user's terminal on every exit path."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, input_fd: int | None = None, output: TextIO | None = None) -> None:
|
|
27
|
+
self.input_fd = sys.stdin.fileno() if input_fd is None else input_fd
|
|
28
|
+
self.output = sys.stdout if output is None else output
|
|
29
|
+
self._attributes: list | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def size(self) -> tuple[int, int]:
|
|
33
|
+
size = shutil.get_terminal_size((80, 24))
|
|
34
|
+
return max(20, size.columns), max(8, size.lines)
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> Terminal:
|
|
37
|
+
if not POSIX:
|
|
38
|
+
raise RuntimeError("ZettCode TUI requires a POSIX terminal")
|
|
39
|
+
import termios
|
|
40
|
+
import tty
|
|
41
|
+
|
|
42
|
+
if not os.isatty(self.input_fd) or not self.output.isatty():
|
|
43
|
+
raise RuntimeError("ZettCode TUI requires an interactive terminal")
|
|
44
|
+
self._attributes = termios.tcgetattr(self.input_fd)
|
|
45
|
+
tty.setraw(self.input_fd)
|
|
46
|
+
self.write("\x1b[?1049h\x1b[?7l\x1b[?25l\x1b[?2004h\x1b[?1000h\x1b[?1003h\x1b[?1006h")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(
|
|
50
|
+
self,
|
|
51
|
+
exception_type: type[BaseException] | None,
|
|
52
|
+
exception: BaseException | None,
|
|
53
|
+
traceback: TracebackType | None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.write("\x1b[0m\x1b[?25h\x1b[?7h\x1b[?1000l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?1049l")
|
|
56
|
+
if self._attributes is not None:
|
|
57
|
+
import termios
|
|
58
|
+
|
|
59
|
+
termios.tcsetattr(self.input_fd, termios.TCSADRAIN, self._attributes)
|
|
60
|
+
self._attributes = None
|
|
61
|
+
|
|
62
|
+
def write(self, value: str) -> None:
|
|
63
|
+
self.output.write(value)
|
|
64
|
+
self.output.flush()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AsyncInput:
|
|
68
|
+
"""Publish decoded terminal input into one application queue."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, terminal: Terminal, publish: Callable[[InputEvent], None]) -> None:
|
|
71
|
+
self.terminal = terminal
|
|
72
|
+
self.publish = publish
|
|
73
|
+
self.decoder = InputDecoder()
|
|
74
|
+
self.loop: asyncio.AbstractEventLoop | None = None
|
|
75
|
+
self._previous_resize_handler = None
|
|
76
|
+
self._escape_handle: asyncio.TimerHandle | None = None
|
|
77
|
+
|
|
78
|
+
def start(self) -> None:
|
|
79
|
+
self.loop = asyncio.get_running_loop()
|
|
80
|
+
self.loop.add_reader(self.terminal.input_fd, self._read_ready)
|
|
81
|
+
if hasattr(signal, "SIGWINCH"):
|
|
82
|
+
self._previous_resize_handler = signal.getsignal(signal.SIGWINCH)
|
|
83
|
+
self.loop.add_signal_handler(signal.SIGWINCH, self._resize)
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
if self.loop is None:
|
|
87
|
+
return
|
|
88
|
+
self.loop.remove_reader(self.terminal.input_fd)
|
|
89
|
+
if hasattr(signal, "SIGWINCH"):
|
|
90
|
+
self.loop.remove_signal_handler(signal.SIGWINCH)
|
|
91
|
+
if self._escape_handle is not None:
|
|
92
|
+
self._escape_handle.cancel()
|
|
93
|
+
self._escape_handle = None
|
|
94
|
+
if hasattr(signal, "SIGWINCH") and callable(self._previous_resize_handler):
|
|
95
|
+
signal.signal(signal.SIGWINCH, self._previous_resize_handler)
|
|
96
|
+
self.loop = None
|
|
97
|
+
|
|
98
|
+
def _read_ready(self) -> None:
|
|
99
|
+
try:
|
|
100
|
+
data = os.read(self.terminal.input_fd, 65536)
|
|
101
|
+
except OSError:
|
|
102
|
+
return
|
|
103
|
+
if self._escape_handle is not None:
|
|
104
|
+
self._escape_handle.cancel()
|
|
105
|
+
self._escape_handle = None
|
|
106
|
+
for event in self.decoder.feed(data):
|
|
107
|
+
self.publish(event)
|
|
108
|
+
if self.decoder.buffer == b"\x1b" and self.loop is not None:
|
|
109
|
+
self._escape_handle = self.loop.call_later(0.03, self._flush_escape)
|
|
110
|
+
|
|
111
|
+
def _resize(self) -> None:
|
|
112
|
+
self.publish(InputEvent(EventType.RESIZE))
|
|
113
|
+
|
|
114
|
+
def _flush_escape(self) -> None:
|
|
115
|
+
self._escape_handle = None
|
|
116
|
+
event = self.decoder.flush_escape()
|
|
117
|
+
if event is not None:
|
|
118
|
+
self.publish(event)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: zettcode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A focused terminal coding agent powered by zett-agent
|
|
5
|
+
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
|
|
6
|
+
Project-URL: Issues, https://github.com/Chang-LeHung/zettcode/issues
|
|
7
|
+
Author: Chang-LeHung
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,coding-agent,llm,terminal,tui
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Requires-Dist: pyperclip<2,>=1.9
|
|
17
|
+
Requires-Dist: wcwidth<1,>=0.2
|
|
18
|
+
Requires-Dist: zett-agent==0.1.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# ZettCode
|
|
22
|
+
|
|
23
|
+
ZettCode is a focused terminal coding agent built on `zett-agent`. Its TUI owns
|
|
24
|
+
raw terminal input, component layout, mouse interaction, and cell-level
|
|
25
|
+
differential rendering directly; it does not use prompt-toolkit or another TUI
|
|
26
|
+
framework.
|
|
27
|
+
|
|
28
|
+
The agent runtime is published separately as
|
|
29
|
+
[`zett-agent`](https://github.com/Chang-LeHung/zett-agent); this repository owns
|
|
30
|
+
only the terminal client.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
ZettCode needs Python 3.14 or newer and is published on PyPI as `zettcode`:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv tool install zettcode
|
|
38
|
+
# or
|
|
39
|
+
pip install zettcode
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Run
|
|
43
|
+
|
|
44
|
+
DeepSeek:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export DEEPSEEK_API_KEY=...
|
|
48
|
+
zettcode --provider deepseek --model deepseek-chat
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
OpenAI or an OpenAI-compatible endpoint:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export OPENAI_API_KEY=...
|
|
55
|
+
zettcode --provider openai --model gpt-5-mini
|
|
56
|
+
zettcode --provider openai --model my-model --base-url https://example.com/v1
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Pass a workspace path as the final argument. It defaults to the current directory.
|
|
60
|
+
Session data is stored in `~/.zettcode/sessions.sqlite3` by default.
|
|
61
|
+
|
|
62
|
+
From a source checkout, run the same commands through `uv`:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv sync
|
|
66
|
+
uv run zettcode --provider deepseek --model deepseek-chat
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Keys
|
|
70
|
+
|
|
71
|
+
- `Enter`: send
|
|
72
|
+
- `Esc`, then `Enter`: insert a newline
|
|
73
|
+
- `Ctrl-C`: stop the active request, or clear the input
|
|
74
|
+
- `Ctrl-D`: delete the next character; exit when the input is empty and the agent is idle
|
|
75
|
+
- `Ctrl-A` / `Ctrl-E`, `Home` / `End`: move to the start or end of the current line
|
|
76
|
+
- `Ctrl-B` / `Ctrl-F`, `Left` / `Right`: move by one character
|
|
77
|
+
- `Alt-B` / `Alt-F`, `Ctrl-Left` / `Ctrl-Right`: move by one word
|
|
78
|
+
- `Ctrl-Home` / `Ctrl-End`, `Alt-<` / `Alt->`: move to the start or end of the whole input
|
|
79
|
+
- `Ctrl-U` / `Ctrl-K`: delete to the start or end of the current line
|
|
80
|
+
- `Ctrl-W` / `Alt-Backspace`, `Alt-D`: delete the previous or next word
|
|
81
|
+
- `Ctrl-Y`: paste the last text removed with a line or word deletion
|
|
82
|
+
- `Ctrl-Z` / `Ctrl-_`: undo the latest input edit
|
|
83
|
+
- `Up` / `Down`: move between input lines, then browse submitted input history
|
|
84
|
+
- `Ctrl-P` / `Ctrl-N`: browse submitted input history directly
|
|
85
|
+
- `Ctrl-R`: search backward through submitted input history
|
|
86
|
+
- `Ctrl-L`: redraw the terminal
|
|
87
|
+
- `Ctrl-T`: expand or collapse the latest thinking block
|
|
88
|
+
- `Page Up` / `Page Down`: scroll history while keeping the composer focused
|
|
89
|
+
- Click the transcript and use `Up` / `Down`, `Home` / `End` to browse it
|
|
90
|
+
- Drag in the transcript to select text; dragging past an edge automatically scrolls
|
|
91
|
+
- Double-click a transcript row to select the complete visible line
|
|
92
|
+
- `Shift`-click extends the existing selection
|
|
93
|
+
- Completed mouse selections are copied to the system clipboard automatically
|
|
94
|
+
- `Ctrl-C` also copies a transcript selection and returns focus to the input
|
|
95
|
+
- `Tab` / `Shift-Tab`: complete slash commands
|
|
96
|
+
|
|
97
|
+
Thinking is collapsed by default. Click a Thinking row to inspect it; moving the
|
|
98
|
+
pointer outside that block collapses it again. Tool calls keep their output and
|
|
99
|
+
completion status visible in the transcript. Active thinking and tool rows use a
|
|
100
|
+
low-frequency moving highlight wave with an animated activity icon. Tool results show a five-row preview by default;
|
|
101
|
+
click a completed tool row to expand it. Expanded output is still bounded so a
|
|
102
|
+
large command result cannot take over the terminal or exhaust renderer memory.
|
|
103
|
+
|
|
104
|
+
Assistant text renders common terminal-friendly Markdown: bold and emphasis,
|
|
105
|
+
headings, inline and fenced code, links, quotes, unordered bullet lists, and
|
|
106
|
+
GFM-style tables. Tables preserve left, center, and right alignment, account for
|
|
107
|
+
wide CJK characters, and fit or truncate columns to the current terminal width.
|
|
108
|
+
|
|
109
|
+
Use `/help` inside the application to see session commands.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
zettcode/__init__.py,sha256=xH18ql2SktiD5p1dRVP7VS1bpsXKhG--wllDyw65Tpg,201
|
|
2
|
+
zettcode/cli.py,sha256=2qGRfZYWp_YBqhcb999AVZDTK-8yhU498Th0LoiYOKY,3077
|
|
3
|
+
zettcode/config.py,sha256=u5ZFOO6F5sQvPWte7tdi4VxdI3p37qU7gfLWMk7uotM,2493
|
|
4
|
+
zettcode/runtime.py,sha256=y6yPj0dO5xjVztTmvZHBVMMMGbumRFeOBAE8XDu8kI4,4445
|
|
5
|
+
zettcode/tui.py,sha256=5VZXegTI6ROOFs2EvisB0JRnC1VmiFvnxpQwpa2ZrJg,37433
|
|
6
|
+
zettcode/tui_framework/__init__.py,sha256=4-ykS_KrpI9V3W4q2mcwGe_87moNZEpTNZnVEuMdp-U,679
|
|
7
|
+
zettcode/tui_framework/application.py,sha256=5PwqmxVG7MewlcOUBuNu0jdI8RaS9YBhWHazvccGECo,3174
|
|
8
|
+
zettcode/tui_framework/components.py,sha256=bm6RH5f9cbwrlYUt_NSbcsZonlEYaOczV6FFwJ_1ePg,31111
|
|
9
|
+
zettcode/tui_framework/events.py,sha256=OGa15-_WEB0Cb8Bq_p8R6kI-xjihqAVyGV1XwcDRtZw,685
|
|
10
|
+
zettcode/tui_framework/input.py,sha256=pHTX89NH8JEY73KHBBc8QoHFa1w7C8nvIxVvvc1ugPU,4944
|
|
11
|
+
zettcode/tui_framework/screen.py,sha256=XJCvJ_Muvyaj1mnMkqybWLLl2ZO3Xl0lfAKLPFsaklk,7332
|
|
12
|
+
zettcode/tui_framework/terminal.py,sha256=naowmJgZQCBOoJZSeMMBky7HHpsju-Y0WkDM-1VWWXw,4291
|
|
13
|
+
zettcode-0.1.0.dist-info/METADATA,sha256=9QtQu7O0t9L73_4Ku6lHPa9qF7EHycd3fZsrOsJOy58,4368
|
|
14
|
+
zettcode-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
zettcode-0.1.0.dist-info/entry_points.txt,sha256=NAGIYd8zCFIJdjAjPsYQ28tOKZOoWB_N-CQUT-a-9zA,47
|
|
16
|
+
zettcode-0.1.0.dist-info/licenses/LICENSE,sha256=O9b87-u8V1aSp6xkr9qnbHZIJAWNV6m04GEQjycLLiw,1069
|
|
17
|
+
zettcode-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chang-LeHung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|