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/setup.py ADDED
@@ -0,0 +1,400 @@
1
+ """First run: roots, agents, and the one line that turns on `work`.
2
+
3
+ Three things have to be true before workmap is useful, and none of them can be
4
+ guessed reliably: where your projects live, which agent you want launched, and
5
+ whether your shell has been told about `work`. Leaving all three to the README
6
+ means most people get a tool that maps an empty desk and never mentions the
7
+ half they'd actually use every day.
8
+
9
+ Everything here is a plain function over its inputs; `run()` is the only part
10
+ that talks to a person, so the rest is testable.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import config, shell
21
+ from .tui import onboarding as ui
22
+
23
+ # Command-line coding agents worth looking for. This list only decides what
24
+ # gets *offered*: every entry is checked with shutil.which first, so a name
25
+ # that is wrong or that you have never heard of simply never appears. Anything
26
+ # not here is still usable: type the command at the prompt, or put it under
27
+ # "agents" in the config file.
28
+ AGENT_CANDIDATES = {
29
+ "claude": "claude",
30
+ "codex": "codex",
31
+ "cursor": "cursor-agent",
32
+ "aider": "aider",
33
+ "gemini": "gemini",
34
+ "opencode": "opencode",
35
+ "goose": "goose",
36
+ "amp": "amp",
37
+ }
38
+
39
+ RC_FILES = {
40
+ "zsh": "~/.zshrc",
41
+ "bash": "~/.bashrc",
42
+ }
43
+
44
+
45
+ def detect_agents(candidates: dict | None = None) -> dict:
46
+ """Which candidate agents are actually installed here."""
47
+ found = {}
48
+ for key, command in (candidates or AGENT_CANDIDATES).items():
49
+ words = (command or "").split()
50
+ if words and shutil.which(words[0]):
51
+ found[key] = command
52
+ return found
53
+
54
+
55
+ def current_shell() -> str:
56
+ """zsh or bash, from $SHELL. Defaults to zsh, which is the macOS default."""
57
+ name = Path(os.environ.get("SHELL", "")).name
58
+ return name if name in shell.SUPPORTED_SHELLS else "zsh"
59
+
60
+
61
+ def rc_path(shell_name: str) -> Path:
62
+ return Path(RC_FILES.get(shell_name, "~/.zshrc")).expanduser()
63
+
64
+
65
+ def init_line(shell_name: str, *, name: str = "work") -> str:
66
+ extra = f" --name {name}" if name != "work" else ""
67
+ return f'eval "$(workmap shell-init {shell_name}{extra})"'
68
+
69
+
70
+ def name_is_taken(name: str, rc: Path) -> bool:
71
+ """Does the reader already have a command by this name?
72
+
73
+ `work` is an ordinary English word and a very ordinary personal alias,
74
+ which `shell.shell_init` says out loud as the reason `--name` exists.
75
+ Adding ours on top shadows theirs in every new tab, and the person least
76
+ able to work out why is the person this screen is written for.
77
+
78
+ Two places, because the likely collision is not on PATH. A shell function
79
+ or alias in their own startup file is what `work` usually is, and
80
+ `shutil.which` cannot see one.
81
+ """
82
+ if shutil.which(name):
83
+ return True
84
+ try:
85
+ body = rc.read_text(encoding="utf-8")
86
+ except OSError:
87
+ return False
88
+ pattern = re.compile(
89
+ r"^\s*(alias\s+%s=|(function\s+)?%s\s*\(\s*\))" % (re.escape(name),
90
+ re.escape(name)))
91
+ # Never read our own line back as a command of theirs, or a second run of
92
+ # setup renames the command on every pass until it runs out of names.
93
+ # Redundant while the pattern above only matches `alias x=` and `x()`,
94
+ # since the line we write is an `eval`, and kept because the day somebody
95
+ # widens that pattern is the day it stops being redundant.
96
+ return any(pattern.match(ln) for ln in body.splitlines()
97
+ if not ln.lstrip().startswith("#")
98
+ and "workmap shell-init" not in ln)
99
+
100
+
101
+ def free_name(rc: Path) -> str:
102
+ """`work`, or the first thing near it that nobody is using."""
103
+ for name in ("work", "wm", "wk", "workon"):
104
+ if not name_is_taken(name, rc):
105
+ return name
106
+ return "workmap-work"
107
+
108
+
109
+ def already_wired(rc: Path, line: str) -> bool:
110
+ """Is the init line already live in this rc file? Setup must be re-runnable.
111
+
112
+ Live, not merely present. A substring test over the whole file counted a
113
+ commented-out line, so someone who had switched `work` off by putting a
114
+ `#` in front of it was told "Already done" and could never switch it back
115
+ on from here.
116
+ """
117
+ try:
118
+ body = rc.read_text(encoding="utf-8")
119
+ except OSError:
120
+ return False
121
+ return any("workmap shell-init" in ln
122
+ for ln in body.splitlines() if not ln.lstrip().startswith("#"))
123
+
124
+
125
+ def append_init(rc: Path, line: str, *, name: str = "work") -> bool:
126
+ """Add the init line to an rc file. True if it wrote anything.
127
+
128
+ `name` only labels the line. It is what somebody reads when they open
129
+ their own startup file six months later and want to know what put this
130
+ here, so it has to be the command they actually got rather than the one
131
+ it is usually called.
132
+ """
133
+ if already_wired(rc, line):
134
+ return False
135
+ try:
136
+ rc.parent.mkdir(parents=True, exist_ok=True)
137
+ existing = rc.read_text(encoding="utf-8") if rc.exists() else ""
138
+ prefix = "" if existing.endswith("\n") or not existing else "\n"
139
+ with open(rc, "a", encoding="utf-8") as fh:
140
+ fh.write(f"{prefix}\n# workmap: the `{name}` command\n{line}\n")
141
+ return True
142
+ except OSError:
143
+ return False
144
+
145
+
146
+ def _setup_roots(interactive: bool) -> list[str]:
147
+ """Confirm or ask where projects live."""
148
+ current = ui.spin("Looking for your projects", config.configured_roots)
149
+ shown = current or config.detect_roots()
150
+ body = []
151
+ if shown:
152
+ body.append(ui.note("Looks like you keep them here:"))
153
+ body.append("")
154
+ body += [ui.bullet(ui.path_label(root)) for root in shown]
155
+ body.append("")
156
+ body.append(ui.note("Every folder directly inside becomes a project,"))
157
+ example = ui.path_label(Path(str(shown[0])) / "my-app")
158
+ body.append(ui.note(f"so {example} shows up as \u201cmy-app\u201d."))
159
+ else:
160
+ body.append(ui.warn("We couldn't find a projects folder."))
161
+ body.append("")
162
+ body.append(ui.note("Tell us where you keep your code and we'll"))
163
+ body.append(ui.note("remember it. Something like ~/dev or ~/code."))
164
+ ui.render(ui.panel("Where do you keep your projects?", body,
165
+ step=1, total=3))
166
+
167
+ if not interactive:
168
+ return [str(r) for r in shown]
169
+
170
+ if shown:
171
+ default = ":".join(str(r) for r in shown)
172
+ answer = ui.ask("Use these?",
173
+ enter_does="yes", default=default)
174
+ else:
175
+ answer = ui.ask("Where are they?",
176
+ enter_does="skip for now", default="")
177
+
178
+ chosen = [p for p in answer.split(":") if p]
179
+ for path in chosen:
180
+ if not Path(path).expanduser().is_dir():
181
+ ui.say(" " + ui.warn(f"Heads up: {path} doesn't exist yet."))
182
+ return chosen
183
+
184
+
185
+ def _setup_agents(interactive: bool) -> tuple[dict, str]:
186
+ """Offer what's installed, and let anything else be typed in."""
187
+ print()
188
+ found = ui.spin("Checking what you have installed", detect_agents)
189
+ missing = [k for k in AGENT_CANDIDATES if k not in found]
190
+
191
+ body = []
192
+ if found:
193
+ body.append(ui.note("You already have these:"))
194
+ body.append("")
195
+ body += [ui.found(key, command) for key, command in found.items()]
196
+ else:
197
+ body.append(ui.warn("We didn't find any of the agents we know about."))
198
+ if missing:
199
+ body.append("")
200
+ body.append(ui.listing("Not found: " + ", ".join(missing)))
201
+ body.append("")
202
+ body.append(ui.note("Use something else? Type its command instead,"))
203
+ body.append(ui.note("like \u201caider --model sonnet\u201d. Type \u201cnone\u201d to"))
204
+ body.append(ui.note("just open the folder without starting anything."))
205
+ ui.render(ui.panel("Which one should `work` start for you?", body,
206
+ step=2, total=3))
207
+
208
+ keys = list(found) + ["none"]
209
+ # Whatever they picked last time, if it is still installed. This used to
210
+ # be "the first agent that happens to be on PATH", so re-running setup and
211
+ # pressing Enter, which the screen invites, silently replaced a choice the
212
+ # user had already made.
213
+ chosen = config.default_agent()
214
+ default = chosen if chosen in keys else (keys[0] if keys else "none")
215
+ if not interactive:
216
+ return {}, default
217
+ answer = ui.ask("Which one?", enter_does=default, default=default)
218
+
219
+ agents = {}
220
+ if answer not in keys and answer != "none":
221
+ # A literal command. Give it a name so it can be picked later.
222
+ words = answer.split()
223
+ key = Path(words[0]).name if words else answer
224
+ if words and not shutil.which(words[0]):
225
+ ui.say(" " + ui.warn(
226
+ f"Heads up: {words[0]} isn't installed yet, so `work` will"
227
+ f" use something else until it is."))
228
+ agents[key] = answer
229
+ return agents, key
230
+ return agents, answer
231
+
232
+
233
+ def _a_project_under(roots: list[str]) -> str:
234
+ """A project name from the roots just chosen.
235
+
236
+ Not config.project_dirs(): config.ROOTS is read once at import, so it
237
+ still holds whatever this process started with, and the closing screen
238
+ would offer a project from the roots the reader had just replaced.
239
+ """
240
+ candidates = roots or [str(r) for r in config.ROOTS]
241
+ for root in candidates:
242
+ try:
243
+ entries = sorted(Path(root).expanduser().iterdir())
244
+ except OSError:
245
+ continue
246
+ for entry in entries:
247
+ if (not entry.name.startswith(".") and config.addressable(entry.name)
248
+ and entry.is_dir()):
249
+ return entry.name
250
+ return ""
251
+
252
+
253
+ def _setup_shell(interactive: bool) -> tuple[str, Path]:
254
+ """Returns (what happened, rc_path).
255
+
256
+ Three answers, not two. This used to return "needs a new tab" for every
257
+ path including the one where the user declined, and Enter is the decline:
258
+ the prompt is `enter_does="skip"`. So the likeliest first run ended on a
259
+ card headed "you're all set" telling the reader to open a new tab and run
260
+ `work`, a command that had just not been installed.
261
+
262
+ wired it is in the rc file, this shell has not read it yet
263
+ already it was in the rc file before we got here
264
+ skipped the reader said no, or we could not write
265
+ """
266
+ print()
267
+ shell_name = current_shell()
268
+ rc = rc_path(shell_name)
269
+ line = init_line(shell_name)
270
+
271
+ if already_wired(rc, line):
272
+ ui.render(ui.panel("Switching on the `work` command", [
273
+ ui.listing(f"Already done. It's in {ui.path_label(rc)}.", ok=True),
274
+ "",
275
+ ui.note("Nothing to do here."),
276
+ ], step=3, total=3))
277
+ return "already", rc
278
+
279
+ # A name nobody is using. `work` is an ordinary word and an ordinary
280
+ # personal alias, and since Enter now says yes, a reader who has one gets
281
+ # theirs shadowed in every new tab without having typed anything.
282
+ name = free_name(rc)
283
+ line = init_line(shell_name, name=name)
284
+ body = [
285
+ ui.note(f"`{name}` moves you into a project folder, and only your"),
286
+ ui.note("own shell can do that, so it lives in your shell,"),
287
+ ui.note("not in workmap. It needs this one line:"),
288
+ "",
289
+ ui.bullet(line),
290
+ ]
291
+ if name != "work":
292
+ body += [
293
+ "",
294
+ ui.warn(f"You already have a `work` command, so this one is"),
295
+ ui.warn(f"called `{name}` instead. Yours is untouched."),
296
+ ]
297
+ body += ["", ui.note(f"We can add it to {ui.path_label(rc)} for you."),
298
+ ui.note("Nothing else in that file is changed, and taking the"),
299
+ ui.note("line out again switches it back off.")]
300
+ ui.render(ui.panel(f"Last thing: switching on `{name}`", body,
301
+ step=3, total=3))
302
+ if not interactive:
303
+ return "skipped", rc
304
+ # Enter adds it, like the two questions before this one. It used to skip,
305
+ # which meant the likeliest guided run, three presses of Enter, ended with
306
+ # the command the whole tool is named for not installed, and a card
307
+ # telling the reader to edit a startup file by hand. The screen above
308
+ # shows the exact line and names the file before asking, so Enter here is
309
+ # still an answer to a question that was put.
310
+ if not ui.ask("Add it for you?",
311
+ enter_does="yes", default="y").lower().startswith("n"):
312
+ if append_init(rc, line, name=name):
313
+ ui.say(" " + ui.listing("Added.", ok=True))
314
+ return "wired", rc
315
+ ui.say(" " + ui.warn(
316
+ f"Couldn't write {ui.path_label(rc)}. Add the line by hand."))
317
+ return "skipped", rc
318
+ return "skipped", rc
319
+
320
+
321
+ def run(argv: list[str] | None = None) -> int:
322
+ """Walk through everything workmap needs to know."""
323
+ interactive = sys.stdin.isatty()
324
+ print()
325
+ ui.render(ui.title_card([
326
+ "Where you keep your projects",
327
+ "Which agent you want to start",
328
+ "Switching on the `work` command",
329
+ ]), stagger=False)
330
+ if not interactive:
331
+ ui.say(ui.note(" (Not a terminal, so this is only a preview. "
332
+ "Nothing will be saved.)"))
333
+ print()
334
+
335
+ roots = _setup_roots(interactive)
336
+ agents, default_agent = _setup_agents(interactive)
337
+ if interactive:
338
+ cfg = config.load_config()
339
+ if roots:
340
+ cfg["roots"] = roots
341
+ if agents:
342
+ cfg.setdefault("agents", {}).update(agents)
343
+ cfg["default_agent"] = default_agent
344
+ config.save_config(cfg)
345
+ ui.say(" " + ui.listing(f"Saved to {ui.path_label(config.CONFIG_PATH)}", ok=True))
346
+ shell_state, rc = _setup_shell(interactive)
347
+
348
+ print()
349
+ # Show a project they actually have, read after the roots were saved: the
350
+ # module-level ROOTS is frozen at import, so asking config directly would
351
+ # answer for the roots this process started with rather than the ones the
352
+ # reader just chose.
353
+ sample = _a_project_under(roots)
354
+ rows = []
355
+ if shell_state == "wired":
356
+ # Nothing below works in this tab until the shell reloads, so this
357
+ # goes first and is the only amber thing on the screen.
358
+ rows += [
359
+ ui.warn("First, open a new terminal tab."),
360
+ ui.note(f"Or run: source {ui.path_label(rc)}"),
361
+ "",
362
+ ]
363
+ if shell_state == "skipped":
364
+ # The name the step above offered, which is not always `work`: this
365
+ # card used to print the `work` line regardless, so a reader who had
366
+ # been shown `wm` was told by hand to install something else.
367
+ name = free_name(rc)
368
+ # It was not switched on, so nothing below may suggest typing it.
369
+ rows += [
370
+ ui.note(f"`{name}` is not switched on. To do it yourself, add"),
371
+ ui.note(f"this line to {ui.path_label(rc)}:"),
372
+ "",
373
+ ui.bullet(init_line(current_shell(), name=name)),
374
+ "",
375
+ ui.note("Then open a new terminal tab. In the meantime:"),
376
+ "",
377
+ ui.command("workmap"),
378
+ ui.command_note("see everything you have open"),
379
+ "",
380
+ ui.note("Run `workmap setup` again any time to change this."),
381
+ ]
382
+ ui.render(ui.done_card(rows, "one thing left"), stagger=False)
383
+ print()
384
+ return 0
385
+ rows.append(ui.note("Then try:"))
386
+ rows.append("")
387
+ rows.append(ui.command("work"))
388
+ rows.append(ui.command_note("lists your projects, pick one by number"))
389
+ rows.append("")
390
+ if sample:
391
+ rows.append(ui.command(f"work {sample}"))
392
+ rows.append(ui.command_note("go straight to that one"))
393
+ rows.append("")
394
+ rows.append(ui.command("workmap"))
395
+ rows.append(ui.command_note("see everything you have open"))
396
+ rows.append("")
397
+ rows.append(ui.note("Run `workmap setup` again any time to change this."))
398
+ ui.render(ui.done_card(rows), stagger=False)
399
+ print()
400
+ return 0
workmap/shell.py ADDED
@@ -0,0 +1,117 @@
1
+ """The `work` command, as shell source.
2
+
3
+ workmap can map the desk and colour it, but it cannot put you *in* a project:
4
+ changing directory is something only your own shell can do to itself. So the
5
+ one command that starts work has to be a shell function, and the honest way to
6
+ ship a shell function from a Python package is to print it and have the shell
7
+ evaluate it, the same arrangement zoxide, direnv and pyenv use.
8
+
9
+ eval "$(workmap shell-init zsh)"
10
+
11
+ Everything with real logic in it stays on the Python side, reachable as
12
+ `workmap projects`, `workmap path` and `workmap agent`. What is generated here
13
+ is deliberately thin: a picker, a cd, and a launch. That keeps the part that
14
+ cannot be unit-tested down to the part that cannot be anything else.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ SUPPORTED_SHELLS = ("zsh", "bash")
19
+
20
+ # Words that are already syntax. `if() { ... }` is not a function definition in
21
+ # either shell, it is a parse error, and the generated source is evaluated from
22
+ # the user's rc file, so the cost of accepting one of these is a shell that
23
+ # will not start. str.isidentifier() says yes to all of them, because it is
24
+ # answering a question about Python.
25
+ SHELL_KEYWORDS = frozenset({
26
+ "if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done",
27
+ "case", "esac", "select", "function", "in", "time", "coproc", "foreach",
28
+ "end", "repeat", "nocorrect", "declare", "local", "return", "exit",
29
+ })
30
+
31
+ # Written to run identically under zsh and bash: POSIX parameter expansion,
32
+ # `read -r`, no arrays, no [[ ]]. One function, two shells, no divergence to
33
+ # keep in step.
34
+ _TEMPLATE = r"""
35
+ # workmap: start work on a project. Generated by `workmap shell-init`.
36
+ {name}() {{
37
+ # Not `path`: in zsh that name is tied to $PATH, so declaring it
38
+ # local inside a function empties the command search path and every
39
+ # command below becomes "not found".
40
+ local proj="${{1:-}}" agent="${{2:-}}" dir pick n cmd
41
+
42
+ if [ -z "$proj" ]; then
43
+ n=$(workmap projects | wc -l | tr -d ' ')
44
+ if [ "$n" = "0" ]; then
45
+ workmap projects # prints why there are none
46
+ return 1
47
+ fi
48
+ workmap projects | nl -w4 -s') '
49
+ printf 'Which project? (number or name, Enter to cancel) '
50
+ read -r pick || return 1
51
+ case "$pick" in
52
+ '') return 1 ;;
53
+ *[!0-9]*) proj="$pick" ;;
54
+ *) proj=$(workmap projects | sed -n "${{pick}}p") ;;
55
+ esac
56
+ fi
57
+ [ -z "$proj" ] && return 1
58
+
59
+ dir=$(workmap path "$proj") || return 1
60
+
61
+ # Optional: bring the project's servers up, if devstack is installed.
62
+ command -v devstack >/dev/null 2>&1 && devstack up "$proj"
63
+
64
+ cd "$dir" || return 1
65
+
66
+ if [ -z "$agent" ]; then
67
+ agent=$(workmap agent --pick) || return 1
68
+ fi
69
+
70
+ # Colour and title the tab. Both are front-tab only and cost no desk scan.
71
+ workmap paint "$proj" >/dev/null 2>&1
72
+ workmap title "$proj" "${{agent:-shell}}" >/dev/null 2>&1
73
+
74
+ cmd=$(workmap agent --command "$agent") || return 1
75
+ if [ -n "$cmd" ]; then
76
+ printf '\033]0;%s\302\267%s\007' "$proj" "${{agent}}"
77
+ eval "$cmd"
78
+ fi
79
+ }}
80
+ """
81
+
82
+
83
+ def shell_init(shell: str = "zsh", *, name: str = "work") -> str:
84
+ """Shell source defining the `work` function.
85
+
86
+ `name` exists because `work` is a common enough word to already be taken.
87
+ On the machine this was written for it was, by an earlier hand-rolled
88
+ version of exactly this function.
89
+ """
90
+ if shell not in SUPPORTED_SHELLS:
91
+ raise ValueError(
92
+ f"workmap can't generate a `work` function for {shell}. "
93
+ f"It knows {' and '.join(SUPPORTED_SHELLS)}."
94
+ )
95
+ if not _usable_function_name(name):
96
+ raise ValueError(
97
+ f"{name!r} is not a usable shell function name; use letters, "
98
+ "digits and underscores, starting with a letter or underscore"
99
+ )
100
+ if name in SHELL_KEYWORDS:
101
+ raise ValueError(f"{name!r} is a shell keyword, so it cannot name a "
102
+ "function; try something like 'proj'")
103
+ return _TEMPLATE.format(name=name).lstrip("\n")
104
+
105
+
106
+ def _usable_function_name(name: str) -> bool:
107
+ """ASCII identifier, which is what both shells will reliably parse.
108
+
109
+ isidentifier() was the old test and it is the wrong question twice over:
110
+ it accepts shell keywords, and it accepts every non-ASCII letter Python
111
+ allows. bash will not define a function whose name is not a valid name in
112
+ the POSIX sense, so a name that works here and fails there is worse than a
113
+ name refused in both.
114
+ """
115
+ if not name or name[0].isdigit():
116
+ return False
117
+ return all(c.isascii() and (c.isalnum() or c == "_") for c in name)
workmap/terminal.py ADDED
@@ -0,0 +1,50 @@
1
+ """The terminal driver, whichever one this machine needs.
2
+
3
+ Everything above this line in the layer order talks to `terminal.something()`
4
+ and does not know or care which emulator is answering. Everything below it is
5
+ one module per emulator in `drivers/`.
6
+
7
+ This file used to *be* the Terminal.app driver, which meant the seam the
8
+ README described was really just a promise that nobody had reached past it.
9
+ Now it is a selector: it picks a driver once, at import, from $TERM_PROGRAM,
10
+ and re-exports its contract. Consumers were not changed, because there was
11
+ nothing about them to change, which is the evidence the seam was in the right
12
+ place.
13
+
14
+ To add an emulator: write `drivers/<name>.py` satisfying `drivers.CONTRACT`,
15
+ add it to `drivers.BY_TERM_PROGRAM`, and make it pass
16
+ `tests/test_driver_contract.py`. Nothing else in the package should need to
17
+ know it exists.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from . import drivers
22
+
23
+ driver = drivers.for_host()
24
+
25
+ # The contract, re-exported. Written out rather than star-imported so that
26
+ # reading this file tells you the whole surface, and so a driver that is
27
+ # missing one of these fails at import here rather than at the keypress that
28
+ # happens to need it.
29
+ list_terminal_tabs = driver.list_terminal_tabs
30
+ terminal_status = driver.terminal_status
31
+ owner_pids = driver.owner_pids
32
+ list_profiles = driver.list_profiles
33
+ available_profile_names = driver.available_profile_names
34
+ unsupported_host = driver.unsupported_host
35
+ desktop_bounds = driver.desktop_bounds
36
+
37
+ set_window_titles = driver.set_window_titles
38
+ set_front_title = driver.set_front_title
39
+ paint_front_window = driver.paint_front_window
40
+ apply_profile = driver.apply_profile
41
+ place_windows = driver.place_windows
42
+ close_windows = driver.close_windows
43
+ focus_window = driver.focus_window
44
+ ensure_title_settings = driver.ensure_title_settings
45
+
46
+ # Not part of the contract, but the escaping helper is exercised directly by
47
+ # tests and by anyone writing the next driver, so it stays reachable.
48
+ as_string = getattr(driver, "as_string", None)
49
+
50
+ __all__ = sorted(drivers.CONTRACT) + ["driver", "as_string"]
workmap/themes.py ADDED
@@ -0,0 +1,45 @@
1
+ """Terminal profile names and how each one is drawn.
2
+
3
+ Three views of one table: hex swatches for anything graphical, a 256-colour
4
+ approximation for the TUI rails, and the order new projects are assigned
5
+ colours in. Depends on nothing, so both config and terminal can read it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ # Curated distinct Terminal profiles + UI swatches (approx).
10
+ PROFILE_SWATCHES = {
11
+ "Ocean": {"bg": "#0b3d5c", "fg": "#7ec8e3", "rail": "#1f7a9c"},
12
+ "Grass": {"bg": "#143d1c", "fg": "#8fd19a", "rail": "#2f8f44"},
13
+ "Homebrew": {"bg": "#0c0c0c", "fg": "#39ff14", "rail": "#1a9e2f"},
14
+ "Red Sands": {"bg": "#5c2418", "fg": "#f0c0a0", "rail": "#c45a32"},
15
+ "Pro": {"bg": "#111111", "fg": "#f0f0f0", "rail": "#5a5a5a"},
16
+ "Novel": {"bg": "#3e3228", "fg": "#efe6d4", "rail": "#8a6a4a"},
17
+ "Silver Aerogel": {"bg": "#4a4f55", "fg": "#e6e8ea", "rail": "#8b9299"},
18
+ "Man Page": {"bg": "#5c5428", "fg": "#f5efc8", "rail": "#b5a64a"},
19
+ "Basic": {"bg": "#f4f4f4", "fg": "#222222", "rail": "#888888"},
20
+ "Clear Dark": {"bg": "#1a1d22", "fg": "#d7dbe0", "rail": "#6b7280"},
21
+ "Clear Light": {"bg": "#f0f2f5", "fg": "#222222", "rail": "#9aa3ad"},
22
+ "Solid Colors": {"bg": "#001a66", "fg": "#ffffff", "rail": "#3355cc"},
23
+ }
24
+
25
+ # Profile → ANSI 256 (approx of Terminal themes)
26
+ PROFILE_ANSI = {
27
+ "Ocean": 31,
28
+ "Grass": 34,
29
+ "Homebrew": 40,
30
+ "Red Sands": 166,
31
+ "Pro": 245,
32
+ "Novel": 136,
33
+ "Silver Aerogel": 248,
34
+ "Man Page": 178,
35
+ "Basic": 250,
36
+ "Clear Dark": 240,
37
+ "Clear Light": 252,
38
+ "Solid Colors": 27,
39
+ }
40
+
41
+ # Order in which unseen projects are handed a colour.
42
+ ASSIGN_ORDER = [
43
+ "Ocean", "Grass", "Red Sands", "Homebrew", "Pro",
44
+ "Silver Aerogel", "Novel", "Man Page", "Solid Colors", "Clear Dark",
45
+ ]
@@ -0,0 +1,6 @@
1
+ """The terminal interface.
2
+
3
+ text display-width model and ANSI helpers (pure string maths)
4
+ widgets panels, chips and table rows, each an exact display width
5
+ app screen state, key handling, frame assembly
6
+ """