Skip to content

Commit 119ce2b

Browse files
futrimeclaude
andcommitted
feat(tui): the CLIs turn on the arrows, and a step shows only its own question
Three things the walk was still carrying from when it was one sheet. The CLIs turned on tab, because the sheet they were on was also reading a list with the arrows. It is not that sheet any more: the first step asks one thing, so up and down are the accounts under the tab and left and right are the tabs. A chord for what a row of tabs is for is a key somebody has to already know. The step that asks which model was reading the account back on its tuning line. That step cannot change it -- it was the step before -- and a setting shown where it cannot be changed is a setting somebody tries to change there. What the walk has settled is on the line above the prompt when it is over. Where the agent works stays, because for an isolated agent it is settled by the flow and there is no step of its own that will say it. And the row for the account nobody chose was a sentence. It is a row in a list of accounts, beside `deepseek` and `work`, so it is two words: `as installed`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 39fa1db commit 119ce2b

6 files changed

Lines changed: 66 additions & 54 deletions

File tree

docs/tui.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -242,20 +242,21 @@ accounts, since an [account](providers.md) is one backend's — what signs in to
242242
not what signs in to codex:
243243

244244
```
245-
claude · codex · kimi · mimo · opencode · pi tab/shift+tab to switch
245+
claude · codex · kimi · mimo · opencode · pi ←/→ to switch
246246
247-
❯ 1. as this machine is signed in ✔ nothing is redirected
247+
❯ 1. as installed ✔ signed in as you signed it in
248248
2. deepseek gateway · ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL
249249
3. work login
250250
```
251251

252-
**tab** turns to the next CLI, **shift+tab** to the one before, and they wrap. One CLI on its
253-
own is a heading rather than a row of tabs: there is nowhere to switch to, so nothing says
254-
there is. Where the flow said that agent has to run a particular moment, only the CLIs that
252+
**** turns to the next CLI, **** to the one before, and they wrap. The arrows rather than tab,
253+
because this sheet asks one thing: up and down are the accounts under the tab, so left and right
254+
are the tabs. One CLI on its own is a heading rather than a row of tabs: there is nowhere to
255+
switch to, so nothing says there is. Where the flow said that agent has to run a particular moment, only the CLIs that
255256
run it are here — choosing one that does not is a flow that would refuse to start.
256257

257-
The first row is what every agent ran as before there were any accounts: whatever this machine
258-
is already signed in as. It is asked here rather than hidden behind a chord because it decides
258+
`as installed` — the first row is what every agent ran as before there were any accounts:
259+
the CLI signed in the way you signed it in, with nothing redirected. It is asked here rather than hidden behind a chord because it decides
259260
which credentials the turns run under, which is not a side question about anything: two agents
260261
of one CLI, one on a subscription and one on somebody's gateway, are two accounts running at
261262
once, each refreshing its own token and neither able to read the other's.
@@ -282,7 +283,7 @@ that runs it, and the CLI was settled a step ago, so it is named above them as a
282283
1. claude-opus-5 claude
283284
❯ 2. claude-sonnet-5 claude
284285
285-
◉ max effort ←/→ to adjust · ◉ swarm mode off ctrl+w to toggle · ◉ every skill ctrl+s to choose · ◉ bypass ctrl+p to change · ◉ as deepseek
286+
◉ max effort ←/→ to adjust · ◉ swarm mode off ctrl+w to toggle · ◉ every skill ctrl+s to choose · ◉ bypass ctrl+p to change
286287
```
287288

288289
Typing narrows the list — `cop` finds `claude-opus-5`, since nobody types a model id out — and
@@ -294,11 +295,14 @@ Under the models, the things that are adjusted rather than chosen: **←/→** t
294295
[what it may do](agents.md#what-an-agent-may-do). Those three really are side questions about
295296
the same agent, which is why they are keys here rather than steps of their own.
296297

297-
The same line also **reads back** what a step of its own has already settled, with no key on it:
298-
the account from the step before, and where it works — `◉ in a container of python:3.12` for an
299-
agent the flow put in a container of its own, or `◉ on ssh://box` for one already pointed
300-
somewhere. An agent that works here says nothing there, which is what an agent nobody said
301-
anything about has always done.
298+
The account is **not** on that line. It was the step before this one, it is on the line above
299+
the prompt once the walk is over, and a setting shown where it cannot be changed is a setting
300+
somebody tries to change.
301+
302+
Where the agent works is read back there, with no key on it, where a step of its own has
303+
already settled it: `◉ in a container of python:3.12` for an agent the flow put in a container
304+
of its own, or `◉ on ssh://box` for one already pointed somewhere. An agent that works here says
305+
nothing there, which is what an agent nobody said anything about has always done.
302306

303307
Enter takes the row under the cursor and goes on: to where that agent works, if the flow says it
304308
may be pointed anywhere, and otherwise to the next agent the flow drives.

src/humanize/tui/SPEC.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,14 @@ the cursor, and under them whatever is adjusted rather than chosen.
102102
are each a question nobody is being asked.
103103
- What an agent runs MUST be one choice rather than two, a model belonging to the CLI that runs
104104
it; and the CLIs MUST be read one at a time, a tab apiece, since every model of every CLI in
105-
one list is a list that grows each time any of them ships a model. Tab and shift+tab MUST
106-
turn between them and MUST wrap. One CLI MUST be a heading rather than a row of tabs: there
107-
is nowhere to switch to, so nothing MUST say there is.
105+
one list is a list that grows each time any of them ships a model. The arrows the list itself
106+
is not using MUST turn between them, and MUST wrap: a sheet that asks one thing has its keys
107+
free, and a chord for what a row of tabs is for would be a key to already know. One CLI MUST
108+
be a heading rather than a row of tabs: there is nowhere to switch to, so nothing MUST say
109+
there is.
110+
- A step MUST NOT read back what another step settles. What cannot be changed where it is shown
111+
is a setting somebody tries to change there; what the walk has settled is on the line above
112+
the prompt when it is over.
108113
- Typing MUST narrow the list being read and MUST belong to the tab it was typed into: a search
109114
that narrowed one CLI to one model would narrow the next to none, which reads as a CLI with
110115
nothing in it. Esc MUST clear what was typed before it leaves, on every sheet that is

src/humanize/tui/pick.py

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -661,11 +661,11 @@ def _fill(self) -> None:
661661
)
662662

663663
def _tuned(self) -> str:
664-
"""The line under the models: the effort, and the rest of what this agent is.
664+
"""The line under the models: the effort, and the side questions about this agent.
665665
666-
What is adjusted here carries the key that adjusts it. What a step of its own settled
667-
-- the account it runs as, the container the flow put it in, the machine it was
668-
pointed at -- is read rather than adjusted, and says so by carrying no key at all.
666+
What is adjusted here carries the key that adjusts it. Where this one works is read
667+
rather than adjusted where the flow settled it, and says so by carrying no key at all;
668+
the account it runs as is not here at all, being the step before this one.
669669
670670
Returns:
671671
The line, as markup, or "" where the letters typed have left no model to say it of.
@@ -694,11 +694,9 @@ def _tuned(self) -> str:
694694
f"{_DOT}[$secondary]◉[/] {PERMISSIONS[self._permission]} "
695695
f"[$text-muted]ctrl+p to change[/]"
696696
)
697-
as_who = self._whose.provider
698-
said += (
699-
f"{_DOT}[$secondary]◉[/] as "
700-
f"{escape(as_who) if as_who else 'this machine is signed in'}"
701-
)
697+
# The account is not read back here: it was the step before this one, it is on the
698+
# line above the prompt once the walk is over, and a setting shown where it cannot be
699+
# changed is a setting somebody tries to change.
702700
if where := self._where():
703701
said += f"{_DOT}[$secondary]◉[/] {where}"
704702
return said
@@ -1526,12 +1524,11 @@ class RunsAs(Sheet[Whose]):
15261524

15271525
BINDINGS: ClassVar = [
15281526
("escape", "back", "back"),
1529-
# The tabs: one CLI at a time, forwards and back. Priority, or the list under the
1530-
# cursor would take tab as moving the focus and the interface below would take
1531-
# shift+tab as switching the flow -- which is not a thing to do from inside a sheet
1532-
# that is choosing what the flow runs on.
1533-
Binding("tab", "next_cli", "next CLI", priority=True),
1534-
Binding("shift+tab", "prev_cli", "previous CLI", priority=True),
1527+
# The tabs, on the arrows the list is not using: up and down are the accounts under
1528+
# the tab, so left and right are the tabs themselves. Priority, or the list under the
1529+
# cursor would take them as moving between rows it has none of.
1530+
Binding("left", "prev_cli", "previous CLI", priority=True),
1531+
Binding("right", "next_cli", "next CLI", priority=True),
15351532
# Making one is asked for here rather than somewhere else: this is the moment somebody
15361533
# finds out they have no account for this CLI, or that the one they want is not among
15371534
# these, and sending them out of the question to answer it would lose the question.
@@ -1618,7 +1615,7 @@ def _tabs(self) -> str:
16181615
)
16191616
# Only where there is somewhere to switch to: one CLI is a heading rather than tabs.
16201617
if len(able) > 1:
1621-
said += " [$text-muted]tab/shift+tab to switch[/]"
1618+
said += " [$text-muted]←/→ to switch[/]"
16221619
return said
16231620

16241621
def _turn_to(self, by: int) -> None:
@@ -1657,9 +1654,9 @@ def _ask(self) -> None:
16571654
)
16581655
needs = self._place.moments
16591656
self.query_one("#about", Label).update(
1660-
f"Which coding agent takes this one's turns in {self._flow}, a tab apiece, and "
1661-
"under it that CLI's own accounts -- an account is one backend's. Its sessions, "
1662-
"its settings and its skills are the CLI's own whichever account it runs as."
1657+
f"Which coding agent takes this one's turns in {self._flow}, one per tab on the "
1658+
"arrows, and under it that CLI's own accounts -- an account is one backend's. Its "
1659+
"sessions, its settings and its skills are the CLI's own whichever it runs as."
16631660
+ (
16641661
f" This one has to run {', '.join(sorted(needs))}, so only the CLIs that do "
16651662
"are here."
@@ -1701,7 +1698,7 @@ def _fill(self) -> None:
17011698
f"[$text-muted]{said}[/]" if said else ""
17021699
)
17031700
self.query_one("#keys", Label).update(
1704-
"ctrl+n to make one · Type to search · Enter to choose · Esc to go back"
1701+
"←/→ CLI · ctrl+n to make one · Type to search · Enter to choose · Esc to go back"
17051702
f"{self.searching()}"
17061703
)
17071704

@@ -1713,7 +1710,9 @@ def _accounts(self) -> list[tuple[str, str, str]]:
17131710
if not backend:
17141711
return []
17151712
return [
1716-
("", "as this machine is signed in", "nothing is redirected"),
1713+
# Two words for the account nobody chose: this is a row in a list of accounts, and
1714+
# what it is is the one the CLI is already signed in as.
1715+
("", "as installed", "signed in as you signed it in"),
17171716
*((one.name, one.name, _sets(one)) for one in providers.providers(backend)),
17181717
]
17191718

tests/tui/test_agents_walk.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ async def test_stepping_back_and_turning_to_another_cli_asks_about_that_one(
264264
"escape", "escape"
265265
) # back to which CLI, and turn to the other
266266
await until(lambda: isinstance(app.screen, RunsAs), driver)
267-
await driver.press("tab")
267+
await driver.press("right")
268268
await driver.press("enter")
269269
await until(lambda: isinstance(app.screen, Models), driver)
270270
listing = app.screen.query_one("#choices", OptionList)

tests/tui/test_app.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,7 +1108,7 @@ async def test_what_an_agent_runs_is_one_cli_at_a_time_and_an_effort_the_arrows_
11081108
assert "claude" in tabs
11091109
assert "codex" in tabs
11101110
assert "kimi" not in tabs # not installed, so not a tab
1111-
assert "tab/shift+tab to switch" in tabs
1111+
assert "←/→ to switch" in tabs
11121112

11131113
await into_models(app, driver)
11141114
sheet = app.screen
@@ -1150,15 +1150,15 @@ async def test_what_an_agent_runs_is_one_cli_at_a_time_and_an_effort_the_arrows_
11501150
"codex": (Model("gpt-5.6-sol", ("xhigh",)), Model("gpt-5.5", ("high",))),
11511151
},
11521152
)
1153-
async def test_tab_and_shift_tab_turn_to_the_next_cli_and_the_one_before(
1153+
async def test_the_arrows_turn_to_the_next_cli_and_the_one_before(
11541154
_installed: unittest.mock.MagicMock, # noqa: PT019 -- `mock.patch` hands it over
11551155
) -> None:
11561156
"""Which is the point of the tabs: one CLI's accounts rather than everyone's at once.
11571157
1158-
They wrap, so the last tab is one press from the first however many CLIs are installed --
1159-
and shift+tab here is the tab before rather than the interface's own next flow, which is
1160-
not a thing to do from inside the sheet that is choosing what this flow runs on. What the
1161-
tab was left on is the CLI whose models the step after asks about.
1158+
They wrap, so the last tab is one press from the first however many CLIs are installed.
1159+
The arrows rather than tab, now that this sheet asks one thing: up and down are the
1160+
accounts under the tab, so left and right are the tabs. What the tab was left on is the
1161+
CLI whose models the step after asks about.
11621162
"""
11631163
app = Humanize()
11641164
async with app.run_test() as driver:
@@ -1170,18 +1170,20 @@ async def test_tab_and_shift_tab_turn_to_the_next_cli_and_the_one_before(
11701170
await until(lambda: bool(listing.options), driver)
11711171
flow = app._flow_named
11721172

1173-
await driver.press("tab")
1173+
await driver.press("right")
11741174
await driver.pause()
11751175
assert "[b $primary]codex" in str(sheet.query_one("#tabs", Label).content)
11761176

1177-
await driver.press("tab") # round the end, back to the first
1177+
await driver.press("right") # round the end, back to the first
11781178
await driver.pause()
11791179
assert "[b $primary]claude" in str(sheet.query_one("#tabs", Label).content)
11801180

1181-
await driver.press("shift+tab") # and back the other way
1181+
await driver.press("left") # and back the other way
11821182
await driver.pause()
11831183
assert "[b $primary]codex" in str(sheet.query_one("#tabs", Label).content)
1184-
# The interface's own shift+tab did not fire under the sheet.
1184+
# And the interface's own shift+tab is still not a thing to do from under a sheet.
1185+
await driver.press("shift+tab")
1186+
await driver.pause()
11851187
assert app._flow_named == flow
11861188

11871189
await driver.press("enter") # as this machine is signed in, on codex
@@ -1226,7 +1228,7 @@ async def test_one_cli_is_a_heading_rather_than_a_row_of_tabs(
12261228
assert "claude" in tabs
12271229
assert "to switch" not in tabs
12281230

1229-
await driver.press("tab") # nowhere to go, and nothing moves
1231+
await driver.press("right") # nowhere to go, and nothing moves
12301232
await driver.pause()
12311233
assert [str(option.id) for option in listing.options] == ["="]
12321234

@@ -1271,7 +1273,7 @@ async def test_what_was_typed_belongs_to_the_tab_it_was_typed_into(
12711273
await driver.pause()
12721274
assert [str(option.id) for option in listing.options] == ["=deepseek"]
12731275

1274-
await driver.press("tab")
1276+
await driver.press("right")
12751277
await driver.pause()
12761278
# The next CLI is read from its whole list rather than through the last search.
12771279
assert [str(option.id) for option in listing.options] == ["=", "=work"]
@@ -1313,7 +1315,7 @@ async def test_a_turn_is_said_to_run_hard_and_said_to_run_wide_separately(
13131315
# Kimi has one. Which CLI it is was the step before, so that is where it is turned to.
13141316
await driver.press("escape")
13151317
await until(lambda: isinstance(app.screen, RunsAs), driver)
1316-
await driver.press("tab")
1318+
await driver.press("right")
13171319
await driver.press("enter")
13181320
await until(lambda: isinstance(app.screen, Models), driver)
13191321
tuning = app.screen.query_one("#tuning", Label)

tests/tui/test_providers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,11 @@ async def test_the_first_row_leaves_the_agent_running_as_this_machine(
342342
await driver.press("enter")
343343
await until(lambda: isinstance(app.screen, Models), driver)
344344
tuning = app.screen.query_one("#tuning", Label)
345-
await until(
346-
lambda: "as this machine is signed in" in str(tuning.content), driver
347-
)
345+
await until(lambda: "effort" in str(tuning.content), driver)
346+
# The account was the step before this one, so it is not read back here: a setting
347+
# shown where it cannot be changed is a setting somebody tries to change.
348+
assert "as installed" not in str(tuning.content)
349+
assert "deepseek" not in str(tuning.content)
348350
await driver.press("enter")
349351
await until(lambda: not isinstance(app.screen, Models), driver)
350352

0 commit comments

Comments
 (0)