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/tui/app.py ADDED
@@ -0,0 +1,1090 @@
1
+ """The Desk: screen state, key handling, and frame assembly."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import select
6
+ import sys
7
+ import termios
8
+ import tty
9
+
10
+ from .. import actions, procs, scan, terminal
11
+ from ..themes import PROFILE_ANSI
12
+ from ..model import Project, Session, fmt_mb, fmt_mem, held_note, plural
13
+ from ..config import SESSION_VISIBLE, roots_advice
14
+ from ..model import clean_label
15
+ from .text import (ALT_OFF, ALT_ON, AMBER, BOLD, BORDER, CLEAR_LINE, DIM, GOOD,
16
+ HIDE, HOME, RESET, REV, SHOW, TAG, cell, disp_width, fg,
17
+ pad_ansi, strip_ansi, visible_len, wrap_plain)
18
+ from .widgets import (CHIP_W, META_GAP, PROJ_W, RAIL_OFF, RAIL_ON, RAIL_W,
19
+ RAM_W, TABLE_MIN, THEME_W, box_bot, box_row, box_top,
20
+ boxed, chip, cmd_cell, fit_strip, flat_panel,
21
+ frame_width, inner_width, longest_label, panel,
22
+ project_row, session_row, side_by_side)
23
+
24
+
25
+ UNNAMED = "unsorted" # projects with no root of their own
26
+
27
+
28
+ def term_size() -> tuple[int, int]:
29
+ try:
30
+ sz = os.get_terminal_size()
31
+ return max(40, sz.columns), max(12, sz.lines)
32
+ except Exception:
33
+ return 80, 24
34
+
35
+
36
+ class Desk:
37
+ def __init__(self) -> None:
38
+ self.projects: list[Project] = []
39
+ self.mem: dict = {}
40
+ self.tracked = 0
41
+ self.bg_all = 0 # orphan count from the last refresh (render must not rescan)
42
+ self.profiles: list[str] = []
43
+ self.cursor = 0 # index into projects, or -1 if nothing selected
44
+ self.scroll = 0 # first body line index into full body buffer
45
+ self.expanded: set[str] = set()
46
+ self.mode = "list" # list | color
47
+ self.color_cursor = 0
48
+ self.message = ""
49
+ self.pending: str | None = None # confirm: kill_bg | stop | close_wins
50
+ self._armed_sessions: list[Session] = [] # exactly what `y` will kill
51
+ self.blocked = "" # why Terminal couldn't be read, if it couldn't
52
+ self.blocked_fix = "" # and what the reader can do about that one
53
+ self.advice = "" # why nothing can be an orphan, if nothing can
54
+ self.held = "" # why something running is not on the desk
55
+ self._fd = sys.stdin.fileno()
56
+ self._old = None
57
+
58
+ # ------------------------------------------------------------------ data
59
+ def refresh_data(self) -> None:
60
+ """Full desk scan. Call on start / r / after mutating actions, never on ↑↓."""
61
+ keep = self.current().name if self.current() else None
62
+ cleared = self.cursor < 0
63
+ # One build_projects() pass (snapshot() would scan again for dict export).
64
+ held: list = []
65
+ self.projects = [p for p in scan.build_projects(held=held)
66
+ if p.sessions]
67
+ # Immediately: every driver call writes the same status global, and
68
+ # list_profiles() below is cheaper than the desk query and can succeed
69
+ # where it failed. Reading afterwards erased the failure this field
70
+ # exists to report.
71
+ status = terminal.terminal_status()
72
+ self.mem = procs.mem_summary()
73
+ self.tracked = sum(p.mb for p in self.projects)
74
+ self.bg_all = sum(
75
+ 1 for p in self.projects for s in p.sessions if s.kind == "bg"
76
+ )
77
+ self.profiles = [p["name"] for p in terminal.list_profiles()]
78
+ # An empty desk and a wedged Terminal look identical otherwise. So do
79
+ # an empty desk and a desk with no roots configured, where nothing can
80
+ # ever be an orphan.
81
+ self.blocked = "" if status["ok"] else status["reason"]
82
+ self.blocked_fix = "" if status["ok"] else status.get("fix", "")
83
+ self.advice = terminal.unsupported_host() or roots_advice()
84
+ # Why the desk is shorter than the machine, if it is.
85
+ self.held = held_note(held)
86
+ if cleared:
87
+ self.cursor = -1
88
+ elif keep is not None:
89
+ for i, p in enumerate(self.projects):
90
+ if p.name == keep:
91
+ self.cursor = i
92
+ break
93
+ else:
94
+ self.cursor = 0 if self.projects else -1
95
+ elif not self.projects:
96
+ self.cursor = -1
97
+ elif self.cursor >= len(self.projects):
98
+ self.cursor = len(self.projects) - 1
99
+ elif self.cursor < 0:
100
+ self.cursor = 0
101
+
102
+ def current(self) -> Project | None:
103
+ if self.cursor < 0 or not self.projects:
104
+ return None
105
+ if self.cursor >= len(self.projects):
106
+ return None
107
+ return self.projects[self.cursor]
108
+
109
+ def rail(self, profile: str) -> int:
110
+ return PROFILE_ANSI.get(profile, 250)
111
+
112
+ @staticmethod
113
+ def label_of(p: Project) -> str:
114
+ return p.name or UNNAMED
115
+
116
+ # --------------------------------------------------------------- commands
117
+ def always_items(self) -> list[tuple[str, str, str]]:
118
+ items = [
119
+ ("t", "rename all windows", "rename windows"),
120
+ ("O", "organize all", "organize all"),
121
+ ("r", "refresh", "refresh"),
122
+ ("q", "quit workmap", "quit"),
123
+ ]
124
+ if self.current() is None:
125
+ # Nothing selected: `k` means every project, and there is no
126
+ # selection to clear, so don't offer a key that does nothing.
127
+ count = f" {DIM}·{self.bg_all}{RESET}" if self.bg_all else ""
128
+ items.insert(1, ("k", "quit all orphans" + count,
129
+ "quit orphans" + count))
130
+ else:
131
+ items.insert(3, ("Esc", "clear selection", "clear pick"))
132
+ return items
133
+
134
+ def project_items(self, p: Project) -> list[tuple[str, str, str]]:
135
+ bg_n = sum(1 for s in p.sessions if s.kind == "bg")
136
+ tag = f" {DIM}·{bg_n}{RESET}" if bg_n else ""
137
+ expanded = p.name in self.expanded
138
+ return [
139
+ ("f", "bring to front", "bring to front"),
140
+ ("o", "organize windows", "organize"),
141
+ ("c", "change theme", "change theme"),
142
+ ("x", "collapse sessions" if expanded else "expand sessions",
143
+ "collapse" if expanded else "expand"),
144
+ ("k", "quit orphans" + tag, "quit orphans" + tag),
145
+ ("s", "stop servers + orphans", "stop servers"),
146
+ ("w", "close windows", "close windows"),
147
+ ]
148
+
149
+ def _command_block(self, cols: int) -> list[str]:
150
+ """Always / For <project> as two distinct panels, stacked or side by side."""
151
+ frame = frame_width(cols)
152
+ inner = frame - 4
153
+ p = self.current()
154
+ always = self.always_items()
155
+ title_a = f"{DIM}Always{RESET}"
156
+ title_p = ""
157
+ if p is not None:
158
+ title_p = (
159
+ f"{DIM}For{RESET} {fg(self.rail(p.profile))}{BOLD}"
160
+ f"{self.label_of(p)}{RESET}"
161
+ )
162
+
163
+ if not boxed(cols):
164
+ w = inner_width(cols)
165
+ out = flat_panel(w, title_a, always, short=True)
166
+ if p is not None:
167
+ out += [""] + flat_panel(w, title_p, self.project_items(p),
168
+ short=True)
169
+ return out
170
+
171
+ if p is None:
172
+ block = panel(frame, title_a, always,
173
+ columns=2 if inner >= 2 * (CHIP_W + 16) + 2 else 1)
174
+ if not self.projects:
175
+ return block
176
+ hint = "↑↓ pick a project for its own commands"
177
+ return block + [f" {DIM}{cell(hint, max(0, cols - 2))}{RESET}"]
178
+
179
+ proj = self.project_items(p)
180
+ for short in (False, True):
181
+ need_a = 4 + CHIP_W + longest_label(always, short)
182
+ need_p = 4 + CHIP_W + longest_label(proj, short)
183
+ if need_a + 1 + need_p <= frame:
184
+ slack = frame - 1 - need_a - need_p
185
+ lw = need_a + slack // 2
186
+ rw = frame - 1 - lw
187
+ tall = max(len(always), len(proj))
188
+ return side_by_side(
189
+ panel(lw, title_a, always, short=short, min_rows=tall), lw,
190
+ panel(rw, title_p, proj, short=short, min_rows=tall), rw,
191
+ )
192
+ cols_n = 2 if inner >= 2 * (CHIP_W + longest_label(proj, True)) + 2 else 1
193
+ return (
194
+ panel(frame, title_a, always, columns=cols_n, short=True)
195
+ + panel(frame, title_p, proj, columns=cols_n, short=True)
196
+ )
197
+
198
+ # Both keys that answer, named. `_handle_list` takes Return for yes and
199
+ # Esc for cancel as well as the letters, and the screen used to offer
200
+ # only the letters: a keystroke that sends SIGTERM was on no screen
201
+ # anywhere. Setup has had the rule that a prompt names what Enter does
202
+ # since its first version; this is the prompt where it matters most.
203
+ CONFIRM_KEYS = (("y", "yes or Enter"), ("n", "cancel or Esc"))
204
+
205
+ def _confirm_block(self, cols: int) -> list[str]:
206
+ frame = frame_width(cols)
207
+ inner = frame - 4
208
+ if not boxed(cols):
209
+ w = inner_width(cols)
210
+ return (
211
+ [f" {AMBER}{BOLD}Confirm{RESET}"]
212
+ + [f" {ln}" for ln in wrap_plain(self.message, w - 1)]
213
+ + [" " + fit_strip(self.CONFIRM_KEYS, w - 1)]
214
+ )
215
+ lines = [box_top(inner, title=f"{AMBER}{BOLD}Confirm{RESET}")]
216
+ for line in wrap_plain(self.message, inner):
217
+ lines.append(box_row(inner, line))
218
+ for line in self._armed_preview(inner):
219
+ lines.append(box_row(inner, line))
220
+ lines.append(box_row(inner, ""))
221
+ keys = "".join(cmd_cell(k, label, CHIP_W + 13)
222
+ for k, label in self.CONFIRM_KEYS)
223
+ lines.append(box_row(inner, keys))
224
+ lines.append(box_bot(inner))
225
+ return lines
226
+
227
+ # How many armed processes to name before summarising the rest.
228
+ PREVIEW_ROWS = 5
229
+
230
+ def _armed_preview(self, inner: int) -> list[str]:
231
+ """Name the processes `y` will signal.
232
+
233
+ A count and a size are not consent. The whole class of bug this tool
234
+ shipped with was killing the wrong thing while reporting a plausible
235
+ number, and a number is exactly what the user could not check.
236
+ """
237
+ rows: list[tuple[int, str]] = []
238
+ for s in self._armed_sessions:
239
+ for pid in s.pids:
240
+ rows.append((pid, s.cmds.get(pid) or s.label))
241
+ if not rows:
242
+ return []
243
+ # What the word means, here, where it is being acted on. The desk
244
+ # carries a legend saying it, and `_footer` returns this block
245
+ # instead of the legend, so the definition was on screen for as long
246
+ # as the reader was browsing and gone the moment they were asked.
247
+ # Same sentence `workmap kill -n` already prints above the same list.
248
+ why = "These have no Terminal window left to close:"
249
+ out = ["", f"{DIM}{cell(why, inner)}{RESET}"]
250
+ for pid, cmd in rows[: self.PREVIEW_ROWS]:
251
+ out.append(f"{DIM}{cell(f'{pid:>7} {cmd}', inner)}{RESET}")
252
+ hidden = len(rows) - self.PREVIEW_ROWS
253
+ if hidden > 0:
254
+ # What the number counts, and how to reach the rest from here.
255
+ # It used to name `workmap kill -n` alone, which cannot be run
256
+ # from inside a full-screen program: a reader told there is more
257
+ # they cannot see, by a prompt they cannot leave, answers "no".
258
+ # The footer cannot scroll, so saying how to get out is the
259
+ # honest way to finish it.
260
+ more = f"+{hidden} more; press n and run `workmap kill -n`"
261
+ out.append(f"{DIM}{cell(more, inner)}{RESET}")
262
+ return out
263
+
264
+ def _compact_block(self, cols: int) -> list[str]:
265
+ """Short terminals: one command strip so the list keeps the screen."""
266
+ p = self.current()
267
+ if self.pending:
268
+ bits = list(self.CONFIRM_KEYS)
269
+ elif p is None:
270
+ bits = [("k", "quit all orphans"), ("t", "rename tabs"),
271
+ ("r", "refresh"), ("q", "quit")]
272
+ else:
273
+ bits = [("f", "front"), ("k", "orphans"), ("c", "theme"),
274
+ ("r", "refresh"), ("q", "quit")]
275
+ strip = fit_strip(bits, cols - 2)
276
+ head = (
277
+ f"{AMBER}{cell(self.message, cols - 2)}{RESET}" if self.pending else ""
278
+ )
279
+ return ([f" {head}"] if head else []) + [f" {strip}"]
280
+
281
+ # ----------------------------------------------------------------- render
282
+ def _headline(self) -> tuple[str, str]:
283
+ """The two figures the header exists to show, wherever it is drawn.
284
+
285
+ One function for both widths. The wide header was rewritten to drop
286
+ "tracked" and "free", and the narrow one was not, so a terminal under
287
+ 46 columns still said "workmap · 2.1G tracked · free 214M" long after
288
+ the reasons for removing both were written down next door.
289
+
290
+ The second figure is empty when Terminal could not be read: an empty
291
+ desk and an unreadable one look identical from here, and "nothing
292
+ orphaned" is a claim about a machine nobody managed to look at.
293
+ """
294
+ held = f"{BOLD}{fmt_mb(self.tracked)}{RESET} {DIM}in use{RESET}"
295
+ if self.blocked:
296
+ return held, ""
297
+ orphan_mb = sum(s.mb for p in self.projects
298
+ for s in p.sessions if s.kind == "bg")
299
+ # Amber only when there is something to reclaim, so the eye goes to it
300
+ # exactly when pressing `k` would do something.
301
+ freeable = (f"{AMBER}{fmt_mb(orphan_mb)} orphaned{RESET}" if orphan_mb
302
+ else f"{DIM}nothing orphaned{RESET}")
303
+ return held, freeable
304
+
305
+ def _top_bar(self, inner: int) -> str:
306
+ """Two numbers worth having, in words that mean something.
307
+
308
+ This used to read "2.1G tracked free 214M swap 6.5G", which is
309
+ three figures a reader cannot act on. "tracked" is an internal word for
310
+ the sum of what happens to be on screen. Worse, macOS keeps free
311
+ memory deliberately low, so "free 214M" looks alarming on a machine
312
+ that is perfectly happy, and reading it as a problem is the wrong
313
+ lesson to teach on the first screen.
314
+
315
+ What is worth knowing is how much the sessions are holding, and how
316
+ much of that is already dead and can be had back. The second number is
317
+ the one the whole tool exists to produce.
318
+ """
319
+ held, freeable = self._headline()
320
+ swap_raw = self.mem.get("swap_mb", "?")
321
+ try:
322
+ swap_warn = float(swap_raw) > 3000
323
+ except (TypeError, ValueError):
324
+ swap_warn = False
325
+ swap_bit = f"{AMBER if swap_warn else DIM}swap {fmt_mem(swap_raw)}{RESET}"
326
+ title = f"{BOLD}workmap{RESET}"
327
+ stats = [
328
+ f"{held} {freeable} {swap_bit}" if freeable else
329
+ f"{held} {swap_bit}",
330
+ f"{held} {freeable}" if freeable else f"{held}",
331
+ f"{held}",
332
+ ]
333
+ for tail in stats:
334
+ if 4 + visible_len(title) + visible_len(tail) + 4 <= inner + 2:
335
+ return box_top(inner, title=title, tail=tail)
336
+ return box_top(inner, title=title)
337
+
338
+ def _column_head(self, inner: int) -> str:
339
+ gutter = max(META_GAP, inner - TABLE_MIN + META_GAP)
340
+ return (
341
+ " " * RAIL_W
342
+ + DIM
343
+ + cell("PROJECT", PROJ_W)
344
+ + " "
345
+ + cell("THEME", THEME_W)
346
+ + " " * gutter
347
+ + cell("RAM", RAM_W, align="right")
348
+ + RESET
349
+ )
350
+
351
+ def _header_lines(self, cols: int) -> list[str]:
352
+ inner = inner_width(cols)
353
+ p = self.current()
354
+ if not boxed(cols):
355
+ if self.mode == "color":
356
+ who = self.label_of(p) if p else ""
357
+ return [f"{BOLD}Theme{RESET} {DIM}for{RESET} {BOLD}{who}{RESET}",
358
+ DIM + "─" * inner + RESET]
359
+ held, freeable = self._headline()
360
+ head = f"{BOLD}workmap{RESET} {held}"
361
+ if freeable and visible_len(head) + visible_len(freeable) + 3 <= inner:
362
+ head = f"{head} {freeable}"
363
+ if not self.projects:
364
+ return [head, DIM + "─" * inner + RESET]
365
+ return [head, self._column_head(inner)]
366
+ if self.mode == "color":
367
+ who = self.label_of(p) if p else ""
368
+ return [
369
+ box_top(inner, title=f"{BOLD}Theme{RESET}",
370
+ tail=f"{DIM}for{RESET} {BOLD}{who}{RESET}" if who else ""),
371
+ box_row(inner, f"{DIM}↑↓ choose Enter apply Esc cancel{RESET}"),
372
+ ]
373
+ if not self.projects: # no rows to head
374
+ return [self._top_bar(inner)]
375
+ return [self._top_bar(inner), box_row(inner, self._column_head(inner))]
376
+
377
+ def _project_block(self, p: Project, selected: bool,
378
+ inner: int) -> list[str]:
379
+ # The colour rail is the project's theme; it thickens on selection so
380
+ # the highlight reads without touching column positions.
381
+ c = self.rail(p.profile)
382
+ rail = fg(c) + (RAIL_ON if selected else RAIL_OFF) + RESET
383
+ name = self.label_of(p)
384
+ out = [
385
+ project_row(name, p.profile, fmt_mb(p.mb), inner,
386
+ rail=rail, selected=selected)
387
+ ]
388
+
389
+ show_all = p.name in self.expanded
390
+ visible = p.sessions if show_all else p.sessions[: SESSION_VISIBLE]
391
+ hidden = 0 if show_all else max(0, len(p.sessions) - SESSION_VISIBLE)
392
+
393
+ for j, s in enumerate(visible):
394
+ last = j == len(visible) - 1 and hidden == 0
395
+ branch = "└─" if last else "├─"
396
+ # A window is the norm and goes unmarked; only flag the exception,
397
+ # in the same word the command uses: an "orphan" is what `k` quits.
398
+ where = "" if s.kind == "window" else "orphaned"
399
+ label = clean_label(s.label.replace("\n", " "))
400
+ out.append(session_row(rail, f" {branch} {label}", where,
401
+ fmt_mb(s.mb), inner))
402
+ if hidden:
403
+ out.append(session_row(rail, f" └─ +{hidden} more", "x to show",
404
+ "", inner))
405
+ return out
406
+
407
+ def _color_body(self, inner: int) -> list[str]:
408
+ p = self.current()
409
+ if not p:
410
+ return [f"{DIM}No project selected.{RESET}"]
411
+ names = self.profiles or list(PROFILE_ANSI.keys())
412
+ lines = []
413
+ for i, name in enumerate(names):
414
+ c = self.rail(name)
415
+ cur = i == self.color_cursor
416
+ check = "●" if name == p.profile else "○"
417
+ body = f"{fg(c)}{check}{RESET} " + cell(name, max(1, inner - 5))
418
+ lines.append(f" {REV} {RESET} {body}" if cur else f" {body}")
419
+ return lines
420
+
421
+ def _build_body(self, cols: int) -> tuple[list[str], list[tuple[int, int]]]:
422
+ """The scrollable region, and where each project sits inside it.
423
+
424
+ The spans come back with the lines rather than being left on the
425
+ instance: scrolling needs to know where the selected project starts,
426
+ and reading that from state written by the *last* render is only
427
+ correct as long as nobody reorders draw(). Returning it removes the
428
+ rule instead of documenting it.
429
+ """
430
+ inner = inner_width(cols)
431
+ if self.mode == "color":
432
+ return self._color_body(inner), []
433
+ if not self.projects:
434
+ if self.blocked:
435
+ # The follow-up belongs to the reason. Only one of the
436
+ # driver's failures is a permission problem, and these lines
437
+ # sent everyone to Automation whatever had gone wrong.
438
+ # Wrapped, not cut. This is the one screen whose whole job
439
+ # is an instruction, and the instruction is 65 columns while
440
+ # the panel is 62, so it arrived as "... > Automat..." at
441
+ # every terminal size. The word it lost was the name of the
442
+ # pane the reader has to go and find.
443
+ fix = getattr(self, "blocked_fix", "")
444
+ said = [f"{DIM}{ln}{RESET}"
445
+ for ln in wrap_plain(f"{self.blocked}.", inner)]
446
+ said += [f"{DIM}{ln}{RESET}"
447
+ for ln in wrap_plain(fix, inner)] if fix else []
448
+ return [
449
+ "",
450
+ f"{AMBER}{BOLD}Can't read Terminal{RESET}",
451
+ "",
452
+ ] + said + [
453
+ f"{DIM}Press{RESET} {BOLD}r{RESET}{DIM} to try again.{RESET}",
454
+ "",
455
+ ], []
456
+ msg = self.message or "Nothing open right now"
457
+ # Only ever name things this package ships. This used to say
458
+ # "Start one with `work <name>`", which was a shell function on the
459
+ # author's machine, not part of workmap and not installable with
460
+ # it. The empty desk is the first screen a new user sees, which
461
+ # makes it the worst possible place to point at something that
462
+ # does not exist for them.
463
+ return [
464
+ "",
465
+ f"{BOLD}{msg}{RESET}",
466
+ "",
467
+ f"{DIM}Open a Terminal window in a project directory,{RESET}",
468
+ f"{DIM}then press{RESET} {BOLD}r{RESET} {DIM}to remap the desk{RESET}",
469
+ "",
470
+ ], []
471
+ body: list[str] = []
472
+ spans: list[tuple[int, int]] = []
473
+ for i, p in enumerate(self.projects):
474
+ if body:
475
+ body.append("") # one breath between projects, none at the ends
476
+ start = len(body)
477
+ body.extend(self._project_block(p, i == self.cursor, inner))
478
+ spans.append((start, len(body)))
479
+ return body, spans
480
+
481
+ def _scroll_for(self, body_len: int, view_h: int,
482
+ spans: list[tuple[int, int]]) -> int:
483
+ """Where the viewport has to sit for the selection to be on screen."""
484
+ scroll = self.scroll
485
+ def clamp(v: int) -> int:
486
+ return max(0, min(v, max(0, body_len - view_h)))
487
+ if view_h <= 0:
488
+ return scroll
489
+ if self.mode == "color":
490
+ row = self.color_cursor
491
+ if row < scroll:
492
+ scroll = row
493
+ elif row >= scroll + view_h:
494
+ scroll = row - view_h + 1
495
+ return clamp(scroll)
496
+ if self.cursor < 0 or not (0 <= self.cursor < len(spans)):
497
+ return clamp(scroll)
498
+ start, end = spans[self.cursor]
499
+ if start < scroll:
500
+ scroll = start
501
+ elif end > scroll + view_h:
502
+ # Never past the project's own row. It carries the name, the theme
503
+ # and the total, and a project with more sessions than the viewport
504
+ # has lines used to scroll all three off the top so that its last
505
+ # session could be on screen: you were looking at rows belonging to
506
+ # something the screen no longer named.
507
+ scroll = min(start, max(0, end - view_h))
508
+ if start >= scroll + view_h:
509
+ scroll = start
510
+ return clamp(scroll)
511
+
512
+ def _footer(self, cols: int) -> list[str]:
513
+ if self.mode == "color":
514
+ return []
515
+ if self.pending:
516
+ return [""] + self._confirm_block(cols)
517
+ # Wrapped rather than cut. Every one of these is a sentence written
518
+ # to explain something, and at Terminal's own 80 columns they were
519
+ # all cut at about the point the explanation started: "Closed 2 of 3
520
+ # windows for acme-api. Terminal is asking about what's still run…"
521
+ # is the reader left with a window that would not close and a
522
+ # sentence that stops mid-word.
523
+ def said(text, colour):
524
+ return [f" {colour}{ln}{RESET}"
525
+ for ln in wrap_plain(text, max(1, cols - 2))]
526
+
527
+ if self.blocked and self.projects:
528
+ note = said(self.blocked, AMBER)
529
+ elif self.advice and not self.message:
530
+ note = said(self.advice, AMBER)
531
+ elif self.message and self.projects:
532
+ note = said(self.message, GOOD)
533
+ elif self.held:
534
+ # Above the legend, because the legend explains a word that is on
535
+ # screen and this explains something that is not. A desk with
536
+ # nothing on it and a desk whose orphans are all inside a tmux
537
+ # pane drew the same picture.
538
+ note = said(self.held, AMBER)
539
+ elif self.bg_all:
540
+ # The row above the commands was a blank line for spacing. It is
541
+ # better spent saying what the word on those rows means: the first
542
+ # person shown this screen asked what a marked row was, and there
543
+ # was nowhere on it that said.
544
+ legend = ("orphaned: still running, with no Terminal window left "
545
+ "to close")
546
+ note = said(legend, DIM)
547
+ else:
548
+ note = [""] # one breath between the map and its commands
549
+ return note + self._command_block(cols)
550
+
551
+ def _fit_footer(self, cols: int, rows: int, chrome: int,
552
+ body_n: int) -> list[str]:
553
+ """Full panels only when a real list still fits above them."""
554
+ full = self._footer(cols)
555
+ compact = self._compact_block(cols) if self.mode != "color" else []
556
+ # A confirmation is modal: _handle_list ignores every other key while
557
+ # one is up, so there is no list behind it worth keeping. Budgeting it
558
+ # like ordinary footer chrome meant that at Terminal's own default
559
+ # 80x24, arming "quit all 24 orphans" dropped the panel naming them and
560
+ # asked for a SIGKILL against a count and a size. That is precisely
561
+ # what _armed_preview exists to prevent.
562
+ if self.pending and chrome + len(full) + 1 <= rows:
563
+ return full
564
+ min_body = min(10, max(3, body_n))
565
+ if chrome + len(full) + min_body <= rows:
566
+ return full
567
+ if compact and chrome + len(compact) + 1 <= rows:
568
+ return compact
569
+ if self.mode == "color":
570
+ return []
571
+ return [f" {chip('q', 3)} quit {chip('r', 3)} refresh"]
572
+
573
+ def draw(self) -> None:
574
+ """Paint one frame. Always paints something.
575
+
576
+ Composing the frame touches every formatting path in the package. If
577
+ one of them raises, a blank screen is the worst possible answer: the
578
+ user is left with a frozen desk and no way to tell whether the tool is
579
+ working. Show the error and keep the keys live instead.
580
+ """
581
+ cols, rows = term_size()
582
+ try:
583
+ frame_lines = self._compose(cols, rows)
584
+ except (KeyboardInterrupt, SystemExit):
585
+ raise
586
+ except Exception as exc: # noqa: BLE001 - see docstring
587
+ frame_lines = self._error_frame(exc, cols)
588
+
589
+ out = [HOME, HIDE]
590
+ for r in range(rows):
591
+ out.append(f"\033[{r + 1};1H{CLEAR_LINE}")
592
+ if r < len(frame_lines):
593
+ out.append(pad_ansi(frame_lines[r], cols).rstrip() + RESET)
594
+ sys.stdout.write("".join(out))
595
+ sys.stdout.flush()
596
+
597
+ def _error_frame(self, exc: Exception, cols: int) -> list[str]:
598
+ """The last-resort screen: plain strings, no layout helpers."""
599
+ return [
600
+ f"{AMBER}{BOLD}workmap hit an error drawing this screen{RESET}",
601
+ "",
602
+ f"{DIM}{type(exc).__name__}: {exc}{RESET}"[: cols + 20],
603
+ "",
604
+ f"{BOLD}r{RESET}{DIM} to rescan {RESET}{BOLD}q{RESET}{DIM} to quit{RESET}",
605
+ ]
606
+
607
+ def _compose(self, cols: int, rows: int) -> list[str]:
608
+ inner = inner_width(cols)
609
+ narrow = not boxed(cols)
610
+
611
+ header = self._header_lines(cols)
612
+ body, spans = self._build_body(cols)
613
+ close = [] if narrow else [box_bot(inner)]
614
+ footer = self._fit_footer(cols, rows, len(header) + len(close), len(body))
615
+
616
+ view_h = max(1, rows - len(header) - len(close) - len(footer))
617
+ self.scroll = self._scroll_for(len(body), view_h, spans)
618
+
619
+ window = body[self.scroll : self.scroll + view_h]
620
+ more_below = self.scroll + len(window) < len(body)
621
+ more_above = self.scroll > 0
622
+ hint = ("↑" if more_above else " ") + ("↓" if more_below else " ")
623
+ if close and (more_above or more_below):
624
+ close = [box_bot(inner, tail=f"{DIM}{hint.strip()} more{RESET}")]
625
+
626
+ rendered = (
627
+ [pad_ansi(ln, inner) for ln in window] if narrow
628
+ else [box_row(inner, ln) for ln in window]
629
+ )
630
+ if narrow and (more_above or more_below):
631
+ # The hint rides the bottom edge, and a narrow terminal has no
632
+ # edges. Without this the list simply stopped, with nothing saying
633
+ # there was more of it: the one place the desk can hide a project
634
+ # is the one place it never said so.
635
+ rendered = rendered[:-1] + [
636
+ f"{DIM}{hint.strip()} more{RESET}"] if len(rendered) > 1 else rendered
637
+ return header + rendered + close + footer
638
+
639
+ # ------------------------------------------------------------------ input
640
+ def run(self) -> None:
641
+ self._old = termios.tcgetattr(self._fd)
642
+ sys.stdout.write(ALT_ON + HIDE)
643
+ sys.stdout.flush()
644
+ try:
645
+ tty.setcbreak(self._fd)
646
+ # Paint immediately so large windows aren't a blank void during scan.
647
+ self.message = "Scanning Terminal…"
648
+ self.draw()
649
+ self._guard(self.refresh_data)
650
+ if self.message == "Scanning Terminal…":
651
+ self.message = ""
652
+ while True:
653
+ self._guard(self.draw)
654
+ try:
655
+ key = self._read_key()
656
+ except (KeyboardInterrupt, SystemExit):
657
+ raise
658
+ except Exception:
659
+ # No way left to read the keyboard, so there is no way for
660
+ # the user to quit. Leaving is the only honest response.
661
+ break
662
+ if not self._guard(self._handle_key, key, default=True):
663
+ break
664
+ except KeyboardInterrupt:
665
+ pass
666
+ finally:
667
+ if self._old is not None:
668
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)
669
+ sys.stdout.write(ALT_OFF + SHOW + RESET)
670
+ sys.stdout.flush()
671
+
672
+ def _handle_key(self, key: str) -> bool:
673
+ """Dispatch one key, then say anything the action left to be said.
674
+
675
+ The notice is picked up here rather than inside `t`, `o` and `O`
676
+ because it belongs to the run and not to the key: three keys reach the
677
+ profile rewrite today, and the fourth will be added by somebody who
678
+ has not read this. It is appended rather than substituted, so pressing
679
+ `t` still tells you how many windows were renamed.
680
+ """
681
+ handle = (self._handle_color if self.mode == "color"
682
+ else self._handle_list)
683
+ alive = handle(key)
684
+ notice = actions.take_title_notice()
685
+ if notice:
686
+ self.message = f"{self.message} {notice}".strip()
687
+ return alive
688
+
689
+ def _guard(self, fn, *args, default=None):
690
+ """Run one step of the loop; survive anything it raises.
691
+
692
+ Every refresh shells out to ps, lsof, top and AppleScript. A single
693
+ malformed line or an OS hiccup used to end the session with a
694
+ traceback, which for a tool you leave open all day is the difference
695
+ between a blip and losing your place. Errors become a message on the
696
+ bar; the loop keeps going so `r` and `q` still work.
697
+
698
+ SystemExit and KeyboardInterrupt are deliberately not caught: those
699
+ are someone asking to leave.
700
+ """
701
+ try:
702
+ return fn(*args)
703
+ except (KeyboardInterrupt, SystemExit):
704
+ raise
705
+ except Exception as exc: # noqa: BLE001 - a TUI must not die here
706
+ self.pending = None
707
+ self._armed_sessions = []
708
+ self.message = f"{type(exc).__name__}: {exc}"[:200] or "error"
709
+ return default
710
+
711
+ def _read_key(self) -> str:
712
+ ch = os.read(self._fd, 1)
713
+ if ch == b"\x1b":
714
+ rest = b""
715
+ if select.select([self._fd], [], [], 0.05)[0]:
716
+ rest += os.read(self._fd, 8)
717
+ if rest.startswith(b"[A"):
718
+ return "up"
719
+ if rest.startswith(b"[B"):
720
+ return "down"
721
+ if rest.startswith(b"[C"):
722
+ return "right"
723
+ if rest.startswith(b"[D"):
724
+ return "left"
725
+ return "esc"
726
+ try:
727
+ return ch.decode()
728
+ except Exception:
729
+ return ""
730
+
731
+ def _move(self, delta: int) -> None:
732
+ n = len(self.projects)
733
+ if n == 0:
734
+ self.cursor = -1
735
+ return
736
+ if self.cursor < 0:
737
+ self.cursor = 0 if delta > 0 else n - 1
738
+ else:
739
+ self.cursor = (self.cursor + delta) % n
740
+ self.pending = None
741
+
742
+ def _drop_what_was_typed_before_the_question(self) -> None:
743
+ """Throw away input that arrived before this prompt was on screen.
744
+
745
+ _read_key takes one byte at a time out of whatever the tty already
746
+ holds, so two adjacent bytes of a paste were a whole authorisation:
747
+ `s`, `w` or `k` arms, and the Return that ends any pasted command line
748
+ answers yes. Nobody read the prompt, because it had not been drawn
749
+ yet. Pasting `git status` and a newline into the desk was enough.
750
+
751
+ An answer has to be typed after the question, so anything already
752
+ queued when it goes up is not an answer to it.
753
+ """
754
+ fd = getattr(self, "_fd", None)
755
+ if fd is None:
756
+ return
757
+ try:
758
+ while select.select([fd], [], [], 0)[0]:
759
+ if not os.read(fd, 4096):
760
+ break
761
+ except (OSError, ValueError):
762
+ pass
763
+
764
+ def _arm(self, kind: str, message: str,
765
+ sessions: list[Session] | None = None) -> None:
766
+ # Pin the sessions now: `y` must kill what the prompt just quoted.
767
+ self.pending = kind
768
+ self.message = message
769
+ self._armed_sessions = list(sessions or [])
770
+ self._drop_what_was_typed_before_the_question()
771
+
772
+ def _cancel_pending(self) -> None:
773
+ self.pending = None
774
+ self._armed_sessions = []
775
+ self.message = "Cancelled, nothing was quit"
776
+
777
+ def _confirm_pending(self) -> None:
778
+ kind = self.pending
779
+ self.pending = None
780
+ p = self.current()
781
+ if kind == "kill_bg_all":
782
+ result = actions.kill_sessions(self._armed_sessions,
783
+ reason="orphans:all")
784
+ self.refresh_data()
785
+ self.message = (
786
+ f"Quit {plural(result['sessions'], 'orphan')} across every "
787
+ f"project, freeing about {fmt_mb(result['mb'])}"
788
+ )
789
+ return
790
+ if kind == "kill_bg" and p:
791
+ result = actions.kill_sessions(self._armed_sessions,
792
+ reason=f"orphans:{p.name}")
793
+ self.refresh_data()
794
+ self.message = (
795
+ f"Quit {plural(result['sessions'], 'orphan')} on "
796
+ f"{self.label_of(p)}, freeing about {fmt_mb(result['mb'])}"
797
+ )
798
+ return
799
+ if kind == "stop" and p:
800
+ result = actions.stop_project_stack(p.name, projects=self.projects)
801
+ quit_n = result["background"]["sessions"]
802
+ self.refresh_data()
803
+ if result["servers_stopped"]:
804
+ self.message = (f"Stopped the servers and "
805
+ f"{plural(quit_n, 'orphan')} on "
806
+ f"{self.label_of(p)}.")
807
+ else:
808
+ # `devstack` is a separate tool this key asks to bring servers
809
+ # down, and it is named nowhere a user would have read. Say
810
+ # what happened to them, not what the program is called.
811
+ why = result["reason"]
812
+ if not why:
813
+ note = "There were no servers to stop."
814
+ elif why == "devstack not installed":
815
+ note = ("No server manager is installed, so only the "
816
+ "orphans were quit.")
817
+ elif why == "devstack timed out":
818
+ note = ("The server manager took too long, so only the "
819
+ "orphans were quit.")
820
+ else:
821
+ note = f"The server manager could not stop them: {why}."
822
+ self.message = (f"Quit {plural(quit_n, 'orphan')} on "
823
+ f"{self.label_of(p)}. {note}")
824
+ return
825
+ if kind == "close_wins" and p:
826
+ asked = list(p.window_ids)
827
+ n = actions.close_project_windows(p.name, projects=self.projects)
828
+ # Before the refresh, which runs its own queries over the same
829
+ # status global. close_windows() answers 0 both for "none of them
830
+ # closed" and for "Terminal never answered", and only one of those
831
+ # means Terminal is holding a sheet open.
832
+ asked_ok = terminal.terminal_status()
833
+ self.refresh_data()
834
+ if not asked_ok["ok"]:
835
+ self.message = (f"Couldn't ask Terminal to close those "
836
+ f"windows: {asked_ok['reason']}.")
837
+ return
838
+ # Which of them are still there, asked fresh, rather than
839
+ # `asked - n`. A window id comes off the last scan, so one of
840
+ # them can have been closed by hand between that scan and this
841
+ # keypress: it is a window that had already gone, not a window
842
+ # Terminal is holding a sheet over, and saying otherwise points
843
+ # the reader at a dialog that is not on their screen.
844
+ after = next((q for q in self.projects if q.name == p.name), None)
845
+ waiting = len(set(asked) & set(after.window_ids)) if after else 0
846
+ if waiting:
847
+ # Terminal is holding a sheet open on these, asking whether to
848
+ # terminate what is running in them. Saying so beats a number
849
+ # that does not match what is on screen.
850
+ self.message = (
851
+ f"Closed {n} of {plural(len(asked), 'window')} for "
852
+ f"{self.label_of(p)}. Terminal is asking about what's "
853
+ f"still running in the other {waiting}."
854
+ )
855
+ else:
856
+ self.message = (
857
+ f"Closed {plural(n, 'Terminal window')} for "
858
+ f"{self.label_of(p)}.")
859
+ return
860
+ self.message = ""
861
+
862
+ def _handle_list(self, key: str) -> bool:
863
+ if self.pending:
864
+ if key in ("y", "Y", "\r", "\n"):
865
+ self._confirm_pending()
866
+ return True
867
+ if key in ("n", "N", "esc"):
868
+ self._cancel_pending()
869
+ return True
870
+ if key not in ("up", "down"):
871
+ return True # modal: ignore everything else
872
+ # Navigating away is a decision too, so drop the prompt and move on.
873
+ # Only ever resolves toward "don't kill anything".
874
+ self._cancel_pending()
875
+ self.message = ""
876
+
877
+ if key in ("q", "Q", "\x03"):
878
+ return False
879
+ if key == "esc":
880
+ self.cursor = -1
881
+ self.message = ""
882
+ self.pending = None
883
+ return True
884
+ if key in ("r", "R"):
885
+ self.refresh_data()
886
+ self.message = "Remapped the desk"
887
+ return True
888
+ if key == "up":
889
+ self._move(-1)
890
+ self.message = ""
891
+ return True
892
+ if key == "down":
893
+ self._move(1)
894
+ self.message = ""
895
+ return True
896
+
897
+ p = self.current()
898
+
899
+ # General commands (footer), nothing to do with the selection
900
+ if key == "t":
901
+ n = actions.retitle_all(projects=self.projects)
902
+ self.refresh_data()
903
+ self.message = (f"Renamed {plural(n, 'Terminal window')} to "
904
+ f"project + what.")
905
+ return True
906
+
907
+ if key in ("k", "K"):
908
+ # One key, one meaning: selected project → that project only;
909
+ # nothing selected → all orphans. (No overlapping k / K.)
910
+ if p is not None:
911
+ sessions = [s for s in p.sessions if s.kind == "bg"]
912
+ if not sessions:
913
+ self.message = (f"Nothing on {self.label_of(p)} is "
914
+ f"running without a window")
915
+ return True
916
+ mb = sum(s.mb for s in sessions)
917
+ self._arm(
918
+ "kill_bg",
919
+ f"Quit {plural(len(sessions), 'orphan')} on "
920
+ f"{self.label_of(p)}, holding about {fmt_mb(mb)}?",
921
+ sessions,
922
+ )
923
+ return True
924
+ sessions = [
925
+ s for proj in self.projects for s in proj.sessions if s.kind == "bg"
926
+ ]
927
+ if not sessions:
928
+ self.message = ("Nothing anywhere is running without a "
929
+ "window, so there is nothing to quit")
930
+ return True
931
+ mb = sum(s.mb for s in sessions)
932
+ self._arm(
933
+ "kill_bg_all",
934
+ f"Quit all {plural(len(sessions), 'orphan')}, across every "
935
+ f"project, holding about {fmt_mb(mb)}?",
936
+ sessions,
937
+ )
938
+ return True
939
+
940
+ if key == "O":
941
+ result = actions.organize_all(projects=self.projects)
942
+ self.refresh_data()
943
+ if not result.get("ok"):
944
+ self.message = (f"Nothing to lay out: "
945
+ f"{result.get('reason', 'no Terminal windows')}.")
946
+ else:
947
+ self.message = (
948
+ f"Laid out {plural(result.get('projects', 0), 'project')}, "
949
+ f"each in its own region of the screen"
950
+ )
951
+ stuck = actions.stuck_words(result)
952
+ if stuck:
953
+ self.message = f"{self.message}. {stuck}"
954
+ return True
955
+
956
+ # Project-only commands
957
+ if not p:
958
+ return True
959
+
960
+ if key in ("f", "F"):
961
+ if p.window_ids:
962
+ actions.focus_project(p)
963
+ self.message = f"Brought {self.label_of(p)} to the front"
964
+ else:
965
+ self.message = (f"{self.label_of(p)} has no Terminal "
966
+ f"windows open")
967
+ return True
968
+ if key == "o":
969
+ if not p.window_ids:
970
+ self.message = (f"{self.label_of(p)} has no Terminal "
971
+ f"windows to lay out.")
972
+ return True
973
+ result = actions.organize_project(p.name, projects=self.projects)
974
+ self.refresh_data()
975
+ if not result.get("ok"):
976
+ self.message = (f"Nothing to lay out for {self.label_of(p)}: "
977
+ f"{result.get('reason', 'no Terminal windows')}.")
978
+ elif result.get("merged"):
979
+ self.message = f"Gathered {self.label_of(p)} into one window"
980
+ else:
981
+ stuck = actions.stuck_words(result)
982
+ if stuck:
983
+ self.message = (
984
+ f"Laid out {result.get('placed', 0)} of "
985
+ f"{plural(result.get('windows', 0), 'window')} for "
986
+ f"{self.label_of(p)}. {stuck}"
987
+ )
988
+ else:
989
+ self.message = (
990
+ f"Laid out {plural(result.get('windows', 0), 'window')} "
991
+ f"for {self.label_of(p)}"
992
+ )
993
+ return True
994
+ if key in ("c", "C"):
995
+ self.mode = "color"
996
+ self.scroll = 0
997
+ try:
998
+ self.color_cursor = self.profiles.index(p.profile)
999
+ except ValueError:
1000
+ self.color_cursor = 0
1001
+ self.message = ""
1002
+ return True
1003
+ if key in ("x", "X", " "):
1004
+ if p.name in self.expanded:
1005
+ self.expanded.discard(p.name)
1006
+ else:
1007
+ self.expanded.add(p.name)
1008
+ return True
1009
+ if key in ("s", "S"):
1010
+ n = sum(1 for s in p.sessions if s.kind == "bg")
1011
+ self._arm(
1012
+ "stop",
1013
+ f"Stop {self.label_of(p)}'s servers and quit its "
1014
+ f"{plural(n, 'orphan')}?",
1015
+ [s for s in p.sessions if s.kind == "bg"],
1016
+ )
1017
+ return True
1018
+ if key in ("w", "W"):
1019
+ if not p.window_ids:
1020
+ self.message = (f"{self.label_of(p)} has no Terminal "
1021
+ f"windows to close")
1022
+ return True
1023
+ self._arm(
1024
+ "close_wins",
1025
+ # Not "nothing is lost": closing a window does end what is
1026
+ # running in it, which is exactly why Terminal puts up a sheet
1027
+ # first. Say who asks and when.
1028
+ f"Close {plural(len(p.window_ids), 'Terminal window')} for "
1029
+ f"{self.label_of(p)}? Terminal will ask before ending "
1030
+ f"anything still running.",
1031
+ )
1032
+ return True
1033
+ return True
1034
+
1035
+ def _handle_color(self, key: str) -> bool:
1036
+ names = self.profiles or list(PROFILE_ANSI.keys())
1037
+ if key in ("esc", "q", "Q"):
1038
+ self.mode = "list"
1039
+ self.scroll = 0
1040
+ return True
1041
+ if key == "up":
1042
+ self.color_cursor = (self.color_cursor - 1) % max(1, len(names))
1043
+ return True
1044
+ if key == "down":
1045
+ self.color_cursor = (self.color_cursor + 1) % max(1, len(names))
1046
+ return True
1047
+ if key in ("\r", "\n", " "):
1048
+ p = self.current()
1049
+ if p and names:
1050
+ profile = names[self.color_cursor]
1051
+ actions.set_project_profile(p.name, profile, apply=True,
1052
+ projects=self.projects)
1053
+ self.refresh_data()
1054
+ self.message = f"{self.label_of(p)} is now {profile}"
1055
+ self.mode = "list"
1056
+ self.scroll = 0
1057
+ return True
1058
+ return True
1059
+
1060
+
1061
+ class NoTerminalToDrawOn(Exception):
1062
+ """There is no terminal here, or there is one that will not go raw.
1063
+
1064
+ Carried to the caller rather than handled here. Falling back to the text
1065
+ snapshot meant this module importing cli, which is the top layer reaching
1066
+ back into an entry point: the one dependency the layer order forbids and
1067
+ the layering test could not see, because `cli` is not one of the layers.
1068
+ Deciding what to do instead of the map is the entry point's job.
1069
+ """
1070
+
1071
+
1072
+ def run() -> int:
1073
+ """Draw the interactive map.
1074
+
1075
+ Raises NoTerminalToDrawOn if this is not a terminal it can take over.
1076
+ """
1077
+ if not sys.stdin.isatty():
1078
+ raise NoTerminalToDrawOn(
1079
+ "The map needs a terminal to draw on, and this is being piped. "
1080
+ "Here it is as text; `workmap list` prints the same thing without "
1081
+ "this line.")
1082
+ try:
1083
+ Desk().run()
1084
+ except (termios.error, OSError) as exc:
1085
+ # isatty() said yes but the terminal will not go raw: a pty that does
1086
+ # not support it, a detached session, some CI shells.
1087
+ raise NoTerminalToDrawOn(
1088
+ f"workmap could not take this terminal over ({exc}). "
1089
+ f"Here is the same map as text.") from exc
1090
+ return 0