workmap 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.
- workmap/__init__.py +30 -0
- workmap/__main__.py +15 -0
- workmap/actions.py +350 -0
- workmap/audit.py +153 -0
- workmap/cli.py +789 -0
- workmap/config.py +589 -0
- workmap/demo.py +94 -0
- workmap/drivers/__init__.py +93 -0
- workmap/drivers/apple_terminal.py +578 -0
- workmap/layout.py +64 -0
- workmap/model.py +719 -0
- workmap/multiplexer.py +201 -0
- workmap/procs.py +504 -0
- workmap/scan.py +413 -0
- workmap/setup.py +400 -0
- workmap/shell.py +117 -0
- workmap/terminal.py +50 -0
- workmap/themes.py +45 -0
- workmap/tui/__init__.py +6 -0
- workmap/tui/app.py +1090 -0
- workmap/tui/onboarding.py +266 -0
- workmap/tui/text.py +156 -0
- workmap/tui/widgets.py +189 -0
- workmap-0.1.0.dist-info/METADATA +258 -0
- workmap-0.1.0.dist-info/RECORD +28 -0
- workmap-0.1.0.dist-info/WHEEL +4 -0
- workmap-0.1.0.dist-info/entry_points.txt +2 -0
- workmap-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""How setup looks.
|
|
2
|
+
|
|
3
|
+
Setup is the first thing anybody sees, and it is the only screen that has to
|
|
4
|
+
sell the tool before the tool has done anything. So it uses the same vocabulary
|
|
5
|
+
as the desk, rounded panels, the hairline rail, one restrained palette, and
|
|
6
|
+
not a second style invented for the occasion.
|
|
7
|
+
|
|
8
|
+
Presentation only. Every decision setup makes lives in `workmap/setup.py`;
|
|
9
|
+
nothing here knows what a root or an agent is, which is why it can be rendered
|
|
10
|
+
into a string and asserted on.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import itertools
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from .text import (AMBER, BOLD, DIM, RESET, cell, fg, strip_ansi)
|
|
21
|
+
from .widgets import RAIL_ON, box_bot, box_row, box_top, frame_width
|
|
22
|
+
|
|
23
|
+
# Progress glyphs. Filled / current / not yet, the same "state at a glance"
|
|
24
|
+
# idea as the project rail, so the two screens read as one product.
|
|
25
|
+
DONE, CURRENT, TODO = "●", "◆", "○"
|
|
26
|
+
CHECK, CROSS = "✓", "·"
|
|
27
|
+
|
|
28
|
+
ACCENT = 111 # the blue the panels lean on
|
|
29
|
+
MUTED = 244
|
|
30
|
+
|
|
31
|
+
SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
32
|
+
FRAME_S = 0.08
|
|
33
|
+
REVEAL_S = 0.035 # per-row stagger; small enough to feel like one motion
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def colour() -> bool:
|
|
37
|
+
"""Style output? Not when piped, and not when asked not to.
|
|
38
|
+
|
|
39
|
+
NO_COLOR is honoured because a setup screen is exactly the kind of thing
|
|
40
|
+
people capture into an issue or a terminal recording.
|
|
41
|
+
"""
|
|
42
|
+
if os.environ.get("NO_COLOR"):
|
|
43
|
+
return False
|
|
44
|
+
return sys.stdout.isatty()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def animate() -> bool:
|
|
48
|
+
"""Motion is for people watching. Never for pipes, CI, or a recording."""
|
|
49
|
+
return colour() and not os.environ.get("WORKMAP_NO_ANIM")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _style(s: str, *codes: str) -> str:
|
|
53
|
+
return "".join(codes) + s + RESET if colour() else s
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def plain(line: str) -> str:
|
|
57
|
+
"""Drop styling when nobody can see it.
|
|
58
|
+
|
|
59
|
+
The panel helpers in widgets.py always colour their borders, they were
|
|
60
|
+
written for a TUI, which is only ever on a terminal. Setup is not: people
|
|
61
|
+
pipe it, capture it into issues, and run it in CI, and escape codes in a
|
|
62
|
+
pasted transcript are noise.
|
|
63
|
+
"""
|
|
64
|
+
return line if colour() else strip_ansi(line)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def say(line: str = "") -> None:
|
|
68
|
+
"""One line, styled only if it will be seen."""
|
|
69
|
+
print(plain(line))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def width() -> int:
|
|
73
|
+
try:
|
|
74
|
+
cols = os.get_terminal_size().columns
|
|
75
|
+
except OSError:
|
|
76
|
+
cols = 80
|
|
77
|
+
# No cap of its own: `frame_width` already stops at FRAME_MAX. This used
|
|
78
|
+
# to read `min(cols, 100)`, which says a setup panel can be a hundred
|
|
79
|
+
# columns wide. It cannot, at any width, and a second ceiling written in
|
|
80
|
+
# a different number is a ceiling somebody will one day raise.
|
|
81
|
+
return frame_width(max(46, cols))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rail(step: int, total: int) -> str:
|
|
85
|
+
"""● ● ◆ ○, where you are, without spending a line saying so."""
|
|
86
|
+
marks = []
|
|
87
|
+
for i in range(1, total + 1):
|
|
88
|
+
if i < step:
|
|
89
|
+
marks.append(_style(DONE, fg(ACCENT)))
|
|
90
|
+
elif i == step:
|
|
91
|
+
marks.append(_style(CURRENT, fg(ACCENT), BOLD))
|
|
92
|
+
else:
|
|
93
|
+
marks.append(_style(TODO, DIM))
|
|
94
|
+
return " ".join(marks)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def panel(title: str, body: list[str], *, step: int, total: int) -> list[str]:
|
|
98
|
+
"""One step, framed. `body` lines may carry ANSI; they are padded safely."""
|
|
99
|
+
inner = width() - 4
|
|
100
|
+
head = _style("workmap setup", BOLD)
|
|
101
|
+
lines = [box_top(inner, title=head, tail=rail(step, total)), box_row(inner)]
|
|
102
|
+
lines.append(box_row(inner, " " + _style(title, BOLD)))
|
|
103
|
+
lines.append(box_row(inner))
|
|
104
|
+
for line in body:
|
|
105
|
+
lines.append(box_row(inner, " " + line if line else ""))
|
|
106
|
+
lines.append(box_row(inner))
|
|
107
|
+
lines.append(box_bot(inner))
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render(lines: list[str], *, stagger: bool = True) -> None:
|
|
112
|
+
"""Print a panel, one row at a time when someone is watching."""
|
|
113
|
+
delay = REVEAL_S if (stagger and animate()) else 0
|
|
114
|
+
for line in lines:
|
|
115
|
+
sys.stdout.write(plain(line) + "\n")
|
|
116
|
+
if delay:
|
|
117
|
+
sys.stdout.flush()
|
|
118
|
+
time.sleep(delay)
|
|
119
|
+
if not delay:
|
|
120
|
+
sys.stdout.flush()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def found(label: str, detail: str = "", *, ok: bool = True) -> str:
|
|
124
|
+
"""A checklist row: mark, name, and a dim detail column."""
|
|
125
|
+
mark = _style(CHECK, fg(ACCENT)) if ok else _style(CROSS, DIM)
|
|
126
|
+
name = cell(label, 12)
|
|
127
|
+
name = _style(name, BOLD) if ok else _style(name, DIM)
|
|
128
|
+
return f"{mark} {name}{_style(detail, DIM)}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def listing(text: str, *, ok: bool = False) -> str:
|
|
132
|
+
"""A checklist row whose label is prose, not a name to line up."""
|
|
133
|
+
mark = _style(CHECK, fg(ACCENT)) if ok else _style(CROSS, DIM)
|
|
134
|
+
return f"{mark} {_style(text, DIM)}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def path_label(path) -> str:
|
|
138
|
+
"""`~/dev/Company` rather than the whole thing.
|
|
139
|
+
|
|
140
|
+
Panels are a fixed measure and paths are the longest thing in them, so
|
|
141
|
+
the part that identifies the directory is the part worth keeping.
|
|
142
|
+
"""
|
|
143
|
+
text = str(path)
|
|
144
|
+
home = str(os.path.expanduser("~"))
|
|
145
|
+
if text == home:
|
|
146
|
+
return "~"
|
|
147
|
+
if text.startswith(home + os.sep):
|
|
148
|
+
return "~" + text[len(home):]
|
|
149
|
+
return text
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def command(cmd: str) -> str:
|
|
153
|
+
"""Something you can literally type, on its own line.
|
|
154
|
+
|
|
155
|
+
Paired with command_note() underneath rather than a description beside
|
|
156
|
+
it: `work acme-api` plus a sentence does not fit across a panel, and a
|
|
157
|
+
command that wraps is a command you cannot copy.
|
|
158
|
+
"""
|
|
159
|
+
return f"{_style(RAIL_ON, fg(ACCENT))} {_style(cmd, BOLD)}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def command_note(what: str) -> str:
|
|
163
|
+
"""What the command above does."""
|
|
164
|
+
return f" {_style(what, DIM)}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def note(text: str) -> str:
|
|
168
|
+
return _style(text, DIM)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def warn(text: str) -> str:
|
|
172
|
+
return _style(text, AMBER)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def bullet(text: str) -> str:
|
|
176
|
+
return f"{_style(RAIL_ON, fg(ACCENT))} {text}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def spin(label: str, work):
|
|
180
|
+
"""Run `work()` behind a spinner. The spinner tracks real work only.
|
|
181
|
+
|
|
182
|
+
If the work finishes instantly the spinner is never drawn, because a
|
|
183
|
+
loader for something that did not take time is a small lie.
|
|
184
|
+
"""
|
|
185
|
+
if not animate():
|
|
186
|
+
result = work()
|
|
187
|
+
say(" " + listing(label, ok=True))
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
box: dict = {}
|
|
191
|
+
|
|
192
|
+
def run():
|
|
193
|
+
try:
|
|
194
|
+
box["value"] = work()
|
|
195
|
+
except BaseException as exc: # surfaced by the caller
|
|
196
|
+
box["error"] = exc
|
|
197
|
+
|
|
198
|
+
thread = threading.Thread(target=run, daemon=True)
|
|
199
|
+
thread.start()
|
|
200
|
+
for frame in itertools.cycle(SPINNER):
|
|
201
|
+
thread.join(timeout=FRAME_S)
|
|
202
|
+
if not thread.is_alive():
|
|
203
|
+
break
|
|
204
|
+
sys.stdout.write(f"\r {_style(frame, fg(ACCENT))} {note(label)}")
|
|
205
|
+
sys.stdout.flush()
|
|
206
|
+
sys.stdout.write("\r\033[2K")
|
|
207
|
+
if "error" in box:
|
|
208
|
+
raise box["error"]
|
|
209
|
+
return box.get("value")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ask(question: str, *, enter_does: str, default: str = "") -> str:
|
|
213
|
+
"""A question, and what the Enter key will do about it.
|
|
214
|
+
|
|
215
|
+
`enter_does` is not optional on purpose. The first version of this screen
|
|
216
|
+
rendered "change them? colon-separated paths [keep]" and the first person
|
|
217
|
+
to use it did not know that pressing Enter would move on, a prompt that
|
|
218
|
+
does not name its own default is a prompt you have to guess at.
|
|
219
|
+
"""
|
|
220
|
+
arrow = _style("›", fg(ACCENT))
|
|
221
|
+
hint = _style(f"(Enter = {enter_does})", DIM)
|
|
222
|
+
try:
|
|
223
|
+
answer = input(f" {arrow} {question} {hint} ").strip()
|
|
224
|
+
except (EOFError, KeyboardInterrupt):
|
|
225
|
+
print()
|
|
226
|
+
return default
|
|
227
|
+
return answer or default
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def title_card(steps: list[str]) -> list[str]:
|
|
231
|
+
"""What is about to happen, and how to get through it.
|
|
232
|
+
|
|
233
|
+
Listing the steps up front costs four lines and buys the thing every
|
|
234
|
+
setup screen is missing: knowing how long this is and what it wants.
|
|
235
|
+
"""
|
|
236
|
+
inner = width() - 4
|
|
237
|
+
out = [
|
|
238
|
+
box_top(inner, title=_style("workmap", BOLD)),
|
|
239
|
+
box_row(inner),
|
|
240
|
+
box_row(inner, " " + _style("Let's get you set up.", BOLD)),
|
|
241
|
+
box_row(inner),
|
|
242
|
+
]
|
|
243
|
+
for i, step in enumerate(steps, 1):
|
|
244
|
+
out.append(box_row(inner, f" {_style(str(i), fg(ACCENT), BOLD)} {step}"))
|
|
245
|
+
out.append(box_row(inner))
|
|
246
|
+
out.append(box_row(inner, " " + _style(
|
|
247
|
+
"Each question suggests an answer in grey.", DIM)))
|
|
248
|
+
out.append(box_row(inner, " " + _style(
|
|
249
|
+
"Press Enter to take it, or type your own.", DIM)))
|
|
250
|
+
out.append(box_row(inner))
|
|
251
|
+
out.append(box_bot(inner))
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def done_card(lines: list[str], title: str = "you're all set") -> list[str]:
|
|
256
|
+
"""The last screen. `title` because a run where a step was skipped is not
|
|
257
|
+
one where you are all set, and saying so anyway is the kind of small lie
|
|
258
|
+
that makes the rest of the screen worth less."""
|
|
259
|
+
inner = width() - 4
|
|
260
|
+
head = (fg(ACCENT) + BOLD + title + RESET if colour() else title)
|
|
261
|
+
out = [box_top(inner, title=head), box_row(inner)]
|
|
262
|
+
for line in lines:
|
|
263
|
+
out.append(box_row(inner, " " + line if line else ""))
|
|
264
|
+
out.append(box_row(inner))
|
|
265
|
+
out.append(box_bot(inner))
|
|
266
|
+
return out
|
workmap/tui/text.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""ANSI styling and the display-width model.
|
|
2
|
+
|
|
3
|
+
Terminal titles carry CJK and emoji, so every pad/truncate measures display
|
|
4
|
+
columns rather than len(). Pure string maths, no state, no I/O."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
RESET = "\033[0m"
|
|
12
|
+
|
|
13
|
+
DIM = "\033[2m"
|
|
14
|
+
|
|
15
|
+
BOLD = "\033[1m"
|
|
16
|
+
|
|
17
|
+
REV = "\033[7m"
|
|
18
|
+
|
|
19
|
+
HIDE = "\033[?25l"
|
|
20
|
+
|
|
21
|
+
SHOW = "\033[?25h"
|
|
22
|
+
|
|
23
|
+
ALT_ON = "\033[?1049h"
|
|
24
|
+
|
|
25
|
+
ALT_OFF = "\033[?1049l"
|
|
26
|
+
|
|
27
|
+
HOME = "\033[H"
|
|
28
|
+
|
|
29
|
+
CLEAR_LINE = "\033[2K"
|
|
30
|
+
|
|
31
|
+
# Restrained palette: borders recede, chips pop, amber = needs attention.
|
|
32
|
+
BORDER = "\033[38;5;240m"
|
|
33
|
+
|
|
34
|
+
CHIP_BG = "\033[48;5;238m"
|
|
35
|
+
|
|
36
|
+
CHIP_TX = "\033[38;5;231m"
|
|
37
|
+
|
|
38
|
+
AMBER = "\033[38;5;208m" # real warnings only: swap pressure, confirmations
|
|
39
|
+
|
|
40
|
+
TAG = "\033[38;5;137m" # muted marker for the inline "orphaned" tag
|
|
41
|
+
|
|
42
|
+
GOOD = "\033[38;5;114m"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fg(n: int) -> str:
|
|
46
|
+
return f"\033[38;5;{n}m"
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------- width model
|
|
49
|
+
# Every pad/truncate goes through display columns, not len(). Terminal titles
|
|
50
|
+
# carry CJK and emoji; len() would silently skew whole columns.
|
|
51
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;?]*[ -/]*[@-~]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _char_w(ch: str) -> int:
|
|
55
|
+
if unicodedata.combining(ch):
|
|
56
|
+
return 0
|
|
57
|
+
o = ord(ch)
|
|
58
|
+
if o < 32 or o == 0x7F:
|
|
59
|
+
return 0
|
|
60
|
+
if unicodedata.east_asian_width(ch) in ("W", "F"):
|
|
61
|
+
return 2
|
|
62
|
+
if 0x1F300 <= o <= 0x1FAFF or 0x1F004 <= o <= 0x1F0CF:
|
|
63
|
+
return 2
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def disp_width(s: str) -> int:
|
|
68
|
+
"""Display columns of PLAIN text."""
|
|
69
|
+
return sum(_char_w(c) for c in s)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def strip_ansi(s: str) -> str:
|
|
73
|
+
return _ANSI_RE.sub("", s)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def visible_len(s: str) -> int:
|
|
77
|
+
"""Display columns of possibly-styled text."""
|
|
78
|
+
return disp_width(strip_ansi(s))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cell(text: str, width: int, *, align: str = "left") -> str:
|
|
82
|
+
"""Exact-width PLAIN cell. Truncate/pad before any ANSI is applied.
|
|
83
|
+
|
|
84
|
+
Control characters are dropped rather than measured. They are zero columns
|
|
85
|
+
wide, so the arithmetic here was always right, but a cell is composed into
|
|
86
|
+
a screen the caller has already laid out and an escape character carries no
|
|
87
|
+
width while still moving the cursor. Project names come from directory
|
|
88
|
+
names and session labels from window titles, so this is the last point
|
|
89
|
+
where either can still be text rather than instructions.
|
|
90
|
+
|
|
91
|
+
A combining mark at the front is dropped for the same reason. It has
|
|
92
|
+
nothing inside the cell to attach to, so the terminal draws it over
|
|
93
|
+
whatever occupies the column to the left: the rail glyph, or the last
|
|
94
|
+
character of the cell before. One cell putting a mark in another one is
|
|
95
|
+
the same class of problem as an escape moving the cursor.
|
|
96
|
+
"""
|
|
97
|
+
text = "".join(" " if ch in "\t\n\r" else ch
|
|
98
|
+
for ch in text if (ch >= " " and ch != "\x7f") or ch in "\t\n\r")
|
|
99
|
+
lead = 0
|
|
100
|
+
while lead < len(text) and unicodedata.combining(text[lead]):
|
|
101
|
+
lead += 1
|
|
102
|
+
text = text[lead:]
|
|
103
|
+
if width <= 0:
|
|
104
|
+
return ""
|
|
105
|
+
w = disp_width(text)
|
|
106
|
+
if w > width:
|
|
107
|
+
out = ""
|
|
108
|
+
used = 0
|
|
109
|
+
for ch in text:
|
|
110
|
+
cw = _char_w(ch)
|
|
111
|
+
if used + cw > width - 1:
|
|
112
|
+
break
|
|
113
|
+
out += ch
|
|
114
|
+
used += cw
|
|
115
|
+
return out + "…" + " " * (width - 1 - used)
|
|
116
|
+
return (" " * (width - w)) + text if align == "right" else text + (" " * (width - w))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pad_ansi(s: str, width: int) -> str:
|
|
120
|
+
"""Pad/truncate styled text to exact display columns (ANSI-safe)."""
|
|
121
|
+
w = visible_len(s)
|
|
122
|
+
if w <= width:
|
|
123
|
+
return s + " " * (width - w)
|
|
124
|
+
out: list[str] = []
|
|
125
|
+
used = 0
|
|
126
|
+
i = 0
|
|
127
|
+
while i < len(s):
|
|
128
|
+
if s[i] == "\033":
|
|
129
|
+
m = _ANSI_RE.match(s, i)
|
|
130
|
+
if m:
|
|
131
|
+
out.append(m.group())
|
|
132
|
+
i = m.end()
|
|
133
|
+
continue
|
|
134
|
+
cw = _char_w(s[i])
|
|
135
|
+
if used + cw > max(0, width - 1):
|
|
136
|
+
break
|
|
137
|
+
out.append(s[i])
|
|
138
|
+
used += cw
|
|
139
|
+
i += 1
|
|
140
|
+
return "".join(out) + "…" + RESET + " " * max(0, width - used - 1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def wrap_plain(text: str, width: int) -> list[str]:
|
|
144
|
+
words = text.split()
|
|
145
|
+
lines: list[str] = []
|
|
146
|
+
cur = ""
|
|
147
|
+
for w in words:
|
|
148
|
+
trial = f"{cur} {w}".strip()
|
|
149
|
+
if disp_width(trial) <= width or not cur:
|
|
150
|
+
cur = trial
|
|
151
|
+
else:
|
|
152
|
+
lines.append(cur)
|
|
153
|
+
cur = w
|
|
154
|
+
if cur:
|
|
155
|
+
lines.append(cur)
|
|
156
|
+
return lines or [""]
|
workmap/tui/widgets.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The visual vocabulary: rounded panels, key chips, and table rows.
|
|
2
|
+
|
|
3
|
+
Every function returns a string of an exact display width, so the caller can
|
|
4
|
+
compose without re-measuring."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .text import (AMBER, BOLD, CHIP_BG, CHIP_TX, DIM, RESET, REV, BORDER,
|
|
8
|
+
TAG, cell, disp_width, pad_ansi,
|
|
9
|
+
strip_ansi, visible_len)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ------------------------------------------------------------------- geometry
|
|
13
|
+
# One measure for the whole UI: header, list and command panels share edges.
|
|
14
|
+
FRAME_MIN = 44
|
|
15
|
+
|
|
16
|
+
FRAME_MAX = 66
|
|
17
|
+
|
|
18
|
+
RAIL_W = 1
|
|
19
|
+
|
|
20
|
+
PROJ_W = 14
|
|
21
|
+
|
|
22
|
+
THEME_W = 14
|
|
23
|
+
|
|
24
|
+
RAM_W = 7
|
|
25
|
+
|
|
26
|
+
META_GAP = 2 # THEME → RAM
|
|
27
|
+
|
|
28
|
+
TABLE_MIN = RAIL_W + PROJ_W + 1 + THEME_W + META_GAP + RAM_W # 39
|
|
29
|
+
|
|
30
|
+
CHIP_W = 5 # widest chip is " Esc "
|
|
31
|
+
|
|
32
|
+
RAIL_OFF = "▏" # hairline rail, distinct from the panel's own │ border
|
|
33
|
+
|
|
34
|
+
RAIL_ON = "▊" # selected project's rail lights up
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def frame_width(cols: int) -> int:
|
|
38
|
+
"""Total width of the boxed UI, borders included."""
|
|
39
|
+
return max(FRAME_MIN, min(cols - 2, FRAME_MAX))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def boxed(cols: int) -> bool:
|
|
43
|
+
return cols >= FRAME_MIN + 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def inner_width(cols: int) -> int:
|
|
47
|
+
"""Content measure. Same column engine boxed or not, only borders differ."""
|
|
48
|
+
return frame_width(cols) - 4 if boxed(cols) else max(TABLE_MIN, cols - 1)
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------- boxes
|
|
51
|
+
TL, TR, BL, BR, HBAR, VBAR = "╭", "╮", "╰", "╯", "─", "│"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _edge(inner: int, left: str, right: str, title: str = "", tail: str = "") -> str:
|
|
55
|
+
"""Rounded rule with optional inline title (left) and tail (right)."""
|
|
56
|
+
span = inner + 2
|
|
57
|
+
t = f" {title} " if title else ""
|
|
58
|
+
e = f" {tail} " if tail else ""
|
|
59
|
+
fill = span - 2 - visible_len(t) - visible_len(e)
|
|
60
|
+
if fill < 1 and e:
|
|
61
|
+
e = ""
|
|
62
|
+
fill = span - 2 - visible_len(t)
|
|
63
|
+
if fill < 1 and t:
|
|
64
|
+
t = cell(strip_ansi(title), max(1, span - 4))
|
|
65
|
+
t = f" {t} "
|
|
66
|
+
fill = max(0, span - 2 - visible_len(t))
|
|
67
|
+
bar = BORDER + HBAR + RESET
|
|
68
|
+
return (
|
|
69
|
+
f"{BORDER}{left}{RESET}{bar}{t}{BORDER}{HBAR * fill}{RESET}"
|
|
70
|
+
f"{e}{bar}{BORDER}{right}{RESET}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def box_top(inner: int, title: str = "", tail: str = "") -> str:
|
|
75
|
+
return _edge(inner, TL, TR, title, tail)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def box_bot(inner: int, title: str = "", tail: str = "") -> str:
|
|
79
|
+
return _edge(inner, BL, BR, title, tail)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def box_row(inner: int, content: str = "") -> str:
|
|
83
|
+
v = f"{BORDER}{VBAR}{RESET}"
|
|
84
|
+
return f"{v} {pad_ansi(content, inner)} {v}"
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------- command panels
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def chip(key: str, width: int = CHIP_W) -> str:
|
|
90
|
+
"""Key glyph on a solid chip so the eye lands on keys before labels."""
|
|
91
|
+
body = f" {key} "
|
|
92
|
+
pad = max(0, width - disp_width(body))
|
|
93
|
+
return f"{CHIP_BG}{CHIP_TX}{BOLD}{body}{RESET}" + " " * pad
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_cell(key: str, label: str, width: int) -> str:
|
|
97
|
+
# Labels may carry ANSI (dim counts), measure and pad in display columns.
|
|
98
|
+
return chip(key) + pad_ansi(label, max(0, width - CHIP_W))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def longest_label(items: list[tuple], short: bool = False) -> int:
|
|
102
|
+
idx = 2 if short else 1
|
|
103
|
+
return max(visible_len(str(i[idx])) for i in items) if items else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def panel(width: int, title: str, items: list[tuple], *, columns: int = 1,
|
|
107
|
+
short: bool = False, min_rows: int = 0) -> list[str]:
|
|
108
|
+
"""A titled command panel exactly `width` display columns wide.
|
|
109
|
+
|
|
110
|
+
min_rows pads with empty rows so side-by-side panels close on the same
|
|
111
|
+
line, uneven bottom edges read as a layout bug, not as design.
|
|
112
|
+
"""
|
|
113
|
+
inner = width - 4
|
|
114
|
+
idx = 2 if short else 1
|
|
115
|
+
lines = [box_top(inner, title=title)]
|
|
116
|
+
per = (inner - 2 * (columns - 1)) // columns
|
|
117
|
+
for i in range(0, len(items), columns):
|
|
118
|
+
chunk = items[i : i + columns]
|
|
119
|
+
cells = [cmd_cell(it[0], str(it[idx]), per) for it in chunk]
|
|
120
|
+
while len(cells) < columns:
|
|
121
|
+
cells.append(" " * per)
|
|
122
|
+
lines.append(box_row(inner, " ".join(cells)))
|
|
123
|
+
while len(lines) - 1 < min_rows:
|
|
124
|
+
lines.append(box_row(inner, ""))
|
|
125
|
+
lines.append(box_bot(inner))
|
|
126
|
+
return lines
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def flat_panel(width: int, title: str, items: list[tuple], *,
|
|
130
|
+
short: bool = False) -> list[str]:
|
|
131
|
+
"""Borderless panel for terminals too narrow to spend 2 columns on chrome."""
|
|
132
|
+
idx = 2 if short else 1
|
|
133
|
+
return [f" {title}"] + [
|
|
134
|
+
" " + cmd_cell(it[0], str(it[idx]), width - 1) for it in items
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def fit_strip(bits: list[tuple[str, str]], width: int) -> str:
|
|
139
|
+
"""Chip strip that sheds commands rather than getting cut mid-label.
|
|
140
|
+
|
|
141
|
+
Sheds from the back but keeps the last pair pinned, the tail is the way
|
|
142
|
+
out (`q quit`), and a strip that can't tell you how to leave is a trap.
|
|
143
|
+
"""
|
|
144
|
+
items = list(bits)
|
|
145
|
+
|
|
146
|
+
def render(seq):
|
|
147
|
+
return " ".join(f"{chip(k, disp_width(k) + 2)} {lab}" for k, lab in seq)
|
|
148
|
+
|
|
149
|
+
while len(items) > 1:
|
|
150
|
+
if visible_len(render(items)) <= width:
|
|
151
|
+
return render(items)
|
|
152
|
+
items.pop(-2)
|
|
153
|
+
return render(items) if items and visible_len(render(items)) <= width else ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def side_by_side(left: list[str], lw: int, right: list[str], rw: int) -> list[str]:
|
|
157
|
+
"""Two panels on one row; short one is padded so both bottoms line up."""
|
|
158
|
+
n = max(len(left), len(right))
|
|
159
|
+
out = []
|
|
160
|
+
for i in range(n):
|
|
161
|
+
a = left[i] if i < len(left) else " " * lw
|
|
162
|
+
b = right[i] if i < len(right) else " " * rw
|
|
163
|
+
out.append(f"{pad_ansi(a, lw)} {pad_ansi(b, rw)}")
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ list rows
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def project_row(name: str, theme: str, ram: str, inner: int, *,
|
|
170
|
+
rail: str, selected: bool) -> str:
|
|
171
|
+
"""rail | PROJECT | THEME … RAM, identity clustered left, size anchored right."""
|
|
172
|
+
gutter = max(META_GAP, inner - TABLE_MIN + META_GAP)
|
|
173
|
+
p = cell(name, PROJ_W)
|
|
174
|
+
t = cell(theme, THEME_W)
|
|
175
|
+
r = cell(ram, RAM_W, align="right")
|
|
176
|
+
p_out = f"{REV}{p}{RESET}" if selected else p
|
|
177
|
+
return f"{rail}{p_out} {DIM}{t}{RESET}{' ' * gutter}{BOLD}{r}{RESET}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def session_row(rail: str, label: str, extra: str, ram: str, inner: int) -> str:
|
|
181
|
+
"""Session lines get the full measure, long agent titles stay readable."""
|
|
182
|
+
label_w = inner - RAIL_W - META_GAP - RAM_W
|
|
183
|
+
tail = f" · {extra}" if extra else ""
|
|
184
|
+
# Truncate the label, never the "· orphaned" tag it's qualified by.
|
|
185
|
+
keep = max(1, min(disp_width(label), label_w - disp_width(tail)))
|
|
186
|
+
accent = TAG if extra == "orphaned" else DIM
|
|
187
|
+
body = f"{DIM}{cell(label, keep)}{RESET}{accent}{cell(tail, label_w - keep)}{RESET}"
|
|
188
|
+
r = cell(ram, RAM_W, align="right")
|
|
189
|
+
return f"{rail}{body}{' ' * META_GAP}{DIM}{r}{RESET}"
|