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
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
|
+
]
|