zettcode-tui 0.4.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.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,218 @@
1
+ """Named commands and the contextual key bindings that trigger them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import TypeAlias
9
+
10
+ from .events import AnyEvent, KeyEvent, TextEvent
11
+ from .host import Host
12
+
13
+ CommandRun: TypeAlias = Callable[[AnyEvent, Host], bool]
14
+ BindingPriority: TypeAlias = str
15
+
16
+ _PRIORITIES = ("capture", "bubble")
17
+ _SEPARATORS = re.compile(r"[-+\s]+")
18
+
19
+
20
+ def normalize_key(value: str) -> str:
21
+ """Fold every common spelling of a key into one canonical name."""
22
+ return _SEPARATORS.sub("_", value.strip().lower())
23
+
24
+
25
+ def key_id(event: KeyEvent) -> str:
26
+ """Return the binding name for a key event, including its modifiers.
27
+
28
+ Args:
29
+ event: Named key press; a modifier already folded into ``key`` is not
30
+ repeated in the returned name.
31
+ """
32
+ name = normalize_key(event.key)
33
+ prefixes = [
34
+ modifier
35
+ for modifier, active in (
36
+ ("ctrl", event.control),
37
+ ("alt", event.alt),
38
+ ("shift", event.shift),
39
+ ("meta", event.meta),
40
+ )
41
+ if active and not name.startswith(f"{modifier}_")
42
+ ]
43
+ return "_".join([*prefixes, name]) if prefixes else name
44
+
45
+
46
+ def event_key(event: AnyEvent) -> str | None:
47
+ """Return the binding name an event resolves to, if it can be bound.
48
+
49
+ Args:
50
+ event: Any input event. A terminal delivers a printable character as
51
+ text rather than as a named key, so a binding such as ``"q"`` has to
52
+ be matched from that path as well; only single-character text
53
+ qualifies, which keeps a paste from tripping a binding.
54
+ """
55
+ if isinstance(event, KeyEvent):
56
+ return key_id(event)
57
+ if isinstance(event, TextEvent) and len(event.text) == 1:
58
+ return normalize_key(event.text)
59
+ return None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Command:
64
+ """One named action a keymap or a command palette can invoke.
65
+
66
+ Attributes:
67
+ name: Unique lookup key, e.g. ``"interrupt"``.
68
+ run: Receives the event and the host; returns whether it handled it.
69
+ description: Human-readable text for a palette or a help screen.
70
+ """
71
+
72
+ name: str
73
+ run: CommandRun
74
+ description: str = ""
75
+
76
+
77
+ class CommandRegistry:
78
+ """Look up commands by name and run them against the live host."""
79
+
80
+ def __init__(self) -> None:
81
+ """Start with no registered commands."""
82
+ self._commands: dict[str, Command] = {}
83
+
84
+ def register(self, command: Command) -> Command:
85
+ """Add or replace a command and return it."""
86
+ if not command.name:
87
+ raise ValueError("Command name cannot be empty")
88
+ self._commands[command.name] = command
89
+ return command
90
+
91
+ def add(self, name: str, run: CommandRun, *, description: str = "") -> Command:
92
+ """Build a command from a name and a run function, then register it.
93
+
94
+ Args:
95
+ name: Lookup key; an existing command with the same name is replaced.
96
+ run: Receives the event and the host, and reports whether it handled
97
+ the event.
98
+ description: Human-readable text for a palette or a help screen.
99
+ """
100
+ return self.register(Command(name, run, description))
101
+
102
+ def get(self, name: str) -> Command | None:
103
+ """Return a command by name, or None when it is not registered."""
104
+ return self._commands.get(name)
105
+
106
+ @property
107
+ def names(self) -> tuple[str, ...]:
108
+ """Return every registered command name, sorted."""
109
+ return tuple(sorted(self._commands))
110
+
111
+ def run(self, name: str, event: AnyEvent, host: Host) -> bool:
112
+ """Run one command by name, reporting whether it handled the event."""
113
+ command = self._commands.get(name)
114
+ if command is None:
115
+ return False
116
+ return bool(command.run(event, host))
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class Binding:
121
+ """One key-to-command mapping, optionally limited to a live context.
122
+
123
+ Attributes:
124
+ command: Name resolved through the command registry.
125
+ priority: ``"capture"`` runs before the widget tree sees the event,
126
+ ``"bubble"`` only after the focused widget declines it.
127
+ when: Predicate re-checked on every matching key; ``None`` always allows.
128
+ description: Human-readable text for a palette or a help screen.
129
+ """
130
+
131
+ command: str
132
+ priority: BindingPriority = "bubble"
133
+ when: Callable[[], bool] | None = None
134
+ description: str = ""
135
+
136
+ def matches(self) -> bool:
137
+ """Return whether the binding's predicate currently allows it."""
138
+ return self.when is None or self.when()
139
+
140
+
141
+ class Keymap:
142
+ """Contextual bindings split into capture and bubble priorities.
143
+
144
+ Capture bindings run before the widget tree sees the event, so an
145
+ application can reserve keys such as ``ctrl_c``. Bubble bindings run only
146
+ after the focused widget declines the event, which lets a composer keep
147
+ keys like ``tab`` for completion.
148
+ """
149
+
150
+ def __init__(self) -> None:
151
+ """Start with no bindings."""
152
+ self._bindings: dict[str, list[Binding]] = {}
153
+
154
+ def bind(
155
+ self,
156
+ key: str,
157
+ command: str,
158
+ *,
159
+ when: Callable[[], bool] | None = None,
160
+ priority: BindingPriority = "bubble",
161
+ description: str = "",
162
+ ) -> Binding:
163
+ """Bind a key spelling to a command at one priority.
164
+
165
+ Args:
166
+ key: Any spelling ``normalize_key`` folds, e.g. ``"Ctrl-C"``.
167
+ command: Name looked up in the command registry when the key fires.
168
+ when: Extra predicate sampled on every key press; the binding is
169
+ skipped while it returns False.
170
+ priority: ``"capture"`` or ``"bubble"``.
171
+ description: Human-readable text for a palette or a help screen.
172
+
173
+ Returns:
174
+ The stored binding, so callers can keep it for a later unbind.
175
+ """
176
+ if priority not in _PRIORITIES:
177
+ raise ValueError(f"Unknown binding priority: {priority!r}")
178
+ if not command:
179
+ raise ValueError("Binding command cannot be empty")
180
+ binding = Binding(command, priority, when, description)
181
+ self._bindings.setdefault(normalize_key(key), []).append(binding)
182
+ return binding
183
+
184
+ def unbind(self, key: str, command: str | None = None) -> int:
185
+ """Remove bindings for one key and return how many were dropped.
186
+
187
+ Args:
188
+ key: Any spelling ``normalize_key`` folds.
189
+ command: Only drop the binding that names this command; ``None``
190
+ removes every binding registered for the key.
191
+ """
192
+ name = normalize_key(key)
193
+ existing = self._bindings.get(name)
194
+ if not existing:
195
+ return 0
196
+ if command is None:
197
+ self._bindings.pop(name, None)
198
+ return len(existing)
199
+ kept = [binding for binding in existing if binding.command != command]
200
+ if kept:
201
+ self._bindings[name] = kept
202
+ else:
203
+ self._bindings.pop(name, None)
204
+ return len(existing) - len(kept)
205
+
206
+ def bindings_for(self, key: str) -> tuple[Binding, ...]:
207
+ """Return the bindings registered for one key spelling, in order."""
208
+ return tuple(self._bindings.get(normalize_key(key), ()))
209
+
210
+ def resolve(self, event: AnyEvent, *, priority: BindingPriority = "bubble") -> str | None:
211
+ """Return the first matching command name, if any binding applies."""
212
+ name = event_key(event)
213
+ if name is None:
214
+ return None
215
+ for binding in self._bindings.get(name, ()):
216
+ if binding.priority == priority and binding.matches():
217
+ return binding.command
218
+ return None
@@ -0,0 +1,105 @@
1
+ """Frame scheduling that keeps animation cost inside a fixed budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from time import monotonic
7
+
8
+
9
+ class Scheduler:
10
+ """Decide when the application should paint its next frame.
11
+
12
+ Repaints are requested explicitly, while animations run on a bounded
13
+ interval so a busy widget cannot drive the loop at full speed.
14
+ """
15
+
16
+ def __init__(self, *, max_fps: float = 120.0, clock: Callable[[], float] = monotonic) -> None:
17
+ """Configure the frame budget and the clock used to time frames.
18
+
19
+ Args:
20
+ max_fps: Ceiling for animated frames per second; must be positive.
21
+ clock: Monotonic time source, injectable so tests can step time.
22
+ """
23
+ if max_fps <= 0:
24
+ raise ValueError("max_fps must be positive")
25
+ self.max_fps = max_fps
26
+ self.interval = 1.0 / max_fps
27
+ self.clock = clock
28
+ self.on_request: Callable[[], None] | None = None
29
+ self._last_frame = float("-inf")
30
+ self._dirty = True
31
+ #: Whether the frame the last :meth:`poll` returned was asked for, as opposed to
32
+ #: falling due on an animation's interval alone.
33
+ self.requested = True
34
+ self._animations: set[str] = set()
35
+
36
+ @property
37
+ def animating(self) -> bool:
38
+ """Return whether any animation token is registered."""
39
+ return bool(self._animations)
40
+
41
+ @property
42
+ def pending(self) -> bool:
43
+ """Return whether a repaint has been requested and not yet taken."""
44
+ return self._dirty
45
+
46
+ @property
47
+ def animations(self) -> tuple[str, ...]:
48
+ """Return the registered animation tokens, sorted."""
49
+ return tuple(sorted(self._animations))
50
+
51
+ def request_repaint(self) -> None:
52
+ """Mark the frame as stale even if no animation is running."""
53
+ self._dirty = True
54
+ self._notify()
55
+
56
+ def animate(self, token: str, *, active: bool = True) -> None:
57
+ """Register or release one animation token.
58
+
59
+ Args:
60
+ token: Stable identity for one animation source, e.g.
61
+ ``f"spinner:{id(self)}"``; registering the same token twice is
62
+ harmless because the set collapses duplicates.
63
+ active: ``True`` keeps the frame loop alive for this token, ``False``
64
+ releases it without disturbing any other animation.
65
+ """
66
+ if not token:
67
+ raise ValueError("Animation token cannot be empty")
68
+ if active:
69
+ self._animations.add(token)
70
+ self._notify()
71
+ else:
72
+ self._animations.discard(token)
73
+
74
+ def poll(self, *, now: float | None = None) -> bool:
75
+ """Return whether a frame is due, recording it as painted.
76
+
77
+ Args:
78
+ now: Overrides the clock; tests use it to pin the frame time.
79
+ """
80
+ moment = self.clock() if now is None else now
81
+ if not self._dirty:
82
+ if not self._animations or moment - self._last_frame < self.interval:
83
+ return False
84
+ self.requested = self._dirty
85
+ self._dirty = False
86
+ self._last_frame = moment
87
+ return True
88
+
89
+ def delay(self, *, now: float | None = None) -> float | None:
90
+ """Return seconds until the next frame, or None when fully idle.
91
+
92
+ Args:
93
+ now: Overrides the clock, matching ``poll``.
94
+ """
95
+ moment = self.clock() if now is None else now
96
+ if self._dirty:
97
+ return 0.0
98
+ if not self._animations:
99
+ return None
100
+ return max(0.0, self.interval - (moment - self._last_frame))
101
+
102
+ def _notify(self) -> None:
103
+ """Wake a sleeping driver, which would otherwise miss a late request."""
104
+ if self.on_request is not None:
105
+ self.on_request()
@@ -0,0 +1,80 @@
1
+ """Layers of the application: a widget subtree plus its focus policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+
8
+ from .widget import Widget
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class Screen:
13
+ """One stacked layer of the application.
14
+
15
+ Attributes:
16
+ widget: Root of this layer's subtree.
17
+ name: Debug label such as ``"approval"``; the base layer is ``"main"``.
18
+ modal: Block input from every layer below this one.
19
+ interactive: Whether the layer may receive input at all. A paint-only
20
+ layer (a toast, a status flash) still draws over the shell but lets
21
+ clicks, wheel, and keys fall through to the layer below.
22
+ remembered_focus: Focus to restore when this layer is uncovered again;
23
+ the app writes it when a new layer is pushed on top.
24
+ """
25
+
26
+ widget: Widget
27
+ name: str = ""
28
+ modal: bool = False
29
+ interactive: bool = True
30
+ remembered_focus: Widget | None = field(default=None)
31
+
32
+
33
+ class ScreenStack:
34
+ """The ordered screen layers, oldest first and topmost last."""
35
+
36
+ def __init__(self, base: Screen) -> None:
37
+ """Start a stack whose base layer can never be popped.
38
+
39
+ Args:
40
+ base: The permanent bottom layer, named ``"main"`` by ``TuiApp``.
41
+ """
42
+ self._screens: list[Screen] = [base]
43
+
44
+ def __iter__(self) -> Iterator[Screen]:
45
+ """Iterate from the oldest layer to the newest."""
46
+ return iter(self._screens)
47
+
48
+ def __reversed__(self) -> Iterator[Screen]:
49
+ """Iterate from the newest layer to the oldest."""
50
+ return reversed(self._screens)
51
+
52
+ def __len__(self) -> int:
53
+ """Return the number of stacked layers."""
54
+ return len(self._screens)
55
+
56
+ @property
57
+ def base(self) -> Screen:
58
+ """Return the permanent bottom layer."""
59
+ return self._screens[0]
60
+
61
+ @property
62
+ def top(self) -> Screen:
63
+ """Return the layer that currently receives input."""
64
+ return self._screens[-1]
65
+
66
+ def push(self, screen: Screen) -> Screen:
67
+ """Add a layer on top and return it.
68
+
69
+ Args:
70
+ screen: The new topmost layer; it becomes the only one that can
71
+ receive input until it is popped or a modal layer covers it.
72
+ """
73
+ self._screens.append(screen)
74
+ return screen
75
+
76
+ def pop(self) -> Screen | None:
77
+ """Remove the topmost layer, refusing to drop the base screen."""
78
+ if len(self._screens) == 1:
79
+ return None
80
+ return self._screens.pop()
@@ -0,0 +1,248 @@
1
+ """Semantic color tokens that widgets render against."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ..render.code import CodeTheme
8
+ from ..render.color import blend, parse_hex
9
+
10
+ #: How much of a diff line's colour is mixed into the page to make the fill behind that line.
11
+ DIFF_FILL_SHARE = 0.16
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ToolTheme:
16
+ """The colour a tool row is painted with, by what the tool did.
17
+
18
+ The built-in palettes keep to a few hues, the way editors keep their tool and
19
+ status colours to a few: a soft lavender for what the agent goes looking with
20
+ (a glob, a grep, a picture, a command, the plan), a warm yellow for reading one
21
+ file (and for ``read_skill``, which reads a file too), orange for what it writes, red for what
22
+ it deletes, teal for a delegated child agent, and mauve for talking to another session. More
23
+ hues than that read as noise in a transcript that is mostly tool rows, so reading is the one
24
+ addition: those rows are the most of any, and the warm yellow says "this is a file I read" where
25
+ the lavender says "this is the agent doing something". That yellow is also the warning colour in the bundled palettes whose yellow and orange differ (every one but
26
+ rose-pine-dawn, which the anti-collision step moved), and the overlap is accepted rather than hidden: it is the one warm hue no tool
27
+ row claims, a skipped row carries a glyph of its own, and the alternative — a step toward the write
28
+ orange — put reads within a few ΔE of writes, which are on screen just as often. The two built-in
29
+ palettes take their tool hues from the Catppuccin family rather than from their page (``dark``'s read
30
+ is mocha's yellow, ``light``'s a shade of its own beside its warning), which is how their other tool
31
+ colours work too. Writing is orange and not green because green is the
32
+ colour of an added line: the heading of an edit sits on the line above its diff, and the same
33
+ green on both made the heading read as one more added line (and a yellow one, close to the
34
+ green in hue and weight, still read as its neighbour). The roles are separate so a theme
35
+ file can pull them apart without touching any code. A tool the palette has never heard of, and
36
+ any role left unset, falls back to :attr:`Theme.accent`.
37
+
38
+ Attributes:
39
+ read: Reading one file, the least surprising action.
40
+ search: Finding paths or matches across the workspace.
41
+ image: Looking at a picture rather than text.
42
+ write: Creating a file or editing one in place.
43
+ delete: Removing a path; deliberately the same hue as a failure.
44
+ shell: Running a command in the workspace.
45
+ plan: Recording the task list.
46
+ subagent: Delegating work to an isolated child agent, which is its own
47
+ family rather than a file operation.
48
+ message: Talking to another session: ``send_message`` and ``list_sessions``, and the
49
+ header of a message another session's agent sent. One hue for both directions, so a
50
+ conversation between sessions reads as one thing.
51
+ web_search: A search the endpoint ran on the model's behalf (``web_search``). Its own
52
+ role rather than ``search``: it leaves the workspace for the open web, and a theme
53
+ may say so. Spelled ``web-search`` in a theme file's ``[tools]`` table.
54
+ ask_user: A question the model put to the reader (``ask_user``). Its own role rather than
55
+ ``plan``: it is the one row that waits for a person, and a theme may colour it apart.
56
+ Spelled ``ask-user`` in a theme file's ``[tools]`` table.
57
+ """
58
+
59
+ read: str = "#f9e2af"
60
+ search: str = "#b4befe"
61
+ image: str = "#b4befe"
62
+ write: str = "#ef9a62"
63
+ delete: str = "#e67e80"
64
+ shell: str = "#b4befe"
65
+ plan: str = "#b4befe"
66
+ subagent: str = "#94e2d5"
67
+ message: str = "#cba6f7"
68
+ web_search: str = "#b4befe"
69
+ ask_user: str = "#b4befe"
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class Theme:
74
+ """One named palette expressed as semantic roles, never raw hex.
75
+
76
+ Widgets read roles such as ``accent`` or ``muted`` so a new palette is a
77
+ data change rather than a search-and-replace through every component.
78
+
79
+ The built-in palettes leave the page to the terminal and follow
80
+ Everforest's green-leaning accents
81
+ (``#A7C080`` green, ``#83C092`` aqua, ``#DBBC7F`` yellow, ``#E67E80`` red)
82
+ over the neutral greys, so a transcript can tell reasoning from tool output
83
+ by hue instead of drawing every row in one green.
84
+
85
+ Attributes:
86
+ name: Palette key used by ``/theme`` and by ``base =`` in a theme file.
87
+ background: Page fill behind everything; ``None`` — what both built-in
88
+ palettes use — leaves the terminal's own background in place, so the
89
+ shell blends into the profile it was started in.
90
+ surface: Raised panel fill.
91
+ surface_alt: Fill shared by the composer and submitted user messages.
92
+ surface_side: Fill of a side question, which is answered on screen and
93
+ deliberately kept out of the conversation. It stays as quiet as any
94
+ other background — the muted body colour has to remain readable on
95
+ it — but it leans a different way from the green-toned neutrals, a
96
+ soft slate, so an aside reads as a note beside the conversation
97
+ rather than as one of its turns.
98
+ surface_peer: Fill of a message that crossed between two sessions, in either direction. It
99
+ is drawn like the reader's own message (the arrow, the same text column), so only its
100
+ background says it is not the reader's. It is as light as ``surface_alt`` and only leans another way —
101
+ Catppuccin Mocha's lavender-grey ``surface0`` in the dark palette, a pale sage in the light one —
102
+ so it reads as a neighbour of the reader's message rather than as a hole or a banner next to it.
103
+ text: Body text.
104
+ muted: Lowest-emphasis text (hints, timestamps, disabled rows).
105
+ subtle: Secondary body text that must stay readable, such as a reasoning
106
+ body or an unchanged diff line.
107
+ accent: Primary highlight: running rows, list markers, the selection
108
+ bullet in a tool row.
109
+ accent_bright: Emphasised accent: the composer prompt, link targets,
110
+ hunk headers.
111
+ warning: Warning state, currently the warning toast.
112
+ error: Failure state: failed tool rows and the error toast.
113
+ diff_added: A line a change adds, and the ``+N`` beside the file. ``None`` — what both built-in
114
+ palettes use — means :attr:`accent`, so a theme that changes the accent changes the diff
115
+ with it; set it to colour additions on their own. Read it through :attr:`addition`.
116
+ diff_removed: A line a change removes, and the ``-N`` beside the file. ``None`` means
117
+ :attr:`error`. Read it through :attr:`deletion`.
118
+ diff_added_fill: The band behind an added line of an edit. ``None`` mixes :attr:`addition` into the page
119
+ (:attr:`background`, or :attr:`surface` when the page is the terminal's), so the band follows the
120
+ palette. Read it through :attr:`addition_fill`.
121
+ diff_removed_fill: The same for a removed line; read it through :attr:`deletion_fill`.
122
+ border: Frame glyphs and separators.
123
+ focus: Focus ring colour; reserved, no built-in widget draws a ring yet.
124
+ selection: Background of a selected or emphasised cell, used by list
125
+ rows and diff segments; text selection uses reverse video instead.
126
+ tools: Per-tool palette for agent rows; see :class:`ToolTheme`.
127
+ code: Per-token palette for fenced code blocks.
128
+ """
129
+
130
+ name: str = "dark"
131
+ background: str | None = None
132
+ surface: str = "#2d353b"
133
+ surface_alt: str = "#343f44"
134
+ surface_side: str = "#393a44"
135
+ surface_peer: str = "#313244"
136
+ text: str = "#f2f5f3"
137
+ muted: str = "#7a8478"
138
+ subtle: str = "#9da9a0"
139
+ accent: str = "#a7c080"
140
+ accent_bright: str = "#83c092"
141
+ warning: str = "#dbbc7f"
142
+ error: str = "#e67e80"
143
+ diff_added: str | None = None
144
+ diff_removed: str | None = None
145
+ diff_added_fill: str | None = None
146
+ diff_removed_fill: str | None = None
147
+ border: str = "#3d484d"
148
+ focus: str = "#83c092"
149
+ selection: str = "#4a5f52"
150
+ tools: ToolTheme = ToolTheme()
151
+ code: CodeTheme = CodeTheme()
152
+
153
+ @property
154
+ def addition_fill(self) -> str:
155
+ """Return the fill behind an added line: ``diff_added_fill`` if set, else the addition colour mixed into the page."""
156
+ return self.diff_added_fill or blend(self.background or self.surface, self.addition, DIFF_FILL_SHARE)
157
+
158
+ @property
159
+ def deletion_fill(self) -> str:
160
+ """Return the fill behind a removed line: ``diff_removed_fill`` if set, else the deletion colour mixed in."""
161
+ return self.diff_removed_fill or blend(self.background or self.surface, self.deletion, DIFF_FILL_SHARE)
162
+
163
+ @property
164
+ def addition(self) -> str:
165
+ """Return the colour of an added line: ``diff_added`` if set, else the accent."""
166
+ return self.diff_added or self.accent
167
+
168
+ @property
169
+ def deletion(self) -> str:
170
+ """Return the colour of a removed line: ``diff_removed`` if set, else the error colour."""
171
+ return self.diff_removed or self.error
172
+
173
+
174
+ DARK = Theme()
175
+
176
+ LIGHT = Theme(
177
+ name="light",
178
+ background=None,
179
+ surface="#ffffff",
180
+ surface_alt="#eceeec",
181
+ surface_side="#e5e6ee",
182
+ surface_peer="#e1ebe4",
183
+ text="#1b211d",
184
+ muted="#68736c",
185
+ subtle="#4c554e",
186
+ accent="#5f7a10",
187
+ accent_bright="#237a5c",
188
+ warning="#8a6314",
189
+ error="#b23b30",
190
+ border="#bdc3af",
191
+ focus="#237a5c",
192
+ selection="#cfe4d6",
193
+ tools=ToolTheme(
194
+ read="#7d5f10",
195
+ search="#5b3fa8",
196
+ image="#5b3fa8",
197
+ write="#b85a14",
198
+ delete="#b23b30",
199
+ shell="#5b3fa8",
200
+ plan="#5b3fa8",
201
+ subagent="#1e7f76",
202
+ message="#8a4a9e",
203
+ ),
204
+ code=CodeTheme(
205
+ name="light",
206
+ inline="#256b3b",
207
+ text="#1b211d",
208
+ keyword="#a3234a",
209
+ string="#2f7a48",
210
+ comment="#6d766f",
211
+ number="#8a6314",
212
+ function="#1f5f8b",
213
+ builtin="#5b3fa8",
214
+ operator="#4c554e",
215
+ punctuator="#4c554e",
216
+ ),
217
+ )
218
+
219
+
220
+ def scheme_named(background: str | None) -> str | None:
221
+ """Return ``"light"`` or ``"dark"`` for a background colour, or None without one.
222
+
223
+ Used to follow the terminal: the luminance of what it says its background is
224
+ picks the palette, so the shell matches the profile it was started in.
225
+
226
+ Args:
227
+ background: ``#rrggbb`` colour, or None when nothing was reported.
228
+ """
229
+ channels = parse_hex(background) if background else None
230
+ if channels is None:
231
+ return None
232
+ red, green, blue = (channel / 255 for channel in channels)
233
+ luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
234
+ return "light" if luminance > 0.5 else "dark"
235
+
236
+
237
+ def theme_named(name: str) -> Theme:
238
+ """Return the built-in theme registered under a name."""
239
+ if name == DARK.name:
240
+ return DARK
241
+ if name == LIGHT.name:
242
+ return LIGHT
243
+ raise ValueError(f"Unknown theme: {name!r}")
244
+
245
+
246
+ def theme_names() -> tuple[str, ...]:
247
+ """Return the built-in palette keys, in the order a picker should list them."""
248
+ return (DARK.name, LIGHT.name)