okstra 0.170.3 → 0.171.0
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.
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/cmux.py +144 -59
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -30,17 +30,26 @@ LOGIN_SHELL_TIMEOUT_SECONDS = 15
|
|
|
30
30
|
# re-enters cmux's own agent wrapper instead of the real CLI.
|
|
31
31
|
SHIM_DIR_MARKER = "cmux-cli-shims"
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
|
|
33
|
+
# The workspace is split in fifths: two for the lead, three for the workers.
|
|
34
|
+
# Every worker lands in that one column and the column divides downward, so all
|
|
35
|
+
# workers hold the same width and only one border — the lead's — is ever
|
|
36
|
+
# computed. Splitting sideways instead would make each worker's width a
|
|
37
|
+
# function of how many rounds preceded it.
|
|
38
|
+
LEAD_SHARE_WITH_WORKERS = 2 / 5
|
|
39
|
+
|
|
40
|
+
# Three fifths is what a worker needs to be worth watching: measured against a
|
|
41
|
+
# Claude Code worker, 67 columns renders losslessly and 33 drops content off the
|
|
42
|
+
# right edge, and three fifths clears 67 on any window wide enough to hold two
|
|
43
|
+
# panes at all.
|
|
44
|
+
#
|
|
45
|
+
# With no workers on screen the lead has nothing to share with, so it takes the
|
|
46
|
+
# whole workspace back rather than sitting at its working width.
|
|
47
|
+
LEAD_SHARE_ALONE = 1.0
|
|
48
|
+
|
|
49
|
+
# A floor for the dimension the worker column actually divides. A pane
|
|
50
|
+
# this short still shows a command and its first lines of output; below it the
|
|
51
|
+
# pane stops being a window onto the worker and the next one stacks as a tab.
|
|
52
|
+
WORKER_MIN_ROWS = 20
|
|
44
53
|
|
|
45
54
|
# Sidebar entries are keyed by source so tools do not overwrite each other's.
|
|
46
55
|
SIDEBAR_SOURCE = "okstra"
|
|
@@ -151,6 +160,7 @@ class PaneGeometry:
|
|
|
151
160
|
x: int
|
|
152
161
|
y: int
|
|
153
162
|
cell_width_points: int
|
|
163
|
+
width_points: float = 0.0
|
|
154
164
|
ref: str = ""
|
|
155
165
|
selected_surface_id: str = ""
|
|
156
166
|
|
|
@@ -169,10 +179,16 @@ def plan_worker_placement(
|
|
|
169
179
|
*,
|
|
170
180
|
lead_pane_id: str,
|
|
171
181
|
owned_surface_ids: Collection[str],
|
|
172
|
-
|
|
182
|
+
min_rows: int,
|
|
173
183
|
) -> Placement:
|
|
174
184
|
"""Pick the next worker slot from the workspace's current geometry.
|
|
175
185
|
|
|
186
|
+
One column for every worker: the first split takes it off the lead, and each
|
|
187
|
+
one after that divides the column downward. Width is therefore decided once,
|
|
188
|
+
by where the lead's border sits, and every worker inherits it — nothing here
|
|
189
|
+
computes a width, and no worker's width depends on how many rounds ran
|
|
190
|
+
before it.
|
|
191
|
+
|
|
176
192
|
Stateless by design: okstra records surface UUIDs, never a layout, so a
|
|
177
193
|
resumed or crashed run cannot carry a layout model that no longer matches
|
|
178
194
|
the screen. Every dispatch re-reads the panes and derives the next slot.
|
|
@@ -180,9 +196,9 @@ def plan_worker_placement(
|
|
|
180
196
|
Those same UUIDs say which panes okstra may place into. A workspace also
|
|
181
197
|
holds panes okstra never opened — another agent session, a shell the user
|
|
182
198
|
keeps around — and "not the lead" does not make a pane a worker slot. Taken
|
|
183
|
-
as one, a stranger's pane is split or
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
as one, a stranger's pane is split or stacked into: the workers land as
|
|
200
|
+
background tabs in someone else's window, so nothing appears on screen and
|
|
201
|
+
that window grows tabs it did not ask for.
|
|
186
202
|
"""
|
|
187
203
|
workers = [
|
|
188
204
|
pane
|
|
@@ -192,14 +208,48 @@ def plan_worker_placement(
|
|
|
192
208
|
]
|
|
193
209
|
if not workers:
|
|
194
210
|
return Placement(pane_id=lead_pane_id, direction="right", stack_as_tab=False)
|
|
211
|
+
return _extend_the_worker_column(workers, min_rows=min_rows)
|
|
195
212
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
|
|
214
|
+
def lead_target_width(
|
|
215
|
+
panes: Sequence[PaneGeometry],
|
|
216
|
+
*,
|
|
217
|
+
lead_pane_id: str,
|
|
218
|
+
owned_surface_ids: Collection[str],
|
|
219
|
+
container_width_points: float,
|
|
220
|
+
) -> float:
|
|
221
|
+
"""How wide the lead should be, in the points `pixel_frame` reports.
|
|
222
|
+
|
|
223
|
+
Two fifths while workers are on screen, all of it when they are gone. The
|
|
224
|
+
share is taken of what okstra may actually place into, not of the window:
|
|
225
|
+
a workspace can hold panes okstra never opened, and handing the lead the
|
|
226
|
+
whole container would shove those off their own width. Their width is
|
|
227
|
+
subtracted first and the share applies to the remainder.
|
|
228
|
+
|
|
229
|
+
Deriving the target from the container rather than from a fixed column count
|
|
230
|
+
is what keeps the split honest at any window size — a hardcoded 80 columns
|
|
231
|
+
is two fifths of one particular display and an arbitrary slice of every
|
|
232
|
+
other.
|
|
233
|
+
"""
|
|
234
|
+
strangers = sum(
|
|
235
|
+
pane.width_points
|
|
236
|
+
for pane in panes
|
|
237
|
+
if pane.pane_id != lead_pane_id
|
|
238
|
+
and not _holds_an_okstra_surface(pane, owned_surface_ids)
|
|
239
|
+
)
|
|
240
|
+
usable = container_width_points - strangers
|
|
241
|
+
if usable <= 0:
|
|
242
|
+
return 0.0
|
|
243
|
+
workers_on_screen = any(
|
|
244
|
+
pane.pane_id != lead_pane_id
|
|
245
|
+
and _holds_an_okstra_surface(pane, owned_surface_ids)
|
|
246
|
+
for pane in panes
|
|
247
|
+
)
|
|
248
|
+
share = LEAD_SHARE_WITH_WORKERS if workers_on_screen else LEAD_SHARE_ALONE
|
|
249
|
+
return usable * share
|
|
200
250
|
|
|
201
251
|
|
|
202
|
-
def lead_resize_points(lead: PaneGeometry, *,
|
|
252
|
+
def lead_resize_points(lead: PaneGeometry, *, target_width_points: float) -> int:
|
|
203
253
|
"""How far to move the lead's right border, in the points `pane.resize` takes.
|
|
204
254
|
|
|
205
255
|
Signed: positive when the lead is too wide and the border comes in, negative
|
|
@@ -208,14 +258,14 @@ def lead_resize_points(lead: PaneGeometry, *, target_columns: int) -> int:
|
|
|
208
258
|
Both directions are needed. A split halves whatever pane it lands on, and
|
|
209
259
|
the first worker of every round lands on the lead — so a rule that only ever
|
|
210
260
|
shrinks leaves that half permanent, and the round after it takes half of
|
|
211
|
-
what is left. Measured on this display: 215 columns becomes
|
|
212
|
-
then
|
|
261
|
+
what is left. Measured on this display: 215 columns becomes 107, then 53,
|
|
262
|
+
then 26, until neither the lead nor its workers can be read.
|
|
213
263
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
264
|
+
Measured in the same points `pixel_frame` reports, so no cell-size
|
|
265
|
+
conversion happens here at all. A column count would need one, and would
|
|
266
|
+
round the target to a whole cell before the border ever moved.
|
|
217
267
|
"""
|
|
218
|
-
return (lead.
|
|
268
|
+
return round(lead.width_points - target_width_points)
|
|
219
269
|
|
|
220
270
|
|
|
221
271
|
def _holds_an_okstra_surface(
|
|
@@ -229,31 +279,22 @@ def _holds_an_okstra_surface(
|
|
|
229
279
|
return any(surface_id in owned_surface_ids for surface_id in pane.surface_ids)
|
|
230
280
|
|
|
231
281
|
|
|
232
|
-
def
|
|
233
|
-
workers: Sequence[PaneGeometry], *,
|
|
234
|
-
) -> Placement:
|
|
235
|
-
rightmost = max(workers, key=lambda pane: pane.x)
|
|
236
|
-
if rightmost.columns // GRID_COLUMNS >= min_columns:
|
|
237
|
-
return Placement(pane_id=rightmost.pane_id, direction="right", stack_as_tab=False)
|
|
238
|
-
roomiest = min(workers, key=lambda pane: (len(pane.surface_ids), pane.x))
|
|
239
|
-
return Placement(pane_id=roomiest.pane_id, direction="", stack_as_tab=True)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def _extend_the_shortest_column(
|
|
243
|
-
columns: dict[int, list[PaneGeometry]]
|
|
282
|
+
def _extend_the_worker_column(
|
|
283
|
+
workers: Sequence[PaneGeometry], *, min_rows: int
|
|
244
284
|
) -> Placement:
|
|
245
|
-
|
|
246
|
-
bottom = max(shortest, key=lambda pane: pane.y)
|
|
247
|
-
return Placement(pane_id=bottom.pane_id, direction="down", stack_as_tab=False)
|
|
285
|
+
"""Divide the bottom worker, or stack when the halves would be unreadable.
|
|
248
286
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
workers
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
287
|
+
A split halves the pane it lands on, so the bottom pane's own height decides
|
|
288
|
+
whether the column can take another worker at all. Once it cannot, further
|
|
289
|
+
workers stack as tabs into the pane holding the fewest — a background tab is
|
|
290
|
+
worse than a visible pane, but better than two panes too short to show a
|
|
291
|
+
command and its first lines of output.
|
|
292
|
+
"""
|
|
293
|
+
bottom = max(workers, key=lambda pane: pane.y)
|
|
294
|
+
if bottom.rows // 2 >= min_rows:
|
|
295
|
+
return Placement(pane_id=bottom.pane_id, direction="down", stack_as_tab=False)
|
|
296
|
+
roomiest = min(workers, key=lambda pane: (len(pane.surface_ids), pane.y))
|
|
297
|
+
return Placement(pane_id=roomiest.pane_id, direction="", stack_as_tab=True)
|
|
257
298
|
|
|
258
299
|
|
|
259
300
|
def shim_free_login_path() -> str:
|
|
@@ -298,8 +339,7 @@ def worker_command_line(
|
|
|
298
339
|
|
|
299
340
|
|
|
300
341
|
def list_panes(workspace: str) -> list[PaneGeometry]:
|
|
301
|
-
|
|
302
|
-
return [_pane_geometry(entry) for entry in payload.get("panes", [])]
|
|
342
|
+
return _panes_from(_pane_list_payload(workspace))
|
|
303
343
|
|
|
304
344
|
|
|
305
345
|
def spawn_worker_surface(
|
|
@@ -326,13 +366,13 @@ def spawn_worker_surface(
|
|
|
326
366
|
panes,
|
|
327
367
|
lead_pane_id=lead.pane_id,
|
|
328
368
|
owned_surface_ids=owned_surface_ids,
|
|
329
|
-
|
|
369
|
+
min_rows=WORKER_MIN_ROWS,
|
|
330
370
|
)
|
|
331
371
|
target = _pane_by_id(panes, placement.pane_id)
|
|
332
372
|
surface_uuid = _open_worker_surface(workspace, placement, target)
|
|
333
373
|
run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
|
|
334
374
|
_exec_worker(surface_uuid, cwd=cwd, command=command)
|
|
335
|
-
_size_lead_pane(workspace)
|
|
375
|
+
_size_lead_pane(workspace, (*owned_surface_ids, surface_uuid))
|
|
336
376
|
return surface_uuid
|
|
337
377
|
|
|
338
378
|
|
|
@@ -358,11 +398,17 @@ def restore_lead_width() -> None:
|
|
|
358
398
|
holds a run manifest rather than the workspace UUID that `pane.resize`
|
|
359
399
|
needs. An unresolvable workspace means cmux is gone or was never there, and
|
|
360
400
|
there is no pane left to size.
|
|
401
|
+
|
|
402
|
+
Owning nothing is the point of the empty ledger. Teardown has just closed
|
|
403
|
+
every surface it opened, so no pane on screen is okstra's but the lead's —
|
|
404
|
+
which is what makes the lead's share the whole workspace. A pane teardown
|
|
405
|
+
failed to close counts as a stranger's and keeps its width, which is the
|
|
406
|
+
safe way to be wrong here.
|
|
361
407
|
"""
|
|
362
408
|
workspace = resolve_lead_workspace()
|
|
363
409
|
if not workspace:
|
|
364
410
|
return
|
|
365
|
-
_size_lead_pane(workspace)
|
|
411
|
+
_size_lead_pane(workspace, ())
|
|
366
412
|
|
|
367
413
|
|
|
368
414
|
def capture_surface(surface_uuid: str, *, last_lines: int = 200) -> str:
|
|
@@ -557,8 +603,12 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
|
|
|
557
603
|
raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
|
|
558
604
|
|
|
559
605
|
|
|
560
|
-
def _size_lead_pane(workspace: str) -> None:
|
|
561
|
-
"""
|
|
606
|
+
def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
|
|
607
|
+
"""Move the lead's border to its share of the workspace.
|
|
608
|
+
|
|
609
|
+
This is the only border okstra places. Workers divide their column downward
|
|
610
|
+
and therefore all inherit whatever is left of it, so placing this one border
|
|
611
|
+
sizes every pane on screen.
|
|
562
612
|
|
|
563
613
|
Which pane carries the request follows from what `pane.resize` does: it
|
|
564
614
|
moves the named pane's own border in the direction given. The lead can push
|
|
@@ -568,11 +618,26 @@ def _size_lead_pane(workspace: str) -> None:
|
|
|
568
618
|
and widening is the lead's.
|
|
569
619
|
|
|
570
620
|
Run after every worker opens rather than once per round: the split that just
|
|
571
|
-
happened is what knocked the lead off its
|
|
621
|
+
happened is what knocked the lead off its share, and no other event does.
|
|
572
622
|
"""
|
|
573
|
-
|
|
623
|
+
payload = _pane_list_payload(workspace)
|
|
624
|
+
container_width = _container_width_points(payload)
|
|
625
|
+
if container_width <= 0:
|
|
626
|
+
# Without the frame the shares are taken of, there is no target to move
|
|
627
|
+
# toward — and a guessed one would move the border to a wrong place
|
|
628
|
+
# rather than leave it where the user last saw it.
|
|
629
|
+
return
|
|
630
|
+
panes = _panes_from(payload)
|
|
574
631
|
lead = _lead_pane(panes)
|
|
575
|
-
offset = lead_resize_points(
|
|
632
|
+
offset = lead_resize_points(
|
|
633
|
+
lead,
|
|
634
|
+
target_width_points=lead_target_width(
|
|
635
|
+
panes,
|
|
636
|
+
lead_pane_id=lead.pane_id,
|
|
637
|
+
owned_surface_ids=owned_surface_ids,
|
|
638
|
+
container_width_points=container_width,
|
|
639
|
+
),
|
|
640
|
+
)
|
|
576
641
|
if offset == 0:
|
|
577
642
|
return
|
|
578
643
|
neighbours = [pane for pane in panes if pane.x > lead.x]
|
|
@@ -609,6 +674,25 @@ def _pane_by_id(panes: Sequence[PaneGeometry], pane_id: str) -> PaneGeometry:
|
|
|
609
674
|
raise RuntimeError(f"cmux pane {pane_id} disappeared while placing a worker")
|
|
610
675
|
|
|
611
676
|
|
|
677
|
+
def _pane_list_payload(workspace: str) -> dict[str, Any]:
|
|
678
|
+
return rpc("pane.list", {"workspace_id": workspace})
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _panes_from(payload: dict[str, Any]) -> list[PaneGeometry]:
|
|
682
|
+
return [_pane_geometry(entry) for entry in payload.get("panes", [])]
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _container_width_points(payload: dict[str, Any]) -> float:
|
|
686
|
+
"""The workspace's own width, which every share here is taken of.
|
|
687
|
+
|
|
688
|
+
Reported once per `pane.list` reply rather than per pane, because it is the
|
|
689
|
+
frame the panes are laid out inside — summing the panes would instead give
|
|
690
|
+
whatever they currently happen to occupy.
|
|
691
|
+
"""
|
|
692
|
+
frame = payload.get("container_frame") or {}
|
|
693
|
+
return float(frame.get("width") or 0)
|
|
694
|
+
|
|
695
|
+
|
|
612
696
|
def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
|
|
613
697
|
frame = entry.get("pixel_frame") or {}
|
|
614
698
|
return PaneGeometry(
|
|
@@ -619,6 +703,7 @@ def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
|
|
|
619
703
|
x=int(frame.get("x", 0)),
|
|
620
704
|
y=int(frame.get("y", 0)),
|
|
621
705
|
cell_width_points=int(entry.get("cell_width_points", 0)),
|
|
706
|
+
width_points=float(frame.get("width") or 0),
|
|
622
707
|
ref=str(entry.get("ref", "")),
|
|
623
708
|
selected_surface_id=str(entry.get("selected_surface_id", "")),
|
|
624
709
|
)
|