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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zettcode = zettcode.cli:main
@@ -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.