tui-input 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.
tui_input/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """tui-input: A terminal companion that pins a persistent input bar at the bottom of tmux."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
tui_input/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m tui_input`."""
2
+
3
+ from tui_input.cli import main
4
+
5
+ main()
tui_input/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """CLI entry point for tui-input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from tui_input import __version__
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> None:
12
+ """Main entry point for the tui-input CLI."""
13
+ parser = argparse.ArgumentParser(
14
+ prog="tui-input",
15
+ description="A terminal companion that pins a persistent input bar at the bottom of tmux.",
16
+ )
17
+ parser.add_argument(
18
+ "-v",
19
+ "--version",
20
+ action="version",
21
+ version=f"%(prog)s {__version__}",
22
+ )
23
+ parser.add_argument(
24
+ "command",
25
+ nargs=argparse.REMAINDER,
26
+ help="Command to run in the top pane (e.g., 'claude', 'codex', 'vim file.py')",
27
+ )
28
+ parser.add_argument(
29
+ "--companion",
30
+ action="store_true",
31
+ help=argparse.SUPPRESS, # Internal flag
32
+ )
33
+ parser.add_argument(
34
+ "--target-pane",
35
+ help=argparse.SUPPRESS, # Internal flag
36
+ )
37
+ parser.add_argument(
38
+ "--fix-layout",
39
+ action="store_true",
40
+ help=argparse.SUPPRESS, # Internal flag
41
+ )
42
+ parser.add_argument(
43
+ "--agent-pane",
44
+ help=argparse.SUPPRESS, # Internal flag
45
+ )
46
+ parser.add_argument(
47
+ "--companion-pane",
48
+ help=argparse.SUPPRESS, # Internal flag
49
+ )
50
+
51
+ args = parser.parse_args(argv)
52
+
53
+ # Internal: fix layout after split
54
+ if args.fix_layout:
55
+ if not args.agent_pane or not args.companion_pane:
56
+ print(
57
+ "Error: --agent-pane and --companion-pane are required with --fix-layout",
58
+ file=sys.stderr,
59
+ )
60
+ sys.exit(1)
61
+ _fix_layout(args.agent_pane, args.companion_pane)
62
+ return
63
+
64
+ # Internal: run as companion widget
65
+ if args.companion:
66
+ if not args.target_pane:
67
+ print("Error: --target-pane is required with --companion", file=sys.stderr)
68
+ sys.exit(1)
69
+ _run_companion(args.target_pane)
70
+ return
71
+
72
+ # User-facing: launch agent + companion
73
+ if not args.command:
74
+ parser.print_help()
75
+ sys.exit(1)
76
+
77
+ command = " ".join(args.command)
78
+ _launch(command)
79
+
80
+
81
+ def _launch(command: str) -> None:
82
+ """Validate environment and launch the tmux split."""
83
+ from tui_input.tmux import TmuxNotInstalledError, ensure_tmux_installed
84
+
85
+ try:
86
+ ensure_tmux_installed()
87
+ except TmuxNotInstalledError as e:
88
+ print(f"Error: {e}", file=sys.stderr)
89
+ sys.exit(1)
90
+
91
+ from tui_input.launcher import launch
92
+
93
+ launch(command)
94
+
95
+
96
+ def _run_companion(target_pane: str) -> None:
97
+ """Run the companion app."""
98
+ from tui_input.companion import run_companion
99
+
100
+ run_companion(target_pane)
101
+
102
+
103
+ def _fix_layout(agent_pane: str, companion_pane: str) -> None:
104
+ """Rejoin companion pane below agent if layout was broken by a split."""
105
+ from tui_input.launcher import COMPANION_HEIGHT
106
+ from tui_input.tmux import pane_width, rejoin_companion
107
+
108
+ agent_w = pane_width(agent_pane)
109
+ companion_w = pane_width(companion_pane)
110
+ if agent_w != companion_w:
111
+ rejoin_companion(agent_pane, companion_pane, COMPANION_HEIGHT)
112
+
113
+
114
+ # Support running as `python -m tui_input`
115
+ if __name__ == "__main__":
116
+ main()
tui_input/companion.py ADDED
@@ -0,0 +1,444 @@
1
+ """Textual companion app — pinned input bar at the bottom of a tmux split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, ClassVar
10
+
11
+ from textual import on
12
+ from textual.app import App, ComposeResult
13
+ from textual.widgets import Static, TextArea
14
+
15
+ from tui_input.history import History
16
+ from tui_input.tmux import (
17
+ cancel_copy_mode,
18
+ pane_alive,
19
+ pane_height,
20
+ pane_pid,
21
+ resize_pane,
22
+ send_keys,
23
+ send_raw,
24
+ submit_to_pane,
25
+ unset_hook,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from textual.events import Key
30
+ from textual.timer import Timer
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Layout constants
34
+ # ---------------------------------------------------------------------------
35
+
36
+ MIN_HEIGHT = 7
37
+ MAX_HEIGHT = 12
38
+ PROCESS_CHECK_INTERVAL = 0.3
39
+
40
+ # IME commit delay — short enough to be imperceptible, long enough for
41
+ # terminal IME to settle after a key event.
42
+ _IME_COMMIT_DELAY = 0.05
43
+
44
+ CSS_PATH = Path(__file__).parent / "companion.tcss"
45
+
46
+ # Rough bytes-per-token ratio used for the status bar estimate.
47
+ _BYTES_PER_TOKEN = 4
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Key mapping tables
51
+ #
52
+ # Each table maps a Textual key name to a value that determines how the
53
+ # key is forwarded to the agent pane.
54
+ # ---------------------------------------------------------------------------
55
+
56
+ # Keys that insert a newline (terminal-dependent names for Shift+Enter).
57
+ _NEWLINE_KEYS: frozenset[str] = frozenset(
58
+ {
59
+ "shift+enter",
60
+ "shift+return",
61
+ "alt+enter",
62
+ "meta+enter",
63
+ "ctrl+j",
64
+ }
65
+ )
66
+
67
+ # Keys that are ALWAYS forwarded to the agent pane as raw bytes,
68
+ # regardless of whether the companion has text.
69
+ _ALWAYS_FORWARD_KEYS: dict[str, str] = {
70
+ "ctrl+c": "\x03",
71
+ }
72
+
73
+ # Keys forwarded to the agent only when the companion is empty (raw bytes).
74
+ _EMPTY_FORWARD_RAW: dict[str, str] = {
75
+ "escape": "\x1b",
76
+ "ctrl+d": "\x04",
77
+ "ctrl+l": "\x0c",
78
+ "ctrl+z": "\x1a",
79
+ }
80
+
81
+ # Keys forwarded to the agent only when the companion is empty (tmux key names).
82
+ _EMPTY_FORWARD_NAMED: dict[str, str] = {
83
+ "left": "Left",
84
+ "right": "Right",
85
+ "up": "Up",
86
+ "down": "Down",
87
+ "tab": "Tab",
88
+ "shift+tab": "BTab",
89
+ }
90
+
91
+ # Known interactive shells — used by ``_is_shell`` to distinguish a shell
92
+ # wrapper from a directly-executed agent process.
93
+ _KNOWN_SHELLS: frozenset[str] = frozenset(
94
+ {
95
+ "zsh",
96
+ "bash",
97
+ "sh",
98
+ "fish",
99
+ "dash",
100
+ "ksh",
101
+ "tcsh",
102
+ "csh",
103
+ }
104
+ )
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Process inspection helpers
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def _estimate_tokens(text: str) -> int:
113
+ """Rough token estimate based on UTF-8 byte length."""
114
+ if not text:
115
+ return 0
116
+ return max(1, len(text.encode("utf-8")) // _BYTES_PER_TOKEN)
117
+
118
+
119
+ def _has_children(pid: int) -> bool:
120
+ """Return True if *pid* has any child processes (requires ``pgrep``)."""
121
+ result = subprocess.run(
122
+ ["pgrep", "-P", str(pid)],
123
+ capture_output=True,
124
+ text=True,
125
+ )
126
+ return result.returncode == 0
127
+
128
+
129
+ def _is_shell(pid: int) -> bool:
130
+ """Return True if *pid* is a known interactive shell."""
131
+ try:
132
+ result = subprocess.run(
133
+ ["ps", "-p", str(pid), "-o", "comm="],
134
+ capture_output=True,
135
+ text=True,
136
+ check=True,
137
+ )
138
+ # Login shells are prefixed with "-" (e.g. "-zsh").
139
+ comm = result.stdout.strip().lstrip("-")
140
+ return comm in _KNOWN_SHELLS
141
+ except subprocess.CalledProcessError:
142
+ return False
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # InputArea — custom TextArea with key forwarding
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ class InputArea(TextArea):
151
+ """Custom TextArea that replaces the agent CLI's input area.
152
+
153
+ Intercepts specific keys (Enter, Shift+Enter, Ctrl+C, …) and either
154
+ forwards them to the agent pane or performs local actions (newline,
155
+ send, history navigation). All other keys are delegated to the
156
+ parent ``TextArea``.
157
+ """
158
+
159
+ _pending_enter_timer: Timer | None = None
160
+
161
+ # -- Timer management ----------------------------------------------------
162
+
163
+ def _cancel_pending_enter(self) -> None:
164
+ """Cancel any pending deferred Enter/newline timer."""
165
+ if self._pending_enter_timer is not None:
166
+ self._pending_enter_timer.stop()
167
+ self._pending_enter_timer = None
168
+
169
+ # -- Deferred actions (IME-safe) -----------------------------------------
170
+
171
+ def _deferred_send(self) -> None:
172
+ """Schedule ``_send`` after the IME commit delay."""
173
+
174
+ def _callback() -> None:
175
+ self._pending_enter_timer = None
176
+ self._send()
177
+
178
+ self._pending_enter_timer = self.set_timer(_IME_COMMIT_DELAY, _callback)
179
+
180
+ def _deferred_newline(self) -> None:
181
+ """Schedule newline insertion after the IME commit delay."""
182
+
183
+ def _callback() -> None:
184
+ self._pending_enter_timer = None
185
+ self.insert("\n")
186
+
187
+ self._pending_enter_timer = self.set_timer(_IME_COMMIT_DELAY, _callback)
188
+
189
+ def _deferred_enter(self) -> None:
190
+ """Schedule Enter action after the IME commit delay.
191
+
192
+ After the delay: if empty, forward ``\\r`` to agent; otherwise send.
193
+ """
194
+
195
+ def _callback() -> None:
196
+ self._pending_enter_timer = None
197
+ if not self.text:
198
+ self._forward_raw("\r")
199
+ else:
200
+ self._send()
201
+
202
+ self._pending_enter_timer = self.set_timer(_IME_COMMIT_DELAY, _callback)
203
+
204
+ # -- Text submission -----------------------------------------------------
205
+
206
+ def _send(self) -> None:
207
+ """Send text to the target pane and clear the editor."""
208
+ app = self.app
209
+ if not isinstance(app, CompanionApp):
210
+ return
211
+ text = self.text.strip()
212
+ if not text:
213
+ return
214
+ try:
215
+ submit_to_pane(app.target_pane, text)
216
+ except Exception:
217
+ app.bell()
218
+ return
219
+ app.history.add(text)
220
+ self.text = ""
221
+ if app.companion_pane:
222
+ with contextlib.suppress(subprocess.CalledProcessError):
223
+ resize_pane(app.companion_pane, MIN_HEIGHT)
224
+ self._scroll_target_to_bottom()
225
+
226
+ def _scroll_target_to_bottom(self) -> None:
227
+ """Exit copy-mode on the target pane so it scrolls to the bottom."""
228
+ if not isinstance(self.app, CompanionApp):
229
+ return
230
+ with contextlib.suppress(Exception):
231
+ cancel_copy_mode(self.app.target_pane)
232
+
233
+ # -- Key forwarding helpers ----------------------------------------------
234
+
235
+ def _forward_raw(self, data: str) -> None:
236
+ """Forward raw bytes to the agent pane."""
237
+ if not isinstance(self.app, CompanionApp):
238
+ return
239
+ with contextlib.suppress(subprocess.CalledProcessError):
240
+ send_raw(self.app.target_pane, data)
241
+
242
+ def _forward_key(self, tmux_key: str) -> None:
243
+ """Forward a named key to the agent pane via ``tmux send-keys``."""
244
+ if not isinstance(self.app, CompanionApp):
245
+ return
246
+ with contextlib.suppress(subprocess.CalledProcessError):
247
+ send_keys(self.app.target_pane, tmux_key)
248
+
249
+ # -- Key event dispatch --------------------------------------------------
250
+
251
+ async def _on_key(self, event: Key) -> None:
252
+ """Handle all key input with contextual forwarding."""
253
+ app = self.app
254
+ is_empty = not self.text
255
+
256
+ # --- Always forward to agent (e.g. Ctrl+C) ---
257
+ if event.key in _ALWAYS_FORWARD_KEYS:
258
+ self._cancel_pending_enter()
259
+ event.prevent_default()
260
+ event.stop()
261
+ self._forward_raw(_ALWAYS_FORWARD_KEYS[event.key])
262
+ return
263
+
264
+ # --- Forward to agent when companion is empty (raw bytes) ---
265
+ if is_empty and event.key in _EMPTY_FORWARD_RAW:
266
+ self._cancel_pending_enter()
267
+ event.prevent_default()
268
+ event.stop()
269
+ self._forward_raw(_EMPTY_FORWARD_RAW[event.key])
270
+ return
271
+
272
+ # --- Forward to agent when companion is empty (named keys) ---
273
+ if is_empty and event.key in _EMPTY_FORWARD_NAMED:
274
+ self._cancel_pending_enter()
275
+ event.prevent_default()
276
+ event.stop()
277
+ self._forward_key(_EMPTY_FORWARD_NAMED[event.key])
278
+ return
279
+
280
+ # --- Escape with text: clear companion ---
281
+ if event.key == "escape":
282
+ self._cancel_pending_enter()
283
+ event.prevent_default()
284
+ event.stop()
285
+ self.text = ""
286
+ if isinstance(app, CompanionApp):
287
+ app.history.reset_navigation()
288
+ return
289
+
290
+ # --- Newline insertion (Shift+Enter / Alt+Enter / Ctrl+J) ---
291
+ if event.key in _NEWLINE_KEYS:
292
+ self._cancel_pending_enter()
293
+ event.prevent_default()
294
+ event.stop()
295
+ self._deferred_newline()
296
+ return
297
+
298
+ # --- Enter: deferred for IME composition ---
299
+ if event.key == "enter":
300
+ self._cancel_pending_enter()
301
+ event.prevent_default()
302
+ event.stop()
303
+ self._deferred_enter()
304
+ return
305
+
306
+ # --- History navigation (Ctrl+Up/Down, empty companion only) ---
307
+ if not isinstance(app, CompanionApp):
308
+ await super()._on_key(event)
309
+ return
310
+ history = app.history
311
+
312
+ if event.key == "ctrl+up" and is_empty:
313
+ self._cancel_pending_enter()
314
+ event.prevent_default()
315
+ event.stop()
316
+ prev = history.get_previous()
317
+ if prev is not None:
318
+ self.text = prev
319
+ self.move_cursor_relative(rows=999, columns=999)
320
+ return
321
+
322
+ if event.key == "ctrl+down" and is_empty:
323
+ self._cancel_pending_enter()
324
+ event.prevent_default()
325
+ event.stop()
326
+ nxt = history.get_next()
327
+ if nxt is not None:
328
+ self.text = nxt
329
+ self.move_cursor_relative(rows=999, columns=999)
330
+ else:
331
+ self.text = ""
332
+ return
333
+
334
+ # --- Everything else: let TextArea handle ---
335
+ await super()._on_key(event)
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # CompanionApp — Textual application
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ class CompanionApp(App[None]):
344
+ """A companion input widget that sends text to a target tmux pane."""
345
+
346
+ CSS_PATH: ClassVar[str | Path] = CSS_PATH
347
+
348
+ def __init__(
349
+ self,
350
+ target_pane: str,
351
+ companion_pane: str | None = None,
352
+ target_pid: int | None = None,
353
+ ) -> None:
354
+ super().__init__()
355
+ self.target_pane = target_pane
356
+ self.companion_pane = companion_pane
357
+ self.target_pid = target_pid
358
+ self.history = History()
359
+
360
+ def compose(self) -> ComposeResult:
361
+ yield InputArea(id="editor")
362
+ yield Static("0 chars · 0 bytes · ~0 tokens", id="stats")
363
+
364
+ def on_mount(self) -> None:
365
+ editor = self.query_one("#editor", InputArea)
366
+ editor.show_line_numbers = False
367
+ editor.soft_wrap = True
368
+ editor.tab_behavior = "indent"
369
+ self.set_interval(PROCESS_CHECK_INTERVAL, self._check_target_alive)
370
+
371
+ def on_unmount(self) -> None:
372
+ """Clean up tmux hook when companion exits."""
373
+ if self.companion_pane:
374
+ with contextlib.suppress(Exception):
375
+ unset_hook("after-split-window", self.companion_pane)
376
+
377
+ @on(TextArea.Changed)
378
+ def _on_text_changed(self, event: TextArea.Changed) -> None:
379
+ """Adjust pane height based on line count and update stats."""
380
+ text = event.text_area.text
381
+
382
+ # Resize companion pane (only expand, never shrink).
383
+ line_count = text.count("\n") + 1 if text else 1
384
+ # +2 accounts for editor chrome (border) and stats footer.
385
+ target_height = max(MIN_HEIGHT, min(line_count + 2, MAX_HEIGHT))
386
+ if self.companion_pane:
387
+ with contextlib.suppress(subprocess.CalledProcessError):
388
+ current = pane_height(self.companion_pane)
389
+ if target_height > current:
390
+ resize_pane(self.companion_pane, target_height)
391
+
392
+ # Update stats footer.
393
+ chars = len(text)
394
+ byte_count = len(text.encode("utf-8"))
395
+ tokens = _estimate_tokens(text)
396
+ stats = self.query_one("#stats", Static)
397
+ stats.update(f"{chars} chars · {byte_count} bytes · ~{tokens} tokens")
398
+
399
+ def _check_target_alive(self) -> None:
400
+ """Check if the agent pane is still alive; exit if not.
401
+
402
+ Two modes depending on what process owns the target pane:
403
+
404
+ * **Shell mode** (inside-tmux): the pane runs a shell (zsh/bash)
405
+ that spawned the agent as a child. When the agent exits, the
406
+ shell has no children → companion exits.
407
+ * **Direct mode** (outside-tmux): the pane runs the agent
408
+ directly (no shell wrapper). We simply check if the pane
409
+ process is still alive.
410
+ """
411
+ if not pane_alive(self.target_pane):
412
+ self.exit()
413
+ return
414
+ if self.target_pid is None:
415
+ return
416
+ if _is_shell(self.target_pid):
417
+ if not _has_children(self.target_pid):
418
+ self.exit()
419
+ else:
420
+ try:
421
+ os.kill(self.target_pid, 0)
422
+ except ProcessLookupError:
423
+ self.exit()
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Entry point
428
+ # ---------------------------------------------------------------------------
429
+
430
+
431
+ def run_companion(target_pane: str) -> None:
432
+ """Run the companion app targeting a specific tmux pane."""
433
+ # Use TMUX_PANE env var to get the companion's own pane ID.
434
+ # ``current_pane_id()`` uses ``tmux display-message -p`` which returns
435
+ # the *selected* pane (the agent), not the pane we're running in.
436
+ companion_pane = os.environ.get("TMUX_PANE")
437
+ target_pid: int | None = None
438
+ with contextlib.suppress(Exception):
439
+ target_pid = pane_pid(target_pane)
440
+ CompanionApp(
441
+ target_pane=target_pane,
442
+ companion_pane=companion_pane,
443
+ target_pid=target_pid,
444
+ ).run()
@@ -0,0 +1,19 @@
1
+ Screen {
2
+ layout: vertical;
3
+ }
4
+
5
+ #editor {
6
+ height: 1fr;
7
+ border: round $foreground 30%;
8
+ }
9
+
10
+ #editor:focus {
11
+ border: round $foreground 50%;
12
+ }
13
+
14
+ #stats {
15
+ height: 1;
16
+ text-align: right;
17
+ color: $text-muted;
18
+ padding: 0 1;
19
+ }
tui_input/history.py ADDED
@@ -0,0 +1,124 @@
1
+ """JSON-based input history management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ DEFAULT_HISTORY_PATH = Path.home() / ".local" / "share" / "tui-input" / "history.json"
12
+ MAX_ENTRIES = 100
13
+
14
+
15
+ @dataclass
16
+ class HistoryEntry:
17
+ """A single history entry."""
18
+
19
+ text: str
20
+ timestamp: str
21
+
22
+ def to_dict(self) -> dict[str, str]:
23
+ """Serialize to a JSON-compatible dict."""
24
+ return {"text": self.text, "timestamp": self.timestamp}
25
+
26
+ @classmethod
27
+ def from_dict(cls, data: dict[str, Any]) -> HistoryEntry:
28
+ """Deserialize from a dict."""
29
+ return cls(text=str(data["text"]), timestamp=str(data["timestamp"]))
30
+
31
+
32
+ @dataclass
33
+ class History:
34
+ """Manages a JSON-based input history file.
35
+
36
+ Entries are stored as a JSON array of objects with 'text' and 'timestamp' fields.
37
+ Duplicate entries are moved to the end (most recent position).
38
+ Maximum entries are capped at ``MAX_ENTRIES``.
39
+ """
40
+
41
+ path: Path = field(default_factory=lambda: DEFAULT_HISTORY_PATH)
42
+ entries: list[HistoryEntry] = field(default_factory=list)
43
+ _index: int = field(default=-1, repr=False)
44
+
45
+ def __post_init__(self) -> None:
46
+ self.load()
47
+
48
+ def load(self) -> None:
49
+ """Load history from the JSON file.
50
+
51
+ Falls back to an empty list on missing file or corruption.
52
+ """
53
+ if not self.path.exists():
54
+ self.entries = []
55
+ return
56
+ try:
57
+ raw = self.path.read_text(encoding="utf-8")
58
+ data = json.loads(raw)
59
+ if not isinstance(data, list):
60
+ self.entries = []
61
+ return
62
+ self.entries = [HistoryEntry.from_dict(item) for item in data]
63
+ except (json.JSONDecodeError, KeyError, TypeError, OSError):
64
+ self.entries = []
65
+
66
+ def save(self) -> None:
67
+ """Persist current entries to the JSON file."""
68
+ self.path.parent.mkdir(parents=True, exist_ok=True)
69
+ data = [entry.to_dict() for entry in self.entries]
70
+ self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
71
+
72
+ def add(self, text: str) -> None:
73
+ """Add a new entry. Duplicates are moved to the end.
74
+
75
+ Empty or whitespace-only text is silently ignored.
76
+ """
77
+ if not text or not text.strip():
78
+ return
79
+ # Remove existing duplicate.
80
+ self.entries = [e for e in self.entries if e.text != text]
81
+ self.entries.append(
82
+ HistoryEntry(
83
+ text=text,
84
+ timestamp=datetime.now(tz=timezone.utc).isoformat(),
85
+ )
86
+ )
87
+ # Trim to max entries.
88
+ if len(self.entries) > MAX_ENTRIES:
89
+ self.entries = self.entries[-MAX_ENTRIES:]
90
+ self.save()
91
+ self._index = -1
92
+
93
+ def get_previous(self) -> str | None:
94
+ """Navigate backward in history. Returns None if at the beginning."""
95
+ if not self.entries:
96
+ return None
97
+ if self._index == -1:
98
+ self._index = len(self.entries) - 1
99
+ elif self._index > 0:
100
+ self._index -= 1
101
+ else:
102
+ return self.entries[0].text
103
+ return self.entries[self._index].text
104
+
105
+ def get_next(self) -> str | None:
106
+ """Navigate forward in history. Returns None if past the end."""
107
+ if not self.entries or self._index == -1:
108
+ return None
109
+ if self._index < len(self.entries) - 1:
110
+ self._index += 1
111
+ return self.entries[self._index].text
112
+ # Past the end — reset index.
113
+ self._index = -1
114
+ return None
115
+
116
+ def reset_navigation(self) -> None:
117
+ """Reset the navigation index."""
118
+ self._index = -1
119
+
120
+ def clear(self) -> None:
121
+ """Clear all history entries."""
122
+ self.entries = []
123
+ self._index = -1
124
+ self.save()
tui_input/launcher.py ADDED
@@ -0,0 +1,89 @@
1
+ """Tmux environment setup — splits pane and launches companion + agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import sys
9
+
10
+ from tui_input.tmux import (
11
+ create_session_with_split,
12
+ current_pane_id,
13
+ in_tmux,
14
+ set_hook,
15
+ split_window,
16
+ )
17
+
18
+ COMPANION_HEIGHT = 7
19
+ SESSION_NAME = "tui-input"
20
+
21
+
22
+ def launch(command: str) -> None:
23
+ """Set up tmux split and launch the companion alongside the given command.
24
+
25
+ If already inside tmux, splits the current pane.
26
+ If outside tmux, creates a new session with the split.
27
+
28
+ The current process is replaced by the agent command (top pane).
29
+ """
30
+ tui_input_bin = _find_tui_input_bin()
31
+ companion_base = f"{tui_input_bin} --companion --target-pane"
32
+
33
+ if in_tmux():
34
+ _launch_inside_tmux(command, tui_input_bin, companion_base)
35
+ else:
36
+ _launch_outside_tmux(command, companion_base)
37
+
38
+
39
+ def _find_tui_input_bin() -> str:
40
+ """Return a shell command that invokes tui-input via the current interpreter."""
41
+ return f"{sys.executable} -m tui_input"
42
+
43
+
44
+ def _launch_inside_tmux(command: str, tui_input_bin: str, companion_base: str) -> None:
45
+ """Split the current tmux pane and launch companion in the bottom."""
46
+ top_pane = current_pane_id()
47
+ companion_cmd = f"{companion_base} {top_pane}"
48
+
49
+ # Split current pane: companion goes to the bottom.
50
+ companion_pane = split_window(COMPANION_HEIGHT, companion_cmd)
51
+
52
+ # Register hook to fix layout when agent pane is split.
53
+ fix_layout_cmd = (
54
+ f"run-shell '{tui_input_bin} --fix-layout"
55
+ f" --agent-pane {top_pane} --companion-pane {companion_pane}'"
56
+ )
57
+ set_hook("after-split-window", fix_layout_cmd, target=top_pane)
58
+
59
+ # Replace current process with the agent command.
60
+ args = shlex.split(command)
61
+ _validate_command(args[0])
62
+ os.execvp(args[0], args) # noqa: S606 — command is validated above
63
+
64
+
65
+ def _launch_outside_tmux(command: str, companion_base: str) -> None:
66
+ """Create a new tmux session with agent + companion split."""
67
+ # The companion command will resolve the target pane at startup.
68
+ companion_cmd = (
69
+ f"sleep 0.5 && TARGET=$(tmux list-panes -t {SESSION_NAME}: "
70
+ f"-F '#{{pane_id}}' | head -1) && "
71
+ f"{companion_base} $TARGET"
72
+ )
73
+
74
+ create_session_with_split(
75
+ session_name=SESSION_NAME,
76
+ main_command=command,
77
+ companion_command=companion_cmd,
78
+ companion_height=COMPANION_HEIGHT,
79
+ )
80
+
81
+
82
+ def _validate_command(executable: str) -> None:
83
+ """Verify that *executable* exists on PATH before ``execvp``.
84
+
85
+ Raises:
86
+ SystemExit: If the executable cannot be found.
87
+ """
88
+ if shutil.which(executable) is None:
89
+ raise SystemExit(f"Error: command not found: {executable}")
tui_input/tmux.py ADDED
@@ -0,0 +1,292 @@
1
+ """Tmux utility functions for pane management and text pasting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+
10
+
11
+ class TmuxNotInstalledError(RuntimeError):
12
+ """Raised when tmux is not found on the system."""
13
+
14
+
15
+ class TmuxCommandError(RuntimeError):
16
+ """Raised when a tmux subcommand fails with additional context."""
17
+
18
+
19
+ def ensure_tmux_installed() -> None:
20
+ """Check that tmux is installed and raise a helpful error if not."""
21
+ if shutil.which("tmux") is None:
22
+ raise TmuxNotInstalledError(
23
+ "tmux is required but not installed.\n"
24
+ "Install it with:\n"
25
+ " macOS: brew install tmux\n"
26
+ " Ubuntu: sudo apt install tmux\n"
27
+ " Fedora: sudo dnf install tmux"
28
+ )
29
+
30
+
31
+ def in_tmux() -> bool:
32
+ """Return True if the current process is running inside a tmux session."""
33
+ return "TMUX" in os.environ
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Internal helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _run_tmux(*args: str) -> str:
42
+ """Run a tmux subcommand and return stripped stdout.
43
+
44
+ Raises:
45
+ TmuxCommandError: If the tmux command exits with a non-zero status.
46
+ """
47
+ try:
48
+ result = subprocess.run(
49
+ ["tmux", *args],
50
+ capture_output=True,
51
+ text=True,
52
+ check=True,
53
+ )
54
+ except subprocess.CalledProcessError as exc:
55
+ cmd_str = " ".join(["tmux", *args])
56
+ stderr = (exc.stderr or "").strip()
57
+ raise TmuxCommandError(f"tmux command failed: {cmd_str!r} — {stderr}") from exc
58
+ return result.stdout.strip()
59
+
60
+
61
+ def _paste_via_buffer(pane_id: str, data: bytes, *, bracketed: bool = False) -> None:
62
+ """Write *data* to a temp file, load it into a tmux buffer, and paste it.
63
+
64
+ Args:
65
+ pane_id: Target tmux pane.
66
+ data: Raw bytes to paste.
67
+ bracketed: If True, use bracketed-paste mode (``-p``).
68
+ """
69
+ with tempfile.NamedTemporaryFile(mode="wb", suffix=".txt", delete=False) as f:
70
+ f.write(data)
71
+ tmp_path = f.name
72
+ try:
73
+ _run_tmux("load-buffer", tmp_path)
74
+ paste_args = ["paste-buffer", "-d", "-t", pane_id]
75
+ if bracketed:
76
+ paste_args.insert(1, "-p")
77
+ _run_tmux(*paste_args)
78
+ finally:
79
+ os.unlink(tmp_path)
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Public API — pane queries
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def current_pane_id() -> str:
88
+ """Return the pane ID (e.g. '%3') of the current tmux pane."""
89
+ return _run_tmux("display-message", "-p", "#{pane_id}")
90
+
91
+
92
+ def pane_exists(pane_id: str) -> bool:
93
+ """Check if a tmux pane still exists."""
94
+ try:
95
+ result = subprocess.run(
96
+ ["tmux", "list-panes", "-a", "-F", "#{pane_id}"],
97
+ capture_output=True,
98
+ text=True,
99
+ check=True,
100
+ )
101
+ return pane_id in result.stdout.splitlines()
102
+ except subprocess.CalledProcessError:
103
+ return False
104
+
105
+
106
+ def pane_pid(pane_id: str) -> int | None:
107
+ """Return the PID of the process running in a tmux pane, or None."""
108
+ try:
109
+ result = _run_tmux("display-message", "-t", pane_id, "-p", "#{pane_pid}")
110
+ return int(result)
111
+ except (TmuxCommandError, ValueError):
112
+ return None
113
+
114
+
115
+ def pane_alive(pane_id: str) -> bool:
116
+ """Check if a tmux pane exists AND its process is still running.
117
+
118
+ Returns False if the pane doesn't exist or its process has exited
119
+ (even if the pane remains due to ``remain-on-exit``).
120
+ """
121
+ try:
122
+ result = subprocess.run(
123
+ ["tmux", "display-message", "-t", pane_id, "-p", "#{pane_dead}"],
124
+ capture_output=True,
125
+ text=True,
126
+ check=True,
127
+ )
128
+ return result.stdout.strip() != "1"
129
+ except subprocess.CalledProcessError:
130
+ return False
131
+
132
+
133
+ def pane_height(pane_id: str) -> int:
134
+ """Return the height in lines of a tmux pane."""
135
+ return int(_run_tmux("display-message", "-t", pane_id, "-p", "#{pane_height}"))
136
+
137
+
138
+ def pane_width(pane_id: str) -> int:
139
+ """Return the width in columns of a tmux pane."""
140
+ return int(_run_tmux("display-message", "-t", pane_id, "-p", "#{pane_width}"))
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Public API — pane actions
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def split_window(size: int, command: str | None = None) -> str:
149
+ """Split the current pane vertically and return the new pane ID.
150
+
151
+ Args:
152
+ size: Height in lines for the new (bottom) pane.
153
+ command: Optional shell command to run in the new pane.
154
+
155
+ Returns:
156
+ The pane ID of the newly created pane.
157
+ """
158
+ args = ["split-window", "-v", "-l", str(size), "-P", "-F", "#{pane_id}"]
159
+ if command:
160
+ args.append(command)
161
+ return _run_tmux(*args)
162
+
163
+
164
+ def send_keys(pane_id: str, keys: str) -> None:
165
+ """Send a named key (e.g. 'Enter', 'Escape', 'C-c') to a tmux pane."""
166
+ _run_tmux("send-keys", "-t", pane_id, keys)
167
+
168
+
169
+ def send_raw(pane_id: str, data: str) -> None:
170
+ """Send raw bytes to a tmux pane via load-buffer + paste-buffer.
171
+
172
+ Unlike send_keys, this sends arbitrary data without key name interpretation.
173
+ Useful for forwarding control characters (Ctrl+C = ``\\x03``, etc.).
174
+ """
175
+ _paste_via_buffer(pane_id, data.encode("utf-8"))
176
+
177
+
178
+ def paste_to_pane(pane_id: str, text: str) -> None:
179
+ """Paste text into a tmux pane using bracketed paste for safe multi-line handling."""
180
+ _paste_via_buffer(pane_id, text.encode("utf-8"), bracketed=True)
181
+
182
+
183
+ def submit_to_pane(pane_id: str, text: str) -> None:
184
+ """Paste text into a pane, then send Enter separately.
185
+
186
+ The text and the submit keystroke are sent as two distinct operations
187
+ so that the target application (e.g. Claude Code) reliably treats
188
+ Enter as a submit action rather than part of the pasted content.
189
+ """
190
+ _paste_via_buffer(pane_id, text.encode("utf-8"))
191
+ _run_tmux("send-keys", "-t", pane_id, "Enter")
192
+
193
+
194
+ def resize_pane(pane_id: str, height: int) -> None:
195
+ """Resize a tmux pane to the given height."""
196
+ _run_tmux("resize-pane", "-t", pane_id, "-y", str(height))
197
+
198
+
199
+ def set_hook(hook_name: str, command: str, target: str) -> None:
200
+ """Register a tmux hook at window scope."""
201
+ _run_tmux("set-hook", "-w", "-t", target, hook_name, command)
202
+
203
+
204
+ def unset_hook(hook_name: str, target: str) -> None:
205
+ """Remove a tmux hook at window scope."""
206
+ _run_tmux("set-hook", "-u", "-w", "-t", target, hook_name)
207
+
208
+
209
+ def rejoin_companion(agent_pane: str, companion_pane: str, height: int) -> None:
210
+ """Move companion pane below agent pane with join-pane."""
211
+ _run_tmux("join-pane", "-v", "-s", companion_pane, "-t", agent_pane, "-l", str(height))
212
+
213
+
214
+ def cancel_copy_mode(pane_id: str) -> None:
215
+ """Exit copy-mode on a pane so it scrolls to the bottom.
216
+
217
+ If the pane is not in copy-mode, the enter/cancel sequence is harmless.
218
+ """
219
+ _run_tmux("copy-mode", "-t", pane_id)
220
+ _run_tmux("send-keys", "-t", pane_id, "-X", "cancel")
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Session management
225
+ # ---------------------------------------------------------------------------
226
+
227
+
228
+ def create_session_with_split(
229
+ session_name: str,
230
+ main_command: str,
231
+ companion_command: str,
232
+ companion_height: int = 3,
233
+ ) -> None:
234
+ """Create a new tmux session with a vertical split.
235
+
236
+ The main command runs in the top pane; the companion runs in the bottom.
237
+ This function replaces the current process with ``tmux attach``.
238
+
239
+ Args:
240
+ session_name: Name for the tmux session.
241
+ main_command: Shell command to run in the top pane.
242
+ companion_command: Shell command to run in the bottom pane.
243
+ companion_height: Height in lines for the bottom pane.
244
+ """
245
+ # Kill any stale session with the same name before creating a new one.
246
+ subprocess.run(
247
+ ["tmux", "kill-session", "-t", session_name],
248
+ capture_output=True,
249
+ )
250
+ subprocess.run(
251
+ [
252
+ "tmux",
253
+ "new-session",
254
+ "-d",
255
+ "-s",
256
+ session_name,
257
+ "-x",
258
+ "200",
259
+ "-y",
260
+ "50",
261
+ main_command,
262
+ ],
263
+ check=True,
264
+ )
265
+ # Grab the top pane's ID before splitting (works regardless of base-index).
266
+ top_pane = (
267
+ subprocess.run(
268
+ ["tmux", "list-panes", "-t", f"{session_name}:", "-F", "#{pane_id}"],
269
+ capture_output=True,
270
+ text=True,
271
+ check=True,
272
+ )
273
+ .stdout.strip()
274
+ .splitlines()[0]
275
+ )
276
+ subprocess.run(
277
+ [
278
+ "tmux",
279
+ "split-window",
280
+ "-t",
281
+ f"{session_name}:",
282
+ "-v",
283
+ "-l",
284
+ str(companion_height),
285
+ companion_command,
286
+ ],
287
+ check=True,
288
+ )
289
+ # Select the top pane so the user interacts with the agent.
290
+ subprocess.run(["tmux", "select-pane", "-t", top_pane], check=True)
291
+ # User-provided command is already validated by the CLI layer.
292
+ os.execvp("tmux", ["tmux", "attach-session", "-t", session_name]) # noqa: S606
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: tui-input
3
+ Version: 0.1.0
4
+ Summary: A terminal companion that pins a persistent input bar at the bottom of your tmux pane, so you can scroll freely through long TUI agent output while composing your response.
5
+ Project-URL: Homepage, https://github.com/maked-dev/tui-input
6
+ Project-URL: Repository, https://github.com/maked-dev/tui-input
7
+ Project-URL: Issues, https://github.com/maked-dev/tui-input/issues
8
+ Project-URL: Changelog, https://github.com/maked-dev/tui-input/blob/main/CHANGELOG.md
9
+ Author: maked-dev
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,claude,codex,input,terminal,tmux,tui
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Terminals
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: textual>=0.85.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # tui-input
28
+
29
+ [![CI](https://github.com/maked-dev/tui-input/actions/workflows/ci.yml/badge.svg)](https://github.com/maked-dev/tui-input/actions/workflows/ci.yml)
30
+ [![PyPI](https://img.shields.io/pypi/v/tui-input)](https://pypi.org/project/tui-input/)
31
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
32
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
33
+
34
+ A terminal companion that pins a persistent input bar at the bottom of your tmux pane. Scroll freely through long TUI agent output while composing your response.
35
+
36
+ <!-- TODO: demo GIF -->
37
+ <!-- ![demo](https://github.com/maked-dev/tui-input/assets/demo.gif) -->
38
+
39
+ ## The Problem
40
+
41
+ When using TUI-based AI agents (Claude Code, Codex CLI, etc.), long outputs force you to scroll up to read, then scroll back down to type. You end up:
42
+
43
+ - Losing your place in the output
44
+ - Writing responses in a separate editor, then copy-pasting
45
+ - Fighting the terminal scroll position
46
+
47
+ ## The Solution
48
+
49
+ `tui-input` splits your terminal into two panes:
50
+
51
+ ```
52
+ ┌─────────────────────────────────────┐
53
+ │ Top pane (Claude Code / Codex etc) │
54
+ │ Scroll freely through long output │
55
+ ├─────────────────────────────────────┤ ← 3-10 lines, auto-resizing
56
+ │ Type your response here... │
57
+ │ Enter to send · Shift+Enter: newline│
58
+ └─────────────────────────────────────┘
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - **Pinned input bar** — always visible at the bottom, regardless of scroll position
64
+ - **Auto-resize** — companion grows from 3 to 10 lines as you type multi-line input
65
+ - **Bracketed paste** — safe multi-line text transfer via tmux buffers
66
+ - **Input history** — press `↑`/`↓` to recall previous inputs (persisted across sessions)
67
+ - **Agent-agnostic** — works with any terminal program, not just AI agents
68
+ - **Auto-cleanup** — companion exits when the agent exits
69
+
70
+ ## Installation
71
+
72
+ ### Requirements
73
+
74
+ - Python 3.10+
75
+ - tmux
76
+
77
+ ### Install via pip/uv
78
+
79
+ ```bash
80
+ # Using uv (recommended)
81
+ uv tool install tui-input
82
+
83
+ # Using pip
84
+ pip install tui-input
85
+ ```
86
+
87
+ ### One-liner (auto-detects agents + registers aliases)
88
+
89
+ ```bash
90
+ curl -fsSL https://raw.githubusercontent.com/maked-dev/tui-input/main/install.sh | bash
91
+ ```
92
+
93
+ This will:
94
+ 1. Install `tui-input`
95
+ 2. Detect installed agents (claude, codex, etc.)
96
+ 3. Register shell aliases so you can just type `claude` instead of `tui-input claude`
97
+
98
+ To uninstall aliases:
99
+
100
+ ```bash
101
+ curl -fsSL https://raw.githubusercontent.com/maked-dev/tui-input/main/install.sh | bash -s -- --uninstall
102
+ ```
103
+
104
+ ## Usage
105
+
106
+ ```bash
107
+ # Basic usage
108
+ tui-input claude
109
+
110
+ # Any command works
111
+ tui-input "vim file.py"
112
+ tui-input htop
113
+
114
+ # With aliases installed (via install.sh), just type the agent name
115
+ claude
116
+ codex
117
+ ```
118
+
119
+ ### Key Bindings
120
+
121
+ | Key | Action |
122
+ |-----|--------|
123
+ | `Enter` | Send text to top pane + clear input |
124
+ | `Shift+Enter` | Insert newline (multi-line input) |
125
+ | `↑` (on empty) | Previous history entry |
126
+ | `↓` (on empty) | Next history entry |
127
+ | `Esc` | Clear all input |
128
+
129
+ ### How It Works
130
+
131
+ 1. `tui-input` creates a tmux vertical split (or a new session if not in tmux)
132
+ 2. Your command runs in the top pane
133
+ 3. A Textual-based companion widget runs in the bottom pane
134
+ 4. Press `Enter` to send text to the top pane via `tmux paste-buffer`
135
+ 5. When the top pane command exits, the companion auto-exits
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ # Clone and install
141
+ git clone https://github.com/maked-dev/tui-input.git
142
+ cd tui-input
143
+ uv sync
144
+
145
+ # Run tests
146
+ uv run pytest
147
+
148
+ # Lint and format
149
+ uv run ruff check src/ tests/
150
+ uv run ruff format --check src/ tests/
151
+
152
+ # Type check
153
+ uv run mypy src/
154
+ ```
155
+
156
+ ## License
157
+
158
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ tui_input/__init__.py,sha256=4feAoWGQdg5Ldfkg-mqAKQO1E1fjaqxlPOCbyxUmKn8,144
2
+ tui_input/__main__.py,sha256=g66YcZUXfLq-BovEvSAd7P81gVKi5mGaI5ct_aax0ls,86
3
+ tui_input/cli.py,sha256=6cjr85hC_USYnWQefMwLT6D9-6H5F9TYc-uKf6nRwkQ,3218
4
+ tui_input/companion.py,sha256=xsroSBTh6a-wpM44vIJblqlZO0fXnwLtEAqX2a0zgcc,14682
5
+ tui_input/companion.tcss,sha256=o5tTz9PmJJfD4k786XNg1-R-VaC2IYJhaWMCdBSwDiM,244
6
+ tui_input/history.py,sha256=Xov1jNT3xpIwQQb0CMaUdFkU_P9HmTKmeho4E8sSvvc,3967
7
+ tui_input/launcher.py,sha256=I6XrLK4dlOr-KZvPCqdYbCjP-mR2uUpmgZh-FIoRQXs,2785
8
+ tui_input/tmux.py,sha256=4nvq7UYHN_D8GlSYtVtfn5nXdIjGY-SN_ep9O3gCdKs,9392
9
+ tui_input-0.1.0.dist-info/METADATA,sha256=hXQEfx6pamO7S77MFk2tp1Oqw8WHERjv4pyaFNvGHps,5068
10
+ tui_input-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ tui_input-0.1.0.dist-info/entry_points.txt,sha256=NfsqbnOT4ao_OwS3tLvWlzlUyPSnpbcNIVpZ3QkwFK0,49
12
+ tui_input-0.1.0.dist-info/licenses/LICENSE,sha256=rU0e79MlpR2z_i7EfnEx99doSDBVVaOsq41urbcT2gI,1066
13
+ tui_input-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tui-input = tui_input.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maked-dev
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.