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 +5 -0
- tui_input/__main__.py +5 -0
- tui_input/cli.py +116 -0
- tui_input/companion.py +444 -0
- tui_input/companion.tcss +19 -0
- tui_input/history.py +124 -0
- tui_input/launcher.py +89 -0
- tui_input/tmux.py +292 -0
- tui_input-0.1.0.dist-info/METADATA +158 -0
- tui_input-0.1.0.dist-info/RECORD +13 -0
- tui_input-0.1.0.dist-info/WHEEL +4 -0
- tui_input-0.1.0.dist-info/entry_points.txt +2 -0
- tui_input-0.1.0.dist-info/licenses/LICENSE +21 -0
tui_input/__init__.py
ADDED
tui_input/__main__.py
ADDED
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()
|
tui_input/companion.tcss
ADDED
|
@@ -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
|
+
[](https://github.com/maked-dev/tui-input/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/tui-input/)
|
|
31
|
+
[](https://opensource.org/licenses/MIT)
|
|
32
|
+
[](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
|
+
<!--  -->
|
|
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,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.
|